Skip to content

v2.0.0: ticket types, basket checkout, promo codes, fiat, deactivation - #64

Draft
dadofsambonzuki wants to merge 99 commits into
lnbits:mainfrom
dadofsambonzuki:main
Draft

dadofsambonzuki wants to merge 99 commits into
lnbits:mainfrom
dadofsambonzuki:main

Conversation

@dadofsambonzuki

@dadofsambonzuki dadofsambonzuki commented Jul 8, 2026 •

Copy link
Copy Markdown

Summary

Major rewrite: multi-ticket basket checkout, ticket types, ticket-scoped promo offers, event-level payment methods, fiat via core providers, ticket deactivation, and migrations for the new promo/basket schema.

Changes

  • Ticket types — multiple ticket tiers with pricing, availability windows and max limits

  • Multi-ticket basket checkout — buy several tickets of different types in one transaction

  • Promo codes — classic — percentage or fixed discounts, add/edit via inline chip UI, deactivated codes shown with strikethrough

  • Promo codes — ticket-scoped offers — ticket-scoped promo rules are now identified by PromoCode.source alone (PromoSource: require_ticket, event_ids, ticket_type_ids, move, credit), each with its own optional offer code. This replaces the old type / TICKETID encoding, which could only express one such rule per event; an event can now carry several ticket rules (an upgrade rule, a bring-a-friend rule, ...) and still distinguish them. Pricing and reservation share a single resolver (_resolve_ticket_rules) so they cannot disagree about which rule owns a ticket. Rule ranking is named offer code > codeless rule > un-named coded rule, so a typed offer code is never ignored. max_uses is now enforced on ticket rules. Rejections carry a reason (ticket_count_not_eligible, promo_uses_exhausted, ticket_needs_offer_code, ticket_out_of_scope, ticket_already_used, ticket_redemptions_exhausted, currency_mismatch, ...) and are deduplicated per token; checkout surfaces both pending (awaiting a ticket id) and rejections

  • Promo codes — add-on ticket grants — the other half of the offer-code axis: a rule can GRANT tickets of another type instead of discounting (PromoCode.grants = PromoGrant{ticket_type_id, per_source}). A discount cannot express a free GATED add-on, because with no discount to withhold an unsatisfied source condition enforces nothing. An add-on may only be added while a qualifying source ticket is present — in the basket itself, or presented by id — never beyond per_source × admits per source ticket, and never past the add-on type's cap. TicketType.extra.admits (default 1) records how many people a ticket admits, so a Family ticket is worth four add-ons where a Single is worth one; it lives in the JSON column, so no migration is needed. Grant rules are excluded from the discount engine but still pair, so the reservation / redemption / finalize machinery applies unchanged and a holder cannot re-claim. Enforced entirely server-side in _validate_addon_items, with the cap counted from ticket rows because ticket_types.sold under-counts (tickets that arrive already paid skip set_ticket_paid's increment). 15 tests in tests/test_addon.py. Sources may live in another event — a Freedom Fest ticket can claim a Bitfest screening seat — in which case their admits is resolved by looking the type up, rather than defaulting to one. Cap may also SPAN events: types carrying the same extra.cap_group share one cap, summed across the group, because one pool of seats can be sold from more than one event's checkout (two add-on types with their own max_tickets would otherwise mean two pools). 15 tests in tests/test_addon.py.

  • Buyer checkout no longer uppercases the promo input, which had been destroying case-sensitive ticket IDs. Offer codes themselves remain case-insensitive

  • Upgrade semantics — credit basis is TicketExtra.paid_value, upgrades are capped at free, a move rule voids the source ticket and releases its seat, require_ticket rules can cross events on the target event, cross-currency is blocked

  • Migrations — m010_promo_source_axis normalises BOTH the legacy type and the interim source.kind to require_ticket and is re-runnable over either revision; m011_basket_total adds the Basket.total column that the model declared and crud.create_basket wrote but no migration created (without it every basket INSERT failed with "table events.baskets has no column named total" — a checkout 500)

  • Maintenance endpoint — POST /maintenance/backfill-paid-values reconstructs TicketExtra.paid_value for tickets sold before the field existed, from their basket, falling back to the ticket type price; idempotent

  • Payment methods per event — LN, onchain, fiat checkboxes with per-method wallet selection; checkout retries with remaining methods if one fails

  • Fiat via core — fiat provider credentials from LNbits core settings, authorized per user

  • Satspay charge ID linked to tickets — hyperlinked column in the admin tickets table

  • Ticket deactivation — toggle in the admin table, deactivated badge on the public page, rejected at registration

  • Bulk email — resend all tickets, custom message to attendees, with confirmation dialog

  • Admin email — per-event admin notification email for ticket sales

  • Simplified basket — single email + optional name at basket level; batched email per buyer

  • UI — offer-code input plus a require_ticket toggle, with the per-ticket limits shown only when a ticket is required; QR/button layout and field alignment fixes across dialogs

  • i18n — new keys in en, es and de

  • Ticket-type availability is derived — ticket_types.sold is computed on read from the ticket rows (paid, not refunded, not deactivated) rather than trusted from a stored column that set_ticket_paid under-maintained (a ticket arriving already paid, e.g. an import, never ran the increment; a type with 12 paid rows read 0). The checkout's availability filter, its remaining calculation and the public display's cap filter all read the same derived value, so a sold-out type is no longer offered and caps cannot leak.

  • Grant rules are self-describing in the admin — a grant has no code and no discount, so its chip used to render as a blank - and neither dialog showed the ticket it adds, leaving the rule looking like a purposeless "requires ticket id" entry. The chip now names the granted ticket (→ Bitflix …) and both dialogs badge the granted type while disabling the target-scope and discount controls, neither of which applies to a grant. promo_grants_label / promo_grants_hint in en/es/de.

  • Grant rules, offer names and ticket-type facts are authorable in the admin — an "Adds tickets instead of a discount" toggle (which forces the source gate and clears any discount, mirroring the API coercion) with the ticket to add and per_source; an offer title so a code-less rule stays identifiable in the promo list; and admits / cap_group on the ticket type dialog. Also fixes a silent config loss: the ticket type dialog rebuilt extra from scratch while its data carried no extra key, so saving ANY ticket type dropped admits and cap_group.
    See individual commits for detailed changes.

…y payments, admin email, bulk actions, and i18n
- Each promo code in a standard q-card matching rest of UI
- Header row with bold code name, active/inactive badge, red delete button
- Active and combinable as proper q-toggles with labels (not hidden checkbox)
- Max uses hint shortened to avoid spilling
- Discount type toggle with spread, descriptive labels, no-caps
- Add button with outline and icon
- Removed allow_fiat field from TicketType model
- Removed auto-sync of allow_fiat from ticket types to event
- Removed allow_fiat toggle from ticket type dialog UI
- Removed unused i18n keys (allow_fiat_checkout, allow_fiat_hint,
  wave_allow_fiat_hint, fiat_checkout_currency)
- Event-level allow_fiat retained as legacy fallback
- Both use row q-gutter-sm with col-auto for multi-per-line wrapping
- Same chip styling (q-py-xs, height: auto, white-space: normal)
- Removed refresh button from ticket types and its i18n key
…tspay

- EventExtra model now includes fiat_provider field
- Admin UI shows fiat provider dropdown populated from g.user.fiat_providers
- services.py passes fiat_provider from basket to satspay charge creation
- display.js reads fiat_provider from event extra
- Updated i18n hints to reference core server settings
- Add deactivated field to TicketExtra model (defaults to False)
- Add toggle_ticket_deactivation service function
- Add PUT /api/v1/tickets/{ticket_id}/deactivate endpoint
- Reject deactivated tickets at registration
- Add deactivated column + toggle button to admin tickets table
- Show deactivated badge on public ticket page
- Add i18n keys (en/es/de) for deactivation UI
- Promo code inline editing: inline add input, edit dialog, show all codes with strikethrough for inactive
…d dialog

- Remove inline q-input + add button below promo code chips
- Add + icon button next to Promo Codes title
- + opens editPromoCodeDialog in add mode (codeIndex: -1)
- Dialog header shows Add/Edit based on codeIndex
- Remove addPromoCodeInline method and newPromoCodeInput data
- Remove '%' prefix from percent discount toggle label
- Remove '%' suffix from discount_percent input
- Change save button text to 'Save' with i18n key
- Add 'save' i18n key to en/es/de
dadofsambonzuki and others added 12 commits August 30, 2026 18:38
A ticket-type promo has no fixed code string: the buyer submits an existing
paid ticket id as the discount code, resolved against the tickets table. Use
is single-use by default (redemptions_per_ticket, default 1), applies only to
a single-ticket purchase, and is tracked per source ticket in TicketExtra
(ticket_id_voucher_redemptions) with a pending/completed lifecycle reserved at
checkout under a per-event lock. Adds a Voucher Type selector to the promo
dialog (Code/Ticket ID). Also adds the missing en.js key for promo_combinable.
…atus)

api_get_ticket now returns a fully-populated PublicTicket (event_name,
ticket_type_name via ticket-type lookup, name, email, deactivated, time). The
public ticket page shows a details block with ticket type, attendee name,
email, purchase date and a registered yes/no badge, and the deactivated badge
now works (it previously read ticket.extra which the public schema stripped).
Header card with paid-status avatar + badge, details as caption-label/body-value
pairs in a two-column grid (ticket type, name, email, purchase date, registered),
separator, then QR + full-width copy/print buttons. Adds ticket_paid/pending
status badge.
Add PromoCode.ticket_type_ids so a discount code can be restricted to one
or more ticket types. None/empty keeps the existing basket-wide behaviour,
so existing codes are unaffected. The field lives in the EventExtra JSON
column, so no DB migration is needed and existing rows default to None.

Discount engine (_apply_promo_codes): now takes per-ticket-type subtotals.
Ticket-type-scoped ("base") promos are applied first, each capped at the
value of the types it targets, and the discount is consumed from those
types weighted by their remaining share (so a second scoped promo on the
same types cannot discount value already discounted away). Basket-wide
promos then apply against what is left.

Admin UI: both promo dialogs gain a ticket-type multi-select, hidden for
Ticket-ID vouchers (which redeem a single ticket and are always
basket-wide). Empty selection is normalised to null. i18n for en/es/de.
Add PromoCode.max_tickets (type=ticket): the max tickets a single redemption
covers (None = any), replacing the old hardwired single-ticket gate. The
voucher now also honors ticket_type_ids scoping (from the existing promo scope
PR) by carrying it into the synthesized promo code, so a voucher can be limited
to specific ticket types. UI: 'Max tickets per redemption' input and the
ticket-type scope select now shown for Ticket-ID vouchers in both promo
dialogs; empty scope = whole basket, empty max = any number.
Ticket-scoped promo rules are now identified by `PromoCode.source` alone,
with their own OPTIONAL offer code, replacing the old `type` / `TICKETID`
encoding that could only express one such rule per event.

- models: `PromoSource` (`require_ticket`, `event_ids`, `ticket_type_ids`,
  `move`, `credit`) is the single source axis; a rule's `code` is an offer
  code, and `source.require_ticket` is an additional condition on it
- an event may carry several ticket rules (an upgrade rule, a bring-a-friend
  rule, ...) and still distinguish them
- pricing and reservation share one resolver (`_resolve_ticket_rules`) so they
  cannot disagree about which rule owns a ticket
- rule ranking is named offer code > codeless rule > un-named coded rule, so a
  typed offer code is never ignored in favour of a positional match
- `max_uses` is now enforced on ticket rules
- rejections carry a reason (`ticket_count_not_eligible`, `promo_uses_exhausted`,
  `ticket_needs_offer_code`, `ticket_out_of_scope`, ...) and are deduplicated
  per token; checkout surfaces `pending` (awaiting a ticket id) and `rejections`
- buyer checkout no longer uppercases the promo input, which destroyed
  case-sensitive ticket IDs; offer codes themselves stay case-insensitive
- m010 normalises BOTH the legacy `type` and the interim `source.kind` to
  `require_ticket`, and is re-runnable over either revision
- m011 adds `Basket.total`: the model declared the column and `crud.create_basket`
  wrote it, but no migration created it, so every basket INSERT failed with
  "table events.baskets has no column named total" (checkout 500)
- `POST /maintenance/backfill-paid-values` reconstructs `TicketExtra.paid_value`
  for tickets sold before the field existed, from their basket, falling back to
  the ticket type price; idempotent
- UI: offer-code input plus a `require_ticket` toggle, with the per-ticket
  limits shown only when a ticket is required
- i18n: new keys in en/es/de
Adds pytest coverage for the promo-code and ticket-scoped offer logic. The
suites existed as standalone scratch scripts with a hand-rolled harness; this
ports all 79 assertions into 34 tests, keeping the same stub-based approach so
they run without a full LNbits install.

- tests/conftest.py bootstraps the lnbits stub and an in-memory crud store,
  and resets the store between tests
- tests/test_promo_voucher.py — 16 assertions in 6 tests (A-F)
- tests/test_promo_scope.py — 11 assertions in 6 tests (T1-T11)
- tests/test_promo_upgrade.py — 37 assertions in 20 tests (U1-U21, V1-V9, W1-W8)
- tests/test_promo_migration.py — 15 assertions in 3 tests
- synchronous tests calling asyncio.run(...); no dependency added
  (pytest-asyncio was already in the dev group, needed by tests/test_init.py)

Also, two small pieces of housekeeping this work exposed:

- uv.lock: `lnbits-events` 0.0.0 -> 2.0.0 to match pyproject. The 2.0.0 version
  bump in this PR left the lock stale, so every `uv run` dirtied the tree.
- .gitignore: ignore `data/`, which LNbits' Settings creates (a generated
  `.lnbits_auth_key`) when the test session imports the extension.

`uv run pytest tests/` -> 35 passed (the 34 new tests plus the existing
tests/test_init.py).

No runtime file is modified: services.py, models.py, crud.py, migrations.py,
views_api.py, models and static assets are untouched.
Hermes Agent and others added 12 commits October 9, 2026 18:55
Adds the missing half of the offer-code axis: a rule can now GRANT tickets of
another type instead of discounting the basket, which is what a free gated
add-on (e.g. a screening seat) needs. A discount cannot express it, because
with no discount to withhold an unsatisfied source condition enforces nothing.

- models: PromoGrant {ticket_type_id, per_source}; PromoCode.grants. A grant
  implies the source gate and clears any discount (pre-root-validator, so a
  stored row cannot fail the codeless-code check and unload the event).
- TicketType.extra.admits: how many people a ticket admits (default 1), so a
  Family ticket is worth four add-ons where a Single is worth one. JSON column,
  no migration.
- services: _validate_addon_items enforces the gate server-side -- an add-on
  may only be added while a qualifying source ticket is present (in the basket,
  or presented by id) and never beyond per_source x admits per source; grant
  rules are excluded from the discount engine but still pair, so the existing
  reservation/redemption/finalize machinery applies unchanged.
- cap counted from ticket rows, not ticket_types.sold, which under-counts
  (tickets that arrive already paid skip set_ticket_paid's increment).
- crud: count_tickets_by_type.
- tests: tests/test_addon.py, 14 tests / 22 assertions. Disabling the gate
  fails 8 of them (verified), so the suite is not a rubber stamp.
The public checkout appended its basket card below every ticket type, so on an
event with several types the buyer got no feedback at all: the card rendered
off-screen (measured: 580px below the fold on a 9-type event) and the page grew
~490px under them on the first add.

- Sticky basket strip: ticket count, running total and a "View basket" button
  that scrolls to the full basket card. It pins 16px above the viewport bottom,
  settles into normal flow at the end of the page (so it cannot cover the
  footer) and is hidden while the basket is empty.
- Toast on add and remove, so the click is acknowledged where it happened.
  Positioned top: the default bottom position would land on the strip.
- Anonymous checkouts (no name and/or no email) are acknowledged in a dialog
  before the charge is created. The ticket link is the only route back to an
  anonymous ticket and no event updates can be sent, so the buyer confirms
  explicitly. `checkout()` now gates and delegates to `submitCheckout()`.
- Real loading state: the page rendered its empty "event unavailable" card (and
  the "no ticket types available" banner) during the initial fetches, so a blank
  card flashed before the event loaded. The error card is now gated on
  `eventErrorLabel` and the banner on a `ticketTypesLoaded` flag.
- i18n keys in en, es and de.

check-vue.js: 0 errors, 0 warnings. check-i18n.py: passed.
The add-on allowance built its type map from THIS event's ticket types, so a
presented source belonging to another event (a Freedom Fest ticket claiming a
Bitfest screening seat) was not found and its admits silently defaulted to 1 --
a Freedom Fest family ticket bought one seat instead of four. Load the missing
types before computing the allowance, and assert it with a cross-event test
(fails without the fix: "only 1 can be added").
One screening has one pool of seats but can be sold from more than one
event's checkout, so a per-ticket-type cap is not enough: two add-on types
meant two 100-seat pools, i.e. 200 seats. Types carrying the same
extra.cap_group now share a single cap -- the check sums issued tickets
across every member of the group.

- crud: get_all_ticket_types (cross-event; the table is tiny).
- services: _cap_group / _cap_scope_types; _validate_addon_items counts the
  whole group against the item's own max_tickets.
- tests: shared-cap test spanning two events; fails when the group counting
  is disabled (verified).
`sold` was only bumped by set_ticket_paid, which returns early for a ticket
that arrives already paid (an import, a migration), so the stored column
drifted low -- live, a type with 12 paid tickets read 0. Three readers trusted
it: the checkout's active_types filter, its `remaining` calculation and the
public display's `sold < max_tickets` filter. So a sold-out type was still
offered and every cap derived from it leaked.

`sold` is now DERIVED on every read from the ticket rows (models.sold_counts:
paid, not refunded, not deactivated -- an upgrade voids its source and frees
the seat). crud.get_ticket_types stamps it and crud.count_tickets_by_type uses
the same definition, so the checkout, the add-on cap and the display cannot
disagree. The test stub mirrors that definition instead of a laxer one.

- tests: committed-seat accounting, derived-not-stored, cap ignoring
  unpaid/refunded claims; each fails when the fix is disabled (verified).
A grant rule (PromoCode.grants) has no code and no discount, so the promo
chip rendered as a blank " - " and neither dialog showed the ticket it
adds -- the Bitflix gate looked like a purposeless "requires ticket id"
rule. Naming a grant's target makes the rule self-describing.

- chip label: `-> <granted ticket type>` for a grant rule, looked up across
  the loaded events; ordinary labels are byte-identical to before.
- both dialogs: a badge naming the granted type + a hint, with the target
  scope and discount controls disabled (a grant targets and discounts
  nothing).
- i18n: promo_grants_label / promo_grants_hint in en, es, de.
- verified: node --check, check-vue.js (0 errors/0 warnings), check-i18n.py,
  and the label logic run against the live rule configs.
… the UI

A grant rule could previously only be created by hand-editing JSON. Everything
it needs is now in the dialogs:

- Offer name (`PromoCode.title`): a code-less offer has no identifier of its
  own, so it was unidentifiable in the promo list. The chip and the dialog
  header fall back to the title; a code still wins when there is one.
- Effect toggle ("Adds tickets instead of a discount") plus the ticket to add
  and per_source: switches a rule between discounting and granting, forcing
  the source gate and clearing discounts, exactly as the API coerces.
- Ticket type dialog: `admits` (people per ticket) and `cap_group` (shared cap).
- FIX: that dialog REBUILT `extra` from scratch while `data` never carried an
  `extra` key, so saving ANY ticket type silently DROPPED `admits` and
  `cap_group` -- editing Family - Weekend would have quietly taken its
  "admits 4" with it and reverted the family allowance.
- i18n: 10 new keys in en/es/de.
- verified: node --check, check-vue.js (0 errors/0 warnings), check-i18n.py,
  the label + effect helpers run against the live configs, and the served
  files re-checked after deploy.
Three buyer-facing strings asked for a ticket ID without saying where to put
it. The checkout's promo field is labelled "Promo Code(s)", so name it:

- the pending warning on the checkout summary ('{code} also needs your ticket
  ID') now says to add it in the same Promo Code box;
- promo_reason_ticket_needs_offer_code likewise;
- the add-on gate error ('can only be added with a qualifying ticket') now
  ends with 'enter your existing ticket ID in the Promo Code box', and the
  suite asserts the wording so it cannot silently regress.

- en/es/de updated. Verified: node --check, check-i18n, and the live API
  response after deploy.
… editor

The promo rule dialog was a flat stack of 16 inputs with no headers, which hid
the axis the data model gets right: SOURCE scope is a CONDITION, TARGET scope
is a parameter of the discount EFFECT, and the granted ticket is a parameter
of a different effect. The dialog now says so:

- Identity / What does it do? / When does it apply? / Limits & stacking / Usage.
- The effect is chosen first (Discount | Adds tickets) and the form follows it.
- Source scopes and the credit/void pair sit under the condition, where they
  belong; the basket-size limit moved there too (a condition on basket shape,
  not a discount size) and is hidden while voiding, which forces it to 1.
- Unusable fields are HIDDEN, not greyed out.
- A generated fragment summary ('no code . adds 1 x Bitflix ... per person .
  requires ... . unlimited redemptions') answers 'what is this rule for?' at a
  glance. Fragments rather than a sentence, so word order stays translatable.

Also removes the bulk editor: openPromoCodesDialog was defined but never
called -- unreachable markup plus its data, methods and i18n keys. The
per-rule editor already carried every field it had, delete included, so no
capability is lost (checked field by field before cutting).

Verified: check-vue 0/0, check-i18n, node --check, pytest 55 passed, the
summary run against the live rules, and the served files re-checked.
…stake

The refactor's deletion helper brace-matched `promoDiscountTypes: {},` -- a
ONE-LINE object with net-zero braces -- so it never stopped on that line and ate
the next 18 lines, which were the `editPromoCodeDialog` data block. The template
then rendered against state that no longer existed and the admin page came up
blank. `node --check` cannot see this: the file is still valid JavaScript.

Restores the block, and adds the checks that would have caught it:

  * data()/methods key sets diffed before vs after -- only intended keys move;
  * every method the template CALLS must exist in the component;
  * every `<name>Dialog` root the template READS must be a data() key.

The last one is this bug's class: a template referencing state data() no longer
provides fails at render, not at parse.
…he code

The offers column listed each rule by its code, so a rule you had named showed
up as e.g. BRINGANORMIE and you had to remember what it was for. The name now
wins when it exists; the code is still on hover and in the dialog.

- promoChipLabel: title first, then code (a grant keeps its '-> <ticket>' suffix
  and the max_uses counter either way).
- the chip carries the code as a native title attribute, so nothing is hidden.
- verified: node --check, check-vue 0/0, check-i18n, and the shipped label
  function run against the live rules.
Switching Discount <-> Adds tickets nulled discount_percent/discount_fixed, so
the percentage you had entered vanished and had to be retyped; voiding a ticket
dropped max_tickets, and turning off the ticket requirement cleared the source
scopes. Every one of those is a value the admin set, being changed by something
they did NOT set.

Toggles now only ever ADD state (a grant implies the source gate) or fill a BLANK
magnitude. Nothing is overwritten, so flipping an option back and forth is
lossless. The hidden-while-irrelevant fields keep their values too.

Backend: a grant no longer clears a typed discount. A grant rule is dropped from
the discount engine entirely, so a retained discount is inert -- it is now
deliberately kept rather than silently destroyed (test_grant_keeps_and_ignores_a
_discount, and the grant-discounts-nothing test now presents an eligible source
with a discount on the rule and still expects 0).

Verified: 55 tests pass against the deployed tree, node --check, check-vue 0/0,
check-i18n, and a harness that drives the three toggle handlers directly and
asserts no value is destroyed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant