@wopr-network/platform-core 1.45.0 → 1.46.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/dist/billing/stripe/billing-period-summary-repository.d.ts +22 -0
- package/dist/billing/stripe/billing-period-summary-repository.js +14 -0
- package/dist/billing/stripe/index.d.ts +10 -0
- package/dist/billing/stripe/index.js +5 -0
- package/dist/billing/stripe/metered-price-map.d.ts +26 -0
- package/dist/billing/stripe/metered-price-map.js +75 -0
- package/dist/billing/stripe/stripe-payment-processor.test.js +1 -0
- package/dist/billing/stripe/stripe-usage-reconciliation.d.ts +31 -0
- package/dist/billing/stripe/stripe-usage-reconciliation.js +84 -0
- package/dist/billing/stripe/stripe-usage-reconciliation.test.d.ts +1 -0
- package/dist/billing/stripe/stripe-usage-reconciliation.test.js +109 -0
- package/dist/billing/stripe/tenant-store.d.ts +9 -0
- package/dist/billing/stripe/tenant-store.js +7 -0
- package/dist/billing/stripe/usage-report-repository.d.ts +39 -0
- package/dist/billing/stripe/usage-report-repository.js +30 -0
- package/dist/billing/stripe/usage-report-repository.test.d.ts +1 -0
- package/dist/billing/stripe/usage-report-repository.test.js +77 -0
- package/dist/billing/stripe/usage-report-writer.d.ts +41 -0
- package/dist/billing/stripe/usage-report-writer.js +95 -0
- package/dist/billing/stripe/usage-report-writer.test.d.ts +1 -0
- package/dist/billing/stripe/usage-report-writer.test.js +167 -0
- package/dist/gateway/credit-gate.js +5 -0
- package/dist/gateway/credit-gate.test.js +53 -0
- package/dist/gateway/types.d.ts +2 -0
- package/dist/monetization/stripe/stripe-payment-processor.test.js +1 -0
- package/package.json +1 -1
- package/src/billing/stripe/billing-period-summary-repository.ts +32 -0
- package/src/billing/stripe/index.ts +17 -0
- package/src/billing/stripe/metered-price-map.ts +95 -0
- package/src/billing/stripe/stripe-payment-processor.test.ts +1 -0
- package/src/billing/stripe/stripe-usage-reconciliation.test.ts +127 -0
- package/src/billing/stripe/stripe-usage-reconciliation.ts +129 -0
- package/src/billing/stripe/tenant-store.ts +9 -0
- package/src/billing/stripe/usage-report-repository.test.ts +87 -0
- package/src/billing/stripe/usage-report-repository.ts +77 -0
- package/src/billing/stripe/usage-report-writer.test.ts +194 -0
- package/src/billing/stripe/usage-report-writer.ts +139 -0
- package/src/gateway/credit-gate.test.ts +62 -0
- package/src/gateway/credit-gate.ts +6 -0
- package/src/gateway/types.ts +2 -0
- package/src/monetization/stripe/stripe-payment-processor.test.ts +1 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { logger } from "../../config/logger.js";
|
|
3
|
+
import { Credit } from "../../credits/credit.js";
|
|
4
|
+
/**
|
|
5
|
+
* Report metered usage to Stripe for all metered tenants in a given billing period.
|
|
6
|
+
*
|
|
7
|
+
* Uses Stripe's Billing Meters API (stripe.billing.meterEvents.create) — the v20
|
|
8
|
+
* replacement for the legacy subscriptionItems.createUsageRecord API.
|
|
9
|
+
*
|
|
10
|
+
* Flow:
|
|
11
|
+
* 1. Query billingPeriodSummaries for the period window
|
|
12
|
+
* 2. Filter to tenants with inferenceMode === "metered"
|
|
13
|
+
* 3. For each (tenant, capability, provider) tuple:
|
|
14
|
+
* a. Check if already reported (idempotent via stripeUsageReports unique index)
|
|
15
|
+
* b. Submit meter event to Stripe with idempotency identifier
|
|
16
|
+
* c. Insert into stripeUsageReports
|
|
17
|
+
*/
|
|
18
|
+
export async function runUsageReportWriter(cfg) {
|
|
19
|
+
const result = {
|
|
20
|
+
tenantsProcessed: 0,
|
|
21
|
+
reportsCreated: 0,
|
|
22
|
+
reportsSkipped: 0,
|
|
23
|
+
errors: [],
|
|
24
|
+
};
|
|
25
|
+
// 1. Find all metered tenants
|
|
26
|
+
const meteredTenants = await cfg.tenantRepo.listMetered();
|
|
27
|
+
if (meteredTenants.length === 0)
|
|
28
|
+
return result;
|
|
29
|
+
const meteredTenantIds = new Set(meteredTenants.map((t) => t.tenant));
|
|
30
|
+
const customerIdMap = new Map(meteredTenants.map((t) => [t.tenant, t.processorCustomerId]));
|
|
31
|
+
// 2. Query billing period summaries for this period
|
|
32
|
+
const summaries = await cfg.billingPeriodSummaryRepo.listByPeriodWindow(cfg.periodStart, cfg.periodEnd);
|
|
33
|
+
// 3. Filter to metered tenants only
|
|
34
|
+
const meteredSummaries = summaries.filter((s) => meteredTenantIds.has(s.tenant));
|
|
35
|
+
const processedTenants = new Set();
|
|
36
|
+
for (const summary of meteredSummaries) {
|
|
37
|
+
const { tenant, capability, provider, totalCharge } = summary;
|
|
38
|
+
// Skip zero usage
|
|
39
|
+
if (totalCharge <= 0)
|
|
40
|
+
continue;
|
|
41
|
+
// Skip capabilities without a metered price config
|
|
42
|
+
const priceConfig = cfg.meteredPriceMap.get(capability);
|
|
43
|
+
if (!priceConfig)
|
|
44
|
+
continue;
|
|
45
|
+
processedTenants.add(tenant);
|
|
46
|
+
try {
|
|
47
|
+
// Check if already reported (idempotent)
|
|
48
|
+
const existing = await cfg.usageReportRepo.getByTenantAndPeriod(tenant, capability, provider, summary.periodStart);
|
|
49
|
+
if (existing) {
|
|
50
|
+
result.reportsSkipped++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// Look up Stripe customer ID
|
|
54
|
+
const customerId = customerIdMap.get(tenant);
|
|
55
|
+
if (!customerId) {
|
|
56
|
+
result.errors.push({ tenant, capability, error: "No Stripe customer ID" });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// Convert nanodollars to cents
|
|
60
|
+
const valueCents = Credit.fromRaw(totalCharge).toCentsRounded();
|
|
61
|
+
// Build a stable idempotency identifier: tenant + capability + provider + periodStart
|
|
62
|
+
const identifier = `${tenant}:${capability}:${provider}:${summary.periodStart}`;
|
|
63
|
+
// Submit to Stripe Billing Meters API (v20+)
|
|
64
|
+
await cfg.stripe.billing.meterEvents.create({
|
|
65
|
+
event_name: priceConfig.eventName,
|
|
66
|
+
payload: {
|
|
67
|
+
stripe_customer_id: customerId,
|
|
68
|
+
value: String(valueCents),
|
|
69
|
+
},
|
|
70
|
+
identifier,
|
|
71
|
+
timestamp: Math.floor(summary.periodStart / 1000),
|
|
72
|
+
});
|
|
73
|
+
// Insert local record
|
|
74
|
+
await cfg.usageReportRepo.insert({
|
|
75
|
+
id: crypto.randomUUID(),
|
|
76
|
+
tenant,
|
|
77
|
+
capability,
|
|
78
|
+
provider,
|
|
79
|
+
periodStart: summary.periodStart,
|
|
80
|
+
periodEnd: summary.periodEnd,
|
|
81
|
+
eventName: priceConfig.eventName,
|
|
82
|
+
valueCents,
|
|
83
|
+
reportedAt: Date.now(),
|
|
84
|
+
});
|
|
85
|
+
result.reportsCreated++;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
89
|
+
logger.error("Failed to report usage to Stripe", { tenant, capability, error: msg });
|
|
90
|
+
result.errors.push({ tenant, capability, error: msg });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
result.tenantsProcessed = processedTenants.size;
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Credit } from "../../credits/credit.js";
|
|
4
|
+
import { billingPeriodSummaries } from "../../db/schema/meter-events.js";
|
|
5
|
+
import { tenantCustomers } from "../../db/schema/tenant-customers.js";
|
|
6
|
+
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
7
|
+
import { DrizzleBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
|
|
8
|
+
import { DrizzleTenantCustomerRepository } from "./tenant-store.js";
|
|
9
|
+
import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
|
|
10
|
+
import { runUsageReportWriter } from "./usage-report-writer.js";
|
|
11
|
+
vi.mock("../../config/logger.js", () => ({
|
|
12
|
+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
13
|
+
}));
|
|
14
|
+
describe("runUsageReportWriter", () => {
|
|
15
|
+
let db;
|
|
16
|
+
let pool;
|
|
17
|
+
let reportRepo;
|
|
18
|
+
let tenantRepo;
|
|
19
|
+
let billingPeriodSummaryRepo;
|
|
20
|
+
const NOW = Date.now();
|
|
21
|
+
const PERIOD_START = 1700000000000;
|
|
22
|
+
const PERIOD_END = 1700003600000;
|
|
23
|
+
const mockCreateMeterEvent = vi.fn().mockResolvedValue({ identifier: "evt_xxx" });
|
|
24
|
+
const mockStripe = {
|
|
25
|
+
billing: {
|
|
26
|
+
meterEvents: { create: mockCreateMeterEvent },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const priceMap = new Map([["chat-completions", { eventName: "chat_completions_usage" }]]);
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
const t = await createTestDb();
|
|
32
|
+
pool = t.pool;
|
|
33
|
+
db = t.db;
|
|
34
|
+
reportRepo = new DrizzleStripeUsageReportRepository(db);
|
|
35
|
+
tenantRepo = new DrizzleTenantCustomerRepository(db);
|
|
36
|
+
billingPeriodSummaryRepo = new DrizzleBillingPeriodSummaryRepository(db);
|
|
37
|
+
});
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
await truncateAllTables(pool);
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
await pool.close();
|
|
44
|
+
});
|
|
45
|
+
async function seedMeteredTenant(tenant) {
|
|
46
|
+
await db.insert(tenantCustomers).values({
|
|
47
|
+
tenant,
|
|
48
|
+
processorCustomerId: `cus_${tenant}`,
|
|
49
|
+
processor: "stripe",
|
|
50
|
+
inferenceMode: "metered",
|
|
51
|
+
createdAt: NOW,
|
|
52
|
+
updatedAt: NOW,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async function seedBillingPeriod(tenant, opts) {
|
|
56
|
+
await db.insert(billingPeriodSummaries).values({
|
|
57
|
+
id: crypto.randomUUID(),
|
|
58
|
+
tenant,
|
|
59
|
+
capability: opts?.capability ?? "chat-completions",
|
|
60
|
+
provider: "openrouter",
|
|
61
|
+
eventCount: 10,
|
|
62
|
+
totalCost: opts?.totalCharge ?? Credit.fromCents(100).toRaw(),
|
|
63
|
+
totalCharge: opts?.totalCharge ?? Credit.fromCents(100).toRaw(),
|
|
64
|
+
totalDuration: 0,
|
|
65
|
+
periodStart: PERIOD_START,
|
|
66
|
+
periodEnd: PERIOD_END,
|
|
67
|
+
updatedAt: NOW,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
it("reports usage for metered tenants to Stripe and inserts local record", async () => {
|
|
71
|
+
await seedMeteredTenant("t1");
|
|
72
|
+
await seedBillingPeriod("t1");
|
|
73
|
+
const result = await runUsageReportWriter({
|
|
74
|
+
stripe: mockStripe,
|
|
75
|
+
tenantRepo,
|
|
76
|
+
billingPeriodSummaryRepo,
|
|
77
|
+
usageReportRepo: reportRepo,
|
|
78
|
+
meteredPriceMap: priceMap,
|
|
79
|
+
periodStart: PERIOD_START,
|
|
80
|
+
periodEnd: PERIOD_END,
|
|
81
|
+
});
|
|
82
|
+
expect(result.reportsCreated).toBe(1);
|
|
83
|
+
expect(result.errors).toHaveLength(0);
|
|
84
|
+
expect(mockCreateMeterEvent).toHaveBeenCalledOnce();
|
|
85
|
+
const stored = await reportRepo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", PERIOD_START);
|
|
86
|
+
expect(stored).toBeTruthy();
|
|
87
|
+
expect(stored?.valueCents).toBe(100);
|
|
88
|
+
});
|
|
89
|
+
it("skips non-metered tenants", async () => {
|
|
90
|
+
// Insert a managed-mode tenant
|
|
91
|
+
await db.insert(tenantCustomers).values({
|
|
92
|
+
tenant: "t2",
|
|
93
|
+
processorCustomerId: "cus_t2",
|
|
94
|
+
processor: "stripe",
|
|
95
|
+
inferenceMode: "managed",
|
|
96
|
+
createdAt: NOW,
|
|
97
|
+
updatedAt: NOW,
|
|
98
|
+
});
|
|
99
|
+
await seedBillingPeriod("t2");
|
|
100
|
+
const result = await runUsageReportWriter({
|
|
101
|
+
stripe: mockStripe,
|
|
102
|
+
tenantRepo,
|
|
103
|
+
billingPeriodSummaryRepo,
|
|
104
|
+
usageReportRepo: reportRepo,
|
|
105
|
+
meteredPriceMap: priceMap,
|
|
106
|
+
periodStart: PERIOD_START,
|
|
107
|
+
periodEnd: PERIOD_END,
|
|
108
|
+
});
|
|
109
|
+
expect(result.tenantsProcessed).toBe(0);
|
|
110
|
+
expect(mockCreateMeterEvent).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
it("skips already-reported periods (idempotent)", async () => {
|
|
113
|
+
await seedMeteredTenant("t1");
|
|
114
|
+
await seedBillingPeriod("t1");
|
|
115
|
+
// Pre-insert a report for this period
|
|
116
|
+
await reportRepo.insert({
|
|
117
|
+
id: crypto.randomUUID(),
|
|
118
|
+
tenant: "t1",
|
|
119
|
+
capability: "chat-completions",
|
|
120
|
+
provider: "openrouter",
|
|
121
|
+
periodStart: PERIOD_START,
|
|
122
|
+
periodEnd: PERIOD_END,
|
|
123
|
+
eventName: "chat_completions_usage",
|
|
124
|
+
valueCents: 100,
|
|
125
|
+
reportedAt: NOW,
|
|
126
|
+
});
|
|
127
|
+
const result = await runUsageReportWriter({
|
|
128
|
+
stripe: mockStripe,
|
|
129
|
+
tenantRepo,
|
|
130
|
+
billingPeriodSummaryRepo,
|
|
131
|
+
usageReportRepo: reportRepo,
|
|
132
|
+
meteredPriceMap: priceMap,
|
|
133
|
+
periodStart: PERIOD_START,
|
|
134
|
+
periodEnd: PERIOD_END,
|
|
135
|
+
});
|
|
136
|
+
expect(result.reportsSkipped).toBe(1);
|
|
137
|
+
expect(result.reportsCreated).toBe(0);
|
|
138
|
+
expect(mockCreateMeterEvent).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
it("skips zero-usage periods", async () => {
|
|
141
|
+
await seedMeteredTenant("t1");
|
|
142
|
+
await db.insert(billingPeriodSummaries).values({
|
|
143
|
+
id: crypto.randomUUID(),
|
|
144
|
+
tenant: "t1",
|
|
145
|
+
capability: "chat-completions",
|
|
146
|
+
provider: "openrouter",
|
|
147
|
+
eventCount: 0,
|
|
148
|
+
totalCost: 0,
|
|
149
|
+
totalCharge: 0,
|
|
150
|
+
totalDuration: 0,
|
|
151
|
+
periodStart: PERIOD_START,
|
|
152
|
+
periodEnd: PERIOD_END,
|
|
153
|
+
updatedAt: NOW,
|
|
154
|
+
});
|
|
155
|
+
const result = await runUsageReportWriter({
|
|
156
|
+
stripe: mockStripe,
|
|
157
|
+
tenantRepo,
|
|
158
|
+
billingPeriodSummaryRepo,
|
|
159
|
+
usageReportRepo: reportRepo,
|
|
160
|
+
meteredPriceMap: priceMap,
|
|
161
|
+
periodStart: PERIOD_START,
|
|
162
|
+
periodEnd: PERIOD_END,
|
|
163
|
+
});
|
|
164
|
+
expect(result.reportsCreated).toBe(0);
|
|
165
|
+
expect(mockCreateMeterEvent).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -26,6 +26,11 @@ export async function creditBalanceCheck(c, deps, estimatedCostCents = 0) {
|
|
|
26
26
|
if (tenant.type === "platform_service") {
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
|
+
// Metered tenants are invoiced via Stripe subscription, not prepaid credits.
|
|
30
|
+
// Skip balance enforcement but still allow debit (for P&L tracking).
|
|
31
|
+
if (tenant.inferenceMode === "metered") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
29
34
|
const balance = await deps.creditLedger.balance(tenant.id);
|
|
30
35
|
const required = Math.max(0, estimatedCostCents);
|
|
31
36
|
const graceBuffer = deps.graceBufferCents ?? 50; // default -$0.50
|
|
@@ -165,3 +165,56 @@ describe("debitCredits with allowNegative and onBalanceExhausted", () => {
|
|
|
165
165
|
expect(onBalanceExhausted).not.toHaveBeenCalled();
|
|
166
166
|
});
|
|
167
167
|
});
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// creditBalanceCheck — metered tenant bypass
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
async function buildHonoContextWithMode(tenantId, inferenceMode) {
|
|
172
|
+
let capturedCtx;
|
|
173
|
+
const app = new Hono();
|
|
174
|
+
app.get("/test", (c) => {
|
|
175
|
+
c.set("gatewayTenant", {
|
|
176
|
+
id: tenantId,
|
|
177
|
+
spendLimits: { maxSpendPerHour: null, maxSpendPerMonth: null },
|
|
178
|
+
inferenceMode,
|
|
179
|
+
});
|
|
180
|
+
capturedCtx = c;
|
|
181
|
+
return c.json({});
|
|
182
|
+
});
|
|
183
|
+
await app.request("/test");
|
|
184
|
+
return capturedCtx;
|
|
185
|
+
}
|
|
186
|
+
describe("creditBalanceCheck metered tenant bypass", () => {
|
|
187
|
+
beforeEach(async () => {
|
|
188
|
+
await truncateAllTables(pool);
|
|
189
|
+
await new DrizzleLedger(db).seedSystemAccounts();
|
|
190
|
+
});
|
|
191
|
+
it("skips balance check for metered tenants even with zero balance", async () => {
|
|
192
|
+
const ledger = new DrizzleLedger(db);
|
|
193
|
+
// No credits — would normally fail with credits_exhausted
|
|
194
|
+
const c = await buildHonoContextWithMode("metered-t1", "metered");
|
|
195
|
+
const deps = { creditLedger: ledger, topUpUrl: "/billing" };
|
|
196
|
+
const error = await creditBalanceCheck(c, deps, 0);
|
|
197
|
+
expect(error).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
it("still debits metered tenants for P&L tracking", async () => {
|
|
200
|
+
const ledger = new DrizzleLedger(db);
|
|
201
|
+
await ledger.credit("metered-t1", Credit.fromCents(0), "purchase", { description: "setup" }).catch(() => { });
|
|
202
|
+
const mockLedger = {
|
|
203
|
+
debit: vi.fn().mockResolvedValue(undefined),
|
|
204
|
+
balance: vi.fn(),
|
|
205
|
+
credit: vi.fn(),
|
|
206
|
+
};
|
|
207
|
+
const deps = { creditLedger: mockLedger, topUpUrl: "/billing" };
|
|
208
|
+
await debitCredits(deps, "metered-tenant", 0.05, 1.5, "chat-completions", "openrouter");
|
|
209
|
+
expect(mockLedger.debit).toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
it("non-metered (managed) tenant still gets balance checked", async () => {
|
|
212
|
+
const ledger = new DrizzleLedger(db);
|
|
213
|
+
// No credits seeded — should fail
|
|
214
|
+
const c = await buildHonoContextWithMode("managed-t1", "managed");
|
|
215
|
+
const deps = { creditLedger: ledger, topUpUrl: "/billing" };
|
|
216
|
+
const error = await creditBalanceCheck(c, deps, 0);
|
|
217
|
+
// Balance is 0, within grace buffer — passes
|
|
218
|
+
expect(error).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
});
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface GatewayTenant {
|
|
|
44
44
|
instanceId?: string;
|
|
45
45
|
/** User-configured spending caps (null fields = no cap). */
|
|
46
46
|
spendingCaps?: SpendingCaps;
|
|
47
|
+
/** Billing mode — "metered" tenants are invoiced via Stripe, not prepaid credits. */
|
|
48
|
+
inferenceMode?: "metered" | "managed" | "byok";
|
|
47
49
|
}
|
|
48
50
|
/** Fetch function type for dependency injection in tests. */
|
|
49
51
|
export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
|
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { and, gte, lte } from "drizzle-orm";
|
|
2
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
3
|
+
import { billingPeriodSummaries } from "../../db/schema/meter-events.js";
|
|
4
|
+
|
|
5
|
+
export interface BillingPeriodSummaryRow {
|
|
6
|
+
id: string;
|
|
7
|
+
tenant: string;
|
|
8
|
+
capability: string;
|
|
9
|
+
provider: string;
|
|
10
|
+
eventCount: number;
|
|
11
|
+
totalCost: number;
|
|
12
|
+
totalCharge: number;
|
|
13
|
+
totalDuration: number;
|
|
14
|
+
periodStart: number;
|
|
15
|
+
periodEnd: number;
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IBillingPeriodSummaryRepository {
|
|
20
|
+
listByPeriodWindow(start: number, end: number): Promise<BillingPeriodSummaryRow[]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class DrizzleBillingPeriodSummaryRepository implements IBillingPeriodSummaryRepository {
|
|
24
|
+
constructor(private readonly db: PlatformDb) {}
|
|
25
|
+
|
|
26
|
+
async listByPeriodWindow(start: number, end: number): Promise<BillingPeriodSummaryRow[]> {
|
|
27
|
+
return this.db
|
|
28
|
+
.select()
|
|
29
|
+
.from(billingPeriodSummaries)
|
|
30
|
+
.where(and(gte(billingPeriodSummaries.periodStart, start), lte(billingPeriodSummaries.periodEnd, end)));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
BillingPeriodSummaryRow,
|
|
3
|
+
IBillingPeriodSummaryRepository,
|
|
4
|
+
} from "./billing-period-summary-repository.js";
|
|
5
|
+
export { DrizzleBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
|
|
1
6
|
export { createCreditCheckoutSession, createVpsCheckoutSession } from "./checkout.js";
|
|
2
7
|
export { createStripeClient, loadStripeConfig } from "./client.js";
|
|
3
8
|
export type { CreditPriceMap, CreditPricePoint } from "./credit-prices.js";
|
|
@@ -8,6 +13,8 @@ export {
|
|
|
8
13
|
loadCreditPriceMap,
|
|
9
14
|
lookupCreditPrice,
|
|
10
15
|
} from "./credit-prices.js";
|
|
16
|
+
export type { MeteredPriceConfig, MeteredPriceMap } from "./metered-price-map.js";
|
|
17
|
+
export { loadMeteredPriceMap } from "./metered-price-map.js";
|
|
11
18
|
export type { DetachPaymentMethodOpts } from "./payment-methods.js";
|
|
12
19
|
export { detachAllPaymentMethods, detachPaymentMethod } from "./payment-methods.js";
|
|
13
20
|
export { createPortalSession } from "./portal.js";
|
|
@@ -15,6 +22,8 @@ export type { SetupIntentOpts } from "./setup-intent.js";
|
|
|
15
22
|
export { createSetupIntent } from "./setup-intent.js";
|
|
16
23
|
export type { StripePaymentProcessorDeps, StripeWebhookHandlerResult } from "./stripe-payment-processor.js";
|
|
17
24
|
export { StripePaymentProcessor } from "./stripe-payment-processor.js";
|
|
25
|
+
export type { StripeUsageReconciliationConfig, UsageReconciliationResult } from "./stripe-usage-reconciliation.js";
|
|
26
|
+
export { runStripeUsageReconciliation } from "./stripe-usage-reconciliation.js";
|
|
18
27
|
export type { ITenantCustomerRepository } from "./tenant-store.js";
|
|
19
28
|
export { DrizzleTenantCustomerRepository, TenantCustomerRepository } from "./tenant-store.js";
|
|
20
29
|
export type {
|
|
@@ -24,3 +33,11 @@ export type {
|
|
|
24
33
|
TenantCustomerRow,
|
|
25
34
|
VpsCheckoutOpts,
|
|
26
35
|
} from "./types.js";
|
|
36
|
+
export type {
|
|
37
|
+
IStripeUsageReportRepository,
|
|
38
|
+
StripeUsageReportInsert,
|
|
39
|
+
StripeUsageReportRow,
|
|
40
|
+
} from "./usage-report-repository.js";
|
|
41
|
+
export { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
|
|
42
|
+
export type { UsageReportResult, UsageReportWriterConfig } from "./usage-report-writer.js";
|
|
43
|
+
export { runUsageReportWriter } from "./usage-report-writer.js";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metered billing price configuration.
|
|
3
|
+
*
|
|
4
|
+
* Maps capability names to their Stripe Billing Meter event names.
|
|
5
|
+
* Stripe v20 uses the Billing Meters API (stripe.billing.meterEvents.create)
|
|
6
|
+
* instead of the legacy subscriptionItems.createUsageRecord API.
|
|
7
|
+
*
|
|
8
|
+
* Pattern follows loadCreditPriceMap() in credit-prices.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Maps capability name to its metered Stripe configuration. */
|
|
12
|
+
export interface MeteredPriceConfig {
|
|
13
|
+
/** Stripe Billing Meter event name (e.g., "chat_completions_usage"). */
|
|
14
|
+
eventName: string;
|
|
15
|
+
/** Stripe Billing Meter ID for reconciliation lookups (e.g., mtr_xxx). Optional. */
|
|
16
|
+
meterId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Maps capability name to MeteredPriceConfig. */
|
|
20
|
+
export type MeteredPriceMap = ReadonlyMap<string, MeteredPriceConfig>;
|
|
21
|
+
|
|
22
|
+
const METERED_CAPABILITIES: Array<{
|
|
23
|
+
capability: string;
|
|
24
|
+
eventEnvVar: string;
|
|
25
|
+
meterEnvVar: string;
|
|
26
|
+
defaultEvent: string;
|
|
27
|
+
}> = [
|
|
28
|
+
{
|
|
29
|
+
capability: "chat-completions",
|
|
30
|
+
eventEnvVar: "STRIPE_METERED_EVENT_CHAT",
|
|
31
|
+
meterEnvVar: "STRIPE_METERED_METER_CHAT",
|
|
32
|
+
defaultEvent: "chat_completions_usage",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
capability: "tts",
|
|
36
|
+
eventEnvVar: "STRIPE_METERED_EVENT_TTS",
|
|
37
|
+
meterEnvVar: "STRIPE_METERED_METER_TTS",
|
|
38
|
+
defaultEvent: "tts_usage",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
capability: "transcription",
|
|
42
|
+
eventEnvVar: "STRIPE_METERED_EVENT_TRANSCRIPTION",
|
|
43
|
+
meterEnvVar: "STRIPE_METERED_METER_TRANSCRIPTION",
|
|
44
|
+
defaultEvent: "transcription_usage",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
capability: "image-generation",
|
|
48
|
+
eventEnvVar: "STRIPE_METERED_EVENT_IMAGE",
|
|
49
|
+
meterEnvVar: "STRIPE_METERED_METER_IMAGE",
|
|
50
|
+
defaultEvent: "image_generation_usage",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
capability: "embeddings",
|
|
54
|
+
eventEnvVar: "STRIPE_METERED_EVENT_EMBEDDINGS",
|
|
55
|
+
meterEnvVar: "STRIPE_METERED_METER_EMBEDDINGS",
|
|
56
|
+
defaultEvent: "embeddings_usage",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
capability: "phone-inbound",
|
|
60
|
+
eventEnvVar: "STRIPE_METERED_EVENT_PHONE_IN",
|
|
61
|
+
meterEnvVar: "STRIPE_METERED_METER_PHONE_IN",
|
|
62
|
+
defaultEvent: "phone_inbound_usage",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
capability: "phone-outbound",
|
|
66
|
+
eventEnvVar: "STRIPE_METERED_EVENT_PHONE_OUT",
|
|
67
|
+
meterEnvVar: "STRIPE_METERED_METER_PHONE_OUT",
|
|
68
|
+
defaultEvent: "phone_outbound_usage",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
capability: "sms",
|
|
72
|
+
eventEnvVar: "STRIPE_METERED_EVENT_SMS",
|
|
73
|
+
meterEnvVar: "STRIPE_METERED_METER_SMS",
|
|
74
|
+
defaultEvent: "sms_usage",
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load metered price mappings from environment variables.
|
|
80
|
+
*
|
|
81
|
+
* Returns a Map from capability name -> MeteredPriceConfig.
|
|
82
|
+
* All capabilities are included with default event names.
|
|
83
|
+
* meterId is only populated when the env var is set.
|
|
84
|
+
*/
|
|
85
|
+
export function loadMeteredPriceMap(): MeteredPriceMap {
|
|
86
|
+
const map = new Map<string, MeteredPriceConfig>();
|
|
87
|
+
|
|
88
|
+
for (const { capability, eventEnvVar, meterEnvVar, defaultEvent } of METERED_CAPABILITIES) {
|
|
89
|
+
const eventName = process.env[eventEnvVar] ?? defaultEvent;
|
|
90
|
+
const meterId = process.env[meterEnvVar];
|
|
91
|
+
map.set(capability, { eventName, ...(meterId ? { meterId } : {}) });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
4
|
+
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
5
|
+
import { runStripeUsageReconciliation } from "./stripe-usage-reconciliation.js";
|
|
6
|
+
import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../config/logger.js", () => ({
|
|
9
|
+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("runStripeUsageReconciliation", () => {
|
|
13
|
+
let db: PlatformDb;
|
|
14
|
+
let pool: import("@electric-sql/pglite").PGlite;
|
|
15
|
+
let reportRepo: DrizzleStripeUsageReportRepository;
|
|
16
|
+
|
|
17
|
+
const NOW = Date.now();
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
const t = await createTestDb();
|
|
21
|
+
pool = t.pool;
|
|
22
|
+
db = t.db;
|
|
23
|
+
reportRepo = new DrizzleStripeUsageReportRepository(db);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
await truncateAllTables(pool);
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await pool.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns empty result when no local reports exist", async () => {
|
|
36
|
+
const mockStripe = {
|
|
37
|
+
billing: {
|
|
38
|
+
meters: { listEventSummaries: vi.fn().mockResolvedValue({ data: [] }) },
|
|
39
|
+
},
|
|
40
|
+
} as unknown as import("stripe").default;
|
|
41
|
+
|
|
42
|
+
const result = await runStripeUsageReconciliation({
|
|
43
|
+
stripe: mockStripe,
|
|
44
|
+
usageReportRepo: reportRepo,
|
|
45
|
+
targetDate: new Date().toISOString().slice(0, 10),
|
|
46
|
+
flagThresholdCents: 10,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.tenantsChecked).toBe(0);
|
|
50
|
+
expect(result.discrepancies).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("detects drift when local valueCents differs from Stripe aggregated_value", async () => {
|
|
54
|
+
await reportRepo.insert({
|
|
55
|
+
id: crypto.randomUUID(),
|
|
56
|
+
tenant: "t1",
|
|
57
|
+
capability: "chat-completions",
|
|
58
|
+
provider: "openrouter",
|
|
59
|
+
periodStart: 1700000000000,
|
|
60
|
+
periodEnd: 1700003600000,
|
|
61
|
+
eventName: "chat_completions_usage",
|
|
62
|
+
valueCents: 100,
|
|
63
|
+
reportedAt: NOW,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Stripe says it only received 80
|
|
67
|
+
const mockStripe = {
|
|
68
|
+
billing: {
|
|
69
|
+
meters: {
|
|
70
|
+
listEventSummaries: vi.fn().mockResolvedValue({
|
|
71
|
+
data: [{ aggregated_value: 80 }],
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
} as unknown as import("stripe").default;
|
|
76
|
+
|
|
77
|
+
const targetDate = new Date(NOW).toISOString().slice(0, 10);
|
|
78
|
+
// meterLookup returns "meterId:customerId"
|
|
79
|
+
const result = await runStripeUsageReconciliation({
|
|
80
|
+
stripe: mockStripe,
|
|
81
|
+
usageReportRepo: reportRepo,
|
|
82
|
+
meterLookup: vi.fn().mockResolvedValue({ meterId: "mtr_123", customerId: "cus_t1" }),
|
|
83
|
+
targetDate,
|
|
84
|
+
flagThresholdCents: 10,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result.discrepancies).toHaveLength(1);
|
|
88
|
+
expect(result.discrepancies[0].driftCents).toBe(20);
|
|
89
|
+
expect(result.flagged).toContain("t1");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("no discrepancy when values match", async () => {
|
|
93
|
+
await reportRepo.insert({
|
|
94
|
+
id: crypto.randomUUID(),
|
|
95
|
+
tenant: "t1",
|
|
96
|
+
capability: "chat-completions",
|
|
97
|
+
provider: "openrouter",
|
|
98
|
+
periodStart: 1700000000000,
|
|
99
|
+
periodEnd: 1700003600000,
|
|
100
|
+
eventName: "chat_completions_usage",
|
|
101
|
+
valueCents: 100,
|
|
102
|
+
reportedAt: NOW,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const mockStripe = {
|
|
106
|
+
billing: {
|
|
107
|
+
meters: {
|
|
108
|
+
listEventSummaries: vi.fn().mockResolvedValue({
|
|
109
|
+
data: [{ aggregated_value: 100 }],
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
} as unknown as import("stripe").default;
|
|
114
|
+
|
|
115
|
+
const targetDate = new Date(NOW).toISOString().slice(0, 10);
|
|
116
|
+
const result = await runStripeUsageReconciliation({
|
|
117
|
+
stripe: mockStripe,
|
|
118
|
+
usageReportRepo: reportRepo,
|
|
119
|
+
meterLookup: vi.fn().mockResolvedValue({ meterId: "mtr_123", customerId: "cus_t1" }),
|
|
120
|
+
targetDate,
|
|
121
|
+
flagThresholdCents: 10,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result.discrepancies).toHaveLength(0);
|
|
125
|
+
expect(result.flagged).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
});
|