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
@@ -46,6 +46,7 @@ import * as path from "path";
46
46
  import { logger } from "@working-theory/observability";
47
47
  import {
48
48
  ACCOUNT_TERMS_PURPOSE_ID,
49
+ CONSENT_LEAD_SOURCE_LIST,
49
50
  consentPurposeById,
50
51
  SITE_CONSENT_CONFIG_DEFAULTS,
51
52
  } from "@working-theory/validation";
@@ -57,6 +58,7 @@ import {
57
58
  } from "../../lib/__tests__/consent-ledger-double.js";
58
59
  import { prisma } from "../../lib/prisma.js";
59
60
  import { routeLead } from "../lead-routing";
61
+ import * as consentService from "../consent";
60
62
  import * as personService from "../person";
61
63
 
62
64
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -1009,6 +1011,140 @@ describe("#7869 — routeLead with an idempotency key", () => {
1009
1011
  });
1010
1012
  });
1011
1013
 
1014
+ // ─── #8730, #9130 — a keyed consent-bearing lead replays ─────────────────────
1015
+ //
1016
+ // The route writes `payload.consent` on every request, its `acceptedAt` that
1017
+ // request's receive time, so a retry's payload differs from the stored one
1018
+ // there and nowhere else. The replay comparison leaves it out for EVERY
1019
+ // consent-bearing source — iterating the list, so a source added to it later
1020
+ // cannot be gated by the route yet compared here.
1021
+ describe("#8730, #9130 — routeLead replays a keyed consent-bearing lead", () => {
1022
+ const KEY = "fixture-retry-0000-0000-9130";
1023
+ const STORED_ID = "lead_00000000-0000-7000-8000-000000009131";
1024
+ const ENV_KEYS = [
1025
+ "DATABASE_URL",
1026
+ "NODE_ENV",
1027
+ "AILK_INBOX_FALLBACK_DIR",
1028
+ "LEAD_CAPTURE_FILE",
1029
+ "AILK_LEAD_NOTIFICATION_TO",
1030
+ "AILK_LEAD_NOTIFICATION_FROM",
1031
+ ] as const;
1032
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1033
+ let tmpDir: string;
1034
+
1035
+ const consentAt = (acceptedAt: string) => ({
1036
+ statement: "I accept the terms.",
1037
+ acceptedAt,
1038
+ });
1039
+
1040
+ beforeEach(async () => {
1041
+ jest.clearAllMocks();
1042
+ saved = {};
1043
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
1044
+ for (const k of ENV_KEYS) delete process.env[k];
1045
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-consent-replay-"));
1046
+ process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
1047
+ process.env.DATABASE_URL = "postgres://stub";
1048
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1049
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1050
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
1051
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
1052
+ });
1053
+
1054
+ afterEach(async () => {
1055
+ for (const k of ENV_KEYS) {
1056
+ if (saved[k] === undefined) delete process.env[k];
1057
+ else process.env[k] = saved[k];
1058
+ }
1059
+ await fs.rm(tmpDir, { recursive: true, force: true });
1060
+ });
1061
+
1062
+ it("covers every consent-bearing source, the job application included", () => {
1063
+ expect(CONSENT_LEAD_SOURCE_LIST).toContain("flow:job-application");
1064
+ });
1065
+
1066
+ it.each(CONSENT_LEAD_SOURCE_LIST)(
1067
+ "%s — a retry whose only difference is the server-written payload.consent replays the stored lead",
1068
+ async (source) => {
1069
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1070
+ id: STORED_ID,
1071
+ email: "visitor@example.com",
1072
+ source,
1073
+ payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1074
+ });
1075
+
1076
+ const result = await routeLead(
1077
+ {
1078
+ kind: "submit",
1079
+ siteId: "site-abc",
1080
+ source,
1081
+ email: "visitor@example.com",
1082
+ payload: { name: "Jamie", consent: consentAt("2026-10-18T12:00:00.000Z") },
1083
+ },
1084
+ sourceMeta,
1085
+ { idempotencyKey: KEY },
1086
+ );
1087
+
1088
+ expect(result).toMatchObject({ ok: true, replayed: true, id: STORED_ID });
1089
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1090
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
1091
+ },
1092
+ );
1093
+
1094
+ it.each(CONSENT_LEAD_SOURCE_LIST)(
1095
+ "%s — a retry with a different form field is still a conflict",
1096
+ async (source) => {
1097
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1098
+ id: STORED_ID,
1099
+ email: "visitor@example.com",
1100
+ source,
1101
+ payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1102
+ });
1103
+
1104
+ const result = await routeLead(
1105
+ {
1106
+ kind: "submit",
1107
+ siteId: "site-abc",
1108
+ source,
1109
+ email: "visitor@example.com",
1110
+ payload: { name: "Someone else", consent: consentAt("2026-10-18T12:00:00.000Z") },
1111
+ },
1112
+ sourceMeta,
1113
+ { idempotencyKey: KEY },
1114
+ );
1115
+
1116
+ expect(result).toEqual({
1117
+ ok: false,
1118
+ error: { kind: "idempotency_conflict", message: "idempotency key reused" },
1119
+ });
1120
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1121
+ },
1122
+ );
1123
+
1124
+ it("a source outside the list still compares payload.consent", async () => {
1125
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1126
+ id: STORED_ID,
1127
+ email: "visitor@example.com",
1128
+ source: "flow:quiz",
1129
+ payload: { name: "Jamie", consent: consentAt("2026-01-01T00:00:00.000Z") },
1130
+ });
1131
+
1132
+ const result = await routeLead(
1133
+ {
1134
+ kind: "submit",
1135
+ siteId: "site-abc",
1136
+ source: "flow:quiz",
1137
+ email: "visitor@example.com",
1138
+ payload: { name: "Jamie", consent: consentAt("2026-10-18T12:00:00.000Z") },
1139
+ },
1140
+ sourceMeta,
1141
+ { idempotencyKey: KEY },
1142
+ );
1143
+
1144
+ expect(result).toMatchObject({ ok: false, error: { kind: "idempotency_conflict" } });
1145
+ });
1146
+ });
1147
+
1012
1148
  // ─── #852 D6 — every submitted lead carries its Person ──────────────────────
1013
1149
  //
1014
1150
  // The Person step runs through the shared mock's `person` delegate, whose
@@ -1334,7 +1470,11 @@ describe("#852 — the lead's Person (AC-3)", () => {
1334
1470
  // paid checkout lead a registered spoke's server posted with the session id
1335
1471
  // as its key. Every other lead makes no plan call; a failing plan step never
1336
1472
  // changes the lead's result.
1337
- describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1473
+ // #8793 D1 — a marked checkout lead HOLDS its plan: it never writes a
1474
+ // Person's plan, logs `held` (or `skipped` for an offering that is no key),
1475
+ // and the plan is released only at the Person's verified first link
1476
+ // (`person.test.ts`, `checkout-plan.test.ts`).
1477
+ describe("#7877 — the checkout lead holds the plan (AC-5; #8793 D1, AC-7)", () => {
1338
1478
  const ENV_KEYS = [
1339
1479
  "DATABASE_URL",
1340
1480
  "NODE_ENV",
@@ -1400,20 +1540,34 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1400
1540
  [...info.mock.calls, ...error.mock.calls]
1401
1541
  .map(([fields]) => fields as Record<string, unknown>)
1402
1542
  .filter((fields) => fields.event === "checkout-plan");
1543
+ /** Every `checkout-plan` line is logged at `info`, never at `error`. */
1544
+ const infoPlanLines = () =>
1545
+ info.mock.calls
1546
+ .map(([fields]) => fields as Record<string, unknown>)
1547
+ .filter((fields) => fields.event === "checkout-plan");
1548
+ /** The lead's answer, as it was before #8793. */
1549
+ const answered = {
1550
+ ok: true,
1551
+ id: LEAD_ID,
1552
+ personId: MOCK_PERSON_ID,
1553
+ persisted: "db",
1554
+ email: { ok: true, messageId: "msg-7877" },
1555
+ };
1403
1556
 
1404
- it("a live spoke checkout lead sets the plan to the offering minus the <site>_ prefix", async () => {
1557
+ it("a live spoke checkout lead holds the plan: no plan write, one held line", async () => {
1405
1558
  const result = await routeLead(checkoutLead, spokeMeta, {
1406
1559
  idempotencyKey: KEY,
1407
1560
  });
1408
- expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1409
- expect(setPlan).toHaveBeenCalledTimes(1);
1410
- expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
1411
- expect(planLines()).toEqual([
1412
- { event: "checkout-plan", outcome: "set", siteId: WS },
1561
+ expect(result).toEqual(answered);
1562
+ expect(setPlan).not.toHaveBeenCalled();
1563
+ expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
1564
+ expect(infoPlanLines()).toEqual([
1565
+ { event: "checkout-plan", outcome: "held", siteId: WS },
1413
1566
  ]);
1567
+ expect(planLines()).toEqual(infoPlanLines());
1414
1568
  });
1415
1569
 
1416
- it("a bare slug offering is taken as it is", async () => {
1570
+ it("a bare slug offering is held as it is", async () => {
1417
1571
  await routeLead(
1418
1572
  {
1419
1573
  ...checkoutLead,
@@ -1422,7 +1576,10 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1422
1576
  spokeMeta,
1423
1577
  { idempotencyKey: KEY },
1424
1578
  );
1425
- expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
1579
+ expect(setPlan).not.toHaveBeenCalled();
1580
+ expect(planLines()).toEqual([
1581
+ { event: "checkout-plan", outcome: "held", siteId: WS },
1582
+ ]);
1426
1583
  });
1427
1584
 
1428
1585
  it("a positive integer amountTotal still qualifies", async () => {
@@ -1434,7 +1591,10 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1434
1591
  spokeMeta,
1435
1592
  { idempotencyKey: KEY },
1436
1593
  );
1437
- expect(setPlan).toHaveBeenCalledTimes(1);
1594
+ expect(setPlan).not.toHaveBeenCalled();
1595
+ expect(planLines()).toEqual([
1596
+ { event: "checkout-plan", outcome: "held", siteId: WS },
1597
+ ]);
1438
1598
  });
1439
1599
 
1440
1600
  it.each([
@@ -1499,20 +1659,22 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1499
1659
  unknown
1500
1660
  >;
1501
1661
 
1502
- it("a live spoke checkout lead is created with spokeCheckout true and still sets the plan once", async () => {
1662
+ it("a live spoke checkout lead is created with spokeCheckout true and holds the plan once", async () => {
1503
1663
  await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1504
1664
  expect(createData()).toMatchObject({ spokeCheckout: true });
1505
- expect(setPlan).toHaveBeenCalledTimes(1);
1665
+ expect(setPlan).not.toHaveBeenCalled();
1666
+ expect(planLines()).toHaveLength(1);
1506
1667
  });
1507
1668
 
1508
- it("the same lead relayed for a visitor is created with no spokeCheckout key and still sets the plan", async () => {
1669
+ it("the same lead relayed for a visitor is created with no spokeCheckout key and holds no plan", async () => {
1509
1670
  await routeLead(
1510
1671
  checkoutLead,
1511
1672
  { ...spokeMeta, relayed: true },
1512
1673
  { idempotencyKey: KEY },
1513
1674
  );
1514
1675
  expect(createData()).not.toHaveProperty("spokeCheckout");
1515
- expect(setPlan).toHaveBeenCalledTimes(1);
1676
+ expect(setPlan).not.toHaveBeenCalled();
1677
+ expect(planLines()).toEqual([]);
1516
1678
  });
1517
1679
 
1518
1680
  it.each([
@@ -1570,7 +1732,7 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1570
1732
  );
1571
1733
 
1572
1734
  it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
1573
- await routeLead(
1735
+ const result = await routeLead(
1574
1736
  {
1575
1737
  ...checkoutLead,
1576
1738
  payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" },
@@ -1578,10 +1740,26 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1578
1740
  spokeMeta,
1579
1741
  { idempotencyKey: KEY },
1580
1742
  );
1743
+ expect(result).toEqual(answered);
1581
1744
  expect(setPlan).not.toHaveBeenCalled();
1582
- expect(planLines()).toEqual([
1745
+ expect(infoPlanLines()).toEqual([
1583
1746
  { event: "checkout-plan", outcome: "skipped", siteId: WS },
1584
1747
  ]);
1748
+ expect(planLines()).toEqual(infoPlanLines());
1749
+ });
1750
+
1751
+ it("no checkout-plan line names an email, an offering or a key", async () => {
1752
+ await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1753
+ expect(planLines()).toHaveLength(1);
1754
+ for (const line of planLines()) {
1755
+ expect(line).not.toHaveProperty("email");
1756
+ expect(line).not.toHaveProperty("offering");
1757
+ expect(line).not.toHaveProperty("key");
1758
+ }
1759
+ const logged = JSON.stringify(planLines());
1760
+ expect(logged).not.toContain("retainer");
1761
+ expect(logged).not.toContain("cleo@x.io");
1762
+ expect(logged).not.toContain(KEY);
1585
1763
  });
1586
1764
 
1587
1765
  it("a replayed key writes nothing and sets nothing", async () => {
@@ -1598,33 +1776,6 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1598
1776
  expect(prisma.lead.create).not.toHaveBeenCalled();
1599
1777
  expect(setPlan).not.toHaveBeenCalled();
1600
1778
  });
1601
-
1602
- it.each([
1603
- ["throws", () => setPlan.mockRejectedValueOnce(new Error("down"))],
1604
- ["answers not_found", () => setPlan.mockResolvedValueOnce("not_found")],
1605
- ])(
1606
- "a plan step that %s answers exactly like a success, with one failed line naming no offering, email or key",
1607
- async (_label, arrange) => {
1608
- const ok = await routeLead(checkoutLead, spokeMeta, {
1609
- idempotencyKey: KEY,
1610
- });
1611
- info.mockClear();
1612
- error.mockClear();
1613
- arrange();
1614
- const failed = await routeLead(checkoutLead, spokeMeta, {
1615
- idempotencyKey: KEY,
1616
- });
1617
- expect(failed).toEqual(ok);
1618
- expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1619
- expect(planLines()).toEqual([
1620
- { event: "checkout-plan", outcome: "failed", siteId: WS },
1621
- ]);
1622
- const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
1623
- expect(logged).not.toContain("retainer");
1624
- expect(logged).not.toContain("cleo@x.io");
1625
- expect(logged).not.toContain(KEY);
1626
- },
1627
- );
1628
1779
  });
1629
1780
 
1630
1781
  describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-11)", () => {
@@ -2028,7 +2179,7 @@ describe("#8757 D14 — a spoke checkout's purchase email and Inbox message (AC-
2028
2179
  });
2029
2180
  await settle();
2030
2181
  expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2031
- expect(setPlan).toHaveBeenCalledTimes(1);
2182
+ expect(setPlan).not.toHaveBeenCalled();
2032
2183
  const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
2033
2184
  expect(logged).not.toContain("Cleo@X.io");
2034
2185
  expect(logged).not.toContain("retainer");
@@ -2200,6 +2351,8 @@ describe("#8778 — a spoke checkout's terms grant", () => {
2200
2351
  expect(consentLines()).toEqual([
2201
2352
  expect.objectContaining({ outcome: "recorded", siteId: WS }),
2202
2353
  ]);
2354
+ // #8793 D1 — the grant is written; the plan is held, never set here.
2355
+ expect(setPlan).not.toHaveBeenCalled();
2203
2356
  });
2204
2357
 
2205
2358
  it.each([
@@ -2328,3 +2481,180 @@ describe("#8778 — a spoke checkout's terms grant", () => {
2328
2481
  expect(logged).not.toContain(MOCK_PERSON_ID);
2329
2482
  });
2330
2483
  });
2484
+
2485
+ describe("#8932 D7 — the per-Organization webhook's Offerings checkout lead (AC-5)", () => {
2486
+ const ENV_KEYS = [
2487
+ "DATABASE_URL",
2488
+ "NODE_ENV",
2489
+ "AILK_LEAD_NOTIFICATION_TO",
2490
+ "AILK_LEAD_NOTIFICATION_FROM",
2491
+ ] as const;
2492
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
2493
+ const LEAD_ID = "lead_00000000-0000-7000-8000-000000008932";
2494
+ const WS = "wksp_00000000-0000-7000-8000-000000008932";
2495
+ const OTHER_WS = "wksp_00000000-0000-7000-8000-000000008933";
2496
+ const SESSION = "cs_live_a8932Offerings"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8932 AC-5), not a credential
2497
+ const KEY = `stripe-offerings ${SESSION}`;
2498
+ const ACCOUNT_TERMS = consentPurposeById(
2499
+ SITE_CONSENT_CONFIG_DEFAULTS,
2500
+ ACCOUNT_TERMS_PURPOSE_ID,
2501
+ )!;
2502
+ const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${ACCOUNT_TERMS.policyVersion}`;
2503
+ /** The input the webhook builds (D6) — the offering is the record's full key. */
2504
+ const offeringsLead = {
2505
+ kind: "submit" as const,
2506
+ siteId: WS,
2507
+ source: "web",
2508
+ email: "buyer@example.com",
2509
+ payload: {
2510
+ page: "checkout",
2511
+ offering: "growth_monthly",
2512
+ site: WS,
2513
+ amountTotal: 4900,
2514
+ currency: "usd",
2515
+ termsAccepted: STAMP,
2516
+ } as Record<string, unknown>,
2517
+ };
2518
+ const webhookMeta = {
2519
+ ip: "203.0.113.9",
2520
+ userAgent: "Stripe/1.0",
2521
+ receivedAt: "2026-10-18T00:00:00.000Z",
2522
+ };
2523
+ const twin = { idempotencyKey: KEY, offeringsCheckout: true as const };
2524
+ const leadCount = jest.fn();
2525
+ let setPlan: jest.SpyInstance;
2526
+ let consent: jest.SpyInstance;
2527
+ let info: jest.SpyInstance;
2528
+ let error: jest.SpyInstance;
2529
+
2530
+ beforeEach(() => {
2531
+ jest.clearAllMocks();
2532
+ saved = {};
2533
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
2534
+ process.env.DATABASE_URL = "postgres://stub";
2535
+ delete process.env.NODE_ENV;
2536
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
2537
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
2538
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
2539
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
2540
+ Object.assign(prisma.lead, { count: leadCount });
2541
+ leadCount.mockReset().mockResolvedValue(1);
2542
+ jest
2543
+ .mocked(prisma.interaction.create)
2544
+ .mockResolvedValue({ id: "intx_1" } as never);
2545
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "m" });
2546
+ mockSendPurchaseConfirmationEmail.mockResolvedValue({ ok: true, messageId: "m2" });
2547
+ mockListPlanOfferings.mockResolvedValue([]);
2548
+ setPlan = jest.spyOn(personService, "setPersonPlanOffering").mockResolvedValue("set");
2549
+ consent = jest
2550
+ .spyOn(consentService, "recordCheckoutConsent")
2551
+ .mockResolvedValue("recorded");
2552
+ info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
2553
+ error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
2554
+ });
2555
+
2556
+ afterEach(() => {
2557
+ for (const k of ENV_KEYS) {
2558
+ if (saved[k] === undefined) delete process.env[k];
2559
+ else process.env[k] = saved[k];
2560
+ }
2561
+ Reflect.deleteProperty(prisma.lead, "count");
2562
+ setPlan.mockRestore();
2563
+ consent.mockRestore();
2564
+ info.mockRestore();
2565
+ error.mockRestore();
2566
+ });
2567
+
2568
+ const createData = () =>
2569
+ (prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
2570
+ const planLines = () =>
2571
+ [...info.mock.calls, ...error.mock.calls]
2572
+ .map(([fields]) => fields as Record<string, unknown>)
2573
+ .filter((fields) => fields?.["event"] === "checkout-plan");
2574
+ const planWrites = () =>
2575
+ jest
2576
+ .mocked(prisma.person.updateMany)
2577
+ .mock.calls.filter(([args]) => "planOffering" in ((args as { data?: object }).data ?? {}));
2578
+
2579
+ it("creates one marked lead on the prefixed key, holds the full key and grants consent on the bare session id", async () => {
2580
+ const result = await routeLead(offeringsLead, webhookMeta, twin);
2581
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
2582
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
2583
+ expect(createData()).toMatchObject({
2584
+ spokeCheckout: true,
2585
+ idempotencyKey: KEY,
2586
+ payload: expect.objectContaining({ offering: "growth_monthly" }),
2587
+ });
2588
+ expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "held", siteId: WS }]);
2589
+ expect(consent).toHaveBeenCalledTimes(1);
2590
+ expect(consent).toHaveBeenCalledWith(
2591
+ expect.objectContaining({ workspaceId: WS, sessionId: SESSION }),
2592
+ );
2593
+ expect(setPlan).not.toHaveBeenCalled();
2594
+ expect(planWrites()).toEqual([]);
2595
+ });
2596
+
2597
+ it.each([
2598
+ ["without offeringsCheckout", offeringsLead, { idempotencyKey: KEY }],
2599
+ [
2600
+ "an offering the grammar refuses",
2601
+ { ...offeringsLead, payload: { ...offeringsLead.payload, offering: "growth_quarterly" } },
2602
+ twin,
2603
+ ],
2604
+ [
2605
+ "a site that is another workspace",
2606
+ { ...offeringsLead, payload: { ...offeringsLead.payload, site: OTHER_WS } },
2607
+ twin,
2608
+ ],
2609
+ ["a test-mode session key", offeringsLead, { ...twin, idempotencyKey: "stripe-offerings cs_test_a1" }],
2610
+ ["the bare session id as the key", offeringsLead, { ...twin, idempotencyKey: SESSION }],
2611
+ ["source contact", { ...offeringsLead, source: "contact" }, twin],
2612
+ [
2613
+ "page contact",
2614
+ { ...offeringsLead, payload: { ...offeringsLead.payload, page: "contact" } },
2615
+ twin,
2616
+ ],
2617
+ ] as const)("creates an unmarked lead and holds nothing for %s", async (_label, input, options) => {
2618
+ const result = await routeLead(input as typeof offeringsLead, webhookMeta, options);
2619
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
2620
+ expect(createData()).not.toHaveProperty("spokeCheckout");
2621
+ expect(planLines()).toEqual([]);
2622
+ expect(consent).not.toHaveBeenCalled();
2623
+ expect(prisma.interaction.create).not.toHaveBeenCalled();
2624
+ expect(planWrites()).toEqual([]);
2625
+ });
2626
+
2627
+ it("leaves a spoke checkout lead marked exactly as before", async () => {
2628
+ await routeLead(
2629
+ {
2630
+ kind: "submit",
2631
+ siteId: WS,
2632
+ source: "web",
2633
+ email: "cleo@x.io",
2634
+ payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" },
2635
+ },
2636
+ { ...webhookMeta, viaSpoke: true },
2637
+ { idempotencyKey: "cs_live_a1B2c3" }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
2638
+ );
2639
+ expect(createData()).toMatchObject({ spokeCheckout: true });
2640
+ expect(consent).not.toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION }));
2641
+ });
2642
+
2643
+ it("the verified first link releases the full lookup key into the Person's plan", async () => {
2644
+ setPlan.mockRestore();
2645
+ jest.mocked(prisma.lead.findMany).mockResolvedValue([
2646
+ { email: offeringsLead.email, payload: offeringsLead.payload },
2647
+ ] as never);
2648
+ jest.mocked(prisma.person.updateMany).mockResolvedValue({ count: 1 } as never);
2649
+ const outcome = await personService.releaseHeldCheckoutPlan(
2650
+ WS,
2651
+ MOCK_PERSON_ID,
2652
+ "usr_buyer_8932",
2653
+ "buyer@example.com",
2654
+ );
2655
+ expect(outcome).toBe("set");
2656
+ expect(prisma.person.updateMany).toHaveBeenCalledWith(
2657
+ expect.objectContaining({ data: { planOffering: "growth_monthly" } }),
2658
+ );
2659
+ });
2660
+ });