Repository navigation
v2.0.0: ticket types, basket checkout, promo codes, fiat, deactivation - #64
Draft
dadofsambonzuki wants to merge 99 commits into
Draft
dadofsambonzuki wants to merge 99 commits into
dadofsambonzuki wants to merge 99 commits into
Conversation
…y payments, admin email, bulk actions, and i18n
…ike Add to Basket
…itswallet, fix webhook url
… selection and checkout resilience
- 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
…tifications, fix checkout button
…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
…in table with hyperlink
- 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
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.
This was referenced Oct 6, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.sourcealone (PromoSource:require_ticket,event_ids,ticket_type_ids,move,credit), each with its own optional offer code. This replaces the oldtype/TICKETIDencoding, 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_usesis 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 bothpending(awaiting a ticket id) andrejectionsPromo 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 beyondper_source × admitsper 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 becauseticket_types.soldunder-counts (tickets that arrive already paid skipset_ticket_paid's increment). 15 tests intests/test_addon.py. Sources may live in another event — a Freedom Fest ticket can claim a Bitfest screening seat — in which case theiradmitsis resolved by looking the type up, rather than defaulting to one. Cap may also SPAN events: types carrying the sameextra.cap_groupshare 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 ownmax_ticketswould otherwise mean two pools). 15 tests intests/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, amoverule voids the source ticket and releases its seat,require_ticketrules can cross events on the target event, cross-currency is blockedMigrations —
m010_promo_source_axisnormalises BOTH the legacytypeand the interimsource.kindtorequire_ticketand is re-runnable over either revision;m011_basket_totaladds theBasket.totalcolumn that the model declared andcrud.create_basketwrote 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-valuesreconstructsTicketExtra.paid_valuefor tickets sold before the field existed, from their basket, falling back to the ticket type price; idempotentPayment 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_tickettoggle, with the per-ticket limits shown only when a ticket is required; QR/button layout and field alignment fixes across dialogsi18n — new keys in en, es and de
Ticket-type availability is derived —
ticket_types.soldis computed on read from the ticket rows (paid, not refunded, not deactivated) rather than trusted from a stored column thatset_ticket_paidunder-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, itsremainingcalculation 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_hintin 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 offertitleso a code-less rule stays identifiable in the promo list; andadmits/cap_groupon the ticket type dialog. Also fixes a silent config loss: the ticket type dialog rebuiltextrafrom scratch while itsdatacarried noextrakey, so saving ANY ticket type droppedadmitsandcap_group.See individual commits for detailed changes.