create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  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 +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -7,7 +7,10 @@
7
7
  * `resolveMemberPersonId` and `setPersonPlanOffering` (#7877 D8; AC-3, AC-5),
8
8
  * and the hub's buyer read, `resolveCallerBillingCustomerId`, with the
9
9
  * stamped-Person guard on the customer-id write (#7961, spec
10
- * docs/specs/7894-the-hub-signs-in-its-spec.md D13, SEC-7894-6; AC-8).
10
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D13, SEC-7894-6; AC-8),
11
+ * and the held checkout plan's release on a verified first link
12
+ * (#8793, spec docs/specs/8793-the-hub-s-checkout-plan-spec.md D2–D5;
13
+ * AC-1, AC-2, AC-6).
11
14
  *
12
15
  * Runs over `createInMemoryPrisma()`, whose `person` delegate enforces the
13
16
  * declared `(workspaceId, email)` and `(workspaceId, userId)` keys the way
@@ -36,6 +39,7 @@ import { prisma } from "../../lib/prisma.js";
36
39
  import { personContactPatchSchema } from "@working-theory/validation";
37
40
 
38
41
  import {
42
+ checkoutPlanKey,
39
43
  contactConfirmedFromRow,
40
44
  ensureMemberPerson,
41
45
  findOrCreatePerson,
@@ -45,11 +49,13 @@ import {
45
49
  normalizeTelephone,
46
50
  personContactFromRow,
47
51
  recordCheckoutPerson,
52
+ releaseHeldCheckoutPlan,
48
53
  resolveCallerBillingCustomerId,
49
54
  resolveMemberPersonId,
50
55
  setOwnPersonContact,
51
56
  setPersonPlanOffering,
52
57
  stampPersonUser,
58
+ stripeCustomerDashboardUrl,
53
59
  updatePersonContact,
54
60
  } from "../person";
55
61
 
@@ -61,6 +67,7 @@ const store = prisma as unknown as {
61
67
  __db: Record<string, Row[]>;
62
68
  __calls: Array<{ model: string; op: string }>;
63
69
  person: Record<string, jest.Mock>;
70
+ lead: Record<string, jest.Mock>;
64
71
  };
65
72
 
66
73
  const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
@@ -943,3 +950,255 @@ describe("setPersonPlanOffering (#7877 D8, AC-4, AC-5)", () => {
943
950
  expect(await setPersonPlanOffering(WS_A1, "person_x", "retainer")).toBe("internal_error");
944
951
  });
945
952
  });
953
+
954
+ // ─── The held checkout plan (#8793) ──────────────────────────────────────────
955
+
956
+ const leads = () => (store.__db["lead"] ??= []);
957
+ const leadCalls = () => store.__calls.filter((c) => c.model === "lead");
958
+
959
+ /** A spoke checkout lead on a Person, newest last unless `createdAt` says otherwise. */
960
+ function seedMarkedLead(personId: string, row: Row = {}): Row {
961
+ const full: Row = {
962
+ id: `lead_00000000-0000-7000-8000-${String(leads().length + 700).padStart(12, "0")}`,
963
+ workspaceId: WS_A1,
964
+ personId,
965
+ email: "Ada@Example.com",
966
+ source: "web",
967
+ spokeCheckout: true,
968
+ payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" },
969
+ createdAt: new Date(Date.UTC(2026, 1, 1 + leads().length)),
970
+ ...row,
971
+ };
972
+ leads().push(full);
973
+ return full;
974
+ }
975
+
976
+ describe("checkoutPlanKey (#8793 D4)", () => {
977
+ it("strips a leading <site>_ and answers a plan key, else null", () => {
978
+ expect(checkoutPlanKey("wendi_retainer", "wendi")).toBe("retainer");
979
+ expect(checkoutPlanKey("retainer", "wendi")).toBe("retainer");
980
+ expect(checkoutPlanKey("wendi_Not A Key!", "wendi")).toBeNull();
981
+ expect(checkoutPlanKey(42, "wendi")).toBeNull();
982
+ });
983
+ });
984
+
985
+ describe("setPersonPlanOffering's link guard (#8793 D5, AC-1, AC-6)", () => {
986
+ const U = "user_00000000-0000-7000-8000-000000000031";
987
+ const V = "user_00000000-0000-7000-8000-000000000032";
988
+
989
+ it("a guarded write never overwrites a set plan", async () => {
990
+ const p = seedPerson({ email: "ada@example.com", userId: U, planOffering: "premium" });
991
+ expect(
992
+ await setPersonPlanOffering(WS_A1, p["id"] as string, "x", { linkedUserId: U }),
993
+ ).toBe("not_found");
994
+ expect(people()[0]!["planOffering"]).toBe("premium");
995
+ });
996
+
997
+ it("a guarded write never reaches a Person linked to another user", async () => {
998
+ const p = seedPerson({ email: "ada@example.com", userId: U });
999
+ expect(
1000
+ await setPersonPlanOffering(WS_A1, p["id"] as string, "x", { linkedUserId: V }),
1001
+ ).toBe("not_found");
1002
+ expect(people()[0]!["planOffering"]).toBeNull();
1003
+ });
1004
+
1005
+ it("the operator's three-argument write still overwrites a set plan", async () => {
1006
+ const p = seedPerson({ email: "ada@example.com", userId: U, planOffering: "premium" });
1007
+ expect(await setPersonPlanOffering(WS_A1, p["id"] as string, "basic")).toBe("set");
1008
+ expect(people()[0]!["planOffering"]).toBe("basic");
1009
+ });
1010
+ });
1011
+
1012
+ describe("releaseHeldCheckoutPlan (#8793 D3, AC-1)", () => {
1013
+ const U = "user_00000000-0000-7000-8000-000000000041";
1014
+ const EMAIL = "ada@example.com";
1015
+ let personId: string;
1016
+
1017
+ const release = () => releaseHeldCheckoutPlan(WS_A1, personId, U, EMAIL);
1018
+ const plan = () => people()[0]!["planOffering"];
1019
+
1020
+ beforeEach(() => {
1021
+ personId = seedPerson({ email: EMAIL, userId: U })["id"] as string;
1022
+ });
1023
+
1024
+ it("one marked lead sets its key", async () => {
1025
+ seedMarkedLead(personId);
1026
+ expect(await release()).toBe("set");
1027
+ expect(plan()).toBe("retainer");
1028
+ });
1029
+
1030
+ it("two marked leads naming one key, with and without the site prefix, set it", async () => {
1031
+ seedMarkedLead(personId);
1032
+ seedMarkedLead(personId, {
1033
+ payload: { site: "wendi", page: "checkout", offering: "retainer" },
1034
+ });
1035
+ expect(await release()).toBe("set");
1036
+ expect(plan()).toBe("retainer");
1037
+ });
1038
+
1039
+ it.each([
1040
+ ["the basic purchase is newer", 1, 2],
1041
+ ["the retainer purchase is newer", 2, 1],
1042
+ ])("two marked leads naming different keys are ambiguous whatever their order (%s)", async (_label, retainerDay, basicDay) => {
1043
+ seedMarkedLead(personId, { createdAt: new Date(Date.UTC(2026, 2, retainerDay)) });
1044
+ seedMarkedLead(personId, {
1045
+ createdAt: new Date(Date.UTC(2026, 2, basicDay)),
1046
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1047
+ });
1048
+ expect(await release()).toBe("ambiguous");
1049
+ expect(plan()).toBeNull();
1050
+ });
1051
+
1052
+ it("a marked lead under another email is ignored", async () => {
1053
+ seedMarkedLead(personId);
1054
+ seedMarkedLead(personId, {
1055
+ email: "other@example.com",
1056
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1057
+ });
1058
+ expect(await release()).toBe("set");
1059
+ expect(plan()).toBe("retainer");
1060
+ });
1061
+
1062
+ it("only unmarked leads are none", async () => {
1063
+ seedMarkedLead(personId, { spokeCheckout: false });
1064
+ expect(await release()).toBe("none");
1065
+ expect(plan()).toBeNull();
1066
+ });
1067
+
1068
+ it("only an offering that is no plan key is none", async () => {
1069
+ seedMarkedLead(personId, {
1070
+ payload: { site: "wendi", page: "checkout", offering: "wendi_Not A Key!" },
1071
+ });
1072
+ expect(await release()).toBe("none");
1073
+ expect(plan()).toBeNull();
1074
+ });
1075
+
1076
+ it("51 marked leads are ambiguous, even when they all agree", async () => {
1077
+ for (let i = 0; i < 51; i += 1) seedMarkedLead(personId);
1078
+ expect(await release()).toBe("ambiguous");
1079
+ expect(plan()).toBeNull();
1080
+ });
1081
+
1082
+ it("a plan already set is kept", async () => {
1083
+ people()[0]!["planOffering"] = "premium";
1084
+ seedMarkedLead(personId);
1085
+ expect(await release()).toBe("kept");
1086
+ expect(plan()).toBe("premium");
1087
+ });
1088
+
1089
+ it("a failed lead read is internal_error, with no write", async () => {
1090
+ seedMarkedLead(personId);
1091
+ store.lead["findMany"]!.mockRejectedValueOnce(new Error("down"));
1092
+ expect(await release()).toBe("internal_error");
1093
+ expect(personCalls().filter((c) => c.op === "updateMany")).toHaveLength(0);
1094
+ expect(plan()).toBeNull();
1095
+ });
1096
+ });
1097
+
1098
+ describe("resolveMemberPersonId releases a held plan on a fresh link only (#8793 D2, AC-2)", () => {
1099
+ const users = () => (store.__db["user"] ??= []);
1100
+ const U = "user_00000000-0000-7000-8000-000000000051";
1101
+ const EMAIL = "ada@example.com";
1102
+ const log = { warn: jest.fn() };
1103
+ let personId: string;
1104
+
1105
+ const plan = () => people()[0]!["planOffering"];
1106
+ const releaseLines = () =>
1107
+ log.warn.mock.calls
1108
+ .map(([fields]) => fields as Record<string, unknown>)
1109
+ .filter((fields) => fields["event"] === "checkout-plan-release");
1110
+
1111
+ beforeEach(() => {
1112
+ log.warn.mockClear();
1113
+ personId = seedPerson({ email: EMAIL })["id"] as string;
1114
+ seedMarkedLead(personId);
1115
+ });
1116
+
1117
+ it("a verified caller's first link releases the held plan, logging nothing", async () => {
1118
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1119
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1120
+ expect(people()[0]!["userId"]).toBe(U);
1121
+ expect(plan()).toBe("retainer");
1122
+ expect(releaseLines()).toEqual([]);
1123
+ });
1124
+
1125
+ it("an unverified caller links nothing and releases nothing", async () => {
1126
+ users().push({ id: U, email: EMAIL, emailVerified: false });
1127
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBeNull();
1128
+ expect(plan()).toBeNull();
1129
+ });
1130
+
1131
+ it("a Person linked before the call is answered with no release and no lead read", async () => {
1132
+ people()[0]!["userId"] = U;
1133
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1134
+ expect(plan()).toBeNull();
1135
+ expect(leadCalls()).toHaveLength(0);
1136
+ });
1137
+
1138
+ it("a direct stamp (the inbox accept's and the consents route's path) releases nothing", async () => {
1139
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1140
+ expect(await stampPersonUser(WS_A1, personId, U)).toBe("stamped");
1141
+ expect(plan()).toBeNull();
1142
+ expect(leadCalls()).toHaveLength(0);
1143
+ });
1144
+
1145
+ it("a failed release still answers the Person and logs its kind only", async () => {
1146
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1147
+ store.lead["findMany"]!.mockRejectedValueOnce(new Error("down"));
1148
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1149
+ expect(plan()).toBeNull();
1150
+ expect(releaseLines()).toEqual([
1151
+ { event: "checkout-plan-release", outcome: "internal_error" },
1152
+ ]);
1153
+ const logged = JSON.stringify(log.warn.mock.calls);
1154
+ expect(logged).not.toContain(EMAIL);
1155
+ expect(logged).not.toContain("retainer");
1156
+ });
1157
+
1158
+ it("disagreeing purchases release nothing and log ambiguous", async () => {
1159
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1160
+ seedMarkedLead(personId, {
1161
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1162
+ });
1163
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1164
+ expect(plan()).toBeNull();
1165
+ expect(releaseLines()).toEqual([
1166
+ { event: "checkout-plan-release", outcome: "ambiguous" },
1167
+ ]);
1168
+ });
1169
+
1170
+ it("a release that finds nothing to set logs nothing", async () => {
1171
+ leads().length = 0;
1172
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1173
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1174
+ expect(plan()).toBeNull();
1175
+ expect(log.warn).not.toHaveBeenCalled();
1176
+ });
1177
+ });
1178
+
1179
+ describe("stripeCustomerDashboardUrl (#9349 DD5, AC-9)", () => {
1180
+ it("builds the live and the test Dashboard links", () => {
1181
+ expect(stripeCustomerDashboardUrl("cus_ABC123", "live")).toBe(
1182
+ "https://dashboard.stripe.com/customers/cus_ABC123",
1183
+ );
1184
+ expect(stripeCustomerDashboardUrl("cus_ABC123", "test")).toBe(
1185
+ "https://dashboard.stripe.com/test/customers/cus_ABC123",
1186
+ );
1187
+ });
1188
+
1189
+ it("refuses an id outside the cus_ grammar or over 255 characters", () => {
1190
+ for (const id of [
1191
+ "cus_a/../../settings",
1192
+ "cus_",
1193
+ "acct_1",
1194
+ "cus_a?x=1",
1195
+ "cus_a#x",
1196
+ `cus_${"a".repeat(252)}`,
1197
+ ]) {
1198
+ expect(stripeCustomerDashboardUrl(id, "live")).toBeNull();
1199
+ }
1200
+ expect(
1201
+ stripeCustomerDashboardUrl(`cus_${"a".repeat(251)}`, "live"),
1202
+ ).not.toBeNull();
1203
+ });
1204
+ });