Skip to content

Commit 32eeee4

Browse files
spe1020spe1020claude
authored
feat(promo): scope-aware promo engine + membership/genesis Lightning discounts (#408)
* fix(souschef): actually remove ⌘V hint from input toolbar 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> * feat(promo): scope-aware promo engine + membership/genesis Lightning 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> --------- Co-authored-by: spe1020 <sethsager@Seths-MacBook-Air.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9d8cbe9 commit 32eeee4

17 files changed

Lines changed: 1117 additions & 53 deletions

File tree

src/lib/cookbookPricing.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@
55

66
export const COOKBOOK_EXPORT_SATS = 2100;
77

8+
/**
9+
* Surfaces a promo code can apply to. The engine is scope-aware so a
10+
* single KV-backed code list can serve every checkout without one
11+
* surface's codes leaking into another.
12+
*
13+
* `'all'` is a convenience meaning "the general-purpose surfaces" —
14+
* deliberately **cookbook + membership only**. It does NOT cover
15+
* `'genesis'` (a lifetime founder discount must be opted into with an
16+
* explicit `'genesis'` code) and does NOT cover `'sponsor'` (not yet a
17+
* live surface). See `scopeAllows`.
18+
*/
19+
export type PromoScope = 'cookbook' | 'membership' | 'sponsor' | 'genesis' | 'all';
20+
21+
/** Surfaces that a `scope: 'all'` code is valid for. */
22+
export const ALL_SCOPE_COVERS: readonly PromoScope[] = ['cookbook', 'membership'];
23+
24+
/**
25+
* Pure scope-match check. A code's stored scope (default `'all'` for
26+
* legacy entries) is valid for `requested` when it matches exactly, or
27+
* when it's `'all'` and `requested` is one of the general surfaces.
28+
* Client- and server-safe (no I/O).
29+
*/
30+
export function scopeAllows(entryScope: PromoScope | undefined, requested: PromoScope): boolean {
31+
const scope: PromoScope = entryScope ?? 'all';
32+
if (scope === requested) return true;
33+
if (scope === 'all') return ALL_SCOPE_COVERS.includes(requested);
34+
return false;
35+
}
36+
837
export interface PromoApplied {
938
code: string; // canonicalized (uppercase)
1039
originalSats: number;
@@ -18,6 +47,12 @@ export interface PromoApplied {
1847
* Apply a promo to a base price.
1948
* Pure / sync / no I/O — safe on both client and server.
2049
*
50+
* Unit-agnostic: `baseSats` is just "the base amount" — cookbook passes
51+
* sats, membership/genesis pass USD **cents** (so the integer rounding
52+
* below stays exact). `flatOff` shares whatever unit the caller uses.
53+
* Membership/genesis codes are percent-only (see `applyPromo`), so the
54+
* sats-flavoured `flatOff` label branch never fires for them.
55+
*
2156
* Server **must** still validate the code via `getPromoConfig` before
2257
* trusting it, since this helper is happy to apply any percentage.
2358
*/

src/lib/cookbookPromo.server.ts

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020
*/
2121

2222
import { env } from '$env/dynamic/private';
23-
import { applyPromoMath, type PromoApplied } from '$lib/cookbookPricing';
23+
import {
24+
applyPromoMath,
25+
scopeAllows,
26+
type PromoApplied,
27+
type PromoScope
28+
} from '$lib/cookbookPricing';
2429
import {
2530
loadPromoConfig,
2631
type PromoConfigState,
@@ -39,27 +44,45 @@ export const DEFAULT_PROMO_CONFIG: PromoConfigState = {
3944
LAUNCH: {
4045
percentOff: 50,
4146
flatOff: 0,
47+
scope: 'cookbook',
4248
note: 'launch promo: 50% off cookbook export'
4349
},
4450
FREEPACK: {
4551
percentOff: 100,
4652
flatOff: 0,
53+
scope: 'cookbook',
4754
note: '100% off — free cookbook export (limited use)'
4855
}
4956
}
5057
};
5158

5259
export interface PromoLookup {
5360
ok: boolean;
54-
error?: 'unknown_code' | 'expired' | 'disabled';
61+
/**
62+
* `wrong_scope` — code exists but isn't valid for the requested surface.
63+
* `invalid_for_scope` — code's discount shape isn't permitted for the
64+
* requested surface (membership/genesis are percent-only and capped
65+
* below 100%, so every activation stays behind a verified payment).
66+
*/
67+
error?: 'unknown_code' | 'expired' | 'disabled' | 'wrong_scope' | 'invalid_for_scope';
5568
applied?: PromoApplied;
5669
}
5770

58-
function envKillSwitch(): boolean {
59-
const flag = (env.COOKBOOK_PROMOS_DISABLED || '').trim().toLowerCase();
71+
function isFlagOn(raw: string | undefined): boolean {
72+
const flag = (raw || '').trim().toLowerCase();
6073
return flag === '1' || flag === 'true' || flag === 'yes';
6174
}
6275

76+
/** Generic break-glass: disables promos on EVERY scope. */
77+
function genericKillSwitch(): boolean {
78+
return isFlagOn(env.PROMOS_DISABLED);
79+
}
80+
81+
/** Legacy cookbook-only break-glass, kept for back-compat. */
82+
function cookbookKillSwitch(): boolean {
83+
return isFlagOn(env.COOKBOOK_PROMOS_DISABLED);
84+
}
85+
6386
/**
6487
* Resolve the effective config: KV override if present, otherwise the
6588
* hardcoded defaults. Exposed so the admin endpoint can render a
@@ -71,19 +94,28 @@ export async function resolvePromoConfig(kv: PromoKV): Promise<PromoConfigState>
7194
}
7295

7396
/**
74-
* Validate a user-submitted code against the resolved config.
97+
* Validate a user-submitted code against the resolved config, for a
98+
* specific surface (`scope`).
7599
*
76100
* Async because KV is the source of truth. Callers (the public
77101
* apply-promo + create-invoice endpoints) pass through `platform.env`'s
78102
* `GATED_CONTENT` binding.
103+
*
104+
* Unit-agnostic on `baseAmount`: cookbook passes sats, membership/genesis
105+
* pass USD cents (see `applyPromoMath`). The validation here only gates
106+
* *which* code may apply; the caller owns the units and downstream
107+
* conversion.
79108
*/
80109
export async function applyPromo(
81110
kv: PromoKV,
82111
rawCode: string,
83-
baseSats: number
112+
baseAmount: number,
113+
scope: PromoScope
84114
): Promise<PromoLookup> {
85-
// Break-glass env override wins over everything else.
86-
if (envKillSwitch()) return { ok: false, error: 'disabled' };
115+
// Generic break-glass disables every scope; the legacy cookbook
116+
// switch additionally disables the cookbook scope.
117+
if (genericKillSwitch()) return { ok: false, error: 'disabled' };
118+
if (scope === 'cookbook' && cookbookKillSwitch()) return { ok: false, error: 'disabled' };
87119

88120
const config = await resolvePromoConfig(kv);
89121
if (!config.enabled) return { ok: false, error: 'disabled' };
@@ -98,7 +130,26 @@ export async function applyPromo(
98130
if (cfg.expiresAt && Date.now() > cfg.expiresAt) {
99131
return { ok: false, error: 'expired' };
100132
}
101-
const applied = applyPromoMath(baseSats, cfg.percentOff, cfg.flatOff, code);
133+
134+
// Scope gate — a code only applies to its surface (legacy codes with
135+
// no scope default to 'all', which covers cookbook + membership but
136+
// never genesis).
137+
if (!scopeAllows(cfg.scope, scope)) {
138+
return { ok: false, error: 'wrong_scope' };
139+
}
140+
141+
// Membership + genesis are percent-only and capped below 100%, so a
142+
// paid Strike receive always gates activation (no free-grant path in
143+
// v1). flatOff is sats-denominated and meaningless against USD cents,
144+
// so it's rejected outright on these scopes too. Defence-in-depth:
145+
// the admin upsert endpoint enforces the same rules at write time.
146+
if (scope === 'membership' || scope === 'genesis') {
147+
if (cfg.flatOff > 0 || cfg.percentOff >= 100) {
148+
return { ok: false, error: 'invalid_for_scope' };
149+
}
150+
}
151+
152+
const applied = applyPromoMath(baseAmount, cfg.percentOff, cfg.flatOff, code);
102153
return { ok: true, applied };
103154
}
104155

src/lib/cookbookPromoStore.server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@
2121
* extends a code's life by a minute.
2222
*/
2323

24+
import type { PromoScope } from '$lib/cookbookPricing';
25+
2426
export interface PromoEntry {
2527
percentOff: number; // 0-100
26-
flatOff: number; // sats removed AFTER percent
28+
flatOff: number; // sats removed AFTER percent (cookbook scope only; see below)
29+
scope?: PromoScope; // which surface this code applies to; absent = 'all'
2730
expiresAt?: number; // unix ms — undefined = never expires
2831
note?: string;
2932
}

src/lib/promoEngine.server.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Scope-aware promo engine — public server entry point.
3+
*
4+
* The implementation still lives in the historically-named cookbook
5+
* modules (`cookbookPromo.server.ts`, `cookbookPromoStore.server.ts`,
6+
* `cookbookPricing.ts`), kept in place to avoid churning existing
7+
* imports. This barrel re-exports them under a scope-neutral name so
8+
* non-cookbook surfaces (membership, genesis) read clearly:
9+
*
10+
* import { applyPromo } from '$lib/promoEngine.server';
11+
* await applyPromo(kv, code, baseUsdCents, 'membership');
12+
*/
13+
14+
export {
15+
applyPromo,
16+
resolvePromoConfig,
17+
isFreePromoApplied,
18+
DEFAULT_PROMO_CONFIG,
19+
type PromoLookup
20+
} from '$lib/cookbookPromo.server';
21+
22+
export {
23+
applyPromoMath,
24+
scopeAllows,
25+
ALL_SCOPE_COVERS,
26+
type PromoApplied,
27+
type PromoScope
28+
} from '$lib/cookbookPricing';
29+
30+
export {
31+
loadPromoConfig,
32+
savePromoConfig,
33+
setPromoEnabled,
34+
upsertPromoCode,
35+
deletePromoCode,
36+
type PromoEntry,
37+
type PromoConfigState,
38+
type PromoKV
39+
} from '$lib/cookbookPromoStore.server';

0 commit comments

Comments
 (0)