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
@@ -7,7 +7,10 @@
7
7
  * `resolveMemberPersonId` and `setPersonPlanOffering` (#7877 D8; AC-3, AC-5),
8
8
  * and the hub's buyer read, `resolveCallerBillingCustomerId`, with the
9
9
  * stamped-Person guard on the customer-id write (#7961, spec
10
- * docs/specs/7894-the-hub-signs-in-its-spec.md D13, SEC-7894-6; AC-8).
10
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D13, SEC-7894-6; AC-8),
11
+ * and the held checkout plan's release on a verified first link
12
+ * (#8793, spec docs/specs/8793-the-hub-s-checkout-plan-spec.md D2–D5;
13
+ * AC-1, AC-2, AC-6).
11
14
  *
12
15
  * Runs over `createInMemoryPrisma()`, whose `person` delegate enforces the
13
16
  * declared `(workspaceId, email)` and `(workspaceId, userId)` keys the way
@@ -36,6 +39,7 @@ import { prisma } from "../../lib/prisma.js";
36
39
  import { personContactPatchSchema } from "@working-theory/validation";
37
40
 
38
41
  import {
42
+ checkoutPlanKey,
39
43
  contactConfirmedFromRow,
40
44
  ensureMemberPerson,
41
45
  findOrCreatePerson,
@@ -45,6 +49,7 @@ import {
45
49
  normalizeTelephone,
46
50
  personContactFromRow,
47
51
  recordCheckoutPerson,
52
+ releaseHeldCheckoutPlan,
48
53
  resolveCallerBillingCustomerId,
49
54
  resolveMemberPersonId,
50
55
  setOwnPersonContact,
@@ -61,6 +66,7 @@ const store = prisma as unknown as {
61
66
  __db: Record<string, Row[]>;
62
67
  __calls: Array<{ model: string; op: string }>;
63
68
  person: Record<string, jest.Mock>;
69
+ lead: Record<string, jest.Mock>;
64
70
  };
65
71
 
66
72
  const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
@@ -943,3 +949,228 @@ describe("setPersonPlanOffering (#7877 D8, AC-4, AC-5)", () => {
943
949
  expect(await setPersonPlanOffering(WS_A1, "person_x", "retainer")).toBe("internal_error");
944
950
  });
945
951
  });
952
+
953
+ // ─── The held checkout plan (#8793) ──────────────────────────────────────────
954
+
955
+ const leads = () => (store.__db["lead"] ??= []);
956
+ const leadCalls = () => store.__calls.filter((c) => c.model === "lead");
957
+
958
+ /** A spoke checkout lead on a Person, newest last unless `createdAt` says otherwise. */
959
+ function seedMarkedLead(personId: string, row: Row = {}): Row {
960
+ const full: Row = {
961
+ id: `lead_00000000-0000-7000-8000-${String(leads().length + 700).padStart(12, "0")}`,
962
+ workspaceId: WS_A1,
963
+ personId,
964
+ email: "Ada@Example.com",
965
+ source: "web",
966
+ spokeCheckout: true,
967
+ payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" },
968
+ createdAt: new Date(Date.UTC(2026, 1, 1 + leads().length)),
969
+ ...row,
970
+ };
971
+ leads().push(full);
972
+ return full;
973
+ }
974
+
975
+ describe("checkoutPlanKey (#8793 D4)", () => {
976
+ it("strips a leading <site>_ and answers a plan key, else null", () => {
977
+ expect(checkoutPlanKey("wendi_retainer", "wendi")).toBe("retainer");
978
+ expect(checkoutPlanKey("retainer", "wendi")).toBe("retainer");
979
+ expect(checkoutPlanKey("wendi_Not A Key!", "wendi")).toBeNull();
980
+ expect(checkoutPlanKey(42, "wendi")).toBeNull();
981
+ });
982
+ });
983
+
984
+ describe("setPersonPlanOffering's link guard (#8793 D5, AC-1, AC-6)", () => {
985
+ const U = "user_00000000-0000-7000-8000-000000000031";
986
+ const V = "user_00000000-0000-7000-8000-000000000032";
987
+
988
+ it("a guarded write never overwrites a set plan", async () => {
989
+ const p = seedPerson({ email: "ada@example.com", userId: U, planOffering: "premium" });
990
+ expect(
991
+ await setPersonPlanOffering(WS_A1, p["id"] as string, "x", { linkedUserId: U }),
992
+ ).toBe("not_found");
993
+ expect(people()[0]!["planOffering"]).toBe("premium");
994
+ });
995
+
996
+ it("a guarded write never reaches a Person linked to another user", async () => {
997
+ const p = seedPerson({ email: "ada@example.com", userId: U });
998
+ expect(
999
+ await setPersonPlanOffering(WS_A1, p["id"] as string, "x", { linkedUserId: V }),
1000
+ ).toBe("not_found");
1001
+ expect(people()[0]!["planOffering"]).toBeNull();
1002
+ });
1003
+
1004
+ it("the operator's three-argument write still overwrites a set plan", async () => {
1005
+ const p = seedPerson({ email: "ada@example.com", userId: U, planOffering: "premium" });
1006
+ expect(await setPersonPlanOffering(WS_A1, p["id"] as string, "basic")).toBe("set");
1007
+ expect(people()[0]!["planOffering"]).toBe("basic");
1008
+ });
1009
+ });
1010
+
1011
+ describe("releaseHeldCheckoutPlan (#8793 D3, AC-1)", () => {
1012
+ const U = "user_00000000-0000-7000-8000-000000000041";
1013
+ const EMAIL = "ada@example.com";
1014
+ let personId: string;
1015
+
1016
+ const release = () => releaseHeldCheckoutPlan(WS_A1, personId, U, EMAIL);
1017
+ const plan = () => people()[0]!["planOffering"];
1018
+
1019
+ beforeEach(() => {
1020
+ personId = seedPerson({ email: EMAIL, userId: U })["id"] as string;
1021
+ });
1022
+
1023
+ it("one marked lead sets its key", async () => {
1024
+ seedMarkedLead(personId);
1025
+ expect(await release()).toBe("set");
1026
+ expect(plan()).toBe("retainer");
1027
+ });
1028
+
1029
+ it("two marked leads naming one key, with and without the site prefix, set it", async () => {
1030
+ seedMarkedLead(personId);
1031
+ seedMarkedLead(personId, {
1032
+ payload: { site: "wendi", page: "checkout", offering: "retainer" },
1033
+ });
1034
+ expect(await release()).toBe("set");
1035
+ expect(plan()).toBe("retainer");
1036
+ });
1037
+
1038
+ it.each([
1039
+ ["the basic purchase is newer", 1, 2],
1040
+ ["the retainer purchase is newer", 2, 1],
1041
+ ])("two marked leads naming different keys are ambiguous whatever their order (%s)", async (_label, retainerDay, basicDay) => {
1042
+ seedMarkedLead(personId, { createdAt: new Date(Date.UTC(2026, 2, retainerDay)) });
1043
+ seedMarkedLead(personId, {
1044
+ createdAt: new Date(Date.UTC(2026, 2, basicDay)),
1045
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1046
+ });
1047
+ expect(await release()).toBe("ambiguous");
1048
+ expect(plan()).toBeNull();
1049
+ });
1050
+
1051
+ it("a marked lead under another email is ignored", async () => {
1052
+ seedMarkedLead(personId);
1053
+ seedMarkedLead(personId, {
1054
+ email: "other@example.com",
1055
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1056
+ });
1057
+ expect(await release()).toBe("set");
1058
+ expect(plan()).toBe("retainer");
1059
+ });
1060
+
1061
+ it("only unmarked leads are none", async () => {
1062
+ seedMarkedLead(personId, { spokeCheckout: false });
1063
+ expect(await release()).toBe("none");
1064
+ expect(plan()).toBeNull();
1065
+ });
1066
+
1067
+ it("only an offering that is no plan key is none", async () => {
1068
+ seedMarkedLead(personId, {
1069
+ payload: { site: "wendi", page: "checkout", offering: "wendi_Not A Key!" },
1070
+ });
1071
+ expect(await release()).toBe("none");
1072
+ expect(plan()).toBeNull();
1073
+ });
1074
+
1075
+ it("51 marked leads are ambiguous, even when they all agree", async () => {
1076
+ for (let i = 0; i < 51; i += 1) seedMarkedLead(personId);
1077
+ expect(await release()).toBe("ambiguous");
1078
+ expect(plan()).toBeNull();
1079
+ });
1080
+
1081
+ it("a plan already set is kept", async () => {
1082
+ people()[0]!["planOffering"] = "premium";
1083
+ seedMarkedLead(personId);
1084
+ expect(await release()).toBe("kept");
1085
+ expect(plan()).toBe("premium");
1086
+ });
1087
+
1088
+ it("a failed lead read is internal_error, with no write", async () => {
1089
+ seedMarkedLead(personId);
1090
+ store.lead["findMany"]!.mockRejectedValueOnce(new Error("down"));
1091
+ expect(await release()).toBe("internal_error");
1092
+ expect(personCalls().filter((c) => c.op === "updateMany")).toHaveLength(0);
1093
+ expect(plan()).toBeNull();
1094
+ });
1095
+ });
1096
+
1097
+ describe("resolveMemberPersonId releases a held plan on a fresh link only (#8793 D2, AC-2)", () => {
1098
+ const users = () => (store.__db["user"] ??= []);
1099
+ const U = "user_00000000-0000-7000-8000-000000000051";
1100
+ const EMAIL = "ada@example.com";
1101
+ const log = { warn: jest.fn() };
1102
+ let personId: string;
1103
+
1104
+ const plan = () => people()[0]!["planOffering"];
1105
+ const releaseLines = () =>
1106
+ log.warn.mock.calls
1107
+ .map(([fields]) => fields as Record<string, unknown>)
1108
+ .filter((fields) => fields["event"] === "checkout-plan-release");
1109
+
1110
+ beforeEach(() => {
1111
+ log.warn.mockClear();
1112
+ personId = seedPerson({ email: EMAIL })["id"] as string;
1113
+ seedMarkedLead(personId);
1114
+ });
1115
+
1116
+ it("a verified caller's first link releases the held plan, logging nothing", async () => {
1117
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1118
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1119
+ expect(people()[0]!["userId"]).toBe(U);
1120
+ expect(plan()).toBe("retainer");
1121
+ expect(releaseLines()).toEqual([]);
1122
+ });
1123
+
1124
+ it("an unverified caller links nothing and releases nothing", async () => {
1125
+ users().push({ id: U, email: EMAIL, emailVerified: false });
1126
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBeNull();
1127
+ expect(plan()).toBeNull();
1128
+ });
1129
+
1130
+ it("a Person linked before the call is answered with no release and no lead read", async () => {
1131
+ people()[0]!["userId"] = U;
1132
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1133
+ expect(plan()).toBeNull();
1134
+ expect(leadCalls()).toHaveLength(0);
1135
+ });
1136
+
1137
+ it("a direct stamp (the inbox accept's and the consents route's path) releases nothing", async () => {
1138
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1139
+ expect(await stampPersonUser(WS_A1, personId, U)).toBe("stamped");
1140
+ expect(plan()).toBeNull();
1141
+ expect(leadCalls()).toHaveLength(0);
1142
+ });
1143
+
1144
+ it("a failed release still answers the Person and logs its kind only", async () => {
1145
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1146
+ store.lead["findMany"]!.mockRejectedValueOnce(new Error("down"));
1147
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1148
+ expect(plan()).toBeNull();
1149
+ expect(releaseLines()).toEqual([
1150
+ { event: "checkout-plan-release", outcome: "internal_error" },
1151
+ ]);
1152
+ const logged = JSON.stringify(log.warn.mock.calls);
1153
+ expect(logged).not.toContain(EMAIL);
1154
+ expect(logged).not.toContain("retainer");
1155
+ });
1156
+
1157
+ it("disagreeing purchases release nothing and log ambiguous", async () => {
1158
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1159
+ seedMarkedLead(personId, {
1160
+ payload: { site: "wendi", page: "checkout", offering: "wendi_basic" },
1161
+ });
1162
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1163
+ expect(plan()).toBeNull();
1164
+ expect(releaseLines()).toEqual([
1165
+ { event: "checkout-plan-release", outcome: "ambiguous" },
1166
+ ]);
1167
+ });
1168
+
1169
+ it("a release that finds nothing to set logs nothing", async () => {
1170
+ leads().length = 0;
1171
+ users().push({ id: U, email: EMAIL, emailVerified: true });
1172
+ expect(await resolveMemberPersonId(WS_A1, U, EMAIL, log)).toBe(personId);
1173
+ expect(plan()).toBeNull();
1174
+ expect(log.warn).not.toHaveBeenCalled();
1175
+ });
1176
+ });