create-ailk 0.1.1 → 0.3.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 (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -1,768 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file usage-metering.test.ts
4
- * @description Unit tests for the usage-metering service (ADR 0009 D3
5
- * service-layer pattern; spec docs/specs/3705-s1-usage-metering-service-spec.md §5).
6
- *
7
- * External dependency boundary mocked:
8
- * - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts`
9
- * manual mock (`jest.mock("../../lib/prisma.js")`), extended with the
10
- * FEAT-047 P1 model namespaces (usageEvent/usagePeriod/llmModel/
11
- * llmModelPricing/aiMarkupConfig — #3709/#3710, not yet in the generated
12
- * Prisma client this package compiles against) plus `$transaction`.
13
- * `usage-metering.ts` casts `prisma` to a local structural delegate type
14
- * (`UsageMeteringPrismaDelegates`); this test does the same so the mock's
15
- * `jest.fn()` methods type-check the same way the service's calls do —
16
- * this test harness stands in for the not-yet-existing live AI call
17
- * (AC-UB-02 service-level, per the spec's §1 scope-reality note).
18
- *
19
- * `aiMarkupConfig.findFirst` is given a REALISTIC filtering implementation
20
- * over an in-memory fixture "table" (mirroring lead-promotion.test.ts's
21
- * `seedLeadTable` pattern) so the hierarchy tests (AC-2) exercise the actual
22
- * precedence the service resolves, not just a static per-call stub.
23
- *
24
- * `$transaction` is likewise given REALISTIC commit/rollback semantics over an
25
- * in-memory `committedEvents` store (`installAtomicDb`), while the BASE client's
26
- * `usageEvent.create` models an AUTO-COMMIT write. That asymmetry is what makes
27
- * the H3 atomicity test load-bearing: a non-transactional implementation (write
28
- * the event, THEN upsert) commits the event immediately, so a failing upsert
29
- * strands an orphan row in `committedEvents` and the test fails. Only writes
30
- * routed through `$transaction` roll back.
31
- *
32
- * Security-review coverage (PR #3727): every High/Medium finding carries a test
33
- * that fails without its fix. H1/H2 (per-event `ceil` to whole cents) are
34
- * deliberately NOT addressed — a spec-level billing-fairness decision escalated
35
- * to Sam — so the 360¢ expectation below still encodes the current rounding.
36
- */
37
-
38
- // 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.
39
- jest.mock("../../lib/prisma.js");
40
-
41
- import type { Id } from "@working-theory/ids";
42
-
43
- import { prisma } from "../../lib/prisma.js";
44
- import type { TenantContext } from "../tenant-context.js";
45
- import {
46
- calculateProviderCost,
47
- recordUsageEvent,
48
- type ModelPricing,
49
- type RecordUsageEventInput,
50
- type RecordUsageResult,
51
- type UsageMeteringPrismaDelegates,
52
- type UsageWriteClient,
53
- } from "../usage-metering";
54
-
55
- // The generated Prisma client doesn't carry the FEAT-047 P1 models yet
56
- // (#3709/#3710 unmerged) — see the file docblock. Same structural cast the
57
- // service source uses.
58
- // eslint-disable-next-line no-restricted-syntax -- prisma's TS type resolves to the real (not-yet-augmented) PrismaClient; the FEAT-047 P1 model delegates (usageEvent/usagePeriod/llmModel/llmModelPricing/aiMarkupConfig, #3709/#3710) don't exist on it yet, so the intermediate `unknown` widening is required, not gratuitous — mirrors the identical cast in usage-metering.ts.
59
- const db = prisma as unknown as UsageMeteringPrismaDelegates;
60
-
61
- /** The delegate methods are typed as plain functions; the mock behind them is a jest.Mock. */
62
- const mockOf = (fn: unknown): jest.Mock => fn as jest.Mock;
63
-
64
- const ORG_ID = "org_00000000-0000-7000-8000-0000000000a1" as Id<"organization">;
65
- const WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000b1" as Id<"workspace">;
66
- const USER_ID = "user_00000000-0000-7000-8000-0000000000c1" as Id<"user">;
67
- const PROVIDER = "anthropic";
68
- const MODEL = "claude-fable";
69
-
70
- /** The resolved tenant grain — what `resolveActiveTenant` hands the service (H5). */
71
- const TENANT: TenantContext = {
72
- userId: USER_ID,
73
- organizationId: ORG_ID,
74
- workspaceId: WORKSPACE_ID,
75
- role: "owner",
76
- };
77
-
78
- function baseInput(overrides?: Partial<RecordUsageEventInput>): RecordUsageEventInput {
79
- return {
80
- tenant: TENANT,
81
- provider: PROVIDER,
82
- model: MODEL,
83
- inputTokensNoCacheWrite: 1_000_000,
84
- cacheReadTokens: 0,
85
- outputTokens: 0,
86
- ...overrides,
87
- };
88
- }
89
-
90
- /** The in-memory "committed rows" store the atomicity assertions read (H3). */
91
- let committedEvents: unknown[];
92
-
93
- /**
94
- * Forges a tenant grain that did NOT come from `resolveActiveTenant` — the H5
95
- * bypass (a caller casting around the branded `Id<>` types, or a route stuffing
96
- * request-supplied strings into the grain). The double assertion is the point of
97
- * the test, not an accident: it reproduces exactly the shape the service's
98
- * runtime id-grammar check must reject. "Fixing the type at the boundary" would
99
- * delete the test.
100
- */
101
- function forgedTenant(overrides: Record<string, string>): TenantContext {
102
- // eslint-disable-next-line no-restricted-syntax -- see the docblock above: the unbranded cast IS the attack under test (H5).
103
- return { ...TENANT, ...overrides } as unknown as TenantContext;
104
- }
105
-
106
- /**
107
- * Installs a DB double with REAL transactional semantics:
108
- * - `$transaction(fn)` stages writes and commits them only if `fn` resolves;
109
- * a throw discards the staged rows (ROLLBACK).
110
- * - the BASE client's `usageEvent.create` commits IMMEDIATELY (auto-commit) —
111
- * modelling what a non-transactional implementation would do.
112
- * Both base delegates keep recording their calls, so `toHaveBeenCalled*`
113
- * assertions work regardless of which path a write took.
114
- */
115
- function installAtomicDb(options?: { upsertError?: Error }) {
116
- mockOf(db.usageEvent.create).mockImplementation(
117
- async (args: { data: Record<string, unknown> }) => {
118
- committedEvents.push(args.data); // auto-commit — nothing can roll this back
119
- return { id: "evt_1" };
120
- },
121
- );
122
- mockOf(db.usagePeriod.upsert).mockImplementation(async () => {
123
- if (options?.upsertError) {
124
- throw options.upsertError;
125
- }
126
- return {};
127
- });
128
-
129
- mockOf(db.$transaction).mockImplementation(
130
- async (fn: (tx: UsageWriteClient) => Promise<unknown>) => {
131
- const staged: unknown[] = [];
132
- const tx: UsageWriteClient = {
133
- usageEvent: {
134
- create: async (args) => {
135
- // Record the call on the base mock (so call assertions still work),
136
- // but STAGE the row — it is only committed if the callback resolves.
137
- mockOf(db.usageEvent.create).mock.calls.push([args]);
138
- staged.push(args.data);
139
- return { id: "evt_1" };
140
- },
141
- },
142
- usagePeriod: {
143
- upsert: async (args) =>
144
- mockOf(db.usagePeriod.upsert)(args) as Promise<unknown>,
145
- },
146
- };
147
- const result = await fn(tx);
148
- committedEvents.push(...staged); // COMMIT (a throw above skips this → ROLLBACK)
149
- return result;
150
- },
151
- );
152
- }
153
-
154
- /** Seeds aiMarkupConfig.findFirst with a realistic hierarchy-precedence filter. */
155
- function seedMarkupTable(
156
- rows: Array<{
157
- provider: string | null;
158
- model: string | null;
159
- markupPercent: unknown;
160
- }>,
161
- ) {
162
- mockOf(db.aiMarkupConfig.findFirst).mockImplementation(
163
- async (args: {
164
- where: { organizationId: string; provider: string | null; model: string | null };
165
- }) => {
166
- const { organizationId, provider, model } = args.where;
167
- if (organizationId !== ORG_ID) return null;
168
- const row = rows.find((r) => r.provider === provider && r.model === model);
169
- return row ? { markupPercent: row.markupPercent } : null;
170
- },
171
- );
172
- }
173
-
174
- function seedModel(overrides?: Record<string, unknown>) {
175
- mockOf(db.llmModel.findFirst).mockResolvedValue({
176
- tier: "BASE",
177
- isAutoModel: false,
178
- chargeExtendedContextAt2x: false,
179
- consumptionMultiplier: 1,
180
- ...overrides,
181
- });
182
- }
183
-
184
- function seedPricing(overrides?: Record<string, unknown>) {
185
- mockOf(db.llmModelPricing.findFirst).mockResolvedValue({
186
- inputPricePerMillion: 1,
187
- outputPricePerMillion: 1,
188
- cachedInputPricePerMillion: null,
189
- ...overrides,
190
- });
191
- }
192
-
193
- /** The persisted `UsageEvent` data of the Nth create call. */
194
- function createdEventData(call = 0): Record<string, unknown> {
195
- return mockOf(db.usageEvent.create).mock.calls[call]?.[0]?.data as Record<
196
- string,
197
- unknown
198
- >;
199
- }
200
-
201
- /** The `usagePeriod.upsert` args of the Nth upsert call. */
202
- function upsertArgs(call = 0): {
203
- create: Record<string, unknown>;
204
- update: Record<string, unknown>;
205
- } {
206
- return mockOf(db.usagePeriod.upsert).mock.calls[call]?.[0] as {
207
- create: Record<string, unknown>;
208
- update: Record<string, unknown>;
209
- };
210
- }
211
-
212
- function expectInvalidInput(result: RecordUsageResult) {
213
- expect(result.ok).toBe(false);
214
- const err = result as Extract<RecordUsageResult, { ok: false }>;
215
- expect(err.error.kind).toBe("invalid_input");
216
- expect(err.error.persisted).toBe(false);
217
- }
218
-
219
- describe("calculateProviderCost", () => {
220
- // ── AC-1: standard path ────────────────────────────────────────────────────
221
- it("standard path — no context window / at-or-below 200K", () => {
222
- const pricing: ModelPricing = {
223
- inputPricePerMillion: 3,
224
- outputPricePerMillion: 15,
225
- cachedInputPricePerMillion: 0.3,
226
- };
227
- const cost = calculateProviderCost(1_000_000, 500_000, 200_000, pricing);
228
- // inputCost=1*3=3, cachedInputCost=0.5*0.3=0.15, outputCost=0.2*15=3
229
- expect(cost).toBeCloseTo(6.15, 6);
230
- });
231
-
232
- it("cached input defaults to half the input rate when unset", () => {
233
- const pricing: ModelPricing = { inputPricePerMillion: 4, outputPricePerMillion: 20 };
234
- const cost = calculateProviderCost(1_000_000, 1_000_000, 0, pricing);
235
- // inputCost=1*4=4, cachedInputCost=1*(4*0.5)=2
236
- expect(cost).toBeCloseTo(6, 6);
237
- });
238
-
239
- // ── AC-1: extended-context 2x split ────────────────────────────────────────
240
- it("extended-context path — only when chargeExtendedAt2x AND contextWindowSize>200_000", () => {
241
- const pricing: ModelPricing = {
242
- inputPricePerMillion: 1,
243
- outputPricePerMillion: 1,
244
- cachedInputPricePerMillion: 1,
245
- };
246
- // 800K context: standardRatio=200K/800K=0.25, extendedRatio=0.75
247
- // factor = 0.25*1 + 0.75*2 = 1.75
248
- const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 800_000, true);
249
- expect(cost).toBeCloseTo(1.75, 6);
250
- });
251
-
252
- it("chargeExtendedAt2x=false skips the surcharge regardless of contextWindowSize", () => {
253
- const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
254
- const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 800_000, false);
255
- expect(cost).toBeCloseTo(1, 6);
256
- });
257
-
258
- it("contextWindowSize <= 200_000 skips the surcharge even when chargeExtendedAt2x=true", () => {
259
- const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
260
- const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 200_000, true);
261
- expect(cost).toBeCloseTo(1, 6);
262
- });
263
-
264
- it("contextWindowSize omitted defaults to 0 — extended pricing skipped (don't guess)", () => {
265
- const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
266
- const cost = calculateProviderCost(1_000_000, 0, 0, pricing, undefined, true);
267
- expect(cost).toBeCloseTo(1, 6);
268
- });
269
- });
270
-
271
- describe("recordUsageEvent", () => {
272
- beforeEach(() => {
273
- jest.clearAllMocks();
274
- jest.spyOn(console, "warn").mockImplementation(() => undefined);
275
- jest.spyOn(console, "error").mockImplementation(() => undefined);
276
- committedEvents = [];
277
- installAtomicDb();
278
- seedModel();
279
- seedPricing();
280
- seedMarkupTable([]);
281
- });
282
-
283
- afterEach(() => {
284
- jest.restoreAllMocks();
285
- });
286
-
287
- // ── AC-3 (AC-UB-02 service-level): write UsageEvent then upsert UsagePeriod ─
288
- it("AC-3: writes the UsageEvent (grain + costs + attribution=0) then upserts the UsagePeriod", async () => {
289
- seedModel({ tier: "PREMIUM" });
290
- seedPricing({ inputPricePerMillion: 3, outputPricePerMillion: 15 });
291
-
292
- const result = await recordUsageEvent(baseInput());
293
-
294
- // providerCost = 1M/1e6 * 3 = 3; billedCost = 3 * 1.2 = 3.6
295
- expect(result.ok).toBe(true);
296
- const okResult = result as Extract<RecordUsageResult, { ok: true }>;
297
- expect(okResult.eventId).toBe("evt_1");
298
- expect(okResult.billedCostUsd).toBeCloseTo(3.6, 6);
299
-
300
- expect(createdEventData()).toEqual(
301
- expect.objectContaining({
302
- organizationId: ORG_ID,
303
- workspaceId: WORKSPACE_ID,
304
- userId: USER_ID,
305
- provider: PROVIDER,
306
- model: MODEL,
307
- providerCostUsd: 3,
308
- markupPercent: 20,
309
- billedCostUsd: expect.closeTo(3.6, 6),
310
- modelTier: "PREMIUM",
311
- wasAutoModel: false,
312
- includedPortionCents: 0,
313
- creditBalancePortionCents: 0,
314
- onDemandPortionCents: 0,
315
- completionStatus: "success",
316
- }),
317
- );
318
-
319
- // H1/H2 (per-event ceil to whole cents) are DELIBERATELY unchanged — this
320
- // 360¢ expectation encodes the current, spec-mandated rounding, pending Sam's
321
- // billing-fairness decision.
322
- expect(upsertArgs().create).toEqual(
323
- expect.objectContaining({
324
- organizationId: ORG_ID,
325
- tokensConsumed: 1_000_000,
326
- usageSpendCents: 360, // ceil(3.6 * 100)
327
- premiumTokensConsumed: 1_000_000,
328
- }),
329
- );
330
- expect(upsertArgs().update).toEqual(
331
- expect.objectContaining({
332
- tokensConsumed: { increment: 1_000_000 },
333
- usageSpendCents: { increment: 360 },
334
- premiumTokensConsumed: { increment: 1_000_000 },
335
- }),
336
- );
337
- });
338
-
339
- // ── Tier-column selection (BASE / FRONTIER) ────────────────────────────────
340
- it.each([
341
- ["BASE", "baseTokensConsumed"],
342
- ["FRONTIER", "frontierTokensConsumed"],
343
- ] as const)("upserts the %s tier column (%s)", async (tier, column) => {
344
- seedModel({ tier });
345
-
346
- await recordUsageEvent(baseInput());
347
-
348
- expect(upsertArgs().create).toEqual(
349
- expect.objectContaining({ [column]: 1_000_000 }),
350
- );
351
- expect(upsertArgs().update).toEqual(
352
- expect.objectContaining({ [column]: { increment: 1_000_000 } }),
353
- );
354
- });
355
-
356
- // ── AC-2: markup hierarchy ─────────────────────────────────────────────────
357
- it("AC-2: markup resolves the model-specific row first", async () => {
358
- seedMarkupTable([
359
- { provider: PROVIDER, model: MODEL, markupPercent: 5 },
360
- { provider: PROVIDER, model: null, markupPercent: 10 },
361
- { provider: null, model: null, markupPercent: 8 },
362
- ]);
363
-
364
- const result = (await recordUsageEvent(baseInput())) as Extract<
365
- RecordUsageResult,
366
- { ok: true }
367
- >;
368
-
369
- expect(result.billedCostUsd).toBeCloseTo(1.05, 6); // providerCost=1 * (1+5/100)
370
- });
371
-
372
- it("AC-2: markup falls back to the provider-wide row", async () => {
373
- seedMarkupTable([
374
- { provider: PROVIDER, model: null, markupPercent: 10 },
375
- { provider: null, model: null, markupPercent: 8 },
376
- ]);
377
-
378
- const result = (await recordUsageEvent(baseInput())) as Extract<
379
- RecordUsageResult,
380
- { ok: true }
381
- >;
382
-
383
- expect(result.billedCostUsd).toBeCloseTo(1.1, 6);
384
- });
385
-
386
- it("AC-2: markup falls back to the organization-default row", async () => {
387
- seedMarkupTable([{ provider: null, model: null, markupPercent: 8 }]);
388
-
389
- const result = (await recordUsageEvent(baseInput())) as Extract<
390
- RecordUsageResult,
391
- { ok: true }
392
- >;
393
-
394
- expect(result.billedCostUsd).toBeCloseTo(1.08, 6);
395
- });
396
-
397
- it("AC-2: markup falls back to the 20% system default", async () => {
398
- seedMarkupTable([]);
399
-
400
- const result = (await recordUsageEvent(baseInput())) as Extract<
401
- RecordUsageResult,
402
- { ok: true }
403
- >;
404
-
405
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
406
- });
407
-
408
- // ── Unknown model → safe defaults, no crash ────────────────────────────────
409
- it("an unresolvable model defaults to BASE / non-auto / no extended surcharge", async () => {
410
- mockOf(db.llmModel.findFirst).mockResolvedValue(null);
411
-
412
- // A large context window would trigger the surcharge IF chargeExtendedAt2x
413
- // resolved true — for an unknown model it must default false.
414
- const result = (await recordUsageEvent(
415
- baseInput({ contextWindowSize: 800_000 }),
416
- )) as Extract<RecordUsageResult, { ok: true }>;
417
-
418
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6); // no 2x surcharge
419
- expect(createdEventData()).toEqual(
420
- expect.objectContaining({ modelTier: "BASE", wasAutoModel: false }),
421
- );
422
- });
423
-
424
- // ── PII non-leak — the result never carries token content ──────────────────
425
- it("PII/content non-leak — the result carries only ids and computed costs", async () => {
426
- const result = await recordUsageEvent(baseInput());
427
-
428
- expect(Object.keys(result)).toEqual(
429
- expect.arrayContaining(["ok", "eventId", "billedCostUsd"]),
430
- );
431
- expect(JSON.stringify(result).length).toBeLessThan(200);
432
- });
433
-
434
- // ══ H3 — atomicity: the event + the aggregate are ONE transaction ══════════
435
- describe("H3 — atomic write (no double-billing on retry)", () => {
436
- it("a failed UsagePeriod upsert persists NO UsageEvent — a retry cannot double-bill", async () => {
437
- // The P2002 upsert race: two concurrent calls create the same
438
- // (organizationId, periodStart) row; the loser throws.
439
- installAtomicDb({
440
- upsertError: new Error(
441
- "Unique constraint failed on the fields: (`organizationId`,`periodStart`)",
442
- ),
443
- });
444
-
445
- const result = await recordUsageEvent(baseInput());
446
-
447
- expect(result.ok).toBe(false);
448
- // THE assertion: a non-transactional implementation commits the event
449
- // before the upsert runs, stranding an orphan row here — and the caller's
450
- // retry then writes a SECOND event, billing the org twice for one AI call.
451
- expect(committedEvents).toHaveLength(0);
452
- });
453
-
454
- it("a write failure reports persisted:false — a retry is always safe", async () => {
455
- installAtomicDb({ upsertError: new Error("connection reset") });
456
-
457
- const result = await recordUsageEvent(baseInput());
458
-
459
- const err = result as Extract<RecordUsageResult, { ok: false }>;
460
- expect(err.error.kind).toBe("write_failed");
461
- expect(err.error.persisted).toBe(false);
462
- });
463
-
464
- it("both writes go through $transaction — neither is reachable off the base client", async () => {
465
- // A transaction that cannot even be opened must produce ZERO writes:
466
- // proof that the writes live only inside the transaction callback.
467
- mockOf(db.$transaction).mockImplementation(async () => {
468
- throw new Error("could not start transaction");
469
- });
470
-
471
- const result = await recordUsageEvent(baseInput());
472
-
473
- expect(result.ok).toBe(false);
474
- expect(db.usageEvent.create).not.toHaveBeenCalled();
475
- expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
476
- expect(committedEvents).toHaveLength(0);
477
- expect(db.$transaction).toHaveBeenCalledTimes(1);
478
- });
479
-
480
- it("the happy path commits exactly one event, through the transaction", async () => {
481
- const result = await recordUsageEvent(baseInput());
482
-
483
- expect(result.ok).toBe(true);
484
- expect(db.$transaction).toHaveBeenCalledTimes(1);
485
- expect(committedEvents).toHaveLength(1);
486
- });
487
- });
488
-
489
- // ══ H4 — boundary validation: no negative/NaN/Infinity reaches cost or DB ══
490
- describe("H4 — input validation (invalid_input)", () => {
491
- it("a NEGATIVE token count is rejected — it cannot decrement the org's spend", async () => {
492
- const result = await recordUsageEvent(
493
- baseInput({ inputTokensNoCacheWrite: -1_000_000 }),
494
- );
495
-
496
- expectInvalidInput(result);
497
- // The attack: a negative cost → a negative usageSpendCents increment →
498
- // the org's metered spend is DECREMENTED (its bill zeroed out).
499
- expect(db.$transaction).not.toHaveBeenCalled();
500
- expect(db.usageEvent.create).not.toHaveBeenCalled();
501
- expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
502
- expect(committedEvents).toHaveLength(0);
503
- });
504
-
505
- it("NaN is rejected — Postgres double precision accepts it and the aggregate is poisoned forever", async () => {
506
- const result = await recordUsageEvent(baseInput({ outputTokens: Number.NaN }));
507
-
508
- expectInvalidInput(result);
509
- expect(db.usageEvent.create).not.toHaveBeenCalled();
510
- expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
511
- });
512
-
513
- it("Infinity is rejected", async () => {
514
- const result = await recordUsageEvent(
515
- baseInput({ outputTokens: Number.POSITIVE_INFINITY }),
516
- );
517
-
518
- expectInvalidInput(result);
519
- expect(db.usageEvent.create).not.toHaveBeenCalled();
520
- });
521
-
522
- it("a non-integer token count is rejected", async () => {
523
- const result = await recordUsageEvent(baseInput({ outputTokens: 1.5 }));
524
-
525
- expectInvalidInput(result);
526
- expect(db.usageEvent.create).not.toHaveBeenCalled();
527
- });
528
-
529
- it("an absurd token magnitude is rejected (overflow guard)", async () => {
530
- const result = await recordUsageEvent(
531
- baseInput({ inputTokensNoCacheWrite: 1e15 }),
532
- );
533
-
534
- expectInvalidInput(result);
535
- expect(db.usageEvent.create).not.toHaveBeenCalled();
536
- });
537
-
538
- it("a negative contextWindowSize is rejected", async () => {
539
- const result = await recordUsageEvent(baseInput({ contextWindowSize: -1 }));
540
-
541
- expectInvalidInput(result);
542
- expect(db.usageEvent.create).not.toHaveBeenCalled();
543
- });
544
-
545
- it("the error names the offending field", async () => {
546
- const result = await recordUsageEvent(baseInput({ outputTokens: -5 }));
547
-
548
- const err = result as Extract<RecordUsageResult, { ok: false }>;
549
- expect(err.error.message).toContain("outputTokens");
550
- });
551
- });
552
-
553
- // ══ H5 — tenant grain: branded ids, validated at the boundary ══════════════
554
- describe("H5 — tenant grain", () => {
555
- it("rejects a MISMATCHED grain — a workspace id smuggled into the organization slot", async () => {
556
- // organizationId keys BOTH the markup lookup and the spend increment, so a
557
- // wrong id here bills the wrong tenant (and leaks its markup back).
558
- const badTenant = forgedTenant({ organizationId: WORKSPACE_ID });
559
-
560
- const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
561
-
562
- expectInvalidInput(result);
563
- expect(db.$transaction).not.toHaveBeenCalled();
564
- expect(db.aiMarkupConfig.findFirst).not.toHaveBeenCalled();
565
- });
566
-
567
- it("rejects an arbitrary caller-supplied string as an organization id", async () => {
568
- const badTenant = forgedTenant({ organizationId: "victim-org" });
569
-
570
- const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
571
-
572
- expectInvalidInput(result);
573
- expect(db.usageEvent.create).not.toHaveBeenCalled();
574
- });
575
-
576
- it("rejects a malformed workspace id", async () => {
577
- const badTenant = forgedTenant({ workspaceId: "wksp_not-a-uuid" });
578
-
579
- const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
580
-
581
- expectInvalidInput(result);
582
- expect(db.usageEvent.create).not.toHaveBeenCalled();
583
- });
584
-
585
- it("persists the tenant grain exactly as resolved", async () => {
586
- await recordUsageEvent(baseInput());
587
-
588
- expect(createdEventData()).toEqual(
589
- expect.objectContaining({
590
- organizationId: ORG_ID,
591
- workspaceId: WORKSPACE_ID,
592
- userId: USER_ID,
593
- }),
594
- );
595
- });
596
- });
597
-
598
- // ══ M1 — totalTokens is DERIVED, never caller-supplied ═════════════════════
599
- describe("M1 — totalTokens derived server-side", () => {
600
- it("derives totalTokens from the buckets — a caller cannot bill without burning quota", async () => {
601
- await recordUsageEvent(
602
- baseInput({
603
- inputTokensCacheWrite: 1_000,
604
- inputTokensNoCacheWrite: 10_000,
605
- cacheReadTokens: 2_000,
606
- outputTokens: 500,
607
- thinkingTokens: 100,
608
- }),
609
- );
610
-
611
- const total = 1_000 + 10_000 + 2_000 + 500 + 100; // 13_600
612
- expect(createdEventData()).toEqual(
613
- expect.objectContaining({ totalTokens: total }),
614
- );
615
- // The quota surface: tokensConsumed + the tier column track the derived sum.
616
- expect(upsertArgs().create).toEqual(
617
- expect.objectContaining({
618
- tokensConsumed: total,
619
- baseTokensConsumed: total,
620
- }),
621
- );
622
- expect(upsertArgs().update).toEqual(
623
- expect.objectContaining({
624
- tokensConsumed: { increment: total },
625
- baseTokensConsumed: { increment: total },
626
- }),
627
- );
628
- });
629
- });
630
-
631
- // ══ M2 — markup + pricing range validation ═════════════════════════════════
632
- describe("M2 — markup/pricing range validation", () => {
633
- it("a NULL markup column does not become a silent 0% (lost revenue) — falls through to 20%", async () => {
634
- seedMarkupTable([{ provider: PROVIDER, model: MODEL, markupPercent: null }]);
635
-
636
- const result = (await recordUsageEvent(baseInput())) as Extract<
637
- RecordUsageResult,
638
- { ok: true }
639
- >;
640
-
641
- // Number(null) === 0 would bill at cost. The system default must win.
642
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
643
- expect(createdEventData()).toEqual(
644
- expect.objectContaining({ markupPercent: 20 }),
645
- );
646
- });
647
-
648
- it("a NEGATIVE markup row never bills below provider cost", async () => {
649
- seedMarkupTable([{ provider: PROVIDER, model: MODEL, markupPercent: -50 }]);
650
-
651
- const result = (await recordUsageEvent(baseInput())) as Extract<
652
- RecordUsageResult,
653
- { ok: true }
654
- >;
655
-
656
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
657
- });
658
-
659
- it("a non-numeric markup row falls through to the default", async () => {
660
- seedMarkupTable([
661
- { provider: PROVIDER, model: MODEL, markupPercent: "not-a-number" },
662
- ]);
663
-
664
- const result = (await recordUsageEvent(baseInput())) as Extract<
665
- RecordUsageResult,
666
- { ok: true }
667
- >;
668
-
669
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
670
- expect(Number.isFinite(createdEventData().markupPercent as number)).toBe(true);
671
- });
672
-
673
- it("an unusable model-specific row falls through the chain to the provider-wide row", async () => {
674
- seedMarkupTable([
675
- { provider: PROVIDER, model: MODEL, markupPercent: null },
676
- { provider: PROVIDER, model: null, markupPercent: 10 },
677
- ]);
678
-
679
- const result = (await recordUsageEvent(baseInput())) as Extract<
680
- RecordUsageResult,
681
- { ok: true }
682
- >;
683
-
684
- expect(result.billedCostUsd).toBeCloseTo(1.1, 6);
685
- });
686
-
687
- it("a corrupt pricing rate never reaches the cost math (no NaN in the DB)", async () => {
688
- seedPricing({ inputPricePerMillion: "corrupt" });
689
-
690
- const result = (await recordUsageEvent(baseInput())) as Extract<
691
- RecordUsageResult,
692
- { ok: true }
693
- >;
694
-
695
- expect(result.billedCostUsd).toBe(0);
696
- expect(Number.isFinite(createdEventData().providerCostUsd as number)).toBe(true);
697
- expect(Number.isFinite(upsertArgs().create.usageSpendCents as number)).toBe(true);
698
- });
699
- });
700
-
701
- // ══ M3 — unvalidated tier → an "undefined" column key ══════════════════════
702
- describe("M3 — tier validation", () => {
703
- it("an out-of-union tier defaults to BASE — never an 'undefined' upsert key", async () => {
704
- seedModel({ tier: "LEGENDARY" }); // a tier the catalog should never hold
705
-
706
- const result = await recordUsageEvent(baseInput());
707
-
708
- expect(result.ok).toBe(true);
709
- expect(upsertArgs().create).toEqual(
710
- expect.objectContaining({ baseTokensConsumed: 1_000_000 }),
711
- );
712
- expect(upsertArgs().create).not.toHaveProperty("undefined");
713
- expect(upsertArgs().update).not.toHaveProperty("undefined");
714
- expect(createdEventData()).toEqual(
715
- expect.objectContaining({ modelTier: "BASE" }),
716
- );
717
- });
718
- });
719
-
720
- // ══ M4 — missing pricing is MARKED, not silent ═════════════════════════════
721
- describe("M4 — missing pricing", () => {
722
- it("meters at $0 without blocking, and MARKS the event so it can be re-priced", async () => {
723
- mockOf(db.llmModelPricing.findFirst).mockResolvedValue(null);
724
-
725
- const result = await recordUsageEvent(baseInput());
726
-
727
- expect(result).toEqual<RecordUsageResult>({
728
- ok: true,
729
- eventId: "evt_1",
730
- billedCostUsd: 0,
731
- });
732
- const data = createdEventData();
733
- expect(data).toEqual(
734
- expect.objectContaining({ providerCostUsd: 0, billedCostUsd: 0 }),
735
- );
736
- // Distinguishable from a genuine $0 call — and the caller's status survives.
737
- expect(data.completionStatus).toBe("pricing_missing:success");
738
- });
739
-
740
- it("a priced call carries the caller's status unmarked", async () => {
741
- await recordUsageEvent(baseInput({ completionStatus: "error" }));
742
-
743
- expect(createdEventData().completionStatus).toBe("error");
744
- });
745
- });
746
-
747
- // ══ M5 — raw DB error text is never returned ═══════════════════════════════
748
- describe("M5 — error propagation", () => {
749
- it("does not leak the raw Prisma message (which embeds ids + token counts)", async () => {
750
- // A Prisma validation error embeds the whole `data` argument.
751
- const leaky = new Error(
752
- `Invalid \`prisma.usageEvent.create()\` invocation: { organizationId: "${ORG_ID}", userId: "${USER_ID}", inputTokensNoCacheWrite: 1000000 }`,
753
- );
754
- installAtomicDb({ upsertError: leaky });
755
-
756
- const result = await recordUsageEvent(baseInput());
757
-
758
- const err = result as Extract<RecordUsageResult, { ok: false }>;
759
- expect(err.error.kind).toBe("write_failed");
760
- expect(err.error.message).toBe("usage event write failed");
761
- expect(err.error.message).not.toContain(ORG_ID);
762
- expect(err.error.message).not.toContain(USER_ID);
763
- expect(err.error.message).not.toContain("prisma");
764
- // The detail is still available to operators — server-side only.
765
- expect(console.error).toHaveBeenCalled();
766
- });
767
- });
768
- });