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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The Stripe-backed Offerings catalog source (#8922, spec
3
+ * docs/specs/8922-the-may-sell-product-list-spec.md D2; contract
4
+ * docs/specs/8879-the-offerings-security-contract-key-spec.md C8, T1, T7) —
5
+ * the `OfferingsSourceFor` the routes pass to the read services: one
6
+ * Organization's active Products and Prices, read through its own
7
+ * connection's client (#8907 D12) and paged to the normalizer's caps.
8
+ *
9
+ * `stripeCatalogSourceFor` does no work when called, so it can never throw
10
+ * outside the services' failure memo (#8876 D8). It takes a bare
11
+ * Organization id and checks neither the tenant nor the claim — its callers
12
+ * do — so `architecture.yaml` confines it to the exact files that call it.
13
+ *
14
+ * BOUNDED (SEC-8922-3). Each list is paged by calls, not items: at most
15
+ * `OFFERINGS_CATALOG_PRODUCTS_MAX / 100` product calls and
16
+ * `OFFERINGS_CATALOG_PRICES_MAX / 100` price calls, stopping early on
17
+ * `has_more: false`, an empty page, a page whose last id repeats the cursor
18
+ * it was asked with, or a full count. The whole read is bounded by
19
+ * `OFFERINGS_CATALOG_READ_TIMEOUT_MS`; after it fails, no further call is
20
+ * started.
21
+ *
22
+ * A list cut off at its cap while Stripe still had more is logged as
23
+ * `catalog_truncated` (SEC-8922-7), with the Organization and nothing else.
24
+ *
25
+ * Nothing is filtered or cached here: the client lives only inside
26
+ * one read (C8), and every field is normalized once by `normalizeCatalog`
27
+ * (T1). A disconnected or unusable connection rejects, never reading as an
28
+ * empty catalog; a Stripe error rejects as itself, and the services turn it
29
+ * into `OfferingsSourceError` without its text.
30
+ */
31
+ import { logger } from "@working-theory/observability";
32
+
33
+ import {
34
+ OFFERINGS_CATALOG_PRICES_MAX,
35
+ OFFERINGS_CATALOG_PRODUCTS_MAX,
36
+ type OfferingsCatalog,
37
+ type OfferingsCatalogSource,
38
+ } from "./catalog.js";
39
+ import { stripeClientForOrganization } from "./connection.js";
40
+
41
+ import type { OfferingsSourceFor } from "./index.js";
42
+
43
+ /** The longest one catalog read may take, connection lookup included. */
44
+ export const OFFERINGS_CATALOG_READ_TIMEOUT_MS = 20_000;
45
+
46
+ const PAGE_LIMIT = 100;
47
+
48
+ /** No usable connection, or the read timed out. The message is fixed; there is no cause. */
49
+ export class OfferingsCatalogUnavailableError extends Error {
50
+ constructor() {
51
+ super("The Offerings catalog source is unavailable");
52
+ this.name = "OfferingsCatalogUnavailableError";
53
+ }
54
+ }
55
+
56
+ interface ListParams {
57
+ active: true;
58
+ limit: number;
59
+ starting_after?: string;
60
+ }
61
+
62
+ interface ListPage<T> {
63
+ data: T[];
64
+ has_more: boolean;
65
+ }
66
+
67
+ /**
68
+ * One list paged to `maxItems`, by at most `maxItems / PAGE_LIMIT` calls.
69
+ * `truncated` when the bound stopped it while Stripe still had more.
70
+ */
71
+ async function pageAll<T extends { id?: unknown }>(
72
+ list: (params: ListParams) => PromiseLike<ListPage<T>>,
73
+ maxItems: number,
74
+ aborted: () => boolean,
75
+ ): Promise<{ items: T[]; truncated: boolean }> {
76
+ const items: T[] = [];
77
+ let after: string | undefined;
78
+ for (let call = 0; call < maxItems / PAGE_LIMIT && items.length < maxItems; call++) {
79
+ if (aborted()) throw new OfferingsCatalogUnavailableError();
80
+ const page = await list({
81
+ active: true,
82
+ limit: PAGE_LIMIT,
83
+ ...(after !== undefined && { starting_after: after }),
84
+ });
85
+ items.push(...page.data);
86
+ const last = page.data.at(-1)?.id;
87
+ if (!page.has_more || typeof last !== "string" || last === after) return { items, truncated: false };
88
+ after = last;
89
+ }
90
+ return { items, truncated: true };
91
+ }
92
+
93
+ /** The Organization's catalog source. Builds nothing until it is read. */
94
+ export const stripeCatalogSourceFor: OfferingsSourceFor = (
95
+ organizationId: string,
96
+ ): OfferingsCatalogSource => ({
97
+ async listActiveCatalog(): Promise<OfferingsCatalog> {
98
+ let aborted = false;
99
+ const isAborted = () => aborted;
100
+ const read = (async (): Promise<OfferingsCatalog> => {
101
+ const connection = await stripeClientForOrganization(organizationId);
102
+ if (connection.kind !== "ok") throw new OfferingsCatalogUnavailableError();
103
+ const { client } = connection;
104
+ const products = pageAll(
105
+ (params) => client.products.list(params),
106
+ OFFERINGS_CATALOG_PRODUCTS_MAX,
107
+ isAborted,
108
+ );
109
+ const prices = pageAll((params) => client.prices.list(params), OFFERINGS_CATALOG_PRICES_MAX, isAborted);
110
+ // Each loop is caught on its own, so a page that rejects after the
111
+ // other loop or the timeout has failed the read is never unhandled.
112
+ products.catch(() => undefined);
113
+ prices.catch(() => undefined);
114
+ const [productPages, pricePages] = await Promise.all([products, prices]);
115
+ if (productPages.truncated || pricePages.truncated) {
116
+ logger.warn(
117
+ { organizationId, reason: "catalog_truncated" },
118
+ "[offerings] catalog over its read cap",
119
+ );
120
+ }
121
+ return { products: productPages.items, prices: pricePages.items };
122
+ })();
123
+ read.catch(() => undefined);
124
+
125
+ let timer: ReturnType<typeof setTimeout> | undefined;
126
+ const timeout = new Promise<never>((_resolve, reject) => {
127
+ timer = setTimeout(() => {
128
+ aborted = true;
129
+ reject(new OfferingsCatalogUnavailableError());
130
+ }, OFFERINGS_CATALOG_READ_TIMEOUT_MS);
131
+ });
132
+ try {
133
+ return await Promise.race([read, timeout]);
134
+ } finally {
135
+ clearTimeout(timer);
136
+ // A loop still running after the other one failed starts no more calls.
137
+ aborted = true;
138
+ }
139
+ },
140
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The Offerings sets and the snapshot (#8876, spec
3
+ * docs/specs/8876-the-offerings-data-model-typed-spec.md D6) — the one file
4
+ * naming the org-enabled set, the workspace selection and its snapshot, and,
5
+ * for this service, the workspace's Organization.
6
+ *
7
+ * Every argument is a server-resolved id, never a request value, except a
8
+ * set or selection handed to a writer, which its caller checked and the
9
+ * writer parses; and no function lists or enumerates. The selection read
10
+ * never names the snapshot, so checkout's allowlist (contract A4) can never
11
+ * read the display copy.
12
+ *
13
+ * This file writes the snapshot, only into a row that exists; for #8922, the
14
+ * org-enabled set; and, for #8926, the workspace selection and Recommended,
15
+ * creating its row.
16
+ */
17
+ import {
18
+ offeringsProductIdsSchema,
19
+ workspaceOfferingsSelectionSchema,
20
+ type OfferingsSnapshot,
21
+ } from "@working-theory/validation";
22
+
23
+ import { prisma } from "../../lib/prisma.js";
24
+
25
+ /**
26
+ * An Offerings result, or a set handed to a writer, failed its schema. The
27
+ * message is fixed; there is no cause. Re-exported by `index.ts`.
28
+ */
29
+ export class OfferingsShapeError extends Error {
30
+ constructor() {
31
+ super("An Offerings result failed its schema");
32
+ this.name = "OfferingsShapeError";
33
+ }
34
+ }
35
+
36
+ export interface WorkspaceSelection {
37
+ shownProductIds: string[];
38
+ /** The shown Prices (#9070 D1); empty for a row saved per Product. */
39
+ shownPriceIds: string[];
40
+ recommendedPlanProductId: string | null;
41
+ recommendedAddonProductId: string | null;
42
+ }
43
+
44
+ export interface WorkspaceSnapshotRow {
45
+ snapshot: unknown;
46
+ snapshotAt: Date | null;
47
+ }
48
+
49
+ /** The workspace's own Organization, or `null` for no workspace. */
50
+ export async function organizationIdOfWorkspace(workspaceId: string): Promise<string | null> {
51
+ const row = await prisma.workspace.findUnique({
52
+ where: { id: workspaceId },
53
+ select: { organizationId: true },
54
+ });
55
+ return row?.organizationId ?? null;
56
+ }
57
+
58
+ /** The org-enabled set; no row is the empty set. */
59
+ export async function readEnabledProductIds(organizationId: string): Promise<string[]> {
60
+ const row = await prisma.organizationOfferings.findUnique({
61
+ where: { organizationId },
62
+ select: { enabledProductIds: true },
63
+ });
64
+ return row?.enabledProductIds ?? [];
65
+ }
66
+
67
+ /**
68
+ * Replace the org-enabled set (#8922 D4), creating the row on an
69
+ * Organization's first save. Every set is parsed first — at most 100
70
+ * distinct Stripe ids — so the bound holds for every writer.
71
+ */
72
+ export async function writeEnabledProductIds(organizationId: string, productIds: string[]): Promise<void> {
73
+ const parsed = offeringsProductIdsSchema.safeParse(productIds);
74
+ if (!parsed.success) throw new OfferingsShapeError();
75
+ await prisma.organizationOfferings.upsert({
76
+ where: { organizationId },
77
+ create: { organizationId, enabledProductIds: parsed.data },
78
+ update: { enabledProductIds: parsed.data },
79
+ });
80
+ }
81
+
82
+ /** The shown set and Recommended, or `null` for no row. Never the snapshot. */
83
+ export async function readWorkspaceSelection(workspaceId: string): Promise<WorkspaceSelection | null> {
84
+ return prisma.workspaceOfferings.findUnique({
85
+ where: { workspaceId },
86
+ select: {
87
+ shownProductIds: true,
88
+ shownPriceIds: true,
89
+ recommendedPlanProductId: true,
90
+ recommendedAddonProductId: true,
91
+ },
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Replace the workspace's selection (#8926 D5), creating the row on its first
97
+ * save. Every selection is parsed first — at most 100 distinct Product ids
98
+ * and 300 Price ids, Prices exactly when Products (#9070 D4), each
99
+ * Recommended shown — so the database's CHECKs never fire and no new save is
100
+ * at the per-Product grain. Never names the snapshot, so a save keeps the
101
+ * last good copy.
102
+ */
103
+ export async function writeWorkspaceSelection(
104
+ workspaceId: string,
105
+ selection: WorkspaceSelection,
106
+ ): Promise<void> {
107
+ const parsed = workspaceOfferingsSelectionSchema.safeParse(selection);
108
+ if (!parsed.success) throw new OfferingsShapeError();
109
+ const { shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId } =
110
+ parsed.data;
111
+ await prisma.workspaceOfferings.upsert({
112
+ where: { workspaceId },
113
+ create: { workspaceId, shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId },
114
+ update: { shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId },
115
+ });
116
+ }
117
+
118
+ /** The last-good snapshot and its time, or `null` for no row. */
119
+ export async function readWorkspaceSnapshot(workspaceId: string): Promise<WorkspaceSnapshotRow | null> {
120
+ return prisma.workspaceOfferings.findUnique({
121
+ where: { workspaceId },
122
+ select: { snapshot: true, snapshotAt: true },
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Write a parsed snapshot and its time together (the pairing CHECK). Creates
128
+ * no row, and never replaces a snapshot dated after `at` (unless it is dated
129
+ * more than `futureSkewMs` past it, which no reader serves): `false` when
130
+ * nothing was written.
131
+ */
132
+ export async function writeWorkspaceSnapshot(
133
+ workspaceId: string,
134
+ snapshot: OfferingsSnapshot,
135
+ at: Date,
136
+ futureSkewMs: number,
137
+ ): Promise<boolean> {
138
+ const { count } = await prisma.workspaceOfferings.updateMany({
139
+ where: {
140
+ workspaceId,
141
+ OR: [
142
+ { snapshotAt: null },
143
+ { snapshotAt: { lte: at } },
144
+ { snapshotAt: { gt: new Date(at.getTime() + futureSkewMs) } },
145
+ ],
146
+ },
147
+ data: { snapshot, snapshotAt: at },
148
+ });
149
+ return count > 0;
150
+ }
@@ -13,14 +13,27 @@
13
13
  * match, never overwriting a link.
14
14
  * - `recordCheckoutPerson` — the webhook's step: the Person of a completed
15
15
  * hub checkout, with its live-mode customer id.
16
+ * `resolveCheckoutPerson` is the same step answering that Person too, for
17
+ * the checkout's terms grant (#8765 D14); the webhook calls it, and
18
+ * `recordCheckoutPerson` stays as its outcome-only form for the #852
19
+ * suites that pin that outcome.
16
20
  * - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
17
21
  * linked to the caller, else the one whose email is the caller's
18
- * verified email, linked on first read by `stampPersonUser`'s rule.
22
+ * verified email, linked on first read by `stampPersonUser`'s rule. That
23
+ * first link — and no other — releases the Person's held checkout plan
24
+ * (#8793 D2).
19
25
  * - `resolveCallerBillingCustomerId` — the customer id the hub vouches for
20
26
  * in `GET /v1/hub-sign-in/me` (#7961, spec 7894 D13): that same Person's,
21
27
  * only while its `userId` is the caller.
22
28
  * - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
23
- * the Admin's plan route and the checkout-lead rule in `lead-routing.ts`.
29
+ * the Admin's plan route and `releaseHeldCheckoutPlan`.
30
+ * - `releaseHeldCheckoutPlan` and `checkoutPlanKey` — the checkout plan
31
+ * rule (#7877 D10, #8793 D1–D4). A spoke's checkout lead never writes a
32
+ * plan: it holds it (`lead-routing.ts`), because its email is whatever
33
+ * the buyer typed at Stripe Checkout. The hold is released once, at the
34
+ * Person's first member-path link to the user whose VERIFIED email is the
35
+ * Person's own, into a blank plan only, and only when every held purchase
36
+ * under that email names the same plan key.
24
37
  * - the contact details (#8435): `findOrCreatePerson` fills BLANK contact
25
38
  * fields of an uncurated Person (D4); `updatePersonContact` (the
26
39
  * operator's save), `ensureMemberPerson` and `setOwnPersonContact` (the
@@ -49,7 +62,8 @@
49
62
  *
50
63
  * The base-tree `lead-routing.ts` imports this file, so it ships in every
51
64
  * build and must never reach the paid import closure: it imports
52
- * `lib/prisma.js`, `verified-email.js` and types only — never
65
+ * `lib/prisma.js`, `verified-email.js`, `planOfferingSchema` from the
66
+ * base-tree kernel `@working-theory/validation` and types only — never
53
67
  * `lead-promotion.ts`, `owner-workspaces.ts`, `lead-accept-user.ts` (whose
54
68
  * find-or-create shape it mirrors) or `lib/tenant-db.ts`.
55
69
  * `packages/create-ailk/__tests__/paid-module-exclusions.test.ts` holds this
@@ -58,7 +72,11 @@
58
72
  * ## Tenancy
59
73
  *
60
74
  * Every `Person` statement is scoped by a server-resolved `workspaceId`;
61
- * there is no list read here (the reads are `tenantDb(tenant).people`).
75
+ * there is no `Person` list read here (the reads are
76
+ * `tenantDb(tenant).people`). The one list read is `releaseHeldCheckoutPlan`'s
77
+ * (#8793 D3): the newest 51 `spokeCheckout` leads of a Person just linked to
78
+ * the caller, by `(workspaceId, personId)`, the workspace the member read
79
+ * resolved.
62
80
  * `recordCheckoutPerson`'s two other reads are by a server-minted
63
81
  * `FlowCheckout` id from a signature-verified webhook, matched on that
64
82
  * session's own `stripeSessionId`, then that row's lead scoped by the row's
@@ -80,7 +98,7 @@
80
98
 
81
99
  import type { Prisma } from "@working-theory/database";
82
100
  import type { Id } from "@working-theory/ids";
83
- import type { PersonContactPatch } from "@working-theory/validation";
101
+ import { planOfferingSchema, type PersonContactPatch } from "@working-theory/validation";
84
102
 
85
103
  import { prisma } from "../lib/prisma.js";
86
104
 
@@ -768,6 +786,13 @@ export type CheckoutPersonOutcome =
768
786
  | "person_skipped"
769
787
  | "person_failed";
770
788
 
789
+ /** `recordCheckoutPerson`'s outcome plus the Person it found or created (#8765 D14). */
790
+ export type CheckoutPersonResult = {
791
+ outcome: CheckoutPersonOutcome;
792
+ /** The checkout's Person, or `null` when the step skipped or failed. */
793
+ person: { workspaceId: string; personId: string } | null;
794
+ };
795
+
771
796
  /**
772
797
  * The hub webhook's Person step for a paid `checkout.session.completed` of
773
798
  * its OWN site carrying `metadata.flowCheckoutId`.
@@ -786,8 +811,23 @@ export async function recordCheckoutPerson(input: {
786
811
  customer: unknown;
787
812
  livemode: boolean;
788
813
  }): Promise<CheckoutPersonOutcome> {
814
+ return (await resolveCheckoutPerson(input)).outcome;
815
+ }
816
+
817
+ /**
818
+ * `recordCheckoutPerson`, also answering the Person it found or created —
819
+ * the subject of the checkout's terms grant (#8765 D14), which reads the
820
+ * flow checkout's Person from here and never creates one itself.
821
+ */
822
+ export async function resolveCheckoutPerson(input: {
823
+ flowCheckoutId: string;
824
+ stripeSessionId: string | null | undefined;
825
+ customer: unknown;
826
+ livemode: boolean;
827
+ }): Promise<CheckoutPersonResult> {
828
+ const skipped = { outcome: "person_skipped", person: null } as const;
789
829
  if (typeof input.stripeSessionId !== "string" || input.stripeSessionId === "") {
790
- return "person_skipped";
830
+ return skipped;
791
831
  }
792
832
  try {
793
833
  const checkout = await prisma.flowCheckout.findFirst({
@@ -798,13 +838,13 @@ export async function recordCheckoutPerson(input: {
798
838
  },
799
839
  select: { leadId: true, workspaceId: true },
800
840
  });
801
- if (!checkout) return "person_skipped";
841
+ if (!checkout) return skipped;
802
842
 
803
843
  const lead = await prisma.lead.findFirst({
804
844
  where: { id: checkout.leadId, workspaceId: checkout.workspaceId },
805
845
  select: { email: true },
806
846
  });
807
- if (!lead) return "person_skipped";
847
+ if (!lead) return skipped;
808
848
 
809
849
  const customerId =
810
850
  input.livemode === true && typeof input.customer === "string"
@@ -815,18 +855,24 @@ export async function recordCheckoutPerson(input: {
815
855
  ...(customerId !== undefined && { billingCustomerId: customerId }),
816
856
  });
817
857
  if (!result.ok) {
818
- return result.error.kind === "invalid_email" ? "person_skipped" : "person_failed";
858
+ return result.error.kind === "invalid_email"
859
+ ? skipped
860
+ : { outcome: "person_failed", person: null };
819
861
  }
862
+ const person = {
863
+ workspaceId: checkout.workspaceId,
864
+ personId: result.value.personId,
865
+ };
820
866
  switch (result.value.billing) {
821
867
  case "written":
822
- return "person_written";
868
+ return { outcome: "person_written", person };
823
869
  case "kept":
824
- return "person_kept";
870
+ return { outcome: "person_kept", person };
825
871
  default:
826
- return "person_unchanged";
872
+ return { outcome: "person_unchanged", person };
827
873
  }
828
874
  } catch {
829
- return "person_failed";
875
+ return { outcome: "person_failed", person: null };
830
876
  }
831
877
  }
832
878
 
@@ -840,6 +886,14 @@ export async function recordCheckoutPerson(input: {
840
886
  * every other stamp outcome, and a store error, is `null` too, logged by its
841
887
  * kind only. Never throws.
842
888
  *
889
+ * A fresh `stamped` link — and only that — releases the Person's held
890
+ * checkout plan (#8793 D2, `releaseHeldCheckoutPlan`): the caller has just
891
+ * proven they own the email the purchases were made under. A Person linked
892
+ * earlier, a refused stamp and every other caller of `stampPersonUser` (the
893
+ * Inbox accept above all) release nothing. The release never changes the
894
+ * answer; an `ambiguous` or `internal_error` release is logged by its kind
895
+ * only, as `{ event: "checkout-plan-release", outcome }`.
896
+ *
843
897
  * @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
844
898
  * @param userId the authenticated caller.
845
899
  * @param verifiedEmail the caller's verified address, read by the caller.
@@ -870,7 +924,19 @@ export async function resolveMemberPersonId(
870
924
  if (!byEmail) return null;
871
925
 
872
926
  const outcome = await stampPersonUser(workspaceId, byEmail.id, userId);
873
- if (outcome === "stamped" || outcome === "already_linked") return byEmail.id;
927
+ if (outcome === "stamped") {
928
+ const release = await releaseHeldCheckoutPlan(
929
+ workspaceId,
930
+ byEmail.id,
931
+ userId,
932
+ normalizePersonEmail(verifiedEmail),
933
+ );
934
+ if (release === "ambiguous" || release === "internal_error") {
935
+ log?.warn({ event: "checkout-plan-release", outcome: release }, "[person] held checkout plan not released");
936
+ }
937
+ return byEmail.id;
938
+ }
939
+ if (outcome === "already_linked") return byEmail.id;
874
940
  log?.warn({ event: "member-person-stamp", outcome }, "[person] member link refused");
875
941
  return null;
876
942
  }
@@ -911,17 +977,28 @@ export type SetPlanOfferingOutcome = "set" | "not_found" | "internal_error";
911
977
 
912
978
  /**
913
979
  * Set — or, with `null`, clear — a Person's plan: the ONE writer of
914
- * `Person.planOffering`. One `updateMany` scoped by the server-resolved
915
- * workspace; no row → `not_found`. Never throws.
980
+ * `Person.planOffering`, called by the Admin's plan route and the held
981
+ * checkout plan's release (#8793). One `updateMany` scoped by the
982
+ * server-resolved workspace; no row → `not_found`. Never throws.
983
+ *
984
+ * With a `guard` (#8793 D5, the release's own write) the statement also
985
+ * requires a blank plan and the Person still linked to `guard.linkedUserId`,
986
+ * so a plan already set — or one the operator PUTs at the same moment — wins
987
+ * and the answer is `not_found`. Without one it is the operator's overwrite.
916
988
  */
917
989
  export async function setPersonPlanOffering(
918
990
  workspaceId: string,
919
991
  personId: string,
920
992
  planOffering: string | null,
993
+ guard?: { linkedUserId: string },
921
994
  ): Promise<SetPlanOfferingOutcome> {
922
995
  try {
923
996
  const { count } = await prisma.person.updateMany({
924
- where: { id: personId, workspaceId },
997
+ where: {
998
+ id: personId,
999
+ workspaceId,
1000
+ ...(guard !== undefined && { planOffering: null, userId: guard.linkedUserId }),
1001
+ },
925
1002
  data: { planOffering },
926
1003
  });
927
1004
  return count === 0 ? "not_found" : "set";
@@ -930,6 +1007,76 @@ export async function setPersonPlanOffering(
930
1007
  }
931
1008
  }
932
1009
 
1010
+ // ─── The held checkout plan (#8793 D3, D4) ───────────────────────────────────
1011
+
1012
+ /**
1013
+ * A spoke checkout's plan key: the offering minus a leading `<site>_` (when
1014
+ * the site is a string), answered only when `planOfferingSchema` — the key
1015
+ * the operator's PUT accepts — admits it; else `null`. Shared by the
1016
+ * checkout's hold (`lead-routing.ts`) and the release below.
1017
+ */
1018
+ export function checkoutPlanKey(offering: unknown, site: unknown): string | null {
1019
+ if (typeof offering !== "string") return null;
1020
+ const prefix = typeof site === "string" ? `${site}_` : null;
1021
+ const key = prefix !== null && offering.startsWith(prefix) ? offering.slice(prefix.length) : offering;
1022
+ return planOfferingSchema.safeParse(key).success ? key : null;
1023
+ }
1024
+
1025
+ export type HeldPlanOutcome = "set" | "none" | "ambiguous" | "kept" | "internal_error";
1026
+
1027
+ /** The most held purchases a release weighs; one more is `ambiguous`. */
1028
+ const HELD_PURCHASES_READ = 50;
1029
+
1030
+ /**
1031
+ * Release a Person's held checkout plan (#8793 D3) — called only by
1032
+ * `resolveMemberPersonId` on a fresh `stamped` link, so `verifiedEmail` (the
1033
+ * caller's, normalized) is the Person's own. The Person's marked spoke
1034
+ * checkout leads (`Lead.spokeCheckout`, newest 51) under that email are taken
1035
+ * as a SET: one distinct plan key is written to a blank plan through the
1036
+ * guarded `setPersonPlanOffering` (`set`, or `kept` when the guard matched
1037
+ * nothing — a plan was already there, or the Person is no longer linked to
1038
+ * `userId`); no key is `none`; more than one key, or more than 50 leads, is
1039
+ * `ambiguous` and writes nothing — the operator sets the plan. So a
1040
+ * stranger's purchase under the owner's email can deny the automatic plan,
1041
+ * never choose it (SEC-8793-A). A store error is `internal_error`. Never
1042
+ * throws, and logs nothing: the caller logs the kind.
1043
+ */
1044
+ export async function releaseHeldCheckoutPlan(
1045
+ workspaceId: string,
1046
+ personId: string,
1047
+ userId: string,
1048
+ verifiedEmail: string,
1049
+ ): Promise<HeldPlanOutcome> {
1050
+ try {
1051
+ const rows = await prisma.lead.findMany({
1052
+ where: { workspaceId, personId, spokeCheckout: true },
1053
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
1054
+ take: HELD_PURCHASES_READ + 1,
1055
+ select: { email: true, payload: true },
1056
+ });
1057
+ if (rows.length > HELD_PURCHASES_READ) return "ambiguous";
1058
+
1059
+ const keys = new Set<string>();
1060
+ for (const row of rows) {
1061
+ // The purchase read's own rule: a purchase is its Person's only under
1062
+ // the Person's own email.
1063
+ if (normalizePersonEmail(row.email) !== verifiedEmail) continue;
1064
+ const payload = (row.payload ?? {}) as Record<string, unknown>;
1065
+ const key = checkoutPlanKey(payload["offering"], payload["site"]);
1066
+ if (key !== null) keys.add(key);
1067
+ }
1068
+ if (keys.size > 1) return "ambiguous";
1069
+ const [key] = keys;
1070
+ if (key === undefined) return "none";
1071
+
1072
+ const written = await setPersonPlanOffering(workspaceId, personId, key, { linkedUserId: userId });
1073
+ if (written === "set") return "set";
1074
+ return written === "not_found" ? "kept" : "internal_error";
1075
+ } catch {
1076
+ return "internal_error";
1077
+ }
1078
+ }
1079
+
933
1080
  // ─── The two human edits of the contact (#8435 D9, D11) ───────────────────────
934
1081
 
935
1082
  export type SavedContact = {