create-ailk 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -1,835 +0,0 @@
1
- /**
2
- * @file recommender-capture.ts
3
- * @description The recommender's "email moment" service (#4837 — Epic #4760
4
- * C1). One write path for everything the PRD's purchase CTA requires (L111):
5
- * the FlowSession + Lead at the moment the visitor chooses save or checkout,
6
- * the plan minted as a DeliverableGrant through the EXISTING T+0 path
7
- * (`fulfillDeliverable`, #3961 D5), and — for checkout — the `FlowCheckout`
8
- * buyer record at a SERVER-recomputed price (D6: MAJOR units × 100).
9
- *
10
- * Four exports (spec §6):
11
- * - `captureRecommenderChoice` — resolve the served config + register,
12
- * recompute `recommend()` exactly as the browser ran it (D3: no flags),
13
- * create-or-resume a completed session (D1), `routeLead` (D2/D5),
14
- * T+0 grant (opt-in, fail-soft).
15
- * - `recordCheckout` — `price()` over the SERVED register, refuse the
16
- * unpriceable cases (D7), write the row.
17
- * - `attachStripeSession` — the stamp the route child (#4841) calls after the
18
- * Stripe call (the session id and, since #4912, the hosted-page URL a
19
- * replay is served from).
20
- * - `expireFlowCheckout` — the row half of #4912's attach-miss compensation.
21
- * - `findRegisterForSlug` — the slug → register read (the
22
- * `flow-aggregate.ts:135–175` shape, as a named export) the route child's
23
- * `stripeConfigRef` gate calls.
24
- *
25
- * The config read mirrors `serveFlow` (`flow-engine.ts:228–262`) — one
26
- * `flowDefinition.findUnique` + `flowConfigSchema.safeParse` — rather than
27
- * calling it, because `serveFlow` returns the parsed config only and
28
- * `FlowSession.flowId` needs the definition's id (`resolveSession`'s own
29
- * create shape, `flow-engine.ts:629–650`). Still one definition read.
30
- *
31
- * Lives in `apps/api/src/services/` (ADR 0009 D3): it composes the `inbox`
32
- * rows (FlowDefinition / FlowSession / WaitlistExperiment / FlowCheckout)
33
- * with `routeLead` (Lead) and `fulfillDeliverable` (DeliverableGrant).
34
- *
35
- * #4912 (REL-013.2 H2) hardens three things here: resume ownership (a session
36
- * carrying a Lead is resumable only by the email stamped on it, and its Lead is
37
- * reused rather than duplicated), `/checkout` idempotency (a server-derived
38
- * fingerprint under `@@unique([siteId, idempotencyKey])`, replayed within the
39
- * TTL and reconciled past it), and the attach-miss row compensation.
40
- *
41
- * PII boundary: every log line carries only
42
- * `{ siteId, flowSlug, sessionId, leadId, choice, outcome }` (+ a terse `err`
43
- * message on a caught rejection) — never the email, the answers, or the
44
- * payload (the `flow-engine.ts` / `deliverable-fulfillment.ts` boundary).
45
- */
46
-
47
- import { createHash } from "node:crypto";
48
-
49
- import type { Prisma } from "@working-theory/database";
50
- import { logger } from "@working-theory/observability";
51
- import {
52
- flowConfigSchema,
53
- price,
54
- recommend,
55
- waitlistExperimentConfigSchema,
56
- WaitlistPricingError,
57
- type FlowConfig,
58
- type RecommendRegister,
59
- type WaitlistAnswers,
60
- type WaitlistBillingPeriod,
61
- type WaitlistCreditStop,
62
- type WaitlistExperimentConfig,
63
- type WaitlistPriceQuote,
64
- type WaitlistRecommendation,
65
- } from "@working-theory/validation";
66
-
67
- import { prisma } from "../lib/prisma.js";
68
-
69
- import { MAX_AGGREGATION_ROWS } from "./answer-distributions.js";
70
- import { fulfillDeliverable } from "./deliverable-fulfillment.js";
71
- import { routeLead, type SourceMeta } from "./lead-routing.js";
72
-
73
- // ─── Types (spec §6) ────────────────────────────────────────────────────────
74
-
75
- export type RecommenderChoice = "save" | "checkout";
76
-
77
- /** The permalink's answer map (`flow-permalink.ts:38`) — answers only, never identity. */
78
- export type RecommenderAnswers = Record<string, string | string[]>;
79
-
80
- export type CaptureRecommenderChoiceInput = {
81
- siteId: string;
82
- flowSlug: string;
83
- answers: RecommenderAnswers;
84
- email: string;
85
- locale?: string;
86
- /** The visitor's own walk session to resume (D1); absent on a cold load. */
87
- sessionId?: string;
88
- choice: RecommenderChoice;
89
- sourceMeta: SourceMeta;
90
- };
91
-
92
- export type RecommenderCaptureErrorKind =
93
- | "flows_not_configured"
94
- | "flow_not_found"
95
- | "not_recommending"
96
- | "invalid_input"
97
- | "lead_failed";
98
-
99
- export type RecommenderCaptureError = {
100
- kind: RecommenderCaptureErrorKind;
101
- message: string;
102
- };
103
-
104
- export type CaptureRecommenderChoiceResult =
105
- | {
106
- ok: true;
107
- sessionId: string;
108
- leadId: string;
109
- recommendation: WaitlistRecommendation;
110
- config: FlowConfig;
111
- register: WaitlistExperimentConfig | undefined;
112
- }
113
- | { ok: false; error: RecommenderCaptureError };
114
-
115
- export type RecordCheckoutInput = {
116
- siteId: string;
117
- flowSlug: string;
118
- experimentKey: string;
119
- sessionId: string;
120
- leadId: string;
121
- /** The visitor's own email — hashed into the idempotency key (D5), never stored or logged in the clear. */
122
- email: string;
123
- offerKey: string;
124
- seats: number;
125
- period: WaitlistBillingPeriod;
126
- creditStop: WaitlistCreditStop;
127
- /** The answers-only payload string the recommendation was rendered from (#4770). */
128
- permalink: string;
129
- };
130
-
131
- export type RecordCheckoutErrorKind =
132
- | "no_register"
133
- | "session_mismatch"
134
- | "unknown_offer"
135
- | "no_seat_rate"
136
- | "no_rate_table"
137
- | "invalid_seats"
138
- | "unknown_credit_stop"
139
- | "custom_stop"
140
- | "no_currency";
141
-
142
- type RecordCheckoutQuoted = {
143
- flowCheckoutId: string;
144
- amountMinor: number;
145
- currency: string;
146
- quote: WaitlistPriceQuote;
147
- };
148
-
149
- /**
150
- * #4912 FR5 — DISCRIMINATED on `replayed`, so a replay can never be handed to
151
- * the caller with a null session or URL. The route branches on this one field;
152
- * modelling the two Stripe columns as `string | null` on a single arm would
153
- * make the impossible state representable and let a mis-narrowed branch open a
154
- * SECOND Stripe session for a request the service already replayed.
155
- */
156
- export type RecordCheckoutResult =
157
- | ({ ok: true; replayed: false } & RecordCheckoutQuoted)
158
- | ({ ok: true; replayed: true; stripeSessionId: string; checkoutUrl: string } & RecordCheckoutQuoted)
159
- | { ok: false; error: { kind: RecordCheckoutErrorKind; message: string } };
160
-
161
- // ─── Row shapes ──────────────────────────────────────────────────────────────
162
-
163
- const FLOW_DEFINITION_SELECT = {
164
- id: true,
165
- config: true,
166
- } as const satisfies Prisma.FlowDefinitionSelect;
167
- type FlowDefinitionRow = { id: string; config: unknown };
168
-
169
- const EXPERIMENT_CONFIG_SELECT = {
170
- id: true,
171
- config: true,
172
- } as const satisfies Prisma.WaitlistExperimentSelect;
173
- type ExperimentConfigRow = { id: string; config: unknown };
174
-
175
- const SESSION_RESUME_SELECT = {
176
- id: true,
177
- siteId: true,
178
- flowSlug: true,
179
- // #4912 FR3 — resume ownership is decided on these two columns; without them
180
- // the resume path could not tell a visitor's own second call from a caller
181
- // naming somebody else's session id (#4859).
182
- leadId: true,
183
- email: true,
184
- } as const satisfies Prisma.FlowSessionSelect;
185
- const SESSION_OWNER_SELECT = { siteId: true, flowSlug: true, leadId: true } as const;
186
- type SessionOwnerRow = { siteId: string; flowSlug: string; leadId: string | null };
187
- type SessionResumeRow = {
188
- id: string;
189
- siteId: string;
190
- flowSlug: string;
191
- leadId: string | null;
192
- email: string | null;
193
- };
194
-
195
- /** #4912 FR5 — the columns a keyed lookup needs to decide replay vs reconcile. */
196
- const CHECKOUT_KEY_SELECT = {
197
- id: true,
198
- status: true,
199
- stripeSessionId: true,
200
- checkoutUrl: true,
201
- createdAt: true,
202
- } as const satisfies Prisma.FlowCheckoutSelect;
203
- type CheckoutKeyRow = {
204
- id: string;
205
- status: string;
206
- stripeSessionId: string | null;
207
- checkoutUrl: string | null;
208
- createdAt: Date;
209
- };
210
-
211
- // ─── PII-safe logging ───────────────────────────────────────────────────────
212
-
213
- type CaptureOutcome =
214
- | "captured"
215
- | "lead_failed"
216
- | "grant_failed"
217
- | "checkout_recorded";
218
-
219
- type LogCtx = {
220
- siteId: string;
221
- flowSlug: string;
222
- sessionId?: string;
223
- leadId?: string;
224
- choice?: RecommenderChoice;
225
- };
226
-
227
- function logOutcome(ctx: LogCtx, outcome: CaptureOutcome, err?: unknown): void {
228
- const line = { ...ctx, outcome, ...(err !== undefined && { err: errorName(err) }) };
229
- if (outcome === "lead_failed" || outcome === "grant_failed") {
230
- logger.warn(line, `[recommender-capture] ${outcome}`);
231
- } else {
232
- logger.info(line, `[recommender-capture] ${outcome}`);
233
- }
234
- }
235
-
236
- /**
237
- * The error's NAME, never its message — an email-provider rejection can embed
238
- * the recipient address in `message`, and this module's log lines carry no
239
- * PII (spec §5.2; PR #4851 security review, condition (c)).
240
- */
241
- function errorName(err: unknown): string {
242
- return err instanceof Error && err.name ? err.name : "UnknownError";
243
- }
244
-
245
- /** The zod `flowCheckoutInputSchema` ceiling, enforced here too: `amountMinor` is INT4 (PR #4851 security review, condition (b)). */
246
- export const MAX_CHECKOUT_SEATS = 999;
247
-
248
- // ─── DB-required guard (mirrors flow-engine.ts's private `requireDb`) ────────
249
-
250
- function requireDb(): RecommenderCaptureError | null {
251
- if (!process.env.DATABASE_URL) {
252
- return { kind: "flows_not_configured", message: "Flows are not configured." };
253
- }
254
- return null;
255
- }
256
-
257
- // ─── findRegisterForSlug (FR7) ───────────────────────────────────────────────
258
-
259
- /**
260
- * Resolve a flow slug to its backing `WaitlistExperiment` register for one
261
- * site — the `flow-aggregate.ts:135–175` read as a named export: one
262
- * `siteId`-scoped `findMany`, oldest first, matched on `waitlists[].flowSlug`.
263
- * A corrupt row is skipped and logged, never thrown; more than one match
264
- * resolves to the oldest (the aggregate service's own documented posture).
265
- */
266
- export async function findRegisterForSlug(
267
- siteId: string,
268
- slug: string,
269
- ): Promise<WaitlistExperimentConfig | undefined> {
270
- const rows: ExperimentConfigRow[] = await prisma.waitlistExperiment.findMany({
271
- where: { siteId },
272
- select: EXPERIMENT_CONFIG_SELECT,
273
- orderBy: { createdAt: "asc" },
274
- take: MAX_AGGREGATION_ROWS,
275
- });
276
-
277
- let match: WaitlistExperimentConfig | undefined;
278
- for (const row of rows) {
279
- const parsed = waitlistExperimentConfigSchema.safeParse(row.config);
280
- if (!parsed.success) {
281
- logger.warn(
282
- { siteId, flowSlug: slug, outcome: "register_parse_skipped" },
283
- "[recommender-capture] experiment config failed to parse — skipped",
284
- );
285
- continue;
286
- }
287
- if (parsed.data.waitlists.some((w) => w.flowSlug === slug)) {
288
- match = parsed.data;
289
- break;
290
- }
291
- }
292
- return match;
293
- }
294
-
295
- // ─── Helpers ─────────────────────────────────────────────────────────────────
296
-
297
- /**
298
- * FR3 — key the permalink's flat answers by the segmentation step each
299
- * question `name` belongs to (`{ [stepKey]: { [name]: value } }`, the
300
- * `FlowSession.state` shape `flow-engine.ts:730–733` writes). An answer no
301
- * segmentation step declares is dropped — it is not part of the config.
302
- */
303
- function stateFromAnswers(
304
- config: FlowConfig,
305
- answers: RecommenderAnswers,
306
- ): Record<string, Record<string, string | string[]>> {
307
- const state: Record<string, Record<string, string | string[]>> = {};
308
- for (const step of config.steps) {
309
- if (step.type !== "segmentation") continue;
310
- for (const question of step.questions) {
311
- const value = answers[question.name];
312
- if (value === undefined) continue;
313
- (state[step.key] ??= {})[question.name] = value;
314
- }
315
- }
316
- return state;
317
- }
318
-
319
- /** D3 — the identical `recommend()` register `ResultsClient.tsx:47–56` builds. */
320
- function registerFromConfig(config: FlowConfig): RecommendRegister {
321
- return {
322
- key: config.experiment?.key ?? config.slug,
323
- offers: config.offers,
324
- pairings: config.pairings,
325
- rateTable: config.rateTable,
326
- scoring: config.scoring,
327
- };
328
- }
329
-
330
- /**
331
- * #4912 D3 — resume ownership.
332
- *
333
- * A walk session that carries no `leadId` is nobody's yet: the first email to
334
- * name it takes it, which is the cold-load path this service has always had.
335
- * Once a Lead is stamped on it the session belongs to that email, and only
336
- * that email may resume it — a caller naming somebody else's high-entropy id
337
- * gets a fresh session instead of overwriting theirs (#4859). Compared
338
- * case-insensitively after trimming, the same normalization the Lead's own
339
- * email validation applies.
340
- */
341
- function resumeIsOwned(row: SessionResumeRow, email: string): boolean {
342
- if (row.leadId === null) return true;
343
- if (row.email === null) return false;
344
- return row.email.trim().toLowerCase() === email.trim().toLowerCase();
345
- }
346
-
347
- // ─── captureRecommenderChoice (FR2–FR5) ──────────────────────────────────────
348
-
349
- export async function captureRecommenderChoice(
350
- input: CaptureRecommenderChoiceInput,
351
- ): Promise<CaptureRecommenderChoiceResult> {
352
- const dbError = requireDb();
353
- if (dbError) return { ok: false, error: dbError };
354
-
355
- const { siteId, flowSlug, answers, email, choice, sourceMeta } = input;
356
- const logCtx: LogCtx = { siteId, flowSlug, choice };
357
-
358
- // The served config — one definition read + the same parse `serveFlow` does.
359
- const definition: FlowDefinitionRow | null = await prisma.flowDefinition.findUnique({
360
- where: { siteId_slug: { siteId, slug: flowSlug } },
361
- select: FLOW_DEFINITION_SELECT,
362
- });
363
- if (!definition) {
364
- return { ok: false, error: { kind: "flow_not_found", message: "Flow not found." } };
365
- }
366
- const parsedConfig = flowConfigSchema.safeParse(definition.config);
367
- if (!parsedConfig.success) {
368
- return {
369
- ok: false,
370
- error: { kind: "invalid_input", message: "Stored flow config failed validation." },
371
- };
372
- }
373
- const config = parsedConfig.data;
374
- if (config.mode !== "recommending" || config.offers === undefined) {
375
- return {
376
- ok: false,
377
- error: { kind: "not_recommending", message: "Flow is not a recommending flow." },
378
- };
379
- }
380
- if (!config.experiment) {
381
- // A compiled recommending flow always carries its binding
382
- // (`compileWaitlistFlow` sets it unconditionally); its absence is a
383
- // register-integrity fault, not a branch to invent (spec §5.3).
384
- return {
385
- ok: false,
386
- error: { kind: "invalid_input", message: "Flow carries no experiment binding." },
387
- };
388
- }
389
-
390
- const register = await findRegisterForSlug(siteId, flowSlug);
391
-
392
- // D3 — the browser's exact call: no flags.
393
- const recommendation = recommend(registerFromConfig(config), answers as WaitlistAnswers);
394
-
395
- const state = stateFromAnswers(config, answers);
396
- const locale = input.locale ?? config.defaultLocale;
397
- const now = new Date();
398
-
399
- // D1 — resume the visitor's own walk session, else create.
400
- // #4912 FR3 — and only when the session is theirs to resume.
401
- let sessionId: string | undefined;
402
- let ownedLeadId: string | undefined;
403
- if (input.sessionId) {
404
- const existing: SessionResumeRow | null = await prisma.flowSession.findUnique({
405
- where: { id: input.sessionId },
406
- select: SESSION_RESUME_SELECT,
407
- });
408
- if (
409
- existing &&
410
- existing.siteId === siteId &&
411
- existing.flowSlug === flowSlug &&
412
- resumeIsOwned(existing, email)
413
- ) {
414
- await prisma.flowSession.update({
415
- where: { id: existing.id },
416
- data: {
417
- email,
418
- state: state as unknown as Prisma.InputJsonValue,
419
- locale,
420
- completedAt: now,
421
- },
422
- });
423
- sessionId = existing.id;
424
- if (existing.leadId !== null) ownedLeadId = existing.leadId;
425
- }
426
- }
427
- if (sessionId === undefined) {
428
- const created = (await prisma.flowSession.create({
429
- data: {
430
- siteId,
431
- flowId: definition.id,
432
- flowSlug,
433
- configSnapshot: config as unknown as Prisma.InputJsonValue,
434
- state: state as unknown as Prisma.InputJsonValue,
435
- email,
436
- locale,
437
- completedAt: now,
438
- },
439
- select: { id: true },
440
- })) as { id: string };
441
- sessionId = created.id;
442
- }
443
- logCtx.sessionId = sessionId;
444
-
445
- // #4912 FR4 — the visitor's own resubmission of a session that already
446
- // carries its Lead. `routeLead` writes a new Lead on every call, so calling
447
- // it again is exactly the duplicate #4860 reports; the T+0 grant was minted
448
- // with that Lead too, so it is not re-minted either. The session's answers,
449
- // locale and completion stamp were refreshed by the resume update above.
450
- if (ownedLeadId !== undefined) {
451
- logCtx.leadId = ownedLeadId;
452
- logOutcome(logCtx, "captured");
453
- return { ok: true, sessionId, leadId: ownedLeadId, recommendation, config, register };
454
- }
455
-
456
- // FR4 / D2 — the flow-engine completion payload shape + the recommender block.
457
- const payload: Record<string, unknown> = {};
458
- for (const stepData of Object.values(state)) Object.assign(payload, stepData);
459
- payload["flowSlug"] = flowSlug;
460
- payload["locale"] = locale;
461
- payload["experimentKey"] = config.experiment.key;
462
- payload["waitlistKey"] = config.experiment.waitlist;
463
- payload["recommender"] = {
464
- choice,
465
- fits: recommendation.fits.map((f) => f.offerKey),
466
- ...(recommendation.pairing !== undefined && { pairing: recommendation.pairing.offers }),
467
- };
468
-
469
- const leadResult = await routeLead(
470
- { kind: "submit", siteId, source: `flow:${flowSlug}`, email, payload },
471
- sourceMeta,
472
- );
473
-
474
- if (!leadResult.ok) {
475
- // D5 — the purchase path needs a Lead; the session stays the record
476
- // (the engine's own fallback, `flow-engine.ts:925–939`). Idempotent — the
477
- // create/resume above already set `completedAt` — so no branch on which
478
- // path we took.
479
- await prisma.flowSession.update({
480
- where: { id: sessionId },
481
- data: { completedAt: now },
482
- });
483
- logOutcome(logCtx, "lead_failed");
484
- return {
485
- ok: false,
486
- error: { kind: "lead_failed", message: "Lead could not be recorded." },
487
- };
488
- }
489
-
490
- const leadId = leadResult.id;
491
- logCtx.leadId = leadId;
492
- await prisma.flowSession.update({ where: { id: sessionId }, data: { leadId } });
493
-
494
- // FR5 — the T+0 grant: opt-in on the block, fail-soft on any rejection.
495
- if (config.deliverables) {
496
- try {
497
- await fulfillDeliverable({
498
- siteId,
499
- flowSlug,
500
- configSnapshot: config,
501
- leadId,
502
- sessionId,
503
- email,
504
- locale,
505
- });
506
- } catch (err) {
507
- logOutcome(logCtx, "grant_failed", err);
508
- }
509
- }
510
-
511
- logOutcome(logCtx, "captured");
512
- return { ok: true, sessionId, leadId, recommendation, config, register };
513
- }
514
-
515
- // ─── Checkout idempotency (#4912 FR5/FR6, D5/D8) ─────────────────────────────
516
-
517
- /**
518
- * The window in which a repeat `/checkout` submission is still the SAME
519
- * attempt — and, with the same value, the age past which a `created` row that
520
- * never took a Stripe session id is dead (#4912 D8). Thirty minutes covers a
521
- * double click, a retried request and a back-button resubmit, and sits well
522
- * inside Stripe's own 24-hour session expiry, so a replayed row's session is
523
- * never already gone.
524
- */
525
- export const FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS = 30 * 60_000;
526
-
527
- /**
528
- * The request's fingerprint (#4912 D5) — a SHA-256 over the fields that make
529
- * two submissions the same purchase, JSON-encoded in a fixed order.
530
- *
531
- * ENCODED, never delimiter-joined: a joined key forges, since `["a","b"]` and
532
- * `["a:b"]` collide under any separator. `creditStop` is projected field by
533
- * field (its schema is `{kind}` or `{kind, index}`) so JSON key order cannot
534
- * vary between two otherwise-identical requests.
535
- *
536
- * The email is hashed here and never leaves this function in the clear: the
537
- * digest is what reaches the database, and no log line carries it.
538
- */
539
- function checkoutIdempotencyKey(
540
- input: RecordCheckoutInput,
541
- priced: { amountMinor: number; currency: string },
542
- ): string {
543
- const stop = input.creditStop;
544
- return createHash("sha256")
545
- .update(
546
- JSON.stringify([
547
- input.siteId,
548
- input.flowSlug,
549
- input.sessionId,
550
- input.email.trim().toLowerCase(),
551
- input.offerKey,
552
- input.seats,
553
- input.period,
554
- stop.kind,
555
- stop.kind === "tier" ? stop.index : null,
556
- input.permalink,
557
- // The SERVER's resolved price is part of what makes two submissions the
558
- // same purchase: a replay hands back a Stripe session opened at the
559
- // stored amount, so a register edit between the two must mint a new
560
- // session rather than replay the pre-edit price (PRD L104 in effect,
561
- // not only in letter).
562
- priced.amountMinor,
563
- priced.currency,
564
- ]),
565
- )
566
- .digest("hex");
567
- }
568
-
569
- /**
570
- * What a keyed row means for the attempt that just hashed to its key.
571
- *
572
- * - `replayable` — a live `created` row inside the TTL whose Stripe session
573
- * and hosted-page URL are both stamped. This is the double-submit AC-3
574
- * names: hand back what the first attempt got.
575
- * - `in_flight` — a `created` row inside the TTL with no session stamped yet.
576
- * Its own request is between `create` and `attachStripeSession`; touching it
577
- * would strand a session the #4838 webhook could then never join, because
578
- * that webhook's update is guarded on `status: "created"`.
579
- * - `dead` — past the TTL, or no longer `created` (the webhook completed or
580
- * expired it). Its key is not this attempt's to collide with.
581
- */
582
- function classifyKeyedRow(row: CheckoutKeyRow): "replayable" | "in_flight" | "dead" {
583
- const fresh = Date.now() - row.createdAt.getTime() <= FLOW_CHECKOUT_IDEMPOTENCY_TTL_MS;
584
- if (!fresh || row.status !== "created") return "dead";
585
- return row.stripeSessionId !== null && row.checkoutUrl !== null ? "replayable" : "in_flight";
586
- }
587
-
588
- /**
589
- * A Prisma unique-constraint violation, recognised structurally rather than by
590
- * importing the runtime error class — the `waitlist-followup.ts:429` and
591
- * `tenant-context.ts:448` idiom.
592
- */
593
- function isUniqueViolation(err: unknown): boolean {
594
- return (
595
- typeof err === "object" && err !== null && (err as { code?: unknown }).code === "P2002"
596
- );
597
- }
598
-
599
- /**
600
- * A stale keyed row is logged with the row-id prefix and an outcome, and
601
- * nothing else (#4912 AC-4) — no email, no permalink, no URL.
602
- */
603
- function logCheckoutReconciled(flowCheckoutId: string): void {
604
- logger.warn(
605
- { flowCheckoutIdPrefix: flowCheckoutId.substring(0, 8), outcome: "checkout_reconciled" },
606
- "[recommender-capture] stale checkout row reconciled",
607
- );
608
- }
609
-
610
- // ─── recordCheckout (FR6, D6/D7) ─────────────────────────────────────────────
611
-
612
- export async function recordCheckout(input: RecordCheckoutInput): Promise<RecordCheckoutResult> {
613
- const { siteId, flowSlug, offerKey, seats, period, creditStop } = input;
614
- const fail = (kind: RecordCheckoutErrorKind, message: string): RecordCheckoutResult => ({
615
- ok: false,
616
- error: { kind, message },
617
- });
618
-
619
- // The register is resolved HERE, from the DB, never taken from a caller —
620
- // so no route can price against a client-derived register (PR #4851
621
- // security review, condition (a)).
622
- const register = await findRegisterForSlug(siteId, flowSlug);
623
- if (!register) return fail("no_register", "No register names this flow.");
624
-
625
- if (!Number.isInteger(seats) || seats < 1 || seats > MAX_CHECKOUT_SEATS) {
626
- return fail("invalid_seats", `seats must be an integer between 1 and ${MAX_CHECKOUT_SEATS}.`);
627
- }
628
-
629
- // The session + lead ids must be THIS site's, and the lead must be the one
630
- // stamped on that session — never ids a caller threaded through (condition (d)).
631
- const session: SessionOwnerRow | null = await prisma.flowSession.findUnique({
632
- where: { id: input.sessionId },
633
- select: SESSION_OWNER_SELECT,
634
- });
635
- if (
636
- !session ||
637
- session.siteId !== siteId ||
638
- session.flowSlug !== flowSlug ||
639
- session.leadId !== input.leadId
640
- ) {
641
- return fail("session_mismatch", "The session does not belong to this site, flow and lead.");
642
- }
643
-
644
- const rateTable = register.rateTable;
645
- if (!rateTable) return fail("no_rate_table", "The register carries no rateTable.");
646
- const currency = rateTable.currency;
647
- if (!currency) return fail("no_currency", "The register's rateTable declares no currency.");
648
- if (creditStop.kind === "custom") {
649
- return fail("custom_stop", "A custom credit stop carries no total to charge.");
650
- }
651
-
652
- let quote: WaitlistPriceQuote;
653
- try {
654
- quote = price(register, offerKey, seats, period, creditStop);
655
- } catch (err) {
656
- if (err instanceof WaitlistPricingError) return fail(err.code, err.message);
657
- throw err;
658
- }
659
- if (quote.kind !== "quoted") {
660
- // Unreachable — the `custom` stop was refused above — but it is the TS
661
- // narrowing that lets `quote.total` be read below; not a second policy check.
662
- return fail("custom_stop", "A custom credit stop carries no total to charge.");
663
- }
664
-
665
- // D6 — the register is MAJOR units; Stripe and the row take MINOR units.
666
- const amountMinor = Math.round(quote.total * 100);
667
-
668
- // #4912 FR5 — the keyed lookup runs AFTER the gates and the price, so a
669
- // replay is still a fully validated request and the amount is still the
670
- // server's own; only the row write and the Stripe call are skipped.
671
- const priced = { amountMinor, currency };
672
- const idempotencyKey = checkoutIdempotencyKey(input, priced);
673
- const existing: CheckoutKeyRow | null = await prisma.flowCheckout.findUnique({
674
- where: { siteId_idempotencyKey: { siteId, idempotencyKey } },
675
- select: CHECKOUT_KEY_SELECT,
676
- });
677
-
678
- // `true` once this attempt must write an UNKEYED row: the key belongs to an
679
- // attempt that is still in flight, and stealing it would kill a row whose own
680
- // Stripe session is about to be stamped on it (see `keyOwner` below).
681
- let keyHeldByAnother = false;
682
-
683
- if (existing) {
684
- const state = classifyKeyedRow(existing);
685
- if (state === "replayable") {
686
- return {
687
- ok: true,
688
- replayed: true,
689
- flowCheckoutId: existing.id,
690
- amountMinor,
691
- currency,
692
- quote,
693
- stripeSessionId: existing.stripeSessionId as string,
694
- checkoutUrl: existing.checkoutUrl as string,
695
- };
696
- }
697
- if (state === "in_flight") {
698
- // The keyed row was created moments ago and has not been stamped yet —
699
- // an identical request is between its own `create` and its Stripe call.
700
- // Releasing or expiring that row here would strand its session and leave
701
- // a payment the #4838 webhook could never join (its update is guarded on
702
- // `status: "created"`), so it is left completely alone and THIS attempt
703
- // takes a row of its own, unkeyed.
704
- keyHeldByAnother = true;
705
- } else {
706
- // #4912 FR6 — the keyed row is past the TTL, or the webhook already
707
- // completed/expired it. Either way it is not this attempt's, so its key
708
- // is released. A row that never took a Stripe session id can never be
709
- // joined by the webhook, so it is also marked expired and its stored URL
710
- // dropped; a row that HAS one is otherwise left untouched, because the
711
- // webhook may still legitimately stamp a payment onto it.
712
- const unjoinable = existing.status === "created" && existing.stripeSessionId === null;
713
- await prisma.flowCheckout.updateMany({
714
- where: { id: existing.id, siteId },
715
- data: unjoinable
716
- ? { status: "expired", idempotencyKey: null, checkoutUrl: null }
717
- : { idempotencyKey: null },
718
- });
719
- logCheckoutReconciled(existing.id);
720
- }
721
- }
722
-
723
- const writeRow = async (key: string | null): Promise<{ id: string }> =>
724
- (await prisma.flowCheckout.create({
725
- data: {
726
- siteId: input.siteId,
727
- flowSlug: input.flowSlug,
728
- experimentKey: input.experimentKey,
729
- sessionId: input.sessionId,
730
- leadId: input.leadId,
731
- offerKey,
732
- seats,
733
- period,
734
- creditStop: creditStop as unknown as Prisma.InputJsonValue,
735
- amountMinor,
736
- currency,
737
- quote: quote as unknown as Prisma.InputJsonValue,
738
- permalink: input.permalink,
739
- stripeSessionId: null,
740
- checkoutUrl: null,
741
- idempotencyKey: key,
742
- status: "created",
743
- },
744
- select: { id: true },
745
- })) as { id: string };
746
-
747
- let row: { id: string };
748
- if (keyHeldByAnother) {
749
- row = await writeRow(null);
750
- } else {
751
- try {
752
- row = await writeRow(idempotencyKey);
753
- } catch (err) {
754
- // Two identical requests cleared the lookup above before either write
755
- // landed, and the unique caught the loser. The `waitlist-followup.ts`
756
- // idiom: recognise the violation, do not let it surface as a 500 on the
757
- // payment path. Re-read once — the winner may already be replayable —
758
- // and otherwise take an unkeyed row, exactly as the in-flight case does.
759
- if (!isUniqueViolation(err)) throw err;
760
- const winner: CheckoutKeyRow | null = await prisma.flowCheckout.findUnique({
761
- where: { siteId_idempotencyKey: { siteId, idempotencyKey } },
762
- select: CHECKOUT_KEY_SELECT,
763
- });
764
- if (winner && classifyKeyedRow(winner) === "replayable") {
765
- return {
766
- ok: true,
767
- replayed: true,
768
- flowCheckoutId: winner.id,
769
- amountMinor,
770
- currency,
771
- quote,
772
- stripeSessionId: winner.stripeSessionId as string,
773
- checkoutUrl: winner.checkoutUrl as string,
774
- };
775
- }
776
- row = await writeRow(null);
777
- }
778
- }
779
-
780
- logOutcome(
781
- { siteId: input.siteId, flowSlug: input.flowSlug, sessionId: input.sessionId, leadId: input.leadId, choice: "checkout" },
782
- "checkout_recorded",
783
- );
784
-
785
- return { ok: true, replayed: false, flowCheckoutId: row.id, amountMinor, currency, quote };
786
- }
787
-
788
- // ─── attachStripeSession ─────────────────────────────────────────────────────
789
-
790
- /**
791
- * The one-column stamp the route child calls after the Stripe call (#4841).
792
- * Tenant-scoped: the update matches on `siteId` too, so a row id alone can
793
- * never reach another site's record (PR #4851 security review, condition (e)).
794
- * Returns the matched-row count so the caller can refuse to hand out a
795
- * session whose row the #4838 webhook could never join (`count !== 1`).
796
- */
797
- export async function attachStripeSession(
798
- siteId: string,
799
- flowCheckoutId: string,
800
- stripeSessionId: string,
801
- checkoutUrl: string,
802
- ): Promise<{ count: number }> {
803
- const result = await prisma.flowCheckout.updateMany({
804
- // #4912 — `status: "created"` is the third leg, matching the #4838
805
- // webhook's own join. A row something else already finished or expired must
806
- // NOT take this stamp: handing the visitor a URL for a row the webhook can
807
- // no longer update would produce a real payment nobody records. A miss
808
- // routes into the caller's attach-miss compensation, which expires the
809
- // session instead of returning it.
810
- where: { id: flowCheckoutId, siteId, status: "created" },
811
- data: { stripeSessionId, checkoutUrl },
812
- });
813
- return { count: result.count };
814
- }
815
-
816
- /**
817
- * The row half of the attach-miss compensation (#4912 FR7). The Stripe session
818
- * is being expired, so the row is dead: it is marked `expired` and its
819
- * idempotency key released, so the visitor's own retry writes a fresh row
820
- * instead of colliding on the unique. Tenant-scoped like the stamp above.
821
- */
822
- export async function expireFlowCheckout(
823
- siteId: string,
824
- flowCheckoutId: string,
825
- ): Promise<{ count: number }> {
826
- const result = await prisma.flowCheckout.updateMany({
827
- // Guarded on `status: "created"` like the stamp above: a row the webhook
828
- // already completed must never be downgraded to `expired` by a compensation
829
- // for a DIFFERENT attempt. A zero count means the row was no longer this
830
- // attempt's to kill — the caller reports that rather than claiming it acted.
831
- where: { id: flowCheckoutId, siteId, status: "created" },
832
- data: { status: "expired", idempotencyKey: null, checkoutUrl: null },
833
- });
834
- return { count: result.count };
835
- }