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