create-ailk 0.21.0 → 0.23.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 (342) hide show
  1. package/component-catalog.json +186 -8
  2. package/component-catalog.md +9 -7
  3. package/dist/add-module.js +20 -14
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +12 -3
  8. package/dist/derive-architecture.js +20 -8
  9. package/dist/design-package.js +7 -2
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +5 -5
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  15. package/dist/lib/scaffold-render-sweep.js +5 -8
  16. package/dist/module-architecture.js +99 -9
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/programmatic.js +19 -5
  20. package/dist/seed-flows.d.ts +4 -1
  21. package/dist/seed-flows.js +50 -4
  22. package/dist/site-chrome.d.ts +8 -0
  23. package/dist/site-chrome.js +17 -1
  24. package/dist/spoke-checkout.d.ts +65 -41
  25. package/dist/spoke-checkout.js +208 -430
  26. package/dist/starter-grid.d.ts +0 -3
  27. package/dist/starter-grid.js +24 -14
  28. package/dist/surfaces.js +52 -26
  29. package/dist/sync-routes.d.ts +38 -1
  30. package/dist/sync-routes.js +187 -13
  31. package/dist/template-record.d.ts +4 -3
  32. package/dist/template-record.js +8 -4
  33. package/dist/topology.js +25 -1
  34. package/dist/update.js +15 -0
  35. package/package.json +3 -3
  36. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  37. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  38. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  39. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  41. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  43. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  48. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  49. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  50. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  51. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  52. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  53. package/templates/.env.example +9 -7
  54. package/templates/.github/workflows/ci.yml +13 -0
  55. package/templates/README.md +1 -0
  56. package/templates/_gitignore +4 -0
  57. package/templates/apps/api/.env.example +27 -0
  58. package/templates/apps/api/CLAUDE.md +87 -17
  59. package/templates/apps/api/package.json +1 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  61. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  62. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  63. package/templates/apps/api/src/config/index.ts +109 -0
  64. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  65. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  66. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  67. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  68. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  69. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  70. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  71. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  72. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  73. package/templates/apps/api/src/lib/stripe.ts +74 -4
  74. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  75. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  77. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  78. package/templates/apps/api/src/middleware/auth.ts +13 -6
  79. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  80. package/templates/apps/api/src/openapi/spec.ts +33 -3
  81. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  82. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  83. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  84. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  85. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  86. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  87. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  88. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  89. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  90. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  91. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  92. package/templates/apps/api/src/routes/consent/account.ts +268 -0
  93. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  94. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  95. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  96. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  97. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  98. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  99. package/templates/apps/api/src/routes/content/index.ts +16 -2
  100. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  101. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  102. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  103. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  104. package/templates/apps/api/src/routes/leads/index.ts +164 -7
  105. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  106. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  111. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  112. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  113. package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
  114. package/templates/apps/api/src/server.ts +163 -46
  115. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  116. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  117. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  118. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
  119. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  120. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  121. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  122. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  123. package/templates/apps/api/src/services/consent.ts +95 -0
  124. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  125. package/templates/apps/api/src/services/entitlements.ts +157 -1
  126. package/templates/apps/api/src/services/lead-routing.ts +431 -47
  127. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  128. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  129. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  130. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  131. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  132. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  133. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  134. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  135. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  136. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  137. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  138. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  139. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  140. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  141. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  142. package/templates/apps/api/src/services/person.ts +164 -17
  143. package/templates/apps/api/src/services/plan-offerings.ts +125 -0
  144. package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
  145. package/templates/apps/mcp/CLAUDE.md +22 -14
  146. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  147. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  148. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  149. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  150. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  151. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  152. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  153. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  154. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  155. package/templates/apps/mcp/src/server.ts +5 -0
  156. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  157. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  158. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  159. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  160. package/templates/apps/web/.env.example +24 -31
  161. package/templates/apps/web/CLAUDE.md +393 -9
  162. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  163. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  164. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  165. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  166. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  167. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  168. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  169. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  170. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  171. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  172. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  173. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  174. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  176. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  177. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  178. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  179. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  183. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  184. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  185. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  186. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  187. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  188. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  189. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  190. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  191. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  192. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  193. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  194. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  195. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  196. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
  197. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
  198. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
  199. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
  200. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  201. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  202. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
  203. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
  204. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
  205. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  206. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  207. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  208. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  209. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  210. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  211. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  212. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  213. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  214. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
  215. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
  216. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  217. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
  218. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  219. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
  220. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  221. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  222. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
  223. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  224. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  225. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  226. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  227. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  228. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  229. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  230. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  231. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  232. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  233. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  234. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  235. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  236. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  237. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  238. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  239. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  240. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  241. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  242. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  243. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  244. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  245. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  246. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  247. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  248. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  249. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  250. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  251. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  252. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  253. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  254. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  255. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  256. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  257. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  258. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  259. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  260. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  261. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  262. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  263. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  264. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  265. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  266. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  267. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  268. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  269. package/templates/apps/web/auth.ts +28 -2
  270. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  271. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  272. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  273. package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
  274. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  275. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  276. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  277. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  278. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
  279. package/templates/apps/web/lib/account-invite.ts +37 -0
  280. package/templates/apps/web/lib/auth-routes.ts +36 -2
  281. package/templates/apps/web/lib/checkout-config.ts +16 -0
  282. package/templates/apps/web/lib/consent-policies.ts +22 -0
  283. package/templates/apps/web/lib/deck-page.ts +7 -0
  284. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  285. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  286. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  287. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  288. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  289. package/templates/apps/web/lib/modules.generated.ts +9 -0
  290. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  291. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  292. package/templates/apps/web/lib/posting-title.ts +17 -0
  293. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  294. package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
  295. package/templates/apps/web/lib/site-chrome.ts +2 -0
  296. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  297. package/templates/apps/web/messages/en.json +71 -20
  298. package/templates/apps/web/middleware.ts +12 -2
  299. package/templates/apps/web/package.json +1 -0
  300. package/templates/apps/web/security-headers.config.ts +17 -0
  301. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  302. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  303. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  304. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  305. package/templates/content/en/BlogList/blog.mdx +4 -71
  306. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  307. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  308. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  309. package/templates/content/en/BookCall/start-project.mdx +1 -17
  310. package/templates/content/en/ContactPage/contact.mdx +6 -63
  311. package/templates/content/en/HomePage/home.mdx +3 -21
  312. package/templates/database/CHANGELOG.md +69 -0
  313. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  314. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  315. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  316. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  317. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  318. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  319. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  320. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  321. package/templates/database/__tests__/seed.test.ts +64 -1
  322. package/templates/database/auth/schema.prisma +4 -0
  323. package/templates/database/config/schema.prisma +91 -0
  324. package/templates/database/inbox/schema.prisma +17 -2
  325. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  326. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  327. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  328. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  329. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  330. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  331. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  332. package/templates/database/package.json +1 -1
  333. package/templates/database/seed.ts +62 -3
  334. package/versions.json +35 -35
  335. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  336. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
  337. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  338. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  339. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  340. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  341. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  342. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -48,12 +48,17 @@
48
48
  *
49
49
  * Billing upsert (customer.created / checkout.session.completed):
50
50
  * Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
51
- * carrying metadata.organizationId links the Organization (multi-tenant
51
+ * carrying metadata.organizationId takes the Organization path (multi-tenant
52
52
  * module); absent → the User path (single-tenant).
53
53
  *
54
- * Organization path (metadata.organizationId present):
54
+ * Organization path (metadata.organizationId present) — proof-based since
55
+ * #9067 D13: the metadata names the target, the recorded session proves it.
55
56
  * 1. Typed composite match already pointing at this org → idempotent no-op
56
- * 2. Else link the target Organization (by id) billing fields
57
+ * 2. A checkout.session.completed binds only when the Organization's
58
+ * `planCheckoutSessionId` is this session's id and no customer is
59
+ * bound yet (one guarded updateMany, which also clears the session)
60
+ * 3. Anything else — an unrecorded session, a bound customer, any
61
+ * customer.created — logs org_bind_unproven and writes nothing
57
62
  *
58
63
  * User path (no organizationId):
59
64
  * 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customer)
@@ -96,6 +101,16 @@
96
101
  * `stripeSessionId`), with `session.customer` as the customer id only on a
97
102
  * live-mode event. Logs an outcome kind only; never fails the 200.
98
103
  *
104
+ * Checkout terms grant (checkout.session.completed, #8765 D14):
105
+ * Chained after the Person step, `recordCheckoutTermsGrant` records the
106
+ * buyer's acceptance of the session's terms box as one `checkout` row of
107
+ * the consent ledger — only for a session whose `consent.terms_of_service`
108
+ * is `accepted` and whose server-written `metadata.ailk_terms` names
109
+ * `account_terms` at its current version (a Payment Link, invoice or older
110
+ * session carries none), for the Person `checkoutBuyerPerson` resolves.
111
+ * Logs `{ event: "checkout-consent", outcome, eventIdPrefix }` only; never
112
+ * fails the 200.
113
+ *
99
114
  * Dunning (invoice.payment_failed / customer.subscription.updated, #6034):
100
115
  * Records a per-subscriber lifecycle state in a `UserData` row behind a
101
116
  * reserved-key guard (routes/auth/user.ts). `invoice.payment_failed` arms
@@ -108,6 +123,11 @@
108
123
  import { track } from "@working-theory/analytics/server";
109
124
  import type { Prisma } from "@working-theory/database";
110
125
  import { isId } from "@working-theory/ids";
126
+ import {
127
+ ACCOUNT_TERMS_PURPOSE_ID,
128
+ consentPurposeById,
129
+ SITE_CONSENT_CONFIG_DEFAULTS,
130
+ } from "@working-theory/validation";
111
131
  import type { FastifyReply, FastifyRequest } from "fastify";
112
132
 
113
133
  import { config } from "../../config/index.js";
@@ -133,6 +153,8 @@ import {
133
153
  } from "../../lib/stripe.js";
134
154
  import { hasSeen, markSeen } from "../../lib/webhook-idempotency.js";
135
155
  import { recordWorkspaceAuditAction } from "../../services/audit-log.js";
156
+ import { checkoutBuyerPerson } from "../../services/checkout-buyer.js";
157
+ import { recordCheckoutConsent } from "../../services/consent.js";
136
158
  import { isUniqueViolation } from "../../services/default-tenant.js";
137
159
  import {
138
160
  DUNNING_MAX_SUBSCRIPTIONS,
@@ -142,8 +164,8 @@ import {
142
164
  type DunningRecord,
143
165
  } from "../../services/dunning-policy.js";
144
166
  import {
145
- recordCheckoutPerson,
146
- type CheckoutPersonOutcome,
167
+ resolveCheckoutPerson,
168
+ type CheckoutPersonResult,
147
169
  } from "../../services/person.js";
148
170
 
149
171
  const TELEMETRY_TIMEOUT_MS = 2000;
@@ -314,6 +336,23 @@ export const stripeWebhookHandler = async (
314
336
  return;
315
337
  }
316
338
 
339
+ // #8932 D9 (contract W8) — an Offerings-stamped event belongs to the
340
+ // per-Organization endpoint, which must still be able to act on it, so it
341
+ // is acked here BEFORE `markSeen` and nothing is written. Presence decides,
342
+ // whatever the value; any other event takes the path below unchanged.
343
+ if (
344
+ offeringsStampSourcesOf(event).some((source) =>
345
+ Object.hasOwn(source, OFFERINGS_STAMP_KEY),
346
+ )
347
+ ) {
348
+ request.log.info(
349
+ { eventType: event.type, eventIdPrefix, outcome: "offerings_path" },
350
+ "stripe webhook event left to the per-Organization endpoint",
351
+ );
352
+ await reply.code(200).send({ received: true });
353
+ return;
354
+ }
355
+
317
356
  request.log.info(
318
357
  { eventType: event.type, eventIdPrefix },
319
358
  "stripe webhook event received",
@@ -353,15 +392,16 @@ export const stripeWebhookHandler = async (
353
392
  email?: string | null;
354
393
  metadata?: Record<string, string> | null;
355
394
  };
356
- // Billing-entity routing (spec D2): a multi-tenant checkout stamps the
357
- // target Organization id in metadata.organizationId (an individual → their
358
- // sentinel default org). Present → link the Organization; absent → the
359
- // User path (single-tenant, degrade-clean).
395
+ // Billing-entity routing (spec D2): metadata.organizationId present →
396
+ // the Organization path, which a customer's own metadata can never
397
+ // prove (#9067 D13: only a recorded plan session binds), so it writes
398
+ // nothing; absent → the User path (single-tenant, degrade-clean).
360
399
  const organizationId = customer.metadata?.["organizationId"] ?? null;
361
400
  if (organizationId) {
362
401
  await upsertOrgBillingIdentity(request, {
363
402
  organizationId,
364
403
  customerId: customer.id,
404
+ sessionId: null,
365
405
  eventIdPrefix,
366
406
  });
367
407
  } else {
@@ -404,6 +444,7 @@ export const stripeWebhookHandler = async (
404
444
  ? upsertOrgBillingIdentity(request, {
405
445
  organizationId,
406
446
  customerId: session.customer,
447
+ sessionId: session.id ?? null,
407
448
  eventIdPrefix,
408
449
  })
409
450
  : upsertBillingIdentity(request, {
@@ -429,13 +470,24 @@ export const stripeWebhookHandler = async (
429
470
  session,
430
471
  eventIdPrefix,
431
472
  outcome: "completed",
432
- }).then(() =>
433
- recordFlowCheckoutPerson(request, {
434
- session,
435
- livemode: event.livemode === true,
436
- eventIdPrefix,
437
- }),
438
- ),
473
+ })
474
+ .then(() =>
475
+ recordFlowCheckoutPerson(request, {
476
+ session,
477
+ livemode: event.livemode === true,
478
+ eventIdPrefix,
479
+ }),
480
+ )
481
+ // #8765 D14 — after the Person step, whose Person is a flow
482
+ // checkout's grant subject.
483
+ .then((flowPerson) =>
484
+ recordCheckoutTermsGrant(request, {
485
+ session,
486
+ livemode: event.livemode === true,
487
+ eventIdPrefix,
488
+ flowPerson,
489
+ }),
490
+ ),
439
491
  stampRequestPaid(request, { session, eventIdPrefix }),
440
492
  stampProposalPaid(request, { session, eventId: event.id, eventIdPrefix }),
441
493
  ]);
@@ -531,6 +583,8 @@ type CheckoutSessionObject = {
531
583
  currency?: string | null;
532
584
  customer?: string | null;
533
585
  customer_details?: { email?: string | null } | null;
586
+ /** The terms box's answer (#8765 D14): `accepted` when the buyer ticked it. */
587
+ consent?: { terms_of_service?: string | null } | null;
534
588
  metadata?: Record<string, string> | null;
535
589
  };
536
590
 
@@ -616,6 +670,28 @@ function siteStampSourcesOf(event: {
616
670
  .filter((source) => source !== null) as Record<string, string>[];
617
671
  }
618
672
 
673
+ /** The metadata key the hub's Offerings checkout stamps on its sessions (contract A6, #8932 D9). */
674
+ const OFFERINGS_STAMP_KEY = "ailk_offering";
675
+
676
+ /**
677
+ * Where an Offerings stamp may sit (#8932 D9): every site-stamp source
678
+ * (`siteStampSourcesOf` — the Subscription, the Customer and an invoice's
679
+ * three) plus, for a `checkout.session.*` event whose object is a session,
680
+ * the session's own metadata.
681
+ */
682
+ function offeringsStampSourcesOf(event: {
683
+ type: string;
684
+ data?: unknown;
685
+ }): Record<string, string>[] {
686
+ const sources = siteStampSourcesOf(event);
687
+ const object = objectOrNull(objectOrNull(event.data)?.["object"]);
688
+ if (event.type.startsWith("checkout.session.") && object?.["object"] === "checkout.session") {
689
+ const metadata = objectOrNull(object["metadata"]);
690
+ if (metadata !== null) sources.push(metadata as Record<string, string>);
691
+ }
692
+ return sources;
693
+ }
694
+
619
695
  /**
620
696
  * Minimal projected shape of a Stripe Invoice object for the dunning arms
621
697
  * (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
@@ -750,7 +826,7 @@ async function stampFlowCheckout(
750
826
  /**
751
827
  * The hub checkout's Person (#852 D6): after `stampFlowCheckout`, find or
752
828
  * create the Person of the completed checkout's lead
753
- * (`services/person.ts`'s `recordCheckoutPerson`). Runs only for a paid (or
829
+ * (`services/person.ts`'s `resolveCheckoutPerson`). Runs only for a paid (or
754
830
  * nothing-due) session of THIS site — the foreign-site filter has already
755
831
  * returned — that carries `metadata.flowCheckoutId`. The customer id's ONE
756
832
  * source is `session.customer`, written onto the Person only for a
@@ -758,7 +834,8 @@ async function stampFlowCheckout(
758
834
  * delivery whose Person step failed is repaired by the next one.
759
835
  *
760
836
  * Logs `{ eventIdPrefix, flowCheckoutIdPrefix, outcome }` only — never the
761
- * email or the customer id. Never throws: the response stays 200.
837
+ * email or the customer id. Answers the Person, or `null` when the step did
838
+ * not run or found none (#8765 D14). Never throws: the response stays 200.
762
839
  */
763
840
  async function recordFlowCheckoutPerson(
764
841
  request: FastifyRequest,
@@ -771,33 +848,137 @@ async function recordFlowCheckoutPerson(
771
848
  livemode: boolean;
772
849
  eventIdPrefix: string;
773
850
  },
774
- ): Promise<void> {
851
+ ): Promise<CheckoutPersonResult["person"]> {
775
852
  const flowCheckoutId = session.metadata?.["flowCheckoutId"];
776
- if (!flowCheckoutId) return;
853
+ if (!flowCheckoutId) return null;
777
854
  if (
778
855
  session.payment_status !== "paid" &&
779
856
  session.payment_status !== "no_payment_required"
780
857
  ) {
781
- return;
858
+ return null;
782
859
  }
783
860
  const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
784
- let outcome: CheckoutPersonOutcome;
861
+ let result: CheckoutPersonResult;
785
862
  try {
786
- outcome = await recordCheckoutPerson({
863
+ result = await resolveCheckoutPerson({
787
864
  flowCheckoutId,
788
865
  stripeSessionId: session.id,
789
866
  customer: session.customer,
790
867
  livemode,
791
868
  });
792
869
  } catch {
793
- outcome = "person_failed";
870
+ result = { outcome: "person_failed", person: null };
794
871
  }
795
- const line = { eventIdPrefix, flowCheckoutIdPrefix, outcome };
796
- if (outcome === "person_failed") {
872
+ const line = { eventIdPrefix, flowCheckoutIdPrefix, outcome: result.outcome };
873
+ if (result.outcome === "person_failed") {
797
874
  request.log.error(line, "flow checkout person failed — continuing");
798
875
  } else {
799
876
  request.log.info(line, "flow checkout person");
800
877
  }
878
+ return result.person;
879
+ }
880
+
881
+ /** The metadata key the Checkout creators stamp with the terms the box showed (#8765 D12). */
882
+ const TERMS_STAMP_KEY = "ailk_terms";
883
+
884
+ /**
885
+ * `<purposeId>@<policyVersion>` of the account terms as the registry reads
886
+ * them now — the only stamp a grant is recorded for (SEC-8765-E) — and that
887
+ * version, which `recordCheckoutConsent` checks against the site's registry.
888
+ * Derived, never written down, so a version bump retires every older stamp.
889
+ */
890
+ function currentTermsStamp(): { stamp: string; policyVersion: number } | null {
891
+ const purpose = consentPurposeById(
892
+ SITE_CONSENT_CONFIG_DEFAULTS,
893
+ ACCOUNT_TERMS_PURPOSE_ID,
894
+ );
895
+ return purpose
896
+ ? {
897
+ stamp: `${purpose.id}@${purpose.policyVersion}`,
898
+ policyVersion: purpose.policyVersion,
899
+ }
900
+ : null;
901
+ }
902
+
903
+ /**
904
+ * The buyer's terms grant (#8765 D14). Writes nothing — and logs why — for a
905
+ * session whose box was not accepted (`consent_absent`: Stripe enforced the
906
+ * box, so the order completes regardless), one that does not carry the
907
+ * current `ailk_terms` stamp (`terms_unstamped`: a Payment Link, an invoice,
908
+ * an older session or other terms), or one whose buyer has no Person
909
+ * (`person_unresolved`). Otherwise `recordCheckoutConsent` writes at most one
910
+ * row per Person and session (`recorded` / `unchanged`), or none when the
911
+ * site's registry has moved past the stamped version (`version_changed`).
912
+ *
913
+ * Logs `{ event, outcome, eventIdPrefix }` only (plus `errName` on `failed`)
914
+ * — never an email or a Person id. Never throws: the response stays 200.
915
+ */
916
+ async function recordCheckoutTermsGrant(
917
+ request: FastifyRequest,
918
+ {
919
+ session,
920
+ livemode,
921
+ eventIdPrefix,
922
+ flowPerson,
923
+ }: {
924
+ session: CheckoutSessionObject;
925
+ livemode: boolean;
926
+ eventIdPrefix: string;
927
+ /** A flow checkout's Person, from `recordFlowCheckoutPerson`. */
928
+ flowPerson: CheckoutPersonResult["person"];
929
+ },
930
+ ): Promise<void> {
931
+ const line = (outcome: string) => ({
932
+ event: "checkout-consent",
933
+ outcome,
934
+ eventIdPrefix,
935
+ });
936
+ try {
937
+ if (session.consent?.terms_of_service !== "accepted") {
938
+ request.log.info(
939
+ line("consent_absent"),
940
+ "checkout terms grant: box not accepted",
941
+ );
942
+ return;
943
+ }
944
+ const stamp: unknown = session.metadata?.[TERMS_STAMP_KEY];
945
+ const expected = currentTermsStamp();
946
+ if (expected === null || stamp !== expected.stamp) {
947
+ request.log.info(
948
+ line("terms_unstamped"),
949
+ "checkout terms grant: session carries no current terms stamp",
950
+ );
951
+ return;
952
+ }
953
+ const sessionId = session.id;
954
+ const buyer =
955
+ typeof sessionId === "string" && sessionId !== ""
956
+ ? await checkoutBuyerPerson(session, livemode, flowPerson)
957
+ : null;
958
+ if (buyer === null || typeof sessionId !== "string") {
959
+ request.log.info(
960
+ line("person_unresolved"),
961
+ "checkout terms grant: no Person for this buyer",
962
+ );
963
+ return;
964
+ }
965
+ const outcome = await recordCheckoutConsent({
966
+ workspaceId: buyer.workspaceId,
967
+ personId: buyer.personId,
968
+ sessionId,
969
+ policyVersion: expected.policyVersion,
970
+ });
971
+ request.log.info(line(outcome), "checkout terms grant");
972
+ } catch (err) {
973
+ // The error's class only (a Prisma error's message can carry values).
974
+ request.log.error(
975
+ {
976
+ ...line("failed"),
977
+ errName: err instanceof Error ? err.name : "UnknownError",
978
+ },
979
+ "checkout terms grant failed — continuing",
980
+ );
981
+ }
801
982
  }
802
983
 
803
984
  /**
@@ -1196,14 +1377,19 @@ async function upsertBillingIdentity(
1196
1377
 
1197
1378
  /**
1198
1379
  * Link Organization.billingProvider + billingCustomerId for a Stripe customer
1199
- * (org-scoped billing, #3601, spec D2/D3). Called when a checkout carries
1200
- * metadata.organizationId — for a real org, or an individual's sentinel default
1201
- * Organization (orgs-of-one; AC2 — ONE billing path inside the module).
1380
+ * (org-scoped billing, #3601, spec D2/D3), only on proof (#9067 D13,
1381
+ * SEC-9067-1 to -3). `metadata.organizationId` names the target; the proof
1382
+ * is the session id POST /v1/billing/plan-checkout recorded on that
1383
+ * Organization. The bind is one guarded `updateMany` on `{ id,
1384
+ * planCheckoutSessionId: sessionId, billingCustomerId: null }`, which also
1385
+ * clears the recorded session — so a session no route recorded, a session
1386
+ * recorded by another deployment's database, a re-point of a bound customer
1387
+ * and a `customer.created` (`sessionId` null: a customer's metadata proves
1388
+ * nothing) all bind nothing and log `org_bind_unproven`.
1202
1389
  *
1203
1390
  * Idempotent: a typed-composite match (billingProvider="stripe",
1204
1391
  * billingCustomerId=customerId) already pointing at THIS org is a no-op on
1205
- * replay. Otherwise the target Organization (by id from checkout metadata) is
1206
- * linked.
1392
+ * replay.
1207
1393
  *
1208
1394
  * DB failures are caught and logged (never re-thrown) — a broken link must not
1209
1395
  * make Stripe retry (which would re-fire analytics + billing on eventual
@@ -1214,9 +1400,24 @@ async function upsertOrgBillingIdentity(
1214
1400
  {
1215
1401
  organizationId,
1216
1402
  customerId,
1403
+ sessionId,
1217
1404
  eventIdPrefix,
1218
- }: { organizationId: string; customerId: string; eventIdPrefix: string },
1405
+ }: {
1406
+ organizationId: string;
1407
+ customerId: string;
1408
+ sessionId: string | null;
1409
+ eventIdPrefix: string;
1410
+ },
1219
1411
  ): Promise<void> {
1412
+ const unproven = () =>
1413
+ request.log.warn(
1414
+ { eventIdPrefix, outcome: "org_bind_unproven" },
1415
+ "org billing identity not linked — no recorded plan checkout proves it",
1416
+ );
1417
+ if (sessionId === null) {
1418
+ unproven();
1419
+ return;
1420
+ }
1220
1421
  try {
1221
1422
  const existingByBilling = await prisma.organization.findFirst({
1222
1423
  where: {
@@ -1233,22 +1434,30 @@ async function upsertOrgBillingIdentity(
1233
1434
  return;
1234
1435
  }
1235
1436
 
1236
- await prisma.organization.update({
1237
- where: { id: organizationId },
1437
+ const { count } = await prisma.organization.updateMany({
1438
+ where: { id: organizationId, planCheckoutSessionId: sessionId, billingCustomerId: null },
1238
1439
  data: {
1239
1440
  billingProvider: "stripe",
1240
1441
  billingCustomerId: customerId,
1442
+ planCheckoutSessionId: null,
1241
1443
  },
1242
1444
  });
1445
+ if (count !== 1) {
1446
+ unproven();
1447
+ return;
1448
+ }
1243
1449
  request.log.info(
1244
1450
  { eventIdPrefix },
1245
- "org billing identity linked from checkout metadata.organizationId",
1451
+ "org billing identity linked from the recorded plan checkout",
1246
1452
  );
1247
1453
  } catch (err) {
1454
+ // #9067 FR-6 — the name and Prisma code only: some Prisma messages
1455
+ // print the call's arguments, which here carry the session and customer ids.
1248
1456
  request.log.error(
1249
1457
  {
1250
1458
  eventIdPrefix,
1251
- error: err instanceof Error ? err.message : String(err),
1459
+ errName: err instanceof Error ? err.name : "unknown",
1460
+ code: (err as { code?: unknown } | null)?.code,
1252
1461
  },
1253
1462
  "org billing identity upsert failed — continuing",
1254
1463
  );