create-ailk 0.4.0 → 0.6.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 (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,1399 @@
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
+ * Billing upsert (customer.created / checkout.session.completed):
35
+ * Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
36
+ * carrying metadata.organizationId links the Organization (multi-tenant
37
+ * module); absent → the existing User path (single-tenant), byte-for-byte.
38
+ *
39
+ * Organization path (metadata.organizationId present):
40
+ * 1. Typed composite match already pointing at this org → idempotent no-op
41
+ * 2. Else link the target Organization (by id) billing fields
42
+ *
43
+ * User path (no organizationId — unchanged):
44
+ * 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customer)
45
+ * 2. Email fallback: User.findUnique({ where: { email } }) → update billing fields
46
+ * 3. No match → 200 + warn log; no User row created (pre-signup state)
47
+ *
48
+ * Recommender purchase stamp (checkout.session.completed / .expired, #4838):
49
+ * A session carrying metadata.flowCheckoutId (the FlowCheckout buyer row
50
+ * #4841 opened it with) is stamped `completed` / `expired` by ONE guarded
51
+ * `updateMany` on `{ id, status: "created" }` — the durable idempotency key
52
+ * (D1): a replay, a restart between deliveries, or a late `expired` after a
53
+ * `completed` all match zero rows and log `stale_or_unknown`. Runs BESIDE the
54
+ * telemetry + billing tasks (D4), never instead of them; a charged/quoted
55
+ * mismatch is logged, never reconciled by overwrite (D2). Always 200. A
56
+ * `completed` session stamps only when `payment_status` is `"paid"` or
57
+ * `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
58
+ * logs `outcome: "unpaid"` and leaves the row `created`.
59
+ *
60
+ * Dunning (invoice.payment_failed / customer.subscription.updated, #6034):
61
+ * Records a per-subscriber lifecycle state in a `UserData` row behind a
62
+ * reserved-key guard (routes/auth/user.ts). `invoice.payment_failed` arms
63
+ * dunning (`status: "past_due"`) and never revokes; `customer.subscription
64
+ * .updated` maps the narrowed status onto the same record, and `canceled`
65
+ * revokes access only once a per-tenant grace period (SiteSetting-backed,
66
+ * `services/dunning-policy.ts`) has lazily expired. See README.md
67
+ * § Dunning for the full state machine.
68
+ */
69
+ import { track } from "@working-theory/analytics/server";
70
+ import type { Prisma } from "@working-theory/database";
71
+ import type { FastifyReply, FastifyRequest } from "fastify";
72
+
73
+ import { config } from "../../config/index.js";
74
+ import { prisma } from "../../lib/prisma.js";
75
+ import {
76
+ resolveCommerceClaim,
77
+ commerceDisabledResponse,
78
+ LICENSE_UNDETERMINED_BODY,
79
+ LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS,
80
+ LICENSE_UNDETERMINED_STATUS,
81
+ } from "../../lib/require-commerce-claim.js";
82
+ import { currentSiteId } from "../../lib/site.js";
83
+ import {
84
+ StripeWebhookSignatureError,
85
+ toSubscriptionLifecycleStatus,
86
+ verifyWebhookSignature,
87
+ } from "../../lib/stripe.js";
88
+ import { hasSeen, markSeen } from "../../lib/webhook-idempotency.js";
89
+ import { isUniqueViolation } from "../../services/default-tenant.js";
90
+ import {
91
+ DUNNING_MAX_SUBSCRIPTIONS,
92
+ DUNNING_USER_DATA_KEY,
93
+ dunningPolicyFor,
94
+ type DunningEntry,
95
+ type DunningRecord,
96
+ } from "../../services/dunning-policy.js";
97
+
98
+ const TELEMETRY_TIMEOUT_MS = 2000;
99
+ const DUNNING_WRITE_MAX_ATTEMPTS = 3;
100
+
101
+ /**
102
+ * Everything that must be true before an event is dispatched, evaluated as a
103
+ * value rather than as an early `reply.send` — so the caller can settle the
104
+ * signature BEFORE consulting the commerce claim (D3, #5796) without
105
+ * duplicating any of the four rejections. The log lines ride along so a
106
+ * held rejection still logs exactly what it logged before, and only when it
107
+ * is the response actually sent.
108
+ */
109
+ type SettledSignature =
110
+ | { ok: true; event: ReturnType<typeof verifyWebhookSignature> }
111
+ | {
112
+ ok: false;
113
+ status: number;
114
+ body: { error: string };
115
+ log: (request: FastifyRequest) => void;
116
+ };
117
+
118
+ function settleSignature(request: FastifyRequest): SettledSignature {
119
+ const secret = config.STRIPE_WEBHOOK_SECRET;
120
+ if (!secret) {
121
+ return {
122
+ ok: false,
123
+ status: 503,
124
+ body: { error: "webhook-not-configured" },
125
+ log: (req) =>
126
+ req.log.warn("stripe webhook hit but STRIPE_WEBHOOK_SECRET is not set"),
127
+ };
128
+ }
129
+
130
+ const rawBody = request.body;
131
+ if (!Buffer.isBuffer(rawBody)) {
132
+ return {
133
+ ok: false,
134
+ status: 400,
135
+ body: { error: "invalid-request" },
136
+ log: (req) =>
137
+ req.log.error(
138
+ "stripe webhook body is not a Buffer — content-type parser misconfigured",
139
+ ),
140
+ };
141
+ }
142
+
143
+ const signature = request.headers["stripe-signature"];
144
+ if (typeof signature !== "string" || signature.length === 0) {
145
+ return {
146
+ ok: false,
147
+ status: 400,
148
+ body: { error: "signature-missing" },
149
+ log: () => undefined,
150
+ };
151
+ }
152
+
153
+ try {
154
+ return {
155
+ ok: true,
156
+ event: verifyWebhookSignature(rawBody, signature, secret),
157
+ };
158
+ } catch (err) {
159
+ if (err instanceof StripeWebhookSignatureError) {
160
+ const reason = err.message;
161
+ return {
162
+ ok: false,
163
+ status: 400,
164
+ body: { error: "signature-invalid" },
165
+ log: (req) =>
166
+ req.log.warn(
167
+ { reason },
168
+ "stripe webhook signature verification failed",
169
+ ),
170
+ };
171
+ }
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ export const stripeWebhookHandler = async (
177
+ request: FastifyRequest,
178
+ reply: FastifyReply,
179
+ ): Promise<void> => {
180
+ // ── Signature first, claim second (D3, #5796) ────────────────────────────
181
+ //
182
+ // The four pre-dispatch rejections above are SETTLED before the licence is
183
+ // consulted, so an unsigned or unverifiable body never decides anything on
184
+ // the strength of the claim. Their replies are held rather than sent, so
185
+ // the unclaimed branch below can answer the module-disabled 404 instead of
186
+ // leaking a configured-but-off shape.
187
+ const settled = settleSignature(request);
188
+ const claim = await resolveCommerceClaim();
189
+
190
+ if (claim === "undetermined") {
191
+ // The licence read established no "no" — it threw, the licence is revoked
192
+ // mid-window, or one IS configured yet no longer verifies. Any of those
193
+ // may be a live commerce site whose buyer was already charged. Never drop
194
+ // on a guess: 503 + `Retry-After` so Stripe QUEUES the event and
195
+ // redelivers it once the read succeeds (security review 2026-09-16, High,
196
+ // widened by the operator in round 2). Applies to a signed event and an
197
+ // unsigned body alike; the unsigned one was going to be refused either way.
198
+ request.log.warn(
199
+ {
200
+ outcome: "license_undetermined",
201
+ signatureVerified: settled.ok,
202
+ ...(settled.ok
203
+ ? {
204
+ eventType: settled.event.type,
205
+ eventIdPrefix: settled.event.id.substring(0, 8),
206
+ }
207
+ : {}),
208
+ },
209
+ "stripe webhook held — commerce licence could not be read; asking Stripe to retry",
210
+ );
211
+ await reply
212
+ .code(LICENSE_UNDETERMINED_STATUS)
213
+ .header("Retry-After", String(LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS))
214
+ .send(LICENSE_UNDETERMINED_BODY);
215
+ return;
216
+ }
217
+
218
+ if (claim === "unclaimed") {
219
+ if (settled.ok) {
220
+ // The licence was READ and positively does not carry `commerce`. Ack
221
+ // 200 and DROP: a 4xx here would have Stripe retry this event for days
222
+ // against a site that will never handle it. `warn`, not `info` — a
223
+ // discarded Stripe event is never routine.
224
+ request.log.warn(
225
+ {
226
+ eventType: settled.event.type,
227
+ eventIdPrefix: settled.event.id.substring(0, 8),
228
+ outcome: "dropped_no_commerce_claim",
229
+ },
230
+ "stripe webhook event dropped — deployment licence carries no commerce claim",
231
+ );
232
+ await reply.code(200).send({ received: true });
233
+ return;
234
+ }
235
+ // Unsigned / unverifiable AND unclaimed: answer exactly as a build
236
+ // without the commerce module did — the module-disabled 404.
237
+ const err = commerceDisabledResponse();
238
+ await reply.code(err.status).send(err.body);
239
+ return;
240
+ }
241
+
242
+ if (!settled.ok) {
243
+ settled.log(request);
244
+ await reply.code(settled.status).send(settled.body);
245
+ return;
246
+ }
247
+
248
+ const event = settled.event;
249
+ const eventIdPrefix = event.id.substring(0, 8);
250
+ request.log.info(
251
+ { eventType: event.type, eventIdPrefix },
252
+ "stripe webhook event received",
253
+ );
254
+
255
+ // AILK-layer idempotency. Stripe guarantees at-least-once delivery and
256
+ // dashboard "resend" replays a previously-delivered event verbatim. Skip
257
+ // side effects on the second arrival; still 200 so Stripe stops retrying.
258
+ if (hasSeen(event.id)) {
259
+ request.log.info(
260
+ { eventType: event.type, eventIdPrefix },
261
+ "duplicate stripe webhook event — idempotent short-circuit",
262
+ );
263
+ await reply.code(200).send({ received: true });
264
+ return;
265
+ }
266
+ markSeen(event.id);
267
+
268
+ switch (event.type) {
269
+ case "customer.created": {
270
+ const customer = event.data.object as {
271
+ id: string;
272
+ email?: string | null;
273
+ metadata?: Record<string, string> | null;
274
+ };
275
+ // Billing-entity routing (spec D2): a multi-tenant checkout stamps the
276
+ // target Organization id in metadata.organizationId (an individual → their
277
+ // sentinel default org). Present → link the Organization; absent → the
278
+ // existing User path, byte-for-byte (single-tenant, degrade-clean).
279
+ const organizationId = customer.metadata?.["organizationId"] ?? null;
280
+ if (organizationId) {
281
+ await upsertOrgBillingIdentity(request, {
282
+ organizationId,
283
+ customerId: customer.id,
284
+ eventIdPrefix,
285
+ });
286
+ } else {
287
+ await upsertBillingIdentity(request, {
288
+ customerId: customer.id,
289
+ email: customer.email ?? null,
290
+ eventIdPrefix,
291
+ });
292
+ }
293
+ break;
294
+ }
295
+ case "checkout.session.completed": {
296
+ const session = event.data.object as CheckoutSessionObject;
297
+ const props = {
298
+ amount: session.amount_total ?? undefined,
299
+ currency: session.currency ?? undefined,
300
+ productId:
301
+ session.metadata?.["productId"] ??
302
+ session.metadata?.["product_id"] ??
303
+ undefined,
304
+ eventType: event.type,
305
+ eventIdPrefix,
306
+ };
307
+
308
+ // Billing-entity routing (spec D2): metadata.organizationId → Organization
309
+ // path; absent → the existing User path, unchanged (single-tenant).
310
+ const organizationId = session.metadata?.["organizationId"] ?? null;
311
+ let billingTask: Promise<void> = Promise.resolve();
312
+ if (session.customer) {
313
+ billingTask = organizationId
314
+ ? upsertOrgBillingIdentity(request, {
315
+ organizationId,
316
+ customerId: session.customer,
317
+ eventIdPrefix,
318
+ })
319
+ : upsertBillingIdentity(request, {
320
+ customerId: session.customer,
321
+ email: session.customer_details?.email ?? null,
322
+ eventIdPrefix,
323
+ });
324
+ }
325
+
326
+ // Telemetry, billing upsert and the recommender stamp are independent —
327
+ // run in parallel (the stamp is a no-op without metadata.flowCheckoutId).
328
+ // Telemetry, billing upsert, the recommender stamp and the
329
+ // request-and-confirm stamp are independent — run in parallel. The
330
+ // request stamp is BESIDE the existing three, never instead of them,
331
+ // and is a no-op without `session.payment_link` (#5970).
332
+ await Promise.all([
333
+ fireTelemetryWithTimeout(request, "purchase-completed", props),
334
+ billingTask,
335
+ stampFlowCheckout(request, {
336
+ session,
337
+ eventIdPrefix,
338
+ outcome: "completed",
339
+ }),
340
+ stampRequestPaid(request, { session, eventIdPrefix }),
341
+ ]);
342
+ break;
343
+ }
344
+ case "invoice.paid": {
345
+ // #5970 — the hosted-invoice rung of request-and-confirm. Nothing writes
346
+ // `stripeInvoiceId` at this issue's boundary (the invoice path needs a
347
+ // `createCustomer` export the stripe-client does not have), so this arm
348
+ // is INERT today and exists so that rung lands additively: no second
349
+ // migration, no second webhook change. It carries the same guarded
350
+ // stamp, so it is already correct when a writer appears.
351
+ const invoice = event.data.object as { id?: string };
352
+ await stampRequestPaidByInvoice(request, {
353
+ invoiceId: invoice.id ?? null,
354
+ eventIdPrefix,
355
+ });
356
+ break;
357
+ }
358
+ case "checkout.session.expired": {
359
+ const session = event.data.object as CheckoutSessionObject;
360
+ if (!session.metadata?.["flowCheckoutId"]) {
361
+ // Not a recommender session — the same low-noise line the default
362
+ // arm emitted for this event before #4838 handled it.
363
+ request.log.info(
364
+ { eventType: event.type, eventIdPrefix },
365
+ "unhandled webhook event",
366
+ );
367
+ break;
368
+ }
369
+ await stampFlowCheckout(request, {
370
+ session,
371
+ eventIdPrefix,
372
+ outcome: "expired",
373
+ });
374
+ break;
375
+ }
376
+ case "invoice.payment_failed": {
377
+ // Dunning arm 1 of 2 (#6034 FR-1) — arms dunning for the matched
378
+ // subscriber; never revokes on its own. See README.md § Dunning.
379
+ const invoice = event.data.object as InvoiceObject;
380
+ await armDunning(request, {
381
+ invoice,
382
+ eventId: event.id,
383
+ eventCreated: event.created,
384
+ eventIdPrefix,
385
+ });
386
+ break;
387
+ }
388
+ case "customer.subscription.updated": {
389
+ // Dunning arm 2 of 2 (#6034 FR-2/FR-4) — maps the narrowed Stripe
390
+ // status onto the recorded lifecycle state; `canceled` revokes access
391
+ // only once the tenant's grace period has expired. See README.md
392
+ // § Dunning.
393
+ const subscription = event.data.object as SubscriptionObject;
394
+ await applySubscriptionStatus(request, {
395
+ subscription,
396
+ eventId: event.id,
397
+ eventCreated: event.created,
398
+ eventIdPrefix,
399
+ });
400
+ break;
401
+ }
402
+ default:
403
+ request.log.info(
404
+ { eventType: event.type, eventIdPrefix },
405
+ "unhandled webhook event",
406
+ );
407
+ break;
408
+ }
409
+
410
+ await reply.code(200).send({ received: true });
411
+ };
412
+
413
+ type CheckoutSessionObject = {
414
+ id?: string;
415
+ /**
416
+ * Set when the session was opened UNDER a Payment Link — which is how a
417
+ * request-and-confirm purchase arrives (#5970 D8). It is the artifact id
418
+ * AC-3 matches on, and it is absent on an ordinary checkout.
419
+ */
420
+ payment_link?: string | null;
421
+ payment_status?: string | null;
422
+ amount_total?: number | null;
423
+ currency?: string | null;
424
+ customer?: string | null;
425
+ customer_details?: { email?: string | null } | null;
426
+ metadata?: Record<string, string> | null;
427
+ };
428
+
429
+ /**
430
+ * Minimal projected shape of a Stripe Invoice object for the dunning arms
431
+ * (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
432
+ * like `CheckoutSessionObject.payment_link` above, either can arrive as an
433
+ * object rather than a string on a future `expand` or API-version change, so
434
+ * both are `typeof`-guarded at the call site (§ D9) and never trusted from
435
+ * this cast.
436
+ */
437
+ type InvoiceObject = {
438
+ subscription?: string | null;
439
+ customer?: string | null;
440
+ };
441
+
442
+ /**
443
+ * Minimal projected shape of a Stripe Subscription object for the dunning
444
+ * arms (#6034 FR-2). `customer` carries the same expandable-field risk as
445
+ * `InvoiceObject.customer` and is guarded the same way.
446
+ */
447
+ type SubscriptionObject = {
448
+ id?: string;
449
+ customer?: string | null;
450
+ status?: string;
451
+ canceled_at?: number | null;
452
+ ended_at?: number | null;
453
+ };
454
+
455
+ /**
456
+ * Stamp the recommender's FlowCheckout buyer row from a terminal Checkout
457
+ * Session event (#4838).
458
+ *
459
+ * The `status: "created"` guard on the update IS the idempotency key (D1):
460
+ * `count === 1` → stamped; `count === 0` → the row is already terminal, or the
461
+ * id is unknown — a warning, never a retry (Stripe would re-fire telemetry +
462
+ * billing on eventual success). A `completed` session whose
463
+ * `payment_status` is not `"paid"` logs `unpaid` and leaves the row `created`. A charged amount that differs from the quoted
464
+ * `amountMinor` is logged with both integers and the stamp proceeds (D2). Log
465
+ * lines carry only `eventIdPrefix`, an 8-char `flowCheckoutIdPrefix`, an
466
+ * `outcome`, and the two integers — never the email, customer id, or permalink.
467
+ *
468
+ * Never throws — the handler's "always 200 for a verified event" contract.
469
+ */
470
+ async function stampFlowCheckout(
471
+ request: FastifyRequest,
472
+ {
473
+ session,
474
+ eventIdPrefix,
475
+ outcome,
476
+ }: {
477
+ session: CheckoutSessionObject;
478
+ eventIdPrefix: string;
479
+ outcome: "completed" | "expired";
480
+ },
481
+ ): Promise<void> {
482
+ const flowCheckoutId = session.metadata?.["flowCheckoutId"];
483
+ if (!flowCheckoutId) return;
484
+ const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
485
+
486
+ // A `completed` session is only a purchase once Stripe reports it paid, OR
487
+ // reports nothing was due (`no_payment_required` — a 100%-off promo code,
488
+ // #4862, still a Stripe-confirmed completion). A delayed-notification or
489
+ // genuinely unpaid completion leaves the row `created` (code + security
490
+ // review, PR #4855).
491
+ if (
492
+ outcome === "completed" &&
493
+ session.payment_status !== "paid" &&
494
+ session.payment_status !== "no_payment_required"
495
+ ) {
496
+ request.log.warn(
497
+ { eventIdPrefix, flowCheckoutIdPrefix, outcome: "unpaid" },
498
+ "flow checkout: completed session is not paid — not stamping",
499
+ );
500
+ return;
501
+ }
502
+
503
+ try {
504
+ if (outcome === "completed" && typeof session.amount_total === "number") {
505
+ const row = await prisma.flowCheckout.findUnique({
506
+ where: { id: flowCheckoutId },
507
+ select: { amountMinor: true },
508
+ });
509
+ if (row && row.amountMinor !== session.amount_total) {
510
+ request.log.warn(
511
+ {
512
+ eventIdPrefix,
513
+ flowCheckoutIdPrefix,
514
+ outcome: "amount_mismatch",
515
+ expected: row.amountMinor,
516
+ received: session.amount_total,
517
+ },
518
+ "flow checkout: charged amount differs from the quoted amount — stamping anyway",
519
+ );
520
+ }
521
+ }
522
+
523
+ const result = await prisma.flowCheckout.updateMany({
524
+ where: { id: flowCheckoutId, status: "created" },
525
+ data:
526
+ outcome === "completed"
527
+ ? {
528
+ status: "completed",
529
+ completedAt: new Date(),
530
+ stripeSessionId: session.id ?? null,
531
+ stripeCustomerId: session.customer ?? null,
532
+ }
533
+ : { status: "expired" },
534
+ });
535
+
536
+ if (result.count === 1) {
537
+ request.log.info(
538
+ { eventIdPrefix, flowCheckoutIdPrefix, outcome },
539
+ "flow checkout stamped",
540
+ );
541
+ } else {
542
+ request.log.warn(
543
+ { eventIdPrefix, flowCheckoutIdPrefix, outcome: "stale_or_unknown" },
544
+ "flow checkout: no created row matched — replay, late terminal event, or unknown id",
545
+ );
546
+ }
547
+ } catch (err) {
548
+ request.log.error(
549
+ {
550
+ eventIdPrefix,
551
+ flowCheckoutIdPrefix,
552
+ outcome: "stamp_failed",
553
+ errName: err instanceof Error ? err.name : "UnknownError",
554
+ },
555
+ "flow checkout stamp failed — continuing",
556
+ );
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Stamp `paidAt` on a request-and-confirm callback row (#5970 AC-3).
562
+ *
563
+ * THE MATCH KEY IS THE ARTIFACT ID, not metadata. Paying a Payment Link opens
564
+ * a Checkout Session under that link, so the paid event is the ordinary
565
+ * `checkout.session.completed` — and the session carries
566
+ * `payment_link: "plink_…"`, which is exactly the id the confirm route stored
567
+ * on the row. A session with no `payment_link` is an ordinary checkout and is
568
+ * skipped BEFORE any query, so the existing checkout suites see no new DB
569
+ * traffic whatsoever.
570
+ *
571
+ * TWO IDEMPOTENCY LAYERS, and only the second is durable. A replayed event
572
+ * never reaches here at all — `hasSeen` short-circuits it upstream. But that
573
+ * store is an in-process TTL `Map`: restart the process and the id is gone. So
574
+ * the `paidAt: null` guard on the update IS the idempotency key, the same way
575
+ * `status: "created"` is `stampFlowCheckout`'s. `count === 1` ⇒ stamped once;
576
+ * `count === 0` ⇒ already paid, or an id this deployment never minted — a
577
+ * warning, never a throw and never a retry (Stripe would re-fire the sibling
578
+ * telemetry and billing tasks on eventual success).
579
+ *
580
+ * A `completed` session stamps only when Stripe reports it `paid`, or reports
581
+ * nothing was due (`no_payment_required` — a 100%-off promotion is still a
582
+ * Stripe-confirmed completion). The same two values `stampFlowCheckout`
583
+ * accepts, for the same reason.
584
+ *
585
+ * `stripeCheckoutSessionId` is written from the same event for support
586
+ * lookups. Nothing ever matches on it.
587
+ *
588
+ * Never throws — the handler's "always 200 for a verified event" contract.
589
+ */
590
+ async function stampRequestPaid(
591
+ request: FastifyRequest,
592
+ {
593
+ session,
594
+ eventIdPrefix,
595
+ }: { session: CheckoutSessionObject; eventIdPrefix: string },
596
+ ): Promise<void> {
597
+ const paymentLinkId = session.payment_link;
598
+ // `typeof`, not just truthiness (#5970 security review, finding 7):
599
+ // `payment_link` is an EXPANDABLE Stripe field, so a future `expand` or an
600
+ // API-version change could deliver it as an object — on which `.substring`
601
+ // throws a TypeError that would escape this function, reject the
602
+ // `Promise.all` above, and turn a verified event into a 500 that Stripe then
603
+ // retries, taking the sibling telemetry and billing tasks down with it. This
604
+ // function's contract is that it never throws.
605
+ if (typeof paymentLinkId !== "string" || paymentLinkId === "") return;
606
+ const paymentLinkIdPrefix = paymentLinkId.substring(0, 8);
607
+
608
+ if (
609
+ session.payment_status !== "paid" &&
610
+ session.payment_status !== "no_payment_required"
611
+ ) {
612
+ request.log.warn(
613
+ { eventIdPrefix, paymentLinkIdPrefix, outcome: "unpaid" },
614
+ "request-confirm: completed session is not paid — not stamping",
615
+ );
616
+ return;
617
+ }
618
+
619
+ try {
620
+ const result = await prisma.callback.updateMany({
621
+ where: { stripePaymentLinkId: paymentLinkId, paidAt: null },
622
+ data: {
623
+ paidAt: new Date(),
624
+ stripeCheckoutSessionId: session.id ?? null,
625
+ },
626
+ });
627
+
628
+ if (result.count === 1) {
629
+ request.log.info(
630
+ { eventIdPrefix, paymentLinkIdPrefix, outcome: "paid" },
631
+ "request-confirm stamped paid",
632
+ );
633
+ } else {
634
+ request.log.warn(
635
+ { eventIdPrefix, paymentLinkIdPrefix, outcome: "stale_or_unknown" },
636
+ "request-confirm: no unpaid row matched — replay, late event, or an unknown payment link",
637
+ );
638
+ }
639
+ } catch (err) {
640
+ request.log.error(
641
+ {
642
+ eventIdPrefix,
643
+ paymentLinkIdPrefix,
644
+ outcome: "stamp_failed",
645
+ errName: err instanceof Error ? err.name : "UnknownError",
646
+ },
647
+ "request-confirm stamp failed — continuing",
648
+ );
649
+ }
650
+ }
651
+
652
+ /**
653
+ * The hosted-invoice twin of `stampRequestPaid` (#5970 D4/D8).
654
+ *
655
+ * Inert at this issue's boundary — nothing writes `stripeInvoiceId` yet — and
656
+ * deliberately present anyway, carrying the identical guarded update, so the
657
+ * invoice rung is a writer away rather than a migration and a webhook change
658
+ * away.
659
+ */
660
+ async function stampRequestPaidByInvoice(
661
+ request: FastifyRequest,
662
+ {
663
+ invoiceId,
664
+ eventIdPrefix,
665
+ }: { invoiceId: string | null; eventIdPrefix: string },
666
+ ): Promise<void> {
667
+ // The same `typeof` guard its Payment-Link sibling carries, for the same
668
+ // reason (#5970 security review round 2, L1): this arm is explicitly
669
+ // forward-looking, so the hardening has to travel with it rather than be
670
+ // added when a writer appears and the shape stops being guaranteed.
671
+ if (typeof invoiceId !== "string" || invoiceId === "") return;
672
+ const invoiceIdPrefix = invoiceId.substring(0, 8);
673
+
674
+ try {
675
+ const result = await prisma.callback.updateMany({
676
+ where: { stripeInvoiceId: invoiceId, paidAt: null },
677
+ data: { paidAt: new Date() },
678
+ });
679
+
680
+ if (result.count === 1) {
681
+ request.log.info(
682
+ { eventIdPrefix, invoiceIdPrefix, outcome: "paid" },
683
+ "request-confirm stamped paid from invoice",
684
+ );
685
+ return;
686
+ }
687
+ // An unmatched `invoice.paid` is the ORDINARY case today and for every
688
+ // deployment using Stripe invoices for anything else, so this is `info`
689
+ // rather than the `warn` its Payment-Link sibling emits.
690
+ request.log.info(
691
+ { eventIdPrefix, invoiceIdPrefix, outcome: "not_a_request_invoice" },
692
+ "request-confirm: invoice.paid matched no request row",
693
+ );
694
+ } catch (err) {
695
+ request.log.error(
696
+ {
697
+ eventIdPrefix,
698
+ invoiceIdPrefix,
699
+ outcome: "stamp_failed",
700
+ errName: err instanceof Error ? err.name : "UnknownError",
701
+ },
702
+ "request-confirm invoice stamp failed — continuing",
703
+ );
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Link User.billingProvider + User.billingCustomerId for a Stripe customer.
709
+ *
710
+ * Lookup priority:
711
+ * 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customerId)
712
+ * → already linked; no-op (idempotent)
713
+ * 2. Email fallback: User.findUnique({ where: { email } }) → update billing fields
714
+ * 3. No match → warn; do NOT create a User row (pre-signup state preserved for Phase B #852)
715
+ *
716
+ * DB failures are caught and logged — a broken DB link should not cause Stripe to retry
717
+ * the event (which would fire analytics + billing again on eventual success).
718
+ */
719
+ async function upsertBillingIdentity(
720
+ request: FastifyRequest,
721
+ {
722
+ customerId,
723
+ email,
724
+ eventIdPrefix,
725
+ }: { customerId: string; email: string | null; eventIdPrefix: string },
726
+ ): Promise<void> {
727
+ try {
728
+ const existingByBilling = await prisma.user.findFirst({
729
+ where: {
730
+ billingProvider: "stripe",
731
+ billingCustomerId: customerId,
732
+ },
733
+ });
734
+
735
+ if (existingByBilling) {
736
+ request.log.info(
737
+ { eventIdPrefix },
738
+ "billing identity already linked — idempotent no-op",
739
+ );
740
+ return;
741
+ }
742
+
743
+ if (email) {
744
+ const existingByEmail = await prisma.user.findUnique({
745
+ where: { email },
746
+ });
747
+
748
+ if (existingByEmail) {
749
+ await prisma.user.update({
750
+ where: { id: existingByEmail.id },
751
+ data: {
752
+ billingProvider: "stripe",
753
+ billingCustomerId: customerId,
754
+ },
755
+ });
756
+ request.log.info(
757
+ { eventIdPrefix },
758
+ "billing identity linked via email fallback",
759
+ );
760
+ return;
761
+ }
762
+ }
763
+
764
+ request.log.warn(
765
+ { eventIdPrefix },
766
+ "stripe webhook: no User match for billing identity — pre-signup state preserved",
767
+ );
768
+ } catch (err) {
769
+ request.log.error(
770
+ {
771
+ eventIdPrefix,
772
+ error: err instanceof Error ? err.message : String(err),
773
+ },
774
+ "billing identity upsert failed — continuing",
775
+ );
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Link Organization.billingProvider + billingCustomerId for a Stripe customer
781
+ * (org-scoped billing, #3601, spec D2/D3). Called when a checkout carries
782
+ * metadata.organizationId — for a real org, or an individual's sentinel default
783
+ * Organization (orgs-of-one; AC2 — ONE billing path inside the module).
784
+ *
785
+ * Idempotent: a typed-composite match (billingProvider="stripe",
786
+ * billingCustomerId=customerId) already pointing at THIS org is a no-op on
787
+ * replay. Otherwise the target Organization (by id from checkout metadata) is
788
+ * linked.
789
+ *
790
+ * DB failures are caught and logged (never re-thrown) — a broken link must not
791
+ * make Stripe retry (which would re-fire analytics + billing on eventual
792
+ * success), matching the User-path contract above.
793
+ */
794
+ async function upsertOrgBillingIdentity(
795
+ request: FastifyRequest,
796
+ {
797
+ organizationId,
798
+ customerId,
799
+ eventIdPrefix,
800
+ }: { organizationId: string; customerId: string; eventIdPrefix: string },
801
+ ): Promise<void> {
802
+ try {
803
+ const existingByBilling = await prisma.organization.findFirst({
804
+ where: {
805
+ billingProvider: "stripe",
806
+ billingCustomerId: customerId,
807
+ },
808
+ });
809
+
810
+ if (existingByBilling && existingByBilling.id === organizationId) {
811
+ request.log.info(
812
+ { eventIdPrefix },
813
+ "org billing identity already linked — idempotent no-op",
814
+ );
815
+ return;
816
+ }
817
+
818
+ await prisma.organization.update({
819
+ where: { id: organizationId },
820
+ data: {
821
+ billingProvider: "stripe",
822
+ billingCustomerId: customerId,
823
+ },
824
+ });
825
+ request.log.info(
826
+ { eventIdPrefix },
827
+ "org billing identity linked from checkout metadata.organizationId",
828
+ );
829
+ } catch (err) {
830
+ request.log.error(
831
+ {
832
+ eventIdPrefix,
833
+ error: err instanceof Error ? err.message : String(err),
834
+ },
835
+ "org billing identity upsert failed — continuing",
836
+ );
837
+ }
838
+ }
839
+
840
+ async function fireTelemetryWithTimeout(
841
+ request: FastifyRequest,
842
+ name: string,
843
+ props: Record<string, unknown>,
844
+ ): Promise<void> {
845
+ let timeoutId!: ReturnType<typeof setTimeout>;
846
+ const timeout = new Promise<void>((resolve) => {
847
+ timeoutId = setTimeout(() => {
848
+ request.log.warn(
849
+ { telemetryEvent: name },
850
+ "telemetry call exceeded timeout; continuing",
851
+ );
852
+ resolve();
853
+ }, TELEMETRY_TIMEOUT_MS);
854
+ });
855
+
856
+ try {
857
+ await Promise.race([track(name, props), timeout]);
858
+ } catch (err) {
859
+ request.log.error(
860
+ {
861
+ telemetryEvent: name,
862
+ error: err instanceof Error ? err.message : String(err),
863
+ },
864
+ "telemetry call threw",
865
+ );
866
+ } finally {
867
+ clearTimeout(timeoutId);
868
+ }
869
+ }
870
+
871
+ // ── Dunning (#6034) ──────────────────────────────────────────────────────
872
+ //
873
+ // Arms and applies the per-tenant dunning record documented in
874
+ // README.md § Dunning. Both arms below share one write primitive
875
+ // (`writeDunningRecord`) that enforces § D6's ordering/tie/durable-replay
876
+ // admission rule and its compare-and-swap over the FULL observed read-set
877
+ // (`value` as well as `updatedAt`) — never a `$transaction`, following
878
+ // `usage-credits.ts:20-33`'s reasoning: a single conditional `UPDATE` IS the
879
+ // atomic compare-and-swap.
880
+
881
+ /**
882
+ * The "past_due" candidate both arms compute identically (FR-1's
883
+ * `invoice.payment_failed` arm, and `customer.subscription.updated`'s own
884
+ * `past_due`/`unpaid` branch) — one shared shape rather than two copies that
885
+ * could drift: never revokes, `dunningArmedAt` is stamped once and never
886
+ * moved while the subscription stays failing (§ D5), and `accessRevokesAt`
887
+ * is cleared so a re-arm after a prior cancellation carries no stale
888
+ * revocation schedule.
889
+ *
890
+ * Callers differ on whether a recorded `canceled` entry may be relaxed by
891
+ * this candidate — see `armDunning`'s own guard, which is deliberately NOT
892
+ * folded in here (code review finding, #6034): `customer.subscription
893
+ * .updated` is the SUBSCRIPTION-level authoritative source and a strictly
894
+ * newer transition to `past_due` is a real signal worth admitting (§ D6
895
+ * rule 4), while `invoice.payment_failed` is only ever about ONE invoice and
896
+ * must never outrank a subscription-level cancellation already recorded.
897
+ */
898
+ function computeArmedEntry(
899
+ current: DunningEntry | undefined,
900
+ event: { id: string; created: number },
901
+ ): DunningEntry {
902
+ return {
903
+ status: "past_due",
904
+ accessState: "granted",
905
+ dunningArmedAt: current?.dunningArmedAt ?? new Date().toISOString(),
906
+ accessRevokesAt: null,
907
+ lastEventAt: event.created,
908
+ lastEventId: event.id,
909
+ };
910
+ }
911
+
912
+ /**
913
+ * `invoice.payment_failed` (#6034 FR-1). Arms dunning for the `User` matched
914
+ * by the invoice's Stripe customer id — the recorded state becomes
915
+ * `past_due`, `dunningArmedAt` is stamped once (never moved while the
916
+ * subscription stays failing), and `accessState` stays `granted`. This arm
917
+ * never revokes — and, just as importantly, never UN-revokes: it must never
918
+ * clear a subscription-level `canceled` recording, because it is only ever
919
+ * about ONE invoice, not the subscription's current status (code review
920
+ * finding, #6034). Stripe's Smart Retries schedule can still fire a queued
921
+ * retry's failure webhook for an invoice that predates a cancellation, with
922
+ * an `event.created` genuinely NEWER than the cancellation's own — which
923
+ * § D6's admission rule alone does not reject (rule 4 admits a strictly
924
+ * newer event unconditionally, by design, so that a genuine reactivation via
925
+ * `customer.subscription.updated` is never blocked). Only ANOTHER
926
+ * `customer.subscription.updated` may clear a canceled record.
927
+ *
928
+ * Never throws — the handler's "always 200 for a verified event" contract
929
+ * (NFR-1). Every rejection below returns BEFORE any query (§ D9).
930
+ */
931
+ async function armDunning(
932
+ request: FastifyRequest,
933
+ {
934
+ invoice,
935
+ eventId,
936
+ eventCreated,
937
+ eventIdPrefix,
938
+ }: {
939
+ invoice: InvoiceObject;
940
+ eventId: string;
941
+ eventCreated: number;
942
+ eventIdPrefix: string;
943
+ },
944
+ ): Promise<void> {
945
+ const subscriptionId = invoice.subscription;
946
+ if (typeof subscriptionId !== "string" || subscriptionId === "") {
947
+ request.log.info(
948
+ { eventIdPrefix, outcome: "not_a_subscription_invoice" },
949
+ "dunning: invoice.payment_failed carries no subscription — not a subscription invoice",
950
+ );
951
+ return;
952
+ }
953
+
954
+ const customerId = invoice.customer;
955
+ if (typeof customerId !== "string" || customerId === "") {
956
+ request.log.warn(
957
+ { eventIdPrefix, outcome: "unresolvable_customer" },
958
+ "dunning: invoice.payment_failed has no resolvable customer",
959
+ );
960
+ return;
961
+ }
962
+
963
+ const userId = await findSubscriberUserId(request, {
964
+ customerId,
965
+ eventIdPrefix,
966
+ });
967
+ if (!userId) return;
968
+
969
+ await writeDunningRecord(request, {
970
+ userId,
971
+ subscriptionId,
972
+ eventId,
973
+ eventCreated,
974
+ eventIdPrefix,
975
+ computeCandidate: (current) => {
976
+ // A subscription already recorded `canceled` (by the SUBSCRIPTION-level
977
+ // authoritative `customer.subscription.updated` arm) is left untouched
978
+ // by an invoice-level signal — see this function's own doc comment.
979
+ if (current?.status === "canceled") return current;
980
+ return computeArmedEntry(current, { id: eventId, created: eventCreated });
981
+ },
982
+ });
983
+ }
984
+
985
+ /**
986
+ * `customer.subscription.updated` (#6034 FR-2/FR-4). Maps the narrowed
987
+ * Stripe status (§ D3's `toSubscriptionLifecycleStatus`) onto the recorded
988
+ * lifecycle state:
989
+ *
990
+ * - `active` / `trialing` → § D7's clean recovery (both narrow to a
991
+ * recorded `status: "active"`; #6038's read shows nothing for it).
992
+ * - `past_due` (incl. `unpaid`, narrowed) → arms exactly as `armDunning` does.
993
+ * - `canceled` → § D5: grace runs from the FIRST failure, lazily evaluated,
994
+ * never scheduled.
995
+ *
996
+ * Never throws (NFR-1); every identifier is guarded before use (§ D9).
997
+ */
998
+ async function applySubscriptionStatus(
999
+ request: FastifyRequest,
1000
+ {
1001
+ subscription,
1002
+ eventId,
1003
+ eventCreated,
1004
+ eventIdPrefix,
1005
+ }: {
1006
+ subscription: SubscriptionObject;
1007
+ eventId: string;
1008
+ eventCreated: number;
1009
+ eventIdPrefix: string;
1010
+ },
1011
+ ): Promise<void> {
1012
+ const customerId = subscription.customer;
1013
+ if (typeof customerId !== "string" || customerId === "") {
1014
+ request.log.warn(
1015
+ { eventIdPrefix, outcome: "unresolvable_customer" },
1016
+ "dunning: customer.subscription.updated has no resolvable customer",
1017
+ );
1018
+ return;
1019
+ }
1020
+
1021
+ const subscriptionId = subscription.id;
1022
+ if (typeof subscriptionId !== "string" || subscriptionId === "") {
1023
+ request.log.warn(
1024
+ { eventIdPrefix, outcome: "unresolvable_subscription" },
1025
+ "dunning: customer.subscription.updated has no resolvable subscription id",
1026
+ );
1027
+ return;
1028
+ }
1029
+
1030
+ // toSubscriptionLifecycleStatus's own default arm is already "canceled" —
1031
+ // never an implied grant (§ D3) — so a non-string status maps the same
1032
+ // conservative way rather than being silently ignored (§ D9).
1033
+ const statusRaw = subscription.status;
1034
+ const narrowedStatus =
1035
+ typeof statusRaw === "string"
1036
+ ? toSubscriptionLifecycleStatus(statusRaw)
1037
+ : "canceled";
1038
+
1039
+ const userId = await findSubscriberUserId(request, {
1040
+ customerId,
1041
+ eventIdPrefix,
1042
+ });
1043
+ if (!userId) return;
1044
+
1045
+ if (narrowedStatus === "active" || narrowedStatus === "trialing") {
1046
+ await writeDunningRecord(request, {
1047
+ userId,
1048
+ subscriptionId,
1049
+ eventId,
1050
+ eventCreated,
1051
+ eventIdPrefix,
1052
+ computeCandidate: () => ({
1053
+ status: "active",
1054
+ accessState: "granted",
1055
+ dunningArmedAt: null,
1056
+ accessRevokesAt: null,
1057
+ lastEventAt: eventCreated,
1058
+ lastEventId: eventId,
1059
+ }),
1060
+ });
1061
+ return;
1062
+ }
1063
+
1064
+ if (narrowedStatus === "past_due") {
1065
+ await writeDunningRecord(request, {
1066
+ userId,
1067
+ subscriptionId,
1068
+ eventId,
1069
+ eventCreated,
1070
+ eventIdPrefix,
1071
+ computeCandidate: (current) =>
1072
+ computeArmedEntry(current, { id: eventId, created: eventCreated }),
1073
+ });
1074
+ return;
1075
+ }
1076
+
1077
+ // narrowedStatus === "canceled" — § D5's grace computation. The policy read
1078
+ // never throws (dunning-policy.ts AC-3), so this can be awaited unguarded.
1079
+ const policy = await dunningPolicyFor(currentSiteId());
1080
+
1081
+ await writeDunningRecord(request, {
1082
+ userId,
1083
+ subscriptionId,
1084
+ eventId,
1085
+ eventCreated,
1086
+ eventIdPrefix,
1087
+ computeCandidate: (current) => {
1088
+ const armedAtIso =
1089
+ current?.dunningArmedAt ??
1090
+ new Date(
1091
+ resolveCancellationInstantMs(subscription, eventCreated),
1092
+ ).toISOString();
1093
+ const revokeAtMs =
1094
+ Date.parse(armedAtIso) + policy.graceDays * 24 * 60 * 60 * 1000;
1095
+ const revoked = Date.now() >= revokeAtMs;
1096
+ return {
1097
+ status: "canceled",
1098
+ accessState: revoked ? "revoked" : "granted",
1099
+ dunningArmedAt: armedAtIso,
1100
+ accessRevokesAt: new Date(revokeAtMs).toISOString(),
1101
+ lastEventAt: eventCreated,
1102
+ lastEventId: eventId,
1103
+ };
1104
+ },
1105
+ });
1106
+ }
1107
+
1108
+ /**
1109
+ * The cancellation instant, taken from the verified event in § D5's order —
1110
+ * `canceled_at` → `ended_at` → `event.created` → `Date.now()` — each
1111
+ * accepted only when `Number.isFinite()` holds (Stripe epoch SECONDS; the
1112
+ * final fallback is milliseconds already).
1113
+ */
1114
+ function resolveCancellationInstantMs(
1115
+ subscription: SubscriptionObject,
1116
+ eventCreatedSec: number,
1117
+ ): number {
1118
+ const candidatesSec = [
1119
+ subscription.canceled_at,
1120
+ subscription.ended_at,
1121
+ eventCreatedSec,
1122
+ ];
1123
+ for (const candidate of candidatesSec) {
1124
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
1125
+ return candidate * 1000;
1126
+ }
1127
+ }
1128
+ return Date.now();
1129
+ }
1130
+
1131
+ /**
1132
+ * D10 — no matching `User` is a warn-and-continue, not an error. Matches
1133
+ * `upsertBillingIdentity`'s pre-signup posture: a customer id with no linked
1134
+ * `User` row is expected (a buyer who purchased without an account), so no
1135
+ * `User` row is ever created here.
1136
+ */
1137
+ async function findSubscriberUserId(
1138
+ request: FastifyRequest,
1139
+ { customerId, eventIdPrefix }: { customerId: string; eventIdPrefix: string },
1140
+ ): Promise<string | null> {
1141
+ let user: { id: string } | null;
1142
+ try {
1143
+ user = await prisma.user.findUnique({
1144
+ where: {
1145
+ billingProvider_billingCustomerId: {
1146
+ billingProvider: "stripe",
1147
+ billingCustomerId: customerId,
1148
+ },
1149
+ },
1150
+ select: { id: true },
1151
+ });
1152
+ } catch (err) {
1153
+ request.log.error(
1154
+ {
1155
+ eventIdPrefix,
1156
+ outcome: "subscriber_lookup_failed",
1157
+ errName: err instanceof Error ? err.name : "UnknownError",
1158
+ },
1159
+ "dunning: subscriber lookup failed — continuing",
1160
+ );
1161
+ return null;
1162
+ }
1163
+
1164
+ if (!user) {
1165
+ request.log.warn(
1166
+ { eventIdPrefix, outcome: "no_subscriber_match" },
1167
+ "dunning: no User matched this Stripe customer — pre-signup state, no write",
1168
+ );
1169
+ return null;
1170
+ }
1171
+
1172
+ return user.id;
1173
+ }
1174
+
1175
+ type DunningAdmission =
1176
+ | { admit: true }
1177
+ | { admit: false; outcome: "duplicate_event" | "stale_event" | "stale_tie" };
1178
+
1179
+ /**
1180
+ * A write is a DE-ESCALATION when it would move the entry toward LESS
1181
+ * restriction relative to what is currently recorded: setting `status` to
1182
+ * `active` (§ D7's `trialing`-collapses-to-`active` shape), clearing
1183
+ * `accessRevokesAt`, or moving `accessState` from `revoked` to `granted`
1184
+ * (§ D6 rule 3). All three are checked independently — the earlier draft
1185
+ * keyed terminality on `accessState === "revoked"` alone, which left the
1186
+ * in-grace cancellation (`status: "canceled"`, `accessState: "granted"`,
1187
+ * `accessRevokesAt` non-null) unprotected (§ 9 SEC-03).
1188
+ */
1189
+ function isDeEscalation(current: DunningEntry, candidate: DunningEntry): boolean {
1190
+ const statusRelaxed =
1191
+ candidate.status === "active" && current.status !== "active";
1192
+ const revokesAtCleared =
1193
+ current.accessRevokesAt !== null && candidate.accessRevokesAt === null;
1194
+ const accessStateRelaxed =
1195
+ current.accessState === "revoked" && candidate.accessState === "granted";
1196
+ return statusRelaxed || revokesAtCleared || accessStateRelaxed;
1197
+ }
1198
+
1199
+ /**
1200
+ * § D6's five-rule admission ladder, folded to what actually needs checking:
1201
+ * no current entry → admit (first event for this subscription); the SAME
1202
+ * event id replayed → the durable twin of `hasSeen` (rule 1); a strictly
1203
+ * OLDER event → stale (rule 2); an EQUAL-timestamp de-escalation → stale tie
1204
+ * (rule 3, § D6's "terminal-state monotonicity" folded in — a de-escalating
1205
+ * write is rejected at a tie regardless of which of the three terminal
1206
+ * disjuncts made it terminal); anything else (a genuinely newer event,
1207
+ * unconditionally per rule 4/5) → admit.
1208
+ */
1209
+ function admitDunningWrite(
1210
+ current: DunningEntry | undefined,
1211
+ candidate: DunningEntry,
1212
+ event: { id: string; created: number },
1213
+ ): DunningAdmission {
1214
+ if (!current) return { admit: true };
1215
+
1216
+ if (event.id === current.lastEventId) {
1217
+ return { admit: false, outcome: "duplicate_event" };
1218
+ }
1219
+ if (event.created < current.lastEventAt) {
1220
+ return { admit: false, outcome: "stale_event" };
1221
+ }
1222
+ if (event.created === current.lastEventAt && isDeEscalation(current, candidate)) {
1223
+ return { admit: false, outcome: "stale_tie" };
1224
+ }
1225
+ return { admit: true };
1226
+ }
1227
+
1228
+ function parseDunningRecord(value: unknown): DunningRecord {
1229
+ const obj = value as { subscriptions?: unknown } | null | undefined;
1230
+ const subscriptions =
1231
+ obj &&
1232
+ typeof obj === "object" &&
1233
+ obj.subscriptions &&
1234
+ typeof obj.subscriptions === "object" &&
1235
+ !Array.isArray(obj.subscriptions)
1236
+ ? (obj.subscriptions as Record<string, DunningEntry>)
1237
+ : {};
1238
+ return { v: 1, subscriptions };
1239
+ }
1240
+
1241
+ /**
1242
+ * The map cap (§ D2) — evicts the oldest SETTLED entries (`status: "active"`,
1243
+ * `accessState: "granted"`) first, never an entry still being dunned, so a
1244
+ * per-event read-and-rewrite never grows unbounded with a customer's whole
1245
+ * subscription history.
1246
+ */
1247
+ function applyCandidateWithCap(
1248
+ record: DunningRecord,
1249
+ subscriptionId: string,
1250
+ candidate: DunningEntry,
1251
+ ): DunningRecord {
1252
+ const subscriptions: Record<string, DunningEntry> = {
1253
+ ...record.subscriptions,
1254
+ [subscriptionId]: candidate,
1255
+ };
1256
+
1257
+ let overflow = Object.keys(subscriptions).length - DUNNING_MAX_SUBSCRIPTIONS;
1258
+ if (overflow <= 0) {
1259
+ return { v: 1, subscriptions };
1260
+ }
1261
+
1262
+ const evictionOrder = Object.keys(subscriptions)
1263
+ .filter((key) => {
1264
+ const entry = subscriptions[key]!;
1265
+ return entry.status === "active" && entry.accessState === "granted";
1266
+ })
1267
+ .sort((a, b) => subscriptions[a]!.lastEventAt - subscriptions[b]!.lastEventAt);
1268
+
1269
+ for (const key of evictionOrder) {
1270
+ if (overflow <= 0) break;
1271
+ delete subscriptions[key];
1272
+ overflow -= 1;
1273
+ }
1274
+
1275
+ return { v: 1, subscriptions };
1276
+ }
1277
+
1278
+ /**
1279
+ * The shared write primitive (§ D6). Reads the FULL record, applies the
1280
+ * admission rule, and writes back a compare-and-swap whose `where` restates
1281
+ * BOTH `updatedAt` AND the observed `value` (read-set ≡ CAS-set — NFR-9;
1282
+ * `updatedAt` alone lets two writes inside the same millisecond silently
1283
+ * overwrite each other, § 9 SEC-07). Bounded at `DUNNING_WRITE_MAX_ATTEMPTS`
1284
+ * — an honest `write_conflict` log beats an unbounded spin on an access
1285
+ * decision. Never throws (NFR-1) — the outer `catch` is the same
1286
+ * never-throw contract every other helper in this file carries.
1287
+ */
1288
+ async function writeDunningRecord(
1289
+ request: FastifyRequest,
1290
+ {
1291
+ userId,
1292
+ subscriptionId,
1293
+ eventId,
1294
+ eventCreated,
1295
+ eventIdPrefix,
1296
+ computeCandidate,
1297
+ }: {
1298
+ userId: string;
1299
+ subscriptionId: string;
1300
+ eventId: string;
1301
+ eventCreated: number;
1302
+ eventIdPrefix: string;
1303
+ computeCandidate: (current: DunningEntry | undefined) => DunningEntry;
1304
+ },
1305
+ ): Promise<void> {
1306
+ const subscriptionIdPrefix = subscriptionId.substring(0, 8);
1307
+
1308
+ try {
1309
+ for (let attempt = 1; attempt <= DUNNING_WRITE_MAX_ATTEMPTS; attempt++) {
1310
+ const row = await prisma.userData.findUnique({
1311
+ where: { userId_key: { userId, key: DUNNING_USER_DATA_KEY } },
1312
+ select: { value: true, updatedAt: true },
1313
+ });
1314
+
1315
+ const observedValue: unknown = row ? row.value : null;
1316
+ const record = parseDunningRecord(observedValue);
1317
+ const current = record.subscriptions[subscriptionId];
1318
+ const candidate = computeCandidate(current);
1319
+
1320
+ const admission = admitDunningWrite(current, candidate, {
1321
+ id: eventId,
1322
+ created: eventCreated,
1323
+ });
1324
+ if (!admission.admit) {
1325
+ request.log.info(
1326
+ { eventIdPrefix, subscriptionIdPrefix, outcome: admission.outcome },
1327
+ "dunning: event not admitted — record unchanged",
1328
+ );
1329
+ return;
1330
+ }
1331
+
1332
+ const nextRecord = applyCandidateWithCap(record, subscriptionId, candidate);
1333
+ const nextValue = nextRecord as unknown as Prisma.InputJsonValue;
1334
+
1335
+ if (!row) {
1336
+ try {
1337
+ await prisma.userData.create({
1338
+ data: { userId, key: DUNNING_USER_DATA_KEY, value: nextValue },
1339
+ });
1340
+ request.log.info(
1341
+ {
1342
+ eventIdPrefix,
1343
+ subscriptionIdPrefix,
1344
+ outcome: "written",
1345
+ status: candidate.status,
1346
+ },
1347
+ "dunning: record created",
1348
+ );
1349
+ return;
1350
+ } catch (err) {
1351
+ if (isUniqueViolation(err)) {
1352
+ // Another delivery won the create race — re-read and retry.
1353
+ continue;
1354
+ }
1355
+ throw err;
1356
+ }
1357
+ }
1358
+
1359
+ const { count } = await prisma.userData.updateMany({
1360
+ where: {
1361
+ userId,
1362
+ key: DUNNING_USER_DATA_KEY,
1363
+ updatedAt: row.updatedAt,
1364
+ value: { equals: observedValue as Prisma.InputJsonValue },
1365
+ },
1366
+ data: { value: nextValue },
1367
+ });
1368
+
1369
+ if (count > 0) {
1370
+ request.log.info(
1371
+ {
1372
+ eventIdPrefix,
1373
+ subscriptionIdPrefix,
1374
+ outcome: "written",
1375
+ status: candidate.status,
1376
+ },
1377
+ "dunning: record updated",
1378
+ );
1379
+ return;
1380
+ }
1381
+ // count === 0 — a concurrent writer moved the row. Re-read and retry.
1382
+ }
1383
+
1384
+ request.log.error(
1385
+ { eventIdPrefix, subscriptionIdPrefix, outcome: "write_conflict" },
1386
+ "dunning: record write exhausted its retries — continuing",
1387
+ );
1388
+ } catch (err) {
1389
+ request.log.error(
1390
+ {
1391
+ eventIdPrefix,
1392
+ subscriptionIdPrefix,
1393
+ outcome: "stamp_failed",
1394
+ errName: err instanceof Error ? err.name : "UnknownError",
1395
+ },
1396
+ "dunning: record write failed — continuing",
1397
+ );
1398
+ }
1399
+ }