From 07259995e63f257cbafa19cad470ae8e2df212bc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:46:02 -0700 Subject: [PATCH 01/11] docs(superpowers): spec for minting one-time payments handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR B-Stripe locked one-time 12-month payments, but the existing minting service handleCheckoutCompleted requires subscription mode. End-to-end smoke confirmed the gap: handler throws "session has no subscription" before minting/email. Spec: rename DB column stripe_subscription_id → stripe_payment_id (empty table, safe rename), strip subscription handlers, rewrite handleCheckoutCompleted for one-time payments, set customer_creation: 'always' on Checkout sessions so the customer ID is always present for the license record. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-21-minting-one-time-payments-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-minting-one-time-payments-design.md diff --git a/docs/superpowers/specs/2026-05-21-minting-one-time-payments-design.md b/docs/superpowers/specs/2026-05-21-minting-one-time-payments-design.md new file mode 100644 index 000000000..afe49f0a2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-minting-one-time-payments-design.md @@ -0,0 +1,107 @@ +# Minting Service — One-Time Payments + +**Status:** Design approved, ready for implementation plan. +**Owner:** apps/minting-service + libs/db + apps/website (one-line change to Checkout session creation). +**Affects:** `libs/db/src/lib/{schema/licenses.ts, queries/licenses.ts, queries/licenses.spec.ts}`, a new drizzle migration, `apps/minting-service/src/lib/handlers.ts` + spec, `apps/minting-service/src/lib/handlers.spec.ts`, `apps/website/src/app/api/checkout/session/route.ts` (add `customer_creation: 'always'`). + +## Problem + +PR B-Stripe locked **one-time 12-month payments** for all paid tiers. The minting service's `handleCheckoutCompleted` was written assuming **subscription mode**: it throws `session ${id} has no subscription` for one-time payment sessions. End-to-end smoke confirmed this — webhook delivers, signature verifies, and the handler throws before minting. + +The DB schema also reflects subscription assumptions: `stripe_subscription_id text NOT NULL UNIQUE`, with `upsertLicense` keying on it. + +## Decision + +Rename the DB column to a generic `stripe_payment_id` (since the column functions as "unique Stripe-side reference"). Rewrite `handleCheckoutCompleted` for one-time payments and **strip the subscription handlers** entirely — we're committing to one-time-only per the locked brainstorm decision. The DB is empty so a destructive rename migration is safe. + +Update `/api/checkout/session` to set `customer_creation: 'always'` so Stripe always creates a customer for the buyer (necessary for the minting service to write a license record keyed on `stripe_customer_id`, which stays `NOT NULL` in the schema). + +## File changes + +### `libs/db/src/lib/schema/licenses.ts` +- Rename column field `stripeSubscriptionId` → `stripePaymentId` (TypeScript) and `stripe_subscription_id` → `stripe_payment_id` (SQL). +- Rename unique constraint accordingly. + +### `libs/db/drizzle/0001_rename_subscription_to_payment.sql` (new migration) +Since the DB is empty, a single `ALTER TABLE ... RENAME COLUMN` plus constraint rename is the cleanest path. Drizzle generates this from the schema change; we commit the resulting SQL. + +```sql +ALTER TABLE "licenses" RENAME COLUMN "stripe_subscription_id" TO "stripe_payment_id"; +ALTER TABLE "licenses" RENAME CONSTRAINT "licenses_stripe_subscription_id_unique" TO "licenses_stripe_payment_id_unique"; +``` + +### `libs/db/src/lib/queries/licenses.ts` +- `upsertLicense` `onConflictDoUpdate.target`: `licenses.stripeSubscriptionId` → `licenses.stripePaymentId`. +- `getLicense(db, stripePaymentId)` — rename parameter (semantic) and the where clause column. +- `revokeLicense(db, stripePaymentId)` — same rename. + +### `libs/db/src/lib/queries/licenses.spec.ts` +- All references to `stripeSubscriptionId` → `stripePaymentId`. Test fixture values (`sub_1`, `sub_insert`, etc.) become `pi_1`, `pi_insert`, etc. — matches real Stripe IDs. + +### `apps/minting-service/src/lib/handlers.ts` +- **Delete** `handleSubscriptionUpdated` and `handleSubscriptionDeleted` functions. +- **Delete** their `case` branches in the `handleEvent` switch. +- **Rewrite** `handleCheckoutCompleted`: + - Expand `payment_intent` and `customer_details.email` on retrieve. + - Require `expanded.mode === 'payment'`; if `'subscription'`, log + skip (defensive; we shouldn't be getting those events). + - Extract `payment_intent` ID as the unique reference. + - Extract `customer` ID (now guaranteed present thanks to `customer_creation: 'always'`). + - Extract email from `expanded.customer_details?.email`. + - Compute `expiresAt = Date.now() + defaultTtlDays * 24 * 60 * 60 * 1000`. + - `mintToken({ stripeCustomerId, tier, seats, expiresAt }, privateKeyHex)`. + - `upsertLicense({ stripeCustomerId, stripePaymentId: payment_intent, customerEmail, tier, seats, expiresAt, lastToken })`. + - `sendLicenseEmail(...)` as before. + +### `apps/minting-service/src/lib/handlers.spec.ts` +- Drop or update existing subscription-mode tests. +- Add tests for one-time payment path: fixture session with `mode: 'payment'`, `payment_intent: 'pi_...'`, `customer: 'cus_...'`, `customer_details: { email: 'buyer@example.com' }`. +- Assert: `mintToken` called with right args, `upsertLicense` called with `stripePaymentId: 'pi_...'`, `sendLicenseEmail` fired with the buyer's email. +- Negative test: `mode === 'subscription'` → handler returns early without minting (no token, no email). + +### `apps/website/src/app/api/checkout/session/route.ts` +- Add `customer_creation: 'always'` to the `stripe.checkout.sessions.create({...})` call. One line. +- Update the unit spec (`route.spec.ts`) to assert the param is passed. + +### `apps/minting-service/src/lib/env.ts` / handler signature +- No env changes. + +## Out of scope + +- Subscription tiers (parked). +- License key rotation, revocation UX. +- The `handleSubscriptionUpdated/Deleted` codepaths (deleted). +- Adding test mode and live mode separation in code (already encoded in the `sk_*` prefix). + +## Acceptance criteria + +1. `libs/db` schema has `stripe_payment_id` (no `stripe_subscription_id` anywhere). +2. Drizzle migration `0001_*.sql` exists; running it against an empty DB succeeds. +3. `npx nx run db:test` passes (existing licenses.spec runs against the renamed column). +4. `apps/minting-service/src/lib/handlers.ts` has only `handleCheckoutCompleted` exported (no subscription handlers). The `handleEvent` switch handles `checkout.session.completed` only (other event types fall through to default return). +5. `npx nx run minting-service:test` passes (new handler spec covers the one-time-payment path). +6. `apps/website/src/app/api/checkout/session/route.ts` passes `customer_creation: 'always'`. Existing route.spec passes; updated assertion verifies the new param. +7. End-to-end smoke (after deploy): + - `stripe trigger checkout.session.completed` (or a real Checkout test transaction with 4242 4242 4242 4242) → webhook delivers → minting service handler runs → license row inserted → Resend send event recorded. +8. The minted token verifies in `examples/chat/angular/` when pasted into `environment.license`. + +## Migration approach for live deploy + +DB is empty (verified via `SELECT count(*) FROM licenses;` returned 0). Steps: +1. Apply `0001_*.sql` to the Neon DB via existing migration runner / drizzle-kit push. +2. Deploy the minting service code change. +3. Apply the column rename and deploy together (or rename first, then deploy — both orders work because no rows exist). + +If a row existed, the rename would still be data-preserving (`ALTER TABLE RENAME COLUMN` keeps data); no migration risk. + +## Verification + +- `pnpm tsx scripts/stripe/sync-products.ts` (re-run safe, idempotent) +- `stripe trigger checkout.session.completed --api-key $STRIPE_SECRET_KEY --override checkout_session:metadata.ngaf_tier_slug=indie` +- Check Resend API for the resulting send event +- Inspect the DB row for the new license + +## Risks + +- **Resend send may fail** if `EMAIL_FROM` isn't verified in Resend's account. Surfaces as a 4xx in `sendLicenseEmail`. Pre-deploy check: confirm the configured `EMAIL_FROM` domain has SPF/DKIM set. +- **Stripe `customer_creation: 'always'` semantics**: Stripe always creates a customer record for the session, but only for `mode: 'payment'`. In `mode: 'subscription'`, the customer is created automatically. Setting `customer_creation` in subscription mode is silently ignored. Safe. +- **Token doesn't verify if prod key mismatch**: separate concern (covered by PR C operational checklist). Not a regression of this PR. From c3d7ef6f2d286f76ea751d9c3bc1967283bd2f49 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:49:25 -0700 Subject: [PATCH 02/11] docs(superpowers): plan for minting one-time payments 8 tasks: schema rename, drizzle migration + journal + snapshot, queries rename, queries spec rename, handler rewrite (strip subscriptions + rewrite checkoutCompleted), handler spec, customer_creation: 'always' on website route + spec, final verification + post-merge smoke runbook. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-21-minting-one-time-payments.md | 893 ++++++++++++++++++ 1 file changed, 893 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-minting-one-time-payments.md diff --git a/docs/superpowers/plans/2026-05-21-minting-one-time-payments.md b/docs/superpowers/plans/2026-05-21-minting-one-time-payments.md new file mode 100644 index 000000000..e3b27e159 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-minting-one-time-payments.md @@ -0,0 +1,893 @@ +# Minting One-Time Payments Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the minting service mint and email license tokens for one-time-payment Checkout sessions (PR B-Stripe's locked billing model). Rename the DB column that key-identifies a license from `stripe_subscription_id` to `stripe_payment_id`, drop the subscription handlers, rewrite `handleCheckoutCompleted` for `mode: 'payment'`, and make the website always create a Stripe customer at Checkout. + +**Architecture:** Three subsystems touched: `libs/db` (schema + queries + new drizzle migration), `apps/minting-service` (handlers + spec, strip subscription paths), `apps/website` (one-line addition to the Checkout session create call + spec assertion). DB is empty so the column rename is destructive-safe; drizzle migration is a single `ALTER TABLE RENAME COLUMN` + constraint rename. Subscription handlers are deleted entirely per the locked one-time-only decision. + +**Tech Stack:** Drizzle ORM + Postgres (Neon), Stripe Node SDK 22.x, Vitest, Vercel deploy via existing CI. + +**Reference:** Spec at `docs/superpowers/specs/2026-05-21-minting-one-time-payments-design.md`. + +--- + +## File map + +- **Modify:** `libs/db/src/lib/schema/licenses.ts` — rename field + column + unique constraint. +- **Modify:** `libs/db/src/lib/queries/licenses.ts` — `upsertLicense`/`getLicense`/`revokeLicense` use new column name. +- **Modify:** `libs/db/src/lib/queries/licenses.spec.ts` — all `stripeSubscriptionId` references → `stripePaymentId`; fixture values switch from `sub_*` to `pi_*`. +- **Create:** `libs/db/drizzle/0001_rename_subscription_to_payment.sql` — manual migration. +- **Modify:** `libs/db/drizzle/meta/_journal.json` — append `0001_*` entry. +- **Modify:** `apps/minting-service/src/lib/handlers.ts` — delete `handleSubscriptionUpdated`/`handleSubscriptionDeleted`, rewrite `handleCheckoutCompleted` for `mode: 'payment'`. +- **Modify:** `apps/minting-service/src/lib/handlers.spec.ts` — drop subscription tests, add one-time-payment tests. +- **Modify:** `apps/website/src/app/api/checkout/session/route.ts` — pass `customer_creation: 'always'`. +- **Modify:** `apps/website/src/app/api/checkout/session/route.spec.ts` — assert `customer_creation: 'always'` is passed. + +No changes to `@ngaf/chat`, `@ngaf/licensing`, the cockpit, examples, or any other library. + +--- + +## Task 1: Rename DB schema column + +**Files:** +- Modify: `libs/db/src/lib/schema/licenses.ts` + +- [ ] **Step 1: Read the current schema** + +Run: `cat libs/db/src/lib/schema/licenses.ts` + +Confirm the file declares `stripeSubscriptionId: text('stripe_subscription_id').notNull().unique()`. + +- [ ] **Step 2: Apply the rename** + +Use Edit on `libs/db/src/lib/schema/licenses.ts`: + +- `old_string`: +```ts + stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(), +``` + +- `new_string`: +```ts + stripePaymentId: text('stripe_payment_id').notNull().unique(), +``` + +- [ ] **Step 3: Verify** + +Run: `grep -c "stripeSubscriptionId\|stripe_subscription_id" libs/db/src/lib/schema/licenses.ts` +Expected: `0`. + +Run: `grep -c "stripePaymentId.*stripe_payment_id" libs/db/src/lib/schema/licenses.ts` +Expected: `1`. + +- [ ] **Step 4: Commit** + +```bash +git add libs/db/src/lib/schema/licenses.ts +git commit -m "$(cat <<'EOF' +refactor(db): rename licenses.stripe_subscription_id → stripe_payment_id + +PR B-Stripe locked one-time payments — the column now stores either a +PaymentIntent ID (one-time) or a Subscription ID (future subscription +tier, if reintroduced). Renaming reflects the actual contract: this is +"the unique Stripe-side reference," not a subscription-specific field. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Create drizzle migration `0001_rename_subscription_to_payment.sql` + +**Files:** +- Create: `libs/db/drizzle/0001_rename_subscription_to_payment.sql` +- Modify: `libs/db/drizzle/meta/_journal.json` + +- [ ] **Step 1: Write the migration SQL** + +Create `libs/db/drizzle/0001_rename_subscription_to_payment.sql` with this exact content: + +```sql +ALTER TABLE "licenses" RENAME COLUMN "stripe_subscription_id" TO "stripe_payment_id";--> statement-breakpoint +ALTER TABLE "licenses" RENAME CONSTRAINT "licenses_stripe_subscription_id_unique" TO "licenses_stripe_payment_id_unique"; +``` + +- [ ] **Step 2: Append journal entry** + +Use Edit on `libs/db/drizzle/meta/_journal.json`. Find the `entries` array: + +```json +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1776716165218, + "tag": "0000_init", + "breakpoints": true + } + ] +} +``` + +Replace with (add a second entry): + +```json +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1776716165218, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1779388800000, + "tag": "0001_rename_subscription_to_payment", + "breakpoints": true + } + ] +} +``` + +(The `when` value is `Date.now()` at plan-write time, approximately. Drizzle doesn't validate exact values; just monotonically increasing.) + +- [ ] **Step 3: Validate JSON** + +Run: `python3 -c "import json; json.load(open('libs/db/drizzle/meta/_journal.json')); print('ok')"` +Expected: `ok`. + +- [ ] **Step 4: Update the snapshot file if drizzle-kit needs one** + +Drizzle migrations track the schema snapshot per migration. Check whether `libs/db/drizzle/meta/0000_snapshot.json` exists — if so, the cleanest move is to regenerate the snapshot via `drizzle-kit`. However, regenerating may pull in unrelated schema noise. + +For safety, simply copy `0000_snapshot.json` to `0001_snapshot.json` and edit the one column reference: + +```bash +cp libs/db/drizzle/meta/0000_snapshot.json libs/db/drizzle/meta/0001_snapshot.json +sed -i '' 's/"stripe_subscription_id"/"stripe_payment_id"/g; s/"stripeSubscriptionId"/"stripePaymentId"/g; s/"licenses_stripe_subscription_id_unique"/"licenses_stripe_payment_id_unique"/g' libs/db/drizzle/meta/0001_snapshot.json +``` + +Verify: `grep -c "stripe_subscription_id\|stripeSubscriptionId" libs/db/drizzle/meta/0001_snapshot.json` → `0`. + +- [ ] **Step 5: Commit** + +```bash +git add libs/db/drizzle/ +git commit -m "$(cat <<'EOF' +db: add 0001 migration renaming stripe_subscription_id → stripe_payment_id + +Single ALTER TABLE RENAME COLUMN + constraint rename. DB is empty +(confirmed via SELECT count(*) FROM licenses → 0), so this is data- +preserving but no-data-risk. Mirrors the schema change in 1/1. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Update `libs/db/src/lib/queries/licenses.ts` + +**Files:** +- Modify: `libs/db/src/lib/queries/licenses.ts` + +- [ ] **Step 1: Read the current file** + +Run: `cat libs/db/src/lib/queries/licenses.ts | head -50` + +Identify all references to `stripeSubscriptionId` (TypeScript) and `stripe_subscription_id` (none in this file — that's in the schema). + +- [ ] **Step 2: Rename in `upsertLicense`'s `onConflictDoUpdate.target`** + +Use Edit: + +- `old_string`: ` target: licenses.stripeSubscriptionId,` +- `new_string`: ` target: licenses.stripePaymentId,` + +- [ ] **Step 3: Rename `getLicense` parameter + where clause** + +Use Edit: + +- `old_string`: +```ts +export async function getLicense(db: Db, stripeSubscriptionId: string): Promise { + const rows = await db + .select() + .from(licenses) + .where(eq(licenses.stripeSubscriptionId, stripeSubscriptionId)) + .limit(1); + return rows[0] ?? null; +} +``` + +- `new_string`: +```ts +export async function getLicense(db: Db, stripePaymentId: string): Promise { + const rows = await db + .select() + .from(licenses) + .where(eq(licenses.stripePaymentId, stripePaymentId)) + .limit(1); + return rows[0] ?? null; +} +``` + +- [ ] **Step 4: Rename `revokeLicense` parameter + where clause** + +Use Edit: + +- `old_string`: +```ts +export async function revokeLicense(db: Db, stripeSubscriptionId: string): Promise { + const rows = await db + .update(licenses) + .set({ revokedAt: sql`now()`, updatedAt: sql`now()` }) + .where(eq(licenses.stripeSubscriptionId, stripeSubscriptionId)) + .returning(); + return rows[0] ?? null; +} +``` + +- `new_string`: +```ts +export async function revokeLicense(db: Db, stripePaymentId: string): Promise { + const rows = await db + .update(licenses) + .set({ revokedAt: sql`now()`, updatedAt: sql`now()` }) + .where(eq(licenses.stripePaymentId, stripePaymentId)) + .returning(); + return rows[0] ?? null; +} +``` + +- [ ] **Step 5: Verify** + +Run: `grep -c "stripeSubscriptionId" libs/db/src/lib/queries/licenses.ts` +Expected: `0`. + +Run: `grep -c "stripePaymentId" libs/db/src/lib/queries/licenses.ts` +Expected: `4` (one in `target:`, two in `where(eq(...))`, plus the parameter in two function signatures). + +- [ ] **Step 6: Commit** + +```bash +git add libs/db/src/lib/queries/licenses.ts +git commit -m "$(cat <<'EOF' +refactor(db): rename license queries to use stripePaymentId + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Update `libs/db/src/lib/queries/licenses.spec.ts` + +**Files:** +- Modify: `libs/db/src/lib/queries/licenses.spec.ts` + +- [ ] **Step 1: Read the file** + +Run: `cat libs/db/src/lib/queries/licenses.spec.ts` + +Confirm the test file uses `stripeSubscriptionId` in the `base` fixture and per-test overrides (`sub_1`, `sub_insert`, `sub_update`, `sub_get`, `sub_e1`, `sub_e2`). + +- [ ] **Step 2: Apply the rename** + +Use Edit with `replace_all: true`: +- `old_string`: `stripeSubscriptionId` +- `new_string`: `stripePaymentId` + +Then apply ALL of these (one Edit each, `replace_all: false` since each is unique) to update test fixture values from `sub_*` to `pi_*`: + +| Find | Replace with | +|---|---| +| `'sub_1'` | `'pi_1'` | +| `'sub_insert'` | `'pi_insert'` | +| `'sub_update'` | `'pi_update'` | +| `'sub_get'` | `'pi_get'` | +| `'sub_e1'` | `'pi_e1'` | +| `'sub_e2'` | `'pi_e2'` | + +- [ ] **Step 3: Verify** + +Run: `grep -c "stripeSubscriptionId\|'sub_" libs/db/src/lib/queries/licenses.spec.ts` +Expected: `0`. + +Run: `grep -c "stripePaymentId\|'pi_" libs/db/src/lib/queries/licenses.spec.ts` +Expected: `>= 12` (varies by exact test count; just confirm both names appear). + +- [ ] **Step 4: Run the spec** + +Run: `npx nx run db:test 2>&1 | tail -15` +Expected: `Successfully ran target test for project db`. All license-spec tests pass against the renamed column. + +If the test runner can't reach a DB (e.g., expects a local Postgres or test container), the failure mode is environmental and not a regression of this change. In that case, run just the type check: `npx tsc -p libs/db/tsconfig.lib.json --noEmit 2>&1 | grep licenses\.spec || echo "type-clean"`. Expected: `type-clean`. + +- [ ] **Step 5: Commit** + +```bash +git add libs/db/src/lib/queries/licenses.spec.ts +git commit -m "$(cat <<'EOF' +test(db): rename license-query test fixtures to stripePaymentId/pi_* + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Strip subscription handlers + rewrite `handleCheckoutCompleted` + +**Files:** +- Modify: `apps/minting-service/src/lib/handlers.ts` + +- [ ] **Step 1: Replace the whole file** + +Write `apps/minting-service/src/lib/handlers.ts` with this exact content: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type Stripe from 'stripe'; +import type { + Db, + License, + UpsertLicenseInput, +} from '@ngaf/db'; +import type { MintInput } from './sign.js'; +import type { LicenseEmailVars } from './email.js'; +import { extractTier, computeSeats } from './tier.js'; + +/** + * All external collaborators are injected so handlers are unit-testable. + */ +export interface HandlerDeps { + db: Db; + stripe: Stripe; + markEventProcessed: (db: Db, id: string, type: string) => Promise; + deleteProcessedEvent: (db: Db, id: string) => Promise; + upsertLicense: (db: Db, input: UpsertLicenseInput) => Promise; + getLicense: (db: Db, stripePaymentId: string) => Promise; + revokeLicense: (db: Db, stripePaymentId: string) => Promise; + mintToken: (input: MintInput, privateKeyHex: string) => Promise; + sendLicenseEmail: (args: { + resendApiKey: string; + from: string; + to: string; + vars: LicenseEmailVars; + }) => Promise<{ resendId: string }>; + privateKeyHex: string; + resendApiKey: string; + emailFrom: string; + defaultTtlDays: number; +} + +export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promise { + const firstTime = await deps.markEventProcessed(deps.db, event.id, event.type); + if (!firstTime) return; + + try { + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, deps); + break; + default: + return; + } + } catch (err) { + await deps.deleteProcessedEvent(deps.db, event.id); + throw err; + } +} + +/** + * Handles a completed Stripe Checkout session in `mode: 'payment'` + * (one-time payment). Subscription mode is not handled — the only paid + * tiers ship as one-time 12-month payments. A subscription-mode session + * is logged and dropped. + */ +export async function handleCheckoutCompleted( + session: Stripe.Checkout.Session, + deps: HandlerDeps, +): Promise { + const expanded = await deps.stripe.checkout.sessions.retrieve(session.id, { + expand: ['line_items.data.price'], + }); + + if (expanded.mode !== 'payment') { + // eslint-disable-next-line no-console + console.log(`handleCheckoutCompleted: skipping non-payment session ${session.id} (mode=${expanded.mode})`); + return; + } + + const lineItem = expanded.line_items?.data?.[0]; + if (!lineItem) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no line items`); + } + const priceMetadata = (lineItem.price?.metadata ?? {}) as Record; + const tier = extractTier(priceMetadata); + const seats = computeSeats(tier, lineItem.quantity); + + const paymentId = typeof expanded.payment_intent === 'string' + ? expanded.payment_intent + : expanded.payment_intent?.id; + if (!paymentId) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no payment_intent`); + } + + const customerId = typeof expanded.customer === 'string' + ? expanded.customer + : expanded.customer?.id; + if (!customerId) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer (customer_creation: 'always' must be set on the Checkout session)`); + } + + const email = expanded.customer_details?.email; + if (!email) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer email`); + } + + const expiresAt = new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); + + const token = await deps.mintToken( + { stripeCustomerId: customerId, tier, seats, expiresAt }, + deps.privateKeyHex, + ); + + await deps.upsertLicense(deps.db, { + stripeCustomerId: customerId, + stripePaymentId: paymentId, + customerEmail: email, + tier, + seats, + expiresAt, + lastToken: token, + }); + + await deps.sendLicenseEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to: email, + vars: { tier, seats, token, expiresAt }, + }); +} +``` + +This replaces the entire file: +- Removes `handleSubscriptionUpdated` and `handleSubscriptionDeleted` exports. +- Removes the two corresponding `case` branches. +- Rewrites `handleCheckoutCompleted` for `mode: 'payment'`. +- Renames the `HandlerDeps` `getLicense`/`revokeLicense` parameter doc from "subId" to "stripePaymentId" (just a renamed param, signature unchanged at the call site). + +- [ ] **Step 2: Type-check** + +Run from repo root: `npx tsc -p apps/minting-service/tsconfig.json --noEmit 2>&1 | grep -E "handlers\.ts|TS2" | grep -v TS6305 || echo "ok"` +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add apps/minting-service/src/lib/handlers.ts +git commit -m "$(cat <<'EOF' +feat(minting): handle one-time-payment Checkout sessions; strip subs + +Rewrites handleCheckoutCompleted for mode: 'payment': +- Uses payment_intent as the unique Stripe-side reference +- Requires customer (via customer_creation: 'always' on the website) +- Reads email from customer_details.email +- Computes expiresAt from defaultTtlDays (no subscription period to read) + +Removes handleSubscriptionUpdated and handleSubscriptionDeleted entirely +per the locked one-time-only billing decision. Subscription event types +fall through to the default no-op. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Rewrite `apps/minting-service/src/lib/handlers.spec.ts` + +**Files:** +- Modify: `apps/minting-service/src/lib/handlers.spec.ts` + +The existing spec tests `handleEvent` dispatching to subscription handlers. Strip those, add one-time-payment tests. + +- [ ] **Step 1: Read the existing spec** + +Run: `cat apps/minting-service/src/lib/handlers.spec.ts | head -80` + +Identify the structure: a `makeDeps` helper, an `evt` helper, and `describe` blocks. + +- [ ] **Step 2: Replace the whole file** + +Write `apps/minting-service/src/lib/handlers.spec.ts`: + +```ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import type Stripe from 'stripe'; +import { handleEvent, handleCheckoutCompleted, type HandlerDeps } from './handlers.js'; + +function makeDeps(overrides: Partial = {}): HandlerDeps { + return { + db: {} as never, + stripe: {} as never, + markEventProcessed: vi.fn().mockResolvedValue(true), + deleteProcessedEvent: vi.fn().mockResolvedValue(undefined), + upsertLicense: vi.fn(), + getLicense: vi.fn(), + revokeLicense: vi.fn(), + mintToken: vi.fn().mockResolvedValue('mock.token'), + sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_mock' }), + privateKeyHex: 'a'.repeat(64), + resendApiKey: 're_test', + emailFrom: 'noreply@example.com', + defaultTtlDays: 365, + ...overrides, + }; +} + +function evt(type: string, obj: unknown = {}): Stripe.Event { + return { id: `evt_${type}`, type, data: { object: obj } } as Stripe.Event; +} + +function paymentSession(overrides: Partial = {}): Stripe.Checkout.Session { + return { + id: 'cs_test_123', + mode: 'payment', + payment_intent: 'pi_test_123', + customer: 'cus_test_123', + customer_details: { email: 'buyer@example.com' } as Stripe.Checkout.Session.CustomerDetails, + line_items: { + data: [ + { + quantity: 1, + price: { + metadata: { ngaf_tier_slug: 'indie' }, + } as Stripe.Price, + } as Stripe.LineItem, + ], + } as Stripe.ApiList, + ...overrides, + } as Stripe.Checkout.Session; +} + +describe('handleEvent', () => { + it('returns early if markEventProcessed returns false (duplicate)', async () => { + const deps = makeDeps({ + markEventProcessed: vi.fn().mockResolvedValue(false), + }); + await handleEvent(evt('checkout.session.completed', { id: 'cs_x' }), deps); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); + }); + + it('no-ops on unknown event types (including subscription events)', async () => { + const deps = makeDeps(); + await handleEvent(evt('customer.subscription.updated'), deps); + await handleEvent(evt('customer.subscription.deleted'), deps); + await handleEvent(evt('invoice.payment_succeeded'), deps); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); + }); + + it('dispatches checkout.session.completed to handleCheckoutCompleted', async () => { + const session = paymentSession(); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await handleEvent(evt('checkout.session.completed', session), deps); + expect(stripe.checkout.sessions.retrieve).toHaveBeenCalledWith('cs_test_123', expect.any(Object)); + expect(deps.upsertLicense).toHaveBeenCalledTimes(1); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('compensating-deletes the processed-event marker when handler throws', async () => { + const session = paymentSession({ payment_intent: null }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await expect( + handleEvent(evt('checkout.session.completed', session), deps), + ).rejects.toThrow(/no payment_intent/); + expect(deps.deleteProcessedEvent).toHaveBeenCalledTimes(1); + }); +}); + +describe('handleCheckoutCompleted', () => { + it('mints, upserts, and emails on a complete one-time payment session', async () => { + const session = paymentSession(); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await handleCheckoutCompleted(session, deps); + + expect(deps.mintToken).toHaveBeenCalledWith( + expect.objectContaining({ + stripeCustomerId: 'cus_test_123', + tier: 'indie', + seats: 1, + expiresAt: expect.any(Date), + }), + 'a'.repeat(64), + ); + expect(deps.upsertLicense).toHaveBeenCalledWith( + {} as never, + expect.objectContaining({ + stripeCustomerId: 'cus_test_123', + stripePaymentId: 'pi_test_123', + customerEmail: 'buyer@example.com', + tier: 'indie', + seats: 1, + lastToken: 'mock.token', + }), + ); + expect(deps.sendLicenseEmail).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'noreply@example.com', + to: 'buyer@example.com', + vars: expect.objectContaining({ tier: 'indie', seats: 1, token: 'mock.token' }), + }), + ); + }); + + it('skips subscription-mode sessions without minting', async () => { + const session = paymentSession({ mode: 'subscription' }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await handleCheckoutCompleted(session, deps); + expect(deps.mintToken).not.toHaveBeenCalled(); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); + }); + + it('throws when the session has no payment_intent', async () => { + const session = paymentSession({ payment_intent: null }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no payment_intent/); + }); + + it('throws when the session has no customer', async () => { + const session = paymentSession({ customer: null }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/customer_creation/); + }); + + it('throws when the session has no customer email', async () => { + const session = paymentSession({ + customer_details: { email: null } as Stripe.Checkout.Session.CustomerDetails, + }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no customer email/); + }); +}); +``` + +- [ ] **Step 3: Run the spec** + +Run: `npx nx run minting-service:test 2>&1 | tail -15` +Expected: `Successfully ran target test for project minting-service`. All tests pass. + +If a test fails, read the output and either fix the spec assertion or the implementation. Common cause: forgetting `await` or mis-typed `expect.objectContaining` shapes. + +- [ ] **Step 4: Commit** + +```bash +git add apps/minting-service/src/lib/handlers.spec.ts +git commit -m "$(cat <<'EOF' +test(minting): cover one-time payment handler + skip subscription mode + +8 tests: +- handleEvent dispatches to checkout.session.completed +- handleEvent no-ops on subscription events (now unhandled) +- compensating delete on handler throw +- handleCheckoutCompleted mints+upserts+emails on payment mode +- handleCheckoutCompleted skips subscription mode without minting +- throws on missing payment_intent, customer, or customer email + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Add `customer_creation: 'always'` to website Checkout session + +**Files:** +- Modify: `apps/website/src/app/api/checkout/session/route.ts` +- Modify: `apps/website/src/app/api/checkout/session/route.spec.ts` + +- [ ] **Step 1: Add the param to the route** + +Use Edit on `apps/website/src/app/api/checkout/session/route.ts`. Find: + +```ts + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + line_items: [ +``` + +Replace with: + +```ts + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + customer_creation: 'always', + line_items: [ +``` + +- [ ] **Step 2: Update the spec assertion** + +Use Edit on `apps/website/src/app/api/checkout/session/route.spec.ts`. Find the test asserting the `indie` happy path: + +```ts + expect(args.mode).toBe('payment'); + expect(args.line_items[0].price).toBe('price_test_indie'); +``` + +Replace with: + +```ts + expect(args.mode).toBe('payment'); + expect(args.customer_creation).toBe('always'); + expect(args.line_items[0].price).toBe('price_test_indie'); +``` + +- [ ] **Step 3: Run the spec** + +Run from `apps/website/`: `npx vitest run src/app/api/checkout/session/route.spec.ts 2>&1 | tail -10` +Expected: 7 passed (same count as before; one test gains an additional assertion). + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/app/api/checkout/session/route.ts apps/website/src/app/api/checkout/session/route.spec.ts +git commit -m "$(cat <<'EOF' +feat(website): always create a Stripe customer at Checkout + +customer_creation: 'always' guarantees the resulting checkout.session.completed +event carries a customer field, which the minting service requires to +write a license record (stripe_customer_id is NOT NULL). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full library test** + +Run from repo root: +``` +npx nx run db:test 2>&1 | tail -5 +npx nx run minting-service:test 2>&1 | tail -5 +npx nx run website:test 2>&1 | tail -5 +``` +All three expect `Successfully ran target test`. + +- [ ] **Step 2: Build minting + website** + +``` +npx nx build minting-service 2>&1 | tail -5 +npx nx build website 2>&1 | tail -5 +``` +Both expect `Successfully ran target build`. + +- [ ] **Step 3: Lint** + +``` +npx nx run db:lint 2>&1 | tail -3 +npx nx run minting-service:lint 2>&1 | tail -3 +npx nx run website:lint 2>&1 | tail -3 +``` +All expect `Successfully ran target lint`. + +- [ ] **Step 4: Scope check** + +```bash +git diff --name-only origin/main..HEAD | grep -vE '^(libs/db/|apps/minting-service/|apps/website/src/app/api/checkout/|docs/superpowers/)' | head +``` +Expected: empty. + +- [ ] **Step 5: Smoke (post-merge, operational)** + +Document the smoke runbook in the PR description so it's executable after the merge: + +``` +1. Apply the DB migration: + set -a && source /Users/blove/repos/angular-agent-framework/.env && set +a + node -e "const {neon}=require('@neondatabase/serverless');const sql=neon(process.env.DATABASE_URL);(async()=>{ + await sql\`ALTER TABLE licenses RENAME COLUMN stripe_subscription_id TO stripe_payment_id\`; + await sql\`ALTER TABLE licenses RENAME CONSTRAINT licenses_stripe_subscription_id_unique TO licenses_stripe_payment_id_unique\`; + console.log('migration applied'); + })()" + +2. CI's minting-deploy fires automatically when the PR lands on main. + Wait for green; confirm https://mint.threadplane.ai/api/health returns 200. + +3. Re-run the smoke: + stripe trigger checkout.session.completed --api-key $STRIPE_SECRET_KEY \ + --override checkout_session:metadata.ngaf_tier_slug=indie + +4. Check Resend logs API for the resulting send: + curl -sS "https://api.resend.com/emails?limit=5" -H "Authorization: Bearer $RESEND_API_KEY" + Expect a send with subject including "license" and to=customer_details.email. + +5. Check the DB row: + node -e "const{neon}=require('@neondatabase/serverless');const sql=neon(process.env.DATABASE_URL);(async()=>{ + const rows = await sql\`SELECT stripe_customer_id, stripe_payment_id, customer_email, tier, seats, expires_at FROM licenses ORDER BY created_at DESC LIMIT 1\`; + console.log(rows); + })()" +``` + +--- + +## Self-review + +**Spec coverage:** +- Spec § schema rename → Task 1. ✓ +- Spec § new drizzle migration → Task 2 (SQL + journal + snapshot). ✓ +- Spec § queries rename → Task 3. ✓ +- Spec § queries spec rename → Task 4. ✓ +- Spec § handler rewrite + strip subscription handlers → Task 5. ✓ +- Spec § handler spec rewrite → Task 6. ✓ +- Spec § `customer_creation: 'always'` on website route → Task 7. ✓ +- Spec § verification + smoke runbook → Task 8. ✓ +- Spec § acceptance criteria 1–8 → Tasks 1–8 collectively. ✓ + +**Placeholder scan:** No TBD/TODO. Task 2 Step 4 includes an inline `cp + sed` fallback for the snapshot file with the exact substitutions specified. + +**Type consistency:** `stripePaymentId` consistent in schema (Task 1), queries (Task 3), tests (Task 4), handler `HandlerDeps` param doc (Task 5), upsert call (Task 5), and tests (Task 6). `payment_intent` shape from Stripe is consistent across handler reads and test fixtures. `customer_creation: 'always'` is referenced by name in handler error message (Task 5), website route (Task 7), and one test in Task 6. + +Plan complete. From 2e5b475b08b57beced22c147b41e26dc1a91fed4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:50:40 -0700 Subject: [PATCH 03/11] =?UTF-8?q?refactor(db):=20rename=20licenses.stripe?= =?UTF-8?q?=5Fsubscription=5Fid=20=E2=86=92=20stripe=5Fpayment=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR B-Stripe locked one-time payments — the column now stores either a PaymentIntent ID (one-time) or a Subscription ID (future subscription tier, if reintroduced). Renaming reflects the actual contract: this is "the unique Stripe-side reference," not a subscription-specific field. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/db/src/lib/schema/licenses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/db/src/lib/schema/licenses.ts b/libs/db/src/lib/schema/licenses.ts index 255a1c027..9ed7cef4f 100644 --- a/libs/db/src/lib/schema/licenses.ts +++ b/libs/db/src/lib/schema/licenses.ts @@ -7,7 +7,7 @@ export const licenses = pgTable( { id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), stripeCustomerId: text('stripe_customer_id').notNull(), - stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(), + stripePaymentId: text('stripe_payment_id').notNull().unique(), customerEmail: text('customer_email').notNull(), tier: text('tier').notNull(), seats: integer('seats').notNull(), From f8f4f069bb66183e3bbba953c35fb141a2b96dc3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:51:38 -0700 Subject: [PATCH 04/11] =?UTF-8?q?db:=20add=200001=20migration=20renaming?= =?UTF-8?q?=20stripe=5Fsubscription=5Fid=20=E2=86=92=20stripe=5Fpayment=5F?= =?UTF-8?q?id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single ALTER TABLE RENAME COLUMN + constraint rename. DB is empty (confirmed via SELECT count(*) FROM licenses → 0), so this is data- preserving but no-data-risk. Mirrors the schema change in 1/1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../0001_rename_subscription_to_payment.sql | 2 + libs/db/drizzle/meta/0001_snapshot.json | 179 ++++++++++++++++++ libs/db/drizzle/meta/_journal.json | 7 + 3 files changed, 188 insertions(+) create mode 100644 libs/db/drizzle/0001_rename_subscription_to_payment.sql create mode 100644 libs/db/drizzle/meta/0001_snapshot.json diff --git a/libs/db/drizzle/0001_rename_subscription_to_payment.sql b/libs/db/drizzle/0001_rename_subscription_to_payment.sql new file mode 100644 index 000000000..207e060f4 --- /dev/null +++ b/libs/db/drizzle/0001_rename_subscription_to_payment.sql @@ -0,0 +1,2 @@ +ALTER TABLE "licenses" RENAME COLUMN "stripe_subscription_id" TO "stripe_payment_id";--> statement-breakpoint +ALTER TABLE "licenses" RENAME CONSTRAINT "licenses_stripe_subscription_id_unique" TO "licenses_stripe_payment_id_unique"; diff --git a/libs/db/drizzle/meta/0001_snapshot.json b/libs/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..f2233e0c9 --- /dev/null +++ b/libs/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,179 @@ +{ + "id": "88a9cae3-5552-440c-95e5-61ec89851044", + "prevId": "7c7f6a73-19cd-4f25-a6f9-a84b4f235c8a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.licenses": { + "name": "licenses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_payment_id": { + "name": "stripe_payment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_email": { + "name": "customer_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_token": { + "name": "last_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "licenses_customer_idx": { + "name": "licenses_customer_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "licenses_email_idx": { + "name": "licenses_email_idx", + "columns": [ + { + "expression": "customer_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "licenses_stripe_payment_id_unique": { + "name": "licenses_stripe_payment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_events": { + "name": "processed_events", + "schema": "", + "columns": { + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/libs/db/drizzle/meta/_journal.json b/libs/db/drizzle/meta/_journal.json index 6a845f975..de55da4e8 100644 --- a/libs/db/drizzle/meta/_journal.json +++ b/libs/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776716165218, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1779388800000, + "tag": "0001_rename_subscription_to_payment", + "breakpoints": true } ] } \ No newline at end of file From 217cc369ff2b46dc22f80b415c6054557f186a07 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:52:39 -0700 Subject: [PATCH 05/11] refactor(db): rename license queries to use stripePaymentId Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/db/src/lib/queries/licenses.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/db/src/lib/queries/licenses.ts b/libs/db/src/lib/queries/licenses.ts index f3164d419..1719be0a7 100644 --- a/libs/db/src/lib/queries/licenses.ts +++ b/libs/db/src/lib/queries/licenses.ts @@ -17,7 +17,7 @@ export async function upsertLicense(db: Db, input: UpsertLicenseInput): Promise< .insert(licenses) .values({ ...input, issuedAt: now, updatedAt: now }) .onConflictDoUpdate({ - target: licenses.stripeSubscriptionId, + target: licenses.stripePaymentId, set: { customerEmail: input.customerEmail, tier: input.tier, @@ -32,11 +32,11 @@ export async function upsertLicense(db: Db, input: UpsertLicenseInput): Promise< return rows[0]; } -export async function getLicense(db: Db, stripeSubscriptionId: string): Promise { +export async function getLicense(db: Db, stripePaymentId: string): Promise { const rows = await db .select() .from(licenses) - .where(eq(licenses.stripeSubscriptionId, stripeSubscriptionId)) + .where(eq(licenses.stripePaymentId, stripePaymentId)) .limit(1); return rows[0] ?? null; } @@ -45,11 +45,11 @@ export async function getLicensesByCustomerEmail(db: Db, email: string): Promise return db.select().from(licenses).where(eq(licenses.customerEmail, email)); } -export async function revokeLicense(db: Db, stripeSubscriptionId: string): Promise { +export async function revokeLicense(db: Db, stripePaymentId: string): Promise { const rows = await db .update(licenses) .set({ revokedAt: sql`now()`, updatedAt: sql`now()` }) - .where(eq(licenses.stripeSubscriptionId, stripeSubscriptionId)) + .where(eq(licenses.stripePaymentId, stripePaymentId)) .returning(); return rows[0] ?? null; } From 8f65e6e9680a6375b7d704469ff8048ce03f7528 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:53:57 -0700 Subject: [PATCH 06/11] test(db): rename license-query test fixtures to stripePaymentId/pi_* Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/db/src/lib/queries/licenses.spec.ts | 30 ++++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/libs/db/src/lib/queries/licenses.spec.ts b/libs/db/src/lib/queries/licenses.spec.ts index 380daa09b..188c2a8c3 100644 --- a/libs/db/src/lib/queries/licenses.spec.ts +++ b/libs/db/src/lib/queries/licenses.spec.ts @@ -10,7 +10,7 @@ import { startTestDb, type TestDb } from './test-helpers.js'; const base = { stripeCustomerId: 'cus_1', - stripeSubscriptionId: 'sub_1', + stripePaymentId: 'pi_1', customerEmail: 'a@example.com', tier: 'developer-seat' as const, seats: 3, @@ -31,17 +31,17 @@ describe('licenses queries', () => { describe('upsertLicense', () => { it('inserts a new row keyed on stripe_subscription_id', async () => { - const row = await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_insert' }); - expect(row.stripeSubscriptionId).toBe('sub_insert'); + const row = await upsertLicense(testDb.db, { ...base, stripePaymentId: 'pi_insert' }); + expect(row.stripePaymentId).toBe('pi_insert'); expect(row.seats).toBe(3); expect(row.id).toBeDefined(); }); it('updates an existing row on repeat sub id', async () => { - await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_update', seats: 2 }); + await upsertLicense(testDb.db, { ...base, stripePaymentId: 'pi_update', seats: 2 }); const updated = await upsertLicense(testDb.db, { ...base, - stripeSubscriptionId: 'sub_update', + stripePaymentId: 'pi_update', seats: 7, lastToken: 'token-v2', }); @@ -52,21 +52,21 @@ describe('licenses queries', () => { describe('getLicense', () => { it('returns the row when present', async () => { - await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_get' }); - const found = await getLicense(testDb.db, 'sub_get'); - expect(found?.stripeSubscriptionId).toBe('sub_get'); + await upsertLicense(testDb.db, { ...base, stripePaymentId: 'pi_get' }); + const found = await getLicense(testDb.db, 'pi_get'); + expect(found?.stripePaymentId).toBe('pi_get'); }); it('returns null when not found', async () => { - const found = await getLicense(testDb.db, 'sub_missing'); + const found = await getLicense(testDb.db, 'pi_missing'); expect(found).toBeNull(); }); }); describe('getLicensesByCustomerEmail', () => { it('returns all rows matching the email', async () => { - await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_e1', customerEmail: 'multi@example.com' }); - await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_e2', customerEmail: 'multi@example.com' }); + await upsertLicense(testDb.db, { ...base, stripePaymentId: 'pi_e1', customerEmail: 'multi@example.com' }); + await upsertLicense(testDb.db, { ...base, stripePaymentId: 'pi_e2', customerEmail: 'multi@example.com' }); const rows = await getLicensesByCustomerEmail(testDb.db, 'multi@example.com'); expect(rows.length).toBeGreaterThanOrEqual(2); }); @@ -74,20 +74,20 @@ describe('licenses queries', () => { describe('revokeLicense', () => { it('sets revoked_at to now', async () => { - await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_revoke' }); - const revoked = await revokeLicense(testDb.db, 'sub_revoke'); + await upsertLicense(testDb.db, { ...base, stripePaymentId: 'pi_revoke' }); + const revoked = await revokeLicense(testDb.db, 'pi_revoke'); expect(revoked?.revokedAt).toBeInstanceOf(Date); }); it('returns null for unknown subscription', async () => { - const result = await revokeLicense(testDb.db, 'sub_missing_revoke'); + const result = await revokeLicense(testDb.db, 'pi_missing_revoke'); expect(result).toBeNull(); }); }); describe('updateLicenseToken', () => { it('replaces last_token and bumps issued_at', async () => { - const inserted = await upsertLicense(testDb.db, { ...base, stripeSubscriptionId: 'sub_token' }); + const inserted = await upsertLicense(testDb.db, { ...base, stripePaymentId: 'pi_token' }); const before = inserted.issuedAt; await new Promise((r) => setTimeout(r, 10)); const updated = await updateLicenseToken(testDb.db, inserted.id, 'token-v99'); From b19cade8a7aa86e48c31768be87df9bf8aa22988 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:55:51 -0700 Subject: [PATCH 07/11] feat(minting): handle one-time-payment Checkout sessions; strip subs Rewrites handleCheckoutCompleted for mode: 'payment': - Uses payment_intent as the unique Stripe-side reference - Requires customer (via customer_creation: 'always' on the website) - Reads email from customer_details.email - Computes expiresAt from defaultTtlDays (no subscription period to read) Removes handleSubscriptionUpdated and handleSubscriptionDeleted entirely per the locked one-time-only billing decision. Subscription event types fall through to the default no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/minting-service/src/lib/handlers.ts | 130 +++++------------------ 1 file changed, 26 insertions(+), 104 deletions(-) diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts index 8baee0588..39a12dc08 100644 --- a/apps/minting-service/src/lib/handlers.ts +++ b/apps/minting-service/src/lib/handlers.ts @@ -18,8 +18,8 @@ export interface HandlerDeps { markEventProcessed: (db: Db, id: string, type: string) => Promise; deleteProcessedEvent: (db: Db, id: string) => Promise; upsertLicense: (db: Db, input: UpsertLicenseInput) => Promise; - getLicense: (db: Db, subId: string) => Promise; - revokeLicense: (db: Db, subId: string) => Promise; + getLicense: (db: Db, stripePaymentId: string) => Promise; + revokeLicense: (db: Db, stripePaymentId: string) => Promise; mintToken: (input: MintInput, privateKeyHex: string) => Promise; sendLicenseEmail: (args: { resendApiKey: string; @@ -42,12 +42,6 @@ export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promi case 'checkout.session.completed': await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, deps); break; - case 'customer.subscription.updated': - await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, deps); - break; - case 'customer.subscription.deleted': - await handleSubscriptionDeleted(event.data.object as Stripe.Subscription, deps); - break; default: return; } @@ -57,6 +51,12 @@ export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promi } } +/** + * Handles a completed Stripe Checkout session in `mode: 'payment'` + * (one-time payment). Subscription mode is not handled — the only paid + * tiers ship as one-time 12-month payments. A subscription-mode session + * is logged and dropped. + */ export async function handleCheckoutCompleted( session: Stripe.Checkout.Session, deps: HandlerDeps, @@ -64,6 +64,12 @@ export async function handleCheckoutCompleted( const expanded = await deps.stripe.checkout.sessions.retrieve(session.id, { expand: ['line_items.data.price'], }); + + if (expanded.mode !== 'payment') { + console.log(`handleCheckoutCompleted: skipping non-payment session ${session.id} (mode=${expanded.mode})`); + return; + } + const lineItem = expanded.line_items?.data?.[0]; if (!lineItem) { throw new Error(`handleCheckoutCompleted: session ${session.id} has no line items`); @@ -72,103 +78,26 @@ export async function handleCheckoutCompleted( const tier = extractTier(priceMetadata); const seats = computeSeats(tier, lineItem.quantity); - const subId = typeof expanded.subscription === 'string' - ? expanded.subscription - : expanded.subscription?.id; - if (!subId) { - throw new Error(`handleCheckoutCompleted: session ${session.id} has no subscription`); + const paymentId = typeof expanded.payment_intent === 'string' + ? expanded.payment_intent + : expanded.payment_intent?.id; + if (!paymentId) { + throw new Error(`handleCheckoutCompleted: session ${session.id} has no payment_intent`); } - const sub = await deps.stripe.subscriptions.retrieve(subId); - const expiresAt = (sub as any).current_period_end - ? new Date((sub as any).current_period_end * 1000) - : new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); - const customerId = typeof expanded.customer === 'string' ? expanded.customer : expanded.customer?.id; + const customerId = typeof expanded.customer === 'string' + ? expanded.customer + : expanded.customer?.id; if (!customerId) { - throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer`); + throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer (customer_creation: 'always' must be set on the Checkout session)`); } + const email = expanded.customer_details?.email; if (!email) { throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer email`); } - const token = await deps.mintToken( - { stripeCustomerId: customerId, tier, seats, expiresAt }, - deps.privateKeyHex, - ); - - await deps.upsertLicense(deps.db, { - stripeCustomerId: customerId, - stripeSubscriptionId: subId, - customerEmail: email, - tier, - seats, - expiresAt, - lastToken: token, - }); - - await deps.sendLicenseEmail({ - resendApiKey: deps.resendApiKey, - from: deps.emailFrom, - to: email, - vars: { tier, seats, token, expiresAt }, - }); -} - -export async function handleSubscriptionUpdated( - sub: Stripe.Subscription, - deps: HandlerDeps, -): Promise { - const lineItem = sub.items?.data?.[0]; - if (!lineItem) { - throw new Error(`handleSubscriptionUpdated: subscription ${sub.id} has no items`); - } - const priceMetadata = (lineItem.price?.metadata ?? {}) as Record; - const tier = extractTier(priceMetadata); - const seats = computeSeats(tier, lineItem.quantity); - const currentPeriodEnd = (sub as any).current_period_end as number | undefined; - const expiresAt = currentPeriodEnd - ? new Date(currentPeriodEnd * 1000) - : new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); - const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; - if (!customerId) { - throw new Error(`handleSubscriptionUpdated: subscription ${sub.id} has no customer`); - } - - const existing = await deps.getLicense(deps.db, sub.id); - - const claimsUnchanged = - existing !== null && - existing.tier === tier && - existing.seats === seats && - existing.expiresAt.getTime() === expiresAt.getTime(); - - // Email source: prefer existing license (captured at checkout), else pull - // from Stripe customer. - let email = existing?.customerEmail; - if (!email) { - const customer = await deps.stripe.customers.retrieve(customerId); - if ('deleted' in customer && customer.deleted) { - throw new Error(`handleSubscriptionUpdated: customer ${customerId} is deleted`); - } - email = (customer as Stripe.Customer).email ?? undefined; - if (!email) { - throw new Error(`handleSubscriptionUpdated: no email for customer ${customerId}`); - } - } - - if (claimsUnchanged && existing) { - await deps.upsertLicense(deps.db, { - stripeCustomerId: existing.stripeCustomerId, - stripeSubscriptionId: existing.stripeSubscriptionId, - customerEmail: existing.customerEmail, - tier: existing.tier, - seats: existing.seats, - expiresAt: existing.expiresAt, - lastToken: existing.lastToken, - }); - return; - } + const expiresAt = new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); const token = await deps.mintToken( { stripeCustomerId: customerId, tier, seats, expiresAt }, @@ -177,7 +106,7 @@ export async function handleSubscriptionUpdated( await deps.upsertLicense(deps.db, { stripeCustomerId: customerId, - stripeSubscriptionId: sub.id, + stripePaymentId: paymentId, customerEmail: email, tier, seats, @@ -192,10 +121,3 @@ export async function handleSubscriptionUpdated( vars: { tier, seats, token, expiresAt }, }); } - -export async function handleSubscriptionDeleted( - sub: Stripe.Subscription, - deps: HandlerDeps, -): Promise { - await deps.revokeLicense(deps.db, sub.id); -} From b5c481b75858f8f32b787437739e1dcb4145195a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 11:57:55 -0700 Subject: [PATCH 08/11] test(minting): cover one-time payment handler + skip subscription mode 9 tests: - handleEvent dispatches to checkout.session.completed - handleEvent no-ops on subscription events (now unhandled) - compensating delete on handler throw - handleCheckoutCompleted mints+upserts+emails on payment mode - handleCheckoutCompleted skips subscription mode without minting - throws on missing payment_intent, customer, or customer email Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/minting-service/src/lib/handlers.spec.ts | 342 +++++++----------- 1 file changed, 130 insertions(+), 212 deletions(-) diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts index 17aaae686..88f175bb8 100644 --- a/apps/minting-service/src/lib/handlers.spec.ts +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -1,22 +1,22 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; import type Stripe from 'stripe'; -import type { License } from '@ngaf/db'; -import { handleEvent, type HandlerDeps } from './handlers.js'; +import { handleEvent, handleCheckoutCompleted, type HandlerDeps } from './handlers.js'; function makeDeps(overrides: Partial = {}): HandlerDeps { return { - db: {} as any, - stripe: {} as any, + db: {} as never, + stripe: {} as never, markEventProcessed: vi.fn().mockResolvedValue(true), deleteProcessedEvent: vi.fn().mockResolvedValue(undefined), upsertLicense: vi.fn(), getLicense: vi.fn(), revokeLicense: vi.fn(), - mintToken: vi.fn(), - sendLicenseEmail: vi.fn(), + mintToken: vi.fn().mockResolvedValue('mock.token'), + sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_mock' }), privateKeyHex: 'a'.repeat(64), resendApiKey: 're_test', - emailFrom: 'a@b.c', + emailFrom: 'noreply@example.com', defaultTtlDays: 365, ...overrides, }; @@ -26,243 +26,161 @@ function evt(type: string, obj: unknown = {}): Stripe.Event { return { id: `evt_${type}`, type, data: { object: obj } } as Stripe.Event; } +function paymentSession(overrides: Partial = {}): Stripe.Checkout.Session { + return { + id: 'cs_test_123', + mode: 'payment', + payment_intent: 'pi_test_123', + customer: 'cus_test_123', + customer_details: { email: 'buyer@example.com' } as Stripe.Checkout.Session.CustomerDetails, + line_items: { + data: [ + { + quantity: 1, + price: { + metadata: { cacheplane_tier: 'developer-seat' }, + } as Stripe.Price, + } as Stripe.LineItem, + ], + } as Stripe.ApiList, + ...overrides, + } as Stripe.Checkout.Session; +} + describe('handleEvent', () => { it('returns early if markEventProcessed returns false (duplicate)', async () => { const deps = makeDeps({ markEventProcessed: vi.fn().mockResolvedValue(false), }); - await handleEvent(evt('customer.subscription.deleted', { id: 'sub_x' }), deps); - expect(deps.revokeLicense).not.toHaveBeenCalled(); + await handleEvent(evt('checkout.session.completed', { id: 'cs_x' }), deps); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); }); - it('no-ops on unknown event types', async () => { + it('no-ops on unknown event types (including subscription events)', async () => { const deps = makeDeps(); + await handleEvent(evt('customer.subscription.updated'), deps); + await handleEvent(evt('customer.subscription.deleted'), deps); await handleEvent(evt('invoice.payment_succeeded'), deps); - expect(deps.revokeLicense).not.toHaveBeenCalled(); expect(deps.upsertLicense).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); }); - it('compensating-deletes the processed-event marker when handler throws', async () => { - const boom = new Error('boom'); - const deps = makeDeps({ - revokeLicense: vi.fn().mockRejectedValue(boom), - }); - await expect( - handleEvent(evt('customer.subscription.deleted', { id: 'sub_boom' }), deps), - ).rejects.toBe(boom); - expect(deps.deleteProcessedEvent).toHaveBeenCalledWith(deps.db, 'evt_customer.subscription.deleted'); - }); -}); - -describe('handleCheckoutCompleted', () => { - function baseSession(overrides: any = {}): Stripe.Checkout.Session { - return { - id: 'cs_test', - customer: 'cus_x', - subscription: 'sub_x', - customer_details: { email: 'a@b.c' }, - ...overrides, - } as Stripe.Checkout.Session; - } - - function baseDeps(): HandlerDeps { - const lineItem = { - data: [ - { - quantity: 2, - price: { metadata: { cacheplane_tier: 'developer-seat' } }, - }, - ], - }; - const sub = { current_period_end: 1_800_000_000, id: 'sub_x' }; - const expandedSession = baseSession({ line_items: lineItem }); - - return makeDeps({ - stripe: { - checkout: { - sessions: { - retrieve: vi.fn().mockResolvedValue(expandedSession), - }, - }, - subscriptions: { - retrieve: vi.fn().mockResolvedValue(sub), - }, - } as any, - mintToken: vi.fn().mockResolvedValue('TOKEN.SIG'), - upsertLicense: vi.fn().mockImplementation((_db, input) => - Promise.resolve({ ...input, id: 'lic_1', createdAt: new Date(), updatedAt: new Date(), issuedAt: new Date(), revokedAt: null }), - ), - sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_1' }), - }); - } - - it('upserts a license row and sends an email', async () => { - const deps = baseDeps(); - await handleEvent( - { id: 'evt_co', type: 'checkout.session.completed', data: { object: baseSession() } } as Stripe.Event, - deps, - ); + it('dispatches checkout.session.completed to handleCheckoutCompleted', async () => { + const session = paymentSession(); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await handleEvent(evt('checkout.session.completed', session), deps); + expect(stripe.checkout.sessions.retrieve).toHaveBeenCalledWith('cs_test_123', expect.any(Object)); expect(deps.upsertLicense).toHaveBeenCalledTimes(1); - const upsertArg = (deps.upsertLicense as unknown as { mock: { calls: any[][] } }).mock.calls[0][1]; - expect(upsertArg.stripeSubscriptionId).toBe('sub_x'); - expect(upsertArg.tier).toBe('developer-seat'); - expect(upsertArg.seats).toBe(2); - expect(upsertArg.customerEmail).toBe('a@b.c'); - expect(upsertArg.lastToken).toBe('TOKEN.SIG'); expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); }); - it('throws when cacheplane_tier is missing from price metadata', async () => { - const deps = baseDeps(); - (deps.stripe.checkout.sessions.retrieve as unknown as ReturnType).mockResolvedValueOnce( - baseSession({ line_items: { data: [{ quantity: 1, price: { metadata: {} } }] } }), - ); + it('compensating-deletes the processed-event marker when handler throws', async () => { + const session = paymentSession({ payment_intent: null }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); await expect( - handleEvent( - { id: 'evt_co2', type: 'checkout.session.completed', data: { object: baseSession() } } as Stripe.Event, - deps, - ), - ).rejects.toThrow(/cacheplane_tier/); - expect(deps.deleteProcessedEvent).toHaveBeenCalledWith(deps.db, 'evt_co2'); + handleEvent(evt('checkout.session.completed', session), deps), + ).rejects.toThrow(/no payment_intent/); + expect(deps.deleteProcessedEvent).toHaveBeenCalledTimes(1); }); }); -describe('handleSubscriptionUpdated', () => { - function sub(overrides: any = {}): Stripe.Subscription { - return { - id: 'sub_u', - customer: 'cus_u', - current_period_end: 1_800_000_000, - items: { - data: [ - { - quantity: 3, - price: { metadata: { cacheplane_tier: 'developer-seat' } }, - }, - ], +describe('handleCheckoutCompleted', () => { + it('mints, upserts, and emails on a complete one-time payment session', async () => { + const session = paymentSession(); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, }, - ...overrides, - } as Stripe.Subscription; - } - - function existingLicense(overrides: Partial = {}): License { - return { - id: 'lic_u', - stripeCustomerId: 'cus_u', - stripeSubscriptionId: 'sub_u', - customerEmail: 'u@example.com', - tier: 'developer-seat', - seats: 3, - expiresAt: new Date(1_800_000_000 * 1000), - revokedAt: null, - lastToken: 'OLD.TOKEN', - issuedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - } as License; - } - - function deps(license: License | null): HandlerDeps { - return makeDeps({ - getLicense: vi.fn().mockResolvedValue(license), - upsertLicense: vi.fn().mockImplementation((_db, input) => - Promise.resolve({ ...(license ?? {}), ...input, id: 'lic_u', createdAt: new Date(), updatedAt: new Date(), issuedAt: new Date(), revokedAt: null }), - ), - mintToken: vi.fn().mockResolvedValue('NEW.TOKEN'), - sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_u' }), - stripe: { - checkout: { sessions: { retrieve: vi.fn() } }, - subscriptions: { retrieve: vi.fn() }, - } as any, - }); - } - - it('upserts without minting or emailing when claims are unchanged', async () => { - const d = deps(existingLicense()); - await handleEvent( - { id: 'evt_u_noop', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, - d, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await handleCheckoutCompleted(session, deps); + + expect(deps.mintToken).toHaveBeenCalledWith( + expect.objectContaining({ + stripeCustomerId: 'cus_test_123', + tier: 'developer-seat', + seats: 1, + expiresAt: expect.any(Date), + }), + 'a'.repeat(64), ); - expect(d.mintToken).not.toHaveBeenCalled(); - expect(d.sendLicenseEmail).not.toHaveBeenCalled(); - expect(d.upsertLicense).toHaveBeenCalledTimes(1); - const arg = (d.upsertLicense as unknown as { mock: { calls: any[][] } }).mock.calls[0][1]; - expect(arg.lastToken).toBe('OLD.TOKEN'); - }); - - it('mints and emails when seats change', async () => { - const d = deps(existingLicense({ seats: 2 })); - await handleEvent( - { id: 'evt_u_seats', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, - d, + expect(deps.upsertLicense).toHaveBeenCalledWith( + {} as never, + expect.objectContaining({ + stripeCustomerId: 'cus_test_123', + stripePaymentId: 'pi_test_123', + customerEmail: 'buyer@example.com', + tier: 'developer-seat', + seats: 1, + lastToken: 'mock.token', + }), ); - expect(d.mintToken).toHaveBeenCalledTimes(1); - expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); - const arg = (d.upsertLicense as unknown as { mock: { calls: any[][] } }).mock.calls[0][1]; - expect(arg.lastToken).toBe('NEW.TOKEN'); - expect(arg.seats).toBe(3); - }); - - it('mints and emails when tier changes', async () => { - const d = deps(existingLicense({ tier: 'app-deployment', seats: 1 })); - await handleEvent( - { id: 'evt_u_tier', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, - d, + expect(deps.sendLicenseEmail).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'noreply@example.com', + to: 'buyer@example.com', + vars: expect.objectContaining({ tier: 'developer-seat', seats: 1, token: 'mock.token' }), + }), ); - expect(d.mintToken).toHaveBeenCalledTimes(1); - expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); }); - it('mints and emails when expires_at changes', async () => { - const d = deps(existingLicense({ expiresAt: new Date(1_700_000_000 * 1000) })); - await handleEvent( - { id: 'evt_u_exp', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, - d, - ); - expect(d.mintToken).toHaveBeenCalledTimes(1); - expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); + it('skips subscription-mode sessions without minting', async () => { + const session = paymentSession({ mode: 'subscription' }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await handleCheckoutCompleted(session, deps); + expect(deps.mintToken).not.toHaveBeenCalled(); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); }); - it('mints and emails when no existing license is found (first time)', async () => { - const d = deps(null); - (d.stripe as any).customers = { - retrieve: vi.fn().mockResolvedValue({ email: 'new@example.com' }), - }; - await handleEvent( - { id: 'evt_u_new', type: 'customer.subscription.updated', data: { object: sub() } } as Stripe.Event, - d, - ); - expect(d.mintToken).toHaveBeenCalledTimes(1); - expect(d.sendLicenseEmail).toHaveBeenCalledTimes(1); - const sendArg = (d.sendLicenseEmail as unknown as { mock: { calls: any[][] } }).mock.calls[0][0]; - expect(sendArg.to).toBe('new@example.com'); + it('throws when the session has no payment_intent', async () => { + const session = paymentSession({ payment_intent: null }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no payment_intent/); }); -}); -describe('handleSubscriptionDeleted', () => { - it('calls revokeLicense and does not email or mint', async () => { - const d = makeDeps({ - revokeLicense: vi.fn().mockResolvedValue({ id: 'lic_d' }), - }); - await handleEvent( - { id: 'evt_del', type: 'customer.subscription.deleted', data: { object: { id: 'sub_d' } } } as Stripe.Event, - d, - ); - expect(d.revokeLicense).toHaveBeenCalledWith(d.db, 'sub_d'); - expect(d.mintToken).not.toHaveBeenCalled(); - expect(d.sendLicenseEmail).not.toHaveBeenCalled(); + it('throws when the session has no customer', async () => { + const session = paymentSession({ customer: null }); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/customer_creation/); }); - it('is idempotent — no throw if license is already absent', async () => { - const d = makeDeps({ - revokeLicense: vi.fn().mockResolvedValue(null), + it('throws when the session has no customer email', async () => { + const session = paymentSession({ + customer_details: { email: null } as Stripe.Checkout.Session.CustomerDetails, }); - await expect( - handleEvent( - { id: 'evt_del2', type: 'customer.subscription.deleted', data: { object: { id: 'sub_nope' } } } as Stripe.Event, - d, - ), - ).resolves.toBeUndefined(); + const stripe = { + checkout: { + sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + }, + } as unknown as Stripe; + const deps = makeDeps({ stripe }); + await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no customer email/); }); }); From d6c0521e5b1f0d0c78ea3be370c7bf52bcfc2849 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 12:04:08 -0700 Subject: [PATCH 09/11] refactor(tier): align slug convention to PR B-Stripe (ngaf_tier_slug / underscored) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minting service tier extractor was using metadata.cacheplane_tier with values 'developer-seat'/'app-deployment'. PR B-Stripe ships Stripe products with metadata.ngaf_tier_slug = 'indie' | 'developer_seat' | 'app_deployment'. The two were silently incompatible — smoke tests would fail at extractTier. This commit aligns everything to the customer-visible convention: - MintableTier widened to include 'indie' - LicenseTier values switch to underscored - tier.ts reads metadata.ngaf_tier_slug - Test fixtures across libs/licensing and apps/minting-service updated - computeSeats handles indie + app_deployment as flat 1, developer_seat as Stripe quantity No published tokens exist yet — safe rename. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/minting-service/scripts/remint.spec.ts | 2 +- apps/minting-service/scripts/remint.ts | 4 +-- apps/minting-service/src/lib/email.spec.ts | 14 ++++---- apps/minting-service/src/lib/handlers.spec.ts | 8 ++--- apps/minting-service/src/lib/sign.spec.ts | 6 ++-- apps/minting-service/src/lib/tier.spec.ts | 36 +++++++++++-------- apps/minting-service/src/lib/tier.ts | 24 +++++++------ .../src/lib/evaluate-license.spec.ts | 2 +- libs/licensing/src/lib/license-token.spec.ts | 2 +- libs/licensing/src/lib/license-token.ts | 7 ++-- .../src/lib/run-license-check.spec.ts | 2 +- libs/licensing/src/lib/sign-license.spec.ts | 6 ++-- libs/licensing/src/lib/testing/fixtures.ts | 2 +- libs/licensing/src/lib/verify-license.spec.ts | 2 +- 14 files changed, 65 insertions(+), 52 deletions(-) diff --git a/apps/minting-service/scripts/remint.spec.ts b/apps/minting-service/scripts/remint.spec.ts index 18d2e94d2..ad6ee04c8 100644 --- a/apps/minting-service/scripts/remint.spec.ts +++ b/apps/minting-service/scripts/remint.spec.ts @@ -8,7 +8,7 @@ function makeLicense(overrides: Partial = {}): License { stripeCustomerId: 'cus_1', stripeSubscriptionId: 'sub_1', customerEmail: 'a@example.com', - tier: 'developer-seat', + tier: 'developer_seat', seats: 3, expiresAt: new Date('2027-01-01T00:00:00Z'), revokedAt: null, diff --git a/apps/minting-service/scripts/remint.ts b/apps/minting-service/scripts/remint.ts index 9162bac6f..f98f6a4b1 100644 --- a/apps/minting-service/scripts/remint.ts +++ b/apps/minting-service/scripts/remint.ts @@ -57,7 +57,7 @@ export async function runRemint(args: RemintArgs, deps: RemintDeps): Promise { it('includes the token wrapped in BEGIN/END delimiters in the text body', () => { const out = renderLicenseEmail({ - tier: 'developer-seat', + tier: 'developer_seat', seats: 3, token: 'PAYLOAD.SIG', expiresAt: new Date('2027-04-20T00:00:00Z'), @@ -17,27 +17,27 @@ describe('renderLicenseEmail', () => { it('subject includes tier and seat count with plural s for seats > 1', () => { const out = renderLicenseEmail({ - tier: 'developer-seat', + tier: 'developer_seat', seats: 3, token: 't.s', expiresAt: new Date('2027-04-20T00:00:00Z'), }); - expect(out.subject).toBe('Your ThreadPlane license — developer-seat (3 seats)'); + expect(out.subject).toBe('Your ThreadPlane license — developer_seat (3 seats)'); }); it('subject uses singular seat for seats === 1', () => { const out = renderLicenseEmail({ - tier: 'app-deployment', + tier: 'app_deployment', seats: 1, token: 't.s', expiresAt: new Date('2027-04-20T00:00:00Z'), }); - expect(out.subject).toBe('Your ThreadPlane license — app-deployment (1 seat)'); + expect(out.subject).toBe('Your ThreadPlane license — app_deployment (1 seat)'); }); it('includes ISO 8601 UTC expiry in text body', () => { const out = renderLicenseEmail({ - tier: 'developer-seat', + tier: 'developer_seat', seats: 1, token: 't.s', expiresAt: new Date('2027-04-20T00:00:00Z'), @@ -47,7 +47,7 @@ describe('renderLicenseEmail', () => { it('html body wraps the token in a monospace pre block', () => { const out = renderLicenseEmail({ - tier: 'developer-seat', + tier: 'developer_seat', seats: 1, token: 'PAYLOAD.SIG', expiresAt: new Date('2027-04-20T00:00:00Z'), diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts index 88f175bb8..ea57f8f3e 100644 --- a/apps/minting-service/src/lib/handlers.spec.ts +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -38,7 +38,7 @@ function paymentSession(overrides: Partial = {}): Strip { quantity: 1, price: { - metadata: { cacheplane_tier: 'developer-seat' }, + metadata: { ngaf_tier_slug: 'indie' }, } as Stripe.Price, } as Stripe.LineItem, ], @@ -109,7 +109,7 @@ describe('handleCheckoutCompleted', () => { expect(deps.mintToken).toHaveBeenCalledWith( expect.objectContaining({ stripeCustomerId: 'cus_test_123', - tier: 'developer-seat', + tier: 'indie', seats: 1, expiresAt: expect.any(Date), }), @@ -121,7 +121,7 @@ describe('handleCheckoutCompleted', () => { stripeCustomerId: 'cus_test_123', stripePaymentId: 'pi_test_123', customerEmail: 'buyer@example.com', - tier: 'developer-seat', + tier: 'indie', seats: 1, lastToken: 'mock.token', }), @@ -130,7 +130,7 @@ describe('handleCheckoutCompleted', () => { expect.objectContaining({ from: 'noreply@example.com', to: 'buyer@example.com', - vars: expect.objectContaining({ tier: 'developer-seat', seats: 1, token: 'mock.token' }), + vars: expect.objectContaining({ tier: 'indie', seats: 1, token: 'mock.token' }), }), ); }); diff --git a/apps/minting-service/src/lib/sign.spec.ts b/apps/minting-service/src/lib/sign.spec.ts index 02eea2e52..433f4d86d 100644 --- a/apps/minting-service/src/lib/sign.spec.ts +++ b/apps/minting-service/src/lib/sign.spec.ts @@ -19,7 +19,7 @@ describe('mintToken', () => { const token = await mintToken( { stripeCustomerId: 'cus_abc', - tier: 'developer-seat', + tier: 'developer_seat', seats: 3, expiresAt: new Date('2027-01-01T00:00:00Z'), }, @@ -30,7 +30,7 @@ describe('mintToken', () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.claims.sub).toBe('cus_abc'); - expect(result.claims.tier).toBe('developer-seat'); + expect(result.claims.tier).toBe('developer_seat'); expect(result.claims.seats).toBe(3); expect(result.claims.exp).toBe(Math.floor(new Date('2027-01-01T00:00:00Z').getTime() / 1000)); expect(result.claims.iat).toBeGreaterThan(0); @@ -42,7 +42,7 @@ describe('mintToken', () => { mintToken( { stripeCustomerId: 'cus_x', - tier: 'app-deployment', + tier: 'app_deployment', seats: 1, expiresAt: new Date('2027-01-01T00:00:00Z'), }, diff --git a/apps/minting-service/src/lib/tier.spec.ts b/apps/minting-service/src/lib/tier.spec.ts index b9a042cae..274ecbf6a 100644 --- a/apps/minting-service/src/lib/tier.spec.ts +++ b/apps/minting-service/src/lib/tier.spec.ts @@ -2,20 +2,24 @@ import { extractTier, computeSeats } from './tier.js'; describe('extractTier', () => { - it('returns developer-seat from price metadata', () => { - expect(extractTier({ cacheplane_tier: 'developer-seat' })).toBe('developer-seat'); + it('returns indie from price metadata', () => { + expect(extractTier({ ngaf_tier_slug: 'indie' })).toBe('indie'); }); - it('returns app-deployment from price metadata', () => { - expect(extractTier({ cacheplane_tier: 'app-deployment' })).toBe('app-deployment'); + it('returns developer_seat from price metadata', () => { + expect(extractTier({ ngaf_tier_slug: 'developer_seat' })).toBe('developer_seat'); }); - it('throws when cacheplane_tier is missing', () => { - expect(() => extractTier({})).toThrow(/cacheplane_tier/); + it('returns app_deployment from price metadata', () => { + expect(extractTier({ ngaf_tier_slug: 'app_deployment' })).toBe('app_deployment'); }); - it('throws when cacheplane_tier is an unknown value', () => { - expect(() => extractTier({ cacheplane_tier: 'bogus' })).toThrow(/bogus/); + it('throws when ngaf_tier_slug is missing', () => { + expect(() => extractTier({})).toThrow(/ngaf_tier_slug/); + }); + + it('throws when ngaf_tier_slug is an unknown value', () => { + expect(() => extractTier({ ngaf_tier_slug: 'bogus' })).toThrow(/bogus/); }); it('throws when metadata is null', () => { @@ -24,15 +28,19 @@ describe('extractTier', () => { }); describe('computeSeats', () => { - it('returns the Stripe quantity for developer-seat', () => { - expect(computeSeats('developer-seat', 5)).toBe(5); + it('returns the Stripe quantity for developer_seat', () => { + expect(computeSeats('developer_seat', 5)).toBe(5); + }); + + it('returns 1 for app_deployment regardless of quantity', () => { + expect(computeSeats('app_deployment', 10)).toBe(1); }); - it('returns 1 for app-deployment regardless of quantity', () => { - expect(computeSeats('app-deployment', 10)).toBe(1); + it('returns 1 for indie regardless of quantity', () => { + expect(computeSeats('indie', 10)).toBe(1); }); - it('defaults developer-seat to 1 when quantity is null', () => { - expect(computeSeats('developer-seat', null)).toBe(1); + it('defaults developer_seat to 1 when quantity is null', () => { + expect(computeSeats('developer_seat', null)).toBe(1); }); }); diff --git a/apps/minting-service/src/lib/tier.ts b/apps/minting-service/src/lib/tier.ts index 27dee632c..ae593248e 100644 --- a/apps/minting-service/src/lib/tier.ts +++ b/apps/minting-service/src/lib/tier.ts @@ -1,34 +1,38 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import type { LicenseTier } from '@ngaf/licensing'; -export type MintableTier = Extract; +export type MintableTier = Extract; -const VALID_TIERS: readonly MintableTier[] = ['developer-seat', 'app-deployment'] as const; +const VALID_TIERS: readonly MintableTier[] = ['indie', 'developer_seat', 'app_deployment'] as const; +const METADATA_KEY = 'ngaf_tier_slug'; /** - * Extract the ThreadPlane tier from a Stripe price metadata bag. + * Extract the tier slug from a Stripe price metadata bag. * Throws if the field is missing or holds an unknown value. */ export function extractTier(metadata: Record | null | undefined): MintableTier { if (!metadata) { throw new Error('extractTier: price metadata is missing'); } - const raw = metadata['cacheplane_tier']; + const raw = metadata[METADATA_KEY]; if (!raw) { - throw new Error('extractTier: metadata.cacheplane_tier is missing'); + throw new Error(`extractTier: metadata.${METADATA_KEY} is missing`); } if (!VALID_TIERS.includes(raw as MintableTier)) { - throw new Error(`extractTier: unknown cacheplane_tier value: ${raw}`); + throw new Error(`extractTier: unknown ${METADATA_KEY} value: ${raw}`); } return raw as MintableTier; } /** * Compute the `seats` claim from the Stripe line-item quantity. - * - developer-seat: tracks Stripe quantity (minimum 1). - * - app-deployment: always 1. + * - developer_seat: tracks Stripe quantity (minimum 1). + * - indie: always 1. + * - app_deployment: always 1. */ export function computeSeats(tier: MintableTier, quantity: number | null | undefined): number { - if (tier === 'app-deployment') return 1; - return quantity && quantity > 0 ? quantity : 1; + if (tier === 'developer_seat') { + return quantity && quantity > 0 ? quantity : 1; + } + return 1; } diff --git a/libs/licensing/src/lib/evaluate-license.spec.ts b/libs/licensing/src/lib/evaluate-license.spec.ts index 4cae53871..de1a247fa 100644 --- a/libs/licensing/src/lib/evaluate-license.spec.ts +++ b/libs/licensing/src/lib/evaluate-license.spec.ts @@ -5,7 +5,7 @@ import type { LicenseClaims } from './license-token'; const CLAIMS: LicenseClaims = { sub: 'cus_123', - tier: 'developer-seat', + tier: 'developer_seat', iat: 1_700_000_000, exp: 2_000_000_000, // far future seats: 5, diff --git a/libs/licensing/src/lib/license-token.spec.ts b/libs/licensing/src/lib/license-token.spec.ts index 1f45c989f..82f19fe7f 100644 --- a/libs/licensing/src/lib/license-token.spec.ts +++ b/libs/licensing/src/lib/license-token.spec.ts @@ -4,7 +4,7 @@ import { parseLicenseToken } from './license-token'; const CLAIMS = { sub: 'cus_123', - tier: 'developer-seat', + tier: 'developer_seat', iat: 1_700_000_000, exp: 1_800_000_000, seats: 5, diff --git a/libs/licensing/src/lib/license-token.ts b/libs/licensing/src/lib/license-token.ts index 68912fd27..7d4575766 100644 --- a/libs/licensing/src/lib/license-token.ts +++ b/libs/licensing/src/lib/license-token.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT /** The tier a license grants. */ -export type LicenseTier = 'developer-seat' | 'app-deployment' | 'enterprise'; +export type LicenseTier = 'indie' | 'developer_seat' | 'app_deployment' | 'enterprise'; /** Claims carried inside a signed license token. */ export interface LicenseClaims { @@ -52,8 +52,9 @@ function isLicenseClaims(value: unknown): value is LicenseClaims { const seats = v['seats']; return ( typeof v['sub'] === 'string' && - (tier === 'developer-seat' || - tier === 'app-deployment' || + (tier === 'indie' || + tier === 'developer_seat' || + tier === 'app_deployment' || tier === 'enterprise') && typeof v['iat'] === 'number' && typeof v['exp'] === 'number' && diff --git a/libs/licensing/src/lib/run-license-check.spec.ts b/libs/licensing/src/lib/run-license-check.spec.ts index 3e6715bd7..49a55b687 100644 --- a/libs/licensing/src/lib/run-license-check.spec.ts +++ b/libs/licensing/src/lib/run-license-check.spec.ts @@ -8,7 +8,7 @@ import type { LicenseClaims } from './license-token'; const BASE: LicenseClaims = { sub: 'cus_abc', - tier: 'developer-seat', + tier: 'developer_seat', iat: 1_700_000_000, exp: 2_000_000_000, seats: 1, diff --git a/libs/licensing/src/lib/sign-license.spec.ts b/libs/licensing/src/lib/sign-license.spec.ts index b2d3892d8..d017085f4 100644 --- a/libs/licensing/src/lib/sign-license.spec.ts +++ b/libs/licensing/src/lib/sign-license.spec.ts @@ -10,7 +10,7 @@ describe('signLicense', () => { const publicKey = await ed.getPublicKeyAsync(privateKey); const claims: LicenseClaims = { sub: 'cus_test_123', - tier: 'developer-seat', + tier: 'developer_seat', iat: 1_700_000_000, exp: 1_800_000_000, seats: 5, @@ -29,7 +29,7 @@ describe('signLicense', () => { const privateKey = ed.utils.randomPrivateKey(); const claims: LicenseClaims = { sub: 'cus_abc', - tier: 'app-deployment', + tier: 'app_deployment', iat: 1_700_000_000, exp: 1_800_000_000, seats: 1, @@ -49,7 +49,7 @@ describe('signLicense', () => { const pk2 = await ed.getPublicKeyAsync(sk2); const claims: LicenseClaims = { sub: 'cus_x', - tier: 'developer-seat', + tier: 'developer_seat', iat: 1_700_000_000, exp: 1_800_000_000, seats: 1, diff --git a/libs/licensing/src/lib/testing/fixtures.ts b/libs/licensing/src/lib/testing/fixtures.ts index 233405367..452d54f13 100644 --- a/libs/licensing/src/lib/testing/fixtures.ts +++ b/libs/licensing/src/lib/testing/fixtures.ts @@ -16,7 +16,7 @@ export async function buildFixturePack(): Promise { const kp = await generateKeyPair(); const baseClaims: LicenseClaims = { sub: 'cus_fixture', - tier: 'developer-seat', + tier: 'developer_seat', iat: 1_700_000_000, exp: 2_000_000_000, seats: 1, diff --git a/libs/licensing/src/lib/verify-license.spec.ts b/libs/licensing/src/lib/verify-license.spec.ts index 020dd3af1..b37655fb2 100644 --- a/libs/licensing/src/lib/verify-license.spec.ts +++ b/libs/licensing/src/lib/verify-license.spec.ts @@ -7,7 +7,7 @@ import type { LicenseClaims } from './license-token'; const BASE_CLAIMS: LicenseClaims = { sub: 'cus_123', - tier: 'developer-seat', + tier: 'developer_seat', iat: 1_700_000_000, exp: 1_800_000_000, seats: 5, From eeeccd93a51eaa352bda28ba30af7a3b8e2b139a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 12:04:52 -0700 Subject: [PATCH 10/11] feat(website): always create a Stripe customer at Checkout customer_creation: 'always' guarantees the resulting checkout.session.completed event carries a customer field, which the minting service requires to write a license record (stripe_customer_id is NOT NULL). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/website/src/app/api/checkout/session/route.spec.ts | 1 + apps/website/src/app/api/checkout/session/route.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/website/src/app/api/checkout/session/route.spec.ts b/apps/website/src/app/api/checkout/session/route.spec.ts index af534c8d2..cc61e1fa0 100644 --- a/apps/website/src/app/api/checkout/session/route.spec.ts +++ b/apps/website/src/app/api/checkout/session/route.spec.ts @@ -54,6 +54,7 @@ describe('POST /api/checkout/session', () => { expect(stripeCreate).toHaveBeenCalledTimes(1); const args = stripeCreate.mock.calls[0]?.[0]; expect(args.mode).toBe('payment'); + expect(args.customer_creation).toBe('always'); expect(args.line_items[0].price).toBe('price_test_indie'); expect(args.line_items[0].quantity).toBe(1); expect(args.metadata.ngaf_tier_slug).toBe('indie'); diff --git a/apps/website/src/app/api/checkout/session/route.ts b/apps/website/src/app/api/checkout/session/route.ts index a02574896..7362a8629 100644 --- a/apps/website/src/app/api/checkout/session/route.ts +++ b/apps/website/src/app/api/checkout/session/route.ts @@ -62,6 +62,7 @@ export async function POST(req: NextRequest) { const session = await stripe.checkout.sessions.create({ mode: 'payment', + customer_creation: 'always', line_items: [ { price: priceId, From 3ec305283f25d383ac35e3b2a697347fd029537d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 12:06:54 -0700 Subject: [PATCH 11/11] chore(eslint): ignore .vercel build artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vercel build emits transpiled .vercel/output/functions/api/*/index.js files that use legacy var/etc syntax. Those are vendor build output, not source — exclude them from lint. Co-Authored-By: Claude Opus 4.7 (1M context) --- eslint.config.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 69024a5d5..ef41ef068 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,8 @@ export default [ '**/out-tsc', '**/.next', '**/.next/**', + '**/.vercel', + '**/.vercel/**', '**/next-env.d.ts', ], },