create-ailk 0.2.0 → 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 (273) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/lib/apply-module-patches.d.ts +47 -0
  3. package/dist/lib/apply-module-patches.js +370 -0
  4. package/dist/lib/extract-module-bundle.d.ts +12 -3
  5. package/dist/lib/extract-module-bundle.js +236 -24
  6. package/dist/lib/fetch-module.d.ts +3 -38
  7. package/dist/lib/fetch-module.js +26 -8
  8. package/dist/lib/module-license-gate.d.ts +2 -0
  9. package/dist/lib/module-license-gate.js +1 -0
  10. package/dist/lib/paid-paths.d.ts +16 -0
  11. package/dist/lib/paid-paths.js +90 -0
  12. package/dist/module-architecture.d.ts +139 -23
  13. package/dist/module-architecture.js +668 -25
  14. package/dist/parse-args.d.ts +2 -2
  15. package/dist/parse-args.js +3 -1
  16. package/dist/programmatic.d.ts +2 -1
  17. package/dist/programmatic.js +15 -4
  18. package/dist/surfaces.d.ts +12 -36
  19. package/dist/surfaces.js +35 -5
  20. package/dist/sync-routes.js +31 -1
  21. package/package.json +2 -2
  22. package/templates/.claude/agents/web.md +2 -3
  23. package/templates/.claude/rules/architecture.md +5 -5
  24. package/templates/.claude/skills/README.md +2 -1
  25. package/templates/.env.example +21 -9
  26. package/templates/CONVENTIONS.md +6 -7
  27. package/templates/apps/api/.env.example +9 -0
  28. package/templates/apps/api/CLAUDE.md +33 -5
  29. package/templates/apps/api/package.json +0 -1
  30. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  31. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  32. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  33. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  34. package/templates/apps/api/src/config/index.ts +19 -0
  35. package/templates/apps/api/src/config/modules.ts +12 -14
  36. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  37. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  38. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  39. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  40. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  41. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  42. package/templates/apps/api/src/openapi/index.ts +9 -3
  43. package/templates/apps/api/src/openapi/spec.ts +505 -65
  44. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  45. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  46. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/index.ts +38 -11
  49. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  50. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  58. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  59. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  60. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  61. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  63. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  64. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  65. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  66. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  67. package/templates/apps/api/src/server.ts +459 -135
  68. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  69. package/templates/apps/api/src/services/listing-config.ts +58 -0
  70. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  71. package/templates/apps/api/src/services/project-listings.ts +229 -4
  72. package/templates/apps/api/src/vercel-handler.ts +60 -26
  73. package/templates/apps/mcp/.env.example +8 -0
  74. package/templates/apps/mcp/CLAUDE.md +2 -2
  75. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  76. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  77. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  78. package/templates/apps/mcp/src/config/modules.ts +14 -13
  79. package/templates/apps/mcp/src/server.ts +8 -4
  80. package/templates/apps/mcp/src/tools/index.ts +7 -22
  81. package/templates/apps/web/.env.example +15 -0
  82. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  83. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  84. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  91. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  92. package/templates/apps/web/jest.config.cjs +14 -13
  93. package/templates/apps/web/next.config.mjs +1 -3
  94. package/templates/apps/web/package.json +0 -6
  95. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  96. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  97. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  98. package/templates/apps/web/public/favicon-16x16.png +0 -0
  99. package/templates/apps/web/public/favicon-32x32.png +0 -0
  100. package/templates/apps/web/public/favicon.ico +0 -0
  101. package/templates/apps/web/public/favicon.svg +6 -3
  102. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  103. package/templates/apps/web/public/logomark.svg +4 -0
  104. package/templates/apps/web/public/site.webmanifest +2 -2
  105. package/templates/apps/web/public/wordmark.svg +4 -0
  106. package/templates/database/CHANGELOG.md +33 -0
  107. package/templates/database/inbox/schema.prisma +67 -0
  108. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  109. package/templates/database/package.json +1 -1
  110. package/templates/package.json +1 -1
  111. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  112. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  113. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  114. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  115. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  116. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  117. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  118. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  119. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  120. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  121. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  122. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  123. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  124. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  125. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  126. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  127. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  128. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  129. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  130. package/templates/apps/api/src/lib/stripe.ts +0 -52
  131. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  132. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  133. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  134. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  135. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  136. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  137. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  138. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  139. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  140. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  141. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  142. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  143. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  144. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  145. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  146. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  147. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  148. package/templates/apps/api/src/routes/flows/README.md +0 -147
  149. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  150. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  151. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  152. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  153. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  154. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  155. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  156. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  157. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  158. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  159. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  160. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  161. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  162. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  163. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  164. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  165. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  166. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  167. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  168. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  169. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  170. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  171. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  172. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  173. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  174. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  175. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  176. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  177. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  178. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  179. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  180. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  181. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  182. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  183. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  184. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  185. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  186. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  187. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  188. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  189. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  190. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  191. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  192. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  193. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  194. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  195. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  196. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  197. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  198. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  199. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  200. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  201. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  202. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  203. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  204. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  205. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  206. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  207. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  208. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  209. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  210. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  211. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  212. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  213. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  214. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  215. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  216. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  217. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  218. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  219. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  220. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  221. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  222. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  223. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  224. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  225. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  226. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  227. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  228. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  229. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  230. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  231. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  232. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  233. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  234. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  235. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  236. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  237. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  238. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  239. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  240. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  241. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  242. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  243. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  246. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  247. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  248. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  249. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  250. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  251. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  252. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  253. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  254. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  255. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  256. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  257. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  258. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  259. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  260. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  261. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  262. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  263. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  264. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  265. package/templates/apps/web/content/en/blog/README.txt +0 -25
  266. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  267. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  268. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  269. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  270. package/templates/apps/web/lib/source.ts +0 -34
  271. package/templates/apps/web/public/logomark-dark.svg +0 -4
  272. package/templates/apps/web/public/logomark-light.svg +0 -4
  273. 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
- });