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,1100 @@
1
+ import { nanoid } from 'nanoid'
2
+ import Stripe from 'stripe'
3
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
4
+
5
+ import * as TestApp from '../../../../test/App.js'
6
+ import * as TestStripe from '../../../../test/Stripe.js'
7
+ import * as Memberships from '../../../db/tables/memberships.js'
8
+ import * as SponsoredTransactions from '../../../db/tables/sponsoredTransactions.js'
9
+ import * as StripeCustomers from '../../../db/tables/stripeCustomers.js'
10
+ import * as Viem from '../../../internal/Viem.js'
11
+ import * as core_Billing from '../Billing.js'
12
+ import type * as App from '../../../App.js'
13
+
14
+ /** Origin pinned for SIWE domain binding; Hono test requests use this host. */
15
+ const origin = 'http://localhost'
16
+
17
+ /** Super admin secret configured on the test app. */
18
+ const secret = 'tempo:sk:b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1'
19
+
20
+ /** Webhook signing secret configured on the test app. */
21
+ const webhookSecret = `whsec_${'b'.repeat(32)}`
22
+
23
+ /** Distinct webhook signing secret for the test app's sandbox source. */
24
+ const sandboxWebhookSecret = `whsec_${'d'.repeat(32)}`
25
+
26
+ afterAll(() => TestStripe.sweep())
27
+
28
+ /**
29
+ * Real Stripe SDK pointed at an unreachable host — nothing is faked. Guarded
30
+ * paths never issue a request; the paths that do observe a real network
31
+ * failure (mapped to the upstream error envelope). Stripe-flow coverage lives
32
+ * in the sandbox-gated suites below.
33
+ */
34
+ function billing(): App.Billing {
35
+ return {
36
+ stripe: {
37
+ client: new Stripe(`sk_test_${'a'.repeat(24)}`, {
38
+ host: '127.0.0.1',
39
+ maxNetworkRetries: 0,
40
+ port: 9,
41
+ protocol: 'http',
42
+ timeout: 2_000,
43
+ }),
44
+ webhookSecret,
45
+ },
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Adds a sandbox source (same unreachable client) to the base capability. Reads
51
+ * and writes never touch Stripe, so an unreachable client suffices for the
52
+ * DB-only environment-isolation and webhook-verification suites.
53
+ */
54
+ function billingWithSandbox(): App.Billing {
55
+ const base = billing()
56
+ return {
57
+ stripe: { ...base.stripe, sandbox: { client: base.stripe.client, webhookSecret: sandboxWebhookSecret } }, // prettier-ignore
58
+ }
59
+ }
60
+
61
+ function createApp(
62
+ options: { billing?: App.Billing | undefined; db?: TestApp.create.Options['db'] } = {},
63
+ ) {
64
+ // prettier-ignore
65
+ return TestApp.create({
66
+ auth: { superAdmin: { secret } },
67
+ billing: options.billing ?? billing(),
68
+ db: options.db ?? TestApp.database(),
69
+ session: { wallet: { origin } },
70
+ })
71
+ }
72
+
73
+ /** RequestInit presenting the given token. */
74
+ function as(token: string) {
75
+ return { headers: { 'tempo-api-key': token } } as const
76
+ }
77
+
78
+ /** Signs in a fresh scripted account; returns its session cookie. */
79
+ async function session(app: TestApp.signIn.App) {
80
+ const account = privateKeyToAccount(generatePrivateKey())
81
+ const { cookie } = await TestApp.signIn(app, account)
82
+ return cookie!
83
+ }
84
+
85
+ /**
86
+ * Signs in and creates an org owned by that session; returns the cookie and
87
+ * org id. Org names are unique so sandbox customers are tellable apart in the
88
+ * Stripe dashboard.
89
+ */
90
+ async function seeded(app: ReturnType<typeof createApp>) {
91
+ const cookie = await session(app)
92
+ const response = await app.request('/v1/orgs', {
93
+ body: JSON.stringify({ name: `Test Org ${nanoid(10)}` }),
94
+ headers: { 'content-type': 'application/json', cookie },
95
+ method: 'POST',
96
+ })
97
+ const { id } = (await response.json()) as { id: string }
98
+ return { cookie, orgId: id }
99
+ }
100
+
101
+ describe('GET /orgs/:orgId/billing', () => {
102
+ test('behavior: synthesizes none for orgs without a billing source', async () => {
103
+ const app = createApp()
104
+ const { cookie, orgId } = await seeded(app)
105
+ const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
106
+ expect(response.status).toBe(200)
107
+ expect(await response.json()).toMatchInlineSnapshot(`
108
+ {
109
+ "spend": {
110
+ "amount": "0",
111
+ "currency": "usd",
112
+ "period": "month",
113
+ },
114
+ "status": "none",
115
+ }
116
+ `)
117
+ })
118
+
119
+ test('behavior: reflects the stored status', async () => {
120
+ const db = TestApp.database()
121
+ const app = createApp({ db })
122
+ const { cookie, orgId } = await seeded(app)
123
+ await StripeCustomers.create(db, { orgId, stripeCustomerId: 'cus_a' })
124
+ await StripeCustomers.setStatus(db, orgId, 'active')
125
+ const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
126
+ expect(response.status).toBe(200)
127
+ const body = (await response.json()) as { status: string; updatedAt: string }
128
+ expect(body.status).toMatchInlineSnapshot(`"active"`)
129
+ expect(body.updatedAt).toBeDefined()
130
+ })
131
+
132
+ test('behavior: the super admin reads any org', async () => {
133
+ const db = TestApp.database()
134
+ const app = createApp({ db })
135
+ const { orgId } = await seeded(app)
136
+ const response = await app.request(`/v1/orgs/${orgId}/billing`, as(secret))
137
+ expect(response.status).toBe(200)
138
+ expect(((await response.json()) as { status: string }).status).toBe('none')
139
+ })
140
+
141
+ test('behavior: foreign sessions read 404', async () => {
142
+ const app = createApp()
143
+ const { orgId } = await seeded(app)
144
+ const other = await session(app)
145
+ const response = await app.request(`/v1/orgs/${orgId}/billing`, {
146
+ headers: { cookie: other },
147
+ })
148
+ expect(response.status).toBe(404)
149
+ })
150
+
151
+ test('behavior: 401 without a session', async () => {
152
+ const app = createApp()
153
+ const { orgId } = await seeded(app)
154
+ const response = await app.request(`/v1/orgs/${orgId}/billing`)
155
+ expect(response.status).toBe(401)
156
+ })
157
+
158
+ test('behavior: 501 when billing is unconfigured', async () => {
159
+ const app = TestApp.create({
160
+ auth: { superAdmin: { secret } },
161
+ db: TestApp.database(),
162
+ session: { wallet: { origin } },
163
+ })
164
+ const { cookie, orgId } = await seeded(app)
165
+ const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
166
+ expect(response.status).toBe(501)
167
+ const body = (await response.json()) as { error: { code: string } }
168
+ expect(body.error.code).toMatchInlineSnapshot(`"billing_unconfigured"`)
169
+ })
170
+ })
171
+
172
+ /** RequestInit for a JSON PATCH carrying the session cookie. */
173
+ function patch(cookie: string, body: unknown) {
174
+ return {
175
+ body: JSON.stringify(body),
176
+ headers: { 'content-type': 'application/json', cookie },
177
+ method: 'PATCH',
178
+ } as const
179
+ }
180
+
181
+ describe('PATCH /orgs/:orgId/billing', () => {
182
+ test('behavior: sets both limits and reads them back', async () => {
183
+ const app = createApp()
184
+ const { cookie, orgId } = await seeded(app)
185
+ const response = await app.request(
186
+ `/v1/orgs/${orgId}/billing`,
187
+ patch(cookie, {
188
+ spendLimit: { amount: '250' },
189
+ txFeeLimit: { amount: '0.50', currency: 'usd' },
190
+ }),
191
+ )
192
+ expect(response.status).toBe(200)
193
+ expect(await response.json()).toMatchInlineSnapshot(`
194
+ {
195
+ "spend": {
196
+ "amount": "0",
197
+ "currency": "usd",
198
+ "period": "month",
199
+ },
200
+ "spendLimit": {
201
+ "amount": "250",
202
+ "currency": "usd",
203
+ "period": "month",
204
+ },
205
+ "status": "none",
206
+ "txFeeLimit": {
207
+ "amount": "0.50",
208
+ "currency": "usd",
209
+ },
210
+ }
211
+ `)
212
+ const read = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
213
+ expect(((await read.json()) as { spendLimit: object }).spendLimit).toBeDefined()
214
+ })
215
+
216
+ test('behavior: absent fields stay unchanged; null clears', async () => {
217
+ const app = createApp()
218
+ const { cookie, orgId } = await seeded(app)
219
+ await app.request(`/v1/orgs/${orgId}/billing`, patch(cookie, { spendLimit: { amount: '250' }, txFeeLimit: { amount: '1' } })) // prettier-ignore
220
+ const cleared = await app.request(`/v1/orgs/${orgId}/billing`, patch(cookie, { spendLimit: null })) // prettier-ignore
221
+ expect(cleared.status).toBe(200)
222
+ const body = (await cleared.json()) as { spendLimit?: object; txFeeLimit?: object }
223
+ expect(body.spendLimit).toBeUndefined()
224
+ expect(body.txFeeLimit).toStrictEqual({ amount: '1', currency: 'usd' })
225
+ })
226
+
227
+ test('behavior: reports current-window spend from the sponsorship ledger', async () => {
228
+ const db = TestApp.database()
229
+ const app = createApp({ db })
230
+ const { cookie, orgId } = await seeded(app)
231
+ // Finalized fee plus a pending signed cap; sandbox rows stay invisible.
232
+ const finalized = await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), signPayload: `0x${'21'.repeat(32)}` }) // prettier-ignore
233
+ await SponsoredTransactions.finalize(db, finalized.id, { feeAmount: '100000', finalizedAt: new Date().toISOString() }) // prettier-ignore
234
+ await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), feeMax: '250000', signPayload: `0x${'22'.repeat(32)}` }) // prettier-ignore
235
+ await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), billable: false, environment: 'sandbox', feeMax: '900000', signPayload: `0x${'23'.repeat(32)}` }) // prettier-ignore
236
+
237
+ const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
238
+ const body = (await response.json()) as { spend: { amount: string } }
239
+ expect(body.spend).toStrictEqual({ amount: '0.35', currency: 'usd', period: 'month' })
240
+ })
241
+
242
+ test('behavior: requires the owner role', async () => {
243
+ const db = TestApp.database()
244
+ const app = createApp({ db })
245
+ const { orgId } = await seeded(app)
246
+ const member = await session(app)
247
+ const me = await app.request('/v1/me', { headers: { cookie: member } })
248
+ const { id: userId } = (await me.json()) as { id: string }
249
+ await Memberships.create(db, { orgId, role: 'member', userId })
250
+ const response = await app.request(
251
+ `/v1/orgs/${orgId}/billing`,
252
+ patch(member, { spendLimit: null }),
253
+ )
254
+ expect(response.status).toBe(403)
255
+ })
256
+
257
+ test('behavior: rejects malformed limits', async () => {
258
+ const app = createApp()
259
+ const { cookie, orgId } = await seeded(app)
260
+ for (const body of [
261
+ { spendLimit: { amount: '12.345' } }, // too many fraction digits
262
+ { spendLimit: { amount: '1000000000' } }, // over 9 integer digits
263
+ { spendLimit: { amount: 'abc' } },
264
+ { spendLimit: { amount: '10', currency: 'eur' } },
265
+ { spendLimit: { amount: '10', period: 'week' } },
266
+ { spendLimit: { amount: '0' } }, // zero blocks everything; null clears instead
267
+ { txFeeLimit: { amount: '-1' } },
268
+ { txFeeLimit: { amount: '0.00' } },
269
+ ]) {
270
+ const response = await app.request(`/v1/orgs/${orgId}/billing`, patch(cookie, body))
271
+ expect(response.status).toBe(400)
272
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe('body_invalid') // prettier-ignore
273
+ }
274
+ })
275
+
276
+ test('behavior: foreign sessions read 404', async () => {
277
+ const app = createApp()
278
+ const { orgId } = await seeded(app)
279
+ const other = await session(app)
280
+ const response = await app.request(
281
+ `/v1/orgs/${orgId}/billing`,
282
+ patch(other, { spendLimit: null }),
283
+ )
284
+ expect(response.status).toBe(404)
285
+ })
286
+
287
+ test('behavior: 501 when billing is unconfigured', async () => {
288
+ const app = TestApp.create({
289
+ auth: { superAdmin: { secret } },
290
+ db: TestApp.database(),
291
+ session: { wallet: { origin } },
292
+ })
293
+ const { cookie, orgId } = await seeded(app)
294
+ const response = await app.request(
295
+ `/v1/orgs/${orgId}/billing`,
296
+ patch(cookie, { spendLimit: null }),
297
+ )
298
+ expect(response.status).toBe(501)
299
+ })
300
+ })
301
+
302
+ /** Baseline mainnet production sponsorship row attributed to the org. */
303
+ function sponsorship(orgId: string) {
304
+ return {
305
+ apiKeyId: 'key_1',
306
+ billable: true,
307
+ chainId: Viem.chainId.mainnet,
308
+ environment: 'production',
309
+ orgId,
310
+ projectId: 'prj_1',
311
+ transaction: `0x76${'cc'.repeat(16)}`,
312
+ transactionHash: `0x${'aa'.repeat(32)}`,
313
+ } as const
314
+ }
315
+
316
+ /** RequestInit for a bodyless POST carrying the session cookie. */
317
+ function post(cookie: string) {
318
+ return { headers: { cookie }, method: 'POST' } as const
319
+ }
320
+
321
+ describe('POST /orgs/:orgId/billing/stripe/checkout', () => {
322
+ test('behavior: requires the owner role', async () => {
323
+ const db = TestApp.database()
324
+ const app = createApp({ db })
325
+ const { orgId } = await seeded(app)
326
+ // Seed a non-owner membership directly; roles gate writes, not reads.
327
+ const member = await session(app)
328
+ const me = await app.request('/v1/me', { headers: { cookie: member } })
329
+ const { id: userId } = (await me.json()) as { id: string }
330
+ await Memberships.create(db, { orgId, role: 'member', userId })
331
+
332
+ const read = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie: member } })
333
+ expect(read.status).toBe(200)
334
+ const write = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(member))
335
+ expect(write.status).toBe(403)
336
+ })
337
+
338
+ test('behavior: foreign sessions read 404', async () => {
339
+ const app = createApp()
340
+ const { orgId } = await seeded(app)
341
+ const other = await session(app)
342
+ expect(
343
+ (await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(other))).status,
344
+ ).toBe(404)
345
+ })
346
+
347
+ test('behavior: an unreachable Stripe maps to the upstream error envelope', async () => {
348
+ const app = createApp()
349
+ const { cookie, orgId } = await seeded(app)
350
+ // First Stripe call the route makes is the customer create; the connection
351
+ // is refused for real (no transport fake).
352
+ const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie))
353
+ expect(response.status).toBe(502)
354
+ const body = (await response.json()) as { error: { code: string } }
355
+ expect(body.error.code).toMatchInlineSnapshot(`"upstream_error"`)
356
+ })
357
+ })
358
+
359
+ describe('POST /orgs/:orgId/billing/stripe/manage', () => {
360
+ test('behavior: 404 before any billing exists', async () => {
361
+ const app = createApp()
362
+ const { cookie, orgId } = await seeded(app)
363
+ const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/manage`, post(cookie))
364
+ expect(response.status).toBe(404)
365
+ const body = (await response.json()) as { error: { code: string } }
366
+ expect(body.error.code).toMatchInlineSnapshot(`"billing_not_found"`)
367
+ })
368
+ })
369
+
370
+ describe('GET /orgs/:orgId/billing/payment-methods', () => {
371
+ test('behavior: empty before any billing exists; members can read', async () => {
372
+ const db = TestApp.database()
373
+ const app = createApp({ db })
374
+ const { cookie, orgId } = await seeded(app)
375
+
376
+ const response = await app.request(`/v1/orgs/${orgId}/billing/payment-methods`, {
377
+ headers: { cookie },
378
+ })
379
+ expect(response.status).toBe(200)
380
+ expect(await response.json()).toMatchInlineSnapshot(`
381
+ {
382
+ "data": [],
383
+ }
384
+ `)
385
+
386
+ // Reads follow GET /billing: any member, not just owners.
387
+ const member = await session(app)
388
+ const me = await app.request('/v1/me', { headers: { cookie: member } })
389
+ const { id: userId } = (await me.json()) as { id: string }
390
+ await Memberships.create(db, { orgId, role: 'member', userId })
391
+ const read = await app.request(`/v1/orgs/${orgId}/billing/payment-methods`, {
392
+ headers: { cookie: member },
393
+ })
394
+ expect(read.status).toBe(200)
395
+ })
396
+
397
+ test('behavior: foreign sessions read 404', async () => {
398
+ const app = createApp()
399
+ const { orgId } = await seeded(app)
400
+ const other = await session(app)
401
+ const response = await app.request(`/v1/orgs/${orgId}/billing/payment-methods`, {
402
+ headers: { cookie: other },
403
+ })
404
+ expect(response.status).toBe(404)
405
+ })
406
+ })
407
+
408
+ describe('DELETE /orgs/:orgId/billing/payment-methods/:methodId', () => {
409
+ test('behavior: 404 before any billing exists', async () => {
410
+ const app = createApp()
411
+ const { cookie, orgId } = await seeded(app)
412
+ const response = await app.request(
413
+ `/v1/orgs/${orgId}/billing/payment-methods/pm_${'0'.repeat(24)}`,
414
+ { headers: { cookie }, method: 'DELETE' },
415
+ )
416
+ expect(response.status).toBe(404)
417
+ const body = (await response.json()) as { error: { code: string } }
418
+ expect(body.error.code).toMatchInlineSnapshot(`"billing_not_found"`)
419
+ })
420
+
421
+ test('behavior: requires the owner role', async () => {
422
+ const db = TestApp.database()
423
+ const app = createApp({ db })
424
+ const { orgId } = await seeded(app)
425
+ const member = await session(app)
426
+ const me = await app.request('/v1/me', { headers: { cookie: member } })
427
+ const { id: userId } = (await me.json()) as { id: string }
428
+ await Memberships.create(db, { orgId, role: 'member', userId })
429
+
430
+ const response = await app.request(
431
+ `/v1/orgs/${orgId}/billing/payment-methods/pm_${'0'.repeat(24)}`,
432
+ { headers: { cookie: member }, method: 'DELETE' },
433
+ )
434
+ expect(response.status).toBe(403)
435
+ })
436
+ })
437
+
438
+ describe('DELETE /orgs/:orgId', () => {
439
+ test('behavior: deleting the organization removes its billing source', async () => {
440
+ const db = TestApp.database()
441
+ const app = createApp({ db })
442
+ const { cookie, orgId } = await seeded(app)
443
+ await StripeCustomers.create(db, { orgId, stripeCustomerId: 'cus_cascade' })
444
+
445
+ const response = await app.request(`/v1/orgs/${orgId}`, {
446
+ headers: { cookie },
447
+ method: 'DELETE',
448
+ })
449
+ expect(response.status).toBe(200)
450
+ expect(await StripeCustomers.get(db, orgId)).toBeUndefined()
451
+ })
452
+ })
453
+
454
+ describe('environment scoping', () => {
455
+ test('behavior: sandbox is 501 when no sandbox source is configured', async () => {
456
+ const app = createApp()
457
+ const { cookie, orgId } = await seeded(app)
458
+ const response = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, {
459
+ headers: { cookie },
460
+ })
461
+ expect(response.status).toBe(501)
462
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe('billing_unconfigured') // prettier-ignore
463
+ })
464
+
465
+ test('behavior: rejects an unknown environment', async () => {
466
+ const app = createApp({ billing: billingWithSandbox() })
467
+ const { cookie, orgId } = await seeded(app)
468
+ const response = await app.request(`/v1/orgs/${orgId}/billing?environment=staging`, {
469
+ headers: { cookie },
470
+ })
471
+ expect(response.status).toBe(400)
472
+ expect(((await response.json()) as { error: { code: string } }).error.code).toBe(
473
+ 'query_invalid',
474
+ )
475
+ })
476
+
477
+ test('behavior: status reads are isolated per environment', async () => {
478
+ const db = TestApp.database()
479
+ const app = createApp({ billing: billingWithSandbox(), db })
480
+ const { cookie, orgId } = await seeded(app)
481
+ // One org, two Stripe customers — production active, sandbox untouched.
482
+ await StripeCustomers.create(db, { orgId, stripeCustomerId: 'cus_prod' })
483
+ await StripeCustomers.setStatus(db, orgId, 'active')
484
+ await StripeCustomers.create(db, { environment: 'sandbox', orgId, stripeCustomerId: 'cus_sbx' })
485
+
486
+ const prod = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
487
+ const sbx = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie } }) // prettier-ignore
488
+ expect(((await prod.json()) as { status: string }).status).toBe('active')
489
+ expect(((await sbx.json()) as { status: string }).status).toBe('none')
490
+ })
491
+
492
+ test('behavior: PATCH writes only the targeted environment', async () => {
493
+ const db = TestApp.database()
494
+ const app = createApp({ billing: billingWithSandbox(), db })
495
+ const { cookie, orgId } = await seeded(app)
496
+ await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, patch(cookie, { spendLimit: { amount: '99' } })) // prettier-ignore
497
+
498
+ const prod = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
499
+ const sbx = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie } }) // prettier-ignore
500
+ expect(((await prod.json()) as { spendLimit?: object }).spendLimit).toBeUndefined()
501
+ expect(((await sbx.json()) as { spendLimit?: object }).spendLimit).toStrictEqual({
502
+ amount: '99',
503
+ currency: 'usd',
504
+ period: 'month',
505
+ })
506
+ })
507
+
508
+ test('behavior: spend is scoped per environment', async () => {
509
+ const db = TestApp.database()
510
+ const app = createApp({ billing: billingWithSandbox(), db })
511
+ const { cookie, orgId } = await seeded(app)
512
+ // Production: a finalized billable mainnet row (0.10). Sandbox: a pending
513
+ // non-billable testnet row (0.25) — counted despite billable=false.
514
+ const finalized = await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), signPayload: `0x${'31'.repeat(32)}` }) // prettier-ignore
515
+ await SponsoredTransactions.finalize(db, finalized.id, { feeAmount: '100000', finalizedAt: new Date().toISOString() }) // prettier-ignore
516
+ await SponsoredTransactions.upsert(db, { ...sponsorship(orgId), billable: false, chainId: Viem.chainId.testnet, environment: 'sandbox', feeMax: '250000', signPayload: `0x${'32'.repeat(32)}` }) // prettier-ignore
517
+
518
+ const prod = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
519
+ const sbx = await app.request(`/v1/orgs/${orgId}/billing?environment=sandbox`, { headers: { cookie } }) // prettier-ignore
520
+ expect(((await prod.json()) as { spend: { amount: string } }).spend.amount).toBe('0.1')
521
+ expect(((await sbx.json()) as { spend: { amount: string } }).spend.amount).toBe('0.25')
522
+ })
523
+ })
524
+
525
+ describe('POST /stripe/webhook', () => {
526
+ /** Signs with the app's configured secret unless a test overrides it. */
527
+ function sign(payload: string, secret = webhookSecret) {
528
+ return TestStripe.sign({ payload, secret })
529
+ }
530
+
531
+ /** A minimal but shape-valid event addressing the given customer. */
532
+ function event(customer: string, type = 'customer.updated') {
533
+ return JSON.stringify({
534
+ data: { object: { id: customer, object: 'customer' } },
535
+ id: 'evt_test_webhook',
536
+ object: 'event',
537
+ type,
538
+ })
539
+ }
540
+
541
+ function deliver(app: ReturnType<typeof createApp>, payload: string, signature?: string) {
542
+ return app.request('/stripe/webhook', {
543
+ body: payload,
544
+ headers: signature ? { 'stripe-signature': signature } : {},
545
+ method: 'POST',
546
+ })
547
+ }
548
+
549
+ test('behavior: acks events for unknown customers without touching Stripe', async () => {
550
+ const app = createApp()
551
+ const payload = event('cus_unknown')
552
+ const response = await deliver(app, payload, sign(payload))
553
+ expect(response.status).toBe(200)
554
+ expect(await response.json()).toMatchInlineSnapshot(`
555
+ {
556
+ "received": true,
557
+ }
558
+ `)
559
+ })
560
+
561
+ test('behavior: acks unhandled event types untouched', async () => {
562
+ const app = createApp()
563
+ const payload = event('cus_unknown', 'charge.succeeded')
564
+ expect((await deliver(app, payload, sign(payload))).status).toBe(200)
565
+ })
566
+
567
+ test('behavior: verifies events signed with the sandbox secret', async () => {
568
+ const app = createApp({ billing: billingWithSandbox() })
569
+ const payload = event('cus_unknown')
570
+ // Accepted (not signature_invalid) and acked as an unknown customer.
571
+ const response = await deliver(app, payload, sign(payload, sandboxWebhookSecret))
572
+ expect(response.status).toBe(200)
573
+ expect(((await response.json()) as { received: boolean }).received).toBe(true)
574
+ })
575
+
576
+ test('behavior: rejects sandbox-signed events when sandbox is unconfigured', async () => {
577
+ const app = createApp()
578
+ const payload = event('cus_a')
579
+ expect((await deliver(app, payload, sign(payload, sandboxWebhookSecret))).status).toBe(400)
580
+ })
581
+
582
+ test('behavior: rejects a tampered payload', async () => {
583
+ const app = createApp()
584
+ const payload = event('cus_a')
585
+ const response = await deliver(app, payload.replace('cus_a', 'cus_b'), sign(payload))
586
+ expect(response.status).toBe(400)
587
+ const body = (await response.json()) as { error: { code: string } }
588
+ expect(body.error.code).toMatchInlineSnapshot(`"signature_invalid"`)
589
+ })
590
+
591
+ test('behavior: rejects a wrong secret and a missing header', async () => {
592
+ const app = createApp()
593
+ const payload = event('cus_a')
594
+ expect((await deliver(app, payload, sign(payload, `whsec_${'c'.repeat(32)}`))).status).toBe(400) // prettier-ignore
595
+ expect((await deliver(app, payload)).status).toBe(400)
596
+ })
597
+
598
+ test('behavior: rejects a stale timestamp', async () => {
599
+ const app = createApp()
600
+ const payload = event('cus_a')
601
+ const signature = TestStripe.sign({
602
+ payload,
603
+ secret: webhookSecret,
604
+ // Beyond the default 300s tolerance.
605
+ timestamp: Math.floor(Date.now() / 1000) - 600,
606
+ })
607
+ expect((await deliver(app, payload, signature)).status).toBe(400)
608
+ })
609
+
610
+ test('behavior: 501 when billing is unconfigured', async () => {
611
+ const app = TestApp.create({
612
+ auth: { superAdmin: { secret } },
613
+ db: TestApp.database(),
614
+ session: { wallet: { origin } },
615
+ })
616
+ const payload = event('cus_a')
617
+ expect((await deliver(app, payload, sign(payload))).status).toBe(501)
618
+ })
619
+ })
620
+
621
+ /** Billing capability against the real sandbox. */
622
+ function sandboxBilling(): App.Billing {
623
+ return { stripe: { client: TestStripe.client(), webhookSecret } }
624
+ }
625
+
626
+ /**
627
+ * Real-Stripe capability with both sources configured (both hit the same
628
+ * test-mode account, but each environment provisions its own customer row).
629
+ */
630
+ function sandboxBillingBoth(): App.Billing {
631
+ return {
632
+ stripe: {
633
+ client: TestStripe.client(),
634
+ sandbox: { client: TestStripe.client(), webhookSecret: sandboxWebhookSecret },
635
+ webhookSecret,
636
+ },
637
+ }
638
+ }
639
+
640
+ // Real-protocol suites against a Stripe sandbox (never faked, mirroring the
641
+ // MPP-testnet convention). They run when `STRIPE_SECRET_KEY_DEV` is set;
642
+ // CI always sets it.
643
+
644
+ describe.runIf(TestStripe.secretKey)('stripe sandbox (direct)', () => {
645
+ test('checkout provisions distinct customers per environment', { timeout: 30_000 }, async () => {
646
+ const db = TestApp.database()
647
+ const app = createApp({ billing: sandboxBillingBoth(), db })
648
+ const { cookie, orgId } = await seeded(app)
649
+
650
+ const prod = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie))
651
+ const sbx = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout?environment=sandbox`, post(cookie)) // prettier-ignore
652
+ // Track both before asserting so a failure never leaks a provisioned customer.
653
+ const prodRecord = await StripeCustomers.get(db, orgId)
654
+ const sbxRecord = await StripeCustomers.get(db, orgId, 'sandbox')
655
+ if (prodRecord) TestStripe.track(prodRecord.stripeCustomerId)
656
+ if (sbxRecord) TestStripe.track(sbxRecord.stripeCustomerId)
657
+
658
+ expect(prod.status).toBe(200)
659
+ expect(sbx.status).toBe(200)
660
+ expect(prodRecord?.stripeCustomerId).toMatch(/^cus_/)
661
+ expect(sbxRecord?.stripeCustomerId).toMatch(/^cus_/)
662
+ // One org, two environments, two independent Stripe customers.
663
+ expect(prodRecord!.stripeCustomerId).not.toBe(sbxRecord!.stripeCustomerId)
664
+
665
+ // The sandbox session returns to the console in sandbox mode; the full-page
666
+ // Stripe round-trip drops the client-retained `env`, so the URL carries it.
667
+ const stripe = TestStripe.client()
668
+ const sessions = await stripe.checkout.sessions.list({ customer: sbxRecord!.stripeCustomerId })
669
+ expect(sessions.data[0]?.success_url).toContain('env=sandbox')
670
+ })
671
+
672
+ test('checkout provisions a real customer and setup session', { timeout: 30_000 }, async () => {
673
+ const db = TestApp.database()
674
+ const app = createApp({ billing: sandboxBilling(), db })
675
+ const { cookie, orgId } = await seeded(app)
676
+
677
+ const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie))
678
+ // Track for teardown before asserting: a failed assertion must not leak
679
+ // the customer the route already provisioned.
680
+ const record = await StripeCustomers.get(db, orgId)
681
+ if (record) TestStripe.track(record.stripeCustomerId)
682
+ expect(response.status).toBe(200)
683
+ const { url } = (await response.json()) as { url: string }
684
+ expect(url).toMatch(/^https:\/\/checkout\.stripe\.com\//)
685
+ expect(record?.stripeCustomerId).toMatch(/^cus_/)
686
+
687
+ // The customer is real and carries org attribution.
688
+ const stripe = TestStripe.client()
689
+ const customer = await stripe.customers.retrieve(record!.stripeCustomerId)
690
+ expect(customer.deleted).not.toBe(true)
691
+ expect((customer as { metadata: { orgId?: string } }).metadata.orgId).toBe(orgId)
692
+
693
+ // A second checkout reuses the customer; a browser `Origin` overrides the
694
+ // configured console URL so the caller returns to the host it came from.
695
+ const callerOrigin = 'http://localhost:23145'
696
+ const again = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, {
697
+ headers: { cookie, origin: callerOrigin },
698
+ method: 'POST',
699
+ })
700
+ expect(again.status).toBe(200)
701
+ expect((await StripeCustomers.get(db, orgId))?.stripeCustomerId).toBe(record!.stripeCustomerId)
702
+ const sessions = await stripe.checkout.sessions.list({ customer: record!.stripeCustomerId })
703
+ expect(sessions.data.length).toBeGreaterThanOrEqual(2)
704
+ expect(sessions.data.every(({ mode }) => mode === 'setup')).toBe(true)
705
+ const urls = sessions.data.map(({ cancel_url, success_url }) => ({ cancel_url, success_url }))
706
+ expect(urls).toContainEqual({
707
+ cancel_url: `https://console.tempo.xyz/${orgId}/billing?billing=canceled`,
708
+ success_url: `https://console.tempo.xyz/${orgId}/billing?billing=success`,
709
+ })
710
+ expect(urls).toContainEqual({
711
+ cancel_url: `${callerOrigin}/${orgId}/billing?billing=canceled`,
712
+ success_url: `${callerOrigin}/${orgId}/billing?billing=success`,
713
+ })
714
+ })
715
+
716
+ test(
717
+ 'payment methods list live state; detach by id parks status',
718
+ { timeout: 30_000 },
719
+ async () => {
720
+ // prettier-ignore
721
+ const db = TestApp.database()
722
+ const app = createApp({ billing: sandboxBilling(), db })
723
+ const { cookie, orgId } = await seeded(app)
724
+ const checkout = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie))
725
+ const customerId = (await StripeCustomers.get(db, orgId))?.stripeCustomerId
726
+ if (customerId) TestStripe.track(customerId)
727
+ expect(checkout.status).toBe(200)
728
+ if (!customerId) throw new Error('checkout stored no customer')
729
+
730
+ const attached = await TestStripe.attachCard(customerId)
731
+ const stripe = TestStripe.client()
732
+ await stripe.customers.update(customerId, {
733
+ invoice_settings: { default_payment_method: attached.paymentMethod },
734
+ })
735
+ await StripeCustomers.setStatus(db, orgId, 'active')
736
+
737
+ // The list reads live Stripe state: card details plus the default marker.
738
+ const list = await app.request(`/v1/orgs/${orgId}/billing/payment-methods`, {
739
+ headers: { cookie },
740
+ })
741
+ expect(list.status).toBe(200)
742
+ type Method = {
743
+ card?: { brand: string; last4: string }
744
+ default: boolean
745
+ id: string
746
+ provider: string
747
+ type: string
748
+ }
749
+ const { data } = (await list.json()) as { data: Method[] }
750
+ expect(data).toHaveLength(1)
751
+ expect(data[0]!.id).toBe(attached.paymentMethod)
752
+ expect(data[0]!.default).toBe(true)
753
+ expect(data[0]!.provider).toBe('stripe')
754
+ expect(data[0]!.type).toBe('card')
755
+ expect(data[0]!.card?.last4).toMatch(/^\d{4}$/)
756
+
757
+ // Ids outside the org's own list read as absent.
758
+ const missing = await app.request(
759
+ `/v1/orgs/${orgId}/billing/payment-methods/pm_${'0'.repeat(24)}`,
760
+ { headers: { cookie }, method: 'DELETE' },
761
+ )
762
+ expect(missing.status).toBe(404)
763
+ expect(((await missing.json()) as { error: { code: string } }).error.code).toBe(
764
+ 'payment_method_not_found',
765
+ )
766
+
767
+ const response = await app.request(
768
+ `/v1/orgs/${orgId}/billing/payment-methods/${data[0]!.id}`,
769
+ { headers: { cookie }, method: 'DELETE' },
770
+ )
771
+ expect(response.status).toBe(200)
772
+ const body = (await response.json()) as { status: string }
773
+ expect(body.status).toBe('none')
774
+ expect((await StripeCustomers.get(db, orgId))?.status).toBe('none')
775
+
776
+ // Nothing survives on the customer; the default cleared with the detach.
777
+ const methods = await stripe.customers.listPaymentMethods(customerId, { limit: 100 })
778
+ expect(methods.data).toHaveLength(0)
779
+ const customer = await stripe.customers.retrieve(customerId)
780
+ expect(customer.deleted).not.toBe(true)
781
+ if (!customer.deleted) expect(customer.invoice_settings.default_payment_method).toBeNull()
782
+ },
783
+ )
784
+
785
+ test(
786
+ 'checkout.session.completed promotes the payment method to default',
787
+ { timeout: 30_000 },
788
+ async () => {
789
+ // prettier-ignore
790
+ const db = TestApp.database()
791
+ const app = createApp({ billing: sandboxBilling(), db })
792
+ const { cookie, orgId } = await seeded(app)
793
+ const checkout = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie))
794
+ const customerId = (await StripeCustomers.get(db, orgId))?.stripeCustomerId
795
+ if (customerId) TestStripe.track(customerId)
796
+ expect(checkout.status).toBe(200)
797
+ if (!customerId) throw new Error('checkout stored no customer')
798
+
799
+ const attached = await TestStripe.completeCheckout(app, {
800
+ customer: customerId,
801
+ webhookSecret,
802
+ })
803
+
804
+ const stripe = TestStripe.client()
805
+ const customer = await stripe.customers.retrieve(customerId)
806
+ expect(customer.deleted).not.toBe(true)
807
+ expect(
808
+ (customer as { invoice_settings: { default_payment_method: unknown } }).invoice_settings
809
+ .default_payment_method,
810
+ ) // prettier-ignore
811
+ .toBe(attached.paymentMethod)
812
+ const billing = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
813
+ expect(((await billing.json()) as { status: string }).status).toBe('active')
814
+ },
815
+ )
816
+
817
+ test('manage links to a live portal session', { timeout: 30_000 }, async () => {
818
+ await TestStripe.ensurePortalConfiguration()
819
+
820
+ const db = TestApp.database()
821
+ const app = createApp({ billing: sandboxBilling(), db })
822
+ const { cookie, orgId } = await seeded(app)
823
+ const checkout = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie))
824
+ const customerId = (await StripeCustomers.get(db, orgId))?.stripeCustomerId
825
+ if (customerId) TestStripe.track(customerId)
826
+ expect(checkout.status).toBe(200)
827
+
828
+ const response = await app.request(`/v1/orgs/${orgId}/billing/stripe/manage`, post(cookie))
829
+ expect(response.status).toBe(200)
830
+ const { url } = (await response.json()) as { url: string }
831
+ expect(url).toMatch(/^https:\/\/billing\.stripe\.com\//)
832
+ })
833
+ })
834
+
835
+ describe.runIf(TestStripe.secretKey)('stripe sandbox (live delivery)', () => {
836
+ let bridge: TestStripe.bridge.Bridge
837
+ let app: ReturnType<typeof createApp>
838
+ let db: ReturnType<typeof TestApp.database>
839
+
840
+ // Live webhook delivery: mutations fire real events, a `stripe listen`
841
+ // container forwards them with Stripe's own signature, and the app
842
+ // re-derives status from live state.
843
+ beforeAll(async () => {
844
+ bridge = await TestStripe.bridge()
845
+ db = TestApp.database()
846
+ app = createApp({ billing: { stripe: { client: TestStripe.client(), webhookSecret: bridge.secret } }, db }) // prettier-ignore
847
+ bridge.connect((request) => app.fetch(request))
848
+ }, 180_000)
849
+
850
+ afterAll(async () => {
851
+ await bridge?.stop()
852
+ })
853
+
854
+ test('attach → active, detach → none, delete → canceled', { timeout: 300_000 }, async () => {
855
+ const { cookie, orgId } = await seeded(app)
856
+ const checkout = await app.request(`/v1/orgs/${orgId}/billing/stripe/checkout`, post(cookie))
857
+ const customerId = (await StripeCustomers.get(db, orgId))?.stripeCustomerId
858
+ if (customerId) TestStripe.track(customerId)
859
+ expect(checkout.status).toBe(200)
860
+
861
+ if (!customerId) throw new Error('checkout stored no customer')
862
+
863
+ const status = async () => {
864
+ const response = await app.request(`/v1/orgs/${orgId}/billing`, { headers: { cookie } })
865
+ const body = (await response.json()) as { status?: string }
866
+ return body.status
867
+ }
868
+
869
+ // Attach a real card: `payment_method.attached` + `setup_intent.succeeded`
870
+ // deliver through the bridge and flip the derived status.
871
+ await TestStripe.attachCard(customerId)
872
+ await until(async () => (await status()) === 'active')
873
+
874
+ // Detach every card: delivery re-derives back to `none`.
875
+ await TestStripe.detachCards(customerId)
876
+ await until(async () => (await status()) === 'none')
877
+
878
+ // Deleting the customer lands `canceled`.
879
+ await TestStripe.client().customers.del(customerId)
880
+ await until(async () => (await status()) === 'canceled')
881
+ })
882
+ })
883
+
884
+ /** Polls until the condition holds; live delivery typically lands within seconds. */
885
+ async function until(condition: () => Promise<boolean>, timeout = 120_000) {
886
+ const deadline = Date.now() + timeout
887
+ while (Date.now() < deadline) {
888
+ if (await condition()) return
889
+ await new Promise((resolve) => setTimeout(resolve, 1_000))
890
+ }
891
+ throw new Error('condition never held within the timeout')
892
+ }
893
+
894
+ // Metering against the real sandbox: account fixtures, subscription
895
+ // provisioning, the reporter's exactly-once pass, and the priced preview.
896
+ describe.runIf(TestStripe.secretKey)('stripe sandbox (metering)', () => {
897
+ test('ensureFixtures is idempotent across clients', { timeout: 30_000 }, async () => {
898
+ const first = await core_Billing.ensureFixtures(TestStripe.client())
899
+ // A fresh client skips the memo and exercises the real lookup path.
900
+ const second = await core_Billing.ensureFixtures(TestStripe.client())
901
+ expect(second).toStrictEqual(first)
902
+ expect(first.meterId).toMatch(/^mtr_/)
903
+ expect(first.priceId).toMatch(/^price_/)
904
+ })
905
+
906
+ test(
907
+ 'ensureSubscription provisions exactly one live subscription',
908
+ { timeout: 60_000 },
909
+ async () => {
910
+ // prettier-ignore
911
+ const stripe = TestStripe.client()
912
+ const customer = await stripe.customers.create({
913
+ metadata: { orgId: 'org_sub' },
914
+ name: `Test Org ${nanoid(10)}`,
915
+ })
916
+ TestStripe.track(customer.id)
917
+ await core_Billing.ensureSubscription(stripe, customer.id)
918
+ await core_Billing.ensureSubscription(stripe, customer.id)
919
+ const { priceId } = await core_Billing.ensureFixtures(stripe)
920
+ const subscriptions = await stripe.subscriptions.list({
921
+ customer: customer.id,
922
+ limit: 100,
923
+ price: priceId,
924
+ status: 'all',
925
+ })
926
+ const live = subscriptions.data.filter((s) => !['canceled', 'incomplete_expired'].includes(s.status)) // prettier-ignore
927
+ expect(live).toHaveLength(1)
928
+ },
929
+ )
930
+
931
+ test('the reporter reports each finalized row exactly once', { timeout: 120_000 }, async () => {
932
+ const db = TestApp.database()
933
+ const stripe = TestStripe.client()
934
+ const customer = await stripe.customers.create({
935
+ metadata: { orgId: 'org_1' },
936
+ name: `Test Org ${nanoid(10)}`,
937
+ })
938
+ TestStripe.track(customer.id)
939
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: customer.id })
940
+
941
+ const finalize = async (input: { feeAmount: string; orgId: string; seed: string }) => {
942
+ const row = await SponsoredTransactions.upsert(db, { ...meterable(input.orgId), signPayload: `0x${input.seed.repeat(32)}` }) // prettier-ignore
943
+ await SponsoredTransactions.finalize(db, row.id, { feeAmount: input.feeAmount, finalizedAt: new Date().toISOString() }) // prettier-ignore
944
+ }
945
+ await finalize({ feeAmount: '100000', orgId: 'org_1', seed: '41' })
946
+ await finalize({ feeAmount: '250000', orgId: 'org_1', seed: '42' })
947
+ // A deleted org's row marks without a meter event; a pending row waits.
948
+ await finalize({ feeAmount: '900000', orgId: 'org_ghost', seed: '43' })
949
+ await SponsoredTransactions.upsert(db, { ...meterable('org_1'), signPayload: `0x${'44'.repeat(32)}` }) // prettier-ignore
950
+
951
+ const reporter = core_Billing.createReporter({ db, stripe })
952
+ expect(await reporter.tick()).toStrictEqual({ failed: 0, reported: 2, skipped: 1 })
953
+ expect(await reporter.tick()).toStrictEqual({ failed: 0, reported: 0, skipped: 0 })
954
+ })
955
+
956
+ test('the metered invoice preview matches reported spend', { timeout: 240_000 }, async () => {
957
+ const db = TestApp.database()
958
+ const stripe = TestStripe.client()
959
+ const customer = await stripe.customers.create({
960
+ metadata: { orgId: 'org_1' },
961
+ name: `Test Org ${nanoid(10)}`,
962
+ })
963
+ TestStripe.track(customer.id)
964
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: customer.id })
965
+ await core_Billing.ensureSubscription(stripe, customer.id)
966
+
967
+ // $0.35 of finalized spend: 100k + 250k base units.
968
+ for (const [seed, feeAmount] of [
969
+ ['51', '100000'],
970
+ ['52', '250000'],
971
+ ] as const) {
972
+ const row = await SponsoredTransactions.upsert(db, { ...meterable('org_1'), signPayload: `0x${seed.repeat(32)}` }) // prettier-ignore
973
+ await SponsoredTransactions.finalize(db, row.id, { feeAmount, finalizedAt: new Date().toISOString() }) // prettier-ignore
974
+ }
975
+ await core_Billing.createReporter({ db, stripe }).tick()
976
+
977
+ const { priceId } = await core_Billing.ensureFixtures(stripe)
978
+ const [subscription] = (await stripe.subscriptions.list({ customer: customer.id, price: priceId, limit: 1 })).data // prettier-ignore
979
+
980
+ // Meter events process asynchronously; poll the preview until usage lands.
981
+ const deadline = Date.now() + 180_000
982
+ let line: { amount: number; quantity: number | null } | undefined
983
+ while (Date.now() < deadline) {
984
+ const preview = await stripe.invoices.createPreview({
985
+ customer: customer.id,
986
+ subscription: subscription!.id,
987
+ })
988
+ line = preview.lines.data.find((l) => l.pricing?.price_details?.price === priceId)
989
+ if (line?.quantity === 350_000) break
990
+ await new Promise((resolve) => setTimeout(resolve, 5_000))
991
+ }
992
+ expect(line?.quantity).toBe(350_000)
993
+ // 350k units × 0.0001¢ = 35¢.
994
+ expect(line?.amount).toBe(35)
995
+ })
996
+
997
+ test(
998
+ 'a failed metered invoice parks the org past_due; payment restores active',
999
+ { timeout: 600_000 },
1000
+ async () => {
1001
+ // prettier-ignore
1002
+ const db = TestApp.database()
1003
+ const stripe = TestStripe.client()
1004
+ const app = createApp({ billing: sandboxBilling(), db })
1005
+ // Frozen time lets the clock cross a real billing-period boundary.
1006
+ const clock = await stripe.testHelpers.testClocks.create({
1007
+ frozen_time: Math.floor(Date.now() / 1000),
1008
+ })
1009
+ try {
1010
+ const customer = await stripe.customers.create({
1011
+ metadata: { orgId: 'org_1' },
1012
+ name: `Test Org ${nanoid(10)}`,
1013
+ test_clock: clock.id,
1014
+ })
1015
+ await StripeCustomers.create(db, { orgId: 'org_1', stripeCustomerId: customer.id })
1016
+ // A card that saves fine but refuses charges: renewal will fail.
1017
+ const intent = await stripe.setupIntents.create({
1018
+ confirm: true,
1019
+ customer: customer.id,
1020
+ payment_method: 'pm_card_chargeCustomerFail',
1021
+ payment_method_types: ['card'],
1022
+ })
1023
+ await stripe.customers.update(customer.id, {
1024
+ invoice_settings: { default_payment_method: intent.payment_method as string },
1025
+ })
1026
+ await StripeCustomers.setStatus(db, 'org_1', 'active')
1027
+ await core_Billing.ensureSubscription(stripe, customer.id)
1028
+
1029
+ // $5 of finalized usage so the invoice is worth charging.
1030
+ const row = await SponsoredTransactions.upsert(db, { ...meterable('org_1'), signPayload: `0x${'61'.repeat(32)}` }) // prettier-ignore
1031
+ await SponsoredTransactions.finalize(db, row.id, { feeAmount: '5000000', finalizedAt: new Date().toISOString() }) // prettier-ignore
1032
+ await core_Billing.createReporter({ db, stripe }).tick()
1033
+
1034
+ // Cross the period boundary: Stripe invoices the metered usage and the
1035
+ // charge fails on the saved card, emitting a real payment_failed event.
1036
+ await stripe.testHelpers.testClocks.advance(clock.id, {
1037
+ frozen_time: Math.floor(Date.now() / 1000) + 32 * 24 * 60 * 60,
1038
+ })
1039
+ const failed = await eventFor(stripe, 'invoice.payment_failed', customer.id)
1040
+ await deliverEvent(app, failed)
1041
+ expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('past_due')
1042
+
1043
+ // Pay the open invoice with a working card; `invoice.paid` re-derives.
1044
+ const paying = await TestStripe.attachCard(customer.id)
1045
+ const invoice = (failed.data.object as { id: string }).id
1046
+ await stripe.invoices.pay(invoice, { payment_method: paying.paymentMethod })
1047
+ const paid = await eventFor(stripe, 'invoice.paid', customer.id)
1048
+ await deliverEvent(app, paid)
1049
+ expect((await StripeCustomers.get(db, 'org_1'))?.status).toBe('active')
1050
+ } finally {
1051
+ // Deleting the clock deletes its customers and subscriptions.
1052
+ await stripe.testHelpers.testClocks.del(clock.id).catch(() => undefined)
1053
+ }
1054
+ },
1055
+ )
1056
+ })
1057
+
1058
+ /** Baseline mainnet production row the metering suites finalize per case. */
1059
+ function meterable(orgId: string) {
1060
+ return {
1061
+ apiKeyId: 'key_1',
1062
+ billable: true,
1063
+ chainId: Viem.chainId.mainnet,
1064
+ environment: 'production',
1065
+ orgId,
1066
+ projectId: 'prj_1',
1067
+ transaction: `0x76${'cc'.repeat(16)}`,
1068
+ transactionHash: `0x${'ab'.repeat(32)}`,
1069
+ } as const
1070
+ }
1071
+
1072
+ /** Polls the recorded event stream until one matches the customer. */
1073
+ async function eventFor(
1074
+ stripe: Stripe,
1075
+ type: 'invoice.paid' | 'invoice.payment_failed',
1076
+ customerId: string,
1077
+ timeout = 240_000,
1078
+ ) {
1079
+ const deadline = Date.now() + timeout
1080
+ while (Date.now() < deadline) {
1081
+ const events = await stripe.events.list({ limit: 100, type })
1082
+ const match = events.data.find(
1083
+ (event) => (event.data.object as { customer?: string }).customer === customerId,
1084
+ )
1085
+ if (match) return match
1086
+ await new Promise((resolve) => setTimeout(resolve, 5_000))
1087
+ }
1088
+ throw new Error(`no ${type} event recorded for ${customerId}`)
1089
+ }
1090
+
1091
+ /** Delivers a recorded event to the app, signed with the suite's webhook secret. */
1092
+ async function deliverEvent(app: ReturnType<typeof createApp>, event: Stripe.Event) {
1093
+ const payload = JSON.stringify(event)
1094
+ const response = await app.request('/stripe/webhook', {
1095
+ body: payload,
1096
+ headers: { 'stripe-signature': TestStripe.sign({ payload, secret: webhookSecret }) },
1097
+ method: 'POST',
1098
+ })
1099
+ if (response.status !== 200) throw new Error(`webhook delivery returned ${response.status}`)
1100
+ }