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
@@ -23,9 +23,19 @@ jest.mock("../../lib/prisma.js");
23
23
 
24
24
  // ─── Mock: email (external boundary) ─────────────────────────────────────────
25
25
  const mockSendLeadNotification = jest.fn();
26
+ const mockSendPurchaseConfirmationEmail = jest.fn();
26
27
  jest.mock("@working-theory/email", () => ({
27
28
  sendLeadNotification: (...args: unknown[]) =>
28
29
  mockSendLeadNotification(...args),
30
+ sendPurchaseConfirmationEmail: (...args: unknown[]) =>
31
+ mockSendPurchaseConfirmationEmail(...args),
32
+ }));
33
+
34
+ // ─── Mock: the plan-offerings lister (a content read) ────────────────────────
35
+ const mockListPlanOfferings = jest.fn();
36
+ // eslint-disable-next-line no-restricted-syntax -- plan-offerings.ts is the workspace's content-adapter read (the database adapter, an external boundary), the same seam routes/leads/__tests__/magnet-completion.test.ts mocks.
37
+ jest.mock("../plan-offerings.js", () => ({
38
+ listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
29
39
  }));
30
40
 
31
41
  // ─── Import after mocks ───────────────────────────────────────────────────────
@@ -34,10 +44,21 @@ import * as os from "os";
34
44
  import * as path from "path";
35
45
 
36
46
  import { logger } from "@working-theory/observability";
47
+ import {
48
+ ACCOUNT_TERMS_PURPOSE_ID,
49
+ CONSENT_LEAD_SOURCE_LIST,
50
+ consentPurposeById,
51
+ SITE_CONSENT_CONFIG_DEFAULTS,
52
+ } from "@working-theory/validation";
37
53
 
38
54
  import { MOCK_PERSON_ID } from "../../lib/__mocks__/prisma.js";
55
+ import {
56
+ armConsentLedger,
57
+ type ConsentLedgerDouble,
58
+ } from "../../lib/__tests__/consent-ledger-double.js";
39
59
  import { prisma } from "../../lib/prisma.js";
40
60
  import { routeLead } from "../lead-routing";
61
+ import * as consentService from "../consent";
41
62
  import * as personService from "../person";
42
63
 
43
64
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -141,7 +162,9 @@ describe("routeLead", () => {
141
162
 
142
163
  // ── 2. DB down → fallback engages → persisted:file ───────────────────────
143
164
  it("DB down → file fallback → persisted:file", async () => {
144
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
165
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
166
+ new Error("connection refused"),
167
+ );
145
168
 
146
169
  const result = (await routeLead(leadInput, sourceMeta)) as Extract<
147
170
  RouteLeadResult,
@@ -367,9 +390,12 @@ describe("routeLead", () => {
367
390
  const inputForService = {
368
391
  ...inputWithoutId,
369
392
  // Re-attach as a plain unknown property so Zod's strict() can reject it.
370
- ...({"id": inputWithExtraId.id} as Record<string, unknown>),
393
+ ...({ id: inputWithExtraId.id } as Record<string, unknown>),
371
394
  } as Parameters<typeof routeLead>[0];
372
- const result = (await routeLead(inputForService, sourceMeta)) as Extract<RouteLeadResult, { ok: false }>;
395
+ const result = (await routeLead(inputForService, sourceMeta)) as Extract<
396
+ RouteLeadResult,
397
+ { ok: false }
398
+ >;
373
399
 
374
400
  expect(result.ok).toBe(false);
375
401
  expect(result.error.kind).toBe("invalid_input");
@@ -433,7 +459,9 @@ describe("routeLead", () => {
433
459
 
434
460
  const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
435
461
  expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
436
- expect(data.workspaceId).toBe("wksp_00000000-0000-7000-8000-000000000001");
462
+ expect(data.workspaceId).toBe(
463
+ "wksp_00000000-0000-7000-8000-000000000001",
464
+ );
437
465
  });
438
466
  });
439
467
 
@@ -456,10 +484,10 @@ describe("routeLead", () => {
456
484
  );
457
485
  (prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
458
486
 
459
- const result = (await routeLead(
460
- leadInput,
461
- sourceMeta,
462
- )) as Extract<RouteLeadResult, { ok: false }>;
487
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
488
+ RouteLeadResult,
489
+ { ok: false }
490
+ >;
463
491
 
464
492
  expect(result.ok).toBe(false);
465
493
  expect(result.error.kind).toBe("persist_failed");
@@ -475,10 +503,10 @@ describe("routeLead", () => {
475
503
  );
476
504
  (prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
477
505
 
478
- const result = (await routeLead(
479
- leadInput,
480
- sourceMeta,
481
- )) as Extract<RouteLeadResult, { ok: false }>;
506
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
507
+ RouteLeadResult,
508
+ { ok: false }
509
+ >;
482
510
 
483
511
  expect(result.ok).toBe(false);
484
512
  expect(result.error.kind).toBe("persist_failed");
@@ -489,15 +517,17 @@ describe("routeLead", () => {
489
517
  it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
490
518
  const before = await listTmpDirFiles();
491
519
  const notNullErr = Object.assign(
492
- new Error("null value in column \"workspace_id\" violates not-null constraint (23502)"),
520
+ new Error(
521
+ 'null value in column "workspace_id" violates not-null constraint (23502)',
522
+ ),
493
523
  {},
494
524
  );
495
525
  (prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
496
526
 
497
- const result = (await routeLead(
498
- leadInput,
499
- sourceMeta,
500
- )) as Extract<RouteLeadResult, { ok: false }>;
527
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
528
+ RouteLeadResult,
529
+ { ok: false }
530
+ >;
501
531
 
502
532
  expect(result.ok).toBe(false);
503
533
  expect(result.error.kind).toBe("persist_failed");
@@ -512,10 +542,10 @@ describe("routeLead", () => {
512
542
  new Error("connection refused"),
513
543
  );
514
544
 
515
- const result = (await routeLead(
516
- leadInput,
517
- sourceMeta,
518
- )) as Extract<RouteLeadResult, { ok: true }>;
545
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
546
+ RouteLeadResult,
547
+ { ok: true }
548
+ >;
519
549
 
520
550
  expect(result.ok).toBe(true);
521
551
  expect(result.persisted).toBe("file");
@@ -542,14 +572,17 @@ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () =>
542
572
  (prisma.subscriber.create as jest.Mock).mockRejectedValue(
543
573
  Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
544
574
  );
545
- (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "sub_1" });
575
+ (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
576
+ id: "sub_1",
577
+ });
546
578
 
547
579
  await routeLead(
548
580
  { ...subscribeInput, email: "dupe@example.com" },
549
581
  sourceMeta,
550
582
  );
551
583
 
552
- const { where } = (prisma.subscriber.findUnique as jest.Mock).mock.calls[0][0];
584
+ const { where } = (prisma.subscriber.findUnique as jest.Mock).mock
585
+ .calls[0][0];
553
586
  expect(where.workspaceId).toBe(subscribeInput.siteId);
554
587
  expect(where.siteId_email).toEqual({
555
588
  siteId: subscribeInput.siteId,
@@ -588,7 +621,10 @@ describe("#6730 — routeLead delivery modes", () => {
588
621
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
589
622
  process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
590
623
  (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
591
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
624
+ mockSendLeadNotification.mockResolvedValue({
625
+ ok: true,
626
+ messageId: "msg-1",
627
+ });
592
628
  });
593
629
 
594
630
  afterEach(async () => {
@@ -646,7 +682,10 @@ describe("#6730 — routeLead delivery modes", () => {
646
682
  });
647
683
 
648
684
  const result = await routeLead(leadInput, sourceMeta);
649
- expect(result).toMatchObject({ ok: false, error: { kind: "delivery_failed" } });
685
+ expect(result).toMatchObject({
686
+ ok: false,
687
+ error: { kind: "delivery_failed" },
688
+ });
650
689
  });
651
690
 
652
691
  it("a callback is delivered by email too — carrying the name and number to call", async () => {
@@ -669,7 +708,10 @@ describe("#6730 — routeLead delivery modes", () => {
669
708
 
670
709
  it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
671
710
  const result = await routeLead(subscribeInput, sourceMeta);
672
- expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
711
+ expect(result).toMatchObject({
712
+ ok: false,
713
+ error: { kind: "config_missing" },
714
+ });
673
715
  expect(prisma.subscriber.create).not.toHaveBeenCalled();
674
716
  expect(mockSendLeadNotification).not.toHaveBeenCalled();
675
717
  });
@@ -683,7 +725,10 @@ describe("#6730 — routeLead delivery modes", () => {
683
725
  it("refuses with config_missing before any write", async () => {
684
726
  const result = await routeLead(leadInput, sourceMeta);
685
727
 
686
- expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
728
+ expect(result).toMatchObject({
729
+ ok: false,
730
+ error: { kind: "config_missing" },
731
+ });
687
732
  expect(prisma.lead.create).not.toHaveBeenCalled();
688
733
  expect(mockSendLeadNotification).not.toHaveBeenCalled();
689
734
  expect(await listFiles()).toEqual([]);
@@ -694,13 +739,18 @@ describe("#6730 — routeLead delivery modes", () => {
694
739
  beforeEach(() => {
695
740
  process.env.DATABASE_URL = "postgres://test";
696
741
  process.env.NODE_ENV = "production";
697
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
742
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
743
+ new Error("connection refused"),
744
+ );
698
745
  });
699
746
 
700
747
  it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
701
748
  const result = await routeLead(leadInput, sourceMeta);
702
749
 
703
- expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
750
+ expect(result).toMatchObject({
751
+ ok: false,
752
+ error: { kind: "persist_failed" },
753
+ });
704
754
  expect(await listFiles()).toEqual([]);
705
755
  });
706
756
 
@@ -719,11 +769,16 @@ describe("#6730 — routeLead delivery modes", () => {
719
769
 
720
770
  it("a newsletter signup is never emailed — persist_failed", async () => {
721
771
  Object.assign(process.env, EMAIL_ENV);
722
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
772
+ (prisma.subscriber.create as jest.Mock).mockRejectedValue(
773
+ new Error("connection refused"),
774
+ );
723
775
 
724
776
  const result = await routeLead(subscribeInput, sourceMeta);
725
777
 
726
- expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
778
+ expect(result).toMatchObject({
779
+ ok: false,
780
+ error: { kind: "persist_failed" },
781
+ });
727
782
  expect(mockSendLeadNotification).not.toHaveBeenCalled();
728
783
  });
729
784
 
@@ -735,7 +790,10 @@ describe("#6730 — routeLead delivery modes", () => {
735
790
  });
736
791
 
737
792
  const result = await routeLead(leadInput, sourceMeta);
738
- expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
793
+ expect(result).toMatchObject({
794
+ ok: false,
795
+ error: { kind: "persist_failed" },
796
+ });
739
797
  });
740
798
  });
741
799
 
@@ -743,7 +801,9 @@ describe("#6730 — routeLead delivery modes", () => {
743
801
  it("DB down in test → persisted:file", async () => {
744
802
  process.env.DATABASE_URL = "postgres://test";
745
803
  process.env.NODE_ENV = "test";
746
- (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
804
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
805
+ new Error("connection refused"),
806
+ );
747
807
 
748
808
  const result = await routeLead(leadInput, sourceMeta);
749
809
  expect(result).toMatchObject({ ok: true, persisted: "file" });
@@ -951,6 +1011,140 @@ describe("#7869 — routeLead with an idempotency key", () => {
951
1011
  });
952
1012
  });
953
1013
 
1014
+ // ─── #8730, #9130 — a keyed consent-bearing lead replays ─────────────────────
1015
+ //
1016
+ // The route writes `payload.consent` on every request, its `acceptedAt` that
1017
+ // request's receive time, so a retry's payload differs from the stored one
1018
+ // there and nowhere else. The replay comparison leaves it out for EVERY
1019
+ // consent-bearing source — iterating the list, so a source added to it later
1020
+ // cannot be gated by the route yet compared here.
1021
+ describe("#8730, #9130 — routeLead replays a keyed consent-bearing lead", () => {
1022
+ const KEY = "fixture-retry-0000-0000-9130";
1023
+ const STORED_ID = "lead_00000000-0000-7000-8000-000000009131";
1024
+ const ENV_KEYS = [
1025
+ "DATABASE_URL",
1026
+ "NODE_ENV",
1027
+ "AILK_INBOX_FALLBACK_DIR",
1028
+ "LEAD_CAPTURE_FILE",
1029
+ "AILK_LEAD_NOTIFICATION_TO",
1030
+ "AILK_LEAD_NOTIFICATION_FROM",
1031
+ ] as const;
1032
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1033
+ let tmpDir: string;
1034
+
1035
+ const consentAt = (acceptedAt: string) => ({
1036
+ statement: "I accept the terms.",
1037
+ acceptedAt,
1038
+ });
1039
+
1040
+ beforeEach(async () => {
1041
+ jest.clearAllMocks();
1042
+ saved = {};
1043
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
1044
+ for (const k of ENV_KEYS) delete process.env[k];
1045
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-consent-replay-"));
1046
+ process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
1047
+ process.env.DATABASE_URL = "postgres://stub";
1048
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1049
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1050
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
1051
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
1052
+ });
1053
+
1054
+ afterEach(async () => {
1055
+ for (const k of ENV_KEYS) {
1056
+ if (saved[k] === undefined) delete process.env[k];
1057
+ else process.env[k] = saved[k];
1058
+ }
1059
+ await fs.rm(tmpDir, { recursive: true, force: true });
1060
+ });
1061
+
1062
+ it("covers every consent-bearing source, the job application included", () => {
1063
+ expect(CONSENT_LEAD_SOURCE_LIST).toContain("flow:job-application");
1064
+ });
1065
+
1066
+ it.each(CONSENT_LEAD_SOURCE_LIST)(
1067
+ "%s — a retry whose only difference is the server-written payload.consent replays the stored lead",
1068
+ async (source) => {
1069
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1070
+ id: STORED_ID,
1071
+ email: "visitor@example.com",
1072
+ source,
1073
+ payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1074
+ });
1075
+
1076
+ const result = await routeLead(
1077
+ {
1078
+ kind: "submit",
1079
+ siteId: "site-abc",
1080
+ source,
1081
+ email: "visitor@example.com",
1082
+ payload: { name: "Jamie", consent: consentAt("2026-10-18T12:00:00.000Z") },
1083
+ },
1084
+ sourceMeta,
1085
+ { idempotencyKey: KEY },
1086
+ );
1087
+
1088
+ expect(result).toMatchObject({ ok: true, replayed: true, id: STORED_ID });
1089
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1090
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
1091
+ },
1092
+ );
1093
+
1094
+ it.each(CONSENT_LEAD_SOURCE_LIST)(
1095
+ "%s — a retry with a different form field is still a conflict",
1096
+ async (source) => {
1097
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1098
+ id: STORED_ID,
1099
+ email: "visitor@example.com",
1100
+ source,
1101
+ payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1102
+ });
1103
+
1104
+ const result = await routeLead(
1105
+ {
1106
+ kind: "submit",
1107
+ siteId: "site-abc",
1108
+ source,
1109
+ email: "visitor@example.com",
1110
+ payload: { name: "Someone else", consent: consentAt("2026-10-18T12:00:00.000Z") },
1111
+ },
1112
+ sourceMeta,
1113
+ { idempotencyKey: KEY },
1114
+ );
1115
+
1116
+ expect(result).toEqual({
1117
+ ok: false,
1118
+ error: { kind: "idempotency_conflict", message: "idempotency key reused" },
1119
+ });
1120
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1121
+ },
1122
+ );
1123
+
1124
+ it("a source outside the list still compares payload.consent", async () => {
1125
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1126
+ id: STORED_ID,
1127
+ email: "visitor@example.com",
1128
+ source: "flow:quiz",
1129
+ payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1130
+ });
1131
+
1132
+ const result = await routeLead(
1133
+ {
1134
+ kind: "submit",
1135
+ siteId: "site-abc",
1136
+ source: "flow:quiz",
1137
+ email: "visitor@example.com",
1138
+ payload: { name: "Jamie", consent: consentAt("2026-10-18T12:00:00.000Z") },
1139
+ },
1140
+ sourceMeta,
1141
+ { idempotencyKey: KEY },
1142
+ );
1143
+
1144
+ expect(result).toMatchObject({ ok: false, error: { kind: "idempotency_conflict" } });
1145
+ });
1146
+ });
1147
+
954
1148
  // ─── #852 D6 — every submitted lead carries its Person ──────────────────────
955
1149
  //
956
1150
  // The Person step runs through the shared mock's `person` delegate, whose
@@ -967,14 +1161,20 @@ describe("#852 — the lead's Person (AC-3)", () => {
967
1161
  let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
968
1162
  const LEAD_ID = "lead_00000000-0000-7000-8000-000000000852";
969
1163
  const createData = () =>
970
- jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<string, unknown>;
1164
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
1165
+ string,
1166
+ unknown
1167
+ >;
971
1168
  const personDelegate = () => ({
972
1169
  findFirst: jest.mocked(prisma.person.findFirst),
973
1170
  createMany: jest.mocked(prisma.person.createMany),
974
1171
  updateMany: jest.mocked(prisma.person.updateMany),
975
1172
  });
976
1173
  const personCalls = () =>
977
- Object.values(personDelegate()).reduce((n, fn) => n + fn.mock.calls.length, 0);
1174
+ Object.values(personDelegate()).reduce(
1175
+ (n, fn) => n + fn.mock.calls.length,
1176
+ 0,
1177
+ );
978
1178
  let warn: jest.SpyInstance;
979
1179
  let error: jest.SpyInstance;
980
1180
 
@@ -987,9 +1187,16 @@ describe("#852 — the lead's Person (AC-3)", () => {
987
1187
  process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
988
1188
  process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
989
1189
  (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
990
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-852" });
991
- warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
992
- error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
1190
+ mockSendLeadNotification.mockResolvedValue({
1191
+ ok: true,
1192
+ messageId: "msg-852",
1193
+ });
1194
+ warn = jest
1195
+ .spyOn(logger, "warn")
1196
+ .mockImplementation(() => undefined as never);
1197
+ error = jest
1198
+ .spyOn(logger, "error")
1199
+ .mockImplementation(() => undefined as never);
993
1200
  });
994
1201
 
995
1202
  afterEach(() => {
@@ -1003,21 +1210,33 @@ describe("#852 — the lead's Person (AC-3)", () => {
1003
1210
 
1004
1211
  it("a submit writes the Person findOrCreatePerson returned for (siteId, email)", async () => {
1005
1212
  const result = await routeLead(
1006
- { ...leadInput, email: "Test@Example.com", payload: { name: "Tess", message: "hi" } },
1213
+ {
1214
+ ...leadInput,
1215
+ email: "Test@Example.com",
1216
+ payload: { name: "Tess", message: "hi" },
1217
+ },
1007
1218
  sourceMeta,
1008
1219
  );
1009
1220
 
1010
1221
  expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1011
1222
  expect(personDelegate()["findFirst"]).toHaveBeenCalledWith(
1012
- expect.objectContaining({ where: { workspaceId: "site-abc", email: "test@example.com" } }),
1223
+ expect.objectContaining({
1224
+ where: { workspaceId: "site-abc", email: "test@example.com" },
1225
+ }),
1226
+ );
1227
+ expect(createData()).toEqual(
1228
+ expect.objectContaining({ personId: MOCK_PERSON_ID }),
1013
1229
  );
1014
- expect(createData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1015
1230
  });
1016
1231
 
1017
1232
  // ── #8435 D5 (AC-3) — subscribers and callbacks link to their Person ─────
1018
1233
  const LINKED = {
1019
1234
  ok: true as const,
1020
- value: { personId: MOCK_PERSON_ID as never, created: false, billing: "unchanged" as const },
1235
+ value: {
1236
+ personId: MOCK_PERSON_ID as never,
1237
+ created: false,
1238
+ billing: "unchanged" as const,
1239
+ },
1021
1240
  };
1022
1241
  const findSpies: jest.SpyInstance[] = [];
1023
1242
  const spyFind = () => {
@@ -1029,54 +1248,88 @@ describe("#852 — the lead's Person (AC-3)", () => {
1029
1248
  while (findSpies.length > 0) findSpies.pop()?.mockRestore();
1030
1249
  });
1031
1250
  const subscriberData = () =>
1032
- jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<string, unknown>;
1251
+ jest.mocked(prisma.subscriber.create).mock.calls[0]?.[0].data as Record<
1252
+ string,
1253
+ unknown
1254
+ >;
1033
1255
  const callbackData = () =>
1034
- jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<string, unknown>;
1256
+ jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data as Record<
1257
+ string,
1258
+ unknown
1259
+ >;
1035
1260
 
1036
1261
  it("a subscribe calls findOrCreatePerson(siteId, { email }) once and writes that personId", async () => {
1037
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
1262
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1263
+ id: "subscr_x",
1264
+ });
1038
1265
  const find = spyFind().mockResolvedValue(LINKED);
1039
1266
  await routeLead(subscribeInput, sourceMeta);
1040
1267
  expect(find).toHaveBeenCalledTimes(1);
1041
- expect(find).toHaveBeenCalledWith("site-abc", { email: "subscriber@example.com" });
1042
- expect(subscriberData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1268
+ expect(find).toHaveBeenCalledWith("site-abc", {
1269
+ email: "subscriber@example.com",
1270
+ });
1271
+ expect(subscriberData()).toEqual(
1272
+ expect.objectContaining({ personId: MOCK_PERSON_ID }),
1273
+ );
1043
1274
  });
1044
1275
 
1045
1276
  it("a subscribe whose Person is invalid_email writes personId: null and one person-skipped warn with no email", async () => {
1046
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
1277
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1278
+ id: "subscr_x",
1279
+ });
1047
1280
  jest
1048
1281
  .spyOn(personService, "findOrCreatePerson")
1049
1282
  .mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
1050
1283
  const result = await routeLead(subscribeInput, sourceMeta);
1051
1284
  expect(result).toMatchObject({ ok: true, id: "subscr_x", persisted: "db" });
1052
- expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
1053
- const skipped = warn.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-skipped");
1285
+ expect(subscriberData()).toEqual(
1286
+ expect.objectContaining({ personId: null }),
1287
+ );
1288
+ const skipped = warn.mock.calls.filter(
1289
+ ([fields]) => (fields as { event?: string }).event === "person-skipped",
1290
+ );
1054
1291
  expect(skipped).toHaveLength(1);
1055
1292
  expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
1056
1293
  });
1057
1294
 
1058
1295
  it("a subscribe whose Person step fails (internal_error or a throw) answers exactly like a linked one", async () => {
1059
- (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
1296
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1297
+ id: "subscr_x",
1298
+ });
1060
1299
  const find = spyFind().mockResolvedValue(LINKED);
1061
1300
  const linked = await routeLead(subscribeInput, sourceMeta);
1062
1301
  jest.mocked(prisma.subscriber.create).mockClear();
1063
1302
 
1064
- find.mockResolvedValueOnce({ ok: false, error: { kind: "internal_error" } });
1303
+ find.mockResolvedValueOnce({
1304
+ ok: false,
1305
+ error: { kind: "internal_error" },
1306
+ });
1065
1307
  const failed = await routeLead(subscribeInput, sourceMeta);
1066
1308
  expect(failed).toEqual(linked);
1067
- expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
1068
- expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({ event: "person-failed", siteId: "site-abc" });
1309
+ expect(subscriberData()).toEqual(
1310
+ expect.objectContaining({ personId: null }),
1311
+ );
1312
+ expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
1313
+ event: "person-failed",
1314
+ siteId: "site-abc",
1315
+ });
1069
1316
  jest.mocked(prisma.subscriber.create).mockClear();
1070
1317
 
1071
1318
  find.mockRejectedValueOnce(new Error("boom"));
1072
1319
  const thrown = await routeLead(subscribeInput, sourceMeta);
1073
1320
  expect(thrown).toEqual(linked);
1074
- expect(subscriberData()).toEqual(expect.objectContaining({ personId: null }));
1321
+ expect(subscriberData()).toEqual(
1322
+ expect.objectContaining({ personId: null }),
1323
+ );
1075
1324
  });
1076
1325
 
1077
1326
  it("a duplicate subscribe (P2002) makes no second Person call", async () => {
1078
- (prisma.subscriber.create as jest.Mock).mockRejectedValue(Object.assign(new Error("dup"), { code: "P2002" }));
1079
- (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "subscr_existing" });
1327
+ (prisma.subscriber.create as jest.Mock).mockRejectedValue(
1328
+ Object.assign(new Error("dup"), { code: "P2002" }),
1329
+ );
1330
+ (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({
1331
+ id: "subscr_existing",
1332
+ });
1080
1333
  const find = spyFind().mockResolvedValue(LINKED);
1081
1334
  const result = await routeLead(subscribeInput, sourceMeta);
1082
1335
  expect(result).toMatchObject({ ok: true, id: "subscr_existing" });
@@ -1087,7 +1340,11 @@ describe("#852 — the lead's Person (AC-3)", () => {
1087
1340
  (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
1088
1341
  const find = spyFind().mockResolvedValue(LINKED);
1089
1342
  await routeLead(
1090
- { ...callbackInput, phone: "+1 415 555 0123", payload: { email: "c@x.io" } },
1343
+ {
1344
+ ...callbackInput,
1345
+ phone: "+1 415 555 0123",
1346
+ payload: { email: "c@x.io" },
1347
+ },
1091
1348
  sourceMeta,
1092
1349
  );
1093
1350
  expect(find).toHaveBeenCalledWith("site-abc", {
@@ -1095,27 +1352,32 @@ describe("#852 — the lead's Person (AC-3)", () => {
1095
1352
  name: "Alice",
1096
1353
  contact: { telephone: "+14155550123" },
1097
1354
  });
1098
- expect(callbackData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1355
+ expect(callbackData()).toEqual(
1356
+ expect.objectContaining({ personId: MOCK_PERSON_ID }),
1357
+ );
1099
1358
  });
1100
1359
 
1101
1360
  it.each([
1102
1361
  ["no payload.email", { notes: "urgent" }],
1103
1362
  ["a numeric payload.email", { email: 12345 }],
1104
- ])("a callback with %s makes no person delegate call and writes no personId key", async (_label, payload) => {
1105
- (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
1106
- await routeLead({ ...callbackInput, payload }, sourceMeta);
1107
- expect(personCalls()).toBe(0);
1108
- expect(Object.keys(callbackData())).not.toContain("personId");
1109
- });
1363
+ ])(
1364
+ "a callback with %s makes no person delegate call and writes no personId key",
1365
+ async (_label, payload) => {
1366
+ (prisma.callback.create as jest.Mock).mockResolvedValue({
1367
+ id: "callbk_x",
1368
+ });
1369
+ await routeLead({ ...callbackInput, payload }, sourceMeta);
1370
+ expect(personCalls()).toBe(0);
1371
+ expect(Object.keys(callbackData())).not.toContain("personId");
1372
+ },
1373
+ );
1110
1374
 
1111
1375
  // ── #8435 D5 (AC-4) — a submit's explicit contact ────────────────────────
1112
1376
  it("a submit with options.contact.name passes that name over payload.name, and the normalized contact", async () => {
1113
1377
  const find = spyFind().mockResolvedValue(LINKED);
1114
- await routeLead(
1115
- { ...leadInput, payload: { name: "Bob" } },
1116
- sourceMeta,
1117
- { contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" } },
1118
- );
1378
+ await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta, {
1379
+ contact: { name: "Ann", givenName: "Ann", phone: "+1 (415) 555-0123" },
1380
+ });
1119
1381
  expect(find).toHaveBeenCalledWith("site-abc", {
1120
1382
  email: "test@example.com",
1121
1383
  name: "Ann",
@@ -1128,7 +1390,10 @@ describe("#852 — the lead's Person (AC-3)", () => {
1128
1390
  it("a submit with no options.contact passes payload.name and no contact, as before", async () => {
1129
1391
  const find = spyFind().mockResolvedValue(LINKED);
1130
1392
  await routeLead({ ...leadInput, payload: { name: "Bob" } }, sourceMeta);
1131
- expect(find).toHaveBeenCalledWith("site-abc", { email: "test@example.com", name: "Bob" });
1393
+ expect(find).toHaveBeenCalledWith("site-abc", {
1394
+ email: "test@example.com",
1395
+ name: "Bob",
1396
+ });
1132
1397
  });
1133
1398
 
1134
1399
  it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
@@ -1141,9 +1406,15 @@ describe("#852 — the lead's Person (AC-3)", () => {
1141
1406
 
1142
1407
  expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1143
1408
  expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1144
- const skipped = warn.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-skipped");
1409
+ const skipped = warn.mock.calls.filter(
1410
+ ([fields]) => (fields as { event?: string }).event === "person-skipped",
1411
+ );
1145
1412
  expect(skipped).toHaveLength(1);
1146
- expect(skipped[0]![0]).toEqual({ event: "person-skipped", kind: "invalid_email", siteId: "site-abc" });
1413
+ expect(skipped[0]![0]).toEqual({
1414
+ event: "person-skipped",
1415
+ kind: "invalid_email",
1416
+ siteId: "site-abc",
1417
+ });
1147
1418
  expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
1148
1419
  });
1149
1420
 
@@ -1165,18 +1436,30 @@ describe("#852 — the lead's Person (AC-3)", () => {
1165
1436
  const ok = await routeLead(leadInput, sourceMeta);
1166
1437
  jest.mocked(prisma.lead.create).mockClear();
1167
1438
  mockSendLeadNotification.mockClear();
1168
- personDelegate()["findFirst"]!.mockRejectedValueOnce(new Error("relation \"people\" does not exist"));
1439
+ personDelegate()["findFirst"]!.mockRejectedValueOnce(
1440
+ new Error('relation "people" does not exist'),
1441
+ );
1169
1442
 
1170
1443
  const failed = await routeLead(leadInput, sourceMeta);
1171
1444
 
1172
1445
  // The answer is the same as a successful capture, apart from the internal
1173
1446
  // `personId` (#8438 D11) the route never puts on a wire.
1174
1447
  expect(failed).toEqual({ ...ok, personId: null });
1175
- expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db", email: { ok: true } });
1448
+ expect(failed).toMatchObject({
1449
+ ok: true,
1450
+ id: LEAD_ID,
1451
+ persisted: "db",
1452
+ email: { ok: true },
1453
+ });
1176
1454
  expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1177
- const lines = error.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-failed");
1455
+ const lines = error.mock.calls.filter(
1456
+ ([fields]) => (fields as { event?: string }).event === "person-failed",
1457
+ );
1178
1458
  expect(lines).toHaveLength(1);
1179
- expect(lines[0]![0]).toEqual({ event: "person-failed", siteId: "site-abc" });
1459
+ expect(lines[0]![0]).toEqual({
1460
+ event: "person-failed",
1461
+ siteId: "site-abc",
1462
+ });
1180
1463
  expect(JSON.stringify(error.mock.calls)).not.toContain("test@example.com");
1181
1464
  });
1182
1465
  });
@@ -1187,7 +1470,11 @@ describe("#852 — the lead's Person (AC-3)", () => {
1187
1470
  // paid checkout lead a registered spoke's server posted with the session id
1188
1471
  // as its key. Every other lead makes no plan call; a failing plan step never
1189
1472
  // changes the lead's result.
1190
- describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1473
+ // #8793 D1 — a marked checkout lead HOLDS its plan: it never writes a
1474
+ // Person's plan, logs `held` (or `skipped` for an offering that is no key),
1475
+ // and the plan is released only at the Person's verified first link
1476
+ // (`person.test.ts`, `checkout-plan.test.ts`).
1477
+ describe("#7877 — the checkout lead holds the plan (AC-5; #8793 D1, AC-7)", () => {
1191
1478
  const ENV_KEYS = [
1192
1479
  "DATABASE_URL",
1193
1480
  "NODE_ENV",
@@ -1204,7 +1491,11 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1204
1491
  siteId: WS,
1205
1492
  source: "web",
1206
1493
  email: "cleo@x.io",
1207
- payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" } as Record<string, unknown>,
1494
+ payload: {
1495
+ site: "wendi",
1496
+ page: "checkout",
1497
+ offering: "wendi_retainer",
1498
+ } as Record<string, unknown>,
1208
1499
  };
1209
1500
  let setPlan: jest.SpyInstance;
1210
1501
  let info: jest.SpyInstance;
@@ -1220,10 +1511,19 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1220
1511
  process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1221
1512
  (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
1222
1513
  (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1223
- mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-7877" });
1224
- setPlan = jest.spyOn(personService, "setPersonPlanOffering").mockResolvedValue("set");
1225
- info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
1226
- error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
1514
+ mockSendLeadNotification.mockResolvedValue({
1515
+ ok: true,
1516
+ messageId: "msg-7877",
1517
+ });
1518
+ setPlan = jest
1519
+ .spyOn(personService, "setPersonPlanOffering")
1520
+ .mockResolvedValue("set");
1521
+ info = jest
1522
+ .spyOn(logger, "info")
1523
+ .mockImplementation(() => undefined as never);
1524
+ error = jest
1525
+ .spyOn(logger, "error")
1526
+ .mockImplementation(() => undefined as never);
1227
1527
  });
1228
1528
 
1229
1529
  afterEach(() => {
@@ -1240,31 +1540,61 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1240
1540
  [...info.mock.calls, ...error.mock.calls]
1241
1541
  .map(([fields]) => fields as Record<string, unknown>)
1242
1542
  .filter((fields) => fields.event === "checkout-plan");
1543
+ /** Every `checkout-plan` line is logged at `info`, never at `error`. */
1544
+ const infoPlanLines = () =>
1545
+ info.mock.calls
1546
+ .map(([fields]) => fields as Record<string, unknown>)
1547
+ .filter((fields) => fields.event === "checkout-plan");
1548
+ /** The lead's answer, as it was before #8793. */
1549
+ const answered = {
1550
+ ok: true,
1551
+ id: LEAD_ID,
1552
+ personId: MOCK_PERSON_ID,
1553
+ persisted: "db",
1554
+ email: { ok: true, messageId: "msg-7877" },
1555
+ };
1243
1556
 
1244
- it("a live spoke checkout lead sets the plan to the offering minus the <site>_ prefix", async () => {
1245
- const result = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1246
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1247
- expect(setPlan).toHaveBeenCalledTimes(1);
1248
- expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
1249
- expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "set", siteId: WS }]);
1557
+ it("a live spoke checkout lead holds the plan: no plan write, one held line", async () => {
1558
+ const result = await routeLead(checkoutLead, spokeMeta, {
1559
+ idempotencyKey: KEY,
1560
+ });
1561
+ expect(result).toEqual(answered);
1562
+ expect(setPlan).not.toHaveBeenCalled();
1563
+ expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
1564
+ expect(infoPlanLines()).toEqual([
1565
+ { event: "checkout-plan", outcome: "held", siteId: WS },
1566
+ ]);
1567
+ expect(planLines()).toEqual(infoPlanLines());
1250
1568
  });
1251
1569
 
1252
- it("a bare slug offering is taken as it is", async () => {
1570
+ it("a bare slug offering is held as it is", async () => {
1253
1571
  await routeLead(
1254
- { ...checkoutLead, payload: { ...checkoutLead.payload, offering: "retainer" } },
1572
+ {
1573
+ ...checkoutLead,
1574
+ payload: { ...checkoutLead.payload, offering: "retainer" },
1575
+ },
1255
1576
  spokeMeta,
1256
1577
  { idempotencyKey: KEY },
1257
1578
  );
1258
- expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
1579
+ expect(setPlan).not.toHaveBeenCalled();
1580
+ expect(planLines()).toEqual([
1581
+ { event: "checkout-plan", outcome: "held", siteId: WS },
1582
+ ]);
1259
1583
  });
1260
1584
 
1261
1585
  it("a positive integer amountTotal still qualifies", async () => {
1262
1586
  await routeLead(
1263
- { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 4900 } },
1587
+ {
1588
+ ...checkoutLead,
1589
+ payload: { ...checkoutLead.payload, amountTotal: 4900 },
1590
+ },
1264
1591
  spokeMeta,
1265
1592
  { idempotencyKey: KEY },
1266
1593
  );
1267
- expect(setPlan).toHaveBeenCalledTimes(1);
1594
+ expect(setPlan).not.toHaveBeenCalled();
1595
+ expect(planLines()).toEqual([
1596
+ { event: "checkout-plan", outcome: "held", siteId: WS },
1597
+ ]);
1268
1598
  });
1269
1599
 
1270
1600
  it.each([
@@ -1278,16 +1608,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1278
1608
  ],
1279
1609
  [
1280
1610
  "a negative amountTotal",
1281
- { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
1611
+ {
1612
+ ...checkoutLead,
1613
+ payload: { ...checkoutLead.payload, amountTotal: -5 },
1614
+ },
1282
1615
  spokeMeta,
1283
1616
  KEY,
1284
1617
  ],
1285
- ["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
1618
+ [
1619
+ "a key that is not a session id",
1620
+ checkoutLead,
1621
+ spokeMeta,
1622
+ "abcdefghijklmnop",
1623
+ ],
1286
1624
  ["no key", checkoutLead, spokeMeta, undefined],
1287
1625
  ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1288
1626
  [
1289
1627
  "page contact",
1290
- { ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
1628
+ {
1629
+ ...checkoutLead,
1630
+ payload: { ...checkoutLead.payload, page: "contact" },
1631
+ },
1291
1632
  spokeMeta,
1292
1633
  KEY,
1293
1634
  ],
@@ -1313,18 +1654,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1313
1654
  // checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
1314
1655
  // relayed". An unmarked create names no `spokeCheckout` key at all.
1315
1656
  const createData = () =>
1316
- (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
1657
+ (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<
1658
+ string,
1659
+ unknown
1660
+ >;
1317
1661
 
1318
- it("a live spoke checkout lead is created with spokeCheckout true and still sets the plan once", async () => {
1662
+ it("a live spoke checkout lead is created with spokeCheckout true and holds the plan once", async () => {
1319
1663
  await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1320
1664
  expect(createData()).toMatchObject({ spokeCheckout: true });
1321
- expect(setPlan).toHaveBeenCalledTimes(1);
1665
+ expect(setPlan).not.toHaveBeenCalled();
1666
+ expect(planLines()).toHaveLength(1);
1322
1667
  });
1323
1668
 
1324
- it("the same lead relayed for a visitor is created with no spokeCheckout key and still sets the plan", async () => {
1325
- await routeLead(checkoutLead, { ...spokeMeta, relayed: true }, { idempotencyKey: KEY });
1669
+ it("the same lead relayed for a visitor is created with no spokeCheckout key and holds no plan", async () => {
1670
+ await routeLead(
1671
+ checkoutLead,
1672
+ { ...spokeMeta, relayed: true },
1673
+ { idempotencyKey: KEY },
1674
+ );
1326
1675
  expect(createData()).not.toHaveProperty("spokeCheckout");
1327
- expect(setPlan).toHaveBeenCalledTimes(1);
1676
+ expect(setPlan).not.toHaveBeenCalled();
1677
+ expect(planLines()).toEqual([]);
1328
1678
  });
1329
1679
 
1330
1680
  it.each([
@@ -1338,16 +1688,27 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1338
1688
  ],
1339
1689
  [
1340
1690
  "a negative amountTotal",
1341
- { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
1691
+ {
1692
+ ...checkoutLead,
1693
+ payload: { ...checkoutLead.payload, amountTotal: -5 },
1694
+ },
1342
1695
  spokeMeta,
1343
1696
  KEY,
1344
1697
  ],
1345
- ["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
1698
+ [
1699
+ "a key that is not a session id",
1700
+ checkoutLead,
1701
+ spokeMeta,
1702
+ "abcdefghijklmnop",
1703
+ ],
1346
1704
  ["no key", checkoutLead, spokeMeta, undefined],
1347
1705
  ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1348
1706
  [
1349
1707
  "page contact",
1350
- { ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
1708
+ {
1709
+ ...checkoutLead,
1710
+ payload: { ...checkoutLead.payload, page: "contact" },
1711
+ },
1351
1712
  spokeMeta,
1352
1713
  KEY,
1353
1714
  ],
@@ -1357,24 +1718,48 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1357
1718
  spokeMeta,
1358
1719
  KEY,
1359
1720
  ],
1360
- ] as const)("no spokeCheckout key in the create data for %s", async (_label, input, meta, key) => {
1361
- await routeLead(
1362
- input as typeof checkoutLead,
1363
- meta,
1364
- key === undefined ? {} : { idempotencyKey: key },
1365
- );
1366
- expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1367
- expect(createData()).not.toHaveProperty("spokeCheckout");
1368
- });
1721
+ ] as const)(
1722
+ "no spokeCheckout key in the create data for %s",
1723
+ async (_label, input, meta, key) => {
1724
+ await routeLead(
1725
+ input as typeof checkoutLead,
1726
+ meta,
1727
+ key === undefined ? {} : { idempotencyKey: key },
1728
+ );
1729
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1730
+ expect(createData()).not.toHaveProperty("spokeCheckout");
1731
+ },
1732
+ );
1369
1733
 
1370
1734
  it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
1371
- await routeLead(
1372
- { ...checkoutLead, payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" } },
1735
+ const result = await routeLead(
1736
+ {
1737
+ ...checkoutLead,
1738
+ payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" },
1739
+ },
1373
1740
  spokeMeta,
1374
1741
  { idempotencyKey: KEY },
1375
1742
  );
1743
+ expect(result).toEqual(answered);
1376
1744
  expect(setPlan).not.toHaveBeenCalled();
1377
- expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "skipped", siteId: WS }]);
1745
+ expect(infoPlanLines()).toEqual([
1746
+ { event: "checkout-plan", outcome: "skipped", siteId: WS },
1747
+ ]);
1748
+ expect(planLines()).toEqual(infoPlanLines());
1749
+ });
1750
+
1751
+ it("no checkout-plan line names an email, an offering or a key", async () => {
1752
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1753
+ expect(planLines()).toHaveLength(1);
1754
+ for (const line of planLines()) {
1755
+ expect(line).not.toHaveProperty("email");
1756
+ expect(line).not.toHaveProperty("offering");
1757
+ expect(line).not.toHaveProperty("key");
1758
+ }
1759
+ const logged = JSON.stringify(planLines());
1760
+ expect(logged).not.toContain("retainer");
1761
+ expect(logged).not.toContain("cleo@x.io");
1762
+ expect(logged).not.toContain(KEY);
1378
1763
  });
1379
1764
 
1380
1765
  it("a replayed key writes nothing and sets nothing", async () => {
@@ -1384,27 +1769,892 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1384
1769
  source: checkoutLead.source,
1385
1770
  payload: checkoutLead.payload,
1386
1771
  });
1387
- const result = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1772
+ const result = await routeLead(checkoutLead, spokeMeta, {
1773
+ idempotencyKey: KEY,
1774
+ });
1388
1775
  expect(result).toMatchObject({ ok: true, replayed: true, id: LEAD_ID });
1389
1776
  expect(prisma.lead.create).not.toHaveBeenCalled();
1390
1777
  expect(setPlan).not.toHaveBeenCalled();
1391
1778
  });
1779
+ });
1780
+
1781
+ describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-11)", () => {
1782
+ const ENV_KEYS = [
1783
+ "DATABASE_URL",
1784
+ "NODE_ENV",
1785
+ "AILK_LEAD_NOTIFICATION_TO",
1786
+ "AILK_LEAD_NOTIFICATION_FROM",
1787
+ "AILK_DELIVERABLE_EMAIL_FROM",
1788
+ ] as const;
1789
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1790
+ const LEAD_ID = "lead_00000000-0000-7000-8000-000000008757";
1791
+ const WS = "wksp_00000000-0000-7000-8000-000000008757";
1792
+ const KEY = "cs_live_d14Purchase"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8757 AC-11), not a credential
1793
+ const ORIGIN = "https://spoke.example";
1794
+ const spokeMeta = { ...sourceMeta, viaSpoke: true };
1795
+ const checkoutLead = {
1796
+ kind: "submit" as const,
1797
+ siteId: WS,
1798
+ source: "web",
1799
+ email: "Cleo@X.io",
1800
+ payload: {
1801
+ site: "wendi",
1802
+ page: "checkout",
1803
+ offering: "wendi_retainer",
1804
+ amountTotal: 4900,
1805
+ currency: "usd",
1806
+ } as Record<string, unknown>,
1807
+ };
1808
+ // The lead table's count — the per-recipient cap's one read. The shared
1809
+ // prisma double does not carry it, so this suite arranges it.
1810
+ const leadCount = jest.fn();
1811
+ let setPlan: jest.SpyInstance;
1812
+ let info: jest.SpyInstance;
1813
+ let error: jest.SpyInstance;
1814
+
1815
+ /** Let the fired (void) email step run to its end. */
1816
+ const settle = async () => {
1817
+ for (let i = 0; i < 5; i += 1)
1818
+ await new Promise((resolve) => setImmediate(resolve));
1819
+ };
1820
+ const interactionCreates = () =>
1821
+ jest.mocked(prisma.interaction.create).mock.calls;
1822
+ const emailCalls = () => mockSendPurchaseConfirmationEmail.mock.calls;
1823
+
1824
+ beforeEach(() => {
1825
+ jest.clearAllMocks();
1826
+ saved = {};
1827
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
1828
+ process.env.DATABASE_URL = "postgres://stub";
1829
+ delete process.env.NODE_ENV;
1830
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1831
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1832
+ delete process.env.AILK_DELIVERABLE_EMAIL_FROM;
1833
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
1834
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1835
+ Object.assign(prisma.lead, { count: leadCount });
1836
+ leadCount.mockReset().mockResolvedValue(1);
1837
+ jest
1838
+ .mocked(prisma.interaction.create)
1839
+ .mockResolvedValue({ id: "intx_1" } as never);
1840
+ mockSendLeadNotification.mockResolvedValue({
1841
+ ok: true,
1842
+ messageId: "msg-8757",
1843
+ });
1844
+ mockSendPurchaseConfirmationEmail.mockResolvedValue({
1845
+ ok: true,
1846
+ messageId: "msg-buy",
1847
+ });
1848
+ mockListPlanOfferings.mockResolvedValue([
1849
+ { key: "retainer", name: "Retainer", type: "service" },
1850
+ ]);
1851
+ setPlan = jest
1852
+ .spyOn(personService, "setPersonPlanOffering")
1853
+ .mockResolvedValue("set");
1854
+ info = jest
1855
+ .spyOn(logger, "info")
1856
+ .mockImplementation(() => undefined as never);
1857
+ error = jest
1858
+ .spyOn(logger, "error")
1859
+ .mockImplementation(() => undefined as never);
1860
+ });
1861
+
1862
+ afterEach(() => {
1863
+ for (const k of ENV_KEYS) {
1864
+ if (saved[k] === undefined) delete process.env[k];
1865
+ else process.env[k] = saved[k];
1866
+ }
1867
+ Reflect.deleteProperty(prisma.lead, "count");
1868
+ setPlan.mockRestore();
1869
+ info.mockRestore();
1870
+ error.mockRestore();
1871
+ });
1872
+
1873
+ it("the first write sends one purchase email with the spoke's account link and writes one Inbox message", async () => {
1874
+ const result = await routeLead(checkoutLead, spokeMeta, {
1875
+ idempotencyKey: KEY,
1876
+ accountOrigin: ORIGIN,
1877
+ });
1878
+ await settle();
1879
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1880
+
1881
+ expect(emailCalls()).toHaveLength(1);
1882
+ const [email] = emailCalls()[0] as [Record<string, string>];
1883
+ expect(email).toMatchObject({
1884
+ to: "Cleo@X.io",
1885
+ fromAddress: "noreply@example.com",
1886
+ subject: "Your purchase",
1887
+ cta: "Open your account",
1888
+ accountLink: `${ORIGIN}/api/hub-session/sign-in?next=%2Faccount&email=Cleo%40X.io`,
1889
+ });
1890
+ expect(email.body).toContain("Retainer");
1891
+ expect(email.body).toContain("$49.00");
1892
+
1893
+ expect(interactionCreates()).toHaveLength(1);
1894
+ const [{ data }] = interactionCreates()[0] as [
1895
+ { data: Record<string, unknown> },
1896
+ ];
1897
+ expect(data).toEqual({
1898
+ workspaceId: WS,
1899
+ personId: MOCK_PERSON_ID,
1900
+ channel: "member",
1901
+ direction: "outbound",
1902
+ subject: "Your purchase",
1903
+ body: email.body,
1904
+ createdBy: "system:checkout",
1905
+ });
1906
+ expect(mockListPlanOfferings).toHaveBeenCalledWith(WS);
1907
+ });
1908
+
1909
+ it("an offering that is not listed is named 'your purchase' in both, never the raw string", async () => {
1910
+ await routeLead(
1911
+ {
1912
+ ...checkoutLead,
1913
+ payload: { ...checkoutLead.payload, offering: "wendi_<b>Mystery</b>" },
1914
+ },
1915
+ spokeMeta,
1916
+ { idempotencyKey: KEY, accountOrigin: ORIGIN },
1917
+ );
1918
+ await settle();
1919
+ const [email] = emailCalls()[0] as [Record<string, string>];
1920
+ const [{ data }] = interactionCreates()[0] as [
1921
+ { data: Record<string, string> },
1922
+ ];
1923
+ for (const body of [email.body, data.body]) {
1924
+ expect(body).toContain("your purchase");
1925
+ expect(body).not.toContain("Mystery");
1926
+ expect(body).not.toContain("wendi_");
1927
+ }
1928
+ });
1929
+
1930
+ it.each([
1931
+ [
1932
+ "the lister fails",
1933
+ () => mockListPlanOfferings.mockResolvedValueOnce({ failed: "Error" }),
1934
+ ],
1935
+ [
1936
+ "the lister throws",
1937
+ () => mockListPlanOfferings.mockRejectedValueOnce(new Error("down")),
1938
+ ],
1939
+ ])(
1940
+ "when %s, the offering is 'your purchase' and both still go out",
1941
+ async (_label, arrange) => {
1942
+ arrange();
1943
+ await routeLead(checkoutLead, spokeMeta, {
1944
+ idempotencyKey: KEY,
1945
+ accountOrigin: ORIGIN,
1946
+ });
1947
+ await settle();
1948
+ expect(emailCalls()).toHaveLength(1);
1949
+ expect(interactionCreates()).toHaveLength(1);
1950
+ const [{ data }] = interactionCreates()[0] as [
1951
+ { data: Record<string, string> },
1952
+ ];
1953
+ expect(data.body).toContain("your purchase");
1954
+ },
1955
+ );
1956
+
1957
+ it("a listed name holding a bidi override comes out with it stripped", async () => {
1958
+ mockListPlanOfferings.mockResolvedValue([
1959
+ { key: "retainer", name: "Re\u202Etainer\u0007", type: "service" },
1960
+ ]);
1961
+ await routeLead(checkoutLead, spokeMeta, {
1962
+ idempotencyKey: KEY,
1963
+ accountOrigin: ORIGIN,
1964
+ });
1965
+ await settle();
1966
+ const [email] = emailCalls()[0] as [Record<string, string>];
1967
+ const [{ data }] = interactionCreates()[0] as [
1968
+ { data: Record<string, string> },
1969
+ ];
1970
+ for (const body of [email.body, data.body]) {
1971
+ expect(body).toContain("Retainer");
1972
+
1973
+ // The two code points by name, not in a regex: a scaffold's standalone
1974
+ // lint refuses a control character in a regular expression.
1975
+ expect(body).not.toContain("\u202E");
1976
+ expect(body).not.toContain("\u0007");
1977
+ }
1978
+ });
1979
+
1980
+ it("with no amount, the body names the offering alone", async () => {
1981
+ const { amountTotal: _a, currency: _c, ...payload } = checkoutLead.payload;
1982
+ await routeLead({ ...checkoutLead, payload }, spokeMeta, {
1983
+ idempotencyKey: KEY,
1984
+ accountOrigin: ORIGIN,
1985
+ });
1986
+ await settle();
1987
+ const [{ data }] = interactionCreates()[0] as [
1988
+ { data: Record<string, string> },
1989
+ ];
1990
+ expect(data.body).toBe("Thanks for your purchase of Retainer.");
1991
+ });
1992
+
1993
+ it("a Stripe special-case currency (ISK) leaves the amount out rather than misstate it", async () => {
1994
+ await routeLead(
1995
+ {
1996
+ ...checkoutLead,
1997
+ payload: {
1998
+ ...checkoutLead.payload,
1999
+ amountTotal: 990000,
2000
+ currency: "isk",
2001
+ },
2002
+ },
2003
+ spokeMeta,
2004
+ { idempotencyKey: KEY, accountOrigin: ORIGIN },
2005
+ );
2006
+ await settle();
2007
+ const [{ data }] = interactionCreates()[0] as [
2008
+ { data: Record<string, string> },
2009
+ ];
2010
+ expect(data.body).toBe("Thanks for your purchase of Retainer.");
2011
+ });
2012
+
2013
+ it("an offering read that outlasts its budget names 'your purchase' and does not hold the lead", async () => {
2014
+ mockListPlanOfferings.mockReturnValue(new Promise(() => undefined));
2015
+ const started = Date.now();
2016
+ const result = await routeLead(checkoutLead, spokeMeta, {
2017
+ idempotencyKey: KEY,
2018
+ accountOrigin: ORIGIN,
2019
+ });
2020
+ expect(Date.now() - started).toBeLessThan(4000);
2021
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2022
+ const [{ data }] = interactionCreates()[0] as [
2023
+ { data: Record<string, string> },
2024
+ ];
2025
+ expect(data.body).toContain("your purchase");
2026
+ }, 10_000);
2027
+
2028
+ it("with no account origin, the email carries no link", async () => {
2029
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2030
+ await settle();
2031
+ expect(emailCalls()).toHaveLength(1);
2032
+ expect(emailCalls()[0]?.[0]).not.toHaveProperty("accountLink");
2033
+ expect(interactionCreates()).toHaveLength(1);
2034
+ });
2035
+
2036
+ it("a replay of the same Idempotency-Key makes neither", async () => {
2037
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2038
+ id: LEAD_ID,
2039
+ email: checkoutLead.email,
2040
+ source: checkoutLead.source,
2041
+ payload: checkoutLead.payload,
2042
+ });
2043
+ const result = await routeLead(checkoutLead, spokeMeta, {
2044
+ idempotencyKey: KEY,
2045
+ accountOrigin: ORIGIN,
2046
+ });
2047
+ await settle();
2048
+ expect(result).toMatchObject({ ok: true, replayed: true });
2049
+ expect(emailCalls()).toHaveLength(0);
2050
+ expect(interactionCreates()).toHaveLength(0);
2051
+ });
2052
+
2053
+ it.each([
2054
+ ["a test-mode checkout lead", spokeMeta, "cs_test_d14Purchase"],
2055
+ ["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
2056
+ ["an anonymous (non-spoke) lead", sourceMeta, KEY],
2057
+ ] as const)("%s makes neither", async (_label, meta, key) => {
2058
+ const result = await routeLead(checkoutLead, meta, {
2059
+ idempotencyKey: key,
2060
+ accountOrigin: ORIGIN,
2061
+ });
2062
+ await settle();
2063
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
2064
+ expect(emailCalls()).toHaveLength(0);
2065
+ expect(interactionCreates()).toHaveLength(0);
2066
+ });
2067
+
2068
+ it("a lead with no Person makes neither", async () => {
2069
+ const find = jest
2070
+ .spyOn(personService, "findOrCreatePerson")
2071
+ .mockResolvedValue({
2072
+ ok: false,
2073
+ error: { kind: "invalid_email" },
2074
+ } as never);
2075
+ try {
2076
+ const result = await routeLead(checkoutLead, spokeMeta, {
2077
+ idempotencyKey: KEY,
2078
+ accountOrigin: ORIGIN,
2079
+ });
2080
+ await settle();
2081
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
2082
+ expect(emailCalls()).toHaveLength(0);
2083
+ expect(interactionCreates()).toHaveLength(0);
2084
+ } finally {
2085
+ find.mockRestore();
2086
+ }
2087
+ });
2088
+
2089
+ it("with five marked leads for the address in the last day, the sixth sends no email and still writes the message", async () => {
2090
+ const now = Date.now();
2091
+ leadCount.mockResolvedValue(6); // the five before, and this one
2092
+ await routeLead(checkoutLead, spokeMeta, {
2093
+ idempotencyKey: KEY,
2094
+ accountOrigin: ORIGIN,
2095
+ });
2096
+ await settle();
2097
+ expect(emailCalls()).toHaveLength(0);
2098
+ expect(interactionCreates()).toHaveLength(1);
2099
+ const [{ where }] = leadCount.mock.calls[0] as [
2100
+ { where: Record<string, unknown> },
2101
+ ];
2102
+ expect(where).toMatchObject({
2103
+ workspaceId: WS,
2104
+ spokeCheckout: true,
2105
+ email: { equals: "Cleo@X.io", mode: "insensitive" },
2106
+ });
2107
+ const since = (where.createdAt as { gte: Date }).gte.getTime();
2108
+ expect(now - since).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000 - 1000);
2109
+ expect(now - since).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 5000);
2110
+ });
2111
+
2112
+ it("with more than 50 marked leads in the workspace in the last hour, no email (any address) and the message is still written", async () => {
2113
+ const now = Date.now();
2114
+ // The recipient count first, then the workspace's hourly count.
2115
+ leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(51);
2116
+ await routeLead(checkoutLead, spokeMeta, {
2117
+ idempotencyKey: KEY,
2118
+ accountOrigin: ORIGIN,
2119
+ });
2120
+ await settle();
2121
+ expect(emailCalls()).toHaveLength(0);
2122
+ expect(interactionCreates()).toHaveLength(1);
2123
+ const [{ where }] = leadCount.mock.calls[1] as [
2124
+ { where: Record<string, unknown> },
2125
+ ];
2126
+ expect(where).toMatchObject({ workspaceId: WS, spokeCheckout: true });
2127
+ expect(where).not.toHaveProperty("email");
2128
+ const since = (where.createdAt as { gte: Date }).gte.getTime();
2129
+ expect(now - since).toBeGreaterThanOrEqual(60 * 60 * 1000 - 1000);
2130
+ expect(now - since).toBeLessThanOrEqual(60 * 60 * 1000 + 5000);
2131
+ });
2132
+
2133
+ it("with exactly 50 in the hour, the email still goes", async () => {
2134
+ leadCount.mockResolvedValueOnce(1).mockResolvedValueOnce(50);
2135
+ await routeLead(checkoutLead, spokeMeta, {
2136
+ idempotencyKey: KEY,
2137
+ accountOrigin: ORIGIN,
2138
+ });
2139
+ await settle();
2140
+ expect(emailCalls()).toHaveLength(1);
2141
+ });
2142
+
2143
+ it("with five in the day including this one, the email still goes", async () => {
2144
+ leadCount.mockResolvedValue(5);
2145
+ await routeLead(checkoutLead, spokeMeta, {
2146
+ idempotencyKey: KEY,
2147
+ accountOrigin: ORIGIN,
2148
+ });
2149
+ await settle();
2150
+ expect(emailCalls()).toHaveLength(1);
2151
+ });
2152
+
2153
+ it.each([
2154
+ [
2155
+ "the Inbox write throws",
2156
+ () =>
2157
+ jest
2158
+ .mocked(prisma.interaction.create)
2159
+ .mockRejectedValueOnce(new Error("down")),
2160
+ ],
2161
+ [
2162
+ "the cap read throws",
2163
+ () => leadCount.mockRejectedValueOnce(new Error("down")),
2164
+ ],
2165
+ [
2166
+ "the email send rejects",
2167
+ () =>
2168
+ mockSendPurchaseConfirmationEmail.mockRejectedValueOnce(
2169
+ new Error("down"),
2170
+ ),
2171
+ ],
2172
+ ])(
2173
+ "a purchase step that fails (%s) never changes the lead's answer",
2174
+ async (_label, arrange) => {
2175
+ arrange();
2176
+ const result = await routeLead(checkoutLead, spokeMeta, {
2177
+ idempotencyKey: KEY,
2178
+ accountOrigin: ORIGIN,
2179
+ });
2180
+ await settle();
2181
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2182
+ expect(setPlan).not.toHaveBeenCalled();
2183
+ const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
2184
+ expect(logged).not.toContain("Cleo@X.io");
2185
+ expect(logged).not.toContain("retainer");
2186
+ },
2187
+ );
2188
+
2189
+ it("with no sender configured, no email is sent and the message is still written", async () => {
2190
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
2191
+ await routeLead(checkoutLead, spokeMeta, {
2192
+ idempotencyKey: KEY,
2193
+ accountOrigin: ORIGIN,
2194
+ });
2195
+ await settle();
2196
+ expect(emailCalls()).toHaveLength(0);
2197
+ expect(interactionCreates()).toHaveLength(1);
2198
+ });
2199
+ });
2200
+
2201
+ describe("#8778 — a spoke checkout's terms grant", () => {
2202
+ const ENV_KEYS = [
2203
+ "DATABASE_URL",
2204
+ "NODE_ENV",
2205
+ "AILK_LEAD_NOTIFICATION_TO",
2206
+ "AILK_LEAD_NOTIFICATION_FROM",
2207
+ ] as const;
2208
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
2209
+ const LEAD_ID = "lead_00000000-0000-7000-8000-000000008778";
2210
+ const WS = "wksp_00000000-0000-7000-8000-000000008778";
2211
+ const KEY = "cs_live_g8778Grant"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8778), not a credential
2212
+ const ACCOUNT_TERMS = consentPurposeById(
2213
+ SITE_CONSENT_CONFIG_DEFAULTS,
2214
+ ACCOUNT_TERMS_PURPOSE_ID,
2215
+ )!;
2216
+ const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
2217
+ const spokeMeta = { ...sourceMeta, viaSpoke: true };
2218
+ const checkoutLead = {
2219
+ kind: "submit" as const,
2220
+ siteId: WS,
2221
+ source: "web",
2222
+ email: "Cleo@X.io",
2223
+ payload: {
2224
+ site: "wendi",
2225
+ page: "checkout",
2226
+ offering: "wendi_retainer",
2227
+ amountTotal: 4900,
2228
+ currency: "usd",
2229
+ checkoutSession: KEY,
2230
+ termsAccepted: STAMP,
2231
+ } as Record<string, unknown>,
2232
+ };
2233
+ const leadCount = jest.fn();
2234
+ let double: ConsentLedgerDouble;
2235
+ let setPlan: jest.SpyInstance;
2236
+ let info: jest.SpyInstance;
2237
+ let error: jest.SpyInstance;
2238
+
2239
+ /** The ledger double, plus the per-session replay read it does not model. */
2240
+ function armLedger(): void {
2241
+ double = armConsentLedger(prisma);
2242
+ const ledgerRead = (prisma.$queryRaw as jest.Mock).getMockImplementation()!;
2243
+ (prisma.$queryRaw as jest.Mock).mockImplementation(
2244
+ (strings: TemplateStringsArray, ...values: unknown[]) => {
2245
+ if (strings.join("?").includes(`"source" = 'checkout'`)) {
2246
+ const [workspaceId, personId, purposeId, sessionId] =
2247
+ values as string[];
2248
+ return Promise.resolve(
2249
+ double.ledger
2250
+ .filter(
2251
+ (r) =>
2252
+ r.workspaceId === workspaceId &&
2253
+ r.personId === personId &&
2254
+ r.purposeId === purposeId &&
2255
+ r.source === "checkout" &&
2256
+ r.sourceRef === sessionId,
2257
+ )
2258
+ .map(() => ({ found: 1 })),
2259
+ );
2260
+ }
2261
+ return ledgerRead(strings, ...values);
2262
+ },
2263
+ );
2264
+ }
2265
+
2266
+ const consentLines = () =>
2267
+ [...info.mock.calls, ...error.mock.calls]
2268
+ .map(([obj]) => obj as Record<string, unknown>)
2269
+ .filter((obj) => obj?.["event"] === "checkout-consent");
2270
+
2271
+ const settle = async () => {
2272
+ for (let i = 0; i < 5; i += 1)
2273
+ await new Promise((resolve) => setImmediate(resolve));
2274
+ };
2275
+
2276
+ beforeEach(() => {
2277
+ jest.clearAllMocks();
2278
+ saved = {};
2279
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
2280
+ process.env.DATABASE_URL = "postgres://stub";
2281
+ delete process.env.NODE_ENV;
2282
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
2283
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
2284
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
2285
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
2286
+ Object.assign(prisma.lead, { count: leadCount });
2287
+ leadCount.mockReset().mockResolvedValue(1);
2288
+ jest
2289
+ .mocked(prisma.interaction.create)
2290
+ .mockResolvedValue({ id: "intx_1" } as never);
2291
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
2292
+ mockSendPurchaseConfirmationEmail.mockResolvedValue({
2293
+ ok: true,
2294
+ messageId: "m2",
2295
+ });
2296
+ mockListPlanOfferings.mockResolvedValue([]);
2297
+ setPlan = jest
2298
+ .spyOn(personService, "setPersonPlanOffering")
2299
+ .mockResolvedValue("set");
2300
+ info = jest
2301
+ .spyOn(logger, "info")
2302
+ .mockImplementation(() => undefined as never);
2303
+ error = jest
2304
+ .spyOn(logger, "error")
2305
+ .mockImplementation(() => undefined as never);
2306
+ armLedger();
2307
+ });
2308
+
2309
+ afterEach(() => {
2310
+ for (const k of ENV_KEYS) {
2311
+ if (saved[k] === undefined) delete process.env[k];
2312
+ else process.env[k] = saved[k];
2313
+ }
2314
+ Reflect.deleteProperty(prisma.lead, "count");
2315
+ for (const fn of [
2316
+ prisma.$transaction,
2317
+ prisma.$executeRaw,
2318
+ prisma.$queryRaw,
2319
+ prisma.consentGrant.createMany,
2320
+ prisma.consentVisitorLink.createMany,
2321
+ ]) {
2322
+ (fn as jest.Mock).mockReset();
2323
+ }
2324
+ setPlan.mockRestore();
2325
+ info.mockRestore();
2326
+ error.mockRestore();
2327
+ });
2328
+
2329
+ it("a lead whose session accepted the current terms writes one dated checkout grant for the buyer's Person", async () => {
2330
+ const result = await routeLead(checkoutLead, spokeMeta, {
2331
+ idempotencyKey: KEY,
2332
+ });
2333
+ await settle();
2334
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2335
+
2336
+ expect(double.ledger).toHaveLength(1);
2337
+ const [row] = double.ledger;
2338
+ expect(row).toMatchObject({
2339
+ workspaceId: WS,
2340
+ subjectType: "person",
2341
+ subjectId: MOCK_PERSON_ID,
2342
+ personId: MOCK_PERSON_ID,
2343
+ purposeId: ACCOUNT_TERMS_PURPOSE_ID,
2344
+ granted: true,
2345
+ source: "checkout",
2346
+ sourceRef: KEY,
2347
+ policyVersion: ACCOUNT_TERMS.policyVersion,
2348
+ locale: "en",
2349
+ });
2350
+ expect(row!.createdAt).toBeInstanceOf(Date);
2351
+ expect(consentLines()).toEqual([
2352
+ expect.objectContaining({ outcome: "recorded", siteId: WS }),
2353
+ ]);
2354
+ // #8793 D1 — the grant is written; the plan is held, never set here.
2355
+ expect(setPlan).not.toHaveBeenCalled();
2356
+ });
1392
2357
 
1393
2358
  it.each([
1394
- ["throws", () => setPlan.mockRejectedValueOnce(new Error("down"))],
1395
- ["answers not_found", () => setPlan.mockResolvedValueOnce("not_found")],
1396
- ])("a plan step that %s answers exactly like a success, with one failed line naming no offering, email or key", async (_label, arrange) => {
1397
- const ok = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1398
- info.mockClear();
1399
- error.mockClear();
1400
- arrange();
1401
- const failed = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1402
- expect(failed).toEqual(ok);
1403
- expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1404
- expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "failed", siteId: WS }]);
2359
+ ["termsAccepted: null (the box was not accepted)", null, "consent_absent"],
2360
+ ["no termsAccepted key (an older spoke)", undefined, "consent_absent"],
2361
+ ["an older version's stamp", `${ACCOUNT_TERMS_PURPOSE_ID}@1`, "terms_unstamped"],
2362
+ ["another purpose's stamp", "wt_platform_terms@1", "terms_unstamped"],
2363
+ ["a non-string value", true, "terms_unstamped"],
2364
+ ])("a lead with %s writes no grant", async (_label, terms, outcome) => {
2365
+ const { termsAccepted: _t, ...rest } = checkoutLead.payload;
2366
+ const payload = terms === undefined ? rest : { ...rest, termsAccepted: terms };
2367
+ const result = await routeLead({ ...checkoutLead, payload }, spokeMeta, {
2368
+ idempotencyKey: KEY,
2369
+ });
2370
+ await settle();
2371
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2372
+ expect(double.ledger).toHaveLength(0);
2373
+ expect(consentLines()).toEqual([expect.objectContaining({ outcome })]);
2374
+ });
2375
+
2376
+ it("a replayed spoke webhook (the same Idempotency-Key and body) writes no second grant", async () => {
2377
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2378
+ expect(double.ledger).toHaveLength(1);
2379
+
2380
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2381
+ id: LEAD_ID,
2382
+ email: checkoutLead.email,
2383
+ source: checkoutLead.source,
2384
+ payload: checkoutLead.payload,
2385
+ });
2386
+ const replay = await routeLead(checkoutLead, spokeMeta, {
2387
+ idempotencyKey: KEY,
2388
+ });
2389
+ await settle();
2390
+ expect(replay).toMatchObject({ ok: true, replayed: true });
2391
+ expect(double.ledger).toHaveLength(1);
2392
+ });
2393
+
2394
+ it("a redelivery whose stored body predates termsAccepted is a replay, not a conflict, and writes no grant", async () => {
2395
+ const { termsAccepted: _t, ...storedPayload } = checkoutLead.payload;
2396
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2397
+ id: LEAD_ID,
2398
+ email: checkoutLead.email,
2399
+ source: checkoutLead.source,
2400
+ payload: storedPayload,
2401
+ });
2402
+ const result = await routeLead(checkoutLead, spokeMeta, {
2403
+ idempotencyKey: KEY,
2404
+ });
2405
+ await settle();
2406
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, replayed: true });
2407
+ expect(double.ledger).toHaveLength(0);
2408
+ });
2409
+
2410
+ it("a redelivery that differs in any other payload key is still a conflict", async () => {
2411
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
2412
+ id: LEAD_ID,
2413
+ email: checkoutLead.email,
2414
+ source: checkoutLead.source,
2415
+ payload: { ...checkoutLead.payload, amountTotal: 1 },
2416
+ });
2417
+ const result = await routeLead(checkoutLead, spokeMeta, {
2418
+ idempotencyKey: KEY,
2419
+ });
2420
+ expect(result).toMatchObject({
2421
+ ok: false,
2422
+ error: { kind: "idempotency_conflict" },
2423
+ });
2424
+ });
2425
+
2426
+ it("a second write for a session whose grant the Person already holds writes none (the store's own guard)", async () => {
2427
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2428
+ // The lead row lost (the lookup misses), the ledger kept: the grant is
2429
+ // still written at most once per Person and session.
2430
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2431
+ await settle();
2432
+ expect(double.ledger).toHaveLength(1);
2433
+ expect(consentLines().map((l) => l["outcome"])).toEqual([
2434
+ "recorded",
2435
+ "unchanged",
2436
+ ]);
2437
+ });
2438
+
2439
+ it.each([
2440
+ ["a test-mode checkout lead", spokeMeta, "cs_test_g8778Grant"],
2441
+ ["a relayed checkout lead", { ...spokeMeta, relayed: true }, KEY],
2442
+ ["an anonymous (non-spoke) lead", sourceMeta, KEY],
2443
+ ] as const)("%s writes no grant", async (_label, meta, key) => {
2444
+ const result = await routeLead(checkoutLead, meta, { idempotencyKey: key });
2445
+ await settle();
2446
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
2447
+ expect(double.ledger).toHaveLength(0);
2448
+ expect(consentLines()).toEqual([]);
2449
+ });
2450
+
2451
+ it("a lead with no Person writes no grant", async () => {
2452
+ const find = jest
2453
+ .spyOn(personService, "findOrCreatePerson")
2454
+ .mockResolvedValue({
2455
+ ok: false,
2456
+ error: { kind: "invalid_email" },
2457
+ } as never);
2458
+ try {
2459
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
2460
+ await settle();
2461
+ expect(double.ledger).toHaveLength(0);
2462
+ } finally {
2463
+ find.mockRestore();
2464
+ }
2465
+ });
2466
+
2467
+ it("a ledger write that fails never changes the lead's answer, and logs no address or Person id", async () => {
2468
+ (prisma.consentGrant.createMany as jest.Mock).mockRejectedValueOnce(
2469
+ new Error("down Cleo@X.io"),
2470
+ );
2471
+ const result = await routeLead(checkoutLead, spokeMeta, {
2472
+ idempotencyKey: KEY,
2473
+ });
2474
+ await settle();
2475
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2476
+ expect(consentLines()).toEqual([
2477
+ expect.objectContaining({ outcome: "failed", errName: "Error" }),
2478
+ ]);
1405
2479
  const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
1406
- expect(logged).not.toContain("retainer");
1407
- expect(logged).not.toContain("cleo@x.io");
1408
- expect(logged).not.toContain(KEY);
2480
+ expect(logged).not.toContain("Cleo@X.io");
2481
+ expect(logged).not.toContain(MOCK_PERSON_ID);
2482
+ });
2483
+ });
2484
+
2485
+ describe("#8932 D7 — the per-Organization webhook's Offerings checkout lead (AC-5)", () => {
2486
+ const ENV_KEYS = [
2487
+ "DATABASE_URL",
2488
+ "NODE_ENV",
2489
+ "AILK_LEAD_NOTIFICATION_TO",
2490
+ "AILK_LEAD_NOTIFICATION_FROM",
2491
+ ] as const;
2492
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
2493
+ const LEAD_ID = "lead_00000000-0000-7000-8000-000000008932";
2494
+ const WS = "wksp_00000000-0000-7000-8000-000000008932";
2495
+ const OTHER_WS = "wksp_00000000-0000-7000-8000-000000008933";
2496
+ const SESSION = "cs_live_a8932Offerings"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8932 AC-5), not a credential
2497
+ const KEY = `stripe-offerings ${SESSION}`;
2498
+ const ACCOUNT_TERMS = consentPurposeById(
2499
+ SITE_CONSENT_CONFIG_DEFAULTS,
2500
+ ACCOUNT_TERMS_PURPOSE_ID,
2501
+ )!;
2502
+ const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
2503
+ /** The input the webhook builds (D6) — the offering is the record's full key. */
2504
+ const offeringsLead = {
2505
+ kind: "submit" as const,
2506
+ siteId: WS,
2507
+ source: "web",
2508
+ email: "buyer@example.com",
2509
+ payload: {
2510
+ page: "checkout",
2511
+ offering: "growth_monthly",
2512
+ site: WS,
2513
+ amountTotal: 4900,
2514
+ currency: "usd",
2515
+ termsAccepted: STAMP,
2516
+ } as Record<string, unknown>,
2517
+ };
2518
+ const webhookMeta = {
2519
+ ip: "203.0.113.9",
2520
+ userAgent: "Stripe/1.0",
2521
+ receivedAt: "2026-10-18T00:00:00.000Z",
2522
+ };
2523
+ const twin = { idempotencyKey: KEY, offeringsCheckout: true as const };
2524
+ const leadCount = jest.fn();
2525
+ let setPlan: jest.SpyInstance;
2526
+ let consent: jest.SpyInstance;
2527
+ let info: jest.SpyInstance;
2528
+ let error: jest.SpyInstance;
2529
+
2530
+ beforeEach(() => {
2531
+ jest.clearAllMocks();
2532
+ saved = {};
2533
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
2534
+ process.env.DATABASE_URL = "postgres://stub";
2535
+ delete process.env.NODE_ENV;
2536
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
2537
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
2538
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
2539
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
2540
+ Object.assign(prisma.lead, { count: leadCount });
2541
+ leadCount.mockReset().mockResolvedValue(1);
2542
+ jest
2543
+ .mocked(prisma.interaction.create)
2544
+ .mockResolvedValue({ id: "intx_1" } as never);
2545
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
2546
+ mockSendPurchaseConfirmationEmail.mockResolvedValue({ ok: true, messageId: "m2" });
2547
+ mockListPlanOfferings.mockResolvedValue([]);
2548
+ setPlan = jest.spyOn(personService, "setPersonPlanOffering").mockResolvedValue("set");
2549
+ consent = jest
2550
+ .spyOn(consentService, "recordCheckoutConsent")
2551
+ .mockResolvedValue("recorded");
2552
+ info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
2553
+ error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
2554
+ });
2555
+
2556
+ afterEach(() => {
2557
+ for (const k of ENV_KEYS) {
2558
+ if (saved[k] === undefined) delete process.env[k];
2559
+ else process.env[k] = saved[k];
2560
+ }
2561
+ Reflect.deleteProperty(prisma.lead, "count");
2562
+ setPlan.mockRestore();
2563
+ consent.mockRestore();
2564
+ info.mockRestore();
2565
+ error.mockRestore();
2566
+ });
2567
+
2568
+ const createData = () =>
2569
+ (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
2570
+ const planLines = () =>
2571
+ [...info.mock.calls, ...error.mock.calls]
2572
+ .map(([fields]) => fields as Record<string, unknown>)
2573
+ .filter((fields) => fields?.["event"] === "checkout-plan");
2574
+ const planWrites = () =>
2575
+ jest
2576
+ .mocked(prisma.person.updateMany)
2577
+ .mock.calls.filter(([args]) => "planOffering" in ((args as { data?: object }).data ?? {}));
2578
+
2579
+ it("creates one marked lead on the prefixed key, holds the full key and grants consent on the bare session id", async () => {
2580
+ const result = await routeLead(offeringsLead, webhookMeta, twin);
2581
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2582
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
2583
+ expect(createData()).toMatchObject({
2584
+ spokeCheckout: true,
2585
+ idempotencyKey: KEY,
2586
+ payload: expect.objectContaining({ offering: "growth_monthly" }),
2587
+ });
2588
+ expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "held", siteId: WS }]);
2589
+ expect(consent).toHaveBeenCalledTimes(1);
2590
+ expect(consent).toHaveBeenCalledWith(
2591
+ expect.objectContaining({ workspaceId: WS, sessionId: SESSION }),
2592
+ );
2593
+ expect(setPlan).not.toHaveBeenCalled();
2594
+ expect(planWrites()).toEqual([]);
2595
+ });
2596
+
2597
+ it.each([
2598
+ ["without offeringsCheckout", offeringsLead, { idempotencyKey: KEY }],
2599
+ [
2600
+ "an offering the grammar refuses",
2601
+ { ...offeringsLead, payload: { ...offeringsLead.payload, offering: "growth_quarterly" } },
2602
+ twin,
2603
+ ],
2604
+ [
2605
+ "a site that is another workspace",
2606
+ { ...offeringsLead, payload: { ...offeringsLead.payload, site: OTHER_WS } },
2607
+ twin,
2608
+ ],
2609
+ ["a test-mode session key", offeringsLead, { ...twin, idempotencyKey: "stripe-offerings cs_test_a1" }],
2610
+ ["the bare session id as the key", offeringsLead, { ...twin, idempotencyKey: SESSION }],
2611
+ ["source contact", { ...offeringsLead, source: "contact" }, twin],
2612
+ [
2613
+ "page contact",
2614
+ { ...offeringsLead, payload: { ...offeringsLead.payload, page: "contact" } },
2615
+ twin,
2616
+ ],
2617
+ ] as const)("creates an unmarked lead and holds nothing for %s", async (_label, input, options) => {
2618
+ const result = await routeLead(input as typeof offeringsLead, webhookMeta, options);
2619
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
2620
+ expect(createData()).not.toHaveProperty("spokeCheckout");
2621
+ expect(planLines()).toEqual([]);
2622
+ expect(consent).not.toHaveBeenCalled();
2623
+ expect(prisma.interaction.create).not.toHaveBeenCalled();
2624
+ expect(planWrites()).toEqual([]);
2625
+ });
2626
+
2627
+ it("leaves a spoke checkout lead marked exactly as before", async () => {
2628
+ await routeLead(
2629
+ {
2630
+ kind: "submit",
2631
+ siteId: WS,
2632
+ source: "web",
2633
+ email: "cleo@x.io",
2634
+ payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" },
2635
+ },
2636
+ { ...webhookMeta, viaSpoke: true },
2637
+ { idempotencyKey: "cs_live_a1B2c3" }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
2638
+ );
2639
+ expect(createData()).toMatchObject({ spokeCheckout: true });
2640
+ expect(consent).not.toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION }));
2641
+ });
2642
+
2643
+ it("the verified first link releases the full lookup key into the Person's plan", async () => {
2644
+ setPlan.mockRestore();
2645
+ jest.mocked(prisma.lead.findMany).mockResolvedValue([
2646
+ { email: offeringsLead.email, payload: offeringsLead.payload },
2647
+ ] as never);
2648
+ jest.mocked(prisma.person.updateMany).mockResolvedValue({ count: 1 } as never);
2649
+ const outcome = await personService.releaseHeldCheckoutPlan(
2650
+ WS,
2651
+ MOCK_PERSON_ID,
2652
+ "usr_buyer_8932",
2653
+ "buyer@example.com",
2654
+ );
2655
+ expect(outcome).toBe("set");
2656
+ expect(prisma.person.updateMany).toHaveBeenCalledWith(
2657
+ expect.objectContaining({ data: { planOffering: "growth_monthly" } }),
2658
+ );
1409
2659
  });
1410
2660
  });