feat(promo): scope-aware promo engine + membership/genesis Lightning discounts#408
Conversation
PR #399 was supposed to remove the "⌘V works for URLs, text, and images." hint from the Sous Chef input toolbar. The squash-merge applied half the diff — the `handlePaste` comment update landed on main, but the toolbar hunk (the `<span>` removal + `justify-between` → `justify-end` swap) was silently dropped, almost certainly because of merge-base confusion after #397's earlier squash made the file history ambiguous to GitHub's merge algorithm. This branches fresh from current main and applies just the toolbar change to avoid any repeat. After this, the toolbar contains only the "Upload image" button, right-aligned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…discounts
Generalize the cookbook promo engine into a scope-aware engine and wire promo
codes into the membership (Cook+/Pro Kitchen) and Founders Club (Genesis)
Lightning checkouts. Cookbook export behavior is byte-for-byte unchanged.
Engine (Phase 1)
- Add PromoScope ('cookbook'|'membership'|'sponsor'|'genesis'|'all') + pure
scopeAllows() in cookbookPricing.ts. 'all' covers cookbook + membership
only, NOT genesis (a founder discount needs an explicit 'genesis' code).
- PromoEntry gains optional scope (absent => 'all', backward-compatible).
- applyPromo(kv, code, baseAmount, scope) now requires a scope and rejects
mismatches ('wrong_scope'). Membership/genesis are percent-only and capped
below 100% ('invalid_for_scope') so every activation stays behind a verified
Strike payment.
- Generic PROMOS_DISABLED kill-switch (all scopes); cookbook still honors
COOKBOOK_PROMOS_DISABLED.
- Seeded LAUNCH/FREEPACK defaults are now scope:'cookbook'.
- New $lib/promoEngine.server.ts barrel re-exports the engine under a
scope-neutral name for non-cookbook callers.
Money path (Phase 2)
- membership + genesis create-lightning-invoice accept promoCode, validate it
server-side, apply the promo to base USD (integer cents), then the existing
5% BTC discount stacks on the result before sat conversion (D2). Response
echoes { promo: { code, label, originalUsd, finalUsd } }. Server is the sole
source of truth for the charged amount.
- Genesis activation left untouched (still inline, paid-only).
Admin (Phase 3)
- /api/admin/promos upsert accepts + validates scope (NIP-98 auth, body-hash
binding, CODE_RE unchanged); enforces the percent-only/<100 caps for
membership + genesis at write time too.
- Admin UI: scope selector on add/edit rows, scope badge in the code list.
Checkout UIs (Phase 4)
- Cook+, Pro Kitchen, Genesis: "Have a promo code?" input + Apply calling the
new /api/membership/apply-promo preview endpoint; validated code is forwarded
to invoice creation; displayed price comes from the server response. Stripe
path is untouched.
Decisions as implemented: D1 promo on base USD in integer cents; D2 stack
(promo, then 5% BTC); D3 reject >=100% / flatOff on membership+genesis (no
free path); D4 genesis is its own scope and 'all' excludes it.
Migration note: legacy KV codes with no scope default to 'all', so they now
also apply to membership checkouts. The admin should re-scope to 'cookbook'
any existing code that must stay cookbook-only.
Tests: scope matching (incl. 'all' + genesis carve-out), USD-cents math, D2
stacking order, D3 rejection, plus cookbook backward-compat + kill-switches.
Full vitest suite + svelte-check pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
zapcooking-frontend | 947a19b | May 30 2026, 12:51 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
frontend | 947a19b | May 30 2026, 12:55 AM |
Deploying frontend with
|
| Latest commit: |
947a19b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://1d2e6ce2.frontend-hvd.pages.dev |
| Branch Preview URL: | https://souschef-membership-promo-co.frontend-hvd.pages.dev |
There was a problem hiding this comment.
Pull request overview
Generalizes the cookbook promo engine into a scope-aware engine and wires it into the membership (Cook+, Pro Kitchen) and genesis (Founders Club) Lightning checkouts. Cookbook behavior is preserved by passing an explicit 'cookbook' scope at existing call sites. Membership/genesis codes are percent-only and capped below 100% so every activation still requires a verified Strike COMPLETED receive, and 'all' deliberately excludes the genesis scope to prevent generic codes from discounting founder slots.
Changes:
- Add
PromoScope+scopeAllows()and makeapplyPromo(kv, code, baseAmount, scope)unit-agnostic (sats for cookbook, USD cents for membership/genesis), with newwrong_scope/invalid_for_scopeerrors and a genericPROMOS_DISABLEDkill-switch alongside the legacy cookbook one. - New
/api/membership/apply-promopreview endpoint plus promo support in/api/membership/create-lightning-invoiceand/api/genesis/create-lightning-invoice(promo applied to list USD in integer cents, then 5% BTC discount stacks). - Admin API + page gain a scope field with write-time enforcement of the membership/genesis percent-only sub-100% rule; checkout pages get a "Have a promo code?" input; new
promoEngine.test.ts(19 tests) and a vitest$env/dynamic/privatealias.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
src/lib/cookbookPricing.ts |
Adds PromoScope, ALL_SCOPE_COVERS, and pure scopeAllows(). |
src/lib/cookbookPromoStore.server.ts |
Optional scope on PromoEntry (absent ⇒ 'all'). |
src/lib/cookbookPromo.server.ts |
Scope-aware applyPromo, scope/cap enforcement, dual kill-switches, seeded codes set to 'cookbook'. |
src/lib/promoEngine.server.ts |
New neutral barrel re-export. |
src/routes/api/membership/create-lightning-invoice/+server.ts |
Accepts promoCode, validates server-side, stacks BTC discount on promo-adjusted USD, echoes promo. |
src/routes/api/genesis/create-lightning-invoice/+server.ts |
Same flow under 'genesis' scope; KV/prod guard moved earlier. |
src/routes/api/membership/apply-promo/+server.ts |
New preview endpoint for membership + founders. |
src/routes/api/cookbook-export/{apply-promo,create-invoice}/+server.ts |
Pass explicit 'cookbook' scope. |
src/routes/api/admin/promos/+server.ts |
Validates/normalizes scope and enforces membership/genesis percent-only sub-100%. |
src/routes/admin/promos/+page.svelte |
Scope selector, scope badge, mirror of write-time guard, page renamed. |
src/routes/membership/{cook-plus,pro-kitchen,genesis}-checkout/+page.svelte |
Promo input + Apply (Lightning only); reflect server-returned discountedUsdAmount/amountSats. |
src/lib/promoEngine.test.ts |
Cookbook back-compat, kill-switches, scope matching, USD-cents math, D1/D3 caps, D2 stacking, pure scopeAllows. |
vitest.config.ts + src/test/envMock.ts |
Alias $env/dynamic/private to a mutable test stub. |
src/routes/souschef/+page.svelte |
Unrelated: removes the "⌘V works…" helper text and switches bar to justify-end. |
package.json |
Version bump 4.2.446 → 4.2.449. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
What
Adds promo-code discounts to the membership Lightning checkouts (Cook+, Pro Kitchen, Founders Club/Genesis) by generalizing the existing cookbook promo engine into a scope-aware engine. Cookbook export behavior is byte-for-byte unchanged.
Decisions (as implemented)
flatOffrejected on those scopes; stays sats for cookbook).Math.roundat sat conversion.percentOff >= 100andflatOff > 0on membership + genesis (invalid_for_scope), enforced in the engine and at admin write-time. No free-grant path — every activation stays behind a verified StrikeCOMPLETEDreceive.'genesis'scope.'all'covers cookbook + membership but excludes genesis (a founder discount needs an explicit'genesis'code). Seeded LAUNCH/FREEPACK migrated to explicitscope: 'cookbook'.Genesis activation was left inline/untouched per the brief — only promo validation + effective-USD calc added ahead of the existing conversion.
Files changed
Engine (Phase 1)
src/lib/cookbookPricing.ts—PromoScopetype + purescopeAllows()src/lib/cookbookPromoStore.server.ts— optionalscopeonPromoEntry(absent ⇒'all', backward-compatible)src/lib/cookbookPromo.server.ts— scope-awareapplyPromo(kv, code, baseAmount, scope); genericPROMOS_DISABLEDkill-switch (legacyCOOKBOOK_PROMOS_DISABLEDstill honored for cookbook); newwrong_scope/invalid_for_scopeerrorssrc/lib/promoEngine.server.ts— new scope-neutral barrel re-exportMoney path (Phase 2)
src/routes/api/membership/create-lightning-invoice/+server.ts— acceptspromoCode, validates server-side, echoespromoblocksrc/routes/api/genesis/create-lightning-invoice/+server.ts— same,'genesis'scopesrc/routes/api/membership/apply-promo/+server.ts— new preview endpointCookbook callers —
apply-promo+create-invoicepass'cookbook'(no behavior change)Admin (Phase 3) —
api/admin/promosupsert validatesscope(NIP-98 + body-hash +CODE_REunchanged); admin page gets a scope selector + scope badgeCheckout UIs (Phase 4) — Cook+, Pro Kitchen, Genesis get a "Have a promo code?" input + Apply (preview), with the displayed price coming from the server response. Stripe path untouched.
Tests/infra —
src/lib/promoEngine.test.ts(19 tests),src/test/envMock.ts+$env/dynamic/privatealias invitest.config.tsMigration note
Legacy KV codes with no scope now default to
'all', so they also start discounting membership checkouts. Re-scope any cookbook-only legacy code to'cookbook'via/admin/promos.Verification
pnpm test— 151/151 pass (132 existing + 19 new: scope matching incl.'all'/genesis carve-out, USD-cents math, D2 stacking order, D3 rejection, cookbook backward-compat + kill-switches)pnpm check— 0 errors, 128 pre-existing warnings (none from this change)🤖 Generated with Claude Code