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,699 +0,0 @@
1
- /**
2
- * @file usage-metering.ts
3
- * @description Usage-metering write path — cost → markup → write (ADR 0009
4
- * D3 service-layer assembler; spec docs/specs/3705-s1-usage-metering-service-spec.md).
5
- *
6
- * The AILK analog of working-theory's `UsageTracker.logEvent`: given a
7
- * completed AI call's token breakdown + model, compute the provider cost
8
- * (token counts × pricing, with an optional 2x extended-context surcharge),
9
- * apply a hierarchical markup (model → provider → org → 20% system default),
10
- * write the `UsageEvent` (the three attribution portions initialized to 0 —
11
- * populated later by the credit waterfall, P3/P4), then upsert the
12
- * `UsagePeriod` tier aggregate. Mirrors `lead-routing.ts`'s shape:
13
- * discriminated `{ ok } | { ok: false, error }` result, no throw on the
14
- * expected paths.
15
- *
16
- * This is a MONEY-WRITING path. Four invariants hold it (security review of
17
- * PR #3727 — findings H3/H4/H5, M1–M5):
18
- *
19
- * 1. ATOMIC (H3). The `UsageEvent` insert and the `UsagePeriod` upsert run
20
- * in ONE `$transaction`. Either both land or neither does — so a failed
21
- * upsert (incl. the Prisma `upsert` P2002 race on a new period) can never
22
- * strand a persisted event that the caller's retry would then double-bill.
23
- * Every `ok: false` therefore carries `persisted: false`: nothing was
24
- * written, a retry is always safe.
25
- * 2. VALIDATED AT THE BOUNDARY (H4/M1). Every caller-supplied number is
26
- * parsed by `recordUsageEventSchema` before it reaches the cost math or
27
- * the DB — a negative count (which would DECREMENT the org's metered
28
- * spend), a `NaN` (which Postgres `double precision` accepts, permanently
29
- * poisoning the aggregate), a non-integer, or an absurd magnitude is
30
- * rejected as `invalid_input`. `totalTokens` is DERIVED server-side from
31
- * the buckets, never trusted from the caller (M1 — it alone drives the
32
- * quota columns).
33
- * 3. TYPED TENANT GRAIN (H5). The input carries a `TenantContext` whose ids
34
- * are the branded `Id<>` types, so the type system forces callers through
35
- * `resolveActiveTenant` (which proves session ↔ workspace ↔ org ↔
36
- * Membership). `organizationId` keys BOTH the markup lookup AND the spend
37
- * increment, so a caller-supplied string here bills the wrong tenant. The
38
- * zod schema re-checks the id grammar at runtime, so a cast that smuggles
39
- * a workspace id into the organization slot is rejected too.
40
- * 4. DB-SOURCED NUMBERS ARE RANGE-CHECKED (M2/M3). Pricing rates, markup
41
- * percents, and the model tier come from operator-editable rows; each is
42
- * validated (finite, non-negative, in range / in the tier union) before it
43
- * can reach the money math. A null markup column (`Number(null) === 0`)
44
- * no longer silently zeroes revenue.
45
- *
46
- * PII boundary: only ids (organizationId/workspaceId/userId), provider/model
47
- * names, and computed costs ever appear in logs or in a returned error. Never
48
- * token content. Raw DB error text is logged server-side and NEVER returned —
49
- * Prisma validation errors embed the full `data` argument (M5).
50
- *
51
- * Schema note: `UsageEvent`/`UsagePeriod` (database/content, #3709) and
52
- * `LlmModel`/`LlmModelPricing`/`AiMarkupConfig` (database/config, #3710) are
53
- * the FEAT-047 P1 models this P2.S1 service is written against — they are not
54
- * yet in the generated Prisma client this package compiles against (P1 has
55
- * not merged). `db` below is `prisma` structurally cast to the delegate shape
56
- * this file needs; once P1 merges and the client regenerates, the real
57
- * client's delegates satisfy this shape for free — no code change required.
58
- * Field/constraint names (esp. the `UsagePeriod` compound-unique key) are the
59
- * spec's best-effort assumption and should be reconciled against P1 as merged
60
- * (spec §5.3).
61
- */
62
-
63
- import { zOrganizationId, zUserId, zWorkspaceId } from "@working-theory/ids";
64
- import { z } from "zod";
65
-
66
- import { prisma } from "../lib/prisma.js";
67
-
68
- // ─── Types ────────────────────────────────────────────────────────────────────
69
-
70
- export type ModelTier = "BASE" | "PREMIUM" | "FRONTIER";
71
-
72
- /**
73
- * The tenant grain this service bills against (#5182).
74
- *
75
- * Previously `import type { TenantContext } from "./tenant-context.js"` — a
76
- * multi-tenant SLICE file the OSS strip removes, which makes a type-only import
77
- * of it a TS2307 on the public tree exactly as a value import would be (spec
78
- * D1). There is no non-slice home for that type: `packages/observability`'s
79
- * same-named interface is a telemetry attribute bag, not the branded auth
80
- * grain, and reusing it would be a false friend.
81
- *
82
- * So the type is derived from this file's OWN runtime contract instead, which
83
- * is strictly more accurate than the import was. `zTenantContext` below already
84
- * re-validates exactly these three branded ids; the slice type's fourth member
85
- * (`role`) was never read here and was never re-checked at runtime, so the
86
- * declared input type and the validator now agree rather than diverge.
87
- */
88
- type BillableTenant = z.infer<typeof zTenantContext>;
89
-
90
- export interface RecordUsageEventInput {
91
- /**
92
- * The resolved tenant grain (H5) — produced by `resolveActiveTenant`, never
93
- * assembled from request-supplied strings. `organizationId` keys the markup
94
- * lookup AND the spend increment, so this is the tenant that gets billed.
95
- */
96
- tenant: BillableTenant;
97
- provider: string;
98
- model: string;
99
- inputTokensCacheWrite?: number;
100
- inputTokensNoCacheWrite: number;
101
- cacheReadTokens?: number;
102
- outputTokens: number;
103
- thinkingTokens?: number;
104
- /** Actual conversation context size; omitted → 0 → extended pricing skipped ("don't guess"). */
105
- contextWindowSize?: number;
106
- /** Drives downstream aggregation filtering (P2.S2); the write itself never branches on it. */
107
- completionStatus?: string;
108
- }
109
-
110
- export type RecordUsageErrorKind = "invalid_input" | "write_failed";
111
-
112
- export type RecordUsageResult =
113
- | { ok: true; eventId: string; billedCostUsd: number }
114
- | {
115
- ok: false;
116
- error: {
117
- kind: RecordUsageErrorKind;
118
- message: string;
119
- /**
120
- * ALWAYS `false` (H3). The event + aggregate writes are one
121
- * transaction, so a failure leaves NOTHING persisted — a caller may
122
- * always retry without risking a double-bill. If this path ever gains
123
- * a non-atomic branch, this field becomes a union and callers must
124
- * branch on it.
125
- */
126
- persisted: false;
127
- };
128
- };
129
-
130
- /** The pure cost-math input — provider pricing for one (provider, model) pair. */
131
- export interface ModelPricing {
132
- inputPricePerMillion: number;
133
- outputPricePerMillion: number;
134
- cachedInputPricePerMillion?: number | null;
135
- }
136
-
137
- // ─── Boundary validation (H4/M1) ──────────────────────────────────────────────
138
-
139
- /**
140
- * Sane magnitude ceilings. These are ABSURDITY guards, not business limits —
141
- * they exist so a corrupt/hostile caller cannot drive the cost math to
142
- * `Infinity` (which Postgres `double precision` accepts, poisoning the
143
- * aggregate). The largest real context window today is ~10M tokens.
144
- */
145
- const MAX_TOKENS_PER_EVENT = 100_000_000;
146
- const MAX_CONTEXT_WINDOW = 100_000_000;
147
-
148
- /**
149
- * A token bucket: finite, integral, non-negative, bounded. `z.number().int()`
150
- * rejects `NaN` and `±Infinity` (neither is an integer) — the H4 poison path.
151
- */
152
- const zTokenCount = z
153
- .number()
154
- .int("token counts must be integers")
155
- .min(0, "token counts cannot be negative")
156
- .max(MAX_TOKENS_PER_EVENT, "token count exceeds the per-event maximum");
157
-
158
- /** The branded tenant grain (H5) — re-checked at runtime, not merely typed. */
159
- const zTenantContext = z.object({
160
- userId: zUserId,
161
- organizationId: zOrganizationId,
162
- workspaceId: zWorkspaceId,
163
- });
164
-
165
- const recordUsageEventSchema = z.object({
166
- tenant: zTenantContext,
167
- provider: z.string().min(1).max(128),
168
- model: z.string().min(1).max(128),
169
- inputTokensCacheWrite: zTokenCount.optional(),
170
- inputTokensNoCacheWrite: zTokenCount,
171
- cacheReadTokens: zTokenCount.optional(),
172
- outputTokens: zTokenCount,
173
- thinkingTokens: zTokenCount.optional(),
174
- contextWindowSize: z
175
- .number()
176
- .int("contextWindowSize must be an integer")
177
- .min(0, "contextWindowSize cannot be negative")
178
- .max(MAX_CONTEXT_WINDOW)
179
- .optional(),
180
- completionStatus: z.string().min(1).max(64).optional(),
181
- });
182
-
183
- // ─── Structural delegate shape for the not-yet-generated FEAT-047 models ───────
184
- // (See the schema note above — reconcile against P1/P1.S2 as merged.)
185
-
186
- interface UsageEventCreateData {
187
- organizationId: string;
188
- workspaceId: string;
189
- userId: string;
190
- provider: string;
191
- model: string;
192
- inputTokensCacheWrite: number;
193
- inputTokensNoCacheWrite: number;
194
- cacheReadTokens: number;
195
- outputTokens: number;
196
- thinkingTokens: number;
197
- totalTokens: number;
198
- providerCostUsd: number;
199
- markupPercent: number;
200
- billedCostUsd: number;
201
- modelTier: ModelTier;
202
- wasAutoModel: boolean;
203
- includedPortionCents: number;
204
- creditBalancePortionCents: number;
205
- onDemandPortionCents: number;
206
- completionStatus: string;
207
- }
208
-
209
- interface UsagePeriodUpsertData {
210
- organizationId: string;
211
- periodStart: Date;
212
- periodEnd: Date;
213
- tokensConsumed: number;
214
- usageSpendCents: number;
215
- baseTokensConsumed?: number;
216
- premiumTokensConsumed?: number;
217
- frontierTokensConsumed?: number;
218
- }
219
-
220
- interface LlmModelRow {
221
- tier: ModelTier;
222
- isAutoModel: boolean;
223
- chargeExtendedContextAt2x: boolean;
224
- consumptionMultiplier: number | string;
225
- }
226
-
227
- interface LlmModelPricingRow {
228
- inputPricePerMillion: number | string;
229
- outputPricePerMillion: number | string;
230
- cachedInputPricePerMillion: number | string | null;
231
- }
232
-
233
- interface AiMarkupConfigRow {
234
- markupPercent: number | string;
235
- }
236
-
237
- /**
238
- * The two write delegates, as seen INSIDE the transaction (H3). `recordUsageEvent`
239
- * reaches the `UsageEvent` / `UsagePeriod` writers ONLY through the `$transaction`
240
- * callback, never off the base client — which is what makes the pair atomic.
241
- */
242
- export interface UsageWriteClient {
243
- usageEvent: {
244
- create(args: {
245
- data: UsageEventCreateData;
246
- select: { id: true };
247
- }): Promise<{ id: string }>;
248
- };
249
- usagePeriod: {
250
- upsert(args: {
251
- where: {
252
- organizationId_periodStart: { organizationId: string; periodStart: Date };
253
- };
254
- create: UsagePeriodUpsertData;
255
- update: Record<string, unknown>;
256
- }): Promise<unknown>;
257
- };
258
- }
259
-
260
- export interface UsageMeteringPrismaDelegates extends UsageWriteClient {
261
- llmModel: {
262
- findFirst(args: {
263
- where: { provider: string; model: string };
264
- }): Promise<LlmModelRow | null>;
265
- };
266
- llmModelPricing: {
267
- findFirst(args: unknown): Promise<LlmModelPricingRow | null>;
268
- };
269
- aiMarkupConfig: {
270
- findFirst(args: unknown): Promise<AiMarkupConfigRow | null>;
271
- };
272
- $transaction<T>(fn: (tx: UsageWriteClient) => Promise<T>): Promise<T>;
273
- }
274
-
275
- const db = prisma as unknown as UsageMeteringPrismaDelegates;
276
-
277
- // ─── Constants ──────────────────────────────────────────────────────────────
278
-
279
- const STANDARD_CONTEXT_WINDOW = 200_000;
280
- const EXTENDED_CONTEXT_MULTIPLIER = 2;
281
- const SYSTEM_DEFAULT_MARKUP_PERCENT = 20;
282
-
283
- /**
284
- * Absurdity ceilings for the two operator-editable money inputs (M2). A rate or
285
- * markup beyond these is a mis-seeded row, not a pricing decision — treated as
286
- * absent rather than allowed to reach the cost math (where a `1e308` markup
287
- * would produce an `Infinity` bill).
288
- */
289
- const MAX_PRICE_PER_MILLION = 1_000_000;
290
- const MAX_MARKUP_PERCENT = 100_000;
291
-
292
- /**
293
- * Marks an event metered with NO pricing row (M4). Prefix-encoded onto the
294
- * existing `completionStatus` column (no schema change — #3709 owns the
295
- * schema), so the event is (a) distinguishable from a genuine $0 call and
296
- * therefore re-priceable, and (b) still carries the caller's original status.
297
- * A dedicated column is the right long-term home — see the PR follow-up.
298
- */
299
- const PRICING_MISSING_PREFIX = "pricing_missing:";
300
-
301
- const TIER_COLUMN: Record<
302
- ModelTier,
303
- "baseTokensConsumed" | "premiumTokensConsumed" | "frontierTokensConsumed"
304
- > = {
305
- BASE: "baseTokensConsumed",
306
- PREMIUM: "premiumTokensConsumed",
307
- FRONTIER: "frontierTokensConsumed",
308
- };
309
-
310
- /**
311
- * Guard for every DB-sourced money number (M2) — finite, non-negative, sane.
312
- *
313
- * The nullish/empty check is LOAD-BEARING and must come first: `Number(null)`
314
- * and `Number("")` are both `0`, so a coercion-only guard would wave a NULL
315
- * markup column through as a legitimate 0% — the silent revenue loss M2
316
- * reports. A genuine numeric `0` still passes (a real 0% markup is valid).
317
- */
318
- function money(value: unknown, max: number): number | null {
319
- if (value === null || value === undefined || value === "") {
320
- return null;
321
- }
322
- const n = Number(value);
323
- return Number.isFinite(n) && n >= 0 && n <= max ? n : null;
324
- }
325
-
326
- // ─── Pure cost math ───────────────────────────────────────────────────────────
327
-
328
- /**
329
- * Computes the raw provider cost for one AI call. Standard path: tokens/1e6 ×
330
- * rate per bucket (cached input defaults to half the input rate when the
331
- * model has no explicit cached rate). Extended-context path — ONLY when
332
- * `chargeExtendedAt2x && contextWindowSize > 200_000` — splits each token
333
- * bucket by the ratio of the 200K standard window to the actual context,
334
- * charging the extended portion at 2x. `contextWindowSize` omitted/0 skips
335
- * extended pricing ("don't guess").
336
- */
337
- export function calculateProviderCost(
338
- inputTokensNoCacheWrite: number,
339
- cacheReadTokens: number,
340
- outputTokens: number,
341
- pricing: ModelPricing,
342
- contextWindowSize = 0,
343
- chargeExtendedAt2x = false,
344
- ): number {
345
- const inputRate = pricing.inputPricePerMillion;
346
- const cachedInputRate =
347
- pricing.cachedInputPricePerMillion ?? pricing.inputPricePerMillion * 0.5;
348
- const outputRate = pricing.outputPricePerMillion;
349
-
350
- if (!chargeExtendedAt2x || contextWindowSize <= STANDARD_CONTEXT_WINDOW) {
351
- return (
352
- (inputTokensNoCacheWrite / 1_000_000) * inputRate +
353
- (cacheReadTokens / 1_000_000) * cachedInputRate +
354
- (outputTokens / 1_000_000) * outputRate
355
- );
356
- }
357
-
358
- const standardRatio = Math.min(1, STANDARD_CONTEXT_WINDOW / contextWindowSize);
359
- const extendedRatio = 1 - standardRatio;
360
-
361
- const splitCost = (tokens: number, rate: number): number =>
362
- ((tokens * standardRatio) / 1_000_000) * rate +
363
- ((tokens * extendedRatio) / 1_000_000) * rate * EXTENDED_CONTEXT_MULTIPLIER;
364
-
365
- return (
366
- splitCost(inputTokensNoCacheWrite, inputRate) +
367
- splitCost(cacheReadTokens, cachedInputRate) +
368
- splitCost(outputTokens, outputRate)
369
- );
370
- }
371
-
372
- // ─── Resolution helpers ───────────────────────────────────────────────────────
373
-
374
- /**
375
- * Most-recent active pricing row for (provider, model). Missing OR unusable
376
- * (a non-finite / negative / absurd rate — M2) → null, which the caller meters
377
- * at $0 and marks `pricing_missing` (M4). Never throws.
378
- */
379
- async function resolvePricing(
380
- provider: string,
381
- model: string,
382
- ): Promise<ModelPricing | null> {
383
- const now = new Date();
384
- const row = await db.llmModelPricing.findFirst({
385
- where: {
386
- provider,
387
- model,
388
- effectiveFrom: { lte: now },
389
- OR: [{ effectiveTo: null }, { effectiveTo: { gte: now } }],
390
- },
391
- orderBy: { effectiveFrom: "desc" },
392
- select: {
393
- inputPricePerMillion: true,
394
- outputPricePerMillion: true,
395
- cachedInputPricePerMillion: true,
396
- },
397
- });
398
- if (!row) {
399
- return null;
400
- }
401
-
402
- const inputPricePerMillion = money(row.inputPricePerMillion, MAX_PRICE_PER_MILLION);
403
- const outputPricePerMillion = money(row.outputPricePerMillion, MAX_PRICE_PER_MILLION);
404
- if (inputPricePerMillion === null || outputPricePerMillion === null) {
405
- // A corrupt rate must never reach the cost math (it would write NaN/Infinity
406
- // into the org's aggregate — unrecoverable). Treat as "no pricing".
407
- console.warn("usage-metering: unusable pricing row", { provider, model });
408
- return null;
409
- }
410
-
411
- return {
412
- inputPricePerMillion,
413
- outputPricePerMillion,
414
- cachedInputPricePerMillion:
415
- row.cachedInputPricePerMillion != null
416
- ? money(row.cachedInputPricePerMillion, MAX_PRICE_PER_MILLION)
417
- : null,
418
- };
419
- }
420
-
421
- interface ResolvedModelMeta {
422
- tier: ModelTier;
423
- wasAutoModel: boolean;
424
- chargeExtendedContextAt2x: boolean;
425
- }
426
-
427
- /**
428
- * Model catalog lookup. Unknown model → safe defaults (BASE, not auto, no
429
- * extended surcharge). An out-of-union `tier` on the row (the column is
430
- * operator-editable; the TS type is an assumption, not a guarantee) normalizes
431
- * to BASE rather than producing an `undefined` tier column key (M3).
432
- */
433
- async function resolveModelMetadata(
434
- provider: string,
435
- model: string,
436
- ): Promise<ResolvedModelMeta> {
437
- const row = await db.llmModel.findFirst({ where: { provider, model } });
438
- if (!row) {
439
- return { tier: "BASE", wasAutoModel: false, chargeExtendedContextAt2x: false };
440
- }
441
- const tier: ModelTier = row.tier in TIER_COLUMN ? row.tier : "BASE";
442
- if (tier !== row.tier) {
443
- console.warn("usage-metering: unknown model tier, defaulting to BASE", {
444
- provider,
445
- model,
446
- });
447
- }
448
- return {
449
- tier,
450
- wasAutoModel: row.isAutoModel,
451
- chargeExtendedContextAt2x: row.chargeExtendedContextAt2x,
452
- };
453
- }
454
-
455
- /**
456
- * Hierarchical markup resolution: model-specific → provider-wide →
457
- * organization default → 20% system default (first VALID match wins).
458
- *
459
- * A row whose `markupPercent` is unusable — null (`Number(null) === 0`, a
460
- * silent 0% = lost revenue), negative (billing BELOW provider cost), NaN, or
461
- * absurd — is skipped, not obeyed (M2). The chain continues, terminating at
462
- * the 20% system default.
463
- */
464
- async function resolveMarkup(
465
- organizationId: string,
466
- provider: string,
467
- model: string,
468
- ): Promise<number> {
469
- const candidates = [
470
- { organizationId, provider, model },
471
- { organizationId, provider, model: null },
472
- { organizationId, provider: null, model: null },
473
- ];
474
-
475
- for (const where of candidates) {
476
- const row = await db.aiMarkupConfig.findFirst({ where });
477
- if (!row) {
478
- continue;
479
- }
480
- const markupPercent = money(row.markupPercent, MAX_MARKUP_PERCENT);
481
- if (markupPercent !== null) {
482
- return markupPercent;
483
- }
484
- console.warn("usage-metering: unusable markup row, falling through", {
485
- organizationId,
486
- provider: where.provider,
487
- model: where.model,
488
- });
489
- }
490
-
491
- return SYSTEM_DEFAULT_MARKUP_PERCENT;
492
- }
493
-
494
- function startOfMonthUtc(date: Date): Date {
495
- return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
496
- }
497
-
498
- function startOfNextMonthUtc(periodStart: Date): Date {
499
- return new Date(
500
- Date.UTC(periodStart.getUTCFullYear(), periodStart.getUTCMonth() + 1, 1),
501
- );
502
- }
503
-
504
- /**
505
- * Upserts the month's tier aggregate — INSIDE the caller's transaction (H3), so
506
- * a failure here rolls the `UsageEvent` back with it.
507
- *
508
- * NOTE (H1/H2, deliberately unchanged): `Math.ceil(billedCostUsd * 100)` both
509
- * inherits IEEE-754 error (0.07 * 100 → 7.000000000000001 → 8¢) and rounds every
510
- * sub-cent call UP to a whole cent, which compounds into systematic overbilling.
511
- * The fix is a spec-level billing-fairness decision (accumulate sub-cent
512
- * precision, round once at invoice time) that would subsume the float fix —
513
- * escalated to Sam, deliberately NOT patched here (PR #3727).
514
- */
515
- async function upsertUsagePeriod(
516
- tx: UsageWriteClient,
517
- params: {
518
- organizationId: string;
519
- tier: ModelTier;
520
- totalTokens: number;
521
- billedCostUsd: number;
522
- },
523
- ): Promise<void> {
524
- const { organizationId, tier, totalTokens, billedCostUsd } = params;
525
- const periodStart = startOfMonthUtc(new Date());
526
- const periodEnd = startOfNextMonthUtc(periodStart);
527
- const usageSpendCents = Math.ceil(billedCostUsd * 100);
528
- const tierColumn = TIER_COLUMN[tier] ?? TIER_COLUMN.BASE;
529
-
530
- await tx.usagePeriod.upsert({
531
- where: { organizationId_periodStart: { organizationId, periodStart } },
532
- create: {
533
- organizationId,
534
- periodStart,
535
- periodEnd,
536
- tokensConsumed: totalTokens,
537
- usageSpendCents,
538
- [tierColumn]: totalTokens,
539
- },
540
- update: {
541
- tokensConsumed: { increment: totalTokens },
542
- usageSpendCents: { increment: usageSpendCents },
543
- [tierColumn]: { increment: totalTokens },
544
- },
545
- });
546
- }
547
-
548
- // ─── Main export ──────────────────────────────────────────────────────────────
549
-
550
- /**
551
- * Records one billable AI call: validates the input, resolves model metadata +
552
- * pricing, computes the provider cost (incl. the extended-context surcharge),
553
- * resolves the hierarchical markup, then writes the `UsageEvent` (attribution
554
- * portions = 0 — populated later by the credit waterfall) and upserts the
555
- * `UsagePeriod` tier aggregate ATOMICALLY.
556
- *
557
- * Never throws. Invalid caller input → `invalid_input`; a DB failure →
558
- * `write_failed`. Both carry `persisted: false` — the transaction guarantees
559
- * nothing landed, so the caller may always retry (H3). Missing pricing does not
560
- * fail the call: metering never blocks on a missing rate; the event is metered
561
- * at $0 and marked `pricing_missing` so it can be re-priced (M4).
562
- */
563
- export async function recordUsageEvent(
564
- input: RecordUsageEventInput,
565
- ): Promise<RecordUsageResult> {
566
- const parsed = recordUsageEventSchema.safeParse(input);
567
- if (!parsed.success) {
568
- const issue = parsed.error.issues[0];
569
- const field = issue?.path.join(".") ?? "input";
570
- return {
571
- ok: false,
572
- error: {
573
- kind: "invalid_input",
574
- message: `${field}: ${issue?.message ?? "invalid input"}`,
575
- persisted: false,
576
- },
577
- };
578
- }
579
-
580
- const {
581
- tenant,
582
- provider,
583
- model,
584
- inputTokensCacheWrite = 0,
585
- inputTokensNoCacheWrite,
586
- cacheReadTokens = 0,
587
- outputTokens,
588
- thinkingTokens = 0,
589
- contextWindowSize = 0,
590
- completionStatus = "success",
591
- } = parsed.data;
592
-
593
- // Branded by `zTenantContext` (H5) — an `Id<"organization">`, not a bare string.
594
- const organizationId = tenant.organizationId;
595
-
596
- // Derived server-side, never caller-supplied (M1): totalTokens alone drives
597
- // `tokensConsumed` + the tier column — i.e. the QUOTA surface. A caller
598
- // passing `totalTokens: 0` alongside real buckets would otherwise be billed
599
- // for the call while burning no quota.
600
- const totalTokens =
601
- inputTokensCacheWrite +
602
- inputTokensNoCacheWrite +
603
- cacheReadTokens +
604
- outputTokens +
605
- thinkingTokens;
606
-
607
- const modelMeta = await resolveModelMetadata(provider, model);
608
-
609
- const pricing = await resolvePricing(provider, model);
610
- let providerCostUsd = 0;
611
- if (pricing) {
612
- providerCostUsd = calculateProviderCost(
613
- inputTokensNoCacheWrite,
614
- cacheReadTokens,
615
- outputTokens,
616
- pricing,
617
- contextWindowSize,
618
- modelMeta.chargeExtendedContextAt2x,
619
- );
620
- } else {
621
- // Missing pricing never blocks metering — but it is NOT silent (M4): the
622
- // event is marked so it is distinguishable from a real $0 call and can be
623
- // re-priced once the pricing row is seeded. (ids/model only — no token content.)
624
- console.warn("usage-metering: no active pricing found", {
625
- organizationId,
626
- provider,
627
- model,
628
- });
629
- }
630
-
631
- const markupPercent = await resolveMarkup(organizationId, provider, model);
632
- const billedCostUsd = providerCostUsd * (1 + markupPercent / 100);
633
-
634
- try {
635
- // ATOMIC (H3): the event insert and the aggregate upsert are ONE unit. A
636
- // failed upsert (incl. the P2002 upsert race on a fresh period) rolls the
637
- // event back, so `ok: false` always means "nothing persisted" and a retry
638
- // can never double-bill.
639
- const eventId = await db.$transaction(async (tx) => {
640
- const event = await tx.usageEvent.create({
641
- data: {
642
- organizationId,
643
- workspaceId: tenant.workspaceId,
644
- userId: tenant.userId,
645
- provider,
646
- model,
647
- inputTokensCacheWrite,
648
- inputTokensNoCacheWrite,
649
- cacheReadTokens,
650
- outputTokens,
651
- thinkingTokens,
652
- totalTokens,
653
- providerCostUsd,
654
- markupPercent,
655
- billedCostUsd,
656
- modelTier: modelMeta.tier,
657
- wasAutoModel: modelMeta.wasAutoModel,
658
- includedPortionCents: 0,
659
- creditBalancePortionCents: 0,
660
- onDemandPortionCents: 0,
661
- completionStatus: pricing
662
- ? completionStatus
663
- : `${PRICING_MISSING_PREFIX}${completionStatus}`,
664
- },
665
- select: { id: true },
666
- });
667
-
668
- await upsertUsagePeriod(tx, {
669
- organizationId,
670
- tier: modelMeta.tier,
671
- totalTokens,
672
- billedCostUsd,
673
- });
674
-
675
- return event.id;
676
- });
677
-
678
- return { ok: true, eventId, billedCostUsd };
679
- } catch (error) {
680
- // Log the detail server-side; NEVER return it (M5). Prisma validation
681
- // errors embed the full `data` argument — org/workspace/user ids and token
682
- // counts — which a route could forward to an untrusted caller.
683
- const detail = error instanceof Error ? error.message : String(error);
684
- console.error("usage-metering: write failed", {
685
- organizationId,
686
- provider,
687
- model,
688
- detail,
689
- });
690
- return {
691
- ok: false,
692
- error: {
693
- kind: "write_failed",
694
- message: "usage event write failed",
695
- persisted: false,
696
- },
697
- };
698
- }
699
- }