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,682 @@
|
|
|
1
|
+
import { Hash, Hex, Value } from 'ox'
|
|
2
|
+
import type Stripe from 'stripe'
|
|
3
|
+
|
|
4
|
+
import type * as Store from '../../internal/Store.js'
|
|
5
|
+
import * as ApiKeys from '../../ApiKeys.js'
|
|
6
|
+
import type * as BillingSettings from '../../db/tables/billingSettings.js'
|
|
7
|
+
import * as Db from '../../db/Db.js'
|
|
8
|
+
import * as Fees from '../../internal/Fees.js'
|
|
9
|
+
import * as RequestUsage from '../../db/tables/requestUsage.js'
|
|
10
|
+
import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js'
|
|
11
|
+
import * as StripeCustomers from '../../db/tables/stripeCustomers.js'
|
|
12
|
+
import * as Viem from '../../internal/Viem.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the organization has any active billing source — the production
|
|
16
|
+
* sponsorship gate's read. Storage stays one table per source; this resolver
|
|
17
|
+
* is the union across them (Stripe today).
|
|
18
|
+
*
|
|
19
|
+
* @param db - The database.
|
|
20
|
+
* @param orgId - The organization id (`org_…`).
|
|
21
|
+
* @returns Whether any billing source is active.
|
|
22
|
+
*/
|
|
23
|
+
export async function active(
|
|
24
|
+
db: Db.Db,
|
|
25
|
+
orgId: string,
|
|
26
|
+
environment: StripeCustomers.Record['environment'] = 'production',
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
return (await status(db, orgId, environment)) === 'active'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The organization's billing status derived from its stored billing source,
|
|
33
|
+
* or undefined when it has none. The sponsorship gate maps non-`active`
|
|
34
|
+
* statuses to distinct refusal reasons.
|
|
35
|
+
*
|
|
36
|
+
* @param db - The database.
|
|
37
|
+
* @param orgId - The organization id (`org_…`).
|
|
38
|
+
* @returns The stored status, or undefined.
|
|
39
|
+
*/
|
|
40
|
+
export async function status(
|
|
41
|
+
db: Db.Db,
|
|
42
|
+
orgId: string,
|
|
43
|
+
environment: StripeCustomers.Record['environment'] = 'production',
|
|
44
|
+
): Promise<StripeCustomers.Record['status'] | undefined> {
|
|
45
|
+
const record = await StripeCustomers.get(db, orgId, environment)
|
|
46
|
+
return record?.status
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Converts a decimal currency string (e.g. `'0.50'`) to fee-token base units.
|
|
51
|
+
*
|
|
52
|
+
* @param amount - The decimal string; at most {@link Fees.tokenDecimals} fraction digits.
|
|
53
|
+
* @returns The amount in base units.
|
|
54
|
+
*/
|
|
55
|
+
export function toBaseUnits(amount: string): bigint {
|
|
56
|
+
return Value.from(amount, Fees.tokenDecimals)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Converts fee-token base units to a decimal currency string with trailing
|
|
61
|
+
* zeros trimmed (e.g. `350000n` → `'0.35'`).
|
|
62
|
+
*
|
|
63
|
+
* @param units - The amount in base units.
|
|
64
|
+
* @returns The decimal string.
|
|
65
|
+
*/
|
|
66
|
+
export function fromBaseUnits(units: bigint): string {
|
|
67
|
+
return Value.format(units, Fees.tokenDecimals)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Start of the limit window containing `now` (ISO 8601); `month` is the UTC
|
|
72
|
+
* calendar month.
|
|
73
|
+
*
|
|
74
|
+
* @param period - The configured window.
|
|
75
|
+
* @param now - Reference time; defaults to now.
|
|
76
|
+
* @returns The window start.
|
|
77
|
+
*/
|
|
78
|
+
export function periodStart(period: BillingSettings.Record['period'], now = new Date()): string {
|
|
79
|
+
// Single-member today; a switch lands with the second period.
|
|
80
|
+
void period
|
|
81
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A metered billing dimension: a Stripe meter plus its metered monthly price.
|
|
86
|
+
* Each kind bills as a distinct invoice line item on one shared subscription.
|
|
87
|
+
*/
|
|
88
|
+
export type MeterKind = 'apiRequests' | 'feePayerSpend'
|
|
89
|
+
|
|
90
|
+
/** Descriptor for one metered dimension. */
|
|
91
|
+
export type MeterDescriptor = {
|
|
92
|
+
/** Human-readable meter/product display name. */
|
|
93
|
+
displayName: string
|
|
94
|
+
/** Meter event name; the Stripe meter aggregates `sum(value)` per customer per period. */
|
|
95
|
+
eventName: string
|
|
96
|
+
/** Versioned price lookup key; changing economics ships a new key, never a mutated price. */
|
|
97
|
+
lookupKey: string
|
|
98
|
+
/** Price per meter unit in cents (`unit_amount_decimal`). */
|
|
99
|
+
unitAmountDecimal: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The metered dimensions and their unit economics. A price pins cents per meter
|
|
104
|
+
* unit; changing them ships a new versioned `lookupKey`, never a mutated price.
|
|
105
|
+
*/
|
|
106
|
+
export const meters = {
|
|
107
|
+
apiRequests: {
|
|
108
|
+
displayName: 'API Requests',
|
|
109
|
+
eventName: 'api_request_count',
|
|
110
|
+
lookupKey: 'api-request-count-v1',
|
|
111
|
+
// $0.0001/request → 0.01¢ per unit; the meter value is the integer request count.
|
|
112
|
+
unitAmountDecimal: '0.01',
|
|
113
|
+
},
|
|
114
|
+
feePayerSpend: {
|
|
115
|
+
displayName: 'Sponsor Usage',
|
|
116
|
+
eventName: 'fee_payer_spend',
|
|
117
|
+
lookupKey: 'fee-payer-spend-v1',
|
|
118
|
+
// Meter values are 6-decimal base units: 100¢ per 1e6 units.
|
|
119
|
+
unitAmountDecimal: '0.0001',
|
|
120
|
+
},
|
|
121
|
+
} as const satisfies Record<MeterKind, MeterDescriptor>
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The metered dimensions billed in an environment. Production bills sponsorship
|
|
125
|
+
* spend and request counts; sandbox bills request counts only.
|
|
126
|
+
*
|
|
127
|
+
* @param environment - The environment.
|
|
128
|
+
* @returns The metered dimensions for that environment.
|
|
129
|
+
*/
|
|
130
|
+
export function kindsFor(environment: StripeCustomers.Record['environment']): readonly MeterKind[] {
|
|
131
|
+
return environment === 'production' ? ['feePayerSpend', 'apiRequests'] : ['apiRequests']
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Back-compat: the fee-payer meter event name. */
|
|
135
|
+
export const meterEventName = meters.feePayerSpend.eventName
|
|
136
|
+
|
|
137
|
+
/** Back-compat: the fee-payer metered price lookup key. */
|
|
138
|
+
export const priceLookupKey = meters.feePayerSpend.lookupKey
|
|
139
|
+
|
|
140
|
+
/** Fixture promises memoized per client, per kind; a failed ensure retries on the next call. */
|
|
141
|
+
const fixturesByClient = new WeakMap<Stripe, Map<MeterKind, Promise<ensureFixtures.Fixtures>>>()
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Ensures the account-level metering fixtures exist for a kind: its meter and
|
|
145
|
+
* metered monthly price. Idempotent by lookup (meter `event_name`, price
|
|
146
|
+
* `lookup_key`) and memoized per `(client, kind)`.
|
|
147
|
+
*
|
|
148
|
+
* @param stripe - The Stripe client.
|
|
149
|
+
* @param kind - The metered dimension; defaults to fee-payer spend.
|
|
150
|
+
* @returns The fixture ids.
|
|
151
|
+
*/
|
|
152
|
+
export function ensureFixtures(
|
|
153
|
+
stripe: Stripe,
|
|
154
|
+
kind: MeterKind = 'feePayerSpend',
|
|
155
|
+
): Promise<ensureFixtures.Fixtures> {
|
|
156
|
+
let byKind = fixturesByClient.get(stripe)
|
|
157
|
+
if (!byKind) {
|
|
158
|
+
byKind = new Map()
|
|
159
|
+
fixturesByClient.set(stripe, byKind)
|
|
160
|
+
}
|
|
161
|
+
const pending = byKind.get(kind)
|
|
162
|
+
if (pending) return pending
|
|
163
|
+
const descriptor = meters[kind]
|
|
164
|
+
const created = (async () => {
|
|
165
|
+
const meterList = await stripe.billing.meters.list({ limit: 100, status: 'active' })
|
|
166
|
+
const meter =
|
|
167
|
+
meterList.data.find((meter) => meter.event_name === descriptor.eventName) ??
|
|
168
|
+
(await stripe.billing.meters.create({
|
|
169
|
+
customer_mapping: { event_payload_key: 'stripe_customer_id', type: 'by_id' },
|
|
170
|
+
default_aggregation: { formula: 'sum' },
|
|
171
|
+
display_name: descriptor.displayName,
|
|
172
|
+
event_name: descriptor.eventName,
|
|
173
|
+
value_settings: { event_payload_key: 'value' },
|
|
174
|
+
}))
|
|
175
|
+
const prices = await stripe.prices.list({ limit: 1, lookup_keys: [descriptor.lookupKey] })
|
|
176
|
+
const price =
|
|
177
|
+
prices.data[0] ??
|
|
178
|
+
(await stripe.prices.create({
|
|
179
|
+
billing_scheme: 'per_unit',
|
|
180
|
+
currency: 'usd',
|
|
181
|
+
lookup_key: descriptor.lookupKey,
|
|
182
|
+
product_data: { name: descriptor.displayName },
|
|
183
|
+
recurring: { interval: 'month', meter: meter.id, usage_type: 'metered' },
|
|
184
|
+
unit_amount_decimal: descriptor.unitAmountDecimal,
|
|
185
|
+
}))
|
|
186
|
+
return { meterId: meter.id, priceId: price.id }
|
|
187
|
+
})()
|
|
188
|
+
byKind.set(kind, created)
|
|
189
|
+
created.catch(() => byKind.delete(kind))
|
|
190
|
+
return created
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export declare namespace ensureFixtures {
|
|
194
|
+
/** Account-level metering fixture ids. */
|
|
195
|
+
type Fixtures = {
|
|
196
|
+
/** Spend meter id (`mtr_…`). */
|
|
197
|
+
meterId: string
|
|
198
|
+
/** Metered price id (`price_…`). */
|
|
199
|
+
priceId: string
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Subscription statuses that no longer bill; a new subscription must be created. */
|
|
204
|
+
const terminalStatuses = ['canceled', 'incomplete_expired']
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Ensures the customer holds exactly one live subscription carrying one metered
|
|
208
|
+
* item per requested {@link MeterKind}, creating the subscription or adding
|
|
209
|
+
* missing items as needed. Metered usage bills the customer's default payment
|
|
210
|
+
* method monthly.
|
|
211
|
+
*
|
|
212
|
+
* Fail-closed on more than one live managed subscription: duplicates would
|
|
213
|
+
* double-bill the same meter usage, so the caller must reconcile manually.
|
|
214
|
+
*
|
|
215
|
+
* @param stripe - The Stripe client.
|
|
216
|
+
* @param customerId - The Stripe customer id.
|
|
217
|
+
* @param kinds - The metered dimensions to ensure; defaults to fee-payer spend.
|
|
218
|
+
* @returns The per-kind creation state and whether a subscription was created.
|
|
219
|
+
*/
|
|
220
|
+
export async function ensureSubscription(
|
|
221
|
+
stripe: Stripe,
|
|
222
|
+
customerId: string,
|
|
223
|
+
kinds: readonly MeterKind[] = ['feePayerSpend'],
|
|
224
|
+
): Promise<ensureSubscription.Result> {
|
|
225
|
+
const requested = kinds.length ? kinds : (['feePayerSpend'] as const)
|
|
226
|
+
// Resolve every requested kind's price; managed prices are what we reconcile.
|
|
227
|
+
const fixtures = new Map<MeterKind, ensureFixtures.Fixtures>()
|
|
228
|
+
for (const kind of requested) fixtures.set(kind, await ensureFixtures(stripe, kind))
|
|
229
|
+
const priceToKind = new Map<string, MeterKind>()
|
|
230
|
+
for (const [kind, fixture] of fixtures) priceToKind.set(fixture.priceId, kind)
|
|
231
|
+
|
|
232
|
+
const subscriptions = await stripe.subscriptions.list({
|
|
233
|
+
customer: customerId,
|
|
234
|
+
limit: 100,
|
|
235
|
+
status: 'all',
|
|
236
|
+
})
|
|
237
|
+
const liveManaged = subscriptions.data.filter(
|
|
238
|
+
(subscription) =>
|
|
239
|
+
!terminalStatuses.includes(subscription.status) &&
|
|
240
|
+
subscription.items.data.some((item) => priceToKind.has(item.price.id)),
|
|
241
|
+
)
|
|
242
|
+
if (liveManaged.length > 1) throw new DuplicateSubscriptionError(customerId)
|
|
243
|
+
|
|
244
|
+
const items: Record<string, ensureSubscription.ItemResult> = {}
|
|
245
|
+
const subscription = liveManaged[0]
|
|
246
|
+
if (subscription) {
|
|
247
|
+
const present = new Set(
|
|
248
|
+
subscription.items.data
|
|
249
|
+
.map((item) => priceToKind.get(item.price.id))
|
|
250
|
+
.filter((kind): kind is MeterKind => kind !== undefined),
|
|
251
|
+
)
|
|
252
|
+
for (const kind of requested) {
|
|
253
|
+
if (present.has(kind)) {
|
|
254
|
+
items[kind] = { created: false }
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
// Add the missing item; the idempotency key absorbs concurrent adds.
|
|
258
|
+
await stripe.subscriptionItems.create(
|
|
259
|
+
{ price: fixtures.get(kind)!.priceId, subscription: subscription.id },
|
|
260
|
+
{ idempotencyKey: `${customerId}-item-${meters[kind].lookupKey}` },
|
|
261
|
+
)
|
|
262
|
+
items[kind] = { created: true }
|
|
263
|
+
}
|
|
264
|
+
return { created: false, items }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The idempotency key absorbs concurrent activations: a duplicate metered
|
|
268
|
+
// subscription would double-bill the same meter usage.
|
|
269
|
+
await stripe.subscriptions.create(
|
|
270
|
+
{
|
|
271
|
+
collection_method: 'charge_automatically',
|
|
272
|
+
customer: customerId,
|
|
273
|
+
items: requested.map((kind) => ({ price: fixtures.get(kind)!.priceId })),
|
|
274
|
+
},
|
|
275
|
+
{ idempotencyKey: `${customerId}-metered-sub-v2` },
|
|
276
|
+
)
|
|
277
|
+
for (const kind of requested) items[kind] = { created: true }
|
|
278
|
+
return { created: true, items }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export declare namespace ensureSubscription {
|
|
282
|
+
/** Per-kind item reconciliation state. */
|
|
283
|
+
type ItemResult = {
|
|
284
|
+
/** Whether this call added the item (a fresh item bills nothing before its start). */
|
|
285
|
+
created: boolean
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Outcome of {@link ensureSubscription}. */
|
|
289
|
+
type Result = {
|
|
290
|
+
/** Whether this call created the subscription (none was live before). */
|
|
291
|
+
created: boolean
|
|
292
|
+
/** Per-kind item state, keyed by {@link MeterKind}. */
|
|
293
|
+
items: Partial<Record<MeterKind, ItemResult>>
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** More than one live managed subscription for a customer; requires manual reconcile. */
|
|
298
|
+
export class DuplicateSubscriptionError extends Error {
|
|
299
|
+
constructor(customerId: string) {
|
|
300
|
+
super(`multiple live metered subscriptions for customer ${customerId}`)
|
|
301
|
+
this.name = 'Billing.DuplicateSubscriptionError'
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Creates the metering job: each tick reports finalized, unreported billable
|
|
307
|
+
* mainnet spend to the Stripe meter and marks rows reported. A host runtime
|
|
308
|
+
* drives {@link createReporter.Reporter.tick} on a timer, like the
|
|
309
|
+
* sponsorship finalizer.
|
|
310
|
+
*/
|
|
311
|
+
export function createReporter(options: createReporter.Options): createReporter.Reporter {
|
|
312
|
+
const { batchSize = 100, chainIds = [Viem.chainId.mainnet], db, stripe } = options
|
|
313
|
+
return {
|
|
314
|
+
async tick() {
|
|
315
|
+
// Resolve per tick so Workers factories build a fresh Hyperdrive-pooled
|
|
316
|
+
// connection each run.
|
|
317
|
+
const database = Db.get(db)
|
|
318
|
+
const rows = await SponsoredTransactions.listUnreported(database, {
|
|
319
|
+
chainIds,
|
|
320
|
+
limit: batchSize,
|
|
321
|
+
})
|
|
322
|
+
// One customer lookup and one subscription ensure per org per tick.
|
|
323
|
+
const customers = new Map<string, StripeCustomers.Record | undefined>()
|
|
324
|
+
const subscriptions = new Map<string, ensureSubscription.Result>()
|
|
325
|
+
let failed = 0
|
|
326
|
+
let reported = 0
|
|
327
|
+
let skipped = 0
|
|
328
|
+
for (const row of rows) {
|
|
329
|
+
// One poisoned row must not stall the queue: it stays unmarked for
|
|
330
|
+
// retry while the rest keep reporting.
|
|
331
|
+
try {
|
|
332
|
+
let customer = customers.get(row.orgId)
|
|
333
|
+
if (!customers.has(row.orgId)) {
|
|
334
|
+
customer = await StripeCustomers.get(database, row.orgId)
|
|
335
|
+
customers.set(row.orgId, customer)
|
|
336
|
+
}
|
|
337
|
+
if (customer && customer.status !== 'canceled') {
|
|
338
|
+
// Self-heal: guarantee the metered subscription before reporting;
|
|
339
|
+
// meter events without one never reach an invoice. Pass the full
|
|
340
|
+
// production kind set so concurrent creates share idempotency.
|
|
341
|
+
let subscription = subscriptions.get(row.orgId)
|
|
342
|
+
if (!subscription) {
|
|
343
|
+
subscription = await ensureSubscription(
|
|
344
|
+
stripe,
|
|
345
|
+
customer.stripeCustomerId,
|
|
346
|
+
kindsFor('production'),
|
|
347
|
+
)
|
|
348
|
+
subscriptions.set(row.orgId, subscription)
|
|
349
|
+
}
|
|
350
|
+
// Meter timestamps must fall inside Stripe's 35-day ingestion
|
|
351
|
+
// window; older backlog bills into the current period instead. A
|
|
352
|
+
// just-created fee-payer item bills nothing before its start, so
|
|
353
|
+
// its backlog stamps at now too.
|
|
354
|
+
const finalizedAt = new Date(row.finalizedAt!).getTime()
|
|
355
|
+
const recent = Date.now() - finalizedAt < 34 * 24 * 60 * 60 * 1000
|
|
356
|
+
const stamp = recent && !subscription.items.feePayerSpend?.created
|
|
357
|
+
// The row id makes short-window retries idempotent on Stripe's
|
|
358
|
+
// side; `meter_reported_at` is the durable exactly-once guard.
|
|
359
|
+
await stripe.billing.meterEvents.create({
|
|
360
|
+
event_name: meterEventName,
|
|
361
|
+
identifier: row.id,
|
|
362
|
+
payload: { stripe_customer_id: customer.stripeCustomerId, value: row.feeAmount! },
|
|
363
|
+
...(stamp ? { timestamp: Math.floor(finalizedAt / 1000) } : {}),
|
|
364
|
+
})
|
|
365
|
+
reported++
|
|
366
|
+
} else {
|
|
367
|
+
// Deleted orgs and Stripe-deleted (canceled) customers keep their
|
|
368
|
+
// ledger rows; nobody remains to invoice.
|
|
369
|
+
skipped++
|
|
370
|
+
}
|
|
371
|
+
// Mark only after the Stripe ack (or a definitive no-customer skip).
|
|
372
|
+
await SponsoredTransactions.markReported(database, row.id, new Date().toISOString())
|
|
373
|
+
} catch (error) {
|
|
374
|
+
failed++
|
|
375
|
+
console.error(`Billing.createReporter: reporting row ${row.id} failed`, error)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { failed, reported, skipped }
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export declare namespace createReporter {
|
|
384
|
+
/** Options for {@link createReporter}. */
|
|
385
|
+
type Options = {
|
|
386
|
+
/** Maximum rows reported per tick. @default 100 */
|
|
387
|
+
batchSize?: number | undefined
|
|
388
|
+
/** Chains whose spend is metered. @default mainnet */
|
|
389
|
+
chainIds?: readonly number[] | undefined
|
|
390
|
+
/** Database holding sponsorship rows, or a factory resolved per tick (Workers/Hyperdrive). */
|
|
391
|
+
db: Db.Source
|
|
392
|
+
/** Stripe client used to send meter events. */
|
|
393
|
+
stripe: Stripe
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** A scheduled metering job. */
|
|
397
|
+
type Reporter = {
|
|
398
|
+
/** Runs one reporting pass. Failed rows stay unmarked and retry next tick. */
|
|
399
|
+
tick(): Promise<{ failed: number; reported: number; skipped: number }>
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Default settlement delay: only seal buckets older than this, absorbing normal ingest lag. */
|
|
404
|
+
const defaultSettlementDelayMs = 15 * 60 * 1000
|
|
405
|
+
|
|
406
|
+
/** Default correction window: how far back a tick reconciles late-arriving deltas. */
|
|
407
|
+
const defaultWindowMs = 48 * 60 * 60 * 1000
|
|
408
|
+
|
|
409
|
+
/** One hour in milliseconds. */
|
|
410
|
+
const hourMs = 60 * 60 * 1000
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Creates the request-metered billing job: each tick reports deduped billable
|
|
414
|
+
* request counts to the `api_request_count` meter, per environment, using that
|
|
415
|
+
* environment's Stripe client. Exactly-once from the request analytics stream
|
|
416
|
+
* via a Postgres outbox with frozen counts:
|
|
417
|
+
*
|
|
418
|
+
* - Deduped counts come from `request_events` (`uniqExact(request_id)`), never
|
|
419
|
+
* a raw `count()`, so queue retries never over-bill.
|
|
420
|
+
* - Sandbox rows bill only when billing was active at request time
|
|
421
|
+
* (`billing_active = 1`); production rows always bill.
|
|
422
|
+
* - Each tick first re-sends any `pending` events (crash recovery, idempotent
|
|
423
|
+
* on `identifier`), then seals new additive deltas for buckets older than the
|
|
424
|
+
* settlement delay whose count has grown since the last report.
|
|
425
|
+
*
|
|
426
|
+
* A host runtime drives {@link createRequestUsageReporter.Reporter.tick} on a
|
|
427
|
+
* timer, like the fee-payer meter reporter.
|
|
428
|
+
*/
|
|
429
|
+
export function createRequestUsageReporter(
|
|
430
|
+
options: createRequestUsageReporter.Options,
|
|
431
|
+
): createRequestUsageReporter.Reporter {
|
|
432
|
+
const {
|
|
433
|
+
db,
|
|
434
|
+
now = () => Date.now(),
|
|
435
|
+
readCounts,
|
|
436
|
+
sandboxStripe,
|
|
437
|
+
settlementDelayMs = defaultSettlementDelayMs,
|
|
438
|
+
stripe,
|
|
439
|
+
windowMs = defaultWindowMs,
|
|
440
|
+
} = options
|
|
441
|
+
const clients: { client: Stripe; environment: StripeCustomers.Record['environment'] }[] = [
|
|
442
|
+
{ client: stripe, environment: 'production' },
|
|
443
|
+
...(sandboxStripe ? [{ client: sandboxStripe, environment: 'sandbox' as const }] : []),
|
|
444
|
+
]
|
|
445
|
+
return {
|
|
446
|
+
async tick() {
|
|
447
|
+
const database = Db.get(db)
|
|
448
|
+
let failed = 0
|
|
449
|
+
let reported = 0
|
|
450
|
+
let skipped = 0
|
|
451
|
+
for (const { client, environment } of clients) {
|
|
452
|
+
const nowMs = now()
|
|
453
|
+
const sealCutoff = startOfHour(nowMs - settlementDelayMs)
|
|
454
|
+
const windowStart = new Date(new Date(sealCutoff).getTime() - windowMs).toISOString()
|
|
455
|
+
|
|
456
|
+
// Pass A — crash recovery: re-send frozen pending events. Idempotent on
|
|
457
|
+
// `identifier` within Stripe's dedup window; the minutely cadence
|
|
458
|
+
// re-marks well inside it. Buckets that still fail stay blocked so the
|
|
459
|
+
// seal pass never stacks a second delta on them.
|
|
460
|
+
const blocked = new Set<string>()
|
|
461
|
+
for (const event of await RequestUsage.listPending(database, environment)) {
|
|
462
|
+
try {
|
|
463
|
+
await client.billing.meterEvents.create({
|
|
464
|
+
event_name: meters.apiRequests.eventName,
|
|
465
|
+
identifier: event.identifier,
|
|
466
|
+
payload: {
|
|
467
|
+
stripe_customer_id: event.stripeCustomerId,
|
|
468
|
+
value: String(event.deltaCount),
|
|
469
|
+
},
|
|
470
|
+
})
|
|
471
|
+
if (await RequestUsage.settle(database, event.identifier, new Date().toISOString()))
|
|
472
|
+
reported++
|
|
473
|
+
} catch (error) {
|
|
474
|
+
failed++
|
|
475
|
+
blocked.add(bucketKey(event.orgId, event.bucketStart))
|
|
476
|
+
await RequestUsage.markError(database, event.identifier, String(error))
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Pass B — seal new deltas for closed buckets whose count has grown.
|
|
481
|
+
const counts = await readCounts({ environment, from: windowStart, to: sealCutoff })
|
|
482
|
+
const customers = new Map<string, StripeCustomers.Record | undefined>()
|
|
483
|
+
const subscriptions = new Map<string, ensureSubscription.Result>()
|
|
484
|
+
for (const { bucketStart, count, orgId } of counts) {
|
|
485
|
+
const key = { bucketStart, environment, orgId }
|
|
486
|
+
if (blocked.has(bucketKey(orgId, bucketStart))) continue
|
|
487
|
+
try {
|
|
488
|
+
const watermark = await RequestUsage.getBucket(database, key)
|
|
489
|
+
const delta = count - (watermark?.reportedCount ?? 0)
|
|
490
|
+
if (delta <= 0) continue
|
|
491
|
+
|
|
492
|
+
let customer = customers.get(orgId)
|
|
493
|
+
if (!customers.has(orgId)) {
|
|
494
|
+
customer = await StripeCustomers.get(database, orgId, environment)
|
|
495
|
+
customers.set(orgId, customer)
|
|
496
|
+
}
|
|
497
|
+
if (!customer || customer.status === 'canceled') {
|
|
498
|
+
skipped++
|
|
499
|
+
continue
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let subscription = subscriptions.get(orgId)
|
|
503
|
+
if (!subscription) {
|
|
504
|
+
subscription = await ensureSubscription(
|
|
505
|
+
client,
|
|
506
|
+
customer.stripeCustomerId,
|
|
507
|
+
kindsFor(environment),
|
|
508
|
+
)
|
|
509
|
+
subscriptions.set(orgId, subscription)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const sequence = await RequestUsage.countReported(database, key)
|
|
513
|
+
const identifier = usageIdentifier(environment, orgId, bucketStart, sequence)
|
|
514
|
+
const inserted = await RequestUsage.insertPending(database, {
|
|
515
|
+
...key,
|
|
516
|
+
deltaCount: delta,
|
|
517
|
+
identifier,
|
|
518
|
+
sequence,
|
|
519
|
+
stripeCustomerId: customer.stripeCustomerId,
|
|
520
|
+
})
|
|
521
|
+
// A concurrent tick already claimed this delta; leave it to that run.
|
|
522
|
+
if (!inserted) continue
|
|
523
|
+
|
|
524
|
+
// Backdate to the bucket hour unless the item was just created (it
|
|
525
|
+
// bills nothing before its start, so its backlog stamps at now).
|
|
526
|
+
const stamp = !subscription.items.apiRequests?.created
|
|
527
|
+
await client.billing.meterEvents.create({
|
|
528
|
+
event_name: meters.apiRequests.eventName,
|
|
529
|
+
identifier,
|
|
530
|
+
payload: { stripe_customer_id: customer.stripeCustomerId, value: String(delta) },
|
|
531
|
+
...(stamp ? { timestamp: Math.floor(new Date(bucketStart).getTime() / 1000) } : {}),
|
|
532
|
+
})
|
|
533
|
+
if (await RequestUsage.settle(database, identifier, new Date().toISOString()))
|
|
534
|
+
reported++
|
|
535
|
+
} catch (error) {
|
|
536
|
+
failed++
|
|
537
|
+
console.error(
|
|
538
|
+
`Billing.createRequestUsageReporter: reporting ${environment} bucket ${orgId}/${bucketStart} failed`,
|
|
539
|
+
error,
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return { failed, reported, skipped }
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export declare namespace createRequestUsageReporter {
|
|
550
|
+
/** Deduped billable request count for one `(org, hour)` bucket. */
|
|
551
|
+
type Count = {
|
|
552
|
+
/** UTC hour start (ISO 8601). */
|
|
553
|
+
bucketStart: string
|
|
554
|
+
/** Deduped billable request count. */
|
|
555
|
+
count: number
|
|
556
|
+
/** Organization id (`org_…`). */
|
|
557
|
+
orgId: string
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Options for {@link createRequestUsageReporter}. */
|
|
561
|
+
type Options = {
|
|
562
|
+
/** Database holding the outbox/watermarks, or a factory resolved per tick (Workers/Hyperdrive). */
|
|
563
|
+
db: Db.Source
|
|
564
|
+
/** Clock seam for tests; defaults to `Date.now`. */
|
|
565
|
+
now?: (() => number) | undefined
|
|
566
|
+
/** Reads deduped billable counts per `(org, hour)` for an environment window. */
|
|
567
|
+
readCounts: (options: {
|
|
568
|
+
environment: StripeCustomers.Record['environment']
|
|
569
|
+
from: string
|
|
570
|
+
to: string
|
|
571
|
+
}) => Promise<readonly Count[]>
|
|
572
|
+
/** Sandbox (test-mode) Stripe client; omit to skip sandbox billing. */
|
|
573
|
+
sandboxStripe?: Stripe | undefined
|
|
574
|
+
/** Only seal buckets older than this. @default 15min */
|
|
575
|
+
settlementDelayMs?: number | undefined
|
|
576
|
+
/** Production Stripe client. */
|
|
577
|
+
stripe: Stripe
|
|
578
|
+
/** How far back a tick reconciles late deltas. @default 48h */
|
|
579
|
+
windowMs?: number | undefined
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** A scheduled request-metering job. */
|
|
583
|
+
type Reporter = {
|
|
584
|
+
/** Runs one reporting pass across production (and sandbox when configured). */
|
|
585
|
+
tick(): Promise<{ failed: number; reported: number; skipped: number }>
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Composite key for the in-tick blocked-bucket set. */
|
|
590
|
+
function bucketKey(orgId: string, bucketStart: string) {
|
|
591
|
+
return `${orgId}|${bucketStart}`
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Start of the UTC hour containing `ms` (ISO 8601). */
|
|
595
|
+
function startOfHour(ms: number) {
|
|
596
|
+
return new Date(Math.floor(ms / hourMs) * hourMs).toISOString()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Deterministic meter-event identifier, bounded under Stripe's 100-char limit:
|
|
601
|
+
* `rq_${env}_${sha(orgId)[:12]}_${bucketEpochHour}_${sequence}`.
|
|
602
|
+
*/
|
|
603
|
+
function usageIdentifier(
|
|
604
|
+
environment: StripeCustomers.Record['environment'],
|
|
605
|
+
orgId: string,
|
|
606
|
+
bucketStart: string,
|
|
607
|
+
sequence: number,
|
|
608
|
+
) {
|
|
609
|
+
const hash = Hash.keccak256(Hex.fromString(orgId)).slice(2, 14)
|
|
610
|
+
const epochHour = Math.floor(new Date(bucketStart).getTime() / hourMs)
|
|
611
|
+
return `rq_${environment}_${hash}_${epochHour}_${sequence}`
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Every organization's billing-active flag in an environment — the driver set
|
|
616
|
+
* for reconciling cached snapshots against this table. Provider-agnostic seam:
|
|
617
|
+
* callers reconcile against `{ orgId, active }`, never the Stripe source shape.
|
|
618
|
+
*
|
|
619
|
+
* @param db - The database.
|
|
620
|
+
* @param environment - The environment to enumerate.
|
|
621
|
+
* @returns One `{ orgId, active }` per billing source in the environment.
|
|
622
|
+
*/
|
|
623
|
+
export async function listActive(
|
|
624
|
+
db: Db.Db,
|
|
625
|
+
environment: StripeCustomers.Record['environment'],
|
|
626
|
+
): Promise<{ active: boolean; orgId: string }[]> {
|
|
627
|
+
const records = await StripeCustomers.listByEnvironment(db, environment)
|
|
628
|
+
return records.map((record) => ({ active: record.status === 'active', orgId: record.orgId }))
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Creates the snapshot reconciler: each tick re-stamps the `billingActive`
|
|
633
|
+
* flag cached on API-key records from the authoritative billing table, so a
|
|
634
|
+
* missed or failed webhook re-sync self-heals within a tick. A host runtime
|
|
635
|
+
* drives {@link createKeyBillingSyncer.Syncer.tick} on a timer, like the meter
|
|
636
|
+
* reporter. Off the request path, so the auth hot path never reads billing.
|
|
637
|
+
*
|
|
638
|
+
* Only environments where keys carry a billing snapshot are reconciled
|
|
639
|
+
* (sandbox today); production keys carry none.
|
|
640
|
+
*/
|
|
641
|
+
export function createKeyBillingSyncer(
|
|
642
|
+
options: createKeyBillingSyncer.Options,
|
|
643
|
+
): createKeyBillingSyncer.Syncer {
|
|
644
|
+
const { db, environments = ['sandbox'], kv } = options
|
|
645
|
+
return {
|
|
646
|
+
async tick() {
|
|
647
|
+
// Resolve per tick so Workers factories build a fresh Hyperdrive-pooled
|
|
648
|
+
// connection each run.
|
|
649
|
+
const database = Db.get(db)
|
|
650
|
+
let checked = 0
|
|
651
|
+
let updated = 0
|
|
652
|
+
for (const environment of environments) {
|
|
653
|
+
const rows = await listActive(database, environment)
|
|
654
|
+
for (const { active, orgId } of rows) {
|
|
655
|
+
checked += 1
|
|
656
|
+
// `setBillingActive` writes only drifted records, so a converged
|
|
657
|
+
// fleet costs list reads and no writes.
|
|
658
|
+
updated += await ApiKeys.setBillingActive(kv, { active, environment, orgId })
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return { checked, updated }
|
|
662
|
+
},
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export declare namespace createKeyBillingSyncer {
|
|
667
|
+
/** Options for {@link createKeyBillingSyncer}. */
|
|
668
|
+
type Options = {
|
|
669
|
+
/** Database holding billing sources, or a factory resolved per tick (Workers/Hyperdrive). */
|
|
670
|
+
db: Db.Source
|
|
671
|
+
/** Environments to reconcile. @default ['sandbox'] */
|
|
672
|
+
environments?: readonly StripeCustomers.Record['environment'][] | undefined
|
|
673
|
+
/** KV state store holding API-key records. */
|
|
674
|
+
kv: Store.State
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** A scheduled snapshot-reconcile job. */
|
|
678
|
+
type Syncer = {
|
|
679
|
+
/** Runs one reconcile pass, re-stamping drifted key records. */
|
|
680
|
+
tick(): Promise<{ checked: number; updated: number }>
|
|
681
|
+
}
|
|
682
|
+
}
|