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
@@ -59,27 +59,47 @@
59
59
  * as `personId`; a callback with no email makes no Person call and writes no
60
60
  * `personId`. Every one of these steps is best-effort exactly like the lead's.
61
61
  *
62
- * CHECKOUT PLAN (#7877 D10). A spoke's checkout fulfilment posts one
63
- * `submit` with the Checkout Session id as its `Idempotency-Key`. When that
64
- * lead is written (never on a replay) with a Person, and it is a LIVE,
65
- * paid checkout lead from a registered spoke's server — `viaSpoke`, a
62
+ * CHECKOUT PLAN (#7877 D10, #8793 D1). A spoke's checkout fulfilment posts
63
+ * one `submit` with the Checkout Session id as its `Idempotency-Key`. A
64
+ * LIVE, paid checkout lead from a registered spoke's server — `viaSpoke`, a
66
65
  * `cs_live_` key, `source: "web"`, `payload.page: "checkout"`, a string
67
66
  * `payload.offering` and `payload.site`, and an `amountTotal` that is
68
- * absent, `null` or a positive integer — the Person's plan becomes the
69
- * offering minus a leading `<site>_` (`setPersonPlanOffering`, the plan's
70
- * only writer). No spoke file changes. The step never throws, never changes
71
- * `routeLead`'s result, and logs one bare `checkout-plan` line — never the
72
- * offering, the email or the key. A lead that does not qualify makes no
73
- * Person call at all.
67
+ * absent, `null` or a positive integer — names a plan: the offering minus a
68
+ * leading `<site>_` (`person.ts`'s `checkoutPlanKey`). Its email is what
69
+ * the buyer typed at Stripe Checkout, which nobody verified, so the lead
70
+ * never writes any Person's plan (SEC-8748-D). It HOLDS it: on the first
71
+ * write of a marked lead (below) with a Person, the step logs one bare
72
+ * `{ event: "checkout-plan", outcome, siteId }` line at `info` — `held` for
73
+ * a plan key, `skipped` for none — and makes no Person call; the marked lead
74
+ * itself is the hold. `person.ts`'s `releaseHeldCheckoutPlan` writes it
75
+ * later, once, at the Person's first member-path link to the user whose
76
+ * verified email is the Person's own. No spoke file changes. The step never
77
+ * throws, never changes `routeLead`'s result, and never logs the offering,
78
+ * the email or the key.
74
79
  *
75
- * SPOKE CHECKOUT MARKER (#8185 D2). The same predicate, plus "not relayed
76
- * for a visitor" (`SourceMeta.relayed`), is evaluated once BEFORE the lead's
77
- * create and stored as `Lead.spokeCheckout` — the hub's conclusion at write
80
+ * THE OFFERINGS TWIN (#8932 D7). The per-Organization Stripe webhook
81
+ * (`routes/webhooks/stripe-offerings.ts`) is the predicate's second source:
82
+ * `options.offeringsCheckout`, which it sets only after W3–W6 (its own
83
+ * signature, the hub's session record for that Organization and workspace,
84
+ * the recorded Price paid) and no request can set, a key
85
+ * `"stripe-offerings " + <cs_live_ session id>` (a space no `Idempotency-Key`
86
+ * header can carry), `source: "web"`, `payload.page: "checkout"`, a
87
+ * `payload.offering` `parseLookupKey` admits and a `payload.site` equal to
88
+ * the lead's workspace. Its plan key is the FULL lookup key (no `<site>_`
89
+ * prefix can match a workspace id), and its consent grant's `sourceRef` is
90
+ * the bare session id. A test-mode session's key fails the twin, so its lead
91
+ * is unmarked (#8932 C9).
92
+ *
93
+ * SPOKE CHECKOUT MARKER (#8185 D2). The same predicate — or its Offerings
94
+ * twin (#8932 D7) — plus "not relayed for a visitor" (`SourceMeta.relayed`),
95
+ * is evaluated once BEFORE the lead's create and stored as
96
+ * `Lead.spokeCheckout` — the hub's conclusion at write
78
97
  * time, which `GET /v1/member/purchases` serves as a purchase
79
98
  * (`lib/member-store.ts`). Written only in the submit create below, never by
80
99
  * an update; an unmarked create carries no `spokeCheckout` key at all and
81
- * relies on the column default. The plan rule above is unchanged: it still
82
- * runs after the write, from the same candidate.
100
+ * relies on the column default. The plan step above runs only for a marked
101
+ * lead, after the write, and holds (#8793 D1), so a request relayed for a
102
+ * visitor never reaches it.
83
103
  *
84
104
  * PURCHASE EMAIL AND INBOX MESSAGE (#8757 D14). After this request's own
85
105
  * write of a lead marked `spokeCheckout` with a Person — so never on a
@@ -136,12 +156,12 @@ import { sendLeadNotification, sendPurchaseConfirmationEmail } from "@working-th
136
156
  import { logger } from "@working-theory/observability";
137
157
  import {
138
158
  accountInvitePath,
139
- isContactLeadSource,
159
+ isConsentLeadSource,
140
160
  LEAD_SITE_MARKER_KEY,
141
161
  leadInputSchema,
162
+ parseLookupKey,
142
163
  PURCHASE_EMAIL_DEFAULTS,
143
164
  PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY,
144
- planOfferingSchema,
145
165
  stripPlainText,
146
166
  } from "@working-theory/validation";
147
167
 
@@ -158,10 +178,10 @@ import {
158
178
  } from "./consent.js";
159
179
  import { enrichLead } from "./enrichment-shim.js";
160
180
  import {
181
+ checkoutPlanKey,
161
182
  findOrCreatePerson,
162
183
  normalizeCapturedContact,
163
184
  normalizeTelephone,
164
- setPersonPlanOffering,
165
185
  } from "./person.js";
166
186
  import type { LeadContact, PersonContact } from "./person.js";
167
187
  import { listPlanOfferings } from "./plan-offerings.js";
@@ -232,6 +252,14 @@ export type RouteLeadOptions = {
232
252
  * from the caller, never the body. Read for a marked checkout lead only.
233
253
  */
234
254
  accountOrigin?: string;
255
+ /**
256
+ * Set only by the per-Organization Stripe webhook after W5 and W6 (#8932
257
+ * D7) — a signature from the Organization's own secret, the hub's own
258
+ * session record for that Organization and workspace, and the recorded
259
+ * Price paid; never from a request. Admits the Offerings twin of the
260
+ * checkout plan predicate (`offeringsCheckoutCandidate`).
261
+ */
262
+ offeringsCheckout?: true;
235
263
  };
236
264
 
237
265
  export type RouteLeadResult =
@@ -307,9 +335,11 @@ async function findLeadByKey(
307
335
  }
308
336
 
309
337
  /**
310
- * A contact lead's `payload.consent` is written by the route on every request
311
- * (#8730 D3) — its `acceptedAt` is that request's receive time — so it is left
312
- * out of the replay comparison; every key the client sent still counts.
338
+ * A consent-bearing lead's `payload.consent` — a contact lead's (#8730 D3) or,
339
+ * since #9130, a job application's — is written by the route on every request,
340
+ * its `acceptedAt` that request's receive time, so it is left out of the replay
341
+ * comparison for every `isConsentLeadSource` source; every key the client sent
342
+ * still counts.
313
343
  *
314
344
  * A checkout lead's `payload.termsAccepted` (#8778) is left out too: a spoke
315
345
  * updated between a stored attempt and Stripe's redelivery sends the key the
@@ -321,7 +351,7 @@ function comparablePayload(payload: unknown, source: string): unknown {
321
351
  return payload;
322
352
  }
323
353
  const record = payload as Record<string, unknown>;
324
- if (isContactLeadSource(source)) {
354
+ if (isConsentLeadSource(source)) {
325
355
  const { consent: _serverWritten, ...rest } = record;
326
356
  return rest;
327
357
  }
@@ -335,9 +365,9 @@ function comparablePayload(payload: unknown, source: string): unknown {
335
365
  /**
336
366
  * A stored row is a replay only when it carries this request's exact `email`
337
367
  * and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
338
- * any other row is a conflict (#7869 D4). A contact lead's server-written
339
- * `payload.consent` is not compared (#8730), nor a checkout lead's
340
- * `payload.termsAccepted` (#8778).
368
+ * any other row is a conflict (#7869 D4). A consent-bearing lead's
369
+ * server-written `payload.consent` is not compared (#8730, #9130), nor a
370
+ * checkout lead's `payload.termsAccepted` (#8778).
341
371
  */
342
372
  function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
343
373
  return row.email === input.email &&
@@ -431,6 +461,9 @@ async function resolveLeadPersonId(input: SubmitInput, contact?: LeadContact): P
431
461
  });
432
462
  }
433
463
 
464
+ /** A marked checkout lead's offering and site; `sessionId` is the Offerings twin's bare session id. */
465
+ type CheckoutCandidate = { offering: string; site: string; sessionId?: string };
466
+
434
467
  /** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
435
468
  const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
436
469
  const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -443,7 +476,7 @@ function checkoutPlanCandidate(
443
476
  input: SubmitInput,
444
477
  idempotencyKey: string | undefined,
445
478
  viaSpoke: boolean,
446
- ): { offering: string; site: string } | null {
479
+ ): CheckoutCandidate | null {
447
480
  if (viaSpoke !== true) return null;
448
481
  if (idempotencyKey === undefined || !LIVE_CHECKOUT_SESSION_KEY.test(idempotencyKey)) {
449
482
  return null;
@@ -466,8 +499,35 @@ function checkoutPlanCandidate(
466
499
  return { offering, site };
467
500
  }
468
501
 
502
+ /** The Offerings webhook's key: a space no `Idempotency-Key` header can carry, then a LIVE session id. */
503
+ const OFFERINGS_CHECKOUT_KEY = /^stripe-offerings (cs_live_[A-Za-z0-9]{1,250})$/;
504
+
505
+ /**
506
+ * The Offerings twin of `checkoutPlanCandidate` (#8932 D7): a lead the
507
+ * per-Organization Stripe webhook wrote after W5 and W6, for a LIVE session
508
+ * only (C9, SEC-7877-C) — a test-mode session's lead is written unmarked.
509
+ * Reads no amount: W6 already proved the session paid. `checkoutPlanCandidate`
510
+ * is not edited, so a spoke lead is marked exactly as before.
511
+ */
512
+ function offeringsCheckoutCandidate(
513
+ input: SubmitInput,
514
+ idempotencyKey: string | undefined,
515
+ offeringsCheckout: boolean,
516
+ ): CheckoutCandidate | null {
517
+ if (offeringsCheckout !== true || idempotencyKey === undefined) return null;
518
+ const sessionId = OFFERINGS_CHECKOUT_KEY.exec(idempotencyKey)?.[1];
519
+ if (sessionId === undefined) return null;
520
+ const { payload } = input;
521
+ if (input.source !== "web" || payload["page"] !== "checkout") return null;
522
+ const offering = payload["offering"];
523
+ if (typeof offering !== "string" || parseLookupKey(offering) === null) return null;
524
+ const site = payload[LEAD_SITE_MARKER_KEY];
525
+ if (typeof site !== "string" || site !== input.siteId) return null;
526
+ return { offering, site, sessionId };
527
+ }
528
+
469
529
  /** The candidate's offering minus a leading `<site>_` (#7877 D10). */
470
- function stripSitePrefix(candidate: { offering: string; site: string }): string {
530
+ function stripSitePrefix(candidate: CheckoutCandidate): string {
471
531
  const prefix = `${candidate.site}_`;
472
532
  return candidate.offering.startsWith(prefix)
473
533
  ? candidate.offering.slice(prefix.length)
@@ -475,31 +535,18 @@ function stripSitePrefix(candidate: { offering: string; site: string }): string
475
535
  }
476
536
 
477
537
  /**
478
- * Set the Person's plan from a qualifying checkout lead (#7877 D10). Never
479
- * throws and never changes the lead's result; logs the outcome kind and the
480
- * site only.
538
+ * Hold the plan of a marked checkout lead (#7877 D10, #8793 D1): no Person
539
+ * call — the marked lead itself is the hold, released only at the Person's
540
+ * verified first link (`person.ts`'s `releaseHeldCheckoutPlan`). Logs
541
+ * `held` for an offering that is a plan key, else `skipped`, and the site
542
+ * only.
481
543
  */
482
- async function applyCheckoutPlan(
544
+ function applyCheckoutPlan(
483
545
  input: SubmitInput,
484
- personId: string,
485
- candidate: { offering: string; site: string },
486
- ): Promise<void> {
487
- const offering = stripSitePrefix(candidate);
488
- let outcome: "set" | "skipped" | "failed";
489
- // The operator's PUT accepts the same keys (`planOfferingSchema`).
490
- if (!planOfferingSchema.safeParse(offering).success) {
491
- outcome = "skipped";
492
- } else {
493
- try {
494
- const result = await setPersonPlanOffering(input.siteId, personId, offering);
495
- outcome = result === "set" ? "set" : "failed";
496
- } catch {
497
- outcome = "failed";
498
- }
499
- }
500
- const line = { event: "checkout-plan", outcome, siteId: input.siteId };
501
- if (outcome === "failed") logger.error(line, "checkout plan failed");
502
- else logger.info(line, "checkout plan");
546
+ candidate: CheckoutCandidate,
547
+ ): void {
548
+ const outcome = checkoutPlanKey(candidate.offering, candidate.site) === null ? "skipped" : "held";
549
+ logger.info({ event: "checkout-plan", outcome, siteId: input.siteId }, "checkout plan");
503
550
  }
504
551
 
505
552
  // ─── Purchase email and Inbox message (#8757 D14) ────────────────────────────
@@ -653,7 +700,7 @@ async function sendPurchaseEmail(
653
700
  async function recordPurchase(
654
701
  input: SubmitInput,
655
702
  personId: string,
656
- candidate: { offering: string; site: string },
703
+ candidate: CheckoutCandidate,
657
704
  accountOrigin: string | undefined,
658
705
  ): Promise<void> {
659
706
  // One guard around the whole step: it runs inside `routeLead`'s persist
@@ -738,13 +785,17 @@ async function persistToDb(
738
785
  relayed = false,
739
786
  contact?: LeadContact,
740
787
  accountOrigin?: string,
788
+ offeringsCheckout = false,
741
789
  ): Promise<PersistResult> {
742
790
  if (input.kind === "submit") {
743
791
  const personId = await resolveLeadPersonId(input, contact);
744
792
  // #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
745
793
  // evaluated before the create so its result can be stored. A relayed
746
- // request is never a spoke's server call (D3).
747
- const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
794
+ // request is never a spoke's server call (D3). #8932 D7 — or its
795
+ // Offerings twin, set only by the per-Organization Stripe webhook.
796
+ const candidate =
797
+ checkoutPlanCandidate(input, idempotencyKey, viaSpoke) ??
798
+ offeringsCheckoutCandidate(input, idempotencyKey, offeringsCheckout);
748
799
  const spokeCheckout = candidate !== null && relayed !== true;
749
800
  let row: DbRow;
750
801
  try {
@@ -790,15 +841,18 @@ async function persistToDb(
790
841
  if (!winner) throw err;
791
842
  return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
792
843
  }
793
- // #7877 D10 — only after THIS request's own write, so a replay sets nothing.
844
+ // #7877 D10 — only after THIS request's own write, so a replay holds nothing.
794
845
  if (candidate !== null && personId !== null) {
795
- await applyCheckoutPlan(input, personId, candidate);
796
846
  // #8757 D14 — the marked lead only: live, unrelayed, this write.
797
847
  if (spokeCheckout) {
848
+ // #8793 D1 — the plan is held, never written here.
849
+ applyCheckoutPlan(input, candidate);
798
850
  await recordPurchase(input, personId, candidate, accountOrigin);
799
- // #8778 — a marked lead's key is its live Checkout Session id.
800
- if (idempotencyKey !== undefined) {
801
- await recordSpokeCheckoutConsent(input, personId, idempotencyKey);
851
+ // #8778 — a marked lead's key is its live Checkout Session id;
852
+ // #8932 D7 — the Offerings twin's key is prefixed, so its bare id.
853
+ const sessionId = candidate.sessionId ?? idempotencyKey;
854
+ if (sessionId !== undefined) {
855
+ await recordSpokeCheckoutConsent(input, personId, sessionId);
802
856
  }
803
857
  }
804
858
  }
@@ -1165,6 +1219,7 @@ export async function routeLead(
1165
1219
  sourceMeta.relayed === true,
1166
1220
  input.kind === "submit" ? options.contact : undefined,
1167
1221
  input.kind === "submit" ? options.accountOrigin : undefined,
1222
+ input.kind === "submit" && options.offeringsCheckout === true,
1168
1223
  );
1169
1224
  // #7869 D5 — a lost race answers like the lookup above: no capture
1170
1225
  // record, no notification.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * services/offerings/catalog.ts — the one normalizer (#8876 spec D5, AC-5):
3
+ * text normalization (T1), the product marker (G6), sellable Prices (G3) and
4
+ * one plan per Product (G4), in D5's order, one bad Product never failing the
5
+ * catalog.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import { offeringsSnapshotProductSchema } from "@working-theory/validation";
11
+
12
+ import {
13
+ isOfferingsProduct,
14
+ normalizeCatalog,
15
+ type CatalogPrice,
16
+ type CatalogProduct,
17
+ } from "../catalog.js";
18
+ import {
19
+ EXPECTED_FIXTURE_HELD,
20
+ EXPECTED_FIXTURE_OFFERINGS,
21
+ OFFERINGS_FIXTURE_CATALOG,
22
+ } from "./fixtures.js";
23
+
24
+ function product(id: string, patch: Record<string, unknown> = {}): CatalogProduct {
25
+ return {
26
+ id,
27
+ active: true,
28
+ name: "Growth",
29
+ description: null,
30
+ marketing_features: [],
31
+ unit_label: null,
32
+ metadata: { ailk_group: "plan" },
33
+ ...patch,
34
+ };
35
+ }
36
+
37
+ function price(id: string, productId: string, patch: Record<string, unknown> = {}): CatalogPrice {
38
+ return {
39
+ id,
40
+ active: true,
41
+ product: productId,
42
+ lookup_key: "growth_monthly",
43
+ type: "recurring",
44
+ recurring: { interval: "month", interval_count: 1, usage_type: "licensed" },
45
+ billing_scheme: "per_unit",
46
+ unit_amount: 4900,
47
+ currency: "usd",
48
+ ...patch,
49
+ };
50
+ }
51
+
52
+ /** Normalize one Product with its Prices. */
53
+ function one(p: CatalogProduct, prices: CatalogPrice[]) {
54
+ return normalizeCatalog({ products: [p], prices });
55
+ }
56
+
57
+ describe("normalizeCatalog (#8876 D5, AC-5)", () => {
58
+ it("answers the fixture's offerings and held list exactly, in D5's order", () => {
59
+ expect(normalizeCatalog(OFFERINGS_FIXTURE_CATALOG)).toEqual({
60
+ offerings: EXPECTED_FIXTURE_OFFERINGS,
61
+ held: EXPECTED_FIXTURE_HELD,
62
+ });
63
+ });
64
+
65
+ it("is pure: the same input answers the same output and is not mutated", () => {
66
+ const input = structuredClone(OFFERINGS_FIXTURE_CATALOG);
67
+ expect(normalizeCatalog(input)).toEqual(normalizeCatalog(OFFERINGS_FIXTURE_CATALOG));
68
+ expect(input).toEqual(OFFERINGS_FIXTURE_CATALOG);
69
+ });
70
+
71
+ describe("text (T1)", () => {
72
+ it("strips bidi overrides, zero-width characters and a CR from a name", () => {
73
+ const { offerings } = one(product("prod_a", { name: "Gro‮wth​\r plan" }), [price("price_a", "prod_a")]);
74
+ expect(offerings[0]!.name).toBe("Growth plan");
75
+ });
76
+
77
+ it("answers the NFC form of a decomposed character", () => {
78
+ const { offerings } = one(product("prod_a", { name: "Café" }), [price("price_a", "prod_a")]);
79
+ expect(offerings[0]!.name).toBe("Café");
80
+ });
81
+
82
+ it("keeps a newline in the description only", () => {
83
+ const { offerings } = one(
84
+ product("prod_a", {
85
+ name: "Two\nlines",
86
+ description: " First\nsecond
third ",
87
+ marketing_features: [{ name: "a\nb" }],
88
+ }),
89
+ [price("price_a", "prod_a")],
90
+ );
91
+ expect(offerings[0]!.name).toBe("Twolines");
92
+ expect(offerings[0]!.description).toBe("First\nsecondthird");
93
+ expect(offerings[0]!.marketingFeatures).toEqual(["ab"]);
94
+ });
95
+
96
+ it("cuts a 2001-code-point description to 2000", () => {
97
+ const { offerings } = one(product("prod_a", { description: "x".repeat(2001) }), [price("price_a", "prod_a")]);
98
+ expect([...offerings[0]!.description!]).toHaveLength(2000);
99
+ });
100
+
101
+ it("keeps a name of 200 astral-plane emoji whole, and it parses", () => {
102
+ const name = "😀".repeat(200);
103
+ const { offerings } = one(product("prod_a", { name }), [price("price_a", "prod_a")]);
104
+ expect(offerings[0]!.name).toBe(name);
105
+ expect(offeringsSnapshotProductSchema.safeParse(offerings[0]).success).toBe(true);
106
+ });
107
+
108
+ it("reads a non-string as null and an empty text as null", () => {
109
+ const { offerings } = one(
110
+ product("prod_a", { description: 42, unit_label: " ", metadata: { ailk_group: "plan", ailk_note: {} } }),
111
+ [price("price_a", "prod_a")],
112
+ );
113
+ expect(offerings[0]).toMatchObject({ description: null, unitLabel: null, note: null });
114
+ });
115
+
116
+ it("never reads images, url, nickname, statement_descriptor or other metadata", () => {
117
+ const { offerings } = one(
118
+ product("prod_a", {
119
+ images: ["https://evil.example/x.png"],
120
+ url: "https://evil.example",
121
+ metadata: { ailk_group: "plan", other: "secret-metadata" },
122
+ } as Record<string, unknown>),
123
+ [price("price_a", "prod_a", { nickname: "Nick", statement_descriptor: "STMT" } as Record<string, unknown>)],
124
+ );
125
+ const text = JSON.stringify(offerings);
126
+ for (const leaked of ["evil.example", "secret-metadata", "Nick", "STMT", "images", "url", "nickname"]) {
127
+ expect(text).not.toContain(leaked);
128
+ }
129
+ });
130
+ });
131
+
132
+ describe("the marker (G6)", () => {
133
+ it.each([
134
+ ["no ailk_group", {}],
135
+ ["ailk_group Plan", { ailk_group: "Plan" }],
136
+ ["ailk_group other", { ailk_group: "other" }],
137
+ ])("holds %s group_invalid", (_label, metadata) => {
138
+ expect(one(product("prod_a", { metadata }), [price("price_a", "prod_a")])).toEqual({
139
+ offerings: [],
140
+ held: [{ productId: "prod_a", name: "Growth", reason: "group_invalid" }],
141
+ });
142
+ });
143
+
144
+ it("holds an empty name name_missing", () => {
145
+ expect(one(product("prod_a", { name: "​ " }), [price("price_a", "prod_a")]).held).toEqual([
146
+ { productId: "prod_a", name: "", reason: "name_missing" },
147
+ ]);
148
+ });
149
+
150
+ it("isOfferingsProduct is true for any non-empty ailk_group, Plan included", () => {
151
+ expect(isOfferingsProduct(product("prod_a", { metadata: { ailk_group: "Plan" } }))).toBe(true);
152
+ expect(isOfferingsProduct(product("prod_a", { metadata: { ailk_group: "addon" } }))).toBe(true);
153
+ expect(isOfferingsProduct(product("prod_a", { metadata: { ailk_group: "" } }))).toBe(false);
154
+ expect(isOfferingsProduct(product("prod_a", { metadata: {} }))).toBe(false);
155
+ expect(isOfferingsProduct(product("prod_a", { metadata: null }))).toBe(false);
156
+ });
157
+
158
+ it("keeps ailk_order of 1–4 digits and sorts anything else last", () => {
159
+ const products = [
160
+ product("prod_a", { name: "A", metadata: { ailk_group: "plan", ailk_order: "12345" } }),
161
+ product("prod_b", { name: "B", metadata: { ailk_group: "plan", ailk_order: "2" } }),
162
+ product("prod_c", { name: "C", metadata: { ailk_group: "plan", ailk_order: 1 } }),
163
+ ];
164
+ const prices = [
165
+ price("price_a", "prod_a", { lookup_key: "a_monthly" }),
166
+ price("price_b", "prod_b", { lookup_key: "b_monthly" }),
167
+ price("price_c", "prod_c", { lookup_key: "c_monthly" }),
168
+ ];
169
+ const { offerings } = normalizeCatalog({ products, prices });
170
+ expect(offerings.map((o) => [o.productId, o.ailkOrder])).toEqual([
171
+ ["prod_b", 2],
172
+ ["prod_a", null],
173
+ ["prod_c", null],
174
+ ]);
175
+ });
176
+ });
177
+
178
+ describe("sellable Prices (G3) and one plan per Product (G4)", () => {
179
+ it.each([
180
+ ["one sellable and one growth_quarterly Price", [
181
+ price("price_a", "prod_a"),
182
+ price("price_b", "prod_a", { lookup_key: "growth_quarterly", recurring: { interval: "month", interval_count: 3 } }),
183
+ ]],
184
+ ["a metered Price", [price("price_a", "prod_a", { recurring: { interval: "month", interval_count: 1, usage_type: "metered" } })]],
185
+ ["a tiered Price", [price("price_a", "prod_a", { billing_scheme: "tiered", unit_amount: null })]],
186
+ ["a period that disagrees with the Price", [price("price_a", "prod_a", { recurring: { interval: "year", interval_count: 1 } })]],
187
+ ["no active Price", [price("price_a", "prod_a", { active: false })]],
188
+ ["no Price at all", []],
189
+ ["a null unit_amount", [price("price_a", "prod_a", { unit_amount: null })]],
190
+ ["an uppercase currency", [price("price_a", "prod_a", { currency: "USD" })]],
191
+ ["two Prices of one period", [
192
+ price("price_a", "prod_a"),
193
+ price("price_b", "prod_a", { lookup_key: "growth_monthly" }),
194
+ ]],
195
+ ["two plans on one Product", [
196
+ price("price_a", "prod_a"),
197
+ price("price_b", "prod_a", { lookup_key: "basic_yearly", recurring: { interval: "year", interval_count: 1 } }),
198
+ ]],
199
+ ])("holds a Product with %s needs_lookup_key", (_label, prices) => {
200
+ expect(one(product("prod_a"), prices as CatalogPrice[]).held).toEqual([
201
+ { productId: "prod_a", name: "Growth", reason: "needs_lookup_key" },
202
+ ]);
203
+ });
204
+
205
+ it("ignores an inactive Price beside a sellable one", () => {
206
+ const { offerings } = one(product("prod_a"), [
207
+ price("price_a", "prod_a"),
208
+ price("price_b", "prod_a", { active: false, lookup_key: "growth_quarterly" }),
209
+ ]);
210
+ expect(offerings[0]!.prices.map((p) => p.priceId)).toEqual(["price_a"]);
211
+ });
212
+
213
+ it("holds both Products carrying one plan plan_conflict", () => {
214
+ const { offerings, held } = normalizeCatalog({
215
+ products: [product("prod_a"), product("prod_b", { name: "Growth two" })],
216
+ prices: [
217
+ price("price_a", "prod_a"),
218
+ price("price_b", "prod_b", { lookup_key: "growth_yearly", recurring: { interval: "year", interval_count: 1 } }),
219
+ ],
220
+ });
221
+ expect(offerings).toEqual([]);
222
+ expect(held).toEqual([
223
+ { productId: "prod_a", name: "Growth", reason: "plan_conflict" },
224
+ { productId: "prod_b", name: "Growth two", reason: "plan_conflict" },
225
+ ]);
226
+ });
227
+
228
+ it("reads an expanded product object on a Price", () => {
229
+ const { offerings } = one(product("prod_a"), [price("price_a", "prod_a", { product: { id: "prod_a" } })]);
230
+ expect(offerings).toHaveLength(1);
231
+ });
232
+
233
+ it("leaves out an inactive Product and an id that is not a Stripe id", () => {
234
+ expect(
235
+ normalizeCatalog({
236
+ products: [product("prod_a", { active: false }), product("prod a"), { ...product("prod_x"), id: 42 }],
237
+ prices: [price("price_a", "prod_a")],
238
+ }),
239
+ ).toEqual({ offerings: [], held: [] });
240
+ });
241
+
242
+ it("holds a Product whose offering fails its parse invalid, and every other still normalizes", () => {
243
+ // Nothing the normalizer keeps fails the schema today, so the per-Product
244
+ // parse is driven by a schema that refuses one Product.
245
+ const original = offeringsSnapshotProductSchema.safeParse.bind(offeringsSnapshotProductSchema);
246
+ const spy = jest
247
+ .spyOn(offeringsSnapshotProductSchema, "safeParse")
248
+ .mockImplementation((value: unknown) =>
249
+ (value as { productId?: string }).productId === "prod_a"
250
+ ? ({ success: false } as never)
251
+ : original(value),
252
+ );
253
+ try {
254
+ const { offerings, held } = normalizeCatalog({
255
+ products: [product("prod_a"), product("prod_b", { name: "Basic" })],
256
+ prices: [price("price_a", "prod_a"), price("price_b", "prod_b", { lookup_key: "basic_monthly" })],
257
+ });
258
+ expect(held).toEqual([{ productId: "prod_a", name: "Growth", reason: "invalid" }]);
259
+ expect(offerings.map((o) => o.productId)).toEqual(["prod_b"]);
260
+ } finally {
261
+ spy.mockRestore();
262
+ }
263
+ });
264
+
265
+ it("counts a Product id the source answers twice once", () => {
266
+ const { offerings, held } = normalizeCatalog({
267
+ products: [product("prod_a"), product("prod_a", { name: "Growth again" })],
268
+ prices: [price("price_a", "prod_a")],
269
+ });
270
+ expect(offerings.map((o) => [o.productId, o.name])).toEqual([["prod_a", "Growth"]]);
271
+ expect(held).toEqual([]);
272
+ });
273
+
274
+ it("counts a Price id the source answers twice once", () => {
275
+ const { offerings } = one(product("prod_a"), [price("price_a", "prod_a"), price("price_a", "prod_a")]);
276
+ expect(offerings[0]!.prices.map((p) => p.priceId)).toEqual(["price_a"]);
277
+ });
278
+
279
+ it("reads no more than the catalog caps", () => {
280
+ const products = Array.from({ length: 1001 }, (_, i) =>
281
+ product(`prod_${i}`, { name: `P${i}`, metadata: {} }),
282
+ );
283
+ const { held } = normalizeCatalog({ products, prices: [] });
284
+ expect(held).toHaveLength(1000);
285
+ expect(held.some((h) => h.productId === "prod_1000")).toBe(false);
286
+ });
287
+ });
288
+
289
+ it("splits a lookup key only through parseLookupKey (AC-9)", () => {
290
+ const source = readFileSync(join(__dirname, "..", "catalog.ts"), "utf8");
291
+ expect(source).toContain("parseLookupKey(");
292
+ expect(source).not.toMatch(/split\(\s*["']_["']\s*\)/);
293
+ expect(source).not.toMatch(/monthly\|yearly/);
294
+ });
295
+ });