create-ailk 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/component-catalog.json +149 -5
  2. package/component-catalog.md +7 -6
  3. package/dist/add-module.js +11 -9
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +7 -5
  8. package/dist/derive-architecture.js +9 -32
  9. package/dist/lib/design-checks.js +4 -2
  10. package/dist/lib/env-catalog.js +0 -4
  11. package/dist/lib/npm-auth.d.ts +9 -0
  12. package/dist/lib/npm-auth.js +48 -0
  13. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  14. package/dist/lib/scaffold-render-sweep.js +5 -8
  15. package/dist/module-architecture.js +98 -10
  16. package/dist/paid-sections.d.ts +21 -2
  17. package/dist/paid-sections.js +69 -7
  18. package/dist/seed-flows.d.ts +4 -1
  19. package/dist/seed-flows.js +50 -4
  20. package/dist/spoke-checkout.d.ts +38 -35
  21. package/dist/spoke-checkout.js +66 -229
  22. package/dist/starter-grid.d.ts +0 -3
  23. package/dist/starter-grid.js +24 -14
  24. package/dist/surfaces.js +33 -12
  25. package/dist/sync-routes.d.ts +38 -1
  26. package/dist/sync-routes.js +184 -11
  27. package/dist/template-record.d.ts +1 -1
  28. package/dist/template-record.js +3 -2
  29. package/dist/update.js +10 -0
  30. package/package.json +3 -3
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  32. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  35. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  37. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  38. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  40. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  41. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  43. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/templates/.env.example +9 -7
  48. package/templates/.github/workflows/ci.yml +13 -0
  49. package/templates/README.md +1 -0
  50. package/templates/_gitignore +4 -0
  51. package/templates/apps/api/.env.example +27 -0
  52. package/templates/apps/api/CLAUDE.md +77 -16
  53. package/templates/apps/api/package.json +1 -0
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  55. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  56. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  57. package/templates/apps/api/src/config/index.ts +109 -0
  58. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  59. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  60. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  61. package/templates/apps/api/src/lib/stripe.ts +74 -4
  62. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  63. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  64. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  65. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  66. package/templates/apps/api/src/openapi/spec.ts +10 -7
  67. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  68. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  69. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  70. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  71. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  72. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  73. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  74. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  75. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  76. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  77. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  78. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  79. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  80. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  81. package/templates/apps/api/src/routes/content/index.ts +16 -2
  82. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  83. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  84. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  85. package/templates/apps/api/src/routes/leads/index.ts +17 -14
  86. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  87. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  88. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  89. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  90. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  91. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  92. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  93. package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
  94. package/templates/apps/api/src/server.ts +140 -51
  95. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  96. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  97. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  98. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  99. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  100. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  101. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  102. package/templates/apps/api/src/services/entitlements.ts +157 -1
  103. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  104. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  105. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  106. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  107. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  108. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  109. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  110. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  111. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  112. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  113. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  114. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  115. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  116. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  117. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  118. package/templates/apps/api/src/services/person.ts +124 -9
  119. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  120. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  121. package/templates/apps/mcp/CLAUDE.md +22 -14
  122. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  123. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  124. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  125. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  126. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  127. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  128. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  129. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  130. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  131. package/templates/apps/mcp/src/server.ts +5 -0
  132. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  133. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  134. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  135. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  136. package/templates/apps/web/.env.example +10 -31
  137. package/templates/apps/web/CLAUDE.md +315 -42
  138. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  139. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  140. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  141. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  142. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  143. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  144. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  145. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  146. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  147. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  148. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  149. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  150. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  151. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  152. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  153. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  154. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
  155. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  156. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  157. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  158. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  159. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  160. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  161. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  162. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  163. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  164. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  165. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  166. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  167. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  168. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  169. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  170. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  171. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  172. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  173. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  174. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  175. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  176. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  177. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  178. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  179. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  180. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  181. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  182. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  183. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  184. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  185. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  187. package/templates/apps/web/lib/auth-routes.ts +36 -2
  188. package/templates/apps/web/lib/checkout-config.ts +9 -20
  189. package/templates/apps/web/lib/deck-page.ts +1 -0
  190. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  191. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  192. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  195. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  196. package/templates/apps/web/lib/posting-title.ts +17 -0
  197. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  198. package/templates/apps/web/messages/en.json +49 -15
  199. package/templates/apps/web/middleware.ts +12 -2
  200. package/templates/apps/web/security-headers.config.ts +17 -0
  201. package/templates/content/en/BlogList/blog.mdx +4 -71
  202. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  203. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  204. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  205. package/templates/content/en/BookCall/start-project.mdx +1 -17
  206. package/templates/content/en/ContactPage/contact.mdx +6 -63
  207. package/templates/content/en/HomePage/home.mdx +3 -21
  208. package/templates/database/CHANGELOG.md +31 -0
  209. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  210. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  211. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  212. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  213. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  214. package/templates/database/__tests__/seed.test.ts +31 -1
  215. package/templates/database/auth/schema.prisma +4 -0
  216. package/templates/database/config/schema.prisma +91 -0
  217. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  218. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  219. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  220. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  221. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  222. package/templates/database/package.json +1 -1
  223. package/templates/database/seed.ts +30 -2
  224. package/versions.json +35 -35
  225. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  226. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  227. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  228. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  229. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  230. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  231. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  232. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  233. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  234. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  235. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  236. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  237. package/templates/apps/web/lib/checkout.ts +0 -93
  238. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,444 @@
1
+ /**
2
+ * @file plan-offerings.test.ts
3
+ *
4
+ * The plan offerings re-sourced from the Offerings shown set (#8934, spec
5
+ * docs/specs/8934-re-source-the-plan-offerings-spec.md D1–D6; AC-1, AC-3,
6
+ * AC-4, AC-5): a connected, claimed workspace lists one item per sellable
7
+ * Price in its shown set, keyed by the Price's full lookup key; an unconnected
8
+ * or unclaimed workspace keeps the page list; a licence or snapshot that
9
+ * cannot be read answers `{ failed }`; and the three consumers accept a
10
+ * listed key with no change to their own code.
11
+ *
12
+ * Runs over `createInMemoryPrisma()` and #8876's `seedOfferings`. External
13
+ * dependency boundaries mocked:
14
+ * - apps/api/src/lib/prisma — the in-memory, where-clause-honouring double.
15
+ * Its `lead.count` is arranged per test: the purchase email's cap reads
16
+ * with `mode: "insensitive"`, a filter the double does not model.
17
+ * - @working-theory/license — `checkLicense` per case; a keyless storage
18
+ * file (`absent`).
19
+ * - @working-theory/stripe-client — touching any export throws.
20
+ * - @working-theory/email — the operator notification and purchase email.
21
+ * - public-site-resolver's `workspaceContentAdapter` — a fixture adapter
22
+ * stands in for the workspace's content store.
23
+ */
24
+ import { inspect } from "node:util";
25
+
26
+ // 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.
27
+ jest.mock("../../lib/prisma.js", () =>
28
+ jest
29
+ .requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
30
+ "../../__tests__/factories/in-memory-prisma",
31
+ )
32
+ .createInMemoryPrisma(),
33
+ );
34
+
35
+ const mockCheckLicense = jest.fn();
36
+ jest.mock("@working-theory/license", () => ({
37
+ checkLicense: (...args: unknown[]) => mockCheckLicense(...args),
38
+ readStoredJwtState: () => Promise.resolve({ state: "absent" }),
39
+ }));
40
+
41
+ jest.mock(
42
+ "@working-theory/stripe-client",
43
+ () =>
44
+ new Proxy(
45
+ {},
46
+ {
47
+ get: (_target, key) => {
48
+ if (key === "__esModule") return true;
49
+ throw new Error(`@working-theory/stripe-client.${String(key)} was touched`);
50
+ },
51
+ },
52
+ ),
53
+ );
54
+
55
+ const mockSendLeadNotification = jest.fn();
56
+ const mockSendPurchaseConfirmationEmail = jest.fn();
57
+ jest.mock("@working-theory/email", () => ({
58
+ sendLeadNotification: (...args: unknown[]) => mockSendLeadNotification(...args),
59
+ sendPurchaseConfirmationEmail: (...args: unknown[]) =>
60
+ mockSendPurchaseConfirmationEmail(...args),
61
+ }));
62
+
63
+ const mockWorkspaceContentAdapter = jest.fn();
64
+ // eslint-disable-next-line no-restricted-syntax -- the content adapter seam: a fixture adapter stands in for the workspace's content store
65
+ jest.mock("../public-site-resolver.js", () => ({
66
+ ...jest.requireActual("../public-site-resolver.js"),
67
+ workspaceContentAdapter: (...args: unknown[]) => mockWorkspaceContentAdapter(...args),
68
+ }));
69
+
70
+ import { logger } from "@working-theory/observability";
71
+ import {
72
+ parseLookupKey,
73
+ planOfferingItemSchema,
74
+ planOfferingSchema,
75
+ planOfferingsResponseSchema,
76
+ } from "@working-theory/validation";
77
+
78
+ import { prisma } from "../../lib/prisma.js";
79
+ import { routeLead } from "../lead-routing";
80
+ import { magnetCompletionFor } from "../magnet-completion";
81
+ import {
82
+ FIXTURE_ORG,
83
+ FIXTURE_ORG_2_WS,
84
+ FIXTURE_WS,
85
+ FIXTURE_WS_2,
86
+ OFFERINGS_FIXTURE_CATALOG,
87
+ seedOfferings,
88
+ } from "../offerings/__tests__/fixtures.js";
89
+ import { normalizeCatalog, type CatalogPrice, type CatalogProduct } from "../offerings/index.js";
90
+ import { resolveMemberPersonId } from "../person";
91
+ import { listPlanOfferings } from "../plan-offerings";
92
+
93
+ type Row = Record<string, unknown>;
94
+ // The in-memory double's own surface (`__db`, `__calls`) and its recorded
95
+ // delegates are not on the PrismaClient type the import carries, so it is
96
+ // named here once.
97
+ // eslint-disable-next-line no-restricted-syntax -- justified above
98
+ const store = prisma as unknown as {
99
+ __db: Record<string, Row[]>;
100
+ __calls: Array<{ model: string; op: string }>;
101
+ lead: Record<string, jest.Mock>;
102
+ workspaceOfferings: Record<string, jest.Mock>;
103
+ };
104
+
105
+ const CLAIMED = { tier: "pro", plan: null, features: ["commerce"], expiresAt: null, revoked: false };
106
+ const FREE = { tier: "free", plan: null, features: [], expiresAt: null, revoked: false };
107
+ const HOUR_MS = 60 * 60 * 1000;
108
+
109
+ /** D1's table for the seed. */
110
+ const SHOWN_SET_ITEMS = [
111
+ { key: "basic_once", name: "Basic (One-time)", type: "plan" },
112
+ { key: "growth_monthly", name: "Growth (Monthly)", type: "plan" },
113
+ { key: "growth_yearly", name: "Growth (Annual)", type: "plan" },
114
+ { key: "seo-audit_monthly", name: "SEO audit (Monthly)", type: "addon" },
115
+ ];
116
+
117
+ // The route suite's fixture pages and their keying (#8514 D9).
118
+ const page = (slug: string, title: string) => ({ slug, title });
119
+ const SERVICES = [
120
+ page("services/retainer", "Monthly retainer"),
121
+ page("services/Bad Key!", "Not a key"),
122
+ page("audit", "Brand audit"),
123
+ ];
124
+ const PRODUCTS = [page("products/starter-kit", "Starter kit"), page("retainer", "Duplicate retainer")];
125
+ const PAGE_ITEMS = [
126
+ { key: "retainer", name: "Monthly retainer", type: "service" },
127
+ { key: "audit", name: "Brand audit", type: "service" },
128
+ { key: "starter-kit", name: "Starter kit", type: "product" },
129
+ ];
130
+ const fixtureAdapter = () => ({
131
+ listPages: jest.fn(async (type: string) =>
132
+ type === "Service" ? SERVICES : type === "Product" ? PRODUCTS : [],
133
+ ),
134
+ });
135
+
136
+ const OFFERINGS_MODELS = ["stripeConnection", "organizationOfferings", "workspaceOfferings"];
137
+ const offeringsReads = () => store.__calls.filter((c) => OFFERINGS_MODELS.includes(c.model));
138
+
139
+ const enabledRow = () =>
140
+ store.__db["organizationOfferings"]!.find((r) => r["organizationId"] === FIXTURE_ORG)!;
141
+ const offeringsRow = () =>
142
+ store.__db["workspaceOfferings"]!.find((r) => r["workspaceId"] === FIXTURE_WS)!;
143
+ const interactions = () => (store.__db["interaction"] ??= []);
144
+ const people = () => (store.__db["person"] ??= []);
145
+
146
+ // ─── Logs: no line carries Stripe text (AC-5) ───────────────────────────────
147
+
148
+ const EMOJI_NAME = "\u{1F600}".repeat(200);
149
+ const FORBIDDEN_LOG_TEXT = [
150
+ ...OFFERINGS_FIXTURE_CATALOG.products.flatMap((p) => [
151
+ p.name,
152
+ ...(p.description ? [p.description] : []),
153
+ ...(p.marketing_features ?? []).map((f) => f.name),
154
+ ]),
155
+ EMOJI_NAME.slice(0, 20),
156
+ "prod_",
157
+ "price_",
158
+ ];
159
+ const LOGGER_METHODS = ["error", "warn", "info", "debug"] as const;
160
+ const CONSOLE_METHODS = ["log", "info", "warn", "error", "debug"] as const;
161
+ let spies: jest.SpyInstance[] = [];
162
+
163
+ function leakedLines(): string[] {
164
+ return spies
165
+ .flatMap((spy) => spy.mock.calls.map((args) => inspect(args, { depth: 8 })))
166
+ .filter((line) => FORBIDDEN_LOG_TEXT.some((text) => line.includes(text)));
167
+ }
168
+
169
+ // ─── The lead route (AC-3) ───────────────────────────────────────────────────
170
+
171
+ const BUYER = "buyer@x.io";
172
+ const U = "user_00000000-0000-7000-8000-000000008934";
173
+ let keySeq = 0;
174
+ const sourceMeta = { ip: "127.0.0.1", userAgent: "jest", receivedAt: new Date().toISOString(), viaSpoke: true };
175
+
176
+ /** A marked spoke checkout lead (#8793's shape) for `offering`, from slug site `wendi`. */
177
+ async function checkoutLead(offering: string): Promise<void> {
178
+ keySeq += 1;
179
+ await routeLead(
180
+ {
181
+ kind: "submit",
182
+ siteId: FIXTURE_WS,
183
+ source: "web",
184
+ email: BUYER,
185
+ payload: { site: "wendi", page: "checkout", offering, amountTotal: 900, currency: "usd" },
186
+ },
187
+ sourceMeta,
188
+ { idempotencyKey: `cs_live_h8934Plan${keySeq}` }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
189
+ );
190
+ }
191
+
192
+ /** The newest purchase message's body. */
193
+ const lastPurchaseBody = () => String(interactions().at(-1)?.["body"]);
194
+ const GENERIC = /^Thanks for your purchase\./;
195
+
196
+ const ENV_KEYS = [
197
+ "DATABASE_URL",
198
+ "NODE_ENV",
199
+ "AILK_LICENSE_KEY",
200
+ "AILK_LEAD_NOTIFICATION_TO",
201
+ "AILK_LEAD_NOTIFICATION_FROM",
202
+ "AILK_DELIVERABLE_EMAIL_FROM",
203
+ ] as const;
204
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
205
+
206
+ beforeEach(async () => {
207
+ for (const key of Object.keys(store.__db)) delete store.__db[key];
208
+ saved = {};
209
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
210
+ process.env.DATABASE_URL = "postgres://stub";
211
+ delete process.env.NODE_ENV;
212
+ delete process.env.AILK_LICENSE_KEY;
213
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
214
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
215
+ process.env.AILK_DELIVERABLE_EMAIL_FROM = "orders@example.com";
216
+ mockCheckLicense.mockReset().mockResolvedValue(CLAIMED);
217
+ mockWorkspaceContentAdapter.mockReset().mockImplementation(async () => fixtureAdapter());
218
+ mockSendLeadNotification.mockReset().mockResolvedValue({ ok: true, messageId: "m" });
219
+ mockSendPurchaseConfirmationEmail.mockReset().mockResolvedValue({ ok: true, messageId: "m2" });
220
+ store.lead["count"]!.mockReset().mockResolvedValue(1);
221
+ await seedOfferings(prisma);
222
+ store.__calls.length = 0;
223
+ spies = [
224
+ ...LOGGER_METHODS.map((m) => jest.spyOn(logger, m).mockImplementation(() => undefined as never)),
225
+ ...CONSOLE_METHODS.map((m) => jest.spyOn(console, m).mockImplementation(() => undefined)),
226
+ ];
227
+ });
228
+
229
+ afterEach(() => {
230
+ const leaked = leakedLines();
231
+ for (const spy of spies) spy.mockRestore();
232
+ for (const k of ENV_KEYS) {
233
+ if (saved[k] === undefined) delete process.env[k];
234
+ else process.env[k] = saved[k];
235
+ }
236
+ // AC-5: across every case, no log or console line carries a product name,
237
+ // description, feature string, or a `prod_` or `price_` id.
238
+ expect(leaked).toEqual([]);
239
+ });
240
+
241
+ describe("a connected workspace lists its shown set's lookup keys, and only those (AC-1)", () => {
242
+ it("one item per sellable Price, keyed by its full lookup key, with no page and no Stripe call", async () => {
243
+ const offerings = await listPlanOfferings(FIXTURE_WS);
244
+ expect(offerings).toEqual(SHOWN_SET_ITEMS);
245
+ if (!Array.isArray(offerings)) throw new Error("expected a list");
246
+ for (const { key } of offerings) {
247
+ expect(parseLookupKey(key)).not.toBeNull();
248
+ expect(planOfferingSchema.safeParse(key).success).toBe(true);
249
+ }
250
+ expect(() => planOfferingsResponseSchema.parse({ offerings })).not.toThrow();
251
+ expect(mockWorkspaceContentAdapter).not.toHaveBeenCalled();
252
+ // The bidi add-on is in the catalog but neither enabled nor shown.
253
+ expect(offerings.map((o) => o.key)).not.toContain("audit-plus_monthly");
254
+ });
255
+
256
+ it("a product switched off in the Organization's enabled set is absent on the next call", async () => {
257
+ const { snapshot, snapshotAt } = offeringsRow();
258
+ enabledRow()["enabledProductIds"] = (enabledRow()["enabledProductIds"] as string[]).filter(
259
+ (id) => id !== "prod_fixture_seo_audit",
260
+ );
261
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
262
+ SHOWN_SET_ITEMS.filter((item) => item.key !== "seo-audit_monthly"),
263
+ );
264
+ expect(offeringsRow()["snapshot"]).toBe(snapshot);
265
+ expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
266
+ });
267
+
268
+ it("a product switched off in the workspace's shown set is absent on the next call", async () => {
269
+ const { snapshot, snapshotAt } = offeringsRow();
270
+ offeringsRow()["shownProductIds"] = (offeringsRow()["shownProductIds"] as string[]).filter(
271
+ (id) => id !== "prod_fixture_growth",
272
+ );
273
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
274
+ SHOWN_SET_ITEMS.filter((item) => !item.key.startsWith("growth_")),
275
+ );
276
+ expect(offeringsRow()["snapshot"]).toBe(snapshot);
277
+ expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
278
+ });
279
+
280
+ it("an empty shown set lists none: a fact, not a failure", async () => {
281
+ offeringsRow()["shownProductIds"] = [];
282
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual([]);
283
+ });
284
+ });
285
+
286
+ describe("the consumers accept a listed key with no change to their own code (AC-3)", () => {
287
+ it("(a) the purchase message names a listed lookup key, and only a listed one", async () => {
288
+ await checkoutLead("growth_monthly");
289
+ expect(interactions()).toHaveLength(1);
290
+ expect(lastPurchaseBody()).toContain("Growth (Monthly)");
291
+
292
+ await checkoutLead("growth_quarterly");
293
+ expect(lastPurchaseBody()).toMatch(GENERIC);
294
+ // A page the adapter lists is not a connected workspace's offering.
295
+ await checkoutLead("retainer");
296
+ expect(lastPurchaseBody()).toMatch(GENERIC);
297
+ expect(interactions()).toHaveLength(3);
298
+ });
299
+
300
+ it("(b) the held plan released at the verified first link is a key the list names", async () => {
301
+ await checkoutLead("growth_monthly");
302
+ (store.__db["user"] ??= []).push({ id: U, email: BUYER, emailVerified: true });
303
+ const personId = await resolveMemberPersonId(FIXTURE_WS, U, BUYER);
304
+ const buyer = people().find((p) => p["id"] === personId)!;
305
+ expect(buyer["planOffering"]).toBe("growth_monthly");
306
+ const offerings = await listPlanOfferings(FIXTURE_WS);
307
+ if (!Array.isArray(offerings)) throw new Error("expected a list");
308
+ expect(offerings.find((o) => o.key === buyer["planOffering"])?.name).toBe("Growth (Monthly)");
309
+ });
310
+
311
+ it("(c) a checkout magnet completes on an unconnected workspace's page key, and degrades to lead on a connected one", async () => {
312
+ const magnet = (workspaceId: string, checkoutOffering: string): Row => ({
313
+ id: `flow_${workspaceId.slice(-4)}`,
314
+ workspaceId,
315
+ siteId: workspaceId,
316
+ slug: "buy",
317
+ status: "live",
318
+ title: "Buy",
319
+ config: {},
320
+ completion: "checkout",
321
+ checkoutOffering,
322
+ });
323
+ store.__db["flowDefinition"] = [magnet(FIXTURE_ORG_2_WS, "retainer"), magnet(FIXTURE_WS, "growth")];
324
+ const noTarget = () => Promise.resolve(null);
325
+ expect(await magnetCompletionFor(FIXTURE_ORG_2_WS, "buy", noTarget)).toMatchObject({
326
+ next: { completion: "checkout", checkoutOffering: "retainer" },
327
+ });
328
+ expect(await magnetCompletionFor(FIXTURE_WS, "buy", noTarget)).toBeNull();
329
+ });
330
+
331
+ describe("(d) a licence or snapshot that cannot be read is { failed }, never a pass", () => {
332
+ const failures: Array<[string, () => Promise<void>, string]> = [
333
+ [
334
+ "an undetermined licence",
335
+ async () => {
336
+ mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
337
+ },
338
+ "LicenseUndetermined",
339
+ ],
340
+ [
341
+ "a snapshot 25 hours old",
342
+ async () => {
343
+ for (const key of Object.keys(store.__db)) delete store.__db[key];
344
+ await seedOfferings(prisma, { now: new Date(Date.now() - 25 * HOUR_MS) });
345
+ },
346
+ "OfferingsSnapshotExpired",
347
+ ],
348
+ [
349
+ "an unreadable connection",
350
+ async () => {
351
+ store.__db["stripeConnection"]![0]!["mode"] = "prod";
352
+ },
353
+ "OfferingsSnapshotEmpty",
354
+ ],
355
+ [
356
+ "a selection read that rejects",
357
+ async () => {
358
+ store.workspaceOfferings["findUnique"]!.mockRejectedValueOnce(new Error("db down"));
359
+ },
360
+ "Error",
361
+ ],
362
+ ];
363
+
364
+ it.each(failures)("%s", async (_label, arm, failed) => {
365
+ await arm();
366
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed });
367
+ await arm();
368
+ await checkoutLead("growth_monthly");
369
+ expect(lastPurchaseBody()).toMatch(GENERIC);
370
+ });
371
+
372
+ it("an undetermined licence reads no Offerings or workspace row", async () => {
373
+ mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
374
+ store.__calls.length = 0;
375
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed: "LicenseUndetermined" });
376
+ expect(store.__calls.filter((c) => [...OFFERINGS_MODELS, "workspace"].includes(c.model))).toEqual([]);
377
+ });
378
+ });
379
+ });
380
+
381
+ describe("an unconnected or unclaimed workspace keeps the page list (AC-4)", () => {
382
+ it.each([
383
+ ["a selection with no connection", FIXTURE_ORG_2_WS],
384
+ ["a connection with no selection", FIXTURE_WS_2],
385
+ ["an unknown workspace", "wksp_01920000-8934-7000-8000-0000000000ff"],
386
+ ])("%s answers the page list", async (_label, workspaceId) => {
387
+ expect(await listPlanOfferings(workspaceId)).toEqual(PAGE_ITEMS);
388
+ expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(workspaceId);
389
+ });
390
+
391
+ it("an unclaimed, connected workspace answers the page list and reads no Offerings row", async () => {
392
+ mockCheckLicense.mockReset().mockResolvedValue(FREE);
393
+ expect(await listPlanOfferings(FIXTURE_WS)).toEqual(PAGE_ITEMS);
394
+ expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(FIXTURE_WS);
395
+ expect(offeringsReads()).toEqual([]);
396
+ });
397
+ });
398
+
399
+ describe("names and the item schema (AC-5)", () => {
400
+ it("a 200-code-point emoji name keeps its billing word, fits 200 units and splits no surrogate", async () => {
401
+ const product = {
402
+ id: "prod_fixture_emoji",
403
+ active: true,
404
+ name: EMOJI_NAME,
405
+ description: null,
406
+ marketing_features: [],
407
+ unit_label: null,
408
+ metadata: { ailk_group: "plan" },
409
+ } as CatalogProduct;
410
+ const price = {
411
+ id: "price_fixture_emoji_monthly",
412
+ active: true,
413
+ product: "prod_fixture_emoji",
414
+ lookup_key: "emoji_monthly",
415
+ type: "recurring",
416
+ recurring: { interval: "month", interval_count: 1, usage_type: "licensed" },
417
+ billing_scheme: "per_unit",
418
+ unit_amount: 100,
419
+ currency: "usd",
420
+ } as CatalogPrice;
421
+ const [emoji] = normalizeCatalog({ products: [product], prices: [price] }).offerings;
422
+ expect(emoji?.name).toBe(EMOJI_NAME);
423
+ enabledRow()["enabledProductIds"] = ["prod_fixture_emoji"];
424
+ offeringsRow()["shownProductIds"] = ["prod_fixture_emoji"];
425
+ offeringsRow()["snapshot"] = { v: 1, offerings: [emoji] };
426
+
427
+ const offerings = await listPlanOfferings(FIXTURE_WS);
428
+ if (!Array.isArray(offerings)) throw new Error(`expected a list, got ${offerings.failed}`);
429
+ const [item] = offerings;
430
+ expect(item?.key).toBe("emoji_monthly");
431
+ expect(item?.name.endsWith(" (Monthly)")).toBe(true);
432
+ expect(item!.name.length).toBeLessThanOrEqual(200);
433
+ expect(item!.name).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
434
+ expect(planOfferingItemSchema.safeParse(item).success).toBe(true);
435
+ });
436
+
437
+ it("the item schema takes the Offerings groups beside the page types", () => {
438
+ const item = (type: string) => ({ key: "growth_monthly", name: "Growth (Monthly)", type });
439
+ for (const type of ["service", "product", "plan", "addon"]) {
440
+ expect(planOfferingItemSchema.safeParse(item(type)).success).toBe(true);
441
+ }
442
+ expect(planOfferingItemSchema.safeParse(item("offering")).success).toBe(false);
443
+ });
444
+ });
@@ -387,7 +387,7 @@ export async function sendCompletionEmail(
387
387
 
388
388
  export async function fulfillDeliverable(
389
389
  input: FulfillDeliverableInput,
390
- ): Promise<void> {
390
+ ): Promise<{ grantId: string } | undefined> {
391
391
  const {
392
392
  siteId,
393
393
  flowSlug,
@@ -403,7 +403,7 @@ export async function fulfillDeliverable(
403
403
  // the call on this being present; re-checking here is defense in depth,
404
404
  // never load-bearing for the "no block → no call" AC4 regression floor.
405
405
  const block = configSnapshot.deliverables;
406
- if (!block) return;
406
+ if (!block) return undefined;
407
407
 
408
408
  const logCtx = { siteId, flowSlug, sessionId };
409
409
 
@@ -412,7 +412,7 @@ export async function fulfillDeliverable(
412
412
  const entry = resolveEntry(block.assets, segment);
413
413
  if (!entry) {
414
414
  logOutcome(logCtx, "no_entry");
415
- return;
415
+ return undefined;
416
416
  }
417
417
 
418
418
  // FEAT-074 U4b (#4718) D4 — the `playbook:` virtual asset: a per-lead
@@ -439,7 +439,7 @@ export async function fulfillDeliverable(
439
439
  });
440
440
  if (!instance) {
441
441
  logOutcome(logCtx, "playbook_missing");
442
- return;
442
+ return undefined;
443
443
  }
444
444
  assetId = entry.assetKey;
445
445
  } else {
@@ -464,6 +464,13 @@ export async function fulfillDeliverable(
464
464
  assetId = resolvedAssetId;
465
465
  grantWorkspaceId = workspaceId;
466
466
  } else {
467
+ // #8848 D3 (SEC-8848-B) — a `deliverable` flow resolves against the
468
+ // Asset store only: it is new, so it has no legacy key to keep, and the
469
+ // legacy store also holds the founders' signup CSVs.
470
+ if (configSnapshot.deliverable !== undefined) {
471
+ logOutcome(logCtx, "asset_missing");
472
+ return undefined;
473
+ }
467
474
  // #6310 D2/D3 — legacy fallback. A missing row (config references an
468
475
  // un-uploaded key) → same skip-and-log posture as before.
469
476
  // #6257 — the LEGACY store's read, scoped by the same resolved
@@ -476,7 +483,7 @@ export async function fulfillDeliverable(
476
483
 
477
484
  if (!asset) {
478
485
  logOutcome(logCtx, "asset_missing");
479
- return;
486
+ return undefined;
480
487
  }
481
488
  assetId = asset.id;
482
489
  }
@@ -527,6 +534,7 @@ export async function fulfillDeliverable(
527
534
  defaultLocale: configSnapshot.defaultLocale,
528
535
  copy: block.copy,
529
536
  });
537
+ return { grantId: grant.id };
530
538
  }
531
539
 
532
540
  // ─── #3998 D3 — resend: reuses the existing grant id, never mints ───────────────
@@ -15,15 +15,24 @@
15
15
  * foundation fills `features` + `limits.static` (tier + seat count) and returns
16
16
  * `limits.metered` as the reserved empty shape FEAT-047 (#3679) fills — this
17
17
  * spec builds NO metering engine (spec D5).
18
+ *
19
+ * #9067 D5 — the Billing tab's plan card also reads here: the licence expiry,
20
+ * the Organization's one open plan Checkout, and the customer's billing
21
+ * summary. That summary is the one Stripe read this resolver makes, through
22
+ * `lib/stripe.ts`.
18
23
  */
19
- import type { Entitlements } from "@working-theory/license";
24
+ import { checkLicense, type Entitlements } from "@working-theory/license";
25
+ import { logger } from "@working-theory/observability";
20
26
  import {
21
27
  MAGNET_KIND_MIN_RANK,
28
+ orgBillingDetailsSchema,
22
29
  planRank,
23
30
  type FlowConfig,
31
+ type OrgBillingDetails,
24
32
  } from "@working-theory/validation";
25
33
 
26
34
  import { prisma } from "../lib/prisma.js";
35
+ import { readCustomerBillingSummary } from "../lib/stripe.js";
27
36
 
28
37
  /**
29
38
  * Feature flags unlocked per tier. Free unlocks none; every paid tier unlocks
@@ -180,3 +189,150 @@ export async function isBillingCustomerAnOrganization(
180
189
  });
181
190
  return org !== null;
182
191
  }
192
+
193
+ /**
194
+ * #9067 D5 — the deployment licence's expiry, as an ISO string, or `null`
195
+ * for a keyless, expired or invalid licence. The trial state's date
196
+ * (C2). Never throws: a failed read is `null`.
197
+ */
198
+ export async function resolveLicenseExpiresAt(): Promise<string | null> {
199
+ try {
200
+ const license = await checkLicense();
201
+ return license.expiresAt?.toISOString() ?? null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * #9067 D13 — the one plan Checkout this Organization has open, or `null`.
209
+ * Server-side only: the session id is never served. An unknown
210
+ * Organization reads `null`.
211
+ */
212
+ export async function readOrgPlanCheckoutSession(
213
+ organizationId: string,
214
+ ): Promise<string | null> {
215
+ const org = await prisma.organization.findUnique({
216
+ where: { id: organizationId },
217
+ select: { planCheckoutSessionId: true },
218
+ });
219
+ return org?.planCheckoutSessionId ?? null;
220
+ }
221
+
222
+ /**
223
+ * #9067 D3 step 9b — record the plan Checkout the route just opened, as one
224
+ * compare-and-set: only while the column still holds `expected` and no
225
+ * customer is bound. Answers whether one row moved; throws on a database
226
+ * error.
227
+ */
228
+ export async function setOrgPlanCheckoutSession(
229
+ organizationId: string,
230
+ expected: string | null,
231
+ next: string,
232
+ ): Promise<boolean> {
233
+ const { count } = await prisma.organization.updateMany({
234
+ where: { id: organizationId, planCheckoutSessionId: expected, billingCustomerId: null },
235
+ data: { planCheckoutSessionId: next },
236
+ });
237
+ return count === 1;
238
+ }
239
+
240
+ /**
241
+ * #9067 7b (code review) — bind the Organization to the customer a COMPLETED
242
+ * plan Checkout created, when the webhook's bind never landed (a lost event,
243
+ * a database error the webhook swallowed). The same guarded compare-and-set
244
+ * the webhook runs: only while the column still holds `sessionId` and no
245
+ * customer is bound, and it clears the session. Answers whether one row
246
+ * moved; throws on a database error.
247
+ */
248
+ export async function bindOrgPlanCheckout(
249
+ organizationId: string,
250
+ sessionId: string,
251
+ customerId: string,
252
+ ): Promise<boolean> {
253
+ const { count } = await prisma.organization.updateMany({
254
+ where: { id: organizationId, planCheckoutSessionId: sessionId, billingCustomerId: null },
255
+ data: { billingProvider: "stripe", billingCustomerId: customerId, planCheckoutSessionId: null },
256
+ });
257
+ return count === 1;
258
+ }
259
+
260
+ /** How long one customer's summary is reused, a failure included (#9067 NFR-1). */
261
+ export const ORG_BILLING_SUMMARY_TTL_MS = 60_000;
262
+ /** The most customers held at once; the oldest entry is evicted at the cap. */
263
+ export const ORG_BILLING_SUMMARY_CACHE_MAX = 2_000;
264
+ /** The longest one summary read may take before the keys are left absent (NFR-2). */
265
+ export const ORG_BILLING_SUMMARY_TIMEOUT_MS = 3_000;
266
+
267
+ type OrgBillingSummaryKeys = Partial<Pick<OrgBillingDetails, "renewsAt" | "sites" | "paymentMethod">>;
268
+
269
+ /** Per customer: the settled keys and when they expire. Insertion order is age. */
270
+ const orgBillingSummaryCache = new Map<string, { keys: OrgBillingSummaryKeys; expiresAt: number }>();
271
+ /** Per customer: the one read in flight, joined by every concurrent miss. */
272
+ const orgBillingSummaryInFlight = new Map<string, Promise<OrgBillingSummaryKeys>>();
273
+
274
+ /**
275
+ * #9067 D5 — the Organization customer's renewal date, site count and card,
276
+ * from the cache when fresh, else one read shared by every concurrent caller
277
+ * of the same customer. The member read's wrapper (`services/subscription.ts`),
278
+ * rebuilt here because importing it would be a cycle. A throw or a timeout
279
+ * answers `{}` (every key absent) and is cached like a success; a value
280
+ * outside `orgBillingDetailsSchema`'s bounds is `null`.
281
+ */
282
+ export async function readOrgBillingSummary(customerId: string): Promise<OrgBillingSummaryKeys> {
283
+ const cached = orgBillingSummaryCache.get(customerId);
284
+ if (cached && cached.expiresAt > Date.now()) return cached.keys;
285
+ const pending = orgBillingSummaryInFlight.get(customerId);
286
+ if (pending) return pending;
287
+
288
+ const read = settleOrgBillingSummary(customerId).then((keys) => {
289
+ orgBillingSummaryCache.delete(customerId);
290
+ if (orgBillingSummaryCache.size >= ORG_BILLING_SUMMARY_CACHE_MAX) {
291
+ const oldest = orgBillingSummaryCache.keys().next().value;
292
+ if (oldest !== undefined) orgBillingSummaryCache.delete(oldest);
293
+ }
294
+ orgBillingSummaryCache.set(customerId, {
295
+ keys,
296
+ expiresAt: Date.now() + ORG_BILLING_SUMMARY_TTL_MS,
297
+ });
298
+ return keys;
299
+ });
300
+ // Cleared however the read ends, so a rejection is never served forever.
301
+ void read.finally(() => orgBillingSummaryInFlight.delete(customerId)).catch(() => undefined);
302
+ orgBillingSummaryInFlight.set(customerId, read);
303
+ return read;
304
+ }
305
+
306
+ async function settleOrgBillingSummary(customerId: string): Promise<OrgBillingSummaryKeys> {
307
+ let timer: ReturnType<typeof setTimeout> | undefined;
308
+ try {
309
+ const timedOut = new Promise<"timeout">((resolve) => {
310
+ timer = setTimeout(() => resolve("timeout"), ORG_BILLING_SUMMARY_TIMEOUT_MS);
311
+ });
312
+ const summary = await Promise.race([readCustomerBillingSummary(customerId), timedOut]);
313
+ if (summary === "timeout") throw new Error("timeout");
314
+ const { shape } = orgBillingDetailsSchema;
315
+ const renewsAt = shape.renewsAt.safeParse(summary.renewsAt);
316
+ const sites = shape.sites.safeParse(summary.sites);
317
+ const paymentMethod = shape.paymentMethod.safeParse(summary.paymentMethod);
318
+ return {
319
+ renewsAt: renewsAt.success ? (renewsAt.data ?? null) : null,
320
+ sites: sites.success ? (sites.data ?? null) : null,
321
+ paymentMethod: paymentMethod.success ? (paymentMethod.data ?? null) : null,
322
+ };
323
+ } catch {
324
+ logger.warn(
325
+ { route: "billing", outcome: "summary_unavailable" },
326
+ "[billing] organization billing summary unavailable",
327
+ );
328
+ return {};
329
+ } finally {
330
+ clearTimeout(timer);
331
+ }
332
+ }
333
+
334
+ /** Test seam: forget every cached and in-flight Organization summary. */
335
+ export function __resetOrgBillingSummaryCache(): void {
336
+ orgBillingSummaryCache.clear();
337
+ orgBillingSummaryInFlight.clear();
338
+ }