Skip to content

fix(minting): handle one-time-payment Checkout sessions + align tier slugs (PR D)#516

Merged
blove merged 11 commits into
mainfrom
claude/minting-one-time-payments
May 21, 2026
Merged

fix(minting): handle one-time-payment Checkout sessions + align tier slugs (PR D)#516
blove merged 11 commits into
mainfrom
claude/minting-one-time-payments

Conversation

@blove
Copy link
Copy Markdown
Contributor

@blove blove commented May 21, 2026

Summary

End-to-end smoke after PR #508 + #510 surfaced two real gaps that blocked Stripe → license-email flow:

  1. Minting service handler required subscription mode. PR B-Stripe locked one-time 12-month payments; handleCheckoutCompleted threw "session has no subscription" before minting.
  2. Tier slug + metadata-key mismatch. PR B-Stripe's sync-products.ts writes metadata.ngaf_tier_slug = indie|developer_seat|app_deployment. The minting service tier.ts was reading metadata.cacheplane_tier with hyphenated values (developer-seat/app-deployment).

This PR fixes both.

Changes

libs/db — rename licenses.stripe_subscription_idstripe_payment_id (TS + SQL + drizzle migration 0001_*.sql + journal + snapshot). The column was always "the unique Stripe-side reference"; the new name reflects that. DB is empty (SELECT count(*) FROM licenses → 0); migration is data-preserving regardless.

apps/minting-service — Strip handleSubscriptionUpdated/handleSubscriptionDeleted (committing to one-time-only per the brainstorm). Rewrite handleCheckoutCompleted for mode: 'payment':

  • payment_intent as the unique Stripe-side reference (stored in stripe_payment_id).
  • customer (always present thanks to PR D's customer_creation: 'always' setting).
  • customer_details.email for the recipient.
  • expiresAt = now + defaultTtlDays * 86400000 (365-day default from env).
  • Subscription event types now fall through to the default no-op.
  • Subscription-mode Checkout sessions are logged and skipped (defensive guard; shouldn't be received with PR B-Stripe's mode: 'payment').

Tier slug alignmenttier.ts reads metadata.ngaf_tier_slug. MintableTier = 'indie' | 'developer_seat' | 'app_deployment'. LicenseTier extended to include indie and switched to underscored convention (developer_seat, app_deployment). Test fixtures across libs/licensing and minting-service updated. computeSeats: developer_seat tracks Stripe quantity; indie/app_deployment are flat 1.

apps/website/api/checkout/session passes customer_creation: 'always' so the resulting webhook event carries a customer ID (required by the minting handler).

eslint — Add **/.vercel/** to root ignore list (transpiled function bundles aren't source).

Test plan

  • npx nx run db:test → success
  • npx nx run licensing:test → success
  • npx nx run minting-service:test → success (9 handler tests + existing licenses/tier/email/sign tests)
  • cd apps/website && npx vitest run → 17 files, 72 tests passing
  • npx nx run minting-service:build → success
  • npx nx run website:build → success
  • All three lints clean
  • Scope: only libs/db/, libs/licensing/, apps/minting-service/, apps/website/src/app/api/checkout/, eslint.config.mjs, and docs/superpowers/ touched.

Operational steps for post-merge

The drizzle migration must run against the Neon DB. Two paths:

Option A — apply via Node directly (works now; no drizzle-kit setup):
```
set -a && source ~/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');
})()"
```

Option B — drizzle-kit push (requires drizzle-kit configured): npx drizzle-kit push --config libs/db/drizzle.config.ts

After the migration runs:

  1. CI's minting-deploy fires on this PR's merge → new minting build picks up handler changes.
  2. Re-run smoke: stripe trigger checkout.session.completed --api-key $STRIPE_SECRET_KEY --override "checkout_session:metadata.ngaf_tier_slug=indie"
  3. Confirm Resend send via API; confirm DB row written.

Risks

  • Migration ordering. If the minting-service deploy beats the DB migration, the new code will fail on upsertLicense (column doesn't exist yet). Empty DB means no data loss either way; just an interim error window.
  • Stripe products' price metadata. pricing/tiers.generated.ts is already populated with prices keyed on ngaf_tier_slug (PR chore(stripe): sync test-mode product + price IDs #513). The minting service now reads the same key. End-to-end aligned.
  • Token verify in @ngaf/chat. Separate concern — needs the prod public key to be bundled, which is PR C's responsibility (already merged but the website's published @ngaf/chat won't ship the prod key until the next publish run). Not blocking this PR.

🤖 Generated with Claude Code

blove and others added 11 commits May 21, 2026 11:46
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…nt_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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…/ underscored)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
threadplane Ready Ready Preview, Comment May 21, 2026 7:09pm

Request Review

@blove blove enabled auto-merge (squash) May 21, 2026 19:07
@blove blove merged commit 99be5aa into main May 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant