@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,129 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { logger } from "../../config/logger.js";
|
|
3
|
+
import type { IStripeUsageReportRepository, StripeUsageReportRow } from "./usage-report-repository.js";
|
|
4
|
+
|
|
5
|
+
export interface StripeUsageReconciliationConfig {
|
|
6
|
+
stripe: Stripe;
|
|
7
|
+
usageReportRepo: IStripeUsageReportRepository;
|
|
8
|
+
/** Date to reconcile, as YYYY-MM-DD. */
|
|
9
|
+
targetDate: string;
|
|
10
|
+
/** Drift threshold in cents before flagging a tenant. Default: 10. */
|
|
11
|
+
flagThresholdCents?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Lookup function: (tenant, eventName) -> { meterId, customerId } | null.
|
|
14
|
+
* Used to look up the meter and customer for listEventSummaries.
|
|
15
|
+
*/
|
|
16
|
+
meterLookup?: (tenant: string, eventName: string) => Promise<{ meterId: string; customerId: string } | null>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UsageReconciliationResult {
|
|
20
|
+
date: string;
|
|
21
|
+
tenantsChecked: number;
|
|
22
|
+
discrepancies: Array<{
|
|
23
|
+
tenant: string;
|
|
24
|
+
capability: string;
|
|
25
|
+
localValueCents: number;
|
|
26
|
+
stripeQuantity: number;
|
|
27
|
+
driftCents: number;
|
|
28
|
+
}>;
|
|
29
|
+
flagged: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runStripeUsageReconciliation(
|
|
33
|
+
cfg: StripeUsageReconciliationConfig,
|
|
34
|
+
): Promise<UsageReconciliationResult> {
|
|
35
|
+
const threshold = cfg.flagThresholdCents ?? 10;
|
|
36
|
+
const dayStart = new Date(`${cfg.targetDate}T00:00:00Z`).getTime();
|
|
37
|
+
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
const result: UsageReconciliationResult = {
|
|
40
|
+
date: cfg.targetDate,
|
|
41
|
+
tenantsChecked: 0,
|
|
42
|
+
discrepancies: [],
|
|
43
|
+
flagged: [],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Get all local reports for this day (by reportedAt — when we submitted to Stripe)
|
|
47
|
+
const localReports = await cfg.usageReportRepo.listAll({ since: dayStart, until: dayEnd });
|
|
48
|
+
|
|
49
|
+
if (localReports.length === 0) return result;
|
|
50
|
+
|
|
51
|
+
// Group by tenant+capability+eventName so each distinct meter is reconciled separately
|
|
52
|
+
const grouped = new Map<string, StripeUsageReportRow[]>();
|
|
53
|
+
for (const report of localReports) {
|
|
54
|
+
const key = `${report.tenant}:${report.capability}:${report.eventName}`;
|
|
55
|
+
const arr = grouped.get(key) ?? [];
|
|
56
|
+
arr.push(report);
|
|
57
|
+
grouped.set(key, arr);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const checkedTenants = new Set<string>();
|
|
61
|
+
|
|
62
|
+
for (const [key, reports] of grouped) {
|
|
63
|
+
const parts = key.split(":");
|
|
64
|
+
const tenant = parts[0];
|
|
65
|
+
const capability = parts[1];
|
|
66
|
+
checkedTenants.add(tenant);
|
|
67
|
+
|
|
68
|
+
const localTotal = reports.reduce((sum, r) => sum + r.valueCents, 0);
|
|
69
|
+
|
|
70
|
+
// Need a customer ID and meter lookup to reconcile against Stripe
|
|
71
|
+
if (!cfg.meterLookup) continue;
|
|
72
|
+
|
|
73
|
+
const eventName = reports[0]?.eventName;
|
|
74
|
+
if (!eventName) continue;
|
|
75
|
+
|
|
76
|
+
const lookup = await cfg.meterLookup(tenant, eventName);
|
|
77
|
+
if (!lookup) {
|
|
78
|
+
logger.warn("Cannot reconcile: no meter ID for tenant+capability", { tenant, capability });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { meterId: resolvedMeterId, customerId } = lookup;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const startSec = Math.floor(dayStart / 1000);
|
|
86
|
+
const endSec = Math.floor(dayEnd / 1000);
|
|
87
|
+
|
|
88
|
+
const summaries = await cfg.stripe.billing.meters.listEventSummaries(resolvedMeterId, {
|
|
89
|
+
customer: customerId,
|
|
90
|
+
start_time: startSec,
|
|
91
|
+
end_time: endSec,
|
|
92
|
+
limit: 1,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const stripeTotal = summaries.data[0]?.aggregated_value ?? 0;
|
|
96
|
+
const drift = Math.abs(localTotal - stripeTotal);
|
|
97
|
+
|
|
98
|
+
if (drift > 0) {
|
|
99
|
+
result.discrepancies.push({
|
|
100
|
+
tenant,
|
|
101
|
+
capability,
|
|
102
|
+
localValueCents: localTotal,
|
|
103
|
+
stripeQuantity: stripeTotal,
|
|
104
|
+
driftCents: drift,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (drift >= threshold) {
|
|
108
|
+
if (!result.flagged.includes(tenant)) {
|
|
109
|
+
result.flagged.push(tenant);
|
|
110
|
+
}
|
|
111
|
+
logger.warn("Stripe usage reconciliation: drift exceeds threshold", {
|
|
112
|
+
tenant,
|
|
113
|
+
capability,
|
|
114
|
+
localTotal,
|
|
115
|
+
stripeTotal,
|
|
116
|
+
drift,
|
|
117
|
+
threshold,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
123
|
+
logger.error("Failed to reconcile Stripe usage", { tenant, capability, error: msg });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
result.tenantsChecked = checkedTenants.size;
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
@@ -14,6 +14,7 @@ export interface ITenantCustomerRepository {
|
|
|
14
14
|
setInferenceMode(tenant: string, mode: string): Promise<void>;
|
|
15
15
|
list(): Promise<TenantCustomerRow[]>;
|
|
16
16
|
buildCustomerIdMap(): Promise<Record<string, string>>;
|
|
17
|
+
listMetered(): Promise<Array<{ tenant: string; processorCustomerId: string }>>;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -116,6 +117,14 @@ export class DrizzleTenantCustomerRepository implements ITenantCustomerRepositor
|
|
|
116
117
|
return rows.map(mapRow);
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/** Return all tenants with inferenceMode === "metered". */
|
|
121
|
+
async listMetered(): Promise<Array<{ tenant: string; processorCustomerId: string }>> {
|
|
122
|
+
return this.db
|
|
123
|
+
.select({ tenant: tenantCustomers.tenant, processorCustomerId: tenantCustomers.processorCustomerId })
|
|
124
|
+
.from(tenantCustomers)
|
|
125
|
+
.where(eq(tenantCustomers.inferenceMode, "metered"));
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
/** Build a tenant -> processor_customer_id map for use with UsageAggregationWorker. */
|
|
120
129
|
async buildCustomerIdMap(): Promise<Record<string, string>> {
|
|
121
130
|
const rows = await this.db
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
4
|
+
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
5
|
+
import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
|
|
6
|
+
|
|
7
|
+
describe("DrizzleStripeUsageReportRepository", () => {
|
|
8
|
+
let db: PlatformDb;
|
|
9
|
+
let pool: import("@electric-sql/pglite").PGlite;
|
|
10
|
+
let repo: DrizzleStripeUsageReportRepository;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const t = await createTestDb();
|
|
14
|
+
pool = t.pool;
|
|
15
|
+
db = t.db;
|
|
16
|
+
repo = new DrizzleStripeUsageReportRepository(db);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await truncateAllTables(pool);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await pool.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("inserts a usage report row", async () => {
|
|
28
|
+
const row = {
|
|
29
|
+
id: crypto.randomUUID(),
|
|
30
|
+
tenant: "t1",
|
|
31
|
+
capability: "chat-completions",
|
|
32
|
+
provider: "openrouter",
|
|
33
|
+
periodStart: 1700000000000,
|
|
34
|
+
periodEnd: 1700003600000,
|
|
35
|
+
eventName: "chat_completions_usage",
|
|
36
|
+
valueCents: 150,
|
|
37
|
+
reportedAt: Date.now(),
|
|
38
|
+
};
|
|
39
|
+
await repo.insert(row);
|
|
40
|
+
const found = await repo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", 1700000000000);
|
|
41
|
+
expect(found).toBeTruthy();
|
|
42
|
+
expect(found?.valueCents).toBe(150);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns null for non-existent report", async () => {
|
|
46
|
+
const found = await repo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", 1700000000000);
|
|
47
|
+
expect(found).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects duplicate (tenant, capability, provider, periodStart)", async () => {
|
|
51
|
+
const base = {
|
|
52
|
+
tenant: "t1",
|
|
53
|
+
capability: "chat-completions",
|
|
54
|
+
provider: "openrouter",
|
|
55
|
+
periodStart: 1700000000000,
|
|
56
|
+
periodEnd: 1700003600000,
|
|
57
|
+
eventName: "chat_completions_usage",
|
|
58
|
+
valueCents: 100,
|
|
59
|
+
reportedAt: Date.now(),
|
|
60
|
+
};
|
|
61
|
+
await repo.insert({ ...base, id: crypto.randomUUID() });
|
|
62
|
+
await expect(repo.insert({ ...base, id: crypto.randomUUID() })).rejects.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("lists reports for a tenant in a date range", async () => {
|
|
66
|
+
const base = {
|
|
67
|
+
tenant: "t1",
|
|
68
|
+
capability: "chat-completions",
|
|
69
|
+
provider: "openrouter",
|
|
70
|
+
eventName: "chat_completions_usage",
|
|
71
|
+
valueCents: 50,
|
|
72
|
+
};
|
|
73
|
+
await repo.insert({ ...base, id: crypto.randomUUID(), periodStart: 100, periodEnd: 200, reportedAt: 300 });
|
|
74
|
+
await repo.insert({ ...base, id: crypto.randomUUID(), periodStart: 200, periodEnd: 300, reportedAt: 400 });
|
|
75
|
+
await repo.insert({
|
|
76
|
+
...base,
|
|
77
|
+
id: crypto.randomUUID(),
|
|
78
|
+
periodStart: 300,
|
|
79
|
+
periodEnd: 400,
|
|
80
|
+
reportedAt: 500,
|
|
81
|
+
tenant: "t2",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const results = await repo.listByTenant("t1", { since: 0, until: 500 });
|
|
85
|
+
expect(results).toHaveLength(2);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { and, eq, gte, lt } from "drizzle-orm";
|
|
2
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
3
|
+
import { stripeUsageReports } from "../../db/schema/tenant-customers.js";
|
|
4
|
+
|
|
5
|
+
export interface StripeUsageReportRow {
|
|
6
|
+
id: string;
|
|
7
|
+
tenant: string;
|
|
8
|
+
capability: string;
|
|
9
|
+
provider: string;
|
|
10
|
+
periodStart: number;
|
|
11
|
+
periodEnd: number;
|
|
12
|
+
eventName: string;
|
|
13
|
+
valueCents: number;
|
|
14
|
+
reportedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type StripeUsageReportInsert = StripeUsageReportRow;
|
|
18
|
+
|
|
19
|
+
export interface IStripeUsageReportRepository {
|
|
20
|
+
insert(row: StripeUsageReportInsert): Promise<void>;
|
|
21
|
+
getByTenantAndPeriod(
|
|
22
|
+
tenant: string,
|
|
23
|
+
capability: string,
|
|
24
|
+
provider: string,
|
|
25
|
+
periodStart: number,
|
|
26
|
+
): Promise<StripeUsageReportRow | null>;
|
|
27
|
+
listByTenant(tenant: string, opts: { since: number; until: number }): Promise<StripeUsageReportRow[]>;
|
|
28
|
+
listAll(opts: { since: number; until: number }): Promise<StripeUsageReportRow[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class DrizzleStripeUsageReportRepository implements IStripeUsageReportRepository {
|
|
32
|
+
constructor(private readonly db: PlatformDb) {}
|
|
33
|
+
|
|
34
|
+
async insert(row: StripeUsageReportInsert): Promise<void> {
|
|
35
|
+
await this.db.insert(stripeUsageReports).values(row);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async getByTenantAndPeriod(
|
|
39
|
+
tenant: string,
|
|
40
|
+
capability: string,
|
|
41
|
+
provider: string,
|
|
42
|
+
periodStart: number,
|
|
43
|
+
): Promise<StripeUsageReportRow | null> {
|
|
44
|
+
const rows = await this.db
|
|
45
|
+
.select()
|
|
46
|
+
.from(stripeUsageReports)
|
|
47
|
+
.where(
|
|
48
|
+
and(
|
|
49
|
+
eq(stripeUsageReports.tenant, tenant),
|
|
50
|
+
eq(stripeUsageReports.capability, capability),
|
|
51
|
+
eq(stripeUsageReports.provider, provider),
|
|
52
|
+
eq(stripeUsageReports.periodStart, periodStart),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
return rows[0] ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async listByTenant(tenant: string, opts: { since: number; until: number }): Promise<StripeUsageReportRow[]> {
|
|
59
|
+
return this.db
|
|
60
|
+
.select()
|
|
61
|
+
.from(stripeUsageReports)
|
|
62
|
+
.where(
|
|
63
|
+
and(
|
|
64
|
+
eq(stripeUsageReports.tenant, tenant),
|
|
65
|
+
gte(stripeUsageReports.reportedAt, opts.since),
|
|
66
|
+
lt(stripeUsageReports.reportedAt, opts.until),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async listAll(opts: { since: number; until: number }): Promise<StripeUsageReportRow[]> {
|
|
72
|
+
return this.db
|
|
73
|
+
.select()
|
|
74
|
+
.from(stripeUsageReports)
|
|
75
|
+
.where(and(gte(stripeUsageReports.reportedAt, opts.since), lt(stripeUsageReports.reportedAt, opts.until)));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
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 type { PlatformDb } from "../../db/index.js";
|
|
5
|
+
import { billingPeriodSummaries } from "../../db/schema/meter-events.js";
|
|
6
|
+
import { tenantCustomers } from "../../db/schema/tenant-customers.js";
|
|
7
|
+
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
8
|
+
import { DrizzleBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
|
|
9
|
+
import type { MeteredPriceConfig } from "./metered-price-map.js";
|
|
10
|
+
import { DrizzleTenantCustomerRepository } from "./tenant-store.js";
|
|
11
|
+
import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
|
|
12
|
+
import { runUsageReportWriter } from "./usage-report-writer.js";
|
|
13
|
+
|
|
14
|
+
vi.mock("../../config/logger.js", () => ({
|
|
15
|
+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("runUsageReportWriter", () => {
|
|
19
|
+
let db: PlatformDb;
|
|
20
|
+
let pool: import("@electric-sql/pglite").PGlite;
|
|
21
|
+
let reportRepo: DrizzleStripeUsageReportRepository;
|
|
22
|
+
let tenantRepo: DrizzleTenantCustomerRepository;
|
|
23
|
+
let billingPeriodSummaryRepo: DrizzleBillingPeriodSummaryRepository;
|
|
24
|
+
|
|
25
|
+
const NOW = Date.now();
|
|
26
|
+
const PERIOD_START = 1700000000000;
|
|
27
|
+
const PERIOD_END = 1700003600000;
|
|
28
|
+
|
|
29
|
+
const mockCreateMeterEvent = vi.fn().mockResolvedValue({ identifier: "evt_xxx" });
|
|
30
|
+
|
|
31
|
+
const mockStripe = {
|
|
32
|
+
billing: {
|
|
33
|
+
meterEvents: { create: mockCreateMeterEvent },
|
|
34
|
+
},
|
|
35
|
+
} as unknown as import("stripe").default;
|
|
36
|
+
|
|
37
|
+
const priceMap = new Map<string, MeteredPriceConfig>([["chat-completions", { eventName: "chat_completions_usage" }]]);
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
const t = await createTestDb();
|
|
41
|
+
pool = t.pool;
|
|
42
|
+
db = t.db;
|
|
43
|
+
reportRepo = new DrizzleStripeUsageReportRepository(db);
|
|
44
|
+
tenantRepo = new DrizzleTenantCustomerRepository(db);
|
|
45
|
+
billingPeriodSummaryRepo = new DrizzleBillingPeriodSummaryRepository(db);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
await truncateAllTables(pool);
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await pool.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function seedMeteredTenant(tenant: string) {
|
|
58
|
+
await db.insert(tenantCustomers).values({
|
|
59
|
+
tenant,
|
|
60
|
+
processorCustomerId: `cus_${tenant}`,
|
|
61
|
+
processor: "stripe",
|
|
62
|
+
inferenceMode: "metered",
|
|
63
|
+
createdAt: NOW,
|
|
64
|
+
updatedAt: NOW,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function seedBillingPeriod(tenant: string, opts?: { capability?: string; totalCharge?: number }) {
|
|
69
|
+
await db.insert(billingPeriodSummaries).values({
|
|
70
|
+
id: crypto.randomUUID(),
|
|
71
|
+
tenant,
|
|
72
|
+
capability: opts?.capability ?? "chat-completions",
|
|
73
|
+
provider: "openrouter",
|
|
74
|
+
eventCount: 10,
|
|
75
|
+
totalCost: opts?.totalCharge ?? Credit.fromCents(100).toRaw(),
|
|
76
|
+
totalCharge: opts?.totalCharge ?? Credit.fromCents(100).toRaw(),
|
|
77
|
+
totalDuration: 0,
|
|
78
|
+
periodStart: PERIOD_START,
|
|
79
|
+
periodEnd: PERIOD_END,
|
|
80
|
+
updatedAt: NOW,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it("reports usage for metered tenants to Stripe and inserts local record", async () => {
|
|
85
|
+
await seedMeteredTenant("t1");
|
|
86
|
+
await seedBillingPeriod("t1");
|
|
87
|
+
|
|
88
|
+
const result = await runUsageReportWriter({
|
|
89
|
+
stripe: mockStripe,
|
|
90
|
+
tenantRepo,
|
|
91
|
+
billingPeriodSummaryRepo,
|
|
92
|
+
usageReportRepo: reportRepo,
|
|
93
|
+
meteredPriceMap: priceMap,
|
|
94
|
+
periodStart: PERIOD_START,
|
|
95
|
+
periodEnd: PERIOD_END,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(result.reportsCreated).toBe(1);
|
|
99
|
+
expect(result.errors).toHaveLength(0);
|
|
100
|
+
expect(mockCreateMeterEvent).toHaveBeenCalledOnce();
|
|
101
|
+
|
|
102
|
+
const stored = await reportRepo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", PERIOD_START);
|
|
103
|
+
expect(stored).toBeTruthy();
|
|
104
|
+
expect(stored?.valueCents).toBe(100);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("skips non-metered tenants", async () => {
|
|
108
|
+
// Insert a managed-mode tenant
|
|
109
|
+
await db.insert(tenantCustomers).values({
|
|
110
|
+
tenant: "t2",
|
|
111
|
+
processorCustomerId: "cus_t2",
|
|
112
|
+
processor: "stripe",
|
|
113
|
+
inferenceMode: "managed",
|
|
114
|
+
createdAt: NOW,
|
|
115
|
+
updatedAt: NOW,
|
|
116
|
+
});
|
|
117
|
+
await seedBillingPeriod("t2");
|
|
118
|
+
|
|
119
|
+
const result = await runUsageReportWriter({
|
|
120
|
+
stripe: mockStripe,
|
|
121
|
+
tenantRepo,
|
|
122
|
+
billingPeriodSummaryRepo,
|
|
123
|
+
usageReportRepo: reportRepo,
|
|
124
|
+
meteredPriceMap: priceMap,
|
|
125
|
+
periodStart: PERIOD_START,
|
|
126
|
+
periodEnd: PERIOD_END,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(result.tenantsProcessed).toBe(0);
|
|
130
|
+
expect(mockCreateMeterEvent).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("skips already-reported periods (idempotent)", async () => {
|
|
134
|
+
await seedMeteredTenant("t1");
|
|
135
|
+
await seedBillingPeriod("t1");
|
|
136
|
+
|
|
137
|
+
// Pre-insert a report for this period
|
|
138
|
+
await reportRepo.insert({
|
|
139
|
+
id: crypto.randomUUID(),
|
|
140
|
+
tenant: "t1",
|
|
141
|
+
capability: "chat-completions",
|
|
142
|
+
provider: "openrouter",
|
|
143
|
+
periodStart: PERIOD_START,
|
|
144
|
+
periodEnd: PERIOD_END,
|
|
145
|
+
eventName: "chat_completions_usage",
|
|
146
|
+
valueCents: 100,
|
|
147
|
+
reportedAt: NOW,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await runUsageReportWriter({
|
|
151
|
+
stripe: mockStripe,
|
|
152
|
+
tenantRepo,
|
|
153
|
+
billingPeriodSummaryRepo,
|
|
154
|
+
usageReportRepo: reportRepo,
|
|
155
|
+
meteredPriceMap: priceMap,
|
|
156
|
+
periodStart: PERIOD_START,
|
|
157
|
+
periodEnd: PERIOD_END,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.reportsSkipped).toBe(1);
|
|
161
|
+
expect(result.reportsCreated).toBe(0);
|
|
162
|
+
expect(mockCreateMeterEvent).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("skips zero-usage periods", async () => {
|
|
166
|
+
await seedMeteredTenant("t1");
|
|
167
|
+
await db.insert(billingPeriodSummaries).values({
|
|
168
|
+
id: crypto.randomUUID(),
|
|
169
|
+
tenant: "t1",
|
|
170
|
+
capability: "chat-completions",
|
|
171
|
+
provider: "openrouter",
|
|
172
|
+
eventCount: 0,
|
|
173
|
+
totalCost: 0,
|
|
174
|
+
totalCharge: 0,
|
|
175
|
+
totalDuration: 0,
|
|
176
|
+
periodStart: PERIOD_START,
|
|
177
|
+
periodEnd: PERIOD_END,
|
|
178
|
+
updatedAt: NOW,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = await runUsageReportWriter({
|
|
182
|
+
stripe: mockStripe,
|
|
183
|
+
tenantRepo,
|
|
184
|
+
billingPeriodSummaryRepo,
|
|
185
|
+
usageReportRepo: reportRepo,
|
|
186
|
+
meteredPriceMap: priceMap,
|
|
187
|
+
periodStart: PERIOD_START,
|
|
188
|
+
periodEnd: PERIOD_END,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(result.reportsCreated).toBe(0);
|
|
192
|
+
expect(mockCreateMeterEvent).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type Stripe from "stripe";
|
|
3
|
+
import { logger } from "../../config/logger.js";
|
|
4
|
+
import { Credit } from "../../credits/credit.js";
|
|
5
|
+
import type { IBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
|
|
6
|
+
import type { MeteredPriceConfig } from "./metered-price-map.js";
|
|
7
|
+
import type { ITenantCustomerRepository } from "./tenant-store.js";
|
|
8
|
+
import type { IStripeUsageReportRepository } from "./usage-report-repository.js";
|
|
9
|
+
|
|
10
|
+
export interface UsageReportWriterConfig {
|
|
11
|
+
stripe: Stripe;
|
|
12
|
+
tenantRepo: ITenantCustomerRepository;
|
|
13
|
+
billingPeriodSummaryRepo: IBillingPeriodSummaryRepository;
|
|
14
|
+
usageReportRepo: IStripeUsageReportRepository;
|
|
15
|
+
meteredPriceMap: ReadonlyMap<string, MeteredPriceConfig>;
|
|
16
|
+
/** Start of the billing period to report (unix epoch ms, inclusive). */
|
|
17
|
+
periodStart: number;
|
|
18
|
+
/** End of the billing period to report (unix epoch ms, exclusive). */
|
|
19
|
+
periodEnd: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UsageReportResult {
|
|
23
|
+
tenantsProcessed: number;
|
|
24
|
+
reportsCreated: number;
|
|
25
|
+
reportsSkipped: number;
|
|
26
|
+
errors: Array<{ tenant: string; capability: string; error: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Report metered usage to Stripe for all metered tenants in a given billing period.
|
|
31
|
+
*
|
|
32
|
+
* Uses Stripe's Billing Meters API (stripe.billing.meterEvents.create) — the v20
|
|
33
|
+
* replacement for the legacy subscriptionItems.createUsageRecord API.
|
|
34
|
+
*
|
|
35
|
+
* Flow:
|
|
36
|
+
* 1. Query billingPeriodSummaries for the period window
|
|
37
|
+
* 2. Filter to tenants with inferenceMode === "metered"
|
|
38
|
+
* 3. For each (tenant, capability, provider) tuple:
|
|
39
|
+
* a. Check if already reported (idempotent via stripeUsageReports unique index)
|
|
40
|
+
* b. Submit meter event to Stripe with idempotency identifier
|
|
41
|
+
* c. Insert into stripeUsageReports
|
|
42
|
+
*/
|
|
43
|
+
export async function runUsageReportWriter(cfg: UsageReportWriterConfig): Promise<UsageReportResult> {
|
|
44
|
+
const result: UsageReportResult = {
|
|
45
|
+
tenantsProcessed: 0,
|
|
46
|
+
reportsCreated: 0,
|
|
47
|
+
reportsSkipped: 0,
|
|
48
|
+
errors: [],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// 1. Find all metered tenants
|
|
52
|
+
const meteredTenants = await cfg.tenantRepo.listMetered();
|
|
53
|
+
|
|
54
|
+
if (meteredTenants.length === 0) return result;
|
|
55
|
+
|
|
56
|
+
const meteredTenantIds = new Set(meteredTenants.map((t) => t.tenant));
|
|
57
|
+
const customerIdMap = new Map(meteredTenants.map((t) => [t.tenant, t.processorCustomerId]));
|
|
58
|
+
|
|
59
|
+
// 2. Query billing period summaries for this period
|
|
60
|
+
const summaries = await cfg.billingPeriodSummaryRepo.listByPeriodWindow(cfg.periodStart, cfg.periodEnd);
|
|
61
|
+
|
|
62
|
+
// 3. Filter to metered tenants only
|
|
63
|
+
const meteredSummaries = summaries.filter((s) => meteredTenantIds.has(s.tenant));
|
|
64
|
+
|
|
65
|
+
const processedTenants = new Set<string>();
|
|
66
|
+
|
|
67
|
+
for (const summary of meteredSummaries) {
|
|
68
|
+
const { tenant, capability, provider, totalCharge } = summary;
|
|
69
|
+
|
|
70
|
+
// Skip zero usage
|
|
71
|
+
if (totalCharge <= 0) continue;
|
|
72
|
+
|
|
73
|
+
// Skip capabilities without a metered price config
|
|
74
|
+
const priceConfig = cfg.meteredPriceMap.get(capability);
|
|
75
|
+
if (!priceConfig) continue;
|
|
76
|
+
|
|
77
|
+
processedTenants.add(tenant);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Check if already reported (idempotent)
|
|
81
|
+
const existing = await cfg.usageReportRepo.getByTenantAndPeriod(
|
|
82
|
+
tenant,
|
|
83
|
+
capability,
|
|
84
|
+
provider,
|
|
85
|
+
summary.periodStart,
|
|
86
|
+
);
|
|
87
|
+
if (existing) {
|
|
88
|
+
result.reportsSkipped++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Look up Stripe customer ID
|
|
93
|
+
const customerId = customerIdMap.get(tenant);
|
|
94
|
+
if (!customerId) {
|
|
95
|
+
result.errors.push({ tenant, capability, error: "No Stripe customer ID" });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Convert nanodollars to cents
|
|
100
|
+
const valueCents = Credit.fromRaw(totalCharge).toCentsRounded();
|
|
101
|
+
|
|
102
|
+
// Build a stable idempotency identifier: tenant + capability + provider + periodStart
|
|
103
|
+
const identifier = `${tenant}:${capability}:${provider}:${summary.periodStart}`;
|
|
104
|
+
|
|
105
|
+
// Submit to Stripe Billing Meters API (v20+)
|
|
106
|
+
await cfg.stripe.billing.meterEvents.create({
|
|
107
|
+
event_name: priceConfig.eventName,
|
|
108
|
+
payload: {
|
|
109
|
+
stripe_customer_id: customerId,
|
|
110
|
+
value: String(valueCents),
|
|
111
|
+
},
|
|
112
|
+
identifier,
|
|
113
|
+
timestamp: Math.floor(summary.periodStart / 1000),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Insert local record
|
|
117
|
+
await cfg.usageReportRepo.insert({
|
|
118
|
+
id: crypto.randomUUID(),
|
|
119
|
+
tenant,
|
|
120
|
+
capability,
|
|
121
|
+
provider,
|
|
122
|
+
periodStart: summary.periodStart,
|
|
123
|
+
periodEnd: summary.periodEnd,
|
|
124
|
+
eventName: priceConfig.eventName,
|
|
125
|
+
valueCents,
|
|
126
|
+
reportedAt: Date.now(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
result.reportsCreated++;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
132
|
+
logger.error("Failed to report usage to Stripe", { tenant, capability, error: msg });
|
|
133
|
+
result.errors.push({ tenant, capability, error: msg });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
result.tenantsProcessed = processedTenants.size;
|
|
138
|
+
return result;
|
|
139
|
+
}
|