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,400 @@
1
+ /**
2
+ * services/offerings — the owner's product list and the org-enabled write
3
+ * (#8922, spec docs/specs/8922-the-may-sell-product-list-spec.md D3, D4):
4
+ * `listOrgProducts` lists every active product with its enabled flag and
5
+ * held reason (AC-1), `setOrgEnabledProducts` refuses a held or unknown id
6
+ * and writes the set through `store.ts` alone (AC-2), a failed read or a
7
+ * failed response parse writes nothing (AC-4, AC-6), and the owner path
8
+ * shares the per-minute catalog read (AC-7).
9
+ *
10
+ * Every suite runs over `createInMemoryPrisma()` and #8876's seed, with
11
+ * `@working-theory/stripe-client` mocked so that touching any of its exports
12
+ * throws.
13
+ */
14
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
15
+ jest.mock("../../../lib/prisma.js", () =>
16
+ jest
17
+ .requireActual<typeof import("../../../__tests__/factories/in-memory-prisma")>(
18
+ "../../../__tests__/factories/in-memory-prisma",
19
+ )
20
+ .createInMemoryPrisma(),
21
+ );
22
+
23
+ jest.mock(
24
+ "@working-theory/stripe-client",
25
+ () =>
26
+ new Proxy(
27
+ {},
28
+ {
29
+ get: (_target, key) => {
30
+ if (key === "__esModule") return true;
31
+ throw new Error(`@working-theory/stripe-client.${String(key)} was touched`);
32
+ },
33
+ },
34
+ ),
35
+ );
36
+
37
+ const mockLog = jest.fn();
38
+ jest.mock("@working-theory/observability", () => ({
39
+ logger: {
40
+ error: (...args: unknown[]) => mockLog("error", ...args),
41
+ warn: (...args: unknown[]) => mockLog("warn", ...args),
42
+ info: (...args: unknown[]) => mockLog("info", ...args),
43
+ debug: (...args: unknown[]) => mockLog("debug", ...args),
44
+ },
45
+ }));
46
+
47
+ import { prisma } from "../../../lib/prisma.js";
48
+ import {
49
+ OfferingsShapeError,
50
+ OfferingsSourceError,
51
+ getOrgEnabledProducts,
52
+ listOrgProducts,
53
+ resetOfferingsRefreshStateForTests,
54
+ setOrgEnabledProducts,
55
+ type CatalogPrice,
56
+ type CatalogProduct,
57
+ type OfferingsSourceFor,
58
+ type OrgProductEntry,
59
+ } from "../index.js";
60
+ import { writeEnabledProductIds } from "../store.js";
61
+ import {
62
+ EXPECTED_FIXTURE_OFFERINGS,
63
+ FIXTURE_NOW,
64
+ FIXTURE_ORG,
65
+ FIXTURE_ORG_2,
66
+ OFFERINGS_FIXTURE_CATALOG,
67
+ refusingSourceFor,
68
+ seedOfferings,
69
+ } from "./fixtures.js";
70
+
71
+ type Row = Record<string, unknown>;
72
+ // The in-memory double exposes its store and recorded delegates, which the
73
+ // PrismaClient type the import carries does not name, so it is named here once.
74
+ // eslint-disable-next-line no-restricted-syntax -- justified above
75
+ const db = prisma as unknown as {
76
+ __db: Record<string, Row[]>;
77
+ organizationOfferings: { upsert: jest.Mock };
78
+ };
79
+
80
+ const SECOND = 1000;
81
+ const at = (offsetMs: number) => new Date(FIXTURE_NOW.getTime() + offsetMs);
82
+
83
+ const plan = (id: string, name: string, active = true): CatalogProduct => ({
84
+ id,
85
+ active,
86
+ name,
87
+ description: `${name} description`,
88
+ marketing_features: [],
89
+ unit_label: null,
90
+ metadata: { ailk_group: "plan" },
91
+ });
92
+ const price = (
93
+ id: string,
94
+ product: string,
95
+ lookupKey: string | null,
96
+ interval: "month" | "year",
97
+ ): CatalogPrice => ({
98
+ id,
99
+ active: true,
100
+ product,
101
+ lookup_key: lookupKey,
102
+ type: "recurring",
103
+ recurring: { interval, interval_count: 1, usage_type: "licensed" },
104
+ billing_scheme: "per_unit",
105
+ unit_amount: 1000,
106
+ currency: "usd",
107
+ });
108
+
109
+ /** #8876's fixture catalog plus the spec §5 additions (an inactive Product, a paging repeat, G3 and G4). */
110
+ const CASE_CATALOG = {
111
+ products: [
112
+ ...OFFERINGS_FIXTURE_CATALOG.products,
113
+ plan("prod_t_inactive", "Retired plan", false),
114
+ OFFERINGS_FIXTURE_CATALOG.products.find((p) => p.id === "prod_fixture_basic")!,
115
+ plan("prod_t_conflict_a", "Team A"),
116
+ plan("prod_t_conflict_b", "Team B"),
117
+ plan("prod_t_period", "Pro by period"),
118
+ plan("prod_t_nokey", "No key"),
119
+ ],
120
+ prices: [
121
+ ...OFFERINGS_FIXTURE_CATALOG.prices,
122
+ price("price_t_inactive", "prod_t_inactive", "retired_monthly", "month"),
123
+ price("price_t_conflict_a", "prod_t_conflict_a", "team_monthly", "month"),
124
+ price("price_t_conflict_b", "prod_t_conflict_b", "team_yearly", "year"),
125
+ price("price_t_period", "prod_t_period", "pro_monthly", "year"),
126
+ price("price_t_nokey", "prod_t_nokey", null, "month"),
127
+ ],
128
+ };
129
+
130
+ /** Every fixture string a log line or an error must never carry. */
131
+ const STRIPE_TEXT = [
132
+ ...CASE_CATALOG.products.flatMap((p) => [p.id, p.name, p.description] as unknown[]),
133
+ ...CASE_CATALOG.prices.map((p) => p.id),
134
+ "Unlimited pages",
135
+ "Priority support",
136
+ ].filter((v): v is string => typeof v === "string" && v.length > 0);
137
+
138
+ const sellable = (productId: string, enabled: boolean): OrgProductEntry => {
139
+ const o = EXPECTED_FIXTURE_OFFERINGS.find((x) => x.productId === productId)!;
140
+ return { productId, name: o.name, description: o.description, enabled, heldReason: null };
141
+ };
142
+ const held = (productId: string, name: string, heldReason: string): OrgProductEntry =>
143
+ ({ productId, name, description: null, enabled: false, heldReason }) as OrgProductEntry;
144
+
145
+ /** The list for the case catalog with `enabled` the stored set. */
146
+ function expectedList(enabled: string[]): OrgProductEntry[] {
147
+ const on = new Set(enabled);
148
+ return [
149
+ ...EXPECTED_FIXTURE_OFFERINGS.map((o) => sellable(o.productId, on.has(o.productId))),
150
+ held("prod_fixture_quarterly", "Pro quarterly", "needs_lookup_key"),
151
+ held("prod_fixture_legacy", "Legacy retainer", "group_invalid"),
152
+ held("prod_t_conflict_a", "Team A", "plan_conflict"),
153
+ held("prod_t_conflict_b", "Team B", "plan_conflict"),
154
+ held("prod_t_period", "Pro by period", "needs_lookup_key"),
155
+ held("prod_t_nokey", "No key", "needs_lookup_key"),
156
+ ];
157
+ }
158
+
159
+ /** A counting `sourceFor` answering the case catalog, or rejecting with `failWith`. */
160
+ function countingSource(failWith?: unknown) {
161
+ const asked: string[] = [];
162
+ let calls = 0;
163
+ const sourceFor: OfferingsSourceFor = (organizationId) => {
164
+ asked.push(organizationId);
165
+ return {
166
+ listActiveCatalog: async () => {
167
+ calls += 1;
168
+ if (failWith !== undefined) throw failWith;
169
+ return structuredClone(CASE_CATALOG);
170
+ },
171
+ };
172
+ };
173
+ return { sourceFor, asked, calls: () => calls };
174
+ }
175
+
176
+ function storedSet(organizationId = FIXTURE_ORG): unknown {
177
+ return db.__db.organizationOfferings?.find((r) => r.organizationId === organizationId)?.enabledProductIds;
178
+ }
179
+
180
+ function setStoredSet(ids: string[]): void {
181
+ const row = db.__db.organizationOfferings!.find((r) => r.organizationId === FIXTURE_ORG)!;
182
+ row.enabledProductIds = [...ids];
183
+ }
184
+
185
+ function corruptConnection(): void {
186
+ db.__db.stripeConnection!.find((r) => r.organizationId === FIXTURE_ORG)!.keyLast4 = "ab";
187
+ }
188
+
189
+ const noValidate = { validate: () => undefined };
190
+
191
+ beforeEach(async () => {
192
+ // With the multi-tenant module and no STRIPE_ENV_ORGANIZATION_ID nobody owns the
193
+ // environment key (#9285 AC-8), so "no connection" stays no connection here.
194
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
195
+ delete process.env.STRIPE_ENV_ORGANIZATION_ID;
196
+ for (const key of Object.keys(db.__db)) delete db.__db[key];
197
+ jest.clearAllMocks();
198
+ resetOfferingsRefreshStateForTests();
199
+ await seedOfferings(prisma, { now: FIXTURE_NOW });
200
+ setStoredSet(["prod_fixture_growth", "prod_fixture_quarterly"]);
201
+ });
202
+
203
+ afterEach(() => {
204
+ const text = JSON.stringify(mockLog.mock.calls);
205
+ for (const s of STRIPE_TEXT) expect(text).not.toContain(s);
206
+ for (const [, obj] of mockLog.mock.calls as Array<[string, Record<string, unknown>]>) {
207
+ for (const key of Object.keys(obj)) {
208
+ expect(["organizationId", "workspaceId", "outcome", "reason", "errName", "dropped", "tool"]).toContain(key);
209
+ }
210
+ }
211
+ });
212
+
213
+ // ─── AC-1 — every active product, its flag and its reason ────────────────────
214
+
215
+ describe("listOrgProducts (#8922 D3, AC-1)", () => {
216
+ it("lists every active, distinct product: sellable first, then held, each once", async () => {
217
+ const src = countingSource();
218
+ const list = await listOrgProducts(FIXTURE_ORG, src.sourceFor, { now: FIXTURE_NOW });
219
+ expect(list).toEqual({ connection: "connected", products: expectedList(["prod_fixture_growth"]) });
220
+ if (list.connection !== "connected") throw new Error("unreachable");
221
+ const ids = list.products.map((p) => p.productId);
222
+ expect(ids).not.toContain("prod_t_inactive");
223
+ expect(ids.filter((id) => id === "prod_fixture_basic")).toHaveLength(1);
224
+ for (const entry of list.products) {
225
+ expect(Object.keys(entry).sort()).toEqual(["description", "enabled", "heldReason", "name", "productId"]);
226
+ }
227
+ // A held product in the stored set reads off (CL5).
228
+ expect(list.products.find((p) => p.productId === "prod_fixture_quarterly")!.enabled).toBe(false);
229
+ expect(JSON.stringify(list)).not.toMatch(/example\.com|price_/);
230
+ expect(src.calls()).toBe(1);
231
+ });
232
+
233
+ it("answers none with no connection, without building a source", async () => {
234
+ await expect(listOrgProducts(FIXTURE_ORG_2, refusingSourceFor, { now: FIXTURE_NOW })).resolves.toEqual({
235
+ connection: "none",
236
+ });
237
+ });
238
+
239
+ it("answers unreadable for a corrupt connection, never none, without building a source", async () => {
240
+ corruptConnection();
241
+ await expect(listOrgProducts(FIXTURE_ORG, refusingSourceFor, { now: FIXTURE_NOW })).resolves.toEqual({
242
+ connection: "unreadable",
243
+ });
244
+ });
245
+
246
+ it("turns a source failure into OfferingsSourceError carrying no Stripe text and no cause", async () => {
247
+ const keyShaped = ["rk", "test", "QzQjVvWwKkXxYyZzQqJjPpBb"].join("_");
248
+ const src = countingSource(new Error(`Invalid API Key provided: ${keyShaped}`));
249
+ const err = await listOrgProducts(FIXTURE_ORG, src.sourceFor, { now: FIXTURE_NOW }).catch((e: unknown) => e);
250
+ expect(err).toBeInstanceOf(OfferingsSourceError);
251
+ expect(JSON.stringify(Object.entries(err as object))).not.toContain(keyShaped);
252
+ expect((err as Error).message).not.toContain(keyShaped);
253
+ expect((err as { cause?: unknown }).cause).toBeUndefined();
254
+ expect(mockLog).toHaveBeenCalledWith(
255
+ "warn",
256
+ { organizationId: FIXTURE_ORG, outcome: "source_failed", errName: "Error" },
257
+ expect.any(String),
258
+ );
259
+ });
260
+ });
261
+
262
+ // ─── AC-2 — the write, through store.ts only ────────────────────────────────
263
+
264
+ describe("setOrgEnabledProducts (#8922 D3, D4, AC-2)", () => {
265
+ it("writes the set, answers the previous set and the list for the new set", async () => {
266
+ const src = countingSource();
267
+ const ids = ["prod_fixture_basic", "prod_fixture_seo_audit"];
268
+ const result = await setOrgEnabledProducts(FIXTURE_ORG, ids, src.sourceFor, { now: FIXTURE_NOW, ...noValidate });
269
+ expect(result).toEqual({
270
+ kind: "written",
271
+ previous: ["prod_fixture_growth", "prod_fixture_quarterly"],
272
+ list: { connection: "connected", products: expectedList(ids) },
273
+ });
274
+ expect(storedSet()).toEqual(ids);
275
+ expect(db.organizationOfferings.upsert).toHaveBeenCalledTimes(1);
276
+
277
+ // Read back from the row, with no further Stripe call (CL4).
278
+ const read = await getOrgEnabledProducts(FIXTURE_ORG, src.sourceFor, { now: at(30 * SECOND) });
279
+ expect(read!.productIds).toEqual(ids);
280
+ expect(src.calls()).toBe(1);
281
+ });
282
+
283
+ it.each([
284
+ ["prod_fixture_quarterly", "product_held"],
285
+ ["prod_t_period", "product_held"],
286
+ ["prod_t_nokey", "product_held"],
287
+ ["prod_t_conflict_a", "product_held"],
288
+ ["prod_t_conflict_b", "product_held"],
289
+ ["prod_fixture_legacy", "product_held"],
290
+ ["prod_t_inactive", "product_not_in_catalog"],
291
+ ["prod_absent", "product_not_in_catalog"],
292
+ ])("refuses %s as %s and writes nothing", async (id, reason) => {
293
+ const result = await setOrgEnabledProducts(
294
+ FIXTURE_ORG,
295
+ ["prod_fixture_basic", id],
296
+ countingSource().sourceFor,
297
+ { now: FIXTURE_NOW, ...noValidate },
298
+ );
299
+ expect(result).toEqual({ kind: "refused", reason });
300
+ expect(db.organizationOfferings.upsert).not.toHaveBeenCalled();
301
+ expect(storedSet()).toEqual(["prod_fixture_growth", "prod_fixture_quarterly"]);
302
+ });
303
+
304
+ it("answers product_not_in_catalog when a set holds both an unknown and a held id", async () => {
305
+ const result = await setOrgEnabledProducts(
306
+ FIXTURE_ORG,
307
+ ["prod_fixture_quarterly", "prod_absent"],
308
+ countingSource().sourceFor,
309
+ { now: FIXTURE_NOW, ...noValidate },
310
+ );
311
+ expect(result).toEqual({ kind: "refused", reason: "product_not_in_catalog" });
312
+ });
313
+
314
+ it("refuses with no connection and with a corrupt one, without building a source or writing", async () => {
315
+ await expect(
316
+ setOrgEnabledProducts(FIXTURE_ORG_2, [], refusingSourceFor, { now: FIXTURE_NOW, ...noValidate }),
317
+ ).resolves.toEqual({ kind: "not_connected" });
318
+ corruptConnection();
319
+ await expect(
320
+ setOrgEnabledProducts(FIXTURE_ORG, [], refusingSourceFor, { now: FIXTURE_NOW, ...noValidate }),
321
+ ).resolves.toEqual({ kind: "connection_unreadable" });
322
+ expect(db.organizationOfferings.upsert).not.toHaveBeenCalled();
323
+ });
324
+
325
+ it("writes nothing when the response parse throws (D3 step 4, AC-4)", async () => {
326
+ const validate = jest.fn(() => {
327
+ throw new Error("response failed its schema");
328
+ });
329
+ await expect(
330
+ setOrgEnabledProducts(FIXTURE_ORG, ["prod_fixture_basic"], countingSource().sourceFor, {
331
+ now: FIXTURE_NOW,
332
+ validate,
333
+ }),
334
+ ).rejects.toThrow("response failed its schema");
335
+ expect(validate).toHaveBeenCalledTimes(1);
336
+ expect(db.organizationOfferings.upsert).not.toHaveBeenCalled();
337
+ expect(storedSet()).toEqual(["prod_fixture_growth", "prod_fixture_quarterly"]);
338
+ });
339
+
340
+ it("stores an empty set, creating the row for an Organization that had none", async () => {
341
+ db.__db.organizationOfferings = [];
342
+ const result = await setOrgEnabledProducts(FIXTURE_ORG, [], countingSource().sourceFor, {
343
+ now: FIXTURE_NOW,
344
+ ...noValidate,
345
+ });
346
+ expect(result).toMatchObject({ kind: "written", previous: [] });
347
+ expect(storedSet()).toEqual([]);
348
+ });
349
+ });
350
+
351
+ describe("writeEnabledProductIds (#8922 D4)", () => {
352
+ it.each([
353
+ ["101 ids", Array.from({ length: 101 }, (_, i) => `prod_${i}`)],
354
+ ["a duplicate", ["prod_a", "prod_a"]],
355
+ ["a malformed id", ["bad id!"]],
356
+ ])("refuses %s with OfferingsShapeError, writing nothing", async (_label, ids) => {
357
+ await expect(writeEnabledProductIds(FIXTURE_ORG, ids)).rejects.toBeInstanceOf(OfferingsShapeError);
358
+ expect(db.organizationOfferings.upsert).not.toHaveBeenCalled();
359
+ });
360
+ });
361
+
362
+ // ─── AC-7 — one catalog read per Organization per minute ────────────────────
363
+
364
+ describe("the owner path shares the per-minute read (#8922 D3, AC-7)", () => {
365
+ it("serves a list, a write and a read inside one minute from one source call", async () => {
366
+ const src = countingSource();
367
+ await listOrgProducts(FIXTURE_ORG, src.sourceFor, { now: at(0) });
368
+ await setOrgEnabledProducts(FIXTURE_ORG, ["prod_fixture_basic"], src.sourceFor, {
369
+ now: at(20 * SECOND),
370
+ ...noValidate,
371
+ });
372
+ await getOrgEnabledProducts(FIXTURE_ORG, src.sourceFor, { now: at(40 * SECOND) });
373
+ expect(src.calls()).toBe(1);
374
+
375
+ await Promise.all(
376
+ Array.from({ length: 5 }, () => listOrgProducts(FIXTURE_ORG, src.sourceFor, { now: at(61 * SECOND) })),
377
+ );
378
+ expect(src.calls()).toBe(2);
379
+ expect(new Set(src.asked)).toEqual(new Set([FIXTURE_ORG]));
380
+ });
381
+
382
+ it("holds the Organization for a minute after a failed read, and writes nothing", async () => {
383
+ const failing = countingSource(new Error("stripe down"));
384
+ await expect(listOrgProducts(FIXTURE_ORG, failing.sourceFor, { now: at(0) })).rejects.toBeInstanceOf(
385
+ OfferingsSourceError,
386
+ );
387
+ const fresh = countingSource();
388
+ await expect(listOrgProducts(FIXTURE_ORG, fresh.sourceFor, { now: at(30 * SECOND) })).rejects.toBeInstanceOf(
389
+ OfferingsSourceError,
390
+ );
391
+ await expect(
392
+ setOrgEnabledProducts(FIXTURE_ORG, ["prod_fixture_basic"], fresh.sourceFor, {
393
+ now: at(30 * SECOND),
394
+ ...noValidate,
395
+ }),
396
+ ).rejects.toBeInstanceOf(OfferingsSourceError);
397
+ expect(fresh.calls()).toBe(0);
398
+ expect(db.organizationOfferings.upsert).not.toHaveBeenCalled();
399
+ });
400
+ });