Skip to content

feat(promo): scope-aware promo engine + membership/genesis Lightning discounts#408

Merged
spe1020 merged 3 commits into
mainfrom
souschef/membership-promo-codes
May 30, 2026
Merged

feat(promo): scope-aware promo engine + membership/genesis Lightning discounts#408
spe1020 merged 3 commits into
mainfrom
souschef/membership-promo-codes

Conversation

@spe1020
Copy link
Copy Markdown
Contributor

@spe1020 spe1020 commented May 30, 2026

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)

  • D1 — apply unit: promo applies to the base USD price computed in integer cents (exact rounding), then cents→USD. Membership + genesis are percent-only (flatOff rejected on those scopes; stays sats for cookbook).
  • D2 — stacking: promo computes an effective list USD → the existing 5% Bitcoin discount stacks on the result → then sat conversion. Float kept until the final Math.round at sat conversion.
  • D3 — 100%/free: reject percentOff >= 100 and flatOff > 0 on membership + genesis (invalid_for_scope), enforced in the engine and at admin write-time. No free-grant path — every activation stays behind a verified Strike COMPLETED receive.
  • D4 — genesis scope: genesis gets its own 'genesis' scope. 'all' covers cookbook + membership but excludes genesis (a founder discount needs an explicit 'genesis' code). Seeded LAUNCH/FREEPACK migrated to explicit scope: '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.tsPromoScope type + pure scopeAllows()
  • src/lib/cookbookPromoStore.server.ts — optional scope on PromoEntry (absent ⇒ 'all', backward-compatible)
  • src/lib/cookbookPromo.server.ts — scope-aware applyPromo(kv, code, baseAmount, scope); generic PROMOS_DISABLED kill-switch (legacy COOKBOOK_PROMOS_DISABLED still honored for cookbook); new wrong_scope / invalid_for_scope errors
  • src/lib/promoEngine.server.ts — new scope-neutral barrel re-export

Money path (Phase 2)

  • src/routes/api/membership/create-lightning-invoice/+server.ts — accepts promoCode, validates server-side, echoes promo block
  • src/routes/api/genesis/create-lightning-invoice/+server.ts — same, 'genesis' scope
  • src/routes/api/membership/apply-promo/+server.ts — new preview endpoint

Cookbook callersapply-promo + create-invoice pass 'cookbook' (no behavior change)

Admin (Phase 3)api/admin/promos upsert validates scope (NIP-98 + body-hash + CODE_RE unchanged); admin page gets a scope selector + scope badge

Checkout 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/infrasrc/lib/promoEngine.test.ts (19 tests), src/test/envMock.ts + $env/dynamic/private alias in vitest.config.ts

Migration 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 test151/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 check0 errors, 128 pre-existing warnings (none from this change)

🤖 Generated with Claude Code

spe1020 and others added 2 commits May 16, 2026 06:31
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>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
zapcooking-frontend 947a19b May 30 2026, 12:51 AM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
frontend 947a19b May 30 2026, 12:55 AM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 30, 2026

Deploying frontend with  Cloudflare Pages  Cloudflare Pages

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

View logs

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 make applyPromo(kv, code, baseAmount, scope) unit-agnostic (sats for cookbook, USD cents for membership/genesis), with new wrong_scope / invalid_for_scope errors and a generic PROMOS_DISABLED kill-switch alongside the legacy cookbook one.
  • New /api/membership/apply-promo preview endpoint plus promo support in /api/membership/create-lightning-invoice and /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/private alias.

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.

@spe1020 spe1020 merged commit 32eeee4 into main May 30, 2026
2 of 4 checks passed
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.

2 participants