create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,481 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* POST /api/webhooks/stripe
|
|
3
|
-
*
|
|
4
|
-
* Receives Stripe webhook events. Verifies the signature on the raw
|
|
5
|
-
* request body using the secret from STRIPE_WEBHOOK_SECRET. Dispatches
|
|
6
|
-
* verified events through a `switch (event.type)` statement.
|
|
7
|
-
*
|
|
8
|
-
* Error mapping (in priority order):
|
|
9
|
-
* 1. STRIPE_WEBHOOK_SECRET missing → 503 webhook-not-configured
|
|
10
|
-
* 2. Body is not a Buffer (parser miss) → 400 invalid-request
|
|
11
|
-
* 3. Stripe-Signature header missing → 400 signature-missing
|
|
12
|
-
* 4. Signature verification fails → 400 signature-invalid
|
|
13
|
-
*
|
|
14
|
-
* Successful dispatch always 200s — including unknown event types.
|
|
15
|
-
* Telemetry failures are caught, logged, and do NOT change the response.
|
|
16
|
-
*
|
|
17
|
-
* Billing upsert (customer.created / checkout.session.completed):
|
|
18
|
-
* Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
|
|
19
|
-
* carrying metadata.organizationId links the Organization (multi-tenant
|
|
20
|
-
* module); absent → the existing User path (single-tenant), byte-for-byte.
|
|
21
|
-
*
|
|
22
|
-
* Organization path (metadata.organizationId present):
|
|
23
|
-
* 1. Typed composite match already pointing at this org → idempotent no-op
|
|
24
|
-
* 2. Else link the target Organization (by id) billing fields
|
|
25
|
-
*
|
|
26
|
-
* User path (no organizationId — unchanged):
|
|
27
|
-
* 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customer)
|
|
28
|
-
* 2. Email fallback: User.findUnique({ where: { email } }) → update billing fields
|
|
29
|
-
* 3. No match → 200 + warn log; no User row created (pre-signup state)
|
|
30
|
-
*
|
|
31
|
-
* Recommender purchase stamp (checkout.session.completed / .expired, #4838):
|
|
32
|
-
* A session carrying metadata.flowCheckoutId (the FlowCheckout buyer row
|
|
33
|
-
* #4841 opened it with) is stamped `completed` / `expired` by ONE guarded
|
|
34
|
-
* `updateMany` on `{ id, status: "created" }` — the durable idempotency key
|
|
35
|
-
* (D1): a replay, a restart between deliveries, or a late `expired` after a
|
|
36
|
-
* `completed` all match zero rows and log `stale_or_unknown`. Runs BESIDE the
|
|
37
|
-
* telemetry + billing tasks (D4), never instead of them; a charged/quoted
|
|
38
|
-
* mismatch is logged, never reconciled by overwrite (D2). Always 200. A
|
|
39
|
-
* `completed` session stamps only when `payment_status` is `"paid"` or
|
|
40
|
-
* `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
|
|
41
|
-
* logs `outcome: "unpaid"` and leaves the row `created`.
|
|
42
|
-
*/
|
|
43
|
-
import { track } from "@working-theory/analytics/server";
|
|
44
|
-
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
45
|
-
|
|
46
|
-
import { config } from "../../config/index.js";
|
|
47
|
-
import { prisma } from "../../lib/prisma.js";
|
|
48
|
-
import {
|
|
49
|
-
StripeWebhookSignatureError,
|
|
50
|
-
verifyWebhookSignature,
|
|
51
|
-
} from "../../lib/stripe.js";
|
|
52
|
-
import { hasSeen, markSeen } from "../../lib/webhook-idempotency.js";
|
|
53
|
-
|
|
54
|
-
const TELEMETRY_TIMEOUT_MS = 2000;
|
|
55
|
-
|
|
56
|
-
export const stripeWebhookHandler = async (
|
|
57
|
-
request: FastifyRequest,
|
|
58
|
-
reply: FastifyReply,
|
|
59
|
-
): Promise<void> => {
|
|
60
|
-
const secret = config.STRIPE_WEBHOOK_SECRET;
|
|
61
|
-
if (!secret) {
|
|
62
|
-
request.log.warn("stripe webhook hit but STRIPE_WEBHOOK_SECRET is not set");
|
|
63
|
-
await reply.code(503).send({ error: "webhook-not-configured" });
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const rawBody = request.body;
|
|
68
|
-
if (!Buffer.isBuffer(rawBody)) {
|
|
69
|
-
request.log.error(
|
|
70
|
-
"stripe webhook body is not a Buffer — content-type parser misconfigured",
|
|
71
|
-
);
|
|
72
|
-
await reply.code(400).send({ error: "invalid-request" });
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const signature = request.headers["stripe-signature"];
|
|
77
|
-
if (typeof signature !== "string" || signature.length === 0) {
|
|
78
|
-
await reply.code(400).send({ error: "signature-missing" });
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
let event;
|
|
83
|
-
try {
|
|
84
|
-
event = verifyWebhookSignature(rawBody, signature, secret);
|
|
85
|
-
} catch (err) {
|
|
86
|
-
if (err instanceof StripeWebhookSignatureError) {
|
|
87
|
-
request.log.warn(
|
|
88
|
-
{ reason: err.message },
|
|
89
|
-
"stripe webhook signature verification failed",
|
|
90
|
-
);
|
|
91
|
-
await reply.code(400).send({ error: "signature-invalid" });
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
throw err;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const eventIdPrefix = event.id.substring(0, 8);
|
|
98
|
-
request.log.info(
|
|
99
|
-
{ eventType: event.type, eventIdPrefix },
|
|
100
|
-
"stripe webhook event received",
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
// AILK-layer idempotency. Stripe guarantees at-least-once delivery and
|
|
104
|
-
// dashboard "resend" replays a previously-delivered event verbatim. Skip
|
|
105
|
-
// side effects on the second arrival; still 200 so Stripe stops retrying.
|
|
106
|
-
if (hasSeen(event.id)) {
|
|
107
|
-
request.log.info(
|
|
108
|
-
{ eventType: event.type, eventIdPrefix },
|
|
109
|
-
"duplicate stripe webhook event — idempotent short-circuit",
|
|
110
|
-
);
|
|
111
|
-
await reply.code(200).send({ received: true });
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
markSeen(event.id);
|
|
115
|
-
|
|
116
|
-
switch (event.type) {
|
|
117
|
-
case "customer.created": {
|
|
118
|
-
const customer = event.data.object as {
|
|
119
|
-
id: string;
|
|
120
|
-
email?: string | null;
|
|
121
|
-
metadata?: Record<string, string> | null;
|
|
122
|
-
};
|
|
123
|
-
// Billing-entity routing (spec D2): a multi-tenant checkout stamps the
|
|
124
|
-
// target Organization id in metadata.organizationId (an individual → their
|
|
125
|
-
// sentinel default org). Present → link the Organization; absent → the
|
|
126
|
-
// existing User path, byte-for-byte (single-tenant, degrade-clean).
|
|
127
|
-
const organizationId = customer.metadata?.["organizationId"] ?? null;
|
|
128
|
-
if (organizationId) {
|
|
129
|
-
await upsertOrgBillingIdentity(request, {
|
|
130
|
-
organizationId,
|
|
131
|
-
customerId: customer.id,
|
|
132
|
-
eventIdPrefix,
|
|
133
|
-
});
|
|
134
|
-
} else {
|
|
135
|
-
await upsertBillingIdentity(request, {
|
|
136
|
-
customerId: customer.id,
|
|
137
|
-
email: customer.email ?? null,
|
|
138
|
-
eventIdPrefix,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
case "checkout.session.completed": {
|
|
144
|
-
const session = event.data.object as CheckoutSessionObject;
|
|
145
|
-
const props = {
|
|
146
|
-
amount: session.amount_total ?? undefined,
|
|
147
|
-
currency: session.currency ?? undefined,
|
|
148
|
-
productId:
|
|
149
|
-
session.metadata?.["productId"] ??
|
|
150
|
-
session.metadata?.["product_id"] ??
|
|
151
|
-
undefined,
|
|
152
|
-
eventType: event.type,
|
|
153
|
-
eventIdPrefix,
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
// Billing-entity routing (spec D2): metadata.organizationId → Organization
|
|
157
|
-
// path; absent → the existing User path, unchanged (single-tenant).
|
|
158
|
-
const organizationId = session.metadata?.["organizationId"] ?? null;
|
|
159
|
-
let billingTask: Promise<void> = Promise.resolve();
|
|
160
|
-
if (session.customer) {
|
|
161
|
-
billingTask = organizationId
|
|
162
|
-
? upsertOrgBillingIdentity(request, {
|
|
163
|
-
organizationId,
|
|
164
|
-
customerId: session.customer,
|
|
165
|
-
eventIdPrefix,
|
|
166
|
-
})
|
|
167
|
-
: upsertBillingIdentity(request, {
|
|
168
|
-
customerId: session.customer,
|
|
169
|
-
email: session.customer_details?.email ?? null,
|
|
170
|
-
eventIdPrefix,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Telemetry, billing upsert and the recommender stamp are independent —
|
|
175
|
-
// run in parallel (the stamp is a no-op without metadata.flowCheckoutId).
|
|
176
|
-
await Promise.all([
|
|
177
|
-
fireTelemetryWithTimeout(request, "purchase-completed", props),
|
|
178
|
-
billingTask,
|
|
179
|
-
stampFlowCheckout(request, { session, eventIdPrefix, outcome: "completed" }),
|
|
180
|
-
]);
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
183
|
-
case "checkout.session.expired": {
|
|
184
|
-
const session = event.data.object as CheckoutSessionObject;
|
|
185
|
-
if (!session.metadata?.["flowCheckoutId"]) {
|
|
186
|
-
// Not a recommender session — the same low-noise line the default
|
|
187
|
-
// arm emitted for this event before #4838 handled it.
|
|
188
|
-
request.log.info(
|
|
189
|
-
{ eventType: event.type, eventIdPrefix },
|
|
190
|
-
"unhandled webhook event",
|
|
191
|
-
);
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
|
-
await stampFlowCheckout(request, { session, eventIdPrefix, outcome: "expired" });
|
|
195
|
-
break;
|
|
196
|
-
}
|
|
197
|
-
default:
|
|
198
|
-
request.log.info(
|
|
199
|
-
{ eventType: event.type, eventIdPrefix },
|
|
200
|
-
"unhandled webhook event",
|
|
201
|
-
);
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
await reply.code(200).send({ received: true });
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
type CheckoutSessionObject = {
|
|
209
|
-
id?: string;
|
|
210
|
-
payment_status?: string | null;
|
|
211
|
-
amount_total?: number | null;
|
|
212
|
-
currency?: string | null;
|
|
213
|
-
customer?: string | null;
|
|
214
|
-
customer_details?: { email?: string | null } | null;
|
|
215
|
-
metadata?: Record<string, string> | null;
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Stamp the recommender's FlowCheckout buyer row from a terminal Checkout
|
|
220
|
-
* Session event (#4838).
|
|
221
|
-
*
|
|
222
|
-
* The `status: "created"` guard on the update IS the idempotency key (D1):
|
|
223
|
-
* `count === 1` → stamped; `count === 0` → the row is already terminal, or the
|
|
224
|
-
* id is unknown — a warning, never a retry (Stripe would re-fire telemetry +
|
|
225
|
-
* billing on eventual success). A `completed` session whose
|
|
226
|
-
* `payment_status` is not `"paid"` logs `unpaid` and leaves the row `created`. A charged amount that differs from the quoted
|
|
227
|
-
* `amountMinor` is logged with both integers and the stamp proceeds (D2). Log
|
|
228
|
-
* lines carry only `eventIdPrefix`, an 8-char `flowCheckoutIdPrefix`, an
|
|
229
|
-
* `outcome`, and the two integers — never the email, customer id, or permalink.
|
|
230
|
-
*
|
|
231
|
-
* Never throws — the handler's "always 200 for a verified event" contract.
|
|
232
|
-
*/
|
|
233
|
-
async function stampFlowCheckout(
|
|
234
|
-
request: FastifyRequest,
|
|
235
|
-
{
|
|
236
|
-
session,
|
|
237
|
-
eventIdPrefix,
|
|
238
|
-
outcome,
|
|
239
|
-
}: { session: CheckoutSessionObject; eventIdPrefix: string; outcome: "completed" | "expired" },
|
|
240
|
-
): Promise<void> {
|
|
241
|
-
const flowCheckoutId = session.metadata?.["flowCheckoutId"];
|
|
242
|
-
if (!flowCheckoutId) return;
|
|
243
|
-
const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
|
|
244
|
-
|
|
245
|
-
// A `completed` session is only a purchase once Stripe reports it paid, OR
|
|
246
|
-
// reports nothing was due (`no_payment_required` — a 100%-off promo code,
|
|
247
|
-
// #4862, still a Stripe-confirmed completion). A delayed-notification or
|
|
248
|
-
// genuinely unpaid completion leaves the row `created` (code + security
|
|
249
|
-
// review, PR #4855).
|
|
250
|
-
if (
|
|
251
|
-
outcome === "completed" &&
|
|
252
|
-
session.payment_status !== "paid" &&
|
|
253
|
-
session.payment_status !== "no_payment_required"
|
|
254
|
-
) {
|
|
255
|
-
request.log.warn(
|
|
256
|
-
{ eventIdPrefix, flowCheckoutIdPrefix, outcome: "unpaid" },
|
|
257
|
-
"flow checkout: completed session is not paid — not stamping",
|
|
258
|
-
);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
if (outcome === "completed" && typeof session.amount_total === "number") {
|
|
264
|
-
const row = await prisma.flowCheckout.findUnique({
|
|
265
|
-
where: { id: flowCheckoutId },
|
|
266
|
-
select: { amountMinor: true },
|
|
267
|
-
});
|
|
268
|
-
if (row && row.amountMinor !== session.amount_total) {
|
|
269
|
-
request.log.warn(
|
|
270
|
-
{
|
|
271
|
-
eventIdPrefix,
|
|
272
|
-
flowCheckoutIdPrefix,
|
|
273
|
-
outcome: "amount_mismatch",
|
|
274
|
-
expected: row.amountMinor,
|
|
275
|
-
received: session.amount_total,
|
|
276
|
-
},
|
|
277
|
-
"flow checkout: charged amount differs from the quoted amount — stamping anyway",
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const result = await prisma.flowCheckout.updateMany({
|
|
283
|
-
where: { id: flowCheckoutId, status: "created" },
|
|
284
|
-
data:
|
|
285
|
-
outcome === "completed"
|
|
286
|
-
? {
|
|
287
|
-
status: "completed",
|
|
288
|
-
completedAt: new Date(),
|
|
289
|
-
stripeSessionId: session.id ?? null,
|
|
290
|
-
stripeCustomerId: session.customer ?? null,
|
|
291
|
-
}
|
|
292
|
-
: { status: "expired" },
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
if (result.count === 1) {
|
|
296
|
-
request.log.info(
|
|
297
|
-
{ eventIdPrefix, flowCheckoutIdPrefix, outcome },
|
|
298
|
-
"flow checkout stamped",
|
|
299
|
-
);
|
|
300
|
-
} else {
|
|
301
|
-
request.log.warn(
|
|
302
|
-
{ eventIdPrefix, flowCheckoutIdPrefix, outcome: "stale_or_unknown" },
|
|
303
|
-
"flow checkout: no created row matched — replay, late terminal event, or unknown id",
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
} catch (err) {
|
|
307
|
-
request.log.error(
|
|
308
|
-
{
|
|
309
|
-
eventIdPrefix,
|
|
310
|
-
flowCheckoutIdPrefix,
|
|
311
|
-
outcome: "stamp_failed",
|
|
312
|
-
errName: err instanceof Error ? err.name : "UnknownError",
|
|
313
|
-
},
|
|
314
|
-
"flow checkout stamp failed — continuing",
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Link User.billingProvider + User.billingCustomerId for a Stripe customer.
|
|
321
|
-
*
|
|
322
|
-
* Lookup priority:
|
|
323
|
-
* 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customerId)
|
|
324
|
-
* → already linked; no-op (idempotent)
|
|
325
|
-
* 2. Email fallback: User.findUnique({ where: { email } }) → update billing fields
|
|
326
|
-
* 3. No match → warn; do NOT create a User row (pre-signup state preserved for Phase B #852)
|
|
327
|
-
*
|
|
328
|
-
* DB failures are caught and logged — a broken DB link should not cause Stripe to retry
|
|
329
|
-
* the event (which would fire analytics + billing again on eventual success).
|
|
330
|
-
*/
|
|
331
|
-
async function upsertBillingIdentity(
|
|
332
|
-
request: FastifyRequest,
|
|
333
|
-
{
|
|
334
|
-
customerId,
|
|
335
|
-
email,
|
|
336
|
-
eventIdPrefix,
|
|
337
|
-
}: { customerId: string; email: string | null; eventIdPrefix: string },
|
|
338
|
-
): Promise<void> {
|
|
339
|
-
try {
|
|
340
|
-
const existingByBilling = await prisma.user.findFirst({
|
|
341
|
-
where: {
|
|
342
|
-
billingProvider: "stripe",
|
|
343
|
-
billingCustomerId: customerId,
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
if (existingByBilling) {
|
|
348
|
-
request.log.info(
|
|
349
|
-
{ eventIdPrefix },
|
|
350
|
-
"billing identity already linked — idempotent no-op",
|
|
351
|
-
);
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (email) {
|
|
356
|
-
const existingByEmail = await prisma.user.findUnique({
|
|
357
|
-
where: { email },
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
if (existingByEmail) {
|
|
361
|
-
await prisma.user.update({
|
|
362
|
-
where: { id: existingByEmail.id },
|
|
363
|
-
data: {
|
|
364
|
-
billingProvider: "stripe",
|
|
365
|
-
billingCustomerId: customerId,
|
|
366
|
-
},
|
|
367
|
-
});
|
|
368
|
-
request.log.info(
|
|
369
|
-
{ eventIdPrefix },
|
|
370
|
-
"billing identity linked via email fallback",
|
|
371
|
-
);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
request.log.warn(
|
|
377
|
-
{ eventIdPrefix },
|
|
378
|
-
"stripe webhook: no User match for billing identity — pre-signup state preserved",
|
|
379
|
-
);
|
|
380
|
-
} catch (err) {
|
|
381
|
-
request.log.error(
|
|
382
|
-
{
|
|
383
|
-
eventIdPrefix,
|
|
384
|
-
error: err instanceof Error ? err.message : String(err),
|
|
385
|
-
},
|
|
386
|
-
"billing identity upsert failed — continuing",
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Link Organization.billingProvider + billingCustomerId for a Stripe customer
|
|
393
|
-
* (org-scoped billing, #3601, spec D2/D3). Called when a checkout carries
|
|
394
|
-
* metadata.organizationId — for a real org, or an individual's sentinel default
|
|
395
|
-
* Organization (orgs-of-one; AC2 — ONE billing path inside the module).
|
|
396
|
-
*
|
|
397
|
-
* Idempotent: a typed-composite match (billingProvider="stripe",
|
|
398
|
-
* billingCustomerId=customerId) already pointing at THIS org is a no-op on
|
|
399
|
-
* replay. Otherwise the target Organization (by id from checkout metadata) is
|
|
400
|
-
* linked.
|
|
401
|
-
*
|
|
402
|
-
* DB failures are caught and logged (never re-thrown) — a broken link must not
|
|
403
|
-
* make Stripe retry (which would re-fire analytics + billing on eventual
|
|
404
|
-
* success), matching the User-path contract above.
|
|
405
|
-
*/
|
|
406
|
-
async function upsertOrgBillingIdentity(
|
|
407
|
-
request: FastifyRequest,
|
|
408
|
-
{
|
|
409
|
-
organizationId,
|
|
410
|
-
customerId,
|
|
411
|
-
eventIdPrefix,
|
|
412
|
-
}: { organizationId: string; customerId: string; eventIdPrefix: string },
|
|
413
|
-
): Promise<void> {
|
|
414
|
-
try {
|
|
415
|
-
const existingByBilling = await prisma.organization.findFirst({
|
|
416
|
-
where: {
|
|
417
|
-
billingProvider: "stripe",
|
|
418
|
-
billingCustomerId: customerId,
|
|
419
|
-
},
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
if (existingByBilling && existingByBilling.id === organizationId) {
|
|
423
|
-
request.log.info(
|
|
424
|
-
{ eventIdPrefix },
|
|
425
|
-
"org billing identity already linked — idempotent no-op",
|
|
426
|
-
);
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
await prisma.organization.update({
|
|
431
|
-
where: { id: organizationId },
|
|
432
|
-
data: {
|
|
433
|
-
billingProvider: "stripe",
|
|
434
|
-
billingCustomerId: customerId,
|
|
435
|
-
},
|
|
436
|
-
});
|
|
437
|
-
request.log.info(
|
|
438
|
-
{ eventIdPrefix },
|
|
439
|
-
"org billing identity linked from checkout metadata.organizationId",
|
|
440
|
-
);
|
|
441
|
-
} catch (err) {
|
|
442
|
-
request.log.error(
|
|
443
|
-
{
|
|
444
|
-
eventIdPrefix,
|
|
445
|
-
error: err instanceof Error ? err.message : String(err),
|
|
446
|
-
},
|
|
447
|
-
"org billing identity upsert failed — continuing",
|
|
448
|
-
);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
async function fireTelemetryWithTimeout(
|
|
453
|
-
request: FastifyRequest,
|
|
454
|
-
name: string,
|
|
455
|
-
props: Record<string, unknown>,
|
|
456
|
-
): Promise<void> {
|
|
457
|
-
let timeoutId!: ReturnType<typeof setTimeout>;
|
|
458
|
-
const timeout = new Promise<void>((resolve) => {
|
|
459
|
-
timeoutId = setTimeout(() => {
|
|
460
|
-
request.log.warn(
|
|
461
|
-
{ telemetryEvent: name },
|
|
462
|
-
"telemetry call exceeded timeout; continuing",
|
|
463
|
-
);
|
|
464
|
-
resolve();
|
|
465
|
-
}, TELEMETRY_TIMEOUT_MS);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
try {
|
|
469
|
-
await Promise.race([track(name, props), timeout]);
|
|
470
|
-
} catch (err) {
|
|
471
|
-
request.log.error(
|
|
472
|
-
{
|
|
473
|
-
telemetryEvent: name,
|
|
474
|
-
error: err instanceof Error ? err.message : String(err),
|
|
475
|
-
},
|
|
476
|
-
"telemetry call threw",
|
|
477
|
-
);
|
|
478
|
-
} finally {
|
|
479
|
-
clearTimeout(timeoutId);
|
|
480
|
-
}
|
|
481
|
-
}
|