create-ailk 0.24.0 → 0.26.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/README.md +53 -22
- package/component-catalog.json +3 -3
- package/component-catalog.md +3 -3
- package/dist/add-module.js +4 -2
- package/dist/add-route.d.ts +12 -1
- package/dist/add-route.js +31 -3
- package/dist/cli.js +12 -7
- package/dist/derive-architecture.d.ts +3 -4
- package/dist/derive-architecture.js +65 -29
- package/dist/lib/design-checks.d.ts +9 -4
- package/dist/lib/design-checks.js +13 -7
- package/dist/lib/extract-module-bundle.js +29 -2
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +8 -3
- package/dist/lib/import-graph.d.ts +1 -1
- package/dist/lib/import-graph.js +4 -4
- package/dist/lib/module-license-gate.d.ts +12 -1
- package/dist/lib/module-license-gate.js +36 -15
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.d.ts +20 -3
- package/dist/module-architecture.js +129 -552
- package/dist/paid-sections.d.ts +25 -1
- package/dist/paid-sections.js +88 -3
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +39 -2
- package/dist/template-record.d.ts +21 -3
- package/dist/template-record.js +38 -7
- package/dist/topology.d.ts +16 -3
- package/dist/topology.js +54 -23
- package/dist/update.d.ts +10 -1
- package/dist/update.js +715 -21
- package/package.json +3 -3
- package/release-scope.json +2 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/template-guard.sh +137 -0
- package/templates/.claude/rules/extending.md +106 -0
- package/templates/.claude/rules/loop-contract.md +2 -2
- package/templates/.claude/settings.json +11 -0
- package/templates/.env.example +20 -5
- package/templates/.github/workflows/ci.yml +7 -4
- package/templates/CLAUDE.md +11 -1
- package/templates/CONVENTIONS.md +8 -10
- package/templates/README.md +14 -13
- package/templates/apps/api/.env.example +23 -2
- package/templates/apps/api/CLAUDE.md +119 -83
- package/templates/apps/api/jest.config.cjs +15 -5
- package/templates/apps/api/package.json +16 -20
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
- package/templates/apps/api/src/index.ts +1 -1
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
- package/templates/apps/api/src/modules/commerce.ts +10 -0
- package/templates/apps/api/src/modules/content-write.ts +10 -0
- package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
- package/templates/apps/api/src/modules/scheduling.ts +10 -0
- package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
- package/templates/apps/api/src/modules/workspace.ts +10 -0
- package/templates/apps/api/src/server.ts +117 -1882
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/CLAUDE.md +90 -26
- package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
- package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
- package/templates/apps/mcp/jest.config.cjs +6 -1
- package/templates/apps/mcp/package.json +5 -13
- package/templates/apps/mcp/src/app.ts +8 -7
- package/templates/apps/mcp/src/index.ts +6 -34
- package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
- package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
- package/templates/apps/mcp/src/site.ts +12 -0
- package/templates/apps/web/.env.example +1 -1
- package/templates/apps/web/CLAUDE.md +31 -3
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
- package/templates/apps/web/app/[locale]/layout.tsx +33 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
- package/templates/apps/web/app/api/revalidate/route.ts +3 -3
- package/templates/apps/web/auth.ts +10 -6
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/auth-email.ts +103 -21
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +2 -2
- package/templates/apps/web/lib/release-scope.ts +47 -0
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +186 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
- package/templates/customizations.yaml +37 -0
- package/templates/database/prisma.config.ts +10 -14
- package/templates/database/seed-test.ts +2 -2
- package/templates/database/seed.ts +5 -5
- package/templates/package.json +12 -6
- package/templates/pnpm-workspace.yaml +0 -1
- package/templates/project.yaml +16 -8
- package/templates/tools/jest/reporters.cjs +65 -0
- package/templates/tools/lint-architecture.ts +553 -7
- package/templates/tsconfig.json +1 -2
- package/versions.json +49 -36
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
- package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
- package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
- package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
- package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
- package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
- package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
- package/templates/apps/api/src/bin/spokes.ts +0 -198
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
- package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
- package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
- package/templates/apps/api/src/config/index.ts +0 -596
- package/templates/apps/api/src/config/modules.ts +0 -83
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
- package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
- package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
- package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
- package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
- package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
- package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
- package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
- package/templates/apps/api/src/lib/agent-store.ts +0 -613
- package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
- package/templates/apps/api/src/lib/consent-store.ts +0 -363
- package/templates/apps/api/src/lib/content-adapter.ts +0 -112
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
- package/templates/apps/api/src/lib/error-serializer.ts +0 -164
- package/templates/apps/api/src/lib/errors.ts +0 -95
- package/templates/apps/api/src/lib/file-fallback.ts +0 -87
- package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
- package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
- package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
- package/templates/apps/api/src/lib/license-key-store.ts +0 -45
- package/templates/apps/api/src/lib/locales.ts +0 -201
- package/templates/apps/api/src/lib/outbound-http.ts +0 -127
- package/templates/apps/api/src/lib/prisma.ts +0 -21
- package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
- package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
- package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
- package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
- package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
- package/templates/apps/api/src/lib/site.ts +0 -257
- package/templates/apps/api/src/lib/slice-load.ts +0 -90
- package/templates/apps/api/src/lib/spoke-store.ts +0 -180
- package/templates/apps/api/src/lib/stripe.ts +0 -321
- package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
- package/templates/apps/api/src/middleware/auth.ts +0 -864
- package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
- package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
- package/templates/apps/api/src/openapi/index.ts +0 -18
- package/templates/apps/api/src/openapi/spec.ts +0 -2148
- package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
- package/templates/apps/api/src/routes/aeo/index.ts +0 -107
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
- package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
- package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
- package/templates/apps/api/src/routes/auth/user.ts +0 -305
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
- package/templates/apps/api/src/routes/checkout/index.ts +0 -34
- package/templates/apps/api/src/routes/checkout/session.ts +0 -171
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
- package/templates/apps/api/src/routes/consent/account.ts +0 -268
- package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
- package/templates/apps/api/src/routes/consent/index.ts +0 -26
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
- package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
- package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
- package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
- package/templates/apps/api/src/routes/content/get-page.ts +0 -183
- package/templates/apps/api/src/routes/content/index.ts +0 -49
- package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
- package/templates/apps/api/src/routes/content/list-types.ts +0 -19
- package/templates/apps/api/src/routes/content/offerings.ts +0 -184
- package/templates/apps/api/src/routes/content/pricing.ts +0 -176
- package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
- package/templates/apps/api/src/routes/content/search.ts +0 -100
- package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
- package/templates/apps/api/src/routes/leads/index.ts +0 -639
- package/templates/apps/api/src/routes/leads/list.ts +0 -104
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
- package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
- package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
- package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
- package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
- package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
- package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
- package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
- package/templates/apps/api/src/routes/requests/index.ts +0 -54
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
- package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
- package/templates/apps/api/src/routes/webhooks/README.md +0 -398
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
- package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
- package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
- package/templates/apps/api/src/services/abandon-email.ts +0 -336
- package/templates/apps/api/src/services/aeo-score.ts +0 -325
- package/templates/apps/api/src/services/answer-distributions.ts +0 -138
- package/templates/apps/api/src/services/audit-log.ts +0 -961
- package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
- package/templates/apps/api/src/services/consent.ts +0 -834
- package/templates/apps/api/src/services/default-tenant.ts +0 -416
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
- package/templates/apps/api/src/services/dunning-policy.ts +0 -294
- package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
- package/templates/apps/api/src/services/entitlements.ts +0 -338
- package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
- package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
- package/templates/apps/api/src/services/lead-promotion.ts +0 -160
- package/templates/apps/api/src/services/lead-routing.ts +0 -1372
- package/templates/apps/api/src/services/leads-inbox.ts +0 -312
- package/templates/apps/api/src/services/listing-config.ts +0 -178
- package/templates/apps/api/src/services/magnet-completion.ts +0 -242
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
- package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
- package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
- package/templates/apps/api/src/services/offerings/index.ts +0 -614
- package/templates/apps/api/src/services/offerings/source.ts +0 -140
- package/templates/apps/api/src/services/offerings/store.ts +0 -150
- package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
- package/templates/apps/api/src/services/person.ts +0 -1211
- package/templates/apps/api/src/services/plan-offerings.ts +0 -125
- package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
- package/templates/apps/api/src/services/project-listings.ts +0 -2085
- package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
- package/templates/apps/api/src/services/request-confirm.ts +0 -698
- package/templates/apps/api/src/services/revalidation.ts +0 -235
- package/templates/apps/api/src/services/slack-notify.ts +0 -291
- package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
- package/templates/apps/api/src/services/usage-credits.ts +0 -340
- package/templates/apps/api/src/services/verified-email.ts +0 -71
- package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
- package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
- package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
- package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
- package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
- package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
- package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
- package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
- package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
- package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
- package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
- package/templates/apps/mcp/__tests__/index.test.ts +0 -195
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
- package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
- package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
- package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
- package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
- package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
- package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
- package/templates/apps/mcp/__tests__/server.test.ts +0 -388
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
- package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
- package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
- package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
- package/templates/apps/mcp/src/abuse/index.ts +0 -349
- package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
- package/templates/apps/mcp/src/boot.ts +0 -167
- package/templates/apps/mcp/src/config/modules.ts +0 -43
- package/templates/apps/mcp/src/http-transport.ts +0 -564
- package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
- package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
- package/templates/apps/mcp/src/lib/errors.ts +0 -61
- package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
- package/templates/apps/mcp/src/lib/locales.ts +0 -171
- package/templates/apps/mcp/src/lib/prisma.ts +0 -39
- package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
- package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
- package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
- package/templates/apps/mcp/src/lib/site-config.ts +0 -53
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
- package/templates/apps/mcp/src/request-context.ts +0 -52
- package/templates/apps/mcp/src/server.ts +0 -343
- package/templates/apps/mcp/src/serverless-app.ts +0 -88
- package/templates/apps/mcp/src/tools/audit.ts +0 -127
- package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
- package/templates/apps/mcp/src/tools/create_project.ts +0 -134
- package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
- package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
- package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
- package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
- package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
- package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
- package/templates/apps/mcp/src/tools/index.ts +0 -138
- package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
- package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
- package/templates/apps/mcp/src/tools/proposals.ts +0 -27
- package/templates/apps/mcp/src/tools/purchase.ts +0 -205
- package/templates/apps/mcp/src/tools/read_content.ts +0 -81
- package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
- package/templates/apps/mcp/src/tools/search_content.ts +0 -63
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
- package/templates/apps/mcp/src/workspace-server.ts +0 -224
- package/templates/database/CHANGELOG.md +0 -981
- package/templates/database/LICENSE +0 -202
- package/templates/database/NOTICE +0 -2
- package/templates/database/README.md +0 -243
- package/templates/database/__tests__/agents.db.test.ts +0 -320
- package/templates/database/__tests__/appointment.db.test.ts +0 -106
- package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
- package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
- package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
- package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
- package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
- package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
- package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
- package/templates/database/__tests__/flows-presets.test.ts +0 -173
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
- package/templates/database/__tests__/member-area.db.test.ts +0 -257
- package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
- package/templates/database/__tests__/member-contract.db.test.ts +0 -413
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
- package/templates/database/__tests__/offerings-migration.test.ts +0 -197
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
- package/templates/database/__tests__/offerings.db.test.ts +0 -303
- package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
- package/templates/database/__tests__/person-contact.db.test.ts +0 -563
- package/templates/database/__tests__/person-notes.db.test.ts +0 -249
- package/templates/database/__tests__/person.db.test.ts +0 -586
- package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
- package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
- package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
- package/templates/database/__tests__/seed.test.ts +0 -272
- package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
- package/templates/database/__tests__/tenancy.test.ts +0 -141
- package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
- package/templates/database/__tests__/user-cascade.test.ts +0 -140
- package/templates/database/_gitignore +0 -8
- package/templates/database/auth/.gitkeep +0 -0
- package/templates/database/auth/schema.prisma +0 -641
- package/templates/database/config/.gitkeep +0 -0
- package/templates/database/config/schema.prisma +0 -441
- package/templates/database/content/.gitkeep +0 -0
- package/templates/database/content/schema.prisma +0 -339
- package/templates/database/eslint.config.mjs +0 -69
- package/templates/database/flows/listing-diagnostic.ts +0 -182
- package/templates/database/flows/presets.ts +0 -315
- package/templates/database/inbox/.gitkeep +0 -0
- package/templates/database/inbox/README.md +0 -3
- package/templates/database/inbox/__tests__/schema.test.ts +0 -44
- package/templates/database/inbox/schema.prisma +0 -1447
- package/templates/database/jest.config.cjs +0 -36
- package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
- package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
- package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
- package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
- package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
- package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
- package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
- package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
- package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
- package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
- package/templates/database/migrations/migration_lock.toml +0 -3
- package/templates/database/ops/.gitkeep +0 -0
- package/templates/database/ops/schema.prisma +0 -43
- package/templates/database/package.json +0 -89
- package/templates/database/schema.prisma +0 -71
- package/templates/database/scripts/db-generate-locked.sh +0 -80
- package/templates/database/scripts/person-backfill.sql +0 -132
- package/templates/database/tenancy.ts +0 -183
- package/templates/database/tsconfig.build.json +0 -16
- package/templates/database/tsconfig.flows.json +0 -15
- package/templates/database/tsconfig.json +0 -16
- package/templates/database/tsconfig.tenancy.json +0 -14
|
@@ -1,2028 +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
|
-
* Commerce claim (D-64, #5796): the receiver is a base-tree primitive, so it
|
|
15
|
-
* registers on every build and gates on the JWT `commerce` claim at call time.
|
|
16
|
-
* The signature is settled FIRST (D3), then the claim is read as a TRI-STATE:
|
|
17
|
-
* - claimed → every mapping below is byte-for-byte what it was
|
|
18
|
-
* - unclaimed → a SIGNED event is acked 200 and dropped with a `warn`
|
|
19
|
-
* log line so Stripe stops retrying; an unsigned or
|
|
20
|
-
* unverifiable body gets the module-disabled 404,
|
|
21
|
-
* indistinguishable from a build without commerce
|
|
22
|
-
* - undetermined → 503 + `Retry-After: 300`, so Stripe QUEUES and
|
|
23
|
-
* redelivers. The read established no "no" (it threw, the
|
|
24
|
-
* licence is revoked mid-window, or one is configured yet
|
|
25
|
-
* no longer verifies), so this may be a live commerce site
|
|
26
|
-
* whose buyer was already charged; dropping on such a read
|
|
27
|
-
* loses the event permanently (security review 2026-09-16,
|
|
28
|
-
* High, widened by the operator in round 2).
|
|
29
|
-
* See README.md § Commerce claim.
|
|
30
|
-
*
|
|
31
|
-
* Successful dispatch always 200s — including unknown event types.
|
|
32
|
-
* Telemetry failures are caught, logged, and do NOT change the response.
|
|
33
|
-
*
|
|
34
|
-
* Site filter (#7868):
|
|
35
|
-
* The Stripe account is shared with spoke sites, so this endpoint receives
|
|
36
|
-
* their Checkout sessions too. A `checkout.session.completed` or `.expired`
|
|
37
|
-
* whose `metadata.site` is a non-empty string naming another site
|
|
38
|
-
* (`isForeignSiteStamp`, lib/site.ts) takes no action: it logs
|
|
39
|
-
* `outcome: "foreign_site"` and answers 200. An unstamped session is the
|
|
40
|
-
* hub's own (roadmap D4) and is handled as before. The hub stamps its own
|
|
41
|
-
* sessions through `siteStamp()`. See README.md § Site filter (#7868).
|
|
42
|
-
* Since #8024 one gate before the switch does the same for every
|
|
43
|
-
* `customer.*`, `customer.subscription.*` and `invoice.*` event, reading the
|
|
44
|
-
* stamp from the Customer's or Subscription's own metadata — for an invoice,
|
|
45
|
-
* its own metadata and then the subscription snapshot under
|
|
46
|
-
* `parent.subscription_details` or (pre-`basil`) `subscription_details` —
|
|
47
|
-
* and only when `data.object.object` is the family's own type.
|
|
48
|
-
*
|
|
49
|
-
* Billing upsert (customer.created / checkout.session.completed):
|
|
50
|
-
* Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
|
|
51
|
-
* carrying metadata.organizationId takes the Organization path (multi-tenant
|
|
52
|
-
* module); absent → the User path (single-tenant).
|
|
53
|
-
*
|
|
54
|
-
* Organization path (metadata.organizationId present) — proof-based since
|
|
55
|
-
* #9067 D13: the metadata names the target, the recorded session proves it.
|
|
56
|
-
* 1. Typed composite match already pointing at this org → idempotent no-op
|
|
57
|
-
* 2. A checkout.session.completed binds only when the Organization's
|
|
58
|
-
* `planCheckoutSessionId` is this session's id and no customer is
|
|
59
|
-
* bound yet (one guarded updateMany, which also clears the session)
|
|
60
|
-
* 3. Anything else — an unrecorded session, a bound customer, any
|
|
61
|
-
* customer.created — logs org_bind_unproven and writes nothing
|
|
62
|
-
*
|
|
63
|
-
* User path (no organizationId):
|
|
64
|
-
* 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customer)
|
|
65
|
-
* 2. Verified user signal: metadata.ailk_user_id →
|
|
66
|
-
* User.findUnique({ where: { id } }) → bind only when the row's
|
|
67
|
-
* billingCustomerId is empty; otherwise log billing_identity_conflict
|
|
68
|
-
* and write nothing (#6113). No signal → log billing_identity_unverified,
|
|
69
|
-
* write nothing (#6852) — the payer-typed Checkout email never binds.
|
|
70
|
-
* 3. Signal names no User → warn billing_identity_unknown_user; no row created
|
|
71
|
-
*
|
|
72
|
-
* Recommender purchase stamp (checkout.session.completed / .expired, #4838):
|
|
73
|
-
* A session carrying metadata.flowCheckoutId (the FlowCheckout buyer row
|
|
74
|
-
* #4841 opened it with) is stamped `completed` / `expired` by ONE guarded
|
|
75
|
-
* `updateMany` on `{ id, status: "created" }` — the durable idempotency key
|
|
76
|
-
* (D1): a replay, a restart between deliveries, or a late `expired` after a
|
|
77
|
-
* `completed` all match zero rows and log `stale_or_unknown`. Runs BESIDE the
|
|
78
|
-
* telemetry + billing tasks (D4), never instead of them; a charged/quoted
|
|
79
|
-
* mismatch is logged, never reconciled by overwrite (D2). Always 200. A
|
|
80
|
-
* `completed` session stamps only when `payment_status` is `"paid"` or
|
|
81
|
-
* `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
|
|
82
|
-
* logs `outcome: "unpaid"` and leaves the row `created`.
|
|
83
|
-
*
|
|
84
|
-
* Proposal paid stamp (checkout.session.completed, #8516 D13):
|
|
85
|
-
* A session carrying the server-written `metadata.ailk_proposal` and
|
|
86
|
-
* `ailk_workspace` (the client's proposal Checkout,
|
|
87
|
-
* `services/proposal-payments.ts`) stamps that proposal's `paid_at` and
|
|
88
|
-
* `paid_session_id` once, through the base-tree boundary
|
|
89
|
-
* `lib/proposal-paid-store.ts` (this file names no `piece` delegate), and
|
|
90
|
-
* writes one `people.proposal.paid` audit row as the Stripe system actor
|
|
91
|
-
* keyed by the event id: `succeeded` when stamped, `failed`
|
|
92
|
-
* (`already_paid`) when a different session already paid it, none on a
|
|
93
|
-
* same-session replay, a `warn` when no such proposal exists, and `failed`
|
|
94
|
-
* (`not_paid`) with no stamp for a session that is not paid. A session
|
|
95
|
-
* without both keys makes no query. Runs BESIDE the other stamps; never
|
|
96
|
-
* throws; logs an 8-character prefix and the outcome only.
|
|
97
|
-
*
|
|
98
|
-
* Checkout Person (checkout.session.completed, #852 D6):
|
|
99
|
-
* Chained after that stamp, `recordFlowCheckoutPerson` finds or creates the
|
|
100
|
-
* Person of the checkout row THIS session completed (matched on its own
|
|
101
|
-
* `stripeSessionId`), with `session.customer` as the customer id only on a
|
|
102
|
-
* live-mode event. Logs an outcome kind only; never fails the 200.
|
|
103
|
-
*
|
|
104
|
-
* Checkout terms grant (checkout.session.completed, #8765 D14):
|
|
105
|
-
* Chained after the Person step, `recordCheckoutTermsGrant` records the
|
|
106
|
-
* buyer's acceptance of the session's terms box as one `checkout` row of
|
|
107
|
-
* the consent ledger — only for a session whose `consent.terms_of_service`
|
|
108
|
-
* is `accepted` and whose server-written `metadata.ailk_terms` names
|
|
109
|
-
* `account_terms` at its current version (a Payment Link, invoice or older
|
|
110
|
-
* session carries none), for the Person `checkoutBuyerPerson` resolves.
|
|
111
|
-
* Logs `{ event: "checkout-consent", outcome, eventIdPrefix }` only; never
|
|
112
|
-
* fails the 200.
|
|
113
|
-
*
|
|
114
|
-
* Dunning (invoice.payment_failed / customer.subscription.updated, #6034):
|
|
115
|
-
* Records a per-subscriber lifecycle state in a `UserData` row behind a
|
|
116
|
-
* reserved-key guard (routes/auth/user.ts). `invoice.payment_failed` arms
|
|
117
|
-
* dunning (`status: "past_due"`) and never revokes; `customer.subscription
|
|
118
|
-
* .updated` maps the narrowed status onto the same record, and `canceled`
|
|
119
|
-
* revokes access only once a per-tenant grace period (SiteSetting-backed,
|
|
120
|
-
* `services/dunning-policy.ts`) has lazily expired. See README.md
|
|
121
|
-
* § Dunning for the full state machine.
|
|
122
|
-
*/
|
|
123
|
-
import { track } from "@working-theory/analytics/server";
|
|
124
|
-
import type { Prisma } from "@working-theory/database";
|
|
125
|
-
import { isId } from "@working-theory/ids";
|
|
126
|
-
import {
|
|
127
|
-
ACCOUNT_TERMS_PURPOSE_ID,
|
|
128
|
-
consentPurposeById,
|
|
129
|
-
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
130
|
-
} from "@working-theory/validation";
|
|
131
|
-
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
132
|
-
|
|
133
|
-
import { config } from "../../config/index.js";
|
|
134
|
-
import { prisma } from "../../lib/prisma.js";
|
|
135
|
-
import { stampProposalPaid as writeProposalPaid } from "../../lib/proposal-paid-store.js";
|
|
136
|
-
import {
|
|
137
|
-
resolveCommerceClaim,
|
|
138
|
-
commerceDisabledResponse,
|
|
139
|
-
LICENSE_UNDETERMINED_BODY,
|
|
140
|
-
LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS,
|
|
141
|
-
LICENSE_UNDETERMINED_STATUS,
|
|
142
|
-
} from "../../lib/require-commerce-claim.js";
|
|
143
|
-
import {
|
|
144
|
-
currentSiteId,
|
|
145
|
-
isForeignSiteStamp,
|
|
146
|
-
SITE_STAMP_KEY,
|
|
147
|
-
} from "../../lib/site.js";
|
|
148
|
-
import {
|
|
149
|
-
stripeKeyMode,
|
|
150
|
-
StripeWebhookSignatureError,
|
|
151
|
-
toSubscriptionLifecycleStatus,
|
|
152
|
-
verifyWebhookSignature,
|
|
153
|
-
} from "../../lib/stripe.js";
|
|
154
|
-
import { hasSeen, markSeen } from "../../lib/webhook-idempotency.js";
|
|
155
|
-
import { recordWorkspaceAuditAction } from "../../services/audit-log.js";
|
|
156
|
-
import { checkoutBuyerPerson } from "../../services/checkout-buyer.js";
|
|
157
|
-
import { recordCheckoutConsent } from "../../services/consent.js";
|
|
158
|
-
import { isUniqueViolation } from "../../services/default-tenant.js";
|
|
159
|
-
import {
|
|
160
|
-
DUNNING_MAX_SUBSCRIPTIONS,
|
|
161
|
-
DUNNING_USER_DATA_KEY,
|
|
162
|
-
dunningPolicyFor,
|
|
163
|
-
type DunningEntry,
|
|
164
|
-
type DunningRecord,
|
|
165
|
-
} from "../../services/dunning-policy.js";
|
|
166
|
-
import {
|
|
167
|
-
resolveCheckoutPerson,
|
|
168
|
-
type CheckoutPersonResult,
|
|
169
|
-
} from "../../services/person.js";
|
|
170
|
-
|
|
171
|
-
const TELEMETRY_TIMEOUT_MS = 2000;
|
|
172
|
-
const DUNNING_WRITE_MAX_ATTEMPTS = 3;
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Everything that must be true before an event is dispatched, evaluated as a
|
|
176
|
-
* value rather than as an early `reply.send` — so the caller can settle the
|
|
177
|
-
* signature BEFORE consulting the commerce claim (D3, #5796) without
|
|
178
|
-
* duplicating any of the four rejections. The log lines ride along so a
|
|
179
|
-
* held rejection still logs exactly what it logged before, and only when it
|
|
180
|
-
* is the response actually sent.
|
|
181
|
-
*/
|
|
182
|
-
type SettledSignature =
|
|
183
|
-
| { ok: true; event: ReturnType<typeof verifyWebhookSignature> }
|
|
184
|
-
| {
|
|
185
|
-
ok: false;
|
|
186
|
-
status: number;
|
|
187
|
-
body: { error: string };
|
|
188
|
-
log: (request: FastifyRequest) => void;
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
function settleSignature(request: FastifyRequest): SettledSignature {
|
|
192
|
-
const secret = config.STRIPE_WEBHOOK_SECRET;
|
|
193
|
-
if (!secret) {
|
|
194
|
-
return {
|
|
195
|
-
ok: false,
|
|
196
|
-
status: 503,
|
|
197
|
-
body: { error: "webhook-not-configured" },
|
|
198
|
-
log: (req) =>
|
|
199
|
-
req.log.warn("stripe webhook hit but STRIPE_WEBHOOK_SECRET is not set"),
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const rawBody = request.body;
|
|
204
|
-
if (!Buffer.isBuffer(rawBody)) {
|
|
205
|
-
return {
|
|
206
|
-
ok: false,
|
|
207
|
-
status: 400,
|
|
208
|
-
body: { error: "invalid-request" },
|
|
209
|
-
log: (req) =>
|
|
210
|
-
req.log.error(
|
|
211
|
-
"stripe webhook body is not a Buffer — content-type parser misconfigured",
|
|
212
|
-
),
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const signature = request.headers["stripe-signature"];
|
|
217
|
-
if (typeof signature !== "string" || signature.length === 0) {
|
|
218
|
-
return {
|
|
219
|
-
ok: false,
|
|
220
|
-
status: 400,
|
|
221
|
-
body: { error: "signature-missing" },
|
|
222
|
-
log: () => undefined,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
try {
|
|
227
|
-
return {
|
|
228
|
-
ok: true,
|
|
229
|
-
event: verifyWebhookSignature(rawBody, signature, secret),
|
|
230
|
-
};
|
|
231
|
-
} catch (err) {
|
|
232
|
-
if (err instanceof StripeWebhookSignatureError) {
|
|
233
|
-
const reason = err.message;
|
|
234
|
-
return {
|
|
235
|
-
ok: false,
|
|
236
|
-
status: 400,
|
|
237
|
-
body: { error: "signature-invalid" },
|
|
238
|
-
log: (req) =>
|
|
239
|
-
req.log.warn(
|
|
240
|
-
{ reason },
|
|
241
|
-
"stripe webhook signature verification failed",
|
|
242
|
-
),
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
throw err;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export const stripeWebhookHandler = async (
|
|
250
|
-
request: FastifyRequest,
|
|
251
|
-
reply: FastifyReply,
|
|
252
|
-
): Promise<void> => {
|
|
253
|
-
// ── Signature first, claim second (D3, #5796) ────────────────────────────
|
|
254
|
-
//
|
|
255
|
-
// The four pre-dispatch rejections above are SETTLED before the licence is
|
|
256
|
-
// consulted, so an unsigned or unverifiable body never decides anything on
|
|
257
|
-
// the strength of the claim. Their replies are held rather than sent, so
|
|
258
|
-
// the unclaimed branch below can answer the module-disabled 404 instead of
|
|
259
|
-
// leaking a configured-but-off shape.
|
|
260
|
-
const settled = settleSignature(request);
|
|
261
|
-
const claim = await resolveCommerceClaim();
|
|
262
|
-
|
|
263
|
-
if (claim === "undetermined") {
|
|
264
|
-
// The licence read established no "no" — it threw, the licence is revoked
|
|
265
|
-
// mid-window, or one IS configured yet no longer verifies. Any of those
|
|
266
|
-
// may be a live commerce site whose buyer was already charged. Never drop
|
|
267
|
-
// on a guess: 503 + `Retry-After` so Stripe QUEUES the event and
|
|
268
|
-
// redelivers it once the read succeeds (security review 2026-09-16, High,
|
|
269
|
-
// widened by the operator in round 2). Applies to a signed event and an
|
|
270
|
-
// unsigned body alike; the unsigned one was going to be refused either way.
|
|
271
|
-
request.log.warn(
|
|
272
|
-
{
|
|
273
|
-
outcome: "license_undetermined",
|
|
274
|
-
signatureVerified: settled.ok,
|
|
275
|
-
...(settled.ok
|
|
276
|
-
? {
|
|
277
|
-
eventType: settled.event.type,
|
|
278
|
-
eventIdPrefix: settled.event.id.substring(0, 8),
|
|
279
|
-
}
|
|
280
|
-
: {}),
|
|
281
|
-
},
|
|
282
|
-
"stripe webhook held — commerce licence could not be read; asking Stripe to retry",
|
|
283
|
-
);
|
|
284
|
-
await reply
|
|
285
|
-
.code(LICENSE_UNDETERMINED_STATUS)
|
|
286
|
-
.header("Retry-After", String(LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS))
|
|
287
|
-
.send(LICENSE_UNDETERMINED_BODY);
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (claim === "unclaimed") {
|
|
292
|
-
if (settled.ok) {
|
|
293
|
-
// The licence was READ and positively does not carry `commerce`. Ack
|
|
294
|
-
// 200 and DROP: a 4xx here would have Stripe retry this event for days
|
|
295
|
-
// against a site that will never handle it. `warn`, not `info` — a
|
|
296
|
-
// discarded Stripe event is never routine.
|
|
297
|
-
request.log.warn(
|
|
298
|
-
{
|
|
299
|
-
eventType: settled.event.type,
|
|
300
|
-
eventIdPrefix: settled.event.id.substring(0, 8),
|
|
301
|
-
outcome: "dropped_no_commerce_claim",
|
|
302
|
-
},
|
|
303
|
-
"stripe webhook event dropped — deployment licence carries no commerce claim",
|
|
304
|
-
);
|
|
305
|
-
await reply.code(200).send({ received: true });
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
// Unsigned / unverifiable AND unclaimed: answer exactly as a build
|
|
309
|
-
// without the commerce module did — the module-disabled 404.
|
|
310
|
-
const err = commerceDisabledResponse();
|
|
311
|
-
await reply.code(err.status).send(err.body);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
if (!settled.ok) {
|
|
316
|
-
settled.log(request);
|
|
317
|
-
await reply.code(settled.status).send(settled.body);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const event = settled.event;
|
|
322
|
-
const eventIdPrefix = event.id.substring(0, 8);
|
|
323
|
-
|
|
324
|
-
// #8450 D10 (#6852 SEC-03) — an event whose livemode is not the key class
|
|
325
|
-
// this process admitted is never one of its own: a live hub must not act on
|
|
326
|
-
// a test-mode event anyone with the test key can forge, nor a test hub on a
|
|
327
|
-
// live one. Dropped before the idempotency check and any write; 200 so
|
|
328
|
-
// Stripe stops retrying, and NOT marked seen, so a corrected deployment
|
|
329
|
-
// still accepts a redelivery.
|
|
330
|
-
if ((event.livemode === true) !== (stripeKeyMode() === "live")) {
|
|
331
|
-
request.log.warn(
|
|
332
|
-
{ eventType: event.type, eventIdPrefix, outcome: "livemode_mismatch" },
|
|
333
|
-
"stripe webhook event dropped — livemode differs from the admitted key class",
|
|
334
|
-
);
|
|
335
|
-
await reply.code(200).send({ received: true });
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// #8932 D9 (contract W8) — an Offerings-stamped event belongs to the
|
|
340
|
-
// per-Organization endpoint, which must still be able to act on it, so it
|
|
341
|
-
// is acked here BEFORE `markSeen` and nothing is written. Presence decides,
|
|
342
|
-
// whatever the value; any other event takes the path below unchanged.
|
|
343
|
-
if (
|
|
344
|
-
offeringsStampSourcesOf(event).some((source) =>
|
|
345
|
-
Object.hasOwn(source, OFFERINGS_STAMP_KEY),
|
|
346
|
-
)
|
|
347
|
-
) {
|
|
348
|
-
request.log.info(
|
|
349
|
-
{ eventType: event.type, eventIdPrefix, outcome: "offerings_path" },
|
|
350
|
-
"stripe webhook event left to the per-Organization endpoint",
|
|
351
|
-
);
|
|
352
|
-
await reply.code(200).send({ received: true });
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
request.log.info(
|
|
357
|
-
{ eventType: event.type, eventIdPrefix },
|
|
358
|
-
"stripe webhook event received",
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
// AILK-layer idempotency. Stripe guarantees at-least-once delivery and
|
|
362
|
-
// dashboard "resend" replays a previously-delivered event verbatim. Skip
|
|
363
|
-
// side effects on the second arrival; still 200 so Stripe stops retrying.
|
|
364
|
-
if (hasSeen(event.id)) {
|
|
365
|
-
request.log.info(
|
|
366
|
-
{ eventType: event.type, eventIdPrefix },
|
|
367
|
-
"duplicate stripe webhook event — idempotent short-circuit",
|
|
368
|
-
);
|
|
369
|
-
await reply.code(200).send({ received: true });
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
markSeen(event.id);
|
|
373
|
-
|
|
374
|
-
// #8024 — the billing families' site filter, after `markSeen` as #7868's
|
|
375
|
-
// in-arm check is, so a foreign redelivery is a duplicate. One gate rather
|
|
376
|
-
// than a check per arm: the `default` arm's types are filtered too.
|
|
377
|
-
const foreignStamp = siteStampSourcesOf(event).find(isForeignSiteStamp);
|
|
378
|
-
if (foreignStamp) {
|
|
379
|
-
logForeignSiteSkip(request, {
|
|
380
|
-
stamp: foreignStamp[SITE_STAMP_KEY],
|
|
381
|
-
eventType: event.type,
|
|
382
|
-
eventIdPrefix,
|
|
383
|
-
});
|
|
384
|
-
await reply.code(200).send({ received: true });
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
switch (event.type) {
|
|
389
|
-
case "customer.created": {
|
|
390
|
-
const customer = event.data.object as {
|
|
391
|
-
id: string;
|
|
392
|
-
email?: string | null;
|
|
393
|
-
metadata?: Record<string, string> | null;
|
|
394
|
-
};
|
|
395
|
-
// Billing-entity routing (spec D2): metadata.organizationId present →
|
|
396
|
-
// the Organization path, which a customer's own metadata can never
|
|
397
|
-
// prove (#9067 D13: only a recorded plan session binds), so it writes
|
|
398
|
-
// nothing; absent → the User path (single-tenant, degrade-clean).
|
|
399
|
-
const organizationId = customer.metadata?.["organizationId"] ?? null;
|
|
400
|
-
if (organizationId) {
|
|
401
|
-
await upsertOrgBillingIdentity(request, {
|
|
402
|
-
organizationId,
|
|
403
|
-
customerId: customer.id,
|
|
404
|
-
sessionId: null,
|
|
405
|
-
eventIdPrefix,
|
|
406
|
-
});
|
|
407
|
-
} else {
|
|
408
|
-
await upsertBillingIdentity(request, {
|
|
409
|
-
customerId: customer.id,
|
|
410
|
-
userId: signalOf(customer.metadata),
|
|
411
|
-
eventType: event.type,
|
|
412
|
-
eventIdPrefix,
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
break;
|
|
416
|
-
}
|
|
417
|
-
case "checkout.session.completed": {
|
|
418
|
-
const session = event.data.object as CheckoutSessionObject;
|
|
419
|
-
if (isForeignSiteStamp(session.metadata)) {
|
|
420
|
-
logForeignSiteSkip(request, {
|
|
421
|
-
stamp: session.metadata?.[SITE_STAMP_KEY],
|
|
422
|
-
eventType: event.type,
|
|
423
|
-
eventIdPrefix,
|
|
424
|
-
});
|
|
425
|
-
break;
|
|
426
|
-
}
|
|
427
|
-
const props = {
|
|
428
|
-
amount: session.amount_total ?? undefined,
|
|
429
|
-
currency: session.currency ?? undefined,
|
|
430
|
-
productId:
|
|
431
|
-
session.metadata?.["productId"] ??
|
|
432
|
-
session.metadata?.["product_id"] ??
|
|
433
|
-
undefined,
|
|
434
|
-
eventType: event.type,
|
|
435
|
-
eventIdPrefix,
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
// Billing-entity routing (spec D2): metadata.organizationId → Organization
|
|
439
|
-
// path; absent → the User path (single-tenant).
|
|
440
|
-
const organizationId = session.metadata?.["organizationId"] ?? null;
|
|
441
|
-
let billingTask: Promise<void> = Promise.resolve();
|
|
442
|
-
if (session.customer) {
|
|
443
|
-
billingTask = organizationId
|
|
444
|
-
? upsertOrgBillingIdentity(request, {
|
|
445
|
-
organizationId,
|
|
446
|
-
customerId: session.customer,
|
|
447
|
-
sessionId: session.id ?? null,
|
|
448
|
-
eventIdPrefix,
|
|
449
|
-
})
|
|
450
|
-
: upsertBillingIdentity(request, {
|
|
451
|
-
customerId: session.customer,
|
|
452
|
-
userId: signalOf(session.metadata),
|
|
453
|
-
eventType: event.type,
|
|
454
|
-
eventIdPrefix,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Telemetry, billing upsert and the recommender stamp are independent —
|
|
459
|
-
// run in parallel (the stamp is a no-op without metadata.flowCheckoutId).
|
|
460
|
-
// Telemetry, billing upsert, the recommender stamp and the
|
|
461
|
-
// request-and-confirm stamp are independent — run in parallel. The
|
|
462
|
-
// request stamp is BESIDE the existing three, never instead of them,
|
|
463
|
-
// and is a no-op without `session.payment_link` (#5970).
|
|
464
|
-
await Promise.all([
|
|
465
|
-
fireTelemetryWithTimeout(request, "purchase-completed", props),
|
|
466
|
-
billingTask,
|
|
467
|
-
// #852 D6 — the Person step runs only after the stamp has settled,
|
|
468
|
-
// so it reads the row this very session completed.
|
|
469
|
-
stampFlowCheckout(request, {
|
|
470
|
-
session,
|
|
471
|
-
eventIdPrefix,
|
|
472
|
-
outcome: "completed",
|
|
473
|
-
})
|
|
474
|
-
.then(() =>
|
|
475
|
-
recordFlowCheckoutPerson(request, {
|
|
476
|
-
session,
|
|
477
|
-
livemode: event.livemode === true,
|
|
478
|
-
eventIdPrefix,
|
|
479
|
-
}),
|
|
480
|
-
)
|
|
481
|
-
// #8765 D14 — after the Person step, whose Person is a flow
|
|
482
|
-
// checkout's grant subject.
|
|
483
|
-
.then((flowPerson) =>
|
|
484
|
-
recordCheckoutTermsGrant(request, {
|
|
485
|
-
session,
|
|
486
|
-
livemode: event.livemode === true,
|
|
487
|
-
eventIdPrefix,
|
|
488
|
-
flowPerson,
|
|
489
|
-
}),
|
|
490
|
-
),
|
|
491
|
-
stampRequestPaid(request, { session, eventIdPrefix }),
|
|
492
|
-
stampProposalPaid(request, { session, eventId: event.id, eventIdPrefix }),
|
|
493
|
-
]);
|
|
494
|
-
break;
|
|
495
|
-
}
|
|
496
|
-
case "invoice.paid": {
|
|
497
|
-
// #5970 — the hosted-invoice rung of request-and-confirm. Nothing writes
|
|
498
|
-
// `stripeInvoiceId` at this issue's boundary (the invoice path needs a
|
|
499
|
-
// `createCustomer` export the stripe-client does not have), so this arm
|
|
500
|
-
// is INERT today and exists so that rung lands additively: no second
|
|
501
|
-
// migration, no second webhook change. It carries the same guarded
|
|
502
|
-
// stamp, so it is already correct when a writer appears.
|
|
503
|
-
const invoice = event.data.object as { id?: string };
|
|
504
|
-
await stampRequestPaidByInvoice(request, {
|
|
505
|
-
invoiceId: invoice.id ?? null,
|
|
506
|
-
eventIdPrefix,
|
|
507
|
-
});
|
|
508
|
-
break;
|
|
509
|
-
}
|
|
510
|
-
case "checkout.session.expired": {
|
|
511
|
-
const session = event.data.object as CheckoutSessionObject;
|
|
512
|
-
if (isForeignSiteStamp(session.metadata)) {
|
|
513
|
-
logForeignSiteSkip(request, {
|
|
514
|
-
stamp: session.metadata?.[SITE_STAMP_KEY],
|
|
515
|
-
eventType: event.type,
|
|
516
|
-
eventIdPrefix,
|
|
517
|
-
});
|
|
518
|
-
break;
|
|
519
|
-
}
|
|
520
|
-
if (!session.metadata?.["flowCheckoutId"]) {
|
|
521
|
-
// Not a recommender session — the same low-noise line the default
|
|
522
|
-
// arm emitted for this event before #4838 handled it.
|
|
523
|
-
request.log.info(
|
|
524
|
-
{ eventType: event.type, eventIdPrefix },
|
|
525
|
-
"unhandled webhook event",
|
|
526
|
-
);
|
|
527
|
-
break;
|
|
528
|
-
}
|
|
529
|
-
await stampFlowCheckout(request, {
|
|
530
|
-
session,
|
|
531
|
-
eventIdPrefix,
|
|
532
|
-
outcome: "expired",
|
|
533
|
-
});
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
case "invoice.payment_failed": {
|
|
537
|
-
// Dunning arm 1 of 2 (#6034 FR-1) — arms dunning for the matched
|
|
538
|
-
// subscriber; never revokes on its own. See README.md § Dunning.
|
|
539
|
-
const invoice = event.data.object as InvoiceObject;
|
|
540
|
-
await armDunning(request, {
|
|
541
|
-
invoice,
|
|
542
|
-
eventId: event.id,
|
|
543
|
-
eventCreated: event.created,
|
|
544
|
-
eventIdPrefix,
|
|
545
|
-
});
|
|
546
|
-
break;
|
|
547
|
-
}
|
|
548
|
-
case "customer.subscription.updated": {
|
|
549
|
-
// Dunning arm 2 of 2 (#6034 FR-2/FR-4) — maps the narrowed Stripe
|
|
550
|
-
// status onto the recorded lifecycle state; `canceled` revokes access
|
|
551
|
-
// only once the tenant's grace period has expired. See README.md
|
|
552
|
-
// § Dunning.
|
|
553
|
-
const subscription = event.data.object as SubscriptionObject;
|
|
554
|
-
await applySubscriptionStatus(request, {
|
|
555
|
-
subscription,
|
|
556
|
-
eventId: event.id,
|
|
557
|
-
eventCreated: event.created,
|
|
558
|
-
eventIdPrefix,
|
|
559
|
-
});
|
|
560
|
-
break;
|
|
561
|
-
}
|
|
562
|
-
default:
|
|
563
|
-
request.log.info(
|
|
564
|
-
{ eventType: event.type, eventIdPrefix },
|
|
565
|
-
"unhandled webhook event",
|
|
566
|
-
);
|
|
567
|
-
break;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
await reply.code(200).send({ received: true });
|
|
571
|
-
};
|
|
572
|
-
|
|
573
|
-
type CheckoutSessionObject = {
|
|
574
|
-
id?: string;
|
|
575
|
-
/**
|
|
576
|
-
* Set when the session was opened UNDER a Payment Link — which is how a
|
|
577
|
-
* request-and-confirm purchase arrives (#5970 D8). It is the artifact id
|
|
578
|
-
* AC-3 matches on, and it is absent on an ordinary checkout.
|
|
579
|
-
*/
|
|
580
|
-
payment_link?: string | null;
|
|
581
|
-
payment_status?: string | null;
|
|
582
|
-
amount_total?: number | null;
|
|
583
|
-
currency?: string | null;
|
|
584
|
-
customer?: string | null;
|
|
585
|
-
customer_details?: { email?: string | null } | null;
|
|
586
|
-
/** The terms box's answer (#8765 D14): `accepted` when the buyer ticked it. */
|
|
587
|
-
consent?: { terms_of_service?: string | null } | null;
|
|
588
|
-
metadata?: Record<string, string> | null;
|
|
589
|
-
};
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* The one log line for an event another site stamped (#7868 D3, #8024 D3).
|
|
593
|
-
* Carries the foreign stamp (first 64 characters) and this site's own alias,
|
|
594
|
-
* so a spoke event correctly skipped can be told from a hub event whose alias
|
|
595
|
-
* drifted — site labels only: it takes the stamp, never the object, so no
|
|
596
|
-
* buyer field, customer id or amount can reach it.
|
|
597
|
-
*/
|
|
598
|
-
function logForeignSiteSkip(
|
|
599
|
-
request: FastifyRequest,
|
|
600
|
-
{
|
|
601
|
-
stamp,
|
|
602
|
-
eventType,
|
|
603
|
-
eventIdPrefix,
|
|
604
|
-
}: {
|
|
605
|
-
stamp: string | undefined;
|
|
606
|
-
eventType: string;
|
|
607
|
-
eventIdPrefix: string;
|
|
608
|
-
},
|
|
609
|
-
): void {
|
|
610
|
-
request.log.info(
|
|
611
|
-
{
|
|
612
|
-
eventType,
|
|
613
|
-
eventIdPrefix,
|
|
614
|
-
outcome: "foreign_site",
|
|
615
|
-
stamp: stamp?.slice(0, 64),
|
|
616
|
-
ownSite: currentSiteId(),
|
|
617
|
-
},
|
|
618
|
-
"stripe event stamped for another site — skipped",
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/** The object type whose metadata carries the site for each billing family (#8024 D2). */
|
|
623
|
-
function stampedObjectTypeOf(eventType: string): "subscription" | "customer" | "invoice" | null {
|
|
624
|
-
if (eventType.startsWith("customer.subscription.")) return "subscription";
|
|
625
|
-
if (eventType.startsWith("customer.")) return "customer";
|
|
626
|
-
if (eventType.startsWith("invoice.")) return "invoice";
|
|
627
|
-
return null;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** `value` when it is a non-null object, else `null` — every step of a stamp read is guarded (NF1). */
|
|
631
|
-
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
|
632
|
-
return typeof value === "object" && value !== null
|
|
633
|
-
? (value as Record<string, unknown>)
|
|
634
|
-
: null;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* The metadata objects that may carry an event's site stamp (#8024 D2), in the
|
|
639
|
-
* order they are checked: a Subscription's or a Customer's own metadata; an
|
|
640
|
-
* invoice's own metadata, then the subscription snapshot at the pinned API
|
|
641
|
-
* version (`parent.subscription_details.metadata`), then at a pre-`basil` one
|
|
642
|
-
* (`subscription_details.metadata`). Empty for every other event type, and
|
|
643
|
-
* when `data.object.object` is not the family's own type — a
|
|
644
|
-
* `customer.source.*` card, say. Reads the payload alone and never throws on
|
|
645
|
-
* any shape: an absent, `null` or non-object step yields no source, and
|
|
646
|
-
* `isForeignSiteStamp` treats a non-string stamp as unstamped.
|
|
647
|
-
*/
|
|
648
|
-
function siteStampSourcesOf(event: {
|
|
649
|
-
type: string;
|
|
650
|
-
data?: unknown;
|
|
651
|
-
}): Record<string, string>[] {
|
|
652
|
-
const objectType = stampedObjectTypeOf(event.type);
|
|
653
|
-
const object = objectOrNull(objectOrNull(event.data)?.["object"]);
|
|
654
|
-
if (objectType === null || object?.["object"] !== objectType) return [];
|
|
655
|
-
|
|
656
|
-
const candidates =
|
|
657
|
-
objectType === "invoice"
|
|
658
|
-
? [
|
|
659
|
-
object["metadata"],
|
|
660
|
-
objectOrNull(objectOrNull(object["parent"])?.["subscription_details"])?.[
|
|
661
|
-
"metadata"
|
|
662
|
-
],
|
|
663
|
-
objectOrNull(object["subscription_details"])?.["metadata"],
|
|
664
|
-
]
|
|
665
|
-
: [object["metadata"]];
|
|
666
|
-
// The cast only narrows the value type for `isForeignSiteStamp`, which
|
|
667
|
-
// reads the stamp as `unknown` and `typeof`-checks it.
|
|
668
|
-
return candidates
|
|
669
|
-
.map(objectOrNull)
|
|
670
|
-
.filter((source) => source !== null) as Record<string, string>[];
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/** The metadata key the hub's Offerings checkout stamps on its sessions (contract A6, #8932 D9). */
|
|
674
|
-
const OFFERINGS_STAMP_KEY = "ailk_offering";
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Where an Offerings stamp may sit (#8932 D9): every site-stamp source
|
|
678
|
-
* (`siteStampSourcesOf` — the Subscription, the Customer and an invoice's
|
|
679
|
-
* three) plus, for a `checkout.session.*` event whose object is a session,
|
|
680
|
-
* the session's own metadata.
|
|
681
|
-
*/
|
|
682
|
-
function offeringsStampSourcesOf(event: {
|
|
683
|
-
type: string;
|
|
684
|
-
data?: unknown;
|
|
685
|
-
}): Record<string, string>[] {
|
|
686
|
-
const sources = siteStampSourcesOf(event);
|
|
687
|
-
const object = objectOrNull(objectOrNull(event.data)?.["object"]);
|
|
688
|
-
if (event.type.startsWith("checkout.session.") && object?.["object"] === "checkout.session") {
|
|
689
|
-
const metadata = objectOrNull(object["metadata"]);
|
|
690
|
-
if (metadata !== null) sources.push(metadata as Record<string, string>);
|
|
691
|
-
}
|
|
692
|
-
return sources;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* Minimal projected shape of a Stripe Invoice object for the dunning arms
|
|
697
|
-
* (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
|
|
698
|
-
* like `CheckoutSessionObject.payment_link` above, either can arrive as an
|
|
699
|
-
* object rather than a string on a future `expand` or API-version change, so
|
|
700
|
-
* both are `typeof`-guarded at the call site (§ D9) and never trusted from
|
|
701
|
-
* this cast.
|
|
702
|
-
*/
|
|
703
|
-
type InvoiceObject = {
|
|
704
|
-
subscription?: string | null;
|
|
705
|
-
customer?: string | null;
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Minimal projected shape of a Stripe Subscription object for the dunning
|
|
710
|
-
* arms (#6034 FR-2). `customer` carries the same expandable-field risk as
|
|
711
|
-
* `InvoiceObject.customer` and is guarded the same way.
|
|
712
|
-
*/
|
|
713
|
-
type SubscriptionObject = {
|
|
714
|
-
id?: string;
|
|
715
|
-
customer?: string | null;
|
|
716
|
-
status?: string;
|
|
717
|
-
canceled_at?: number | null;
|
|
718
|
-
ended_at?: number | null;
|
|
719
|
-
};
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Stamp the recommender's FlowCheckout buyer row from a terminal Checkout
|
|
723
|
-
* Session event (#4838).
|
|
724
|
-
*
|
|
725
|
-
* The `status: "created"` guard on the update IS the idempotency key (D1):
|
|
726
|
-
* `count === 1` → stamped; `count === 0` → the row is already terminal, or the
|
|
727
|
-
* id is unknown — a warning, never a retry (Stripe would re-fire telemetry +
|
|
728
|
-
* billing on eventual success). A `completed` session whose
|
|
729
|
-
* `payment_status` is not `"paid"` logs `unpaid` and leaves the row `created`. A charged amount that differs from the quoted
|
|
730
|
-
* `amountMinor` is logged with both integers and the stamp proceeds (D2). Log
|
|
731
|
-
* lines carry only `eventIdPrefix`, an 8-char `flowCheckoutIdPrefix`, an
|
|
732
|
-
* `outcome`, and the two integers — never the email, customer id, or permalink.
|
|
733
|
-
*
|
|
734
|
-
* Never throws — the handler's "always 200 for a verified event" contract.
|
|
735
|
-
*/
|
|
736
|
-
async function stampFlowCheckout(
|
|
737
|
-
request: FastifyRequest,
|
|
738
|
-
{
|
|
739
|
-
session,
|
|
740
|
-
eventIdPrefix,
|
|
741
|
-
outcome,
|
|
742
|
-
}: {
|
|
743
|
-
session: CheckoutSessionObject;
|
|
744
|
-
eventIdPrefix: string;
|
|
745
|
-
outcome: "completed" | "expired";
|
|
746
|
-
},
|
|
747
|
-
): Promise<void> {
|
|
748
|
-
const flowCheckoutId = session.metadata?.["flowCheckoutId"];
|
|
749
|
-
if (!flowCheckoutId) return;
|
|
750
|
-
const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
|
|
751
|
-
|
|
752
|
-
// A `completed` session is only a purchase once Stripe reports it paid, OR
|
|
753
|
-
// reports nothing was due (`no_payment_required` — a 100%-off promo code,
|
|
754
|
-
// #4862, still a Stripe-confirmed completion). A delayed-notification or
|
|
755
|
-
// genuinely unpaid completion leaves the row `created` (code + security
|
|
756
|
-
// review, PR #4855).
|
|
757
|
-
if (
|
|
758
|
-
outcome === "completed" &&
|
|
759
|
-
session.payment_status !== "paid" &&
|
|
760
|
-
session.payment_status !== "no_payment_required"
|
|
761
|
-
) {
|
|
762
|
-
request.log.warn(
|
|
763
|
-
{ eventIdPrefix, flowCheckoutIdPrefix, outcome: "unpaid" },
|
|
764
|
-
"flow checkout: completed session is not paid — not stamping",
|
|
765
|
-
);
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
try {
|
|
770
|
-
if (outcome === "completed" && typeof session.amount_total === "number") {
|
|
771
|
-
const row = await prisma.flowCheckout.findUnique({
|
|
772
|
-
where: { id: flowCheckoutId },
|
|
773
|
-
select: { amountMinor: true },
|
|
774
|
-
});
|
|
775
|
-
if (row && row.amountMinor !== session.amount_total) {
|
|
776
|
-
request.log.warn(
|
|
777
|
-
{
|
|
778
|
-
eventIdPrefix,
|
|
779
|
-
flowCheckoutIdPrefix,
|
|
780
|
-
outcome: "amount_mismatch",
|
|
781
|
-
expected: row.amountMinor,
|
|
782
|
-
received: session.amount_total,
|
|
783
|
-
},
|
|
784
|
-
"flow checkout: charged amount differs from the quoted amount — stamping anyway",
|
|
785
|
-
);
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
const result = await prisma.flowCheckout.updateMany({
|
|
790
|
-
where: { id: flowCheckoutId, status: "created" },
|
|
791
|
-
data:
|
|
792
|
-
outcome === "completed"
|
|
793
|
-
? {
|
|
794
|
-
status: "completed",
|
|
795
|
-
completedAt: new Date(),
|
|
796
|
-
stripeSessionId: session.id ?? null,
|
|
797
|
-
stripeCustomerId: session.customer ?? null,
|
|
798
|
-
}
|
|
799
|
-
: { status: "expired" },
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
if (result.count === 1) {
|
|
803
|
-
request.log.info(
|
|
804
|
-
{ eventIdPrefix, flowCheckoutIdPrefix, outcome },
|
|
805
|
-
"flow checkout stamped",
|
|
806
|
-
);
|
|
807
|
-
} else {
|
|
808
|
-
request.log.warn(
|
|
809
|
-
{ eventIdPrefix, flowCheckoutIdPrefix, outcome: "stale_or_unknown" },
|
|
810
|
-
"flow checkout: no created row matched — replay, late terminal event, or unknown id",
|
|
811
|
-
);
|
|
812
|
-
}
|
|
813
|
-
} catch (err) {
|
|
814
|
-
request.log.error(
|
|
815
|
-
{
|
|
816
|
-
eventIdPrefix,
|
|
817
|
-
flowCheckoutIdPrefix,
|
|
818
|
-
outcome: "stamp_failed",
|
|
819
|
-
errName: err instanceof Error ? err.name : "UnknownError",
|
|
820
|
-
},
|
|
821
|
-
"flow checkout stamp failed — continuing",
|
|
822
|
-
);
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
/**
|
|
827
|
-
* The hub checkout's Person (#852 D6): after `stampFlowCheckout`, find or
|
|
828
|
-
* create the Person of the completed checkout's lead
|
|
829
|
-
* (`services/person.ts`'s `resolveCheckoutPerson`). Runs only for a paid (or
|
|
830
|
-
* nothing-due) session of THIS site — the foreign-site filter has already
|
|
831
|
-
* returned — that carries `metadata.flowCheckoutId`. The customer id's ONE
|
|
832
|
-
* source is `session.customer`, written onto the Person only for a
|
|
833
|
-
* live-mode event (SEC-852-A). A replay matches the same completed row, so a
|
|
834
|
-
* delivery whose Person step failed is repaired by the next one.
|
|
835
|
-
*
|
|
836
|
-
* Logs `{ eventIdPrefix, flowCheckoutIdPrefix, outcome }` only — never the
|
|
837
|
-
* email or the customer id. Answers the Person, or `null` when the step did
|
|
838
|
-
* not run or found none (#8765 D14). Never throws: the response stays 200.
|
|
839
|
-
*/
|
|
840
|
-
async function recordFlowCheckoutPerson(
|
|
841
|
-
request: FastifyRequest,
|
|
842
|
-
{
|
|
843
|
-
session,
|
|
844
|
-
livemode,
|
|
845
|
-
eventIdPrefix,
|
|
846
|
-
}: {
|
|
847
|
-
session: CheckoutSessionObject;
|
|
848
|
-
livemode: boolean;
|
|
849
|
-
eventIdPrefix: string;
|
|
850
|
-
},
|
|
851
|
-
): Promise<CheckoutPersonResult["person"]> {
|
|
852
|
-
const flowCheckoutId = session.metadata?.["flowCheckoutId"];
|
|
853
|
-
if (!flowCheckoutId) return null;
|
|
854
|
-
if (
|
|
855
|
-
session.payment_status !== "paid" &&
|
|
856
|
-
session.payment_status !== "no_payment_required"
|
|
857
|
-
) {
|
|
858
|
-
return null;
|
|
859
|
-
}
|
|
860
|
-
const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
|
|
861
|
-
let result: CheckoutPersonResult;
|
|
862
|
-
try {
|
|
863
|
-
result = await resolveCheckoutPerson({
|
|
864
|
-
flowCheckoutId,
|
|
865
|
-
stripeSessionId: session.id,
|
|
866
|
-
customer: session.customer,
|
|
867
|
-
livemode,
|
|
868
|
-
});
|
|
869
|
-
} catch {
|
|
870
|
-
result = { outcome: "person_failed", person: null };
|
|
871
|
-
}
|
|
872
|
-
const line = { eventIdPrefix, flowCheckoutIdPrefix, outcome: result.outcome };
|
|
873
|
-
if (result.outcome === "person_failed") {
|
|
874
|
-
request.log.error(line, "flow checkout person failed — continuing");
|
|
875
|
-
} else {
|
|
876
|
-
request.log.info(line, "flow checkout person");
|
|
877
|
-
}
|
|
878
|
-
return result.person;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/** The metadata key the Checkout creators stamp with the terms the box showed (#8765 D12). */
|
|
882
|
-
const TERMS_STAMP_KEY = "ailk_terms";
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* `<purposeId>@<policyVersion>` of the account terms as the registry reads
|
|
886
|
-
* them now — the only stamp a grant is recorded for (SEC-8765-E) — and that
|
|
887
|
-
* version, which `recordCheckoutConsent` checks against the site's registry.
|
|
888
|
-
* Derived, never written down, so a version bump retires every older stamp.
|
|
889
|
-
*/
|
|
890
|
-
function currentTermsStamp(): { stamp: string; policyVersion: number } | null {
|
|
891
|
-
const purpose = consentPurposeById(
|
|
892
|
-
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
893
|
-
ACCOUNT_TERMS_PURPOSE_ID,
|
|
894
|
-
);
|
|
895
|
-
return purpose
|
|
896
|
-
? {
|
|
897
|
-
stamp: `${purpose.id}@${purpose.policyVersion}`,
|
|
898
|
-
policyVersion: purpose.policyVersion,
|
|
899
|
-
}
|
|
900
|
-
: null;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* The buyer's terms grant (#8765 D14). Writes nothing — and logs why — for a
|
|
905
|
-
* session whose box was not accepted (`consent_absent`: Stripe enforced the
|
|
906
|
-
* box, so the order completes regardless), one that does not carry the
|
|
907
|
-
* current `ailk_terms` stamp (`terms_unstamped`: a Payment Link, an invoice,
|
|
908
|
-
* an older session or other terms), or one whose buyer has no Person
|
|
909
|
-
* (`person_unresolved`). Otherwise `recordCheckoutConsent` writes at most one
|
|
910
|
-
* row per Person and session (`recorded` / `unchanged`), or none when the
|
|
911
|
-
* site's registry has moved past the stamped version (`version_changed`).
|
|
912
|
-
*
|
|
913
|
-
* Logs `{ event, outcome, eventIdPrefix }` only (plus `errName` on `failed`)
|
|
914
|
-
* — never an email or a Person id. Never throws: the response stays 200.
|
|
915
|
-
*/
|
|
916
|
-
async function recordCheckoutTermsGrant(
|
|
917
|
-
request: FastifyRequest,
|
|
918
|
-
{
|
|
919
|
-
session,
|
|
920
|
-
livemode,
|
|
921
|
-
eventIdPrefix,
|
|
922
|
-
flowPerson,
|
|
923
|
-
}: {
|
|
924
|
-
session: CheckoutSessionObject;
|
|
925
|
-
livemode: boolean;
|
|
926
|
-
eventIdPrefix: string;
|
|
927
|
-
/** A flow checkout's Person, from `recordFlowCheckoutPerson`. */
|
|
928
|
-
flowPerson: CheckoutPersonResult["person"];
|
|
929
|
-
},
|
|
930
|
-
): Promise<void> {
|
|
931
|
-
const line = (outcome: string) => ({
|
|
932
|
-
event: "checkout-consent",
|
|
933
|
-
outcome,
|
|
934
|
-
eventIdPrefix,
|
|
935
|
-
});
|
|
936
|
-
try {
|
|
937
|
-
if (session.consent?.terms_of_service !== "accepted") {
|
|
938
|
-
request.log.info(
|
|
939
|
-
line("consent_absent"),
|
|
940
|
-
"checkout terms grant: box not accepted",
|
|
941
|
-
);
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
const stamp: unknown = session.metadata?.[TERMS_STAMP_KEY];
|
|
945
|
-
const expected = currentTermsStamp();
|
|
946
|
-
if (expected === null || stamp !== expected.stamp) {
|
|
947
|
-
request.log.info(
|
|
948
|
-
line("terms_unstamped"),
|
|
949
|
-
"checkout terms grant: session carries no current terms stamp",
|
|
950
|
-
);
|
|
951
|
-
return;
|
|
952
|
-
}
|
|
953
|
-
const sessionId = session.id;
|
|
954
|
-
const buyer =
|
|
955
|
-
typeof sessionId === "string" && sessionId !== ""
|
|
956
|
-
? await checkoutBuyerPerson(session, livemode, flowPerson)
|
|
957
|
-
: null;
|
|
958
|
-
if (buyer === null || typeof sessionId !== "string") {
|
|
959
|
-
request.log.info(
|
|
960
|
-
line("person_unresolved"),
|
|
961
|
-
"checkout terms grant: no Person for this buyer",
|
|
962
|
-
);
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
965
|
-
const outcome = await recordCheckoutConsent({
|
|
966
|
-
workspaceId: buyer.workspaceId,
|
|
967
|
-
personId: buyer.personId,
|
|
968
|
-
sessionId,
|
|
969
|
-
policyVersion: expected.policyVersion,
|
|
970
|
-
});
|
|
971
|
-
request.log.info(line(outcome), "checkout terms grant");
|
|
972
|
-
} catch (err) {
|
|
973
|
-
// The error's class only (a Prisma error's message can carry values).
|
|
974
|
-
request.log.error(
|
|
975
|
-
{
|
|
976
|
-
...line("failed"),
|
|
977
|
-
errName: err instanceof Error ? err.name : "UnknownError",
|
|
978
|
-
},
|
|
979
|
-
"checkout terms grant failed — continuing",
|
|
980
|
-
);
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
/**
|
|
985
|
-
* Stamp `paidAt` on a request-and-confirm callback row (#5970 AC-3).
|
|
986
|
-
*
|
|
987
|
-
* THE MATCH KEY IS THE ARTIFACT ID, not metadata. Paying a Payment Link opens
|
|
988
|
-
* a Checkout Session under that link, so the paid event is the ordinary
|
|
989
|
-
* `checkout.session.completed` — and the session carries
|
|
990
|
-
* `payment_link: "plink_…"`, which is exactly the id the confirm route stored
|
|
991
|
-
* on the row. A session with no `payment_link` is an ordinary checkout and is
|
|
992
|
-
* skipped BEFORE any query, so the existing checkout suites see no new DB
|
|
993
|
-
* traffic whatsoever.
|
|
994
|
-
*
|
|
995
|
-
* TWO IDEMPOTENCY LAYERS, and only the second is durable. A replayed event
|
|
996
|
-
* never reaches here at all — `hasSeen` short-circuits it upstream. But that
|
|
997
|
-
* store is an in-process TTL `Map`: restart the process and the id is gone. So
|
|
998
|
-
* the `paidAt: null` guard on the update IS the idempotency key, the same way
|
|
999
|
-
* `status: "created"` is `stampFlowCheckout`'s. `count === 1` ⇒ stamped once;
|
|
1000
|
-
* `count === 0` ⇒ already paid, or an id this deployment never minted — a
|
|
1001
|
-
* warning, never a throw and never a retry (Stripe would re-fire the sibling
|
|
1002
|
-
* telemetry and billing tasks on eventual success).
|
|
1003
|
-
*
|
|
1004
|
-
* A `completed` session stamps only when Stripe reports it `paid`, or reports
|
|
1005
|
-
* nothing was due (`no_payment_required` — a 100%-off promotion is still a
|
|
1006
|
-
* Stripe-confirmed completion). The same two values `stampFlowCheckout`
|
|
1007
|
-
* accepts, for the same reason.
|
|
1008
|
-
*
|
|
1009
|
-
* `stripeCheckoutSessionId` is written from the same event for support
|
|
1010
|
-
* lookups. Nothing ever matches on it.
|
|
1011
|
-
*
|
|
1012
|
-
* Never throws — the handler's "always 200 for a verified event" contract.
|
|
1013
|
-
*/
|
|
1014
|
-
async function stampRequestPaid(
|
|
1015
|
-
request: FastifyRequest,
|
|
1016
|
-
{
|
|
1017
|
-
session,
|
|
1018
|
-
eventIdPrefix,
|
|
1019
|
-
}: { session: CheckoutSessionObject; eventIdPrefix: string },
|
|
1020
|
-
): Promise<void> {
|
|
1021
|
-
const paymentLinkId = session.payment_link;
|
|
1022
|
-
// `typeof`, not just truthiness (#5970 security review, finding 7):
|
|
1023
|
-
// `payment_link` is an EXPANDABLE Stripe field, so a future `expand` or an
|
|
1024
|
-
// API-version change could deliver it as an object — on which `.substring`
|
|
1025
|
-
// throws a TypeError that would escape this function, reject the
|
|
1026
|
-
// `Promise.all` above, and turn a verified event into a 500 that Stripe then
|
|
1027
|
-
// retries, taking the sibling telemetry and billing tasks down with it. This
|
|
1028
|
-
// function's contract is that it never throws.
|
|
1029
|
-
if (typeof paymentLinkId !== "string" || paymentLinkId === "") return;
|
|
1030
|
-
const paymentLinkIdPrefix = paymentLinkId.substring(0, 8);
|
|
1031
|
-
|
|
1032
|
-
if (
|
|
1033
|
-
session.payment_status !== "paid" &&
|
|
1034
|
-
session.payment_status !== "no_payment_required"
|
|
1035
|
-
) {
|
|
1036
|
-
request.log.warn(
|
|
1037
|
-
{ eventIdPrefix, paymentLinkIdPrefix, outcome: "unpaid" },
|
|
1038
|
-
"request-confirm: completed session is not paid — not stamping",
|
|
1039
|
-
);
|
|
1040
|
-
return;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
try {
|
|
1044
|
-
const result = await prisma.callback.updateMany({
|
|
1045
|
-
where: { stripePaymentLinkId: paymentLinkId, paidAt: null },
|
|
1046
|
-
data: {
|
|
1047
|
-
paidAt: new Date(),
|
|
1048
|
-
stripeCheckoutSessionId: session.id ?? null,
|
|
1049
|
-
},
|
|
1050
|
-
});
|
|
1051
|
-
|
|
1052
|
-
if (result.count === 1) {
|
|
1053
|
-
request.log.info(
|
|
1054
|
-
{ eventIdPrefix, paymentLinkIdPrefix, outcome: "paid" },
|
|
1055
|
-
"request-confirm stamped paid",
|
|
1056
|
-
);
|
|
1057
|
-
} else {
|
|
1058
|
-
request.log.warn(
|
|
1059
|
-
{ eventIdPrefix, paymentLinkIdPrefix, outcome: "stale_or_unknown" },
|
|
1060
|
-
"request-confirm: no unpaid row matched — replay, late event, or an unknown payment link",
|
|
1061
|
-
);
|
|
1062
|
-
}
|
|
1063
|
-
} catch (err) {
|
|
1064
|
-
request.log.error(
|
|
1065
|
-
{
|
|
1066
|
-
eventIdPrefix,
|
|
1067
|
-
paymentLinkIdPrefix,
|
|
1068
|
-
outcome: "stamp_failed",
|
|
1069
|
-
errName: err instanceof Error ? err.name : "UnknownError",
|
|
1070
|
-
},
|
|
1071
|
-
"request-confirm stamp failed — continuing",
|
|
1072
|
-
);
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* #8516 D13 — stamp a client's proposal paid from its own Checkout Session,
|
|
1078
|
-
* and audit the outcome as Stripe. The two ids come only from the session's
|
|
1079
|
-
* metadata, which this server wrote when it opened the session — a payer can
|
|
1080
|
-
* never set it — and the stamp is scoped by both. Never throws.
|
|
1081
|
-
*/
|
|
1082
|
-
async function stampProposalPaid(
|
|
1083
|
-
request: FastifyRequest,
|
|
1084
|
-
{
|
|
1085
|
-
session,
|
|
1086
|
-
eventId,
|
|
1087
|
-
eventIdPrefix,
|
|
1088
|
-
}: { session: CheckoutSessionObject; eventId: string; eventIdPrefix: string },
|
|
1089
|
-
): Promise<void> {
|
|
1090
|
-
const pieceId = session.metadata?.["ailk_proposal"];
|
|
1091
|
-
const workspaceId = session.metadata?.["ailk_workspace"];
|
|
1092
|
-
const sessionId = session.id;
|
|
1093
|
-
if (
|
|
1094
|
-
typeof pieceId !== "string" ||
|
|
1095
|
-
typeof workspaceId !== "string" ||
|
|
1096
|
-
typeof sessionId !== "string" ||
|
|
1097
|
-
!isId("piece", pieceId) ||
|
|
1098
|
-
!isId("workspace", workspaceId)
|
|
1099
|
-
) {
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
const ref = pieceId.substring(6, 14);
|
|
1103
|
-
|
|
1104
|
-
const audit = async (outcome: { ok: true } | { ok: false; reason: string }) => {
|
|
1105
|
-
const result = await recordWorkspaceAuditAction({
|
|
1106
|
-
workspaceId,
|
|
1107
|
-
actor: { type: "system", systemId: "stripe" },
|
|
1108
|
-
domain: "people",
|
|
1109
|
-
tool: "proposal.paid",
|
|
1110
|
-
outcome: outcome.ok ? "succeeded" : "failed",
|
|
1111
|
-
...(!outcome.ok && { failureReason: outcome.reason }),
|
|
1112
|
-
idempotencyKey: eventId,
|
|
1113
|
-
targetType: "piece",
|
|
1114
|
-
targetId: pieceId,
|
|
1115
|
-
});
|
|
1116
|
-
if (!result.ok) {
|
|
1117
|
-
request.log.error(
|
|
1118
|
-
{ eventIdPrefix, ref, error: result.error.kind },
|
|
1119
|
-
"proposal paid: audit write failed",
|
|
1120
|
-
);
|
|
1121
|
-
}
|
|
1122
|
-
};
|
|
1123
|
-
|
|
1124
|
-
if (
|
|
1125
|
-
session.payment_status !== "paid" &&
|
|
1126
|
-
session.payment_status !== "no_payment_required"
|
|
1127
|
-
) {
|
|
1128
|
-
request.log.warn(
|
|
1129
|
-
{ eventIdPrefix, ref, outcome: "not_paid" },
|
|
1130
|
-
"proposal paid: completed session is not paid — not stamping",
|
|
1131
|
-
);
|
|
1132
|
-
await audit({ ok: false, reason: "not_paid" });
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
try {
|
|
1137
|
-
const outcome = await writeProposalPaid(workspaceId, pieceId, sessionId, new Date());
|
|
1138
|
-
if (outcome === "stamped") {
|
|
1139
|
-
request.log.info({ eventIdPrefix, ref, outcome }, "proposal stamped paid");
|
|
1140
|
-
await audit({ ok: true });
|
|
1141
|
-
} else if (outcome === "already_paid") {
|
|
1142
|
-
request.log.warn(
|
|
1143
|
-
{ eventIdPrefix, ref, outcome },
|
|
1144
|
-
"proposal paid: already paid by another session — a second charge",
|
|
1145
|
-
);
|
|
1146
|
-
await audit({ ok: false, reason: "already_paid" });
|
|
1147
|
-
} else if (outcome === "not_found") {
|
|
1148
|
-
request.log.warn({ eventIdPrefix, ref, outcome }, "proposal paid: no such proposal");
|
|
1149
|
-
} else {
|
|
1150
|
-
request.log.info({ eventIdPrefix, ref, outcome }, "proposal paid: replay");
|
|
1151
|
-
}
|
|
1152
|
-
} catch (err) {
|
|
1153
|
-
request.log.error(
|
|
1154
|
-
{
|
|
1155
|
-
eventIdPrefix,
|
|
1156
|
-
ref,
|
|
1157
|
-
outcome: "stamp_failed",
|
|
1158
|
-
errName: err instanceof Error ? err.name : "UnknownError",
|
|
1159
|
-
},
|
|
1160
|
-
"proposal paid stamp failed — continuing",
|
|
1161
|
-
);
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* The hosted-invoice twin of `stampRequestPaid` (#5970 D4/D8).
|
|
1167
|
-
*
|
|
1168
|
-
* Inert at this issue's boundary — nothing writes `stripeInvoiceId` yet — and
|
|
1169
|
-
* deliberately present anyway, carrying the identical guarded update, so the
|
|
1170
|
-
* invoice rung is a writer away rather than a migration and a webhook change
|
|
1171
|
-
* away.
|
|
1172
|
-
*/
|
|
1173
|
-
async function stampRequestPaidByInvoice(
|
|
1174
|
-
request: FastifyRequest,
|
|
1175
|
-
{
|
|
1176
|
-
invoiceId,
|
|
1177
|
-
eventIdPrefix,
|
|
1178
|
-
}: { invoiceId: string | null; eventIdPrefix: string },
|
|
1179
|
-
): Promise<void> {
|
|
1180
|
-
// The same `typeof` guard its Payment-Link sibling carries, for the same
|
|
1181
|
-
// reason (#5970 security review round 2, L1): this arm is explicitly
|
|
1182
|
-
// forward-looking, so the hardening has to travel with it rather than be
|
|
1183
|
-
// added when a writer appears and the shape stops being guaranteed.
|
|
1184
|
-
if (typeof invoiceId !== "string" || invoiceId === "") return;
|
|
1185
|
-
const invoiceIdPrefix = invoiceId.substring(0, 8);
|
|
1186
|
-
|
|
1187
|
-
try {
|
|
1188
|
-
const result = await prisma.callback.updateMany({
|
|
1189
|
-
where: { stripeInvoiceId: invoiceId, paidAt: null },
|
|
1190
|
-
data: { paidAt: new Date() },
|
|
1191
|
-
});
|
|
1192
|
-
|
|
1193
|
-
if (result.count === 1) {
|
|
1194
|
-
request.log.info(
|
|
1195
|
-
{ eventIdPrefix, invoiceIdPrefix, outcome: "paid" },
|
|
1196
|
-
"request-confirm stamped paid from invoice",
|
|
1197
|
-
);
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
// An unmatched `invoice.paid` is the ORDINARY case today and for every
|
|
1201
|
-
// deployment using Stripe invoices for anything else, so this is `info`
|
|
1202
|
-
// rather than the `warn` its Payment-Link sibling emits.
|
|
1203
|
-
request.log.info(
|
|
1204
|
-
{ eventIdPrefix, invoiceIdPrefix, outcome: "not_a_request_invoice" },
|
|
1205
|
-
"request-confirm: invoice.paid matched no request row",
|
|
1206
|
-
);
|
|
1207
|
-
} catch (err) {
|
|
1208
|
-
request.log.error(
|
|
1209
|
-
{
|
|
1210
|
-
eventIdPrefix,
|
|
1211
|
-
invoiceIdPrefix,
|
|
1212
|
-
outcome: "stamp_failed",
|
|
1213
|
-
errName: err instanceof Error ? err.name : "UnknownError",
|
|
1214
|
-
},
|
|
1215
|
-
"request-confirm invoice stamp failed — continuing",
|
|
1216
|
-
);
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
/**
|
|
1221
|
-
* The verified user signal (#6852): `metadata.ailk_user_id` on the Checkout
|
|
1222
|
-
* session or the Stripe customer, when it is a non-empty string. Never
|
|
1223
|
-
* `client_reference_id` (a Payment Link accepts it from the payer's URL) and
|
|
1224
|
-
* never the payer-typed email.
|
|
1225
|
-
*/
|
|
1226
|
-
function signalOf(
|
|
1227
|
-
metadata: Record<string, string> | null | undefined,
|
|
1228
|
-
): string | null {
|
|
1229
|
-
const value: unknown = metadata?.["ailk_user_id"];
|
|
1230
|
-
return typeof value === "string" && value !== "" ? value : null;
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
/**
|
|
1234
|
-
* Link User.billingProvider + User.billingCustomerId for a Stripe customer.
|
|
1235
|
-
*
|
|
1236
|
-
* Lookup priority:
|
|
1237
|
-
* 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customerId)
|
|
1238
|
-
* → already linked; no-op (idempotent)
|
|
1239
|
-
* 2. Verified user signal (#6852): `metadata.ailk_user_id` →
|
|
1240
|
-
* User.findUnique({ where: { id } }) → bind only when the row's
|
|
1241
|
-
* billingCustomerId is empty; otherwise log billing_identity_conflict and
|
|
1242
|
-
* write nothing (#6113). The write is guarded on `billingCustomerId: null`;
|
|
1243
|
-
* a P2025 means a concurrent event bound the row first — the same customer
|
|
1244
|
-
* is a no-op, any other is a conflict.
|
|
1245
|
-
* No signal → log billing_identity_unverified and write nothing. The payer
|
|
1246
|
-
* types the Checkout email and Stripe never verifies it, so an email
|
|
1247
|
-
* never makes a binding, first or otherwise.
|
|
1248
|
-
* Trust boundary: Checkout-session and customer `metadata` can be written
|
|
1249
|
-
* only by a principal the seller authorised on their Stripe account (our
|
|
1250
|
-
* server's key, a write-capable restricted key, a connected app, the
|
|
1251
|
-
* dashboard, a Payment Link's own metadata) — never by the payer. A
|
|
1252
|
-
* producer sets the key only from an authenticated server session, never
|
|
1253
|
-
* from request input.
|
|
1254
|
-
* 3. Signal names no User → warn billing_identity_unknown_user; do NOT
|
|
1255
|
-
* create a User row (pre-signup state preserved for Phase B #852)
|
|
1256
|
-
*
|
|
1257
|
-
* No line logs the email, the Stripe customer id, or an unresolved signal.
|
|
1258
|
-
*
|
|
1259
|
-
* DB failures are caught and logged — a broken DB link should not cause Stripe to retry
|
|
1260
|
-
* the event (which would fire analytics + billing again on eventual success).
|
|
1261
|
-
*/
|
|
1262
|
-
async function upsertBillingIdentity(
|
|
1263
|
-
request: FastifyRequest,
|
|
1264
|
-
{
|
|
1265
|
-
customerId,
|
|
1266
|
-
userId,
|
|
1267
|
-
eventType,
|
|
1268
|
-
eventIdPrefix,
|
|
1269
|
-
}: {
|
|
1270
|
-
customerId: string;
|
|
1271
|
-
userId: string | null;
|
|
1272
|
-
eventType: string;
|
|
1273
|
-
eventIdPrefix: string;
|
|
1274
|
-
},
|
|
1275
|
-
): Promise<void> {
|
|
1276
|
-
try {
|
|
1277
|
-
const existingByBilling = await prisma.user.findFirst({
|
|
1278
|
-
where: {
|
|
1279
|
-
billingProvider: "stripe",
|
|
1280
|
-
billingCustomerId: customerId,
|
|
1281
|
-
},
|
|
1282
|
-
select: { id: true },
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
if (existingByBilling) {
|
|
1286
|
-
request.log.info(
|
|
1287
|
-
{ eventIdPrefix },
|
|
1288
|
-
"billing identity already linked — idempotent no-op",
|
|
1289
|
-
);
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
if (!userId) {
|
|
1294
|
-
// A customer Checkout creates never carries session metadata, so a
|
|
1295
|
-
// no-signal customer.created is every ordinary purchase — info, not warn.
|
|
1296
|
-
const fields = {
|
|
1297
|
-
eventIdPrefix,
|
|
1298
|
-
eventType,
|
|
1299
|
-
outcome: "billing_identity_unverified",
|
|
1300
|
-
};
|
|
1301
|
-
const msg =
|
|
1302
|
-
"stripe webhook: no verified user signal — billing identity not bound";
|
|
1303
|
-
if (eventType === "customer.created") request.log.info(fields, msg);
|
|
1304
|
-
else request.log.warn(fields, msg);
|
|
1305
|
-
return;
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
const row = await prisma.user.findUnique({
|
|
1309
|
-
where: { id: userId },
|
|
1310
|
-
select: { id: true, billingProvider: true, billingCustomerId: true },
|
|
1311
|
-
});
|
|
1312
|
-
|
|
1313
|
-
if (!row) {
|
|
1314
|
-
request.log.warn(
|
|
1315
|
-
{ eventIdPrefix, eventType, outcome: "billing_identity_unknown_user" },
|
|
1316
|
-
"stripe webhook: user signal names no User — pre-signup state preserved",
|
|
1317
|
-
);
|
|
1318
|
-
return;
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
const isSameBinding = (r: {
|
|
1322
|
-
billingProvider: string | null;
|
|
1323
|
-
billingCustomerId: string | null;
|
|
1324
|
-
}) => r.billingProvider === "stripe" && r.billingCustomerId === customerId;
|
|
1325
|
-
const logAlreadyLinked = () =>
|
|
1326
|
-
request.log.info(
|
|
1327
|
-
{ eventIdPrefix, outcome: "billing_identity_already_linked" },
|
|
1328
|
-
"billing identity already linked — idempotent no-op",
|
|
1329
|
-
);
|
|
1330
|
-
const logConflict = () =>
|
|
1331
|
-
request.log.warn(
|
|
1332
|
-
{
|
|
1333
|
-
eventIdPrefix,
|
|
1334
|
-
outcome: "billing_identity_conflict",
|
|
1335
|
-
userId: row.id,
|
|
1336
|
-
},
|
|
1337
|
-
"stripe webhook: user already bound to a different billing customer — not re-pointed",
|
|
1338
|
-
);
|
|
1339
|
-
|
|
1340
|
-
if (row.billingCustomerId !== null) {
|
|
1341
|
-
if (isSameBinding(row)) logAlreadyLinked();
|
|
1342
|
-
else logConflict();
|
|
1343
|
-
return;
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
try {
|
|
1347
|
-
await prisma.user.update({
|
|
1348
|
-
where: { id: row.id, billingCustomerId: null },
|
|
1349
|
-
data: {
|
|
1350
|
-
billingProvider: "stripe",
|
|
1351
|
-
billingCustomerId: customerId,
|
|
1352
|
-
},
|
|
1353
|
-
});
|
|
1354
|
-
} catch (err) {
|
|
1355
|
-
if ((err as { code?: unknown }).code !== "P2025") throw err;
|
|
1356
|
-
const now = await prisma.user.findUnique({
|
|
1357
|
-
where: { id: row.id },
|
|
1358
|
-
select: { billingProvider: true, billingCustomerId: true },
|
|
1359
|
-
});
|
|
1360
|
-
if (now && isSameBinding(now)) logAlreadyLinked();
|
|
1361
|
-
else logConflict();
|
|
1362
|
-
return;
|
|
1363
|
-
}
|
|
1364
|
-
request.log.info(
|
|
1365
|
-
{ eventIdPrefix },
|
|
1366
|
-
"billing identity linked via verified user signal",
|
|
1367
|
-
);
|
|
1368
|
-
} catch (err) {
|
|
1369
|
-
request.log.error(
|
|
1370
|
-
{
|
|
1371
|
-
eventIdPrefix,
|
|
1372
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1373
|
-
},
|
|
1374
|
-
"billing identity upsert failed — continuing",
|
|
1375
|
-
);
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/**
|
|
1380
|
-
* Link Organization.billingProvider + billingCustomerId for a Stripe customer
|
|
1381
|
-
* (org-scoped billing, #3601, spec D2/D3), only on proof (#9067 D13,
|
|
1382
|
-
* SEC-9067-1 to -3). `metadata.organizationId` names the target; the proof
|
|
1383
|
-
* is the session id POST /v1/billing/plan-checkout recorded on that
|
|
1384
|
-
* Organization. The bind is one guarded `updateMany` on `{ id,
|
|
1385
|
-
* planCheckoutSessionId: sessionId, billingCustomerId: null }`, which also
|
|
1386
|
-
* clears the recorded session — so a session no route recorded, a session
|
|
1387
|
-
* recorded by another deployment's database, a re-point of a bound customer
|
|
1388
|
-
* and a `customer.created` (`sessionId` null: a customer's metadata proves
|
|
1389
|
-
* nothing) all bind nothing and log `org_bind_unproven`.
|
|
1390
|
-
*
|
|
1391
|
-
* Idempotent: a typed-composite match (billingProvider="stripe",
|
|
1392
|
-
* billingCustomerId=customerId) already pointing at THIS org is a no-op on
|
|
1393
|
-
* replay.
|
|
1394
|
-
*
|
|
1395
|
-
* DB failures are caught and logged (never re-thrown) — a broken link must not
|
|
1396
|
-
* make Stripe retry (which would re-fire analytics + billing on eventual
|
|
1397
|
-
* success), matching the User-path contract above.
|
|
1398
|
-
*/
|
|
1399
|
-
async function upsertOrgBillingIdentity(
|
|
1400
|
-
request: FastifyRequest,
|
|
1401
|
-
{
|
|
1402
|
-
organizationId,
|
|
1403
|
-
customerId,
|
|
1404
|
-
sessionId,
|
|
1405
|
-
eventIdPrefix,
|
|
1406
|
-
}: {
|
|
1407
|
-
organizationId: string;
|
|
1408
|
-
customerId: string;
|
|
1409
|
-
sessionId: string | null;
|
|
1410
|
-
eventIdPrefix: string;
|
|
1411
|
-
},
|
|
1412
|
-
): Promise<void> {
|
|
1413
|
-
const unproven = () =>
|
|
1414
|
-
request.log.warn(
|
|
1415
|
-
{ eventIdPrefix, outcome: "org_bind_unproven" },
|
|
1416
|
-
"org billing identity not linked — no recorded plan checkout proves it",
|
|
1417
|
-
);
|
|
1418
|
-
if (sessionId === null) {
|
|
1419
|
-
unproven();
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
try {
|
|
1423
|
-
const existingByBilling = await prisma.organization.findFirst({
|
|
1424
|
-
where: {
|
|
1425
|
-
billingProvider: "stripe",
|
|
1426
|
-
billingCustomerId: customerId,
|
|
1427
|
-
},
|
|
1428
|
-
select: { id: true },
|
|
1429
|
-
});
|
|
1430
|
-
|
|
1431
|
-
if (existingByBilling && existingByBilling.id === organizationId) {
|
|
1432
|
-
request.log.info(
|
|
1433
|
-
{ eventIdPrefix },
|
|
1434
|
-
"org billing identity already linked — idempotent no-op",
|
|
1435
|
-
);
|
|
1436
|
-
return;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
const { count } = await prisma.organization.updateMany({
|
|
1440
|
-
where: { id: organizationId, planCheckoutSessionId: sessionId, billingCustomerId: null },
|
|
1441
|
-
data: {
|
|
1442
|
-
billingProvider: "stripe",
|
|
1443
|
-
billingCustomerId: customerId,
|
|
1444
|
-
planCheckoutSessionId: null,
|
|
1445
|
-
},
|
|
1446
|
-
});
|
|
1447
|
-
if (count !== 1) {
|
|
1448
|
-
unproven();
|
|
1449
|
-
return;
|
|
1450
|
-
}
|
|
1451
|
-
request.log.info(
|
|
1452
|
-
{ eventIdPrefix },
|
|
1453
|
-
"org billing identity linked from the recorded plan checkout",
|
|
1454
|
-
);
|
|
1455
|
-
} catch (err) {
|
|
1456
|
-
// #9067 FR-6 — the name and Prisma code only: some Prisma messages
|
|
1457
|
-
// print the call's arguments, which here carry the session and customer ids.
|
|
1458
|
-
request.log.error(
|
|
1459
|
-
{
|
|
1460
|
-
eventIdPrefix,
|
|
1461
|
-
errName: err instanceof Error ? err.name : "unknown",
|
|
1462
|
-
code: (err as { code?: unknown } | null)?.code,
|
|
1463
|
-
},
|
|
1464
|
-
"org billing identity upsert failed — continuing",
|
|
1465
|
-
);
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
async function fireTelemetryWithTimeout(
|
|
1470
|
-
request: FastifyRequest,
|
|
1471
|
-
name: string,
|
|
1472
|
-
props: Record<string, unknown>,
|
|
1473
|
-
): Promise<void> {
|
|
1474
|
-
let timeoutId!: ReturnType<typeof setTimeout>;
|
|
1475
|
-
const timeout = new Promise<void>((resolve) => {
|
|
1476
|
-
timeoutId = setTimeout(() => {
|
|
1477
|
-
request.log.warn(
|
|
1478
|
-
{ telemetryEvent: name },
|
|
1479
|
-
"telemetry call exceeded timeout; continuing",
|
|
1480
|
-
);
|
|
1481
|
-
resolve();
|
|
1482
|
-
}, TELEMETRY_TIMEOUT_MS);
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
try {
|
|
1486
|
-
await Promise.race([track(name, props), timeout]);
|
|
1487
|
-
} catch (err) {
|
|
1488
|
-
request.log.error(
|
|
1489
|
-
{
|
|
1490
|
-
telemetryEvent: name,
|
|
1491
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1492
|
-
},
|
|
1493
|
-
"telemetry call threw",
|
|
1494
|
-
);
|
|
1495
|
-
} finally {
|
|
1496
|
-
clearTimeout(timeoutId);
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
// ── Dunning (#6034) ──────────────────────────────────────────────────────
|
|
1501
|
-
//
|
|
1502
|
-
// Arms and applies the per-tenant dunning record documented in
|
|
1503
|
-
// README.md § Dunning. Both arms below share one write primitive
|
|
1504
|
-
// (`writeDunningRecord`) that enforces § D6's ordering/tie/durable-replay
|
|
1505
|
-
// admission rule and its compare-and-swap over the FULL observed read-set
|
|
1506
|
-
// (`value` as well as `updatedAt`) — never a `$transaction`, following
|
|
1507
|
-
// `usage-credits.ts:20-33`'s reasoning: a single conditional `UPDATE` IS the
|
|
1508
|
-
// atomic compare-and-swap.
|
|
1509
|
-
|
|
1510
|
-
/**
|
|
1511
|
-
* The "past_due" candidate both arms compute identically (FR-1's
|
|
1512
|
-
* `invoice.payment_failed` arm, and `customer.subscription.updated`'s own
|
|
1513
|
-
* `past_due`/`unpaid` branch) — one shared shape rather than two copies that
|
|
1514
|
-
* could drift: never revokes, `dunningArmedAt` is stamped once and never
|
|
1515
|
-
* moved while the subscription stays failing (§ D5), and `accessRevokesAt`
|
|
1516
|
-
* is cleared so a re-arm after a prior cancellation carries no stale
|
|
1517
|
-
* revocation schedule.
|
|
1518
|
-
*
|
|
1519
|
-
* Callers differ on whether a recorded `canceled` entry may be relaxed by
|
|
1520
|
-
* this candidate — see `armDunning`'s own guard, which is deliberately NOT
|
|
1521
|
-
* folded in here (code review finding, #6034): `customer.subscription
|
|
1522
|
-
* .updated` is the SUBSCRIPTION-level authoritative source and a strictly
|
|
1523
|
-
* newer transition to `past_due` is a real signal worth admitting (§ D6
|
|
1524
|
-
* rule 4), while `invoice.payment_failed` is only ever about ONE invoice and
|
|
1525
|
-
* must never outrank a subscription-level cancellation already recorded.
|
|
1526
|
-
*/
|
|
1527
|
-
function computeArmedEntry(
|
|
1528
|
-
current: DunningEntry | undefined,
|
|
1529
|
-
event: { id: string; created: number },
|
|
1530
|
-
): DunningEntry {
|
|
1531
|
-
return {
|
|
1532
|
-
status: "past_due",
|
|
1533
|
-
accessState: "granted",
|
|
1534
|
-
dunningArmedAt: current?.dunningArmedAt ?? new Date().toISOString(),
|
|
1535
|
-
accessRevokesAt: null,
|
|
1536
|
-
lastEventAt: event.created,
|
|
1537
|
-
lastEventId: event.id,
|
|
1538
|
-
};
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
/**
|
|
1542
|
-
* `invoice.payment_failed` (#6034 FR-1). Arms dunning for the `User` matched
|
|
1543
|
-
* by the invoice's Stripe customer id — the recorded state becomes
|
|
1544
|
-
* `past_due`, `dunningArmedAt` is stamped once (never moved while the
|
|
1545
|
-
* subscription stays failing), and `accessState` stays `granted`. This arm
|
|
1546
|
-
* never revokes — and, just as importantly, never UN-revokes: it must never
|
|
1547
|
-
* clear a subscription-level `canceled` recording, because it is only ever
|
|
1548
|
-
* about ONE invoice, not the subscription's current status (code review
|
|
1549
|
-
* finding, #6034). Stripe's Smart Retries schedule can still fire a queued
|
|
1550
|
-
* retry's failure webhook for an invoice that predates a cancellation, with
|
|
1551
|
-
* an `event.created` genuinely NEWER than the cancellation's own — which
|
|
1552
|
-
* § D6's admission rule alone does not reject (rule 4 admits a strictly
|
|
1553
|
-
* newer event unconditionally, by design, so that a genuine reactivation via
|
|
1554
|
-
* `customer.subscription.updated` is never blocked). Only ANOTHER
|
|
1555
|
-
* `customer.subscription.updated` may clear a canceled record.
|
|
1556
|
-
*
|
|
1557
|
-
* Never throws — the handler's "always 200 for a verified event" contract
|
|
1558
|
-
* (NFR-1). Every rejection below returns BEFORE any query (§ D9).
|
|
1559
|
-
*/
|
|
1560
|
-
async function armDunning(
|
|
1561
|
-
request: FastifyRequest,
|
|
1562
|
-
{
|
|
1563
|
-
invoice,
|
|
1564
|
-
eventId,
|
|
1565
|
-
eventCreated,
|
|
1566
|
-
eventIdPrefix,
|
|
1567
|
-
}: {
|
|
1568
|
-
invoice: InvoiceObject;
|
|
1569
|
-
eventId: string;
|
|
1570
|
-
eventCreated: number;
|
|
1571
|
-
eventIdPrefix: string;
|
|
1572
|
-
},
|
|
1573
|
-
): Promise<void> {
|
|
1574
|
-
const subscriptionId = invoice.subscription;
|
|
1575
|
-
if (typeof subscriptionId !== "string" || subscriptionId === "") {
|
|
1576
|
-
request.log.info(
|
|
1577
|
-
{ eventIdPrefix, outcome: "not_a_subscription_invoice" },
|
|
1578
|
-
"dunning: invoice.payment_failed carries no subscription — not a subscription invoice",
|
|
1579
|
-
);
|
|
1580
|
-
return;
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
const customerId = invoice.customer;
|
|
1584
|
-
if (typeof customerId !== "string" || customerId === "") {
|
|
1585
|
-
request.log.warn(
|
|
1586
|
-
{ eventIdPrefix, outcome: "unresolvable_customer" },
|
|
1587
|
-
"dunning: invoice.payment_failed has no resolvable customer",
|
|
1588
|
-
);
|
|
1589
|
-
return;
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
const userId = await findSubscriberUserId(request, {
|
|
1593
|
-
customerId,
|
|
1594
|
-
eventIdPrefix,
|
|
1595
|
-
});
|
|
1596
|
-
if (!userId) return;
|
|
1597
|
-
|
|
1598
|
-
await writeDunningRecord(request, {
|
|
1599
|
-
userId,
|
|
1600
|
-
subscriptionId,
|
|
1601
|
-
eventId,
|
|
1602
|
-
eventCreated,
|
|
1603
|
-
eventIdPrefix,
|
|
1604
|
-
computeCandidate: (current) => {
|
|
1605
|
-
// A subscription already recorded `canceled` (by the SUBSCRIPTION-level
|
|
1606
|
-
// authoritative `customer.subscription.updated` arm) is left untouched
|
|
1607
|
-
// by an invoice-level signal — see this function's own doc comment.
|
|
1608
|
-
if (current?.status === "canceled") return current;
|
|
1609
|
-
return computeArmedEntry(current, { id: eventId, created: eventCreated });
|
|
1610
|
-
},
|
|
1611
|
-
});
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
/**
|
|
1615
|
-
* `customer.subscription.updated` (#6034 FR-2/FR-4). Maps the narrowed
|
|
1616
|
-
* Stripe status (§ D3's `toSubscriptionLifecycleStatus`) onto the recorded
|
|
1617
|
-
* lifecycle state:
|
|
1618
|
-
*
|
|
1619
|
-
* - `active` / `trialing` → § D7's clean recovery (both narrow to a
|
|
1620
|
-
* recorded `status: "active"`; #6038's read shows nothing for it).
|
|
1621
|
-
* - `past_due` (incl. `unpaid`, narrowed) → arms exactly as `armDunning` does.
|
|
1622
|
-
* - `canceled` → § D5: grace runs from the FIRST failure, lazily evaluated,
|
|
1623
|
-
* never scheduled.
|
|
1624
|
-
*
|
|
1625
|
-
* Never throws (NFR-1); every identifier is guarded before use (§ D9).
|
|
1626
|
-
*/
|
|
1627
|
-
async function applySubscriptionStatus(
|
|
1628
|
-
request: FastifyRequest,
|
|
1629
|
-
{
|
|
1630
|
-
subscription,
|
|
1631
|
-
eventId,
|
|
1632
|
-
eventCreated,
|
|
1633
|
-
eventIdPrefix,
|
|
1634
|
-
}: {
|
|
1635
|
-
subscription: SubscriptionObject;
|
|
1636
|
-
eventId: string;
|
|
1637
|
-
eventCreated: number;
|
|
1638
|
-
eventIdPrefix: string;
|
|
1639
|
-
},
|
|
1640
|
-
): Promise<void> {
|
|
1641
|
-
const customerId = subscription.customer;
|
|
1642
|
-
if (typeof customerId !== "string" || customerId === "") {
|
|
1643
|
-
request.log.warn(
|
|
1644
|
-
{ eventIdPrefix, outcome: "unresolvable_customer" },
|
|
1645
|
-
"dunning: customer.subscription.updated has no resolvable customer",
|
|
1646
|
-
);
|
|
1647
|
-
return;
|
|
1648
|
-
}
|
|
1649
|
-
|
|
1650
|
-
const subscriptionId = subscription.id;
|
|
1651
|
-
if (typeof subscriptionId !== "string" || subscriptionId === "") {
|
|
1652
|
-
request.log.warn(
|
|
1653
|
-
{ eventIdPrefix, outcome: "unresolvable_subscription" },
|
|
1654
|
-
"dunning: customer.subscription.updated has no resolvable subscription id",
|
|
1655
|
-
);
|
|
1656
|
-
return;
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
// toSubscriptionLifecycleStatus's own default arm is already "canceled" —
|
|
1660
|
-
// never an implied grant (§ D3) — so a non-string status maps the same
|
|
1661
|
-
// conservative way rather than being silently ignored (§ D9).
|
|
1662
|
-
const statusRaw = subscription.status;
|
|
1663
|
-
const narrowedStatus =
|
|
1664
|
-
typeof statusRaw === "string"
|
|
1665
|
-
? toSubscriptionLifecycleStatus(statusRaw)
|
|
1666
|
-
: "canceled";
|
|
1667
|
-
|
|
1668
|
-
const userId = await findSubscriberUserId(request, {
|
|
1669
|
-
customerId,
|
|
1670
|
-
eventIdPrefix,
|
|
1671
|
-
});
|
|
1672
|
-
if (!userId) return;
|
|
1673
|
-
|
|
1674
|
-
if (narrowedStatus === "active" || narrowedStatus === "trialing") {
|
|
1675
|
-
await writeDunningRecord(request, {
|
|
1676
|
-
userId,
|
|
1677
|
-
subscriptionId,
|
|
1678
|
-
eventId,
|
|
1679
|
-
eventCreated,
|
|
1680
|
-
eventIdPrefix,
|
|
1681
|
-
computeCandidate: () => ({
|
|
1682
|
-
status: "active",
|
|
1683
|
-
accessState: "granted",
|
|
1684
|
-
dunningArmedAt: null,
|
|
1685
|
-
accessRevokesAt: null,
|
|
1686
|
-
lastEventAt: eventCreated,
|
|
1687
|
-
lastEventId: eventId,
|
|
1688
|
-
}),
|
|
1689
|
-
});
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
if (narrowedStatus === "past_due") {
|
|
1694
|
-
await writeDunningRecord(request, {
|
|
1695
|
-
userId,
|
|
1696
|
-
subscriptionId,
|
|
1697
|
-
eventId,
|
|
1698
|
-
eventCreated,
|
|
1699
|
-
eventIdPrefix,
|
|
1700
|
-
computeCandidate: (current) =>
|
|
1701
|
-
computeArmedEntry(current, { id: eventId, created: eventCreated }),
|
|
1702
|
-
});
|
|
1703
|
-
return;
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
// narrowedStatus === "canceled" — § D5's grace computation. The policy read
|
|
1707
|
-
// never throws (dunning-policy.ts AC-3), so this can be awaited unguarded.
|
|
1708
|
-
const policy = await dunningPolicyFor(currentSiteId());
|
|
1709
|
-
|
|
1710
|
-
await writeDunningRecord(request, {
|
|
1711
|
-
userId,
|
|
1712
|
-
subscriptionId,
|
|
1713
|
-
eventId,
|
|
1714
|
-
eventCreated,
|
|
1715
|
-
eventIdPrefix,
|
|
1716
|
-
computeCandidate: (current) => {
|
|
1717
|
-
const armedAtIso =
|
|
1718
|
-
current?.dunningArmedAt ??
|
|
1719
|
-
new Date(
|
|
1720
|
-
resolveCancellationInstantMs(subscription, eventCreated),
|
|
1721
|
-
).toISOString();
|
|
1722
|
-
const revokeAtMs =
|
|
1723
|
-
Date.parse(armedAtIso) + policy.graceDays * 24 * 60 * 60 * 1000;
|
|
1724
|
-
const revoked = Date.now() >= revokeAtMs;
|
|
1725
|
-
return {
|
|
1726
|
-
status: "canceled",
|
|
1727
|
-
accessState: revoked ? "revoked" : "granted",
|
|
1728
|
-
dunningArmedAt: armedAtIso,
|
|
1729
|
-
accessRevokesAt: new Date(revokeAtMs).toISOString(),
|
|
1730
|
-
lastEventAt: eventCreated,
|
|
1731
|
-
lastEventId: eventId,
|
|
1732
|
-
};
|
|
1733
|
-
},
|
|
1734
|
-
});
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
/**
|
|
1738
|
-
* The cancellation instant, taken from the verified event in § D5's order —
|
|
1739
|
-
* `canceled_at` → `ended_at` → `event.created` → `Date.now()` — each
|
|
1740
|
-
* accepted only when `Number.isFinite()` holds (Stripe epoch SECONDS; the
|
|
1741
|
-
* final fallback is milliseconds already).
|
|
1742
|
-
*/
|
|
1743
|
-
function resolveCancellationInstantMs(
|
|
1744
|
-
subscription: SubscriptionObject,
|
|
1745
|
-
eventCreatedSec: number,
|
|
1746
|
-
): number {
|
|
1747
|
-
const candidatesSec = [
|
|
1748
|
-
subscription.canceled_at,
|
|
1749
|
-
subscription.ended_at,
|
|
1750
|
-
eventCreatedSec,
|
|
1751
|
-
];
|
|
1752
|
-
for (const candidate of candidatesSec) {
|
|
1753
|
-
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
1754
|
-
return candidate * 1000;
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
return Date.now();
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
/**
|
|
1761
|
-
* D10 — no matching `User` is a warn-and-continue, not an error. Matches
|
|
1762
|
-
* `upsertBillingIdentity`'s pre-signup posture: a customer id with no linked
|
|
1763
|
-
* `User` row is expected (a buyer who purchased without an account), so no
|
|
1764
|
-
* `User` row is ever created here.
|
|
1765
|
-
*/
|
|
1766
|
-
async function findSubscriberUserId(
|
|
1767
|
-
request: FastifyRequest,
|
|
1768
|
-
{ customerId, eventIdPrefix }: { customerId: string; eventIdPrefix: string },
|
|
1769
|
-
): Promise<string | null> {
|
|
1770
|
-
let user: { id: string } | null;
|
|
1771
|
-
try {
|
|
1772
|
-
user = await prisma.user.findUnique({
|
|
1773
|
-
where: {
|
|
1774
|
-
billingProvider_billingCustomerId: {
|
|
1775
|
-
billingProvider: "stripe",
|
|
1776
|
-
billingCustomerId: customerId,
|
|
1777
|
-
},
|
|
1778
|
-
},
|
|
1779
|
-
select: { id: true },
|
|
1780
|
-
});
|
|
1781
|
-
} catch (err) {
|
|
1782
|
-
request.log.error(
|
|
1783
|
-
{
|
|
1784
|
-
eventIdPrefix,
|
|
1785
|
-
outcome: "subscriber_lookup_failed",
|
|
1786
|
-
errName: err instanceof Error ? err.name : "UnknownError",
|
|
1787
|
-
},
|
|
1788
|
-
"dunning: subscriber lookup failed — continuing",
|
|
1789
|
-
);
|
|
1790
|
-
return null;
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
if (!user) {
|
|
1794
|
-
request.log.warn(
|
|
1795
|
-
{ eventIdPrefix, outcome: "no_subscriber_match" },
|
|
1796
|
-
"dunning: no User matched this Stripe customer — pre-signup state, no write",
|
|
1797
|
-
);
|
|
1798
|
-
return null;
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
return user.id;
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
type DunningAdmission =
|
|
1805
|
-
| { admit: true }
|
|
1806
|
-
| { admit: false; outcome: "duplicate_event" | "stale_event" | "stale_tie" };
|
|
1807
|
-
|
|
1808
|
-
/**
|
|
1809
|
-
* A write is a DE-ESCALATION when it would move the entry toward LESS
|
|
1810
|
-
* restriction relative to what is currently recorded: setting `status` to
|
|
1811
|
-
* `active` (§ D7's `trialing`-collapses-to-`active` shape), clearing
|
|
1812
|
-
* `accessRevokesAt`, or moving `accessState` from `revoked` to `granted`
|
|
1813
|
-
* (§ D6 rule 3). All three are checked independently — the earlier draft
|
|
1814
|
-
* keyed terminality on `accessState === "revoked"` alone, which left the
|
|
1815
|
-
* in-grace cancellation (`status: "canceled"`, `accessState: "granted"`,
|
|
1816
|
-
* `accessRevokesAt` non-null) unprotected (§ 9 SEC-03).
|
|
1817
|
-
*/
|
|
1818
|
-
function isDeEscalation(current: DunningEntry, candidate: DunningEntry): boolean {
|
|
1819
|
-
const statusRelaxed =
|
|
1820
|
-
candidate.status === "active" && current.status !== "active";
|
|
1821
|
-
const revokesAtCleared =
|
|
1822
|
-
current.accessRevokesAt !== null && candidate.accessRevokesAt === null;
|
|
1823
|
-
const accessStateRelaxed =
|
|
1824
|
-
current.accessState === "revoked" && candidate.accessState === "granted";
|
|
1825
|
-
return statusRelaxed || revokesAtCleared || accessStateRelaxed;
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
/**
|
|
1829
|
-
* § D6's five-rule admission ladder, folded to what actually needs checking:
|
|
1830
|
-
* no current entry → admit (first event for this subscription); the SAME
|
|
1831
|
-
* event id replayed → the durable twin of `hasSeen` (rule 1); a strictly
|
|
1832
|
-
* OLDER event → stale (rule 2); an EQUAL-timestamp de-escalation → stale tie
|
|
1833
|
-
* (rule 3, § D6's "terminal-state monotonicity" folded in — a de-escalating
|
|
1834
|
-
* write is rejected at a tie regardless of which of the three terminal
|
|
1835
|
-
* disjuncts made it terminal); anything else (a genuinely newer event,
|
|
1836
|
-
* unconditionally per rule 4/5) → admit.
|
|
1837
|
-
*/
|
|
1838
|
-
function admitDunningWrite(
|
|
1839
|
-
current: DunningEntry | undefined,
|
|
1840
|
-
candidate: DunningEntry,
|
|
1841
|
-
event: { id: string; created: number },
|
|
1842
|
-
): DunningAdmission {
|
|
1843
|
-
if (!current) return { admit: true };
|
|
1844
|
-
|
|
1845
|
-
if (event.id === current.lastEventId) {
|
|
1846
|
-
return { admit: false, outcome: "duplicate_event" };
|
|
1847
|
-
}
|
|
1848
|
-
if (event.created < current.lastEventAt) {
|
|
1849
|
-
return { admit: false, outcome: "stale_event" };
|
|
1850
|
-
}
|
|
1851
|
-
if (event.created === current.lastEventAt && isDeEscalation(current, candidate)) {
|
|
1852
|
-
return { admit: false, outcome: "stale_tie" };
|
|
1853
|
-
}
|
|
1854
|
-
return { admit: true };
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
function parseDunningRecord(value: unknown): DunningRecord {
|
|
1858
|
-
const obj = value as { subscriptions?: unknown } | null | undefined;
|
|
1859
|
-
const subscriptions =
|
|
1860
|
-
obj &&
|
|
1861
|
-
typeof obj === "object" &&
|
|
1862
|
-
obj.subscriptions &&
|
|
1863
|
-
typeof obj.subscriptions === "object" &&
|
|
1864
|
-
!Array.isArray(obj.subscriptions)
|
|
1865
|
-
? (obj.subscriptions as Record<string, DunningEntry>)
|
|
1866
|
-
: {};
|
|
1867
|
-
return { v: 1, subscriptions };
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
/**
|
|
1871
|
-
* The map cap (§ D2) — evicts the oldest SETTLED entries (`status: "active"`,
|
|
1872
|
-
* `accessState: "granted"`) first, never an entry still being dunned, so a
|
|
1873
|
-
* per-event read-and-rewrite never grows unbounded with a customer's whole
|
|
1874
|
-
* subscription history.
|
|
1875
|
-
*/
|
|
1876
|
-
function applyCandidateWithCap(
|
|
1877
|
-
record: DunningRecord,
|
|
1878
|
-
subscriptionId: string,
|
|
1879
|
-
candidate: DunningEntry,
|
|
1880
|
-
): DunningRecord {
|
|
1881
|
-
const subscriptions: Record<string, DunningEntry> = {
|
|
1882
|
-
...record.subscriptions,
|
|
1883
|
-
[subscriptionId]: candidate,
|
|
1884
|
-
};
|
|
1885
|
-
|
|
1886
|
-
let overflow = Object.keys(subscriptions).length - DUNNING_MAX_SUBSCRIPTIONS;
|
|
1887
|
-
if (overflow <= 0) {
|
|
1888
|
-
return { v: 1, subscriptions };
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
const evictionOrder = Object.keys(subscriptions)
|
|
1892
|
-
.filter((key) => {
|
|
1893
|
-
const entry = subscriptions[key]!;
|
|
1894
|
-
return entry.status === "active" && entry.accessState === "granted";
|
|
1895
|
-
})
|
|
1896
|
-
.sort((a, b) => subscriptions[a]!.lastEventAt - subscriptions[b]!.lastEventAt);
|
|
1897
|
-
|
|
1898
|
-
for (const key of evictionOrder) {
|
|
1899
|
-
if (overflow <= 0) break;
|
|
1900
|
-
delete subscriptions[key];
|
|
1901
|
-
overflow -= 1;
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
return { v: 1, subscriptions };
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
/**
|
|
1908
|
-
* The shared write primitive (§ D6). Reads the FULL record, applies the
|
|
1909
|
-
* admission rule, and writes back a compare-and-swap whose `where` restates
|
|
1910
|
-
* BOTH `updatedAt` AND the observed `value` (read-set ≡ CAS-set — NFR-9;
|
|
1911
|
-
* `updatedAt` alone lets two writes inside the same millisecond silently
|
|
1912
|
-
* overwrite each other, § 9 SEC-07). Bounded at `DUNNING_WRITE_MAX_ATTEMPTS`
|
|
1913
|
-
* — an honest `write_conflict` log beats an unbounded spin on an access
|
|
1914
|
-
* decision. Never throws (NFR-1) — the outer `catch` is the same
|
|
1915
|
-
* never-throw contract every other helper in this file carries.
|
|
1916
|
-
*/
|
|
1917
|
-
async function writeDunningRecord(
|
|
1918
|
-
request: FastifyRequest,
|
|
1919
|
-
{
|
|
1920
|
-
userId,
|
|
1921
|
-
subscriptionId,
|
|
1922
|
-
eventId,
|
|
1923
|
-
eventCreated,
|
|
1924
|
-
eventIdPrefix,
|
|
1925
|
-
computeCandidate,
|
|
1926
|
-
}: {
|
|
1927
|
-
userId: string;
|
|
1928
|
-
subscriptionId: string;
|
|
1929
|
-
eventId: string;
|
|
1930
|
-
eventCreated: number;
|
|
1931
|
-
eventIdPrefix: string;
|
|
1932
|
-
computeCandidate: (current: DunningEntry | undefined) => DunningEntry;
|
|
1933
|
-
},
|
|
1934
|
-
): Promise<void> {
|
|
1935
|
-
const subscriptionIdPrefix = subscriptionId.substring(0, 8);
|
|
1936
|
-
|
|
1937
|
-
try {
|
|
1938
|
-
for (let attempt = 1; attempt <= DUNNING_WRITE_MAX_ATTEMPTS; attempt++) {
|
|
1939
|
-
const row = await prisma.userData.findUnique({
|
|
1940
|
-
where: { userId_key: { userId, key: DUNNING_USER_DATA_KEY } },
|
|
1941
|
-
select: { value: true, updatedAt: true },
|
|
1942
|
-
});
|
|
1943
|
-
|
|
1944
|
-
const observedValue: unknown = row ? row.value : null;
|
|
1945
|
-
const record = parseDunningRecord(observedValue);
|
|
1946
|
-
const current = record.subscriptions[subscriptionId];
|
|
1947
|
-
const candidate = computeCandidate(current);
|
|
1948
|
-
|
|
1949
|
-
const admission = admitDunningWrite(current, candidate, {
|
|
1950
|
-
id: eventId,
|
|
1951
|
-
created: eventCreated,
|
|
1952
|
-
});
|
|
1953
|
-
if (!admission.admit) {
|
|
1954
|
-
request.log.info(
|
|
1955
|
-
{ eventIdPrefix, subscriptionIdPrefix, outcome: admission.outcome },
|
|
1956
|
-
"dunning: event not admitted — record unchanged",
|
|
1957
|
-
);
|
|
1958
|
-
return;
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
const nextRecord = applyCandidateWithCap(record, subscriptionId, candidate);
|
|
1962
|
-
const nextValue = nextRecord as unknown as Prisma.InputJsonValue;
|
|
1963
|
-
|
|
1964
|
-
if (!row) {
|
|
1965
|
-
try {
|
|
1966
|
-
await prisma.userData.create({
|
|
1967
|
-
data: { userId, key: DUNNING_USER_DATA_KEY, value: nextValue },
|
|
1968
|
-
});
|
|
1969
|
-
request.log.info(
|
|
1970
|
-
{
|
|
1971
|
-
eventIdPrefix,
|
|
1972
|
-
subscriptionIdPrefix,
|
|
1973
|
-
outcome: "written",
|
|
1974
|
-
status: candidate.status,
|
|
1975
|
-
},
|
|
1976
|
-
"dunning: record created",
|
|
1977
|
-
);
|
|
1978
|
-
return;
|
|
1979
|
-
} catch (err) {
|
|
1980
|
-
if (isUniqueViolation(err)) {
|
|
1981
|
-
// Another delivery won the create race — re-read and retry.
|
|
1982
|
-
continue;
|
|
1983
|
-
}
|
|
1984
|
-
throw err;
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
const { count } = await prisma.userData.updateMany({
|
|
1989
|
-
where: {
|
|
1990
|
-
userId,
|
|
1991
|
-
key: DUNNING_USER_DATA_KEY,
|
|
1992
|
-
updatedAt: row.updatedAt,
|
|
1993
|
-
value: { equals: observedValue as Prisma.InputJsonValue },
|
|
1994
|
-
},
|
|
1995
|
-
data: { value: nextValue },
|
|
1996
|
-
});
|
|
1997
|
-
|
|
1998
|
-
if (count > 0) {
|
|
1999
|
-
request.log.info(
|
|
2000
|
-
{
|
|
2001
|
-
eventIdPrefix,
|
|
2002
|
-
subscriptionIdPrefix,
|
|
2003
|
-
outcome: "written",
|
|
2004
|
-
status: candidate.status,
|
|
2005
|
-
},
|
|
2006
|
-
"dunning: record updated",
|
|
2007
|
-
);
|
|
2008
|
-
return;
|
|
2009
|
-
}
|
|
2010
|
-
// count === 0 — a concurrent writer moved the row. Re-read and retry.
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
request.log.error(
|
|
2014
|
-
{ eventIdPrefix, subscriptionIdPrefix, outcome: "write_conflict" },
|
|
2015
|
-
"dunning: record write exhausted its retries — continuing",
|
|
2016
|
-
);
|
|
2017
|
-
} catch (err) {
|
|
2018
|
-
request.log.error(
|
|
2019
|
-
{
|
|
2020
|
-
eventIdPrefix,
|
|
2021
|
-
subscriptionIdPrefix,
|
|
2022
|
-
outcome: "stamp_failed",
|
|
2023
|
-
errName: err instanceof Error ? err.name : "UnknownError",
|
|
2024
|
-
},
|
|
2025
|
-
"dunning: record write failed — continuing",
|
|
2026
|
-
);
|
|
2027
|
-
}
|
|
2028
|
-
}
|