fix(minting): handle one-time-payment Checkout sessions + align tier slugs (PR D)#516
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end smoke after PR #508 + #510 surfaced two real gaps that blocked Stripe → license-email flow:
handleCheckoutCompletedthrew"session has no subscription"before minting.sync-products.tswritesmetadata.ngaf_tier_slug = indie|developer_seat|app_deployment. The minting servicetier.tswas readingmetadata.cacheplane_tierwith hyphenated values (developer-seat/app-deployment).This PR fixes both.
Changes
libs/db — rename
licenses.stripe_subscription_id→stripe_payment_id(TS + SQL + drizzle migration0001_*.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). RewritehandleCheckoutCompletedformode: 'payment':payment_intentas the unique Stripe-side reference (stored instripe_payment_id).customer(always present thanks to PR D'scustomer_creation: 'always'setting).customer_details.emailfor the recipient.expiresAt = now + defaultTtlDays * 86400000(365-day default from env).mode: 'payment').Tier slug alignment —
tier.tsreadsmetadata.ngaf_tier_slug.MintableTier = 'indie' | 'developer_seat' | 'app_deployment'.LicenseTierextended to includeindieand switched to underscored convention (developer_seat,app_deployment). Test fixtures acrosslibs/licensingand minting-service updated.computeSeats:developer_seattracks Stripe quantity;indie/app_deploymentare flat 1.apps/website —
/api/checkout/sessionpassescustomer_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→ successnpx nx run licensing:test→ successnpx 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 passingnpx nx run minting-service:build→ successnpx nx run website:build→ successlibs/db/,libs/licensing/,apps/minting-service/,apps/website/src/app/api/checkout/,eslint.config.mjs, anddocs/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.tsAfter the migration runs:
minting-deployfires on this PR's merge → new minting build picks up handler changes.stripe trigger checkout.session.completed --api-key $STRIPE_SECRET_KEY --override "checkout_session:metadata.ngaf_tier_slug=indie"Risks
upsertLicense(column doesn't exist yet). Empty DB means no data loss either way; just an interim error window.pricing/tiers.generated.tsis already populated with prices keyed onngaf_tier_slug(PR chore(stripe): sync test-mode product + price IDs #513). The minting service now reads the same key. End-to-end aligned.@ngaf/chatwon't ship the prod key until the next publish run). Not blocking this PR.🤖 Generated with Claude Code