create-ailk 0.5.0 → 0.6.1

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 (329) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/cli.js +54 -5
  5. package/dist/copy.d.ts +14 -0
  6. package/dist/copy.js +38 -1
  7. package/dist/lib/api-boot-assertion.d.ts +46 -0
  8. package/dist/lib/api-boot-assertion.js +70 -0
  9. package/dist/lib/extract-module-bundle.d.ts +20 -1
  10. package/dist/lib/extract-module-bundle.js +64 -5
  11. package/dist/lib/import-graph.d.ts +29 -0
  12. package/dist/lib/import-graph.js +140 -0
  13. package/dist/lib/module-license-gate.d.ts +6 -1
  14. package/dist/lib/module-license-gate.js +6 -1
  15. package/dist/lib/pro-callout.js +1 -1
  16. package/dist/module-architecture.d.ts +1 -0
  17. package/dist/module-architecture.js +253 -127
  18. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  19. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  20. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  21. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  22. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  23. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  24. package/dist/programmatic.js +14 -10
  25. package/dist/surfaces.js +105 -7
  26. package/dist/template-manifest.d.ts +33 -0
  27. package/dist/template-manifest.js +99 -16
  28. package/dist/template-names.d.ts +1 -1
  29. package/dist/template-names.js +1 -0
  30. package/package.json +3 -2
  31. package/site-templates/marketing-site/DESIGN.md +148 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  34. package/site-templates/marketing-site/ailk-template.json +1 -1
  35. package/site-templates/marketing-site-free/DESIGN.md +148 -0
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  38. package/site-templates/minimalism-site/DESIGN.md +148 -0
  39. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  41. package/site-templates/minimalism-site/ailk-template.json +2 -1
  42. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  43. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  44. package/site-templates/saas-product/DESIGN.md +148 -0
  45. package/site-templates/simple-service/DESIGN.md +148 -0
  46. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  47. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  48. package/site-templates/simple-service/ailk-template.json +10 -0
  49. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  50. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  51. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  52. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  53. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  54. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  55. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  56. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  57. package/site-templates/simple-service/provenance.json +4 -0
  58. package/templates/.env.example +72 -7
  59. package/templates/CLAUDE.md +2 -3
  60. package/templates/DESIGN.md +155 -0
  61. package/templates/README.md +42 -8
  62. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  63. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  64. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  65. package/templates/apps/api/.env.example +13 -0
  66. package/templates/apps/api/CLAUDE.md +26 -2
  67. package/templates/apps/api/jest.config.cjs +3 -0
  68. package/templates/apps/api/package.json +1 -0
  69. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  70. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  71. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  72. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  73. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  74. package/templates/apps/api/src/config/index.ts +62 -0
  75. package/templates/apps/api/src/config/modules.ts +7 -3
  76. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  77. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  78. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  79. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  80. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  81. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  82. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  83. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  84. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  85. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  86. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  87. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  88. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  89. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  90. package/templates/apps/api/src/lib/site.ts +208 -9
  91. package/templates/apps/api/src/lib/stripe.ts +66 -0
  92. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  93. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  94. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  95. package/templates/apps/api/src/openapi/spec.ts +543 -2
  96. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  97. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  98. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  99. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  100. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  101. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  102. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  103. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  104. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  105. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  106. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  107. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  108. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  109. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  110. package/templates/apps/api/src/routes/content/index.ts +13 -73
  111. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  112. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  113. package/templates/apps/api/src/routes/content/search.ts +129 -0
  114. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  115. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  116. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  120. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  121. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  122. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  123. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  124. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  125. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  126. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  127. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  128. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  129. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  130. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  131. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  132. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  133. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  134. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  135. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  136. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  137. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  138. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  139. package/templates/apps/api/src/server.ts +439 -49
  140. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  141. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  142. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  143. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  144. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  145. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  146. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  147. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  148. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  149. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  150. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  151. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  152. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  153. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  154. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  155. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  156. package/templates/apps/api/src/services/audit-log.ts +668 -0
  157. package/templates/apps/api/src/services/consent.ts +18 -0
  158. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  159. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  160. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  161. package/templates/apps/api/src/services/entitlements.ts +80 -0
  162. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  163. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  164. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  165. package/templates/apps/api/src/services/listing-config.ts +27 -9
  166. package/templates/apps/api/src/services/project-listings.ts +201 -6
  167. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  168. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  169. package/templates/apps/api/src/services/revalidation.ts +173 -0
  170. package/templates/apps/api/src/services/verified-email.ts +71 -0
  171. package/templates/apps/api/src/vercel-handler.ts +75 -35
  172. package/templates/apps/mcp/.env.example +17 -0
  173. package/templates/apps/mcp/CLAUDE.md +57 -24
  174. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  175. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  176. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  177. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  178. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  179. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  180. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  181. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  182. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  183. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  184. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  185. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  186. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  187. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  188. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  189. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  190. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  191. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  192. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  193. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  194. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  195. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  196. package/templates/apps/mcp/jest.config.cjs +13 -0
  197. package/templates/apps/mcp/package.json +2 -0
  198. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  199. package/templates/apps/mcp/src/config/modules.ts +10 -5
  200. package/templates/apps/mcp/src/http-transport.ts +102 -29
  201. package/templates/apps/mcp/src/index.ts +73 -11
  202. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  203. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  204. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  205. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  206. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  207. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  208. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  209. package/templates/apps/mcp/src/server.ts +59 -35
  210. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  211. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  212. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  213. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  214. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  215. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  216. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  217. package/templates/apps/mcp/src/tools/index.ts +6 -4
  218. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  219. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  220. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  221. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  222. package/templates/apps/web/.env.example +71 -5
  223. package/templates/apps/web/CLAUDE.md +44 -0
  224. package/templates/apps/web/PRODUCT.md +86 -0
  225. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  226. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  227. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  228. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  229. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  230. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  231. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  232. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  233. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  234. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  235. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  236. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  237. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  238. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  239. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  240. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  241. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  242. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  243. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  244. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  245. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  246. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  247. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  248. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  249. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  250. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  251. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  252. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  253. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  254. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  255. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  256. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  257. package/templates/apps/web/auth.ts +407 -8
  258. package/templates/apps/web/jest.config.cjs +8 -4
  259. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  260. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  261. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  262. package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
  263. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  264. package/templates/apps/web/lib/auth-email.ts +318 -0
  265. package/templates/apps/web/lib/auth-env.ts +85 -0
  266. package/templates/apps/web/lib/auth-routes.ts +55 -0
  267. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  268. package/templates/apps/web/lib/site-brand.tsx +4 -8
  269. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  270. package/templates/apps/web/messages/en.json +187 -0
  271. package/templates/apps/web/middleware.ts +1 -1
  272. package/templates/apps/web/package.json +2 -1
  273. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  274. package/templates/apps/web/src/lib/commerce-sections.ts +93 -0
  275. package/templates/content/_site.mdx +18 -3
  276. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  277. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  278. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  279. package/templates/database/CHANGELOG.md +149 -0
  280. package/templates/database/README.md +10 -3
  281. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  282. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  283. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  284. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  285. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  286. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  287. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  288. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  289. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  290. package/templates/database/__tests__/tenancy.test.ts +141 -0
  291. package/templates/database/config/schema.prisma +79 -2
  292. package/templates/database/content/schema.prisma +87 -11
  293. package/templates/database/inbox/schema.prisma +452 -9
  294. package/templates/database/jest.config.cjs +5 -3
  295. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  296. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  297. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  298. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  299. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  300. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  301. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  302. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  303. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  304. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  305. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  306. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  307. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  308. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  309. package/templates/database/package.json +3 -2
  310. package/templates/database/tenancy.ts +86 -1
  311. package/templates/database/tsconfig.json +1 -0
  312. package/versions.json +34 -34
  313. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  314. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  315. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  316. package/templates/apps/api/src/routes/content/create.ts +0 -115
  317. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  318. package/templates/apps/api/src/routes/content/update.ts +0 -112
  319. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  320. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  321. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  322. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  323. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  324. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  325. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  326. package/templates/apps/web/public/logo-dark.svg +0 -21
  327. package/templates/apps/web/public/logo-light.svg +0 -21
  328. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  329. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -0,0 +1,648 @@
1
+ /**
2
+ * POST /api/webhooks/stripe — handler unit tests
3
+ *
4
+ * Uses jest.useFakeTimers for the telemetry-timeout test so the 2s wall-clock
5
+ * wait doesn't slow the suite.
6
+ */
7
+ import type { FastifyInstance } from "fastify";
8
+ import Fastify from "fastify";
9
+
10
+ // ── Mocks ────────────────────────────────────────────────────────────────────
11
+
12
+ // Mock analytics track (external boundary: @working-theory/analytics/server)
13
+ const mockTrack = jest.fn().mockResolvedValue(undefined);
14
+ // Commerce claim (D-64, #5796): the receiver is a base-tree primitive gated on
15
+ // the JWT `commerce` claim, so every case in this file is a COMMERCE-CLAIMED
16
+ // deployment — which is what each one was already asserting before the gate
17
+ // existed. The unclaimed matrix lives in
18
+ // `src/__tests__/commerce-claim-registration.test.ts`.
19
+ jest.mock("@working-theory/license", () => ({
20
+ checkLicense: jest.fn().mockResolvedValue({
21
+ tier: "pro",
22
+ plan: "pro-monthly",
23
+ features: ["commerce"],
24
+ expiresAt: null,
25
+ revoked: false,
26
+ }),
27
+ }));
28
+
29
+ jest.mock("@working-theory/analytics/server", () => ({
30
+ track: (...args: unknown[]) => mockTrack(...args),
31
+ }));
32
+
33
+ // Mock Prisma — lib/prisma.ts wraps the external DB boundary (PrismaClient
34
+ // requires a live connection). This call auto-resolves to the manual mock at
35
+ // src/lib/__mocks__/prisma.ts (covers user.findFirst/findUnique/update/create).
36
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
37
+ jest.mock("../../../lib/prisma.js");
38
+
39
+ // Mock verifyWebhookSignature at the external boundary (@working-theory/stripe-client)
40
+ // rather than the internal wrapper (lib/stripe.js). lib/stripe.js is a
41
+ // thin re-export with no logic of its own; the external SDK boundary is the
42
+ // correct mock point for signature verification.
43
+ const mockVerifyWebhookSignature = jest.fn();
44
+ class MockStripeWebhookSignatureError extends Error {
45
+ constructor(message: string) {
46
+ super(message);
47
+ this.name = "StripeWebhookSignatureError";
48
+ }
49
+ }
50
+ jest.mock("@working-theory/stripe-client", () => ({
51
+ verifyWebhookSignature: (...args: unknown[]) =>
52
+ mockVerifyWebhookSignature(...args),
53
+ StripeWebhookSignatureError: MockStripeWebhookSignatureError,
54
+ assertSandboxKey: jest.fn(),
55
+ createCheckoutSession: jest.fn(),
56
+ getStripeClient: jest.fn(),
57
+ STRIPE_API_VERSION: "2024-06-20",
58
+ StripeConfigError: class StripeConfigError extends Error {},
59
+ UnknownProductCodeError: class UnknownProductCodeError extends Error {},
60
+ WEBHOOK_TOLERANCE_SECONDS: 300,
61
+ }));
62
+
63
+ // Import after mocks are in place
64
+ import * as configModule from "../../../config/index.js";
65
+ import { stripeWebhookHandler } from "../stripe.js";
66
+ import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
67
+ import { prisma } from "../../../lib/prisma.js";
68
+
69
+ // ── Helpers ──────────────────────────────────────────────────────────────────
70
+
71
+ function buildFakeEvent(overrides: Record<string, unknown> = {}) {
72
+ return {
73
+ id: "evt_test_12345678abcdef",
74
+ type: "checkout.session.completed",
75
+ data: {
76
+ object: {
77
+ amount_total: 4999,
78
+ currency: "usd",
79
+ metadata: { productId: "starter" },
80
+ customer_details: { email: "alice@example.com" },
81
+ id: "cs_test_session_12345678901234567890",
82
+ },
83
+ },
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ async function buildTestServer(): Promise<FastifyInstance> {
89
+ const server = Fastify({ logger: false });
90
+
91
+ await server.register(async (webhooksScope) => {
92
+ webhooksScope.addContentTypeParser(
93
+ "application/json",
94
+ { parseAs: "buffer" },
95
+ (_req, body, done) => {
96
+ done(null, body);
97
+ },
98
+ );
99
+ webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
100
+ });
101
+
102
+ await server.ready();
103
+ return server;
104
+ }
105
+
106
+ // Valid raw body payload
107
+ const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
108
+ const VALID_SIGNATURE = "t=1234,v1=abc";
109
+
110
+ // ── Tests ────────────────────────────────────────────────────────────────────
111
+
112
+ describe("POST /api/webhooks/stripe", () => {
113
+ let server: FastifyInstance;
114
+
115
+ beforeEach(async () => {
116
+ jest.clearAllMocks();
117
+ __resetIdempotencyStore();
118
+ // Control STRIPE_WEBHOOK_SECRET per-test via jest.replaceProperty.
119
+ // config is a module-level singleton frozen at load time; replaceProperty
120
+ // restores the original value automatically in afterEach.
121
+ jest.replaceProperty(configModule, "config", {
122
+ ...configModule.config,
123
+ STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
124
+ });
125
+ server = await buildTestServer();
126
+ });
127
+
128
+ afterEach(async () => {
129
+ await server.close();
130
+ jest.restoreAllMocks();
131
+ });
132
+
133
+ // ── Case 1: Missing config returns 503 ──────────────────────────────────
134
+
135
+ it("returns 503 when STRIPE_WEBHOOK_SECRET is not set", async () => {
136
+ jest.replaceProperty(configModule, "config", {
137
+ ...configModule.config,
138
+ STRIPE_WEBHOOK_SECRET: undefined,
139
+ });
140
+
141
+ const response = await server.inject({
142
+ method: "POST",
143
+ url: "/v1/webhooks/stripe",
144
+ payload: VALID_BODY,
145
+ headers: {
146
+ "content-type": "application/json",
147
+ "stripe-signature": VALID_SIGNATURE,
148
+ },
149
+ });
150
+
151
+ expect(response.statusCode).toBe(503);
152
+ expect(JSON.parse(response.body)).toEqual({
153
+ error: "webhook-not-configured",
154
+ });
155
+ expect(mockVerifyWebhookSignature).not.toHaveBeenCalled();
156
+ expect(mockTrack).not.toHaveBeenCalled();
157
+ });
158
+
159
+ // ── Case 2: Invalid signature returns 400 ──────────────────────────────
160
+
161
+ it("returns 400 when signature verification fails", async () => {
162
+ mockVerifyWebhookSignature.mockImplementation(() => {
163
+ throw new MockStripeWebhookSignatureError(
164
+ "Signature verification failed.",
165
+ );
166
+ });
167
+
168
+ const response = await server.inject({
169
+ method: "POST",
170
+ url: "/v1/webhooks/stripe",
171
+ payload: VALID_BODY,
172
+ headers: {
173
+ "content-type": "application/json",
174
+ "stripe-signature": VALID_SIGNATURE,
175
+ },
176
+ });
177
+
178
+ expect(response.statusCode).toBe(400);
179
+ expect(JSON.parse(response.body)).toEqual({ error: "signature-invalid" });
180
+ expect(mockTrack).not.toHaveBeenCalled();
181
+ });
182
+
183
+ // ── Case 3: Missing Stripe-Signature header returns 400 ─────────────────
184
+
185
+ it("returns 400 when Stripe-Signature header is missing", async () => {
186
+ const response = await server.inject({
187
+ method: "POST",
188
+ url: "/v1/webhooks/stripe",
189
+ payload: VALID_BODY,
190
+ headers: {
191
+ "content-type": "application/json",
192
+ // no stripe-signature
193
+ },
194
+ });
195
+
196
+ expect(response.statusCode).toBe(400);
197
+ expect(JSON.parse(response.body)).toEqual({ error: "signature-missing" });
198
+ expect(mockTrack).not.toHaveBeenCalled();
199
+ });
200
+
201
+ // ── Case 4: Body not a Buffer returns 400 ──────────────────────────────
202
+
203
+ it("returns 400 when body is not a Buffer (parser misconfig)", async () => {
204
+ // Build a server that passes a string body instead of Buffer
205
+ const stringBodyServer = Fastify({ logger: false });
206
+ await stringBodyServer.register(async (scope) => {
207
+ scope.addContentTypeParser(
208
+ "application/json",
209
+ { parseAs: "string" },
210
+ (_req, body, done) => {
211
+ done(null, body);
212
+ },
213
+ );
214
+ scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
215
+ });
216
+ await stringBodyServer.ready();
217
+
218
+ try {
219
+ const response = await stringBodyServer.inject({
220
+ method: "POST",
221
+ url: "/v1/webhooks/stripe",
222
+ payload: '{"type":"test"}',
223
+ headers: {
224
+ "content-type": "application/json",
225
+ "stripe-signature": VALID_SIGNATURE,
226
+ },
227
+ });
228
+
229
+ expect(response.statusCode).toBe(400);
230
+ expect(JSON.parse(response.body)).toEqual({ error: "invalid-request" });
231
+ expect(mockTrack).not.toHaveBeenCalled();
232
+ } finally {
233
+ await stringBodyServer.close();
234
+ }
235
+ });
236
+
237
+ // ── Case 5: Valid checkout.session.completed fires telemetry and 200s ──
238
+
239
+ it("returns 200 and fires track on valid checkout.session.completed", async () => {
240
+ const fakeEvent = buildFakeEvent();
241
+ mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
242
+
243
+ const response = await server.inject({
244
+ method: "POST",
245
+ url: "/v1/webhooks/stripe",
246
+ payload: VALID_BODY,
247
+ headers: {
248
+ "content-type": "application/json",
249
+ "stripe-signature": VALID_SIGNATURE,
250
+ },
251
+ });
252
+
253
+ expect(response.statusCode).toBe(200);
254
+ expect(JSON.parse(response.body)).toEqual({ received: true });
255
+ expect(mockTrack).toHaveBeenCalledTimes(1);
256
+ expect(mockTrack).toHaveBeenCalledWith("purchase-completed", {
257
+ amount: 4999,
258
+ currency: "usd",
259
+ productId: "starter",
260
+ eventType: "checkout.session.completed",
261
+ eventIdPrefix: "evt_test",
262
+ });
263
+ });
264
+
265
+ // ── Case 6: metadata.product_id (snake_case) is accepted ───────────────
266
+
267
+ it("accepts metadata.product_id (snake_case) for productId", async () => {
268
+ const fakeEvent = buildFakeEvent({
269
+ data: {
270
+ object: {
271
+ amount_total: 9900,
272
+ currency: "usd",
273
+ metadata: { product_id: "pro" },
274
+ },
275
+ },
276
+ });
277
+ mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
278
+
279
+ const response = await server.inject({
280
+ method: "POST",
281
+ url: "/v1/webhooks/stripe",
282
+ payload: VALID_BODY,
283
+ headers: {
284
+ "content-type": "application/json",
285
+ "stripe-signature": VALID_SIGNATURE,
286
+ },
287
+ });
288
+
289
+ expect(response.statusCode).toBe(200);
290
+ expect(mockTrack).toHaveBeenCalledWith("purchase-completed", {
291
+ amount: 9900,
292
+ currency: "usd",
293
+ productId: "pro",
294
+ eventType: "checkout.session.completed",
295
+ eventIdPrefix: "evt_test",
296
+ });
297
+ });
298
+
299
+ // ── Case 7: no metadata produces undefined productId ───────────────────
300
+
301
+ it("sends undefined productId when metadata is absent", async () => {
302
+ const fakeEvent = buildFakeEvent({
303
+ data: {
304
+ object: {
305
+ amount_total: 1999,
306
+ currency: "gbp",
307
+ metadata: null,
308
+ },
309
+ },
310
+ });
311
+ mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
312
+
313
+ await server.inject({
314
+ method: "POST",
315
+ url: "/v1/webhooks/stripe",
316
+ payload: VALID_BODY,
317
+ headers: {
318
+ "content-type": "application/json",
319
+ "stripe-signature": VALID_SIGNATURE,
320
+ },
321
+ });
322
+
323
+ const call = mockTrack.mock.calls[0];
324
+ expect(call[1]).toMatchObject({ productId: undefined });
325
+ });
326
+
327
+ // ── Case 8: Unknown event type 200s with info log ───────────────────────
328
+
329
+ it("returns 200 and does not call track for unknown event type", async () => {
330
+ const fakeEvent = buildFakeEvent({ type: "payment_intent.created" });
331
+ mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
332
+
333
+ const response = await server.inject({
334
+ method: "POST",
335
+ url: "/v1/webhooks/stripe",
336
+ payload: VALID_BODY,
337
+ headers: {
338
+ "content-type": "application/json",
339
+ "stripe-signature": VALID_SIGNATURE,
340
+ },
341
+ });
342
+
343
+ expect(response.statusCode).toBe(200);
344
+ expect(JSON.parse(response.body)).toEqual({ received: true });
345
+ expect(mockTrack).not.toHaveBeenCalled();
346
+ });
347
+
348
+ // ── Case 9: Telemetry failure does not block 200 ────────────────────────
349
+
350
+ it("returns 200 even when track throws synchronously", async () => {
351
+ const fakeEvent = buildFakeEvent();
352
+ mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
353
+ mockTrack.mockRejectedValueOnce(new Error("analytics unreachable"));
354
+
355
+ const response = await server.inject({
356
+ method: "POST",
357
+ url: "/v1/webhooks/stripe",
358
+ payload: VALID_BODY,
359
+ headers: {
360
+ "content-type": "application/json",
361
+ "stripe-signature": VALID_SIGNATURE,
362
+ },
363
+ });
364
+
365
+ expect(response.statusCode).toBe(200);
366
+ expect(JSON.parse(response.body)).toEqual({ received: true });
367
+ });
368
+
369
+ // ── Case 10: Telemetry timeout does not block 200 ───────────────────────
370
+
371
+ it("returns 200 within timeout window when track never resolves", async () => {
372
+ const fakeEvent = buildFakeEvent();
373
+ mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
374
+
375
+ // track returns a promise that never resolves — simulates a hung adapter
376
+ let resolveTimeout!: () => void;
377
+ const neverResolves = new Promise<void>((resolve) => {
378
+ resolveTimeout = resolve;
379
+ });
380
+ mockTrack.mockReturnValueOnce(neverResolves);
381
+
382
+ // Use real timers; inject completes once the 2s timeout fires.
383
+ // We set a generous test timeout so jest doesn't kill it first.
384
+ const responsePromise = server.inject({
385
+ method: "POST",
386
+ url: "/v1/webhooks/stripe",
387
+ payload: VALID_BODY,
388
+ headers: {
389
+ "content-type": "application/json",
390
+ "stripe-signature": VALID_SIGNATURE,
391
+ },
392
+ });
393
+
394
+ // Wait for the 2s telemetry timeout to fire naturally, then resolve the
395
+ // hung track promise so the suite doesn't leak a pending microtask.
396
+ const response = await responsePromise;
397
+ resolveTimeout();
398
+
399
+ expect(response.statusCode).toBe(200);
400
+ expect(JSON.parse(response.body)).toEqual({ received: true });
401
+ }, 6000); // 6s test timeout > 2s TELEMETRY_TIMEOUT_MS
402
+
403
+ // ── Case 11: Logs contain no PII ────────────────────────────────────────
404
+
405
+ it("does not log customer email, full event ID, or full session ID", async () => {
406
+ const logLines: unknown[] = [];
407
+ const logCapture = Fastify({
408
+ logger: {
409
+ level: "info",
410
+ stream: {
411
+ write(msg: string) {
412
+ logLines.push(JSON.parse(msg));
413
+ },
414
+ },
415
+ },
416
+ });
417
+
418
+ await logCapture.register(async (scope) => {
419
+ scope.addContentTypeParser(
420
+ "application/json",
421
+ { parseAs: "buffer" },
422
+ (_req, body, done) => {
423
+ done(null, body);
424
+ },
425
+ );
426
+ scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
427
+ });
428
+ await logCapture.ready();
429
+
430
+ try {
431
+ const fakeEvent = buildFakeEvent();
432
+ mockVerifyWebhookSignature.mockReturnValue(fakeEvent);
433
+
434
+ await logCapture.inject({
435
+ method: "POST",
436
+ url: "/v1/webhooks/stripe",
437
+ payload: VALID_BODY,
438
+ headers: {
439
+ "content-type": "application/json",
440
+ "stripe-signature": VALID_SIGNATURE,
441
+ },
442
+ });
443
+
444
+ const logText = JSON.stringify(logLines);
445
+ // Must NOT contain full event ID
446
+ expect(logText).not.toContain("evt_test_12345678abcdef");
447
+ // Must NOT contain customer email
448
+ expect(logText).not.toContain("alice@example.com");
449
+ // Must NOT contain full session ID
450
+ expect(logText).not.toContain("cs_test_session_12345678901234567890");
451
+ // Should contain the 8-char prefix
452
+ expect(logText).toContain("evt_test");
453
+ } finally {
454
+ await logCapture.close();
455
+ }
456
+ });
457
+
458
+ // ── Case 12: Non-POST methods return 404 ────────────────────────────────
459
+
460
+ it("returns 404 for GET /api/webhooks/stripe", async () => {
461
+ const response = await server.inject({
462
+ method: "GET",
463
+ url: "/v1/webhooks/stripe",
464
+ });
465
+ expect(response.statusCode).toBe(404);
466
+ });
467
+
468
+ it("returns 404 for PUT /api/webhooks/stripe", async () => {
469
+ const response = await server.inject({
470
+ method: "PUT",
471
+ url: "/v1/webhooks/stripe",
472
+ payload: VALID_BODY,
473
+ headers: { "content-type": "application/json" },
474
+ });
475
+ expect(response.statusCode).toBe(404);
476
+ });
477
+
478
+ it("returns 404 for DELETE /api/webhooks/stripe", async () => {
479
+ const response = await server.inject({
480
+ method: "DELETE",
481
+ url: "/v1/webhooks/stripe",
482
+ });
483
+ expect(response.statusCode).toBe(404);
484
+ });
485
+
486
+ // ── Billing upsert tests (AC bullets 4-6) ──────────────────────────────────
487
+
488
+ // AC bullet 4: customer.created → upsert; re-fire same event = no second write
489
+ it("upserts billing fields on customer.created and is idempotent on re-fire", async () => {
490
+ const customerId = "cus_test_abc123";
491
+ const fakeCustomerCreated = {
492
+ id: "evt_customer_created_001",
493
+ type: "customer.created",
494
+ data: {
495
+ object: {
496
+ id: customerId,
497
+ email: "alice@example.com",
498
+ },
499
+ },
500
+ };
501
+ const upsertedUser = {
502
+ id: "usr_1",
503
+ email: "alice@example.com",
504
+ billingProvider: "stripe",
505
+ billingCustomerId: customerId,
506
+ };
507
+
508
+ // First fire: typed composite match misses (user not yet linked)
509
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
510
+ // email fallback finds the user
511
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
512
+ id: "usr_1",
513
+ email: "alice@example.com",
514
+ billingProvider: null,
515
+ billingCustomerId: null,
516
+ });
517
+ (prisma.user.update as jest.Mock).mockResolvedValueOnce(upsertedUser);
518
+
519
+ mockVerifyWebhookSignature.mockReturnValue(fakeCustomerCreated);
520
+ const response1 = await server.inject({
521
+ method: "POST",
522
+ url: "/v1/webhooks/stripe",
523
+ payload: VALID_BODY,
524
+ headers: {
525
+ "content-type": "application/json",
526
+ "stripe-signature": VALID_SIGNATURE,
527
+ },
528
+ });
529
+ expect(response1.statusCode).toBe(200);
530
+ expect(prisma.user.update).toHaveBeenCalledTimes(1);
531
+ expect(prisma.user.update).toHaveBeenCalledWith(
532
+ expect.objectContaining({
533
+ data: expect.objectContaining({
534
+ billingProvider: "stripe",
535
+ billingCustomerId: customerId,
536
+ }),
537
+ }),
538
+ );
539
+
540
+ // Re-fire same event: idempotency store short-circuits — no second write
541
+ mockVerifyWebhookSignature.mockReturnValue(fakeCustomerCreated);
542
+ const response2 = await server.inject({
543
+ method: "POST",
544
+ url: "/v1/webhooks/stripe",
545
+ payload: VALID_BODY,
546
+ headers: {
547
+ "content-type": "application/json",
548
+ "stripe-signature": VALID_SIGNATURE,
549
+ },
550
+ });
551
+ expect(response2.statusCode).toBe(200);
552
+ // update should still be 1 — idempotency store prevented second execution
553
+ expect(prisma.user.update).toHaveBeenCalledTimes(1);
554
+ });
555
+
556
+ // AC bullet 5: checkout.session.completed + existing User matched by email → billing fields populated
557
+ it("populates billingProvider/Id on checkout.session.completed for user matched by email", async () => {
558
+ const customerId = "cus_test_checkout_xyz";
559
+ const fakeCheckout = {
560
+ id: "evt_checkout_002",
561
+ type: "checkout.session.completed",
562
+ data: {
563
+ object: {
564
+ id: "cs_test_abc",
565
+ amount_total: 4999,
566
+ currency: "usd",
567
+ customer: customerId,
568
+ customer_details: { email: "bob@example.com" },
569
+ metadata: { productId: "pro" },
570
+ },
571
+ },
572
+ };
573
+ const updatedUser = {
574
+ id: "usr_2",
575
+ email: "bob@example.com",
576
+ billingProvider: "stripe",
577
+ billingCustomerId: customerId,
578
+ };
579
+
580
+ // Typed composite lookup misses (no prior link)
581
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
582
+ // Email fallback finds user
583
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
584
+ id: "usr_2",
585
+ email: "bob@example.com",
586
+ billingProvider: null,
587
+ billingCustomerId: null,
588
+ });
589
+ (prisma.user.update as jest.Mock).mockResolvedValueOnce(updatedUser);
590
+
591
+ mockVerifyWebhookSignature.mockReturnValue(fakeCheckout);
592
+ const response = await server.inject({
593
+ method: "POST",
594
+ url: "/v1/webhooks/stripe",
595
+ payload: VALID_BODY,
596
+ headers: {
597
+ "content-type": "application/json",
598
+ "stripe-signature": VALID_SIGNATURE,
599
+ },
600
+ });
601
+
602
+ expect(response.statusCode).toBe(200);
603
+ expect(prisma.user.update).toHaveBeenCalledWith(
604
+ expect.objectContaining({
605
+ where: { id: "usr_2" },
606
+ data: expect.objectContaining({
607
+ billingProvider: "stripe",
608
+ billingCustomerId: customerId,
609
+ }),
610
+ }),
611
+ );
612
+ });
613
+
614
+ // AC bullet 6: event with neither typed match nor email match → 200 + warning, no User row created
615
+ it("returns 200 and logs warning when no user match found — no row created", async () => {
616
+ const fakeOrphan = {
617
+ id: "evt_orphan_003",
618
+ type: "customer.created",
619
+ data: {
620
+ object: {
621
+ id: "cus_test_orphan",
622
+ email: null, // no email — can't even do email fallback
623
+ },
624
+ },
625
+ };
626
+
627
+ // Typed composite lookup misses
628
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
629
+ // No email fallback possible (email is null)
630
+
631
+ mockVerifyWebhookSignature.mockReturnValue(fakeOrphan);
632
+ const response = await server.inject({
633
+ method: "POST",
634
+ url: "/v1/webhooks/stripe",
635
+ payload: VALID_BODY,
636
+ headers: {
637
+ "content-type": "application/json",
638
+ "stripe-signature": VALID_SIGNATURE,
639
+ },
640
+ });
641
+
642
+ expect(response.statusCode).toBe(200);
643
+ expect(JSON.parse(response.body)).toEqual({ received: true });
644
+ // No user row created
645
+ expect(prisma.user.update).not.toHaveBeenCalled();
646
+ expect(prisma.user.create).not.toHaveBeenCalled();
647
+ });
648
+ });