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
@@ -59,27 +59,84 @@
59
59
  * as `personId`; a callback with no email makes no Person call and writes no
60
60
  * `personId`. Every one of these steps is best-effort exactly like the lead's.
61
61
  *
62
- * CHECKOUT PLAN (#7877 D10). A spoke's checkout fulfilment posts one
63
- * `submit` with the Checkout Session id as its `Idempotency-Key`. When that
64
- * lead is written (never on a replay) with a Person, and it is a LIVE,
65
- * paid checkout lead from a registered spoke's server — `viaSpoke`, a
62
+ * CHECKOUT PLAN (#7877 D10, #8793 D1). A spoke's checkout fulfilment posts
63
+ * one `submit` with the Checkout Session id as its `Idempotency-Key`. A
64
+ * LIVE, paid checkout lead from a registered spoke's server — `viaSpoke`, a
66
65
  * `cs_live_` key, `source: "web"`, `payload.page: "checkout"`, a string
67
66
  * `payload.offering` and `payload.site`, and an `amountTotal` that is
68
- * absent, `null` or a positive integer — the Person's plan becomes the
69
- * offering minus a leading `<site>_` (`setPersonPlanOffering`, the plan's
70
- * only writer). No spoke file changes. The step never throws, never changes
71
- * `routeLead`'s result, and logs one bare `checkout-plan` line — never the
72
- * offering, the email or the key. A lead that does not qualify makes no
73
- * Person call at all.
67
+ * absent, `null` or a positive integer — names a plan: the offering minus a
68
+ * leading `<site>_` (`person.ts`'s `checkoutPlanKey`). Its email is what
69
+ * the buyer typed at Stripe Checkout, which nobody verified, so the lead
70
+ * never writes any Person's plan (SEC-8748-D). It HOLDS it: on the first
71
+ * write of a marked lead (below) with a Person, the step logs one bare
72
+ * `{ event: "checkout-plan", outcome, siteId }` line at `info` — `held` for
73
+ * a plan key, `skipped` for none — and makes no Person call; the marked lead
74
+ * itself is the hold. `person.ts`'s `releaseHeldCheckoutPlan` writes it
75
+ * later, once, at the Person's first member-path link to the user whose
76
+ * verified email is the Person's own. No spoke file changes. The step never
77
+ * throws, never changes `routeLead`'s result, and never logs the offering,
78
+ * the email or the key.
74
79
  *
75
- * SPOKE CHECKOUT MARKER (#8185 D2). The same predicate, plus "not relayed
76
- * for a visitor" (`SourceMeta.relayed`), is evaluated once BEFORE the lead's
77
- * create and stored as `Lead.spokeCheckout` — the hub's conclusion at write
80
+ * THE OFFERINGS TWIN (#8932 D7). The per-Organization Stripe webhook
81
+ * (`routes/webhooks/stripe-offerings.ts`) is the predicate's second source:
82
+ * `options.offeringsCheckout`, which it sets only after W3–W6 (its own
83
+ * signature, the hub's session record for that Organization and workspace,
84
+ * the recorded Price paid) and no request can set, a key
85
+ * `"stripe-offerings " + <cs_live_ session id>` (a space no `Idempotency-Key`
86
+ * header can carry), `source: "web"`, `payload.page: "checkout"`, a
87
+ * `payload.offering` `parseLookupKey` admits and a `payload.site` equal to
88
+ * the lead's workspace. Its plan key is the FULL lookup key (no `<site>_`
89
+ * prefix can match a workspace id), and its consent grant's `sourceRef` is
90
+ * the bare session id. A test-mode session's key fails the twin, so its lead
91
+ * is unmarked (#8932 C9).
92
+ *
93
+ * SPOKE CHECKOUT MARKER (#8185 D2). The same predicate — or its Offerings
94
+ * twin (#8932 D7) — plus "not relayed for a visitor" (`SourceMeta.relayed`),
95
+ * is evaluated once BEFORE the lead's create and stored as
96
+ * `Lead.spokeCheckout` — the hub's conclusion at write
78
97
  * time, which `GET /v1/member/purchases` serves as a purchase
79
98
  * (`lib/member-store.ts`). Written only in the submit create below, never by
80
99
  * an update; an unmarked create carries no `spokeCheckout` key at all and
81
- * relies on the column default. The plan rule above is unchanged: it still
82
- * runs after the write, from the same candidate.
100
+ * relies on the column default. The plan step above runs only for a marked
101
+ * lead, after the write, and holds (#8793 D1), so a request relayed for a
102
+ * visitor never reaches it.
103
+ *
104
+ * PURCHASE EMAIL AND INBOX MESSAGE (#8757 D14). After this request's own
105
+ * write of a lead marked `spokeCheckout` with a Person — so never on a
106
+ * replay, a test-mode or relayed checkout, or a lead with no Person — the
107
+ * buyer gets one outbound `member` `Interaction` on that Person
108
+ * (`createdBy: "system:checkout"`, subject "Your purchase", no link, payload
109
+ * or attachment) and, fired with `void`, one `sendPurchaseConfirmationEmail`.
110
+ * Both name the offering only when its stripped key is one
111
+ * `listPlanOfferings` lists for the workspace, else "your purchase" — never
112
+ * the spoke-supplied `payload.offering` (SEC-8757-G) — and both bodies are
113
+ * stripped of control and bidi characters. The email's "Open your account"
114
+ * link is `options.accountOrigin` (the route resolves it, as for the invite
115
+ * email) plus the hub sign-in path: a marked lead is always a registered
116
+ * spoke's. With no origin the email goes without the link. The email is
117
+ * capped per recipient: more than `PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY`
118
+ * marked leads of this workspace for the address (any case) in the last 24
119
+ * hours, this one included, skips it; and per workspace: more than
120
+ * `PURCHASE_EMAILS_PER_SITE_PER_HOUR` marked leads in the last hour skips
121
+ * it. The message is still written. Neither
122
+ * step ever throws or changes the lead's result, and their one log line,
123
+ * `{ event: "checkout-purchase", step, outcome }`, names no address or
124
+ * offering.
125
+ *
126
+ * CHECKOUT CONSENT GRANT (#8778). After the same first write of a marked
127
+ * lead with a Person, a lead whose `payload.termsAccepted` equals the current
128
+ * `account_terms@<version>` stamp (`currentAccountTermsStamp`) — the spoke's
129
+ * fulfilment sends the stamp of the terms the buyer ticked in Stripe's box,
130
+ * else `null` — records the buyer's checkout `ConsentGrant` through #8765's
131
+ * `recordCheckoutConsent`: one `account_terms` row for that Person, source
132
+ * `checkout`, `sourceRef` the Checkout Session id (the lead's
133
+ * `Idempotency-Key`), at most once per Person and session. A `null` or absent
134
+ * value writes nothing (`consent_absent`), as does any other value
135
+ * (`terms_unstamped`). A replay never reaches it, and the key is left out of
136
+ * the replay comparison (`comparablePayload`). Never throws and never
137
+ * changes the lead's result; its one log line,
138
+ * `{ event: "checkout-consent", outcome, siteId }` (plus `errName` on
139
+ * `failed`), names no address or Person.
83
140
  *
84
141
  * VISITOR LINK (#8438 D11). When the request carried a valid consent visitor
85
142
  * id (`SourceMeta.consentVisitorId`, set by the leads and flows routes from
@@ -95,12 +152,17 @@ import { randomUUID } from "node:crypto";
95
152
  import { isDeepStrictEqual } from "node:util";
96
153
 
97
154
  import type { Prisma } from "@working-theory/database";
98
- import { sendLeadNotification } from "@working-theory/email";
155
+ import { sendLeadNotification, sendPurchaseConfirmationEmail } from "@working-theory/email";
99
156
  import { logger } from "@working-theory/observability";
100
157
  import {
158
+ accountInvitePath,
159
+ isConsentLeadSource,
101
160
  LEAD_SITE_MARKER_KEY,
102
161
  leadInputSchema,
103
- planOfferingSchema,
162
+ parseLookupKey,
163
+ PURCHASE_EMAIL_DEFAULTS,
164
+ PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY,
165
+ stripPlainText,
104
166
  } from "@working-theory/validation";
105
167
 
106
168
  import { writeLeadFallback } from "../lib/file-fallback.js";
@@ -108,15 +170,21 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
108
170
  import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
109
171
  import { prisma } from "../lib/prisma.js";
110
172
 
111
- import { linkConsentVisitor } from "./consent.js";
173
+ import {
174
+ currentAccountTermsStamp,
175
+ linkConsentVisitor,
176
+ recordCheckoutConsent,
177
+ type RecordCheckoutConsentOutcome,
178
+ } from "./consent.js";
112
179
  import { enrichLead } from "./enrichment-shim.js";
113
180
  import {
181
+ checkoutPlanKey,
114
182
  findOrCreatePerson,
115
183
  normalizeCapturedContact,
116
184
  normalizeTelephone,
117
- setPersonPlanOffering,
118
185
  } from "./person.js";
119
186
  import type { LeadContact, PersonContact } from "./person.js";
187
+ import { listPlanOfferings } from "./plan-offerings.js";
120
188
  import { requestConfirmSupersedesNotification } from "./request-confirm.js";
121
189
 
122
190
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -178,6 +246,20 @@ export type RouteLeadOptions = {
178
246
  * `payload` stays the raw answers.
179
247
  */
180
248
  contact?: LeadContact;
249
+ /**
250
+ * Where the buyer's account lives (#8757 D14): the origin the purchase
251
+ * email's "Open your account" link starts sign-in on, resolved by the route
252
+ * from the caller, never the body. Read for a marked checkout lead only.
253
+ */
254
+ accountOrigin?: string;
255
+ /**
256
+ * Set only by the per-Organization Stripe webhook after W5 and W6 (#8932
257
+ * D7) — a signature from the Organization's own secret, the hub's own
258
+ * session record for that Organization and workspace, and the recorded
259
+ * Price paid; never from a request. Admits the Offerings twin of the
260
+ * checkout plan predicate (`offeringsCheckoutCandidate`).
261
+ */
262
+ offeringsCheckout?: true;
181
263
  };
182
264
 
183
265
  export type RouteLeadResult =
@@ -252,15 +334,48 @@ async function findLeadByKey(
252
334
  })) as KeyedRow | null;
253
335
  }
254
336
 
337
+ /**
338
+ * A consent-bearing lead's `payload.consent` — a contact lead's (#8730 D3) or,
339
+ * since #9130, a job application's — is written by the route on every request,
340
+ * its `acceptedAt` that request's receive time, so it is left out of the replay
341
+ * comparison for every `isConsentLeadSource` source; every key the client sent
342
+ * still counts.
343
+ *
344
+ * A checkout lead's `payload.termsAccepted` (#8778) is left out too: a spoke
345
+ * updated between a stored attempt and Stripe's redelivery sends the key the
346
+ * stored body lacks, and a conflict there would fail every retry. A replay
347
+ * writes no grant, so the key decides nothing on one.
348
+ */
349
+ function comparablePayload(payload: unknown, source: string): unknown {
350
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
351
+ return payload;
352
+ }
353
+ const record = payload as Record<string, unknown>;
354
+ if (isConsentLeadSource(source)) {
355
+ const { consent: _serverWritten, ...rest } = record;
356
+ return rest;
357
+ }
358
+ if (record["page"] === "checkout") {
359
+ const { termsAccepted: _addedByUpdate, ...rest } = record;
360
+ return rest;
361
+ }
362
+ return payload;
363
+ }
364
+
255
365
  /**
256
366
  * A stored row is a replay only when it carries this request's exact `email`
257
367
  * and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
258
- * any other row is a conflict (#7869 D4).
368
+ * any other row is a conflict (#7869 D4). A consent-bearing lead's
369
+ * server-written `payload.consent` is not compared (#8730, #9130), nor a
370
+ * checkout lead's `payload.termsAccepted` (#8778).
259
371
  */
260
372
  function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
261
373
  return row.email === input.email &&
262
374
  row.source === input.source &&
263
- isDeepStrictEqual(row.payload, input.payload)
375
+ isDeepStrictEqual(
376
+ comparablePayload(row.payload, row.source),
377
+ comparablePayload(input.payload, input.source),
378
+ )
264
379
  ? { kind: "replay", id: row.id }
265
380
  : { kind: "conflict" };
266
381
  }
@@ -346,6 +461,9 @@ async function resolveLeadPersonId(input: SubmitInput, contact?: LeadContact): P
346
461
  });
347
462
  }
348
463
 
464
+ /** A marked checkout lead's offering and site; `sessionId` is the Offerings twin's bare session id. */
465
+ type CheckoutCandidate = { offering: string; site: string; sessionId?: string };
466
+
349
467
  /** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
350
468
  const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
351
469
  const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -358,7 +476,7 @@ function checkoutPlanCandidate(
358
476
  input: SubmitInput,
359
477
  idempotencyKey: string | undefined,
360
478
  viaSpoke: boolean,
361
- ): { offering: string; site: string } | null {
479
+ ): CheckoutCandidate | null {
362
480
  if (viaSpoke !== true) return null;
363
481
  if (idempotencyKey === undefined || !LIVE_CHECKOUT_SESSION_KEY.test(idempotencyKey)) {
364
482
  return null;
@@ -381,35 +499,283 @@ function checkoutPlanCandidate(
381
499
  return { offering, site };
382
500
  }
383
501
 
502
+ /** The Offerings webhook's key: a space no `Idempotency-Key` header can carry, then a LIVE session id. */
503
+ const OFFERINGS_CHECKOUT_KEY = /^stripe-offerings (cs_live_[A-Za-z0-9]{1,250})$/;
504
+
384
505
  /**
385
- * Set the Person's plan from a qualifying checkout lead (#7877 D10). Never
386
- * throws and never changes the lead's result; logs the outcome kind and the
387
- * site only.
506
+ * The Offerings twin of `checkoutPlanCandidate` (#8932 D7): a lead the
507
+ * per-Organization Stripe webhook wrote after W5 and W6, for a LIVE session
508
+ * only (C9, SEC-7877-C) — a test-mode session's lead is written unmarked.
509
+ * Reads no amount: W6 already proved the session paid. `checkoutPlanCandidate`
510
+ * is not edited, so a spoke lead is marked exactly as before.
388
511
  */
389
- async function applyCheckoutPlan(
512
+ function offeringsCheckoutCandidate(
390
513
  input: SubmitInput,
391
- personId: string,
392
- candidate: { offering: string; site: string },
393
- ): Promise<void> {
514
+ idempotencyKey: string | undefined,
515
+ offeringsCheckout: boolean,
516
+ ): CheckoutCandidate | null {
517
+ if (offeringsCheckout !== true || idempotencyKey === undefined) return null;
518
+ const sessionId = OFFERINGS_CHECKOUT_KEY.exec(idempotencyKey)?.[1];
519
+ if (sessionId === undefined) return null;
520
+ const { payload } = input;
521
+ if (input.source !== "web" || payload["page"] !== "checkout") return null;
522
+ const offering = payload["offering"];
523
+ if (typeof offering !== "string" || parseLookupKey(offering) === null) return null;
524
+ const site = payload[LEAD_SITE_MARKER_KEY];
525
+ if (typeof site !== "string" || site !== input.siteId) return null;
526
+ return { offering, site, sessionId };
527
+ }
528
+
529
+ /** The candidate's offering minus a leading `<site>_` (#7877 D10). */
530
+ function stripSitePrefix(candidate: CheckoutCandidate): string {
394
531
  const prefix = `${candidate.site}_`;
395
- const offering = candidate.offering.startsWith(prefix)
532
+ return candidate.offering.startsWith(prefix)
396
533
  ? candidate.offering.slice(prefix.length)
397
534
  : candidate.offering;
398
- let outcome: "set" | "skipped" | "failed";
399
- // The operator's PUT accepts the same keys (`planOfferingSchema`).
400
- if (!planOfferingSchema.safeParse(offering).success) {
401
- outcome = "skipped";
402
- } else {
403
- try {
404
- const result = await setPersonPlanOffering(input.siteId, personId, offering);
405
- outcome = result === "set" ? "set" : "failed";
406
- } catch {
407
- outcome = "failed";
535
+ }
536
+
537
+ /**
538
+ * Hold the plan of a marked checkout lead (#7877 D10, #8793 D1): no Person
539
+ * call — the marked lead itself is the hold, released only at the Person's
540
+ * verified first link (`person.ts`'s `releaseHeldCheckoutPlan`). Logs
541
+ * `held` for an offering that is a plan key, else `skipped`, and the site
542
+ * only.
543
+ */
544
+ function applyCheckoutPlan(
545
+ input: SubmitInput,
546
+ candidate: CheckoutCandidate,
547
+ ): void {
548
+ const outcome = checkoutPlanKey(candidate.offering, candidate.site) === null ? "skipped" : "held";
549
+ logger.info({ event: "checkout-plan", outcome, siteId: input.siteId }, "checkout plan");
550
+ }
551
+
552
+ // ─── Purchase email and Inbox message (#8757 D14) ────────────────────────────
553
+
554
+ const DAY_MS = 24 * 60 * 60 * 1000;
555
+ const HOUR_MS = 60 * 60 * 1000;
556
+ /**
557
+ * A workspace's purchase emails per hour, whatever the address (SEC review of
558
+ * #8770, M1): a marked lead's inputs are all the spoke's own, so the
559
+ * per-recipient cap alone would let a spoke key mail any number of distinct
560
+ * addresses. The invite email's per-site cap
561
+ * (`INVITE_EMAILS_PER_SITE_PER_HOUR`) has the same value.
562
+ */
563
+ const PURCHASE_EMAILS_PER_SITE_PER_HOUR = 50;
564
+ const GENERIC_PURCHASE_BODY = "Thanks for your purchase.";
565
+ /** How long the lead write waits on the offering's name before it says "your purchase". */
566
+ const OFFERING_NAME_BUDGET_MS = 1500;
567
+ /**
568
+ * Stripe's special-case currencies, whose minor units differ from the ISO
569
+ * decimals `Intl` formats with: the amount is left out rather than misstated.
570
+ */
571
+ const AMOUNT_UNSTATED_CURRENCIES = new Set(["HUF", "ISK", "TWD", "UGX"]);
572
+
573
+ /**
574
+ * The purchase email's sender: every completion email's rule
575
+ * (`AILK_DELIVERABLE_EMAIL_FROM`, else `AILK_LEAD_NOTIFICATION_FROM`; `||`, so
576
+ * "" names none), the same as `magnet-completion.ts`'s `inviteSenderAddress`,
577
+ * kept here so the lead path does not load that module's graph.
578
+ */
579
+ function purchaseSenderAddress(): string | undefined {
580
+ return (
581
+ process.env.AILK_DELIVERABLE_EMAIL_FROM ||
582
+ process.env.AILK_LEAD_NOTIFICATION_FROM ||
583
+ undefined
584
+ );
585
+ }
586
+
587
+ type PurchaseStep = "message" | "email";
588
+
589
+ function logPurchase(siteId: string, step: PurchaseStep, outcome: string): void {
590
+ const line = { event: "checkout-purchase", step, outcome, siteId };
591
+ if (outcome === "failed") logger.error(line, "checkout purchase step failed");
592
+ else logger.info(line, "checkout purchase step");
593
+ }
594
+
595
+ /**
596
+ * The offering's listed name, or `null` when its stripped key is not listed,
597
+ * the read fails, or it outlasts `OFFERING_NAME_BUDGET_MS`.
598
+ */
599
+ async function listedOfferingName(siteId: string, offering: string): Promise<string | null> {
600
+ let timer: NodeJS.Timeout | undefined;
601
+ const budget = new Promise<null>((resolve) => {
602
+ timer = setTimeout(() => resolve(null), OFFERING_NAME_BUDGET_MS);
603
+ });
604
+ try {
605
+ const listed = await Promise.race([listPlanOfferings(siteId), budget]);
606
+ if (!Array.isArray(listed)) return null;
607
+ const name = listed.find((item) => item.key === offering)?.name;
608
+ const clean = name === undefined ? "" : stripPlainText(name).trim();
609
+ return clean === "" ? null : clean;
610
+ } catch {
611
+ return null;
612
+ } finally {
613
+ clearTimeout(timer);
614
+ }
615
+ }
616
+
617
+ /** `amountTotal` (minor units) in `currency`, formatted, or `null` when either is unusable. */
618
+ function formattedAmount(amountTotal: unknown, currency: unknown): string | null {
619
+ if (typeof amountTotal !== "number" || !Number.isInteger(amountTotal) || amountTotal <= 0) {
620
+ return null;
621
+ }
622
+ if (typeof currency !== "string" || !/^[A-Za-z]{3}$/.test(currency)) return null;
623
+ const code = currency.toUpperCase();
624
+ if (AMOUNT_UNSTATED_CURRENCIES.has(code)) return null;
625
+ try {
626
+ const format = new Intl.NumberFormat("en", { style: "currency", currency: code });
627
+ const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
628
+ return format.format(amountTotal / 10 ** digits);
629
+ } catch {
630
+ return null;
631
+ }
632
+ }
633
+
634
+ /** The one plain-text body the email and the message share. */
635
+ function purchaseBody(name: string | null, amount: string | null): string {
636
+ const sentence =
637
+ name === null ? GENERIC_PURCHASE_BODY : PURCHASE_EMAIL_DEFAULTS.body.replaceAll("{offering}", name);
638
+ return stripPlainText(amount === null ? sentence : `${sentence} Total: ${amount}.`);
639
+ }
640
+
641
+ /** The purchase email, capped per recipient (D14). Never throws; fired with `void`. */
642
+ async function sendPurchaseEmail(
643
+ input: SubmitInput,
644
+ body: string,
645
+ accountOrigin: string | undefined,
646
+ ): Promise<void> {
647
+ let outcome: string;
648
+ try {
649
+ const recent = await prisma.lead.count({
650
+ where: {
651
+ workspaceId: input.siteId,
652
+ spokeCheckout: true,
653
+ email: { equals: input.email, mode: "insensitive" },
654
+ createdAt: { gte: new Date(Date.now() - DAY_MS) },
655
+ },
656
+ });
657
+ const siteHour = await prisma.lead.count({
658
+ where: {
659
+ workspaceId: input.siteId,
660
+ spokeCheckout: true,
661
+ createdAt: { gte: new Date(Date.now() - HOUR_MS) },
662
+ },
663
+ });
664
+ const fromAddress = purchaseSenderAddress();
665
+ if (recent > PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY) {
666
+ outcome = "capped_recipient";
667
+ } else if (siteHour > PURCHASE_EMAILS_PER_SITE_PER_HOUR) {
668
+ outcome = "capped_site";
669
+ } else if (!fromAddress) {
670
+ outcome = "sender_unconfigured";
671
+ } else {
672
+ let accountLink: string | undefined;
673
+ if (accountOrigin !== undefined) {
674
+ try {
675
+ accountLink = accountOrigin + accountInvitePath("hub", input.email);
676
+ } catch {
677
+ // An address the sign-in hint refuses: the email goes without the link.
678
+ }
679
+ }
680
+ const result = await sendPurchaseConfirmationEmail({
681
+ to: input.email,
682
+ fromAddress,
683
+ subject: PURCHASE_EMAIL_DEFAULTS.subject,
684
+ body,
685
+ cta: PURCHASE_EMAIL_DEFAULTS.cta,
686
+ ...(accountLink !== undefined && { accountLink }),
687
+ });
688
+ outcome = result.ok ? "sent" : "failed";
689
+ }
690
+ } catch {
691
+ outcome = "failed";
692
+ }
693
+ logPurchase(input.siteId, "email", outcome);
694
+ }
695
+
696
+ /**
697
+ * The buyer's Inbox message and purchase email for a marked checkout lead
698
+ * just written (D14). Never throws and never changes the lead's result.
699
+ */
700
+ async function recordPurchase(
701
+ input: SubmitInput,
702
+ personId: string,
703
+ candidate: CheckoutCandidate,
704
+ accountOrigin: string | undefined,
705
+ ): Promise<void> {
706
+ // One guard around the whole step: it runs inside `routeLead`'s persist
707
+ // `try`, where a throw after the row is written would be read as a failed
708
+ // write and the lead captured a second time.
709
+ let body: string;
710
+ try {
711
+ const name = await listedOfferingName(input.siteId, stripSitePrefix(candidate));
712
+ body = purchaseBody(name, formattedAmount(input.payload["amountTotal"], input.payload["currency"]));
713
+ } catch {
714
+ logPurchase(input.siteId, "message", "failed");
715
+ return;
716
+ }
717
+ try {
718
+ await prisma.interaction.create({
719
+ data: {
720
+ workspaceId: input.siteId,
721
+ personId,
722
+ channel: "member",
723
+ direction: "outbound",
724
+ subject: PURCHASE_EMAIL_DEFAULTS.subject,
725
+ body,
726
+ createdBy: "system:checkout",
727
+ },
728
+ });
729
+ logPurchase(input.siteId, "message", "written");
730
+ } catch {
731
+ logPurchase(input.siteId, "message", "failed");
732
+ }
733
+ void sendPurchaseEmail(input, body, accountOrigin);
734
+ }
735
+
736
+ /**
737
+ * The buyer's checkout consent grant for a marked checkout lead just written
738
+ * (#8778). Never throws and never changes the lead's result.
739
+ */
740
+ async function recordSpokeCheckoutConsent(
741
+ input: SubmitInput,
742
+ personId: string,
743
+ sessionId: string,
744
+ ): Promise<void> {
745
+ const accepted = input.payload["termsAccepted"];
746
+ let outcome:
747
+ | RecordCheckoutConsentOutcome
748
+ | "consent_absent"
749
+ | "terms_unstamped"
750
+ | "failed";
751
+ let errName: string | undefined;
752
+ try {
753
+ const expected = currentAccountTermsStamp();
754
+ if (accepted === undefined || accepted === null) {
755
+ outcome = "consent_absent";
756
+ } else if (expected === null || accepted !== expected.stamp) {
757
+ outcome = "terms_unstamped";
758
+ } else {
759
+ outcome = await recordCheckoutConsent({
760
+ workspaceId: input.siteId,
761
+ personId,
762
+ sessionId,
763
+ policyVersion: expected.policyVersion,
764
+ });
408
765
  }
766
+ } catch (err) {
767
+ // The error's class only (a Prisma error's message can carry values).
768
+ outcome = "failed";
769
+ errName = err instanceof Error ? err.name : "UnknownError";
409
770
  }
410
- const line = { event: "checkout-plan", outcome, siteId: input.siteId };
411
- if (outcome === "failed") logger.error(line, "checkout plan failed");
412
- else logger.info(line, "checkout plan");
771
+ const line = {
772
+ event: "checkout-consent",
773
+ outcome,
774
+ siteId: input.siteId,
775
+ ...(errName !== undefined && { errName }),
776
+ };
777
+ if (outcome === "failed") logger.error(line, "checkout terms grant failed");
778
+ else logger.info(line, "checkout terms grant");
413
779
  }
414
780
 
415
781
  async function persistToDb(
@@ -418,13 +784,18 @@ async function persistToDb(
418
784
  viaSpoke = false,
419
785
  relayed = false,
420
786
  contact?: LeadContact,
787
+ accountOrigin?: string,
788
+ offeringsCheckout = false,
421
789
  ): Promise<PersistResult> {
422
790
  if (input.kind === "submit") {
423
791
  const personId = await resolveLeadPersonId(input, contact);
424
792
  // #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
425
793
  // evaluated before the create so its result can be stored. A relayed
426
- // request is never a spoke's server call (D3).
427
- const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
794
+ // request is never a spoke's server call (D3). #8932 D7 — or its
795
+ // Offerings twin, set only by the per-Organization Stripe webhook.
796
+ const candidate =
797
+ checkoutPlanCandidate(input, idempotencyKey, viaSpoke) ??
798
+ offeringsCheckoutCandidate(input, idempotencyKey, offeringsCheckout);
428
799
  const spokeCheckout = candidate !== null && relayed !== true;
429
800
  let row: DbRow;
430
801
  try {
@@ -470,9 +841,20 @@ async function persistToDb(
470
841
  if (!winner) throw err;
471
842
  return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
472
843
  }
473
- // #7877 D10 — only after THIS request's own write, so a replay sets nothing.
844
+ // #7877 D10 — only after THIS request's own write, so a replay holds nothing.
474
845
  if (candidate !== null && personId !== null) {
475
- await applyCheckoutPlan(input, personId, candidate);
846
+ // #8757 D14 — the marked lead only: live, unrelayed, this write.
847
+ if (spokeCheckout) {
848
+ // #8793 D1 — the plan is held, never written here.
849
+ applyCheckoutPlan(input, candidate);
850
+ await recordPurchase(input, personId, candidate, accountOrigin);
851
+ // #8778 — a marked lead's key is its live Checkout Session id;
852
+ // #8932 D7 — the Offerings twin's key is prefixed, so its bare id.
853
+ const sessionId = candidate.sessionId ?? idempotencyKey;
854
+ if (sessionId !== undefined) {
855
+ await recordSpokeCheckoutConsent(input, personId, sessionId);
856
+ }
857
+ }
476
858
  }
477
859
  return { ok: true, id: row.id, source: "db", skipEmail: false, personId };
478
860
  }
@@ -836,6 +1218,8 @@ export async function routeLead(
836
1218
  sourceMeta.viaSpoke === true,
837
1219
  sourceMeta.relayed === true,
838
1220
  input.kind === "submit" ? options.contact : undefined,
1221
+ input.kind === "submit" ? options.accountOrigin : undefined,
1222
+ input.kind === "submit" && options.offeringsCheckout === true,
839
1223
  );
840
1224
  // #7869 D5 — a lost race answers like the lookup above: no capture
841
1225
  // record, no notification.