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
@@ -0,0 +1,303 @@
1
+ /**
2
+ * @file offerings.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the three Offerings tables' constraints (#8876, spec
5
+ * docs/specs/8876-the-offerings-data-model-typed-spec.md D1, D2, AC-1).
6
+ * Requires DATABASE_URL pointing at a Postgres with the migrations applied —
7
+ * SKIPS when DATABASE_URL is unset (the repo convention; the `apps/api Route
8
+ * Tests (Postgres)` workflow exercises it).
9
+ *
10
+ * Uses the `pg` driver directly, in ONE transaction that is rolled back, so no
11
+ * row outlives the suite. Each refusal runs in its own savepoint and names the
12
+ * constraint Postgres reports, so a refusal for the wrong reason fails.
13
+ */
14
+ import { Client } from "pg";
15
+
16
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
17
+
18
+ const ORG = "org_01920000-8876-7000-8000-0000000000a1";
19
+ const ORG_2 = "org_01920000-8876-7000-8000-0000000000a2";
20
+ const WS = "wksp_01920000-8876-7000-8000-0000000000b1";
21
+ const WS_2 = "wksp_01920000-8876-7000-8000-0000000000b2";
22
+ /** The fixture's placeholder: the envelope shape, decrypting under no key. */
23
+ const PLACEHOLDER = "v1.Zml4dHVyZWl2ODg3.Zml4dHVyZS10YWctODg3Ng.Y2lwaGVydGV4dA";
24
+ /** The placeholder's `v1.<iv>.<tag>` head, to wrap a cleartext value in. */
25
+ const ENVELOPE_HEAD = PLACEHOLDER.split(".").slice(0, 3).join(".");
26
+ const TOKEN = "A".repeat(43);
27
+ const TOKEN_2 = "B".repeat(43);
28
+
29
+ describeDb("the Offerings tables (#8876 D1, D2, AC-1, live DB)", () => {
30
+ let db: Client;
31
+
32
+ beforeAll(async () => {
33
+ db = new Client({ connectionString: process.env.DATABASE_URL });
34
+ await db.connect();
35
+ await db.query("BEGIN");
36
+ });
37
+
38
+ afterAll(async () => {
39
+ await db.query("ROLLBACK");
40
+ await db.end();
41
+ });
42
+
43
+ /** Run `run` inside a savepoint; resolve to the violated constraint's name, or null. */
44
+ const violation = async (run: () => Promise<unknown>): Promise<string | null> => {
45
+ await db.query("SAVEPOINT attempt");
46
+ try {
47
+ await run();
48
+ await db.query("ROLLBACK TO SAVEPOINT attempt");
49
+ return null;
50
+ } catch (err) {
51
+ await db.query("ROLLBACK TO SAVEPOINT attempt");
52
+ return (err as { constraint?: string }).constraint ?? String(err);
53
+ }
54
+ };
55
+
56
+ /** Insert one row of `table` from `cols` (plus `updated_at`). */
57
+ const insert = (table: string, cols: Record<string, unknown>) => {
58
+ const all: Record<string, unknown> = { updated_at: new Date(), ...cols };
59
+ const names = Object.keys(all);
60
+ return db.query(
61
+ `INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(", ")})
62
+ VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
63
+ names.map((n) => all[n]),
64
+ );
65
+ };
66
+
67
+ const connection = (cols: Record<string, unknown> = {}) =>
68
+ insert("stripe_connections", {
69
+ organization_id: ORG,
70
+ mode: "test",
71
+ key_last4: "4242",
72
+ credential_ciphertext: PLACEHOLDER,
73
+ webhook_token: TOKEN,
74
+ ...cols,
75
+ });
76
+
77
+ const workspaceOfferings = (cols: Record<string, unknown> = {}) =>
78
+ insert("workspace_offerings", { workspace_id: WS, ...cols });
79
+
80
+ describe("stripe_connections", () => {
81
+ it("accepts the fixture's envelope-shaped placeholder and mints a prefixed id", async () => {
82
+ await db.query("SAVEPOINT ok");
83
+ const { rows } = await connection({ webhook_secret_ciphertext: PLACEHOLDER });
84
+ expect(rows[0].id).toMatch(/^stripecon_[0-9a-f]{8}-[0-9a-f]{4}-7/);
85
+ await db.query("ROLLBACK TO SAVEPOINT ok");
86
+ });
87
+
88
+ it("accepts a live row with its go-live time", async () => {
89
+ expect(await violation(() => connection({ mode: "live", live_at: new Date() }))).toBeNull();
90
+ });
91
+
92
+ it("refuses a second row for one Organization", async () => {
93
+ expect(
94
+ await violation(async () => {
95
+ await connection();
96
+ await connection({ webhook_token: TOKEN_2 });
97
+ }),
98
+ ).toBe("stripe_connections_organization_id_key");
99
+ });
100
+
101
+ it("refuses a second row with one webhook token", async () => {
102
+ expect(
103
+ await violation(async () => {
104
+ await connection();
105
+ await connection({ organization_id: ORG_2 });
106
+ }),
107
+ ).toBe("stripe_connections_webhook_token_key");
108
+ });
109
+
110
+ it.each([
111
+ ["a mode of prod", { mode: "prod" }, "stripe_connections_mode_domain"],
112
+ ["a live row with no live_at", { mode: "live" }, "stripe_connections_live_at_pair"],
113
+ ["a test row with a live_at", { live_at: new Date() }, "stripe_connections_live_at_pair"],
114
+ ["a token of 42 characters", { webhook_token: "A".repeat(42) }, "stripe_connections_webhook_token_shape"],
115
+ ["a token with a dot", { webhook_token: `${"A".repeat(42)}.` }, "stripe_connections_webhook_token_shape"],
116
+ ["a last4 of three characters", { key_last4: "424" }, "stripe_connections_key_last4_shape"],
117
+ ["a last4 with a symbol", { key_last4: "42_2" }, "stripe_connections_key_last4_shape"],
118
+ [
119
+ "a credential stored in clear",
120
+ { credential_ciphertext: `rk_test_${"a1B2".repeat(24)}` },
121
+ "stripe_connections_credential_envelope",
122
+ ],
123
+ [
124
+ "a live credential stored in clear",
125
+ { credential_ciphertext: `sk_${"live"}_${"a1B2".repeat(24)}` },
126
+ "stripe_connections_credential_envelope",
127
+ ],
128
+ [
129
+ "a webhook secret stored in clear",
130
+ { webhook_secret_ciphertext: `whsec_${"a1B2".repeat(8)}` },
131
+ "stripe_connections_webhook_secret_envelope",
132
+ ],
133
+ [
134
+ "a live key wrapped in the envelope's dots",
135
+ { credential_ciphertext: `${ENVELOPE_HEAD}.rk_${"live"}_${"a1B2".repeat(24)}` },
136
+ "stripe_connections_credential_envelope",
137
+ ],
138
+ [
139
+ "a test key wrapped in the envelope's dots",
140
+ { credential_ciphertext: `${ENVELOPE_HEAD}.rk_test_${"a1B2".repeat(24)}` },
141
+ "stripe_connections_credential_envelope",
142
+ ],
143
+ [
144
+ "an IV that is not 12 bytes",
145
+ { credential_ciphertext: "v1.Zml4dHVyZQ.Zml4dHVyZS10YWctODg3Ng.Y2lwaGVydGV4dA" },
146
+ "stripe_connections_credential_envelope",
147
+ ],
148
+ [
149
+ "a webhook secret wrapped in the envelope's dots",
150
+ { webhook_secret_ciphertext: `${ENVELOPE_HEAD}.whsec_${"a1B2".repeat(8)}` },
151
+ "stripe_connections_webhook_secret_envelope",
152
+ ],
153
+ ["a malformed organization_id", { organization_id: "org_1" }, "stripe_connections_organization_id_format"],
154
+ [
155
+ "a workspace id as organization_id",
156
+ { organization_id: WS },
157
+ "stripe_connections_organization_id_format",
158
+ ],
159
+ ])("refuses %s", async (_label, cols, constraint) => {
160
+ expect(await violation(() => connection(cols))).toBe(constraint);
161
+ });
162
+ });
163
+
164
+ describe("organization_offerings", () => {
165
+ const orgOfferings = (cols: Record<string, unknown> = {}) =>
166
+ insert("organization_offerings", { organization_id: ORG, ...cols });
167
+
168
+ it("defaults to the empty set and accepts 100 ids", async () => {
169
+ expect(await violation(() => orgOfferings())).toBeNull();
170
+ const hundred = Array.from({ length: 100 }, (_, i) => `prod_${i}`);
171
+ expect(await violation(() => orgOfferings({ enabled_product_ids: hundred }))).toBeNull();
172
+ });
173
+
174
+ it.each([
175
+ [
176
+ "more than 100 ids",
177
+ { enabled_product_ids: Array.from({ length: 101 }, (_, i) => `prod_${i}`) },
178
+ "organization_offerings_enabled_product_ids_bound",
179
+ ],
180
+ ["a NULL set", { enabled_product_ids: null }, "organization_offerings_enabled_product_ids_bound"],
181
+ [
182
+ "a NULL element",
183
+ { enabled_product_ids: ["prod_a", null] },
184
+ "organization_offerings_enabled_product_ids_bound",
185
+ ],
186
+ ["a malformed organization_id", { organization_id: "org_x" }, "organization_offerings_organization_id_format"],
187
+ ])("refuses %s", async (_label, cols, constraint) => {
188
+ expect(await violation(() => orgOfferings(cols))).toBe(constraint);
189
+ });
190
+
191
+ it("refuses a second row for one Organization", async () => {
192
+ expect(
193
+ await violation(async () => {
194
+ await orgOfferings();
195
+ await orgOfferings();
196
+ }),
197
+ ).toBe("organization_offerings_organization_id_key");
198
+ });
199
+ });
200
+
201
+ describe("workspace_offerings", () => {
202
+ it("accepts a shown set with Recommended and a snapshot with its time", async () => {
203
+ expect(
204
+ await violation(() =>
205
+ workspaceOfferings({
206
+ shown_product_ids: ["prod_growth", "prod_seo"],
207
+ recommended_plan_product_id: "prod_growth",
208
+ recommended_addon_product_id: "prod_seo",
209
+ snapshot: JSON.stringify({ v: 1, offerings: [] }),
210
+ snapshot_at: new Date(),
211
+ }),
212
+ ),
213
+ ).toBeNull();
214
+ });
215
+
216
+ it.each([
217
+ [
218
+ "a Recommended plan not in the shown set",
219
+ { shown_product_ids: ["prod_seo"], recommended_plan_product_id: "prod_growth" },
220
+ "workspace_offerings_recommended_plan_shown",
221
+ ],
222
+ [
223
+ "a Recommended add-on not in the shown set",
224
+ { shown_product_ids: ["prod_growth"], recommended_addon_product_id: "prod_seo" },
225
+ "workspace_offerings_recommended_addon_shown",
226
+ ],
227
+ [
228
+ "more than 100 ids",
229
+ { shown_product_ids: Array.from({ length: 101 }, (_, i) => `prod_${i}`) },
230
+ "workspace_offerings_shown_product_ids_bound",
231
+ ],
232
+ ["a NULL element", { shown_product_ids: [null] }, "workspace_offerings_shown_product_ids_bound"],
233
+ [
234
+ "a snapshot without snapshot_at",
235
+ { snapshot: JSON.stringify({ v: 1, offerings: [] }) },
236
+ "workspace_offerings_snapshot_pair",
237
+ ],
238
+ ["a snapshot_at without a snapshot", { snapshot_at: new Date() }, "workspace_offerings_snapshot_pair"],
239
+ ["a malformed workspace_id", { workspace_id: "wksp_1" }, "workspace_offerings_workspace_id_format"],
240
+ ])("refuses %s", async (_label, cols, constraint) => {
241
+ expect(await violation(() => workspaceOfferings(cols))).toBe(constraint);
242
+ });
243
+
244
+ describe("shown_price_ids (#9070 D1, AC-6)", () => {
245
+ it("reads the empty set for a row inserted without it", async () => {
246
+ await db.query("SAVEPOINT attempt");
247
+ const { rows } = await db.query(
248
+ `INSERT INTO "workspace_offerings" ("workspace_id", "shown_product_ids", "updated_at")
249
+ VALUES ($1, $2, now()) RETURNING "shown_price_ids"`,
250
+ [WS, ["prod_growth"]],
251
+ );
252
+ await db.query("ROLLBACK TO SAVEPOINT attempt");
253
+ expect(rows[0].shown_price_ids).toEqual([]);
254
+ });
255
+
256
+ it("accepts 300 Price ids beside one Product", async () => {
257
+ expect(
258
+ await violation(() =>
259
+ workspaceOfferings({
260
+ shown_product_ids: ["prod_growth"],
261
+ shown_price_ids: Array.from({ length: 300 }, (_, i) => `price_${i}`),
262
+ }),
263
+ ),
264
+ ).toBeNull();
265
+ });
266
+
267
+ it.each([
268
+ [
269
+ "301 Price ids",
270
+ { shown_product_ids: ["prod_growth"], shown_price_ids: Array.from({ length: 301 }, (_, i) => `price_${i}`) },
271
+ "workspace_offerings_shown_price_ids_bound",
272
+ ],
273
+ [
274
+ "a NULL Price id",
275
+ { shown_product_ids: ["prod_growth"], shown_price_ids: [null] },
276
+ "workspace_offerings_shown_price_ids_bound",
277
+ ],
278
+ [
279
+ "a Price id with no shown Product",
280
+ { shown_product_ids: [], shown_price_ids: ["price_growth_yearly"] },
281
+ "workspace_offerings_shown_prices_need_products",
282
+ ],
283
+ ])("refuses %s", async (_label, cols, constraint) => {
284
+ expect(await violation(() => workspaceOfferings(cols))).toBe(constraint);
285
+ });
286
+ });
287
+
288
+ it("refuses a second row for one workspace and accepts another workspace's", async () => {
289
+ expect(
290
+ await violation(async () => {
291
+ await workspaceOfferings();
292
+ await workspaceOfferings();
293
+ }),
294
+ ).toBe("workspace_offerings_workspace_id_key");
295
+ expect(
296
+ await violation(async () => {
297
+ await workspaceOfferings();
298
+ await workspaceOfferings({ workspace_id: WS_2 });
299
+ }),
300
+ ).toBeNull();
301
+ });
302
+ });
303
+ });
@@ -15,10 +15,14 @@ import {
15
15
  contactFormFlowConfig,
16
16
  JOB_APPLICATION_FLOW_SLUG,
17
17
  jobApplicationFlowConfig,
18
+ NEWSLETTER_FLOW_SLUG,
19
+ newsletterFlowConfig,
18
20
  REQUEST_QUOTE_FLOW_SLUG,
19
21
  REQUEST_QUOTE_FLOW_TITLE,
20
22
  requestQuoteFlowConfig,
21
23
  STANDARD_MAGNET_COMPLETIONS,
24
+ TRIAL_FLOW_SLUG,
25
+ trialFlowConfig,
22
26
  } from "@working-theory/validation";
23
27
 
24
28
  import {
@@ -112,7 +116,7 @@ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4,
112
116
 
113
117
  await provisionDefaultWorkspace(prisma);
114
118
 
115
- expect(flowDefinitionUpsert).toHaveBeenCalledTimes(3);
119
+ expect(flowDefinitionUpsert).toHaveBeenCalledTimes(5);
116
120
  expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(1, {
117
121
  where: {
118
122
  siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: REQUEST_QUOTE_FLOW_SLUG },
@@ -208,3 +212,61 @@ describe("provisionDefaultWorkspace — the Job application magnet (#8746 D3, AC
208
212
  });
209
213
  });
210
214
  });
215
+
216
+ describe("provisionDefaultWorkspace — the newsletter magnet (#8847 D4, AC-5)", () => {
217
+ it("upserts the default workspace's live newsletter, insert-if-absent", async () => {
218
+ const { prisma, flowDefinitionUpsert } = makeStubPrisma(0);
219
+
220
+ await provisionDefaultWorkspace(prisma);
221
+
222
+ expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(4, {
223
+ where: {
224
+ siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: NEWSLETTER_FLOW_SLUG },
225
+ },
226
+ create: {
227
+ siteId: DEFAULT_WORKSPACE_ID,
228
+ workspaceId: DEFAULT_WORKSPACE_ID,
229
+ slug: "newsletter",
230
+ config: newsletterFlowConfig,
231
+ kind: "newsletter",
232
+ title: "Newsletter",
233
+ status: "live",
234
+ customersEnabled: false,
235
+ completion: STANDARD_MAGNET_COMPLETIONS[NEWSLETTER_FLOW_SLUG],
236
+ landingPage: "/newsletter",
237
+ },
238
+ // Empty: a re-run never overwrites the owner's edits.
239
+ update: {},
240
+ });
241
+ });
242
+ });
243
+
244
+ describe("provisionDefaultWorkspace — the trial magnet (#9287 D11, #9301 AC-1)", () => {
245
+ it("upserts the default workspace's live trial magnet, insert-if-absent, as the fifth magnet", async () => {
246
+ const { prisma, flowDefinitionUpsert } = makeStubPrisma(0);
247
+
248
+ await provisionDefaultWorkspace(prisma);
249
+
250
+ expect(flowDefinitionUpsert).toHaveBeenCalledTimes(5);
251
+ expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(5, {
252
+ where: {
253
+ siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: TRIAL_FLOW_SLUG },
254
+ },
255
+ create: {
256
+ siteId: DEFAULT_WORKSPACE_ID,
257
+ workspaceId: DEFAULT_WORKSPACE_ID,
258
+ slug: "trial",
259
+ config: trialFlowConfig,
260
+ kind: "diagnostic",
261
+ title: "Free trial",
262
+ status: "live",
263
+ customersEnabled: false,
264
+ // No account invite: the licence key's email is the invitation.
265
+ completion: "lead",
266
+ landingPage: "/trial",
267
+ },
268
+ // Empty: a re-run never overwrites the owner's edits.
269
+ update: {},
270
+ });
271
+ });
272
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @file trial-magnet-migration.test.ts
3
+ *
4
+ * DB-free: migrations/20261025120000_trial_magnet writes the trial magnet's
5
+ * config as a JSONB literal, because SQL cannot import TS (#9287 spec
6
+ * docs/specs/9287-operator-started-trials-spec.md D11; #9301 AC-1). This test
7
+ * holds the literal to its one TS source so they can never drift, as
8
+ * `in-page-magnets-migration.test.ts` does for the newsletter backfill:
9
+ * `trial backfill` inserts `trialFlowConfig` into every workspace,
10
+ * insert-only on `(site_id, slug)`, so an owner's own row at `trial` is never
11
+ * updated.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ import {
17
+ flowConfigSchema,
18
+ TRIAL_FLOW_LANDING_PAGE,
19
+ TRIAL_FLOW_SLUG,
20
+ TRIAL_FLOW_TITLE,
21
+ trialFlowConfig,
22
+ } from "@working-theory/validation";
23
+
24
+ const MIGRATION_SQL = readFileSync(
25
+ join(
26
+ __dirname,
27
+ "..",
28
+ "migrations",
29
+ "20261025120000_trial_magnet",
30
+ "migration.sql",
31
+ ),
32
+ "utf8",
33
+ );
34
+
35
+ const PROOF_MIGRATION_SQL = readFileSync(
36
+ join(
37
+ __dirname,
38
+ "..",
39
+ "migrations",
40
+ "20261026120000_trial_magnet_proof_line",
41
+ "migration.sql",
42
+ ),
43
+ "utf8",
44
+ );
45
+
46
+ /**
47
+ * #9301's shipped trial magnet — `trialFlowConfig` as #9301 wrote it, before
48
+ * #9302 added the landing's proof line. Written out here, not imported: the
49
+ * constant has moved on, and this is the value the backfill wrote and the
50
+ * #9302 upgrade's guard must match exactly (the in-page-magnets precedent).
51
+ */
52
+ const TRIAL_9301_DEFAULT = {
53
+ slug: "trial",
54
+ defaultLocale: "en",
55
+ steps: [
56
+ {
57
+ key: "about",
58
+ type: "field",
59
+ copy: { title: { en: "Free trial" }, cta: { en: "Request my trial" } },
60
+ fields: [
61
+ { name: "first_name", inputType: "text", required: true, mapsTo: "givenName", copy: { label: { en: "First name" } } },
62
+ { name: "last_name", inputType: "text", required: true, mapsTo: "familyName", copy: { label: { en: "Last name" } } },
63
+ { name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: { en: "Work email" } } },
64
+ {
65
+ name: "terms",
66
+ inputType: "consent",
67
+ required: true,
68
+ purposes: ["service_terms"],
69
+ copy: { label: { en: "I accept the [Terms](terms) and [Privacy Policy](privacy)." } },
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ ending: {
75
+ default: {
76
+ kind: "screen",
77
+ headline: { en: "Thanks, {name}." },
78
+ body: { en: "We have your request and will be in touch about your trial." },
79
+ },
80
+ },
81
+ landing: {
82
+ title: { en: "Free trial" },
83
+ description: { en: "Try it free for 14 days. No card needed." },
84
+ cta: { en: "Request a free trial" },
85
+ },
86
+ };
87
+
88
+ /** The text between `-- BEGIN <name>` and `-- END <name>`. */
89
+ function block(name: string, source: string = MIGRATION_SQL): string {
90
+ const match = new RegExp(
91
+ `-- BEGIN ${name}\\n([\\s\\S]*?)-- END ${name}`,
92
+ ).exec(source);
93
+ if (!match?.[1]) throw new Error(`${name} markers not found`);
94
+ return match[1];
95
+ }
96
+
97
+ /** A SQL string literal's body, `''` unescaped, parsed as JSON. */
98
+ const parseLiteral = (body: string): unknown =>
99
+ JSON.parse(body.replace(/''/g, "'"));
100
+
101
+ /** The SQL with every string literal blanked, so only its structure is left. */
102
+ const withoutLiterals = (sql: string): string =>
103
+ sql.replace(/'(?:[^']|'')*'/g, "''");
104
+
105
+ describe("the trial backfill (#9287 D11, #9301 AC-1)", () => {
106
+ const sql = block("trial backfill");
107
+
108
+ it("carries exactly one JSONB config literal, and it deep-equals #9301's shipped default", () => {
109
+ const literals = [...sql.matchAll(/'(\{[\s\S]*?\})'::jsonb/g)];
110
+ expect(literals).toHaveLength(1);
111
+ const parsed = parseLiteral(literals[0]![1]!);
112
+ expect(parsed).toEqual(TRIAL_9301_DEFAULT);
113
+ expect(flowConfigSchema.safeParse(parsed).success).toBe(true);
114
+ });
115
+
116
+ it("#9301's default is trialFlowConfig minus the proof line #9302 added", () => {
117
+ const { meta, ...landing } = trialFlowConfig.landing!;
118
+ expect(meta).toEqual({ en: "A person on our team reviews every request." });
119
+ expect({ ...trialFlowConfig, landing }).toEqual(TRIAL_9301_DEFAULT);
120
+ });
121
+
122
+ it("writes the constants' slug, title and landing page, kind diagnostic, live, the lead completion, into every workspace", () => {
123
+ expect(sql).toContain(
124
+ `INSERT INTO "flow_definitions" ("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "customers_enabled", "completion", "landing_page", "updated_at")`,
125
+ );
126
+ expect(sql).toContain(`SELECT w."id", w."id", '${TRIAL_FLOW_SLUG}',`);
127
+ expect(sql).toContain(
128
+ `'diagnostic', '${TRIAL_FLOW_TITLE}', 'live', false, 'lead', '${TRIAL_FLOW_LANDING_PAGE}', CURRENT_TIMESTAMP`,
129
+ );
130
+ expect(sql).toContain(`FROM "workspaces" w`);
131
+ });
132
+
133
+ it('ends ON CONFLICT ("site_id", "slug") DO NOTHING, so an existing row is never overwritten', () => {
134
+ expect(
135
+ sql.trimEnd().endsWith(`ON CONFLICT ("site_id", "slug") DO NOTHING;`),
136
+ ).toBe(true);
137
+ expect(sql).not.toMatch(/DO UPDATE/i);
138
+ });
139
+
140
+ it("the whole migration is that one INSERT — no UPDATE or DELETE anywhere", () => {
141
+ const statements = withoutLiterals(MIGRATION_SQL.replace(/^--.*$/gm, ""))
142
+ .split(";")
143
+ .filter((s) => s.trim().length > 0);
144
+ expect(statements).toHaveLength(1);
145
+ expect(statements[0]!.trim()).toMatch(/^INSERT INTO "flow_definitions" /);
146
+ expect(withoutLiterals(MIGRATION_SQL.replace(/^--.*$/gm, ""))).not.toMatch(
147
+ /\b(UPDATE|DELETE)\b/i,
148
+ );
149
+ });
150
+ });
151
+
152
+ describe("the trial proof-line upgrade (#9302)", () => {
153
+ const sql = block("trial proof-line upgrade", PROOF_MIGRATION_SQL);
154
+ const statements = withoutLiterals(sql).split(";").filter((s) => s.trim().length > 0);
155
+
156
+ it("is one UPDATE of flow_definitions, and the whole migration is only that", () => {
157
+ expect(statements).toHaveLength(1);
158
+ expect(statements[0]!.trim()).toMatch(/^UPDATE "flow_definitions" SET /);
159
+ const all = withoutLiterals(PROOF_MIGRATION_SQL.replace(/^--.*$/gm, ""))
160
+ .split(";")
161
+ .filter((s) => s.trim().length > 0);
162
+ expect(all).toHaveLength(1);
163
+ expect(withoutLiterals(PROOF_MIGRATION_SQL.replace(/^--.*$/gm, ""))).not.toMatch(
164
+ /\b(INSERT|DELETE)\b/i,
165
+ );
166
+ });
167
+
168
+ it("its SET literal deep-equals trialFlowConfig, and it sets only config and updated_at", () => {
169
+ const set = /SET "config" = '(\{[\s\S]*?\})'::jsonb/.exec(sql);
170
+ expect(set?.[1]).toEqual(expect.any(String));
171
+ const parsed = parseLiteral(set![1]!);
172
+ expect(parsed).toEqual(trialFlowConfig);
173
+ expect(flowConfigSchema.safeParse(parsed).success).toBe(true);
174
+ expect(withoutLiterals(sql)).toMatch(
175
+ /SET "config" = ''::jsonb, "updated_at" = CURRENT_TIMESTAMP\s+WHERE /,
176
+ );
177
+ });
178
+
179
+ it("its WHERE names the trial slug and matches only #9301's exact default, so an owner's edit is never touched", () => {
180
+ expect(withoutLiterals(sql)).toMatch(/WHERE "slug" = '' AND "config" = ''::jsonb;\s*$/);
181
+ expect(sql).toContain(`WHERE "slug" = '${TRIAL_FLOW_SLUG}' AND "config" = '`);
182
+ const guard = /AND "config" = '(\{[\s\S]*?\})'::jsonb;/.exec(sql);
183
+ expect(guard?.[1]).toEqual(expect.any(String));
184
+ expect(parseLiteral(guard![1]!)).toEqual(TRIAL_9301_DEFAULT);
185
+ });
186
+ });
@@ -55,6 +55,10 @@ model Organization {
55
55
  tier String @default("free") // "free" | "pro" | "pro_plus" | "enterprise"
56
56
  hasCarePlan Boolean @default(false) @map("has_care_plan")
57
57
  seatCount Int @default(1) @map("seat_count")
58
+ // #9067 D13 — the one plan Checkout this Organization has open, recorded by
59
+ // POST /v1/billing/plan-checkout. The webhook binds billingCustomerId only
60
+ // for this session, into an empty column, and clears it. Server-side only.
61
+ planCheckoutSessionId String? @unique @map("plan_checkout_session_id")
58
62
 
59
63
  // Usage-billing balance counters — the Company-counter live on-demand
60
64
  // credit model (FEAT-047 P1.S2, #3710 — capture D6, NOT a CreditUsagePeriod
@@ -175,6 +175,97 @@ model SchedulingConnection {
175
175
  @@map("scheduling_connections")
176
176
  }
177
177
 
178
+ /// StripeConnection — an Organization's own Stripe account, by a restricted
179
+ /// key (#8876 D1; contract docs/specs/8879-the-offerings-security-contract-key-spec.md
180
+ /// C1). One row per Organization on the SchedulingConnection shape: a plain
181
+ /// `organizationId @unique` scalar, no relation, no foreign key (ADR 0009
182
+ /// invariant 1). In hub-and-spoke every workspace reads its Organization's
183
+ /// one row; a single-tenant site's sentinel Organization holds its own — no
184
+ /// workspace-scoped key column exists anywhere. The key and the webhook
185
+ /// secret are `v1.<iv>.<tag>.<ct>` ciphertext (contract C2), written only by
186
+ /// apps/api's services/offerings/connection.ts; the migration's envelope
187
+ /// CHECKs refuse a value stored in clear. Every reader sees `mode` and
188
+ /// `keyLast4` only. `webhookToken` is a 256-bit routing id, not an
189
+ /// authenticator. Keep every `@map` in lockstep with the migration's column
190
+ /// names (see WorkspaceMeetings).
191
+ model StripeConnection {
192
+ id String @id @default(dbgenerated("('stripecon_'::text || (uuidv7())::text)")) @db.Text
193
+ organizationId String @unique @map("organization_id")
194
+ mode String @db.VarChar(8)
195
+ keyLast4 String @map("key_last4") @db.VarChar(4)
196
+ credentialCiphertext String @map("credential_ciphertext") @db.Text
197
+ webhookToken String @unique @map("webhook_token") @db.VarChar(64)
198
+ webhookSecretCiphertext String? @map("webhook_secret_ciphertext") @db.Text
199
+ liveAt DateTime? @map("live_at") @db.Timestamptz
200
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
201
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
202
+
203
+ @@map("stripe_connections")
204
+ }
205
+
206
+ /// OrganizationOfferings — the Stripe Products an Organization has switched
207
+ /// on for its sites (#8876 D1, C2): the org-enabled set. Its own row, not a
208
+ /// column on the connection, because disconnecting deletes the connection and
209
+ /// keeps this set (contract C8). One row per Organization; no row is the
210
+ /// empty set.
211
+ model OrganizationOfferings {
212
+ id String @id @default(dbgenerated("('orgoffer_'::text || (uuidv7())::text)")) @db.Text
213
+ organizationId String @unique @map("organization_id")
214
+ enabledProductIds String[] @default([]) @map("enabled_product_ids")
215
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
216
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
217
+
218
+ @@map("organization_offerings")
219
+ }
220
+
221
+ /// WorkspaceOfferings — what one workspace shows of its Organization's
222
+ /// enabled Products (#8876 D1): the shown set, Recommended per group, and the
223
+ /// last-good Stripe snapshot anonymous readers serve (contract T7). One row
224
+ /// per workspace, the WorkspaceMeetings shape. The two Recommended columns
225
+ /// must name a shown product (a CHECK), and `snapshot` and `snapshotAt` are
226
+ /// set together (a CHECK). `snapshot` is `offeringsSnapshotSchema`-shaped,
227
+ /// validated on read and write, never trusted. `shownPriceIds` (#9070 D1) is
228
+ /// the shown set at Price grain; empty means every Price of each shown
229
+ /// Product, the grain of a row saved before #9070.
230
+ model WorkspaceOfferings {
231
+ id String @id @default(dbgenerated("('wsoffer_'::text || (uuidv7())::text)")) @db.Text
232
+ workspaceId String @unique @map("workspace_id")
233
+ shownProductIds String[] @default([]) @map("shown_product_ids")
234
+ shownPriceIds String[] @default([]) @map("shown_price_ids")
235
+ recommendedPlanProductId String? @map("recommended_plan_product_id")
236
+ recommendedAddonProductId String? @map("recommended_addon_product_id")
237
+ snapshot Json?
238
+ snapshotAt DateTime? @map("snapshot_at") @db.Timestamptz
239
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
240
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
241
+
242
+ @@map("workspace_offerings")
243
+ }
244
+
245
+ /// OfferingsCheckoutSession — the Offerings Checkout session record (#8930
246
+ /// D1; contract docs/specs/8879-the-offerings-security-contract-key-spec.md
247
+ /// A11, W5, A13). One row per Checkout Session the hub created, written
248
+ /// before the checkout route answers, so the per-Organization webhook and the
249
+ /// session read act only on a session this hub recorded — never on metadata
250
+ /// alone. Exactly A11's six columns beside the id and createdAt; no
251
+ /// updatedAt, because no path updates a record. Plain scalar keys, no
252
+ /// relation, no foreign key (ADR 0009 invariant 1); the migration's CHECKs
253
+ /// bound every column. Written and read only by apps/api's
254
+ /// services/offerings/checkout-session-record.ts. Keep every `@map` in
255
+ /// lockstep with the migration's column names (see WorkspaceMeetings).
256
+ model OfferingsCheckoutSession {
257
+ id String @id @default(dbgenerated("('ocsess_'::text || (uuidv7())::text)")) @db.Text
258
+ sessionId String @unique @map("session_id") @db.Text
259
+ workspaceId String @map("workspace_id")
260
+ organizationId String @map("organization_id")
261
+ priceId String @map("price_id") @db.VarChar(255)
262
+ lookupKey String @map("lookup_key") @db.VarChar(48)
263
+ mode String @db.VarChar(8)
264
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
265
+
266
+ @@map("offerings_checkout_sessions")
267
+ }
268
+
178
269
  /// SiteListingConfig — per-SITE project-listings config blob (#5033).
179
270
  ///
180
271
  /// The fourth site-config model, and the one keyed on `siteId` rather than