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.
- package/README.md +31 -3
- package/dist/ApiKey.d.ts +1 -0
- package/dist/ApiKey.d.ts.map +1 -1
- package/dist/ApiKey.js +5 -2
- package/dist/ApiKey.js.map +1 -1
- package/dist/ApiKeys.d.ts +28 -1
- package/dist/ApiKeys.d.ts.map +1 -1
- package/dist/ApiKeys.js +34 -1
- package/dist/ApiKeys.js.map +1 -1
- package/dist/App.d.ts +7167 -4507
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +8 -0
- package/dist/App.js.map +1 -1
- package/dist/Scope.d.ts +11 -2
- package/dist/Scope.d.ts.map +1 -1
- package/dist/Scope.js +14 -3
- package/dist/Scope.js.map +1 -1
- package/dist/admin/App.d.ts +128 -118
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +7 -0
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/scopes.d.ts +8 -0
- package/dist/admin/apps/scopes.d.ts.map +1 -1
- package/dist/admin/apps/scopes.js +3 -0
- package/dist/admin/apps/scopes.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +57 -57
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +13 -13
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/analytics/Analytics.d.ts +151 -0
- package/dist/analytics/Analytics.d.ts.map +1 -0
- package/dist/analytics/Analytics.js +212 -0
- package/dist/analytics/Analytics.js.map +1 -0
- package/dist/analytics/tables/requestEvents.d.ts +215 -0
- package/dist/analytics/tables/requestEvents.d.ts.map +1 -0
- package/dist/analytics/tables/requestEvents.js +264 -0
- package/dist/analytics/tables/requestEvents.js.map +1 -0
- package/dist/apps/data/App.d.ts +1024 -624
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +36 -0
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/routes/balances.d.ts +45 -45
- package/dist/apps/data/routes/coingecko.d.ts +176 -176
- package/dist/apps/data/routes/exchanges.d.ts +455 -455
- package/dist/apps/data/routes/fee-amm.d.ts +101 -101
- package/dist/apps/data/routes/funding.d.ts +765 -0
- package/dist/apps/data/routes/funding.d.ts.map +1 -0
- package/dist/apps/data/routes/funding.js +383 -0
- package/dist/apps/data/routes/funding.js.map +1 -0
- package/dist/apps/data/routes/receipts.d.ts.map +1 -1
- package/dist/apps/data/routes/receipts.js +2 -8
- package/dist/apps/data/routes/receipts.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts +29 -36
- package/dist/apps/data/routes/rpc.d.ts.map +1 -1
- package/dist/apps/data/routes/rpc.js +29 -1
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/tokenlist.d.ts +36 -36
- package/dist/apps/data/routes/transfers.d.ts +56 -56
- package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
- package/dist/apps/data/routes/webhooks.d.ts +45 -45
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +8 -8
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/docs/App.d.ts +4 -2
- package/dist/apps/docs/App.d.ts.map +1 -1
- package/dist/apps/docs/App.js +27 -95
- package/dist/apps/docs/App.js.map +1 -1
- package/dist/apps/docs/reference.d.ts +21 -0
- package/dist/apps/docs/reference.d.ts.map +1 -0
- package/dist/apps/docs/reference.js +112 -0
- package/dist/apps/docs/reference.js.map +1 -0
- package/dist/apps/index.d.ts +1 -0
- package/dist/apps/index.d.ts.map +1 -1
- package/dist/apps/index.js +1 -0
- package/dist/apps/index.js.map +1 -1
- package/dist/apps/mcp.d.ts +76 -0
- package/dist/apps/mcp.d.ts.map +1 -0
- package/dist/apps/mcp.js +188 -0
- package/dist/apps/mcp.js.map +1 -0
- package/dist/apps/platform/App.d.ts +3005 -728
- package/dist/apps/platform/App.d.ts.map +1 -1
- package/dist/apps/platform/App.js +13 -3
- package/dist/apps/platform/App.js.map +1 -1
- package/dist/apps/platform/Billing.d.ts +279 -0
- package/dist/apps/platform/Billing.d.ts.map +1 -0
- package/dist/apps/platform/Billing.js +477 -0
- package/dist/apps/platform/Billing.js.map +1 -0
- package/dist/apps/platform/index.d.ts +1 -0
- package/dist/apps/platform/index.d.ts.map +1 -1
- package/dist/apps/platform/index.js +1 -0
- package/dist/apps/platform/index.js.map +1 -1
- package/dist/apps/platform/routes/api-keys.d.ts +341 -49
- package/dist/apps/platform/routes/api-keys.d.ts.map +1 -1
- package/dist/apps/platform/routes/api-keys.js +115 -13
- package/dist/apps/platform/routes/api-keys.js.map +1 -1
- package/dist/apps/platform/routes/billing.d.ts +1520 -0
- package/dist/apps/platform/routes/billing.d.ts.map +1 -0
- package/dist/apps/platform/routes/billing.js +795 -0
- package/dist/apps/platform/routes/billing.js.map +1 -0
- package/dist/apps/platform/routes/invitations.d.ts +201 -83
- package/dist/apps/platform/routes/invitations.d.ts.map +1 -1
- package/dist/apps/platform/routes/invitations.js +58 -0
- package/dist/apps/platform/routes/invitations.js.map +1 -1
- package/dist/apps/platform/routes/me.d.ts +17 -17
- package/dist/apps/platform/routes/orgs.d.ts +50 -36
- package/dist/apps/platform/routes/orgs.d.ts.map +1 -1
- package/dist/apps/platform/routes/orgs.js +14 -3
- package/dist/apps/platform/routes/orgs.js.map +1 -1
- package/dist/apps/platform/routes/projects.d.ts +97 -97
- package/dist/apps/platform/routes/scopes.d.ts +107 -0
- package/dist/apps/platform/routes/scopes.d.ts.map +1 -0
- package/dist/apps/platform/routes/scopes.js +66 -0
- package/dist/apps/platform/routes/scopes.js.map +1 -0
- package/dist/apps/platform/routes/usage.d.ts +588 -0
- package/dist/apps/platform/routes/usage.d.ts.map +1 -0
- package/dist/apps/platform/routes/usage.js +391 -0
- package/dist/apps/platform/routes/usage.js.map +1 -0
- package/dist/apps/relay/App.d.ts +4 -1
- package/dist/apps/relay/App.d.ts.map +1 -1
- package/dist/apps/relay/App.js +118 -10
- package/dist/apps/relay/App.js.map +1 -1
- package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
- package/dist/apps/relay/Sponsorships.js +2 -1
- package/dist/apps/relay/Sponsorships.js.map +1 -1
- package/dist/cli/Cli.d.ts +8 -1
- package/dist/cli/Cli.d.ts.map +1 -1
- package/dist/cli/Cli.js +11 -3
- package/dist/cli/Cli.js.map +1 -1
- package/dist/cli/commands/admin.d.ts +4 -0
- package/dist/cli/commands/admin.d.ts.map +1 -1
- package/dist/cli/commands/admin.js +80 -1
- package/dist/cli/commands/admin.js.map +1 -1
- package/dist/db/Db.d.ts +7 -0
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +126 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +79 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +88 -2
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/billingSettings.d.ts +55 -0
- package/dist/db/tables/billingSettings.d.ts.map +1 -0
- package/dist/db/tables/billingSettings.js +70 -0
- package/dist/db/tables/billingSettings.js.map +1 -0
- package/dist/db/tables/invitations.js +2 -2
- package/dist/db/tables/invitations.js.map +1 -1
- package/dist/db/tables/organizations.d.ts +6 -2
- package/dist/db/tables/organizations.d.ts.map +1 -1
- package/dist/db/tables/organizations.js +10 -4
- package/dist/db/tables/organizations.js.map +1 -1
- package/dist/db/tables/projects.js +2 -2
- package/dist/db/tables/projects.js.map +1 -1
- package/dist/db/tables/requestUsage.d.ts +121 -0
- package/dist/db/tables/requestUsage.d.ts.map +1 -0
- package/dist/db/tables/requestUsage.js +150 -0
- package/dist/db/tables/requestUsage.js.map +1 -0
- package/dist/db/tables/sponsoredTransactions.d.ts +119 -0
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +155 -2
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/db/tables/stripeCustomers.d.ts +81 -0
- package/dist/db/tables/stripeCustomers.d.ts.map +1 -0
- package/dist/db/tables/stripeCustomers.js +120 -0
- package/dist/db/tables/stripeCustomers.js.map +1 -0
- package/dist/db/tables/users.js +2 -2
- package/dist/db/tables/users.js.map +1 -1
- package/dist/handlers/internal/sponsorship.d.ts +5 -2
- package/dist/handlers/internal/sponsorship.d.ts.map +1 -1
- package/dist/handlers/internal/sponsorship.js +19 -5
- package/dist/handlers/internal/sponsorship.js.map +1 -1
- package/dist/handlers/internal/utils.d.ts +10 -3
- package/dist/handlers/internal/utils.d.ts.map +1 -1
- package/dist/handlers/internal/utils.js +14 -6
- package/dist/handlers/internal/utils.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/Auth.d.ts +18 -4
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +148 -76
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/Cache.d.ts.map +1 -1
- package/dist/internal/Cache.js +4 -1
- package/dist/internal/Cache.js.map +1 -1
- package/dist/internal/EdgeCache.d.ts +10 -0
- package/dist/internal/EdgeCache.d.ts.map +1 -1
- package/dist/internal/EdgeCache.js +39 -8
- package/dist/internal/EdgeCache.js.map +1 -1
- package/dist/internal/Fees.d.ts +27 -0
- package/dist/internal/Fees.d.ts.map +1 -0
- package/dist/internal/Fees.js +48 -0
- package/dist/internal/Fees.js.map +1 -0
- package/dist/internal/FundingRoutes.d.ts +183 -0
- package/dist/internal/FundingRoutes.d.ts.map +1 -0
- package/dist/internal/FundingRoutes.js +490 -0
- package/dist/internal/FundingRoutes.js.map +1 -0
- package/dist/internal/Id.d.ts +15 -0
- package/dist/internal/Id.d.ts.map +1 -0
- package/dist/internal/Id.js +22 -0
- package/dist/internal/Id.js.map +1 -0
- package/dist/internal/Log.d.ts +2 -0
- package/dist/internal/Log.d.ts.map +1 -1
- package/dist/internal/Log.js +3 -0
- package/dist/internal/Log.js.map +1 -1
- package/dist/internal/OpenApi.d.ts +10 -1
- package/dist/internal/OpenApi.d.ts.map +1 -1
- package/dist/internal/OpenApi.js +8 -1
- package/dist/internal/OpenApi.js.map +1 -1
- package/dist/internal/RateLimit.d.ts +25 -0
- package/dist/internal/RateLimit.d.ts.map +1 -1
- package/dist/internal/RateLimit.js +51 -0
- package/dist/internal/RateLimit.js.map +1 -1
- package/dist/internal/Response.d.ts +1 -1
- package/dist/internal/Response.d.ts.map +1 -1
- package/dist/internal/Schema.d.ts +8 -0
- package/dist/internal/Schema.d.ts.map +1 -1
- package/dist/internal/Schema.js +7 -0
- package/dist/internal/Schema.js.map +1 -1
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +3 -3
- package/dist/internal/Webhooks.js.map +1 -1
- package/package.json +4 -3
- package/src/ApiKey.ts +9 -2
- package/src/ApiKeys.test.ts +51 -0
- package/src/ApiKeys.ts +50 -1
- package/src/App.test.ts +33 -86718
- package/src/App.ts +18 -0
- package/src/Client.test-d.ts +351 -0
- package/src/Scope.ts +16 -3
- package/src/admin/apps/scopes.test.ts +6 -0
- package/src/admin/apps/scopes.ts +3 -0
- package/src/admin/ui/src/components/Page.tsx +2 -3
- package/src/admin/ui/src/routes/__root.tsx +2 -2
- package/src/admin/ui/src/routes/api-keys.tsx +2 -2
- package/src/admin/ui.generated.ts +13 -13
- package/src/analytics/Analytics.test.ts +527 -0
- package/src/analytics/Analytics.ts +318 -0
- package/src/analytics/tables/requestEvents.ts +523 -0
- package/src/apps/data/App.test.ts +72 -0
- package/src/apps/data/App.ts +35 -0
- package/src/apps/data/routes/funding.test.ts +214 -0
- package/src/apps/data/routes/funding.ts +584 -0
- package/src/apps/data/routes/receipts.ts +2 -9
- package/src/apps/data/routes/rpc.test.ts +78 -2
- package/src/apps/data/routes/rpc.ts +31 -1
- package/src/apps/data/routes/webhooks.test.ts +1 -1
- package/src/apps/data/routes/webhooks.ts +11 -8
- package/src/apps/docs/App.ts +29 -100
- package/src/apps/docs/reference.ts +132 -0
- package/src/apps/index.ts +1 -0
- package/src/apps/mcp.test.ts +206 -0
- package/src/apps/mcp.ts +250 -0
- package/src/apps/platform/App.ts +43 -4
- package/src/apps/platform/Billing.test.ts +530 -0
- package/src/apps/platform/Billing.ts +682 -0
- package/src/apps/platform/index.ts +1 -0
- package/src/apps/platform/routes/api-keys.test.ts +172 -9
- package/src/apps/platform/routes/api-keys.ts +136 -20
- package/src/apps/platform/routes/billing.test.ts +1100 -0
- package/src/apps/platform/routes/billing.ts +966 -0
- package/src/apps/platform/routes/invitations.test.ts +55 -7
- package/src/apps/platform/routes/invitations.ts +68 -0
- package/src/apps/platform/routes/me.test.ts +230 -0
- package/src/apps/platform/routes/orgs.ts +26 -4
- package/src/apps/platform/routes/scopes.test.ts +29 -0
- package/src/apps/platform/routes/scopes.ts +85 -0
- package/src/apps/platform/routes/usage.test.ts +422 -0
- package/src/apps/platform/routes/usage.ts +527 -0
- package/src/apps/relay/App.test.ts +457 -8
- package/src/apps/relay/App.ts +120 -12
- package/src/apps/relay/Sponsorships.test.ts +1 -1
- package/src/apps/relay/Sponsorships.ts +2 -1
- package/src/cli/Cli.test.ts +25 -2
- package/src/cli/Cli.ts +17 -2
- package/src/cli/commands/admin.test.ts +83 -0
- package/src/cli/commands/admin.ts +89 -1
- package/src/db/Db.ts +137 -0
- package/src/db/Schema.ts +104 -2
- package/src/db/tables/billingSettings.test.ts +87 -0
- package/src/db/tables/billingSettings.ts +108 -0
- package/src/db/tables/invitations.ts +2 -2
- package/src/db/tables/organizations.test.ts +1 -1
- package/src/db/tables/organizations.ts +15 -5
- package/src/db/tables/projects.ts +2 -2
- package/src/db/tables/requestUsage.ts +231 -0
- package/src/db/tables/sponsoredTransactions.test.ts +108 -1
- package/src/db/tables/sponsoredTransactions.ts +230 -2
- package/src/db/tables/stripeCustomers.test.ts +109 -0
- package/src/db/tables/stripeCustomers.ts +162 -0
- package/src/db/tables/users.ts +2 -2
- package/src/handlers/internal/sponsorship.ts +34 -7
- package/src/handlers/internal/utils.ts +19 -7
- package/src/index.ts +1 -0
- package/src/internal/Auth.test.ts +175 -25
- package/src/internal/Auth.ts +175 -86
- package/src/internal/Cache.ts +5 -1
- package/src/internal/EdgeCache.test.ts +27 -4
- package/src/internal/EdgeCache.ts +47 -9
- package/src/internal/Fees.test.ts +32 -0
- package/src/internal/Fees.ts +51 -0
- package/src/internal/FundingRoutes.ts +583 -0
- package/src/internal/Id.test.ts +17 -0
- package/src/internal/Id.ts +24 -0
- package/src/internal/Log.test.ts +1 -0
- package/src/internal/Log.ts +5 -0
- package/src/internal/OpenApi.ts +12 -1
- package/src/internal/RateLimit.test.ts +84 -0
- package/src/internal/RateLimit.ts +70 -0
- package/src/internal/Response.ts +1 -1
- package/src/internal/Schema.ts +8 -0
- package/src/internal/Webhooks.test.ts +3 -3
- 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
|
+
}
|