feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public (v1.6.1-aio.12) #45

Merged
padreug merged 3 commits from feat/promo-codes into main 2026-09-13 16:54:51 +00:00
Owner

Backend half of promo codes for the webapp (organizer editor + buyer checkout land in aiolabs/webapp feat/events-promo-codes). Promo handling was inherited from upstream unchanged; four gaps the webapp would otherwise expose to buyers are fixed here, in upstream v2's shape (lnbits/events#64) so #33 carries them.

What changes

  1. active is enforced. It was decorative: purchase never read it. Now 400 Promo code is not active.
  2. Redemption caps — PromoCode.max_uses (None/0 = unlimited) with used_count derived from paid tickets carrying the code (extra.applied_promo_code); each ticket of a multi-ticket purchase consumes one use (v2 counts one per basket — documented deviation). Paid-only counting so an abandoned Stripe session can't lock out the last uses for the 24 h unpaid-row lifetime; bounded concurrency overshoot accepted. 400 Promo code has been fully redeemed. / Only {n} use(s) left on this promo code. Closes #32.
  3. Codes no longer public. PublicEvent.extra was the full EventExtra and /events/public returned the untrimmed Event (wallet id included). EventExtraBase / PublicEventExtra project promo codes out; /public goes through PublicEvent. Organizer (GET /api/v1/events) and admin (/all) listings keep the full model, hydrated with used_count. display.js only reads payment_methods / notification flags, so the Quasar buy page is unaffected.
  4. Preview endpoint POST /events/api/v1/promo/validate/{event_id} (anonymous; same URL as v2, quantity instead of v2's items) → v2 BasketTotals + currency. Advisory: unknown/inactive/exhausted codes are simply absent from discounts_applied; purchase still hard-fails them.

All pricing (validate, invoice amount, Stripe amount) goes through one pure basket_totals in promo.py with one rounding rule (whole sats / 2 dp fiat), so the preview equals the charge. Stripe metadata gains promo_code; organizer stats rows gain applied_promo_code.

Rollout guard: api_event_update keeps stored codes when a request omits extra.promo_codes (explicit [] still clears). Without it the currently deployed webapp, which round-trips the public record's extra, would wipe codes on its next edit once they're hidden. Either PR can therefore ship first.

Quasar admin: "Max uses" column + Used n / max; public page Clear now clears the promo field.

Verification

  • make test: 67 passed (new tests/test_promo.py, tests/test_promo_api.py: model normalisation, sat/fiat rounding, first-valid-wins, absent cases, paid-only usage, PublicEvent projection, validate shape/404, purchase rejections before any invoice code, update carry-over keep/clear).
  • black / ruff / prettier clean; mypy clean on the touched files (pre-existing crud.py / nostr_sync.py annotations remain).
  • Manual plan after upgrade on demo: codes HALF 50 % ×2, FREE100 100 % ×1, OLD inactive → curl /events/public | jq '.[].extra' shows no codes; validate ["half"] qty 2 → 2000 / 1000 / 1000 sat; third HALF purchase → "fully redeemed"; Stripe session metadata.promo_code.

Deploy

Merge → tag v1.6.1-aio.12 (aio.11 is feat/organizer-sender-identity; renumber whichever lands second) → catalog entry → upgrade demo.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas

Backend half of promo codes for the webapp (organizer editor + buyer checkout land in aiolabs/webapp `feat/events-promo-codes`). Promo handling was inherited from upstream unchanged; four gaps the webapp would otherwise expose to buyers are fixed here, in upstream v2's shape (lnbits/events#64) so #33 carries them. ## What changes 1. **`active` is enforced.** It was decorative: purchase never read it. Now 400 `Promo code is not active.` 2. **Redemption caps** — `PromoCode.max_uses` (None/0 = unlimited) with `used_count` *derived* from paid tickets carrying the code (`extra.applied_promo_code`); each ticket of a multi-ticket purchase consumes one use (v2 counts one per basket — documented deviation). Paid-only counting so an abandoned Stripe session can't lock out the last uses for the 24 h unpaid-row lifetime; bounded concurrency overshoot accepted. 400 `Promo code has been fully redeemed.` / `Only {n} use(s) left on this promo code.` Closes #32. 3. **Codes no longer public.** `PublicEvent.extra` was the full `EventExtra` and `/events/public` returned the untrimmed `Event` (wallet id included). `EventExtraBase` / `PublicEventExtra` project promo codes out; `/public` goes through `PublicEvent`. Organizer (`GET /api/v1/events`) and admin (`/all`) listings keep the full model, hydrated with `used_count`. `display.js` only reads `payment_methods` / notification flags, so the Quasar buy page is unaffected. 4. **Preview endpoint** `POST /events/api/v1/promo/validate/{event_id}` (anonymous; same URL as v2, `quantity` instead of v2's `items`) → v2 `BasketTotals` + `currency`. Advisory: unknown/inactive/exhausted codes are simply absent from `discounts_applied`; purchase still hard-fails them. All pricing (validate, invoice amount, Stripe amount) goes through one pure `basket_totals` in `promo.py` with one rounding rule (whole sats / 2 dp fiat), so the preview equals the charge. Stripe metadata gains `promo_code`; organizer stats rows gain `applied_promo_code`. **Rollout guard:** `api_event_update` keeps stored codes when a request omits `extra.promo_codes` (explicit `[]` still clears). Without it the currently deployed webapp, which round-trips the public record's `extra`, would wipe codes on its next edit once they're hidden. Either PR can therefore ship first. Quasar admin: "Max uses" column + `Used n / max`; public page Clear now clears the promo field. ## Verification - `make test`: 67 passed (new `tests/test_promo.py`, `tests/test_promo_api.py`: model normalisation, sat/fiat rounding, first-valid-wins, absent cases, paid-only usage, `PublicEvent` projection, validate shape/404, purchase rejections before any invoice code, update carry-over keep/clear). - black / ruff / prettier clean; mypy clean on the touched files (pre-existing `crud.py` / `nostr_sync.py` annotations remain). - Manual plan after upgrade on demo: codes `HALF` 50 % ×2, `FREE100` 100 % ×1, `OLD` inactive → `curl /events/public | jq '.[].extra'` shows no codes; validate `["half"]` qty 2 → `2000 / 1000 / 1000 sat`; third `HALF` purchase → "fully redeemed"; Stripe session `metadata.promo_code`. ## Deploy Merge → tag `v1.6.1-aio.12` (aio.11 is `feat/organizer-sender-identity`; renumber whichever lands second) → catalog entry → upgrade demo. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
Promo handling was inherited from upstream unchanged and had four gaps
the webapp was about to put in front of buyers:

- `active` was decorative: purchase never read it, so a deactivated code
  kept discounting. Now rejected with "Promo code is not active."
- No redemption cap (#32). `PromoCode.max_uses` (None/0 = unlimited) with
  `used_count` DERIVED from paid tickets carrying the code in
  `extra.applied_promo_code` — each ticket of a multi-ticket purchase
  consumes one use (upstream v2 counts one per basket; documented).
  Paid-only counting so an abandoned Stripe session can't lock out the
  last uses for the 24 h unpaid-row lifetime; bounded overshoot under
  concurrency accepted.
- Every code was readable by anyone: `PublicEvent.extra` was the full
  `EventExtra` and `/events/public` returned the untrimmed `Event`
  (wallet id included). `EventExtraBase` / `PublicEventExtra` project
  them out; `/public` now goes through `PublicEvent`. Organizer and admin
  listings keep the full model, now hydrated with `used_count`.
- No preview: `POST /events/api/v1/promo/validate/{event_id}` (same URL as
  upstream v2; `quantity` replaces v2's `items` since this fork has no
  ticket types) returns v2-shaped `BasketTotals` + `currency`. Advisory:
  bad codes are simply absent from `discounts_applied`; purchase still
  hard-fails them with distinct details.

All pricing (validate, invoice, Stripe amount) goes through one pure
`basket_totals` with a single rounding rule (whole sats / 2 dp fiat), so
the preview equals the charge. Stripe metadata carries `promo_code`; the
organizer stats rows carry `applied_promo_code`.

`api_event_update` keeps stored codes when the request omits
`extra.promo_codes` (explicit `[]` still clears): now that public
records don't carry them, a client round-tripping one would otherwise
wipe the organizer's codes on every edit.

Closes #32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
The Quasar editor gains a "Max uses" input per code (blank = unlimited,
normalised to null on save) and shows "Used n / max" from the derived
count. The public buy page's Clear button now also clears the promo field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
docs: promo-code contract; bump 1.6.1-aio.12
Some checks failed
lint.yml / docs: promo-code contract; bump 1.6.1-aio.12 (pull_request) Failing after 0s
c9fbd8335c
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
Author
Owner

Heads-up for whoever rebases this: new rule agreed 2026-09-13 — feature PRs no longer bump config.json; the version is set by a single chore(release): v1.6.1-aio.N commit on main after merging (recorded in ~/dev/CLAUDE.md). Please drop the aio.12 bump when rebasing onto main (now at aio.13 + #44 + #47 once those merge; #44 adds organizer_name / reply_to_email / copy_to_organizer to EventExtra, which will touch the same region as the PublicEventExtra projection here).

Heads-up for whoever rebases this: new rule agreed 2026-09-13 — feature PRs no longer bump `config.json`; the version is set by a single `chore(release): v1.6.1-aio.N` commit on `main` after merging (recorded in `~/dev/CLAUDE.md`). Please drop the `aio.12` bump when rebasing onto main (now at aio.13 + #44 + #47 once those merge; #44 adds `organizer_name` / `reply_to_email` / `copy_to_organizer` to `EventExtra`, which will touch the same region as the `PublicEventExtra` projection here).
padreug force-pushed feat/promo-codes from c9fbd8335c
Some checks failed
lint.yml / docs: promo-code contract; bump 1.6.1-aio.12 (pull_request) Failing after 0s
to 93cc95fe18
Some checks failed
lint.yml / docs: promo-code contract (pull_request) Failing after 0s
2026-09-13 16:43:56 +00:00
Compare
Author
Owner

Rebased onto main at v1.6.1-aio.14 (4c3b1bc): only config.json conflicted — the aio.12 bump is dropped per the new release rule (version set by a release commit after merge), the docs commit is reworded accordingly. models.py / views_api.py / admin JS+Vue applied cleanly on top of aio.13's form-parity and the Email-sent column. 67 tests pass; ruff / black clean. Mergeable again.

Rebased onto `main` at `v1.6.1-aio.14` (`4c3b1bc`): only `config.json` conflicted — the `aio.12` bump is dropped per the new release rule (version set by a release commit after merge), the docs commit is reworded accordingly. `models.py` / `views_api.py` / admin JS+Vue applied cleanly on top of aio.13's form-parity and the Email-sent column. 67 tests pass; ruff / black clean. Mergeable again.
padreug deleted branch feat/promo-codes 2026-09-13 16:54:52 +00:00
padreug referenced this pull request from a commit 2026-09-13 16:55:44 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/events!45
No description provided.