tapimo 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (313) hide show
  1. package/README.md +31 -3
  2. package/dist/ApiKey.d.ts +1 -0
  3. package/dist/ApiKey.d.ts.map +1 -1
  4. package/dist/ApiKey.js +5 -2
  5. package/dist/ApiKey.js.map +1 -1
  6. package/dist/ApiKeys.d.ts +28 -1
  7. package/dist/ApiKeys.d.ts.map +1 -1
  8. package/dist/ApiKeys.js +34 -1
  9. package/dist/ApiKeys.js.map +1 -1
  10. package/dist/App.d.ts +7167 -4507
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +8 -0
  13. package/dist/App.js.map +1 -1
  14. package/dist/Scope.d.ts +11 -2
  15. package/dist/Scope.d.ts.map +1 -1
  16. package/dist/Scope.js +14 -3
  17. package/dist/Scope.js.map +1 -1
  18. package/dist/admin/App.d.ts +128 -118
  19. package/dist/admin/App.d.ts.map +1 -1
  20. package/dist/admin/apps/api-keys.d.ts +7 -0
  21. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  22. package/dist/admin/apps/scopes.d.ts +8 -0
  23. package/dist/admin/apps/scopes.d.ts.map +1 -1
  24. package/dist/admin/apps/scopes.js +3 -0
  25. package/dist/admin/apps/scopes.js.map +1 -1
  26. package/dist/admin/apps/verified-tokens.d.ts +57 -57
  27. package/dist/admin/ui.generated.d.ts +1 -1
  28. package/dist/admin/ui.generated.js +13 -13
  29. package/dist/admin/ui.generated.js.map +1 -1
  30. package/dist/analytics/Analytics.d.ts +151 -0
  31. package/dist/analytics/Analytics.d.ts.map +1 -0
  32. package/dist/analytics/Analytics.js +212 -0
  33. package/dist/analytics/Analytics.js.map +1 -0
  34. package/dist/analytics/tables/requestEvents.d.ts +215 -0
  35. package/dist/analytics/tables/requestEvents.d.ts.map +1 -0
  36. package/dist/analytics/tables/requestEvents.js +264 -0
  37. package/dist/analytics/tables/requestEvents.js.map +1 -0
  38. package/dist/apps/data/App.d.ts +1024 -624
  39. package/dist/apps/data/App.d.ts.map +1 -1
  40. package/dist/apps/data/App.js +36 -0
  41. package/dist/apps/data/App.js.map +1 -1
  42. package/dist/apps/data/routes/balances.d.ts +45 -45
  43. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  44. package/dist/apps/data/routes/exchanges.d.ts +455 -455
  45. package/dist/apps/data/routes/fee-amm.d.ts +101 -101
  46. package/dist/apps/data/routes/funding.d.ts +765 -0
  47. package/dist/apps/data/routes/funding.d.ts.map +1 -0
  48. package/dist/apps/data/routes/funding.js +383 -0
  49. package/dist/apps/data/routes/funding.js.map +1 -0
  50. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  51. package/dist/apps/data/routes/receipts.js +2 -8
  52. package/dist/apps/data/routes/receipts.js.map +1 -1
  53. package/dist/apps/data/routes/rpc.d.ts +29 -36
  54. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  55. package/dist/apps/data/routes/rpc.js +29 -1
  56. package/dist/apps/data/routes/rpc.js.map +1 -1
  57. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  58. package/dist/apps/data/routes/transfers.d.ts +56 -56
  59. package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
  60. package/dist/apps/data/routes/webhooks.d.ts +45 -45
  61. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  62. package/dist/apps/data/routes/webhooks.js +8 -8
  63. package/dist/apps/data/routes/webhooks.js.map +1 -1
  64. package/dist/apps/docs/App.d.ts +4 -2
  65. package/dist/apps/docs/App.d.ts.map +1 -1
  66. package/dist/apps/docs/App.js +27 -95
  67. package/dist/apps/docs/App.js.map +1 -1
  68. package/dist/apps/docs/reference.d.ts +21 -0
  69. package/dist/apps/docs/reference.d.ts.map +1 -0
  70. package/dist/apps/docs/reference.js +112 -0
  71. package/dist/apps/docs/reference.js.map +1 -0
  72. package/dist/apps/index.d.ts +1 -0
  73. package/dist/apps/index.d.ts.map +1 -1
  74. package/dist/apps/index.js +1 -0
  75. package/dist/apps/index.js.map +1 -1
  76. package/dist/apps/mcp.d.ts +76 -0
  77. package/dist/apps/mcp.d.ts.map +1 -0
  78. package/dist/apps/mcp.js +188 -0
  79. package/dist/apps/mcp.js.map +1 -0
  80. package/dist/apps/platform/App.d.ts +3005 -728
  81. package/dist/apps/platform/App.d.ts.map +1 -1
  82. package/dist/apps/platform/App.js +13 -3
  83. package/dist/apps/platform/App.js.map +1 -1
  84. package/dist/apps/platform/Billing.d.ts +279 -0
  85. package/dist/apps/platform/Billing.d.ts.map +1 -0
  86. package/dist/apps/platform/Billing.js +477 -0
  87. package/dist/apps/platform/Billing.js.map +1 -0
  88. package/dist/apps/platform/index.d.ts +1 -0
  89. package/dist/apps/platform/index.d.ts.map +1 -1
  90. package/dist/apps/platform/index.js +1 -0
  91. package/dist/apps/platform/index.js.map +1 -1
  92. package/dist/apps/platform/routes/api-keys.d.ts +341 -49
  93. package/dist/apps/platform/routes/api-keys.d.ts.map +1 -1
  94. package/dist/apps/platform/routes/api-keys.js +115 -13
  95. package/dist/apps/platform/routes/api-keys.js.map +1 -1
  96. package/dist/apps/platform/routes/billing.d.ts +1520 -0
  97. package/dist/apps/platform/routes/billing.d.ts.map +1 -0
  98. package/dist/apps/platform/routes/billing.js +795 -0
  99. package/dist/apps/platform/routes/billing.js.map +1 -0
  100. package/dist/apps/platform/routes/invitations.d.ts +201 -83
  101. package/dist/apps/platform/routes/invitations.d.ts.map +1 -1
  102. package/dist/apps/platform/routes/invitations.js +58 -0
  103. package/dist/apps/platform/routes/invitations.js.map +1 -1
  104. package/dist/apps/platform/routes/me.d.ts +17 -17
  105. package/dist/apps/platform/routes/orgs.d.ts +50 -36
  106. package/dist/apps/platform/routes/orgs.d.ts.map +1 -1
  107. package/dist/apps/platform/routes/orgs.js +14 -3
  108. package/dist/apps/platform/routes/orgs.js.map +1 -1
  109. package/dist/apps/platform/routes/projects.d.ts +97 -97
  110. package/dist/apps/platform/routes/scopes.d.ts +107 -0
  111. package/dist/apps/platform/routes/scopes.d.ts.map +1 -0
  112. package/dist/apps/platform/routes/scopes.js +66 -0
  113. package/dist/apps/platform/routes/scopes.js.map +1 -0
  114. package/dist/apps/platform/routes/usage.d.ts +588 -0
  115. package/dist/apps/platform/routes/usage.d.ts.map +1 -0
  116. package/dist/apps/platform/routes/usage.js +391 -0
  117. package/dist/apps/platform/routes/usage.js.map +1 -0
  118. package/dist/apps/relay/App.d.ts +4 -1
  119. package/dist/apps/relay/App.d.ts.map +1 -1
  120. package/dist/apps/relay/App.js +118 -10
  121. package/dist/apps/relay/App.js.map +1 -1
  122. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  123. package/dist/apps/relay/Sponsorships.js +2 -1
  124. package/dist/apps/relay/Sponsorships.js.map +1 -1
  125. package/dist/cli/Cli.d.ts +8 -1
  126. package/dist/cli/Cli.d.ts.map +1 -1
  127. package/dist/cli/Cli.js +11 -3
  128. package/dist/cli/Cli.js.map +1 -1
  129. package/dist/cli/commands/admin.d.ts +4 -0
  130. package/dist/cli/commands/admin.d.ts.map +1 -1
  131. package/dist/cli/commands/admin.js +80 -1
  132. package/dist/cli/commands/admin.js.map +1 -1
  133. package/dist/db/Db.d.ts +7 -0
  134. package/dist/db/Db.d.ts.map +1 -1
  135. package/dist/db/Db.js +126 -0
  136. package/dist/db/Db.js.map +1 -1
  137. package/dist/db/Schema.d.ts +79 -0
  138. package/dist/db/Schema.d.ts.map +1 -1
  139. package/dist/db/Schema.js +88 -2
  140. package/dist/db/Schema.js.map +1 -1
  141. package/dist/db/tables/billingSettings.d.ts +55 -0
  142. package/dist/db/tables/billingSettings.d.ts.map +1 -0
  143. package/dist/db/tables/billingSettings.js +70 -0
  144. package/dist/db/tables/billingSettings.js.map +1 -0
  145. package/dist/db/tables/invitations.js +2 -2
  146. package/dist/db/tables/invitations.js.map +1 -1
  147. package/dist/db/tables/organizations.d.ts +6 -2
  148. package/dist/db/tables/organizations.d.ts.map +1 -1
  149. package/dist/db/tables/organizations.js +10 -4
  150. package/dist/db/tables/organizations.js.map +1 -1
  151. package/dist/db/tables/projects.js +2 -2
  152. package/dist/db/tables/projects.js.map +1 -1
  153. package/dist/db/tables/requestUsage.d.ts +121 -0
  154. package/dist/db/tables/requestUsage.d.ts.map +1 -0
  155. package/dist/db/tables/requestUsage.js +150 -0
  156. package/dist/db/tables/requestUsage.js.map +1 -0
  157. package/dist/db/tables/sponsoredTransactions.d.ts +119 -0
  158. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  159. package/dist/db/tables/sponsoredTransactions.js +155 -2
  160. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  161. package/dist/db/tables/stripeCustomers.d.ts +81 -0
  162. package/dist/db/tables/stripeCustomers.d.ts.map +1 -0
  163. package/dist/db/tables/stripeCustomers.js +120 -0
  164. package/dist/db/tables/stripeCustomers.js.map +1 -0
  165. package/dist/db/tables/users.js +2 -2
  166. package/dist/db/tables/users.js.map +1 -1
  167. package/dist/handlers/internal/sponsorship.d.ts +5 -2
  168. package/dist/handlers/internal/sponsorship.d.ts.map +1 -1
  169. package/dist/handlers/internal/sponsorship.js +19 -5
  170. package/dist/handlers/internal/sponsorship.js.map +1 -1
  171. package/dist/handlers/internal/utils.d.ts +10 -3
  172. package/dist/handlers/internal/utils.d.ts.map +1 -1
  173. package/dist/handlers/internal/utils.js +14 -6
  174. package/dist/handlers/internal/utils.js.map +1 -1
  175. package/dist/index.d.ts +1 -0
  176. package/dist/index.d.ts.map +1 -1
  177. package/dist/index.js +1 -0
  178. package/dist/index.js.map +1 -1
  179. package/dist/internal/Auth.d.ts +18 -4
  180. package/dist/internal/Auth.d.ts.map +1 -1
  181. package/dist/internal/Auth.js +148 -76
  182. package/dist/internal/Auth.js.map +1 -1
  183. package/dist/internal/Cache.d.ts.map +1 -1
  184. package/dist/internal/Cache.js +4 -1
  185. package/dist/internal/Cache.js.map +1 -1
  186. package/dist/internal/EdgeCache.d.ts +10 -0
  187. package/dist/internal/EdgeCache.d.ts.map +1 -1
  188. package/dist/internal/EdgeCache.js +39 -8
  189. package/dist/internal/EdgeCache.js.map +1 -1
  190. package/dist/internal/Fees.d.ts +27 -0
  191. package/dist/internal/Fees.d.ts.map +1 -0
  192. package/dist/internal/Fees.js +48 -0
  193. package/dist/internal/Fees.js.map +1 -0
  194. package/dist/internal/FundingRoutes.d.ts +183 -0
  195. package/dist/internal/FundingRoutes.d.ts.map +1 -0
  196. package/dist/internal/FundingRoutes.js +490 -0
  197. package/dist/internal/FundingRoutes.js.map +1 -0
  198. package/dist/internal/Id.d.ts +15 -0
  199. package/dist/internal/Id.d.ts.map +1 -0
  200. package/dist/internal/Id.js +22 -0
  201. package/dist/internal/Id.js.map +1 -0
  202. package/dist/internal/Log.d.ts +2 -0
  203. package/dist/internal/Log.d.ts.map +1 -1
  204. package/dist/internal/Log.js +3 -0
  205. package/dist/internal/Log.js.map +1 -1
  206. package/dist/internal/OpenApi.d.ts +10 -1
  207. package/dist/internal/OpenApi.d.ts.map +1 -1
  208. package/dist/internal/OpenApi.js +8 -1
  209. package/dist/internal/OpenApi.js.map +1 -1
  210. package/dist/internal/RateLimit.d.ts +25 -0
  211. package/dist/internal/RateLimit.d.ts.map +1 -1
  212. package/dist/internal/RateLimit.js +51 -0
  213. package/dist/internal/RateLimit.js.map +1 -1
  214. package/dist/internal/Response.d.ts +1 -1
  215. package/dist/internal/Response.d.ts.map +1 -1
  216. package/dist/internal/Schema.d.ts +8 -0
  217. package/dist/internal/Schema.d.ts.map +1 -1
  218. package/dist/internal/Schema.js +7 -0
  219. package/dist/internal/Schema.js.map +1 -1
  220. package/dist/internal/Webhooks.d.ts.map +1 -1
  221. package/dist/internal/Webhooks.js +3 -3
  222. package/dist/internal/Webhooks.js.map +1 -1
  223. package/package.json +4 -3
  224. package/src/ApiKey.ts +9 -2
  225. package/src/ApiKeys.test.ts +51 -0
  226. package/src/ApiKeys.ts +50 -1
  227. package/src/App.test.ts +33 -86718
  228. package/src/App.ts +18 -0
  229. package/src/Client.test-d.ts +351 -0
  230. package/src/Scope.ts +16 -3
  231. package/src/admin/apps/scopes.test.ts +6 -0
  232. package/src/admin/apps/scopes.ts +3 -0
  233. package/src/admin/ui/src/components/Page.tsx +2 -3
  234. package/src/admin/ui/src/routes/__root.tsx +2 -2
  235. package/src/admin/ui/src/routes/api-keys.tsx +2 -2
  236. package/src/admin/ui.generated.ts +13 -13
  237. package/src/analytics/Analytics.test.ts +527 -0
  238. package/src/analytics/Analytics.ts +318 -0
  239. package/src/analytics/tables/requestEvents.ts +523 -0
  240. package/src/apps/data/App.test.ts +72 -0
  241. package/src/apps/data/App.ts +35 -0
  242. package/src/apps/data/routes/funding.test.ts +214 -0
  243. package/src/apps/data/routes/funding.ts +584 -0
  244. package/src/apps/data/routes/receipts.ts +2 -9
  245. package/src/apps/data/routes/rpc.test.ts +78 -2
  246. package/src/apps/data/routes/rpc.ts +31 -1
  247. package/src/apps/data/routes/webhooks.test.ts +1 -1
  248. package/src/apps/data/routes/webhooks.ts +11 -8
  249. package/src/apps/docs/App.ts +29 -100
  250. package/src/apps/docs/reference.ts +132 -0
  251. package/src/apps/index.ts +1 -0
  252. package/src/apps/mcp.test.ts +206 -0
  253. package/src/apps/mcp.ts +250 -0
  254. package/src/apps/platform/App.ts +43 -4
  255. package/src/apps/platform/Billing.test.ts +530 -0
  256. package/src/apps/platform/Billing.ts +682 -0
  257. package/src/apps/platform/index.ts +1 -0
  258. package/src/apps/platform/routes/api-keys.test.ts +172 -9
  259. package/src/apps/platform/routes/api-keys.ts +136 -20
  260. package/src/apps/platform/routes/billing.test.ts +1100 -0
  261. package/src/apps/platform/routes/billing.ts +966 -0
  262. package/src/apps/platform/routes/invitations.test.ts +55 -7
  263. package/src/apps/platform/routes/invitations.ts +68 -0
  264. package/src/apps/platform/routes/me.test.ts +230 -0
  265. package/src/apps/platform/routes/orgs.ts +26 -4
  266. package/src/apps/platform/routes/scopes.test.ts +29 -0
  267. package/src/apps/platform/routes/scopes.ts +85 -0
  268. package/src/apps/platform/routes/usage.test.ts +422 -0
  269. package/src/apps/platform/routes/usage.ts +527 -0
  270. package/src/apps/relay/App.test.ts +457 -8
  271. package/src/apps/relay/App.ts +120 -12
  272. package/src/apps/relay/Sponsorships.test.ts +1 -1
  273. package/src/apps/relay/Sponsorships.ts +2 -1
  274. package/src/cli/Cli.test.ts +25 -2
  275. package/src/cli/Cli.ts +17 -2
  276. package/src/cli/commands/admin.test.ts +83 -0
  277. package/src/cli/commands/admin.ts +89 -1
  278. package/src/db/Db.ts +137 -0
  279. package/src/db/Schema.ts +104 -2
  280. package/src/db/tables/billingSettings.test.ts +87 -0
  281. package/src/db/tables/billingSettings.ts +108 -0
  282. package/src/db/tables/invitations.ts +2 -2
  283. package/src/db/tables/organizations.test.ts +1 -1
  284. package/src/db/tables/organizations.ts +15 -5
  285. package/src/db/tables/projects.ts +2 -2
  286. package/src/db/tables/requestUsage.ts +231 -0
  287. package/src/db/tables/sponsoredTransactions.test.ts +108 -1
  288. package/src/db/tables/sponsoredTransactions.ts +230 -2
  289. package/src/db/tables/stripeCustomers.test.ts +109 -0
  290. package/src/db/tables/stripeCustomers.ts +162 -0
  291. package/src/db/tables/users.ts +2 -2
  292. package/src/handlers/internal/sponsorship.ts +34 -7
  293. package/src/handlers/internal/utils.ts +19 -7
  294. package/src/index.ts +1 -0
  295. package/src/internal/Auth.test.ts +175 -25
  296. package/src/internal/Auth.ts +175 -86
  297. package/src/internal/Cache.ts +5 -1
  298. package/src/internal/EdgeCache.test.ts +27 -4
  299. package/src/internal/EdgeCache.ts +47 -9
  300. package/src/internal/Fees.test.ts +32 -0
  301. package/src/internal/Fees.ts +51 -0
  302. package/src/internal/FundingRoutes.ts +583 -0
  303. package/src/internal/Id.test.ts +17 -0
  304. package/src/internal/Id.ts +24 -0
  305. package/src/internal/Log.test.ts +1 -0
  306. package/src/internal/Log.ts +5 -0
  307. package/src/internal/OpenApi.ts +12 -1
  308. package/src/internal/RateLimit.test.ts +84 -0
  309. package/src/internal/RateLimit.ts +70 -0
  310. package/src/internal/Response.ts +1 -1
  311. package/src/internal/Schema.ts +8 -0
  312. package/src/internal/Webhooks.test.ts +3 -3
  313. package/src/internal/Webhooks.ts +3 -3
@@ -0,0 +1,530 @@
1
+ import { nanoid } from 'nanoid'
2
+ import type Stripe from 'stripe'
3
+
4
+ import { postgresUrl } from '../../../test/containers.js'
5
+ import * as ApiKeys from '../../ApiKeys.js'
6
+ import * as Db from '../../db/Db.js'
7
+ import * as RequestUsage from '../../db/tables/requestUsage.js'
8
+ import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
9
+ import * as StripeCustomers from '../../db/tables/stripeCustomers.js'
10
+ import * as Store from '../../internal/Store.js'
11
+ import * as Viem from '../../internal/Viem.js'
12
+ import * as Billing from './Billing.js'
13
+
14
+ const create = () => Db.postgres({ connectionString: postgresUrl, schema: `t_${nanoid()}` })
15
+
16
+ /** Baseline billable mainnet sponsorship input; tests override per case. */
17
+ const input = {
18
+ apiKeyId: 'key_1',
19
+ billable: true,
20
+ chainId: Viem.chainId.mainnet,
21
+ environment: 'production',
22
+ orgId: 'org_1',
23
+ projectId: 'prj_1',
24
+ transaction: `0x76${'cc'.repeat(16)}`,
25
+ } satisfies Omit<SponsoredTransactions.upsert.Input, 'signPayload'>
26
+
27
+ /** Inserts and finalizes one billable row; `seed` derives a unique sign payload. */
28
+ async function finalized(
29
+ db: Db.Db,
30
+ options: { feeAmount: string; orgId?: string; seed: string },
31
+ ): Promise<SponsoredTransactions.Record> {
32
+ const row = await SponsoredTransactions.upsert(db, {
33
+ ...input,
34
+ ...(options.orgId ? { orgId: options.orgId } : {}),
35
+ signPayload: `0x${options.seed.repeat(32)}`,
36
+ })
37
+ await SponsoredTransactions.finalize(db, row.id, {
38
+ feeAmount: options.feeAmount,
39
+ finalizedAt: new Date().toISOString(),
40
+ })
41
+ return row
42
+ }
43
+
44
+ /** Managed meter/price ids per kind, keyed by lookup key. */
45
+ const priceByLookup: Record<string, string> = {
46
+ [Billing.meters.apiRequests.lookupKey]: 'price_req',
47
+ [Billing.meters.feePayerSpend.lookupKey]: 'price_fee',
48
+ }
49
+ const meterByEvent: Record<string, string> = {
50
+ [Billing.meters.apiRequests.eventName]: 'mtr_req',
51
+ [Billing.meters.feePayerSpend.eventName]: 'mtr_fee',
52
+ }
53
+
54
+ /** A stubbed subscription: live status plus which managed kinds it carries as items. */
55
+ type StubSubscription = { items?: readonly Billing.MeterKind[]; status: string }
56
+
57
+ /**
58
+ * A Stripe stub covering the reporter/subscription surface: fixtures resolve
59
+ * per kind, subscriptions carry per-kind items, and meter-event and
60
+ * item/subscription creation are captured for assertions.
61
+ */
62
+ function stubStripe(options: {
63
+ meterEvent?: ((params: { identifier: string }) => void) | undefined
64
+ subscriptions?: readonly StubSubscription[] | undefined
65
+ }) {
66
+ const meterEvents: {
67
+ event_name: string
68
+ identifier: string
69
+ payload?: { stripe_customer_id: string; value: string }
70
+ timestamp?: number
71
+ }[] = []
72
+ const subscriptionsCreated: { items: { price: string }[] }[] = []
73
+ const itemsCreated: { price: string; subscription: string }[] = []
74
+ const expand = (subscription: StubSubscription) => ({
75
+ id: `sub_${subscriptionsCreated.length}_${Math.random().toString(36).slice(2, 8)}`,
76
+ items: {
77
+ data: (subscription.items ?? ['feePayerSpend']).map((kind) => ({
78
+ price: { id: priceByLookup[Billing.meters[kind].lookupKey] },
79
+ })),
80
+ },
81
+ status: subscription.status,
82
+ })
83
+ const subscriptions = (options.subscriptions ?? []).map(expand)
84
+ const stripe = {
85
+ billing: {
86
+ meterEvents: {
87
+ create: async (params: {
88
+ event_name: string
89
+ identifier: string
90
+ payload?: { stripe_customer_id: string; value: string }
91
+ timestamp?: number
92
+ }) => {
93
+ options.meterEvent?.(params)
94
+ meterEvents.push(params)
95
+ return {}
96
+ },
97
+ },
98
+ meters: {
99
+ create: async (params: { event_name: string }) => ({
100
+ event_name: params.event_name,
101
+ id: meterByEvent[params.event_name] ?? 'mtr_new',
102
+ }),
103
+ list: async () => ({
104
+ data: Object.entries(meterByEvent).map(([event_name, id]) => ({ event_name, id })),
105
+ }),
106
+ },
107
+ },
108
+ prices: {
109
+ create: async (params: { lookup_key: string }) => ({
110
+ id: priceByLookup[params.lookup_key] ?? 'price_new',
111
+ }),
112
+ list: async (params: { lookup_keys: string[] }) => ({
113
+ data: params.lookup_keys
114
+ .map((key) => priceByLookup[key])
115
+ .filter(Boolean)
116
+ .map((id) => ({ id })),
117
+ }),
118
+ },
119
+ subscriptionItems: {
120
+ create: async (params: { price: string; subscription: string }) => {
121
+ itemsCreated.push(params)
122
+ return {}
123
+ },
124
+ },
125
+ subscriptions: {
126
+ create: async (params: { items: { price: string }[] }) => {
127
+ subscriptionsCreated.push(params)
128
+ return {}
129
+ },
130
+ list: async () => ({ data: subscriptions }),
131
+ },
132
+ }
133
+ return { itemsCreated, meterEvents, stripe: stripe as never as Stripe, subscriptionsCreated }
134
+ }
135
+
136
+ describe('periodStart', () => {
137
+ test('behavior: anchors to the UTC calendar month start', () => {
138
+ expect(Billing.periodStart('month', new Date('2026-03-15T12:34:56.789Z'))).toBe('2026-03-01T00:00:00.000Z') // prettier-ignore
139
+ })
140
+
141
+ test('behavior: month boundaries, year rollovers, and leap years resolve in UTC', () => {
142
+ expect(Billing.periodStart('month', new Date('2025-12-31T23:59:59.999Z'))).toBe('2025-12-01T00:00:00.000Z') // prettier-ignore
143
+ expect(Billing.periodStart('month', new Date('2026-01-01T00:00:00.000Z'))).toBe('2026-01-01T00:00:00.000Z') // prettier-ignore
144
+ expect(Billing.periodStart('month', new Date('2028-02-29T10:00:00.000Z'))).toBe('2028-02-01T00:00:00.000Z') // prettier-ignore
145
+ // A UTC month starts while western timezones still sit in the prior one.
146
+ expect(Billing.periodStart('month', new Date('2026-07-01T03:00:00.000Z'))).toBe('2026-07-01T00:00:00.000Z') // prettier-ignore
147
+ })
148
+ })
149
+
150
+ describe('toBaseUnits', () => {
151
+ test('behavior: round-trips decimal strings through fee-token base units', () => {
152
+ expect(Billing.toBaseUnits('0.35')).toBe(350_000n)
153
+ expect(Billing.toBaseUnits('250')).toBe(250_000_000n)
154
+ expect(Billing.fromBaseUnits(350_000n)).toBe('0.35')
155
+ expect(Billing.fromBaseUnits(250_000_000n)).toBe('250')
156
+ })
157
+ })
158
+
159
+ describe('status', () => {
160
+ test('behavior: reflects the stored billing source status', async () => {
161
+ const db = create()
162
+ await db.migrate()
163
+ expect(await Billing.status(db, 'org_1')).toBeUndefined()
164
+ expect(await Billing.active(db, 'org_1')).toBe(false)
165
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
166
+ await StripeCustomers.setStatus(db, 'org_1', 'past_due')
167
+ expect(await Billing.status(db, 'org_1')).toBe('past_due')
168
+ expect(await Billing.active(db, 'org_1')).toBe(false)
169
+ await StripeCustomers.setStatus(db, 'org_1', 'active')
170
+ expect(await Billing.active(db, 'org_1')).toBe(true)
171
+ await db.close()
172
+ })
173
+ })
174
+
175
+ describe('createReporter', () => {
176
+ test('behavior: a poisoned row never stalls the queue', async () => {
177
+ const db = create()
178
+ await db.migrate()
179
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
180
+ const poison = await finalized(db, { feeAmount: '100000', seed: '01' })
181
+ const good = await finalized(db, { feeAmount: '250000', seed: '02' })
182
+
183
+ const { stripe } = stubStripe({
184
+ meterEvent: (params) => {
185
+ if (params.identifier === poison.id) throw new Error('boom')
186
+ },
187
+ subscriptions: [{ items: ['feePayerSpend', 'apiRequests'], status: 'active' }],
188
+ })
189
+ const reporter = Billing.createReporter({ db, stripe })
190
+ expect(await reporter.tick()).toStrictEqual({ failed: 1, reported: 1, skipped: 0 })
191
+ // The good row marked; the poisoned row stays unmarked for retry.
192
+ expect((await SponsoredTransactions.get(db, good.id))?.meterReportedAt).not.toBeNull()
193
+ expect((await SponsoredTransactions.get(db, poison.id))?.meterReportedAt).toBeNull()
194
+ expect(await reporter.tick()).toStrictEqual({ failed: 1, reported: 0, skipped: 0 })
195
+ await db.close()
196
+ })
197
+
198
+ test('behavior: canceled customers skip without meter events', async () => {
199
+ const db = create()
200
+ await db.migrate()
201
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
202
+ await StripeCustomers.setStatus(db, 'org_1', 'canceled')
203
+ const row = await finalized(db, { feeAmount: '100000', seed: '03' })
204
+
205
+ const { meterEvents, stripe } = stubStripe({ subscriptions: [{ status: 'active' }] })
206
+ expect(await Billing.createReporter({ db, stripe }).tick()).toStrictEqual({
207
+ failed: 0,
208
+ reported: 0,
209
+ skipped: 1,
210
+ })
211
+ expect(meterEvents).toHaveLength(0)
212
+ // Skipped rows still mark: nobody remains to invoice, ever.
213
+ expect((await SponsoredTransactions.get(db, row.id))?.meterReportedAt).not.toBeNull()
214
+ await db.close()
215
+ })
216
+
217
+ test('behavior: ensures the metered subscription once and stamps its backlog at now', async () => {
218
+ const db = create()
219
+ await db.migrate()
220
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
221
+ await finalized(db, { feeAmount: '100000', seed: '04' })
222
+ await finalized(db, { feeAmount: '250000', seed: '05' })
223
+
224
+ const { meterEvents, stripe, subscriptionsCreated } = stubStripe({ subscriptions: [] })
225
+ expect(await Billing.createReporter({ db, stripe }).tick()).toStrictEqual({
226
+ failed: 0,
227
+ reported: 2,
228
+ skipped: 0,
229
+ })
230
+ // One ensure per org per tick, not per row.
231
+ expect(subscriptionsCreated).toHaveLength(1)
232
+ // The just-created subscription bills nothing before its start, so the
233
+ // backlog carries no timestamp and lands on the first invoice.
234
+ expect(meterEvents).toHaveLength(2)
235
+ for (const event of meterEvents) expect(event.timestamp).toBeUndefined()
236
+ await db.close()
237
+ })
238
+
239
+ test('behavior: an existing subscription keeps finalization timestamps', async () => {
240
+ const db = create()
241
+ await db.migrate()
242
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
243
+ const row = await finalized(db, { feeAmount: '100000', seed: '06' })
244
+
245
+ const { meterEvents, stripe, subscriptionsCreated } = stubStripe({
246
+ subscriptions: [{ items: ['feePayerSpend', 'apiRequests'], status: 'active' }],
247
+ })
248
+ expect(await Billing.createReporter({ db, stripe }).tick()).toStrictEqual({
249
+ failed: 0,
250
+ reported: 1,
251
+ skipped: 0,
252
+ })
253
+ expect(subscriptionsCreated).toHaveLength(0)
254
+ const finalizedAt = new Date((await SponsoredTransactions.get(db, row.id))!.finalizedAt!)
255
+ expect(meterEvents[0]?.timestamp).toBe(Math.floor(finalizedAt.getTime() / 1000))
256
+ await db.close()
257
+ })
258
+ })
259
+
260
+ describe('createKeyBillingSyncer', () => {
261
+ test('behavior: converges drifted sandbox key snapshots to the billing table', async () => {
262
+ const db = create()
263
+ await db.migrate()
264
+ const kv = Store.memory()
265
+
266
+ // A sandbox key minted while billing was inactive; billing later activates
267
+ // but the webhook re-sync never lands, so the record snapshot is stale.
268
+ const { token } = await ApiKeys.mint(kv, {
269
+ billingActive: false,
270
+ environment: 'sandbox',
271
+ orgId: 'org_1',
272
+ scopes: ['data:read'],
273
+ })
274
+ await StripeCustomers.create(db, {
275
+ environment: 'sandbox',
276
+ orgId: 'org_1',
277
+ stripeCustomerId: 'cus_1',
278
+ })
279
+ await StripeCustomers.setStatus(db, 'org_1', 'active', 'sandbox')
280
+
281
+ const syncer = Billing.createKeyBillingSyncer({ db, kv })
282
+ expect(await syncer.tick()).toStrictEqual({ checked: 1, updated: 1 })
283
+ expect((await ApiKeys.resolve(kv, token))?.billingActive).toBe(true)
284
+
285
+ // A converged fleet re-reads but writes nothing.
286
+ expect(await syncer.tick()).toStrictEqual({ checked: 1, updated: 0 })
287
+ await db.close()
288
+ })
289
+
290
+ test('behavior: reconciles a lapse back to the public quota', async () => {
291
+ const db = create()
292
+ await db.migrate()
293
+ const kv = Store.memory()
294
+
295
+ const { token } = await ApiKeys.mint(kv, {
296
+ billingActive: true,
297
+ environment: 'sandbox',
298
+ orgId: 'org_1',
299
+ scopes: ['data:read'],
300
+ })
301
+ await StripeCustomers.create(db, {
302
+ environment: 'sandbox',
303
+ orgId: 'org_1',
304
+ stripeCustomerId: 'cus_1',
305
+ })
306
+ await StripeCustomers.setStatus(db, 'org_1', 'past_due', 'sandbox')
307
+
308
+ expect(await Billing.createKeyBillingSyncer({ db, kv }).tick()).toStrictEqual({
309
+ checked: 1,
310
+ updated: 1,
311
+ })
312
+ // Non-`active` billing throttles: the snapshot flips back to false.
313
+ expect((await ApiKeys.resolve(kv, token))?.billingActive).toBe(false)
314
+ await db.close()
315
+ })
316
+
317
+ test('behavior: leaves production billing sources untouched', async () => {
318
+ const db = create()
319
+ await db.migrate()
320
+ const kv = Store.memory()
321
+
322
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
323
+ await StripeCustomers.setStatus(db, 'org_1', 'active')
324
+
325
+ // The default syncer reconciles sandbox only; a production-only org is
326
+ // outside its driver set.
327
+ expect(await Billing.createKeyBillingSyncer({ db, kv }).tick()).toStrictEqual({
328
+ checked: 0,
329
+ updated: 0,
330
+ })
331
+ await db.close()
332
+ })
333
+ })
334
+
335
+ describe('ensureSubscription', () => {
336
+ test('behavior: adds the missing item to a live subscription', async () => {
337
+ const { itemsCreated, stripe, subscriptionsCreated } = stubStripe({
338
+ subscriptions: [{ items: ['feePayerSpend'], status: 'active' }],
339
+ })
340
+ const result = await Billing.ensureSubscription(stripe, 'cus_1', [
341
+ 'feePayerSpend',
342
+ 'apiRequests',
343
+ ])
344
+ expect(result.created).toBe(false)
345
+ expect(subscriptionsCreated).toHaveLength(0)
346
+ expect(itemsCreated).toEqual([{ price: 'price_req', subscription: expect.any(String) }])
347
+ expect(result.items.apiRequests?.created).toBe(true)
348
+ expect(result.items.feePayerSpend?.created).toBe(false)
349
+ })
350
+
351
+ test('behavior: creates a subscription carrying every requested item', async () => {
352
+ const { stripe, subscriptionsCreated } = stubStripe({ subscriptions: [] })
353
+ const result = await Billing.ensureSubscription(stripe, 'cus_1', [
354
+ 'feePayerSpend',
355
+ 'apiRequests',
356
+ ])
357
+ expect(result.created).toBe(true)
358
+ expect(subscriptionsCreated).toHaveLength(1)
359
+ expect(subscriptionsCreated[0]!.items.map((item) => item.price).sort()).toEqual([
360
+ 'price_fee',
361
+ 'price_req',
362
+ ])
363
+ })
364
+
365
+ test('behavior: fails closed on multiple live managed subscriptions', async () => {
366
+ const { stripe } = stubStripe({
367
+ subscriptions: [
368
+ { items: ['feePayerSpend'], status: 'active' },
369
+ { items: ['apiRequests'], status: 'active' },
370
+ ],
371
+ })
372
+ await expect(
373
+ Billing.ensureSubscription(stripe, 'cus_1', ['feePayerSpend', 'apiRequests']),
374
+ ).rejects.toBeInstanceOf(Billing.DuplicateSubscriptionError)
375
+ })
376
+ })
377
+
378
+ describe('createRequestUsageReporter', () => {
379
+ // A closed hour inside the settlement window for the fixed clock below.
380
+ const bucketStart = '2026-01-01T12:00:00.000Z'
381
+ const now = () => Date.parse('2026-01-02T00:20:00.000Z')
382
+
383
+ test('behavior: seals the initial count, then bills only the delta on growth', async () => {
384
+ const db = create()
385
+ await db.migrate()
386
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
387
+ await StripeCustomers.setStatus(db, 'org_1', 'active')
388
+
389
+ // Tick 1: fresh customer, initial seal of 5. The just-created item bills at
390
+ // now (no backdate).
391
+ const first = stubStripe({ subscriptions: [] })
392
+ const reporter1 = Billing.createRequestUsageReporter({
393
+ db,
394
+ now,
395
+ readCounts: async () => [{ bucketStart, count: 5, orgId: 'org_1' }],
396
+ stripe: first.stripe,
397
+ })
398
+ expect(await reporter1.tick()).toStrictEqual({ failed: 0, reported: 1, skipped: 0 })
399
+ expect(first.meterEvents).toEqual([
400
+ { event_name: 'api_request_count', identifier: expect.any(String), payload: expect.objectContaining({ value: '5' }) }, // prettier-ignore
401
+ ])
402
+ expect(first.meterEvents[0]!.timestamp).toBeUndefined()
403
+ expect((await RequestUsage.getBucket(db, { bucketStart, environment: 'production', orgId: 'org_1' }))?.reportedCount).toBe(5) // prettier-ignore
404
+
405
+ // Tick 2: count grows to 8; only the delta of 3 bills, backdated (item exists).
406
+ const second = stubStripe({
407
+ subscriptions: [{ items: ['feePayerSpend', 'apiRequests'], status: 'active' }],
408
+ })
409
+ const reporter2 = Billing.createRequestUsageReporter({
410
+ db,
411
+ now,
412
+ readCounts: async () => [{ bucketStart, count: 8, orgId: 'org_1' }],
413
+ stripe: second.stripe,
414
+ })
415
+ expect(await reporter2.tick()).toStrictEqual({ failed: 0, reported: 1, skipped: 0 })
416
+ expect(second.meterEvents[0]?.payload).toMatchObject({ value: '3' })
417
+ expect(second.meterEvents[0]?.timestamp).toBe(Math.floor(Date.parse(bucketStart) / 1000))
418
+ expect((await RequestUsage.getBucket(db, { bucketStart, environment: 'production', orgId: 'org_1' }))?.reportedCount).toBe(8) // prettier-ignore
419
+
420
+ // Tick 3: no growth; nothing bills.
421
+ const third = stubStripe({
422
+ subscriptions: [{ items: ['feePayerSpend', 'apiRequests'], status: 'active' }],
423
+ })
424
+ expect(
425
+ await Billing.createRequestUsageReporter({
426
+ db,
427
+ now,
428
+ readCounts: async () => [{ bucketStart, count: 8, orgId: 'org_1' }],
429
+ stripe: third.stripe,
430
+ }).tick(),
431
+ ).toStrictEqual({ failed: 0, reported: 0, skipped: 0 })
432
+ expect(third.meterEvents).toHaveLength(0)
433
+ await db.close()
434
+ })
435
+
436
+ test('behavior: seals only hours older than the settlement delay', async () => {
437
+ const db = create()
438
+ await db.migrate()
439
+ const readCounts = vi.fn(async () => [] as { bucketStart: string; count: number; orgId: string }[]) // prettier-ignore
440
+ const { stripe } = stubStripe({ subscriptions: [] })
441
+ await Billing.createRequestUsageReporter({ db, now, readCounts, stripe }).tick()
442
+ // Cutoff is the start of the hour 15 minutes before `now`.
443
+ expect(readCounts).toHaveBeenCalledWith({
444
+ environment: 'production',
445
+ from: '2025-12-31T00:00:00.000Z',
446
+ to: '2026-01-02T00:00:00.000Z',
447
+ })
448
+ await db.close()
449
+ })
450
+
451
+ test('behavior: an org without a billing source is skipped and never sealed', async () => {
452
+ const db = create()
453
+ await db.migrate()
454
+ const { meterEvents, stripe } = stubStripe({ subscriptions: [] })
455
+ expect(
456
+ await Billing.createRequestUsageReporter({
457
+ db,
458
+ now,
459
+ readCounts: async () => [{ bucketStart, count: 4, orgId: 'org_1' }],
460
+ stripe,
461
+ }).tick(),
462
+ ).toStrictEqual({ failed: 0, reported: 0, skipped: 1 })
463
+ expect(meterEvents).toHaveLength(0)
464
+ expect(await RequestUsage.getBucket(db, { bucketStart, environment: 'production', orgId: 'org_1' })).toBeUndefined() // prettier-ignore
465
+ await db.close()
466
+ })
467
+
468
+ test('behavior: bills sandbox usage on the sandbox client', async () => {
469
+ const db = create()
470
+ await db.migrate()
471
+ await StripeCustomers.create(db, {
472
+ environment: 'sandbox',
473
+ orgId: 'org_1',
474
+ stripeCustomerId: 'cus_sandbox',
475
+ })
476
+ await StripeCustomers.setStatus(db, 'org_1', 'active', 'sandbox')
477
+
478
+ const production = stubStripe({ subscriptions: [] })
479
+ const sandbox = stubStripe({ subscriptions: [] })
480
+ const reporter = Billing.createRequestUsageReporter({
481
+ db,
482
+ now,
483
+ readCounts: async ({ environment }) =>
484
+ environment === 'sandbox' ? [{ bucketStart, count: 7, orgId: 'org_1' }] : [],
485
+ sandboxStripe: sandbox.stripe,
486
+ stripe: production.stripe,
487
+ })
488
+ expect(await reporter.tick()).toStrictEqual({ failed: 0, reported: 1, skipped: 0 })
489
+ expect(production.meterEvents).toHaveLength(0)
490
+ expect(sandbox.meterEvents[0]?.payload).toMatchObject({
491
+ stripe_customer_id: 'cus_sandbox',
492
+ value: '7',
493
+ })
494
+ expect((await RequestUsage.getBucket(db, { bucketStart, environment: 'sandbox', orgId: 'org_1' }))?.reportedCount).toBe(7) // prettier-ignore
495
+ await db.close()
496
+ })
497
+
498
+ test('behavior: re-sends a stranded pending event and advances the watermark', async () => {
499
+ const db = create()
500
+ await db.migrate()
501
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: 'cus_1' })
502
+ await StripeCustomers.setStatus(db, 'org_1', 'active')
503
+ // Simulate a crash after sealing but before the Stripe ack.
504
+ await RequestUsage.insertPending(db, {
505
+ bucketStart,
506
+ deltaCount: 6,
507
+ environment: 'production',
508
+ identifier: 'rq_production_deadbeef0001_0_0',
509
+ orgId: 'org_1',
510
+ sequence: 0,
511
+ stripeCustomerId: 'cus_1',
512
+ })
513
+
514
+ const { meterEvents, stripe } = stubStripe({
515
+ subscriptions: [{ items: ['feePayerSpend', 'apiRequests'], status: 'active' }],
516
+ })
517
+ expect(
518
+ await Billing.createRequestUsageReporter({
519
+ db,
520
+ now,
521
+ // No new counts; recovery must still drain the pending event.
522
+ readCounts: async () => [],
523
+ stripe,
524
+ }).tick(),
525
+ ).toStrictEqual({ failed: 0, reported: 1, skipped: 0 })
526
+ expect(meterEvents[0]?.payload).toMatchObject({ value: '6' })
527
+ expect((await RequestUsage.getBucket(db, { bucketStart, environment: 'production', orgId: 'org_1' }))?.reportedCount).toBe(6) // prettier-ignore
528
+ await db.close()
529
+ })
530
+ })