@wopr-network/platform-core 1.44.1 → 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.
Files changed (47) hide show
  1. package/dist/billing/payment-processor.d.ts +2 -0
  2. package/dist/billing/stripe/billing-period-summary-repository.d.ts +22 -0
  3. package/dist/billing/stripe/billing-period-summary-repository.js +14 -0
  4. package/dist/billing/stripe/index.d.ts +10 -0
  5. package/dist/billing/stripe/index.js +5 -0
  6. package/dist/billing/stripe/metered-price-map.d.ts +26 -0
  7. package/dist/billing/stripe/metered-price-map.js +75 -0
  8. package/dist/billing/stripe/stripe-payment-processor.js +1 -0
  9. package/dist/billing/stripe/stripe-payment-processor.test.js +16 -1
  10. package/dist/billing/stripe/stripe-usage-reconciliation.d.ts +31 -0
  11. package/dist/billing/stripe/stripe-usage-reconciliation.js +84 -0
  12. package/dist/billing/stripe/stripe-usage-reconciliation.test.d.ts +1 -0
  13. package/dist/billing/stripe/stripe-usage-reconciliation.test.js +109 -0
  14. package/dist/billing/stripe/tenant-store.d.ts +9 -0
  15. package/dist/billing/stripe/tenant-store.js +7 -0
  16. package/dist/billing/stripe/usage-report-repository.d.ts +39 -0
  17. package/dist/billing/stripe/usage-report-repository.js +30 -0
  18. package/dist/billing/stripe/usage-report-repository.test.d.ts +1 -0
  19. package/dist/billing/stripe/usage-report-repository.test.js +77 -0
  20. package/dist/billing/stripe/usage-report-writer.d.ts +41 -0
  21. package/dist/billing/stripe/usage-report-writer.js +95 -0
  22. package/dist/billing/stripe/usage-report-writer.test.d.ts +1 -0
  23. package/dist/billing/stripe/usage-report-writer.test.js +167 -0
  24. package/dist/gateway/credit-gate.js +5 -0
  25. package/dist/gateway/credit-gate.test.js +53 -0
  26. package/dist/gateway/types.d.ts +2 -0
  27. package/dist/monetization/stripe/stripe-payment-processor.js +1 -0
  28. package/dist/monetization/stripe/stripe-payment-processor.test.js +16 -1
  29. package/package.json +1 -1
  30. package/src/billing/payment-processor.ts +2 -0
  31. package/src/billing/stripe/billing-period-summary-repository.ts +32 -0
  32. package/src/billing/stripe/index.ts +17 -0
  33. package/src/billing/stripe/metered-price-map.ts +95 -0
  34. package/src/billing/stripe/stripe-payment-processor.test.ts +16 -1
  35. package/src/billing/stripe/stripe-payment-processor.ts +1 -0
  36. package/src/billing/stripe/stripe-usage-reconciliation.test.ts +127 -0
  37. package/src/billing/stripe/stripe-usage-reconciliation.ts +129 -0
  38. package/src/billing/stripe/tenant-store.ts +9 -0
  39. package/src/billing/stripe/usage-report-repository.test.ts +87 -0
  40. package/src/billing/stripe/usage-report-repository.ts +77 -0
  41. package/src/billing/stripe/usage-report-writer.test.ts +194 -0
  42. package/src/billing/stripe/usage-report-writer.ts +139 -0
  43. package/src/gateway/credit-gate.test.ts +62 -0
  44. package/src/gateway/credit-gate.ts +6 -0
  45. package/src/gateway/types.ts +2 -0
  46. package/src/monetization/stripe/stripe-payment-processor.test.ts +16 -1
  47. package/src/monetization/stripe/stripe-payment-processor.ts +1 -0
@@ -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
+ }
@@ -56,6 +56,7 @@ function createMocks() {
56
56
  setInferenceMode: vi.fn(),
57
57
  list: vi.fn(),
58
58
  buildCustomerIdMap: vi.fn(),
59
+ listMetered: vi.fn(),
59
60
  };
60
61
 
61
62
  const creditLedger: ILedger = {
@@ -263,6 +264,7 @@ describe("StripePaymentProcessor", () => {
263
264
  amount_due: 500,
264
265
  status: "paid",
265
266
  invoice_pdf: "https://stripe.com/invoice.pdf",
267
+ hosted_invoice_url: "https://invoice.stripe.com/i/in_1",
266
268
  },
267
269
  {
268
270
  id: "in_2",
@@ -270,6 +272,7 @@ describe("StripePaymentProcessor", () => {
270
272
  amount_due: 1000,
271
273
  status: "open",
272
274
  invoice_pdf: null,
275
+ hosted_invoice_url: "https://invoice.stripe.com/i/in_2",
273
276
  },
274
277
  ],
275
278
  } as unknown as Stripe.Response<Stripe.ApiList<Stripe.Invoice>>);
@@ -282,6 +285,7 @@ describe("StripePaymentProcessor", () => {
282
285
  amountCents: 500,
283
286
  status: "paid",
284
287
  downloadUrl: "https://stripe.com/invoice.pdf",
288
+ hostedUrl: "https://invoice.stripe.com/i/in_1",
285
289
  },
286
290
  {
287
291
  id: "in_2",
@@ -289,6 +293,7 @@ describe("StripePaymentProcessor", () => {
289
293
  amountCents: 1000,
290
294
  status: "open",
291
295
  downloadUrl: "",
296
+ hostedUrl: "https://invoice.stripe.com/i/in_2",
292
297
  },
293
298
  ]);
294
299
  expect(mocks.stripe.invoices.list).toHaveBeenCalledWith({
@@ -300,11 +305,21 @@ describe("StripePaymentProcessor", () => {
300
305
  it("uses 'unknown' when invoice status is null", async () => {
301
306
  vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
302
307
  vi.mocked(mocks.stripe.invoices.list).mockResolvedValue({
303
- data: [{ id: "in_3", created: 1700000000, amount_due: 100, status: null, invoice_pdf: null }],
308
+ data: [
309
+ {
310
+ id: "in_3",
311
+ created: 1700000000,
312
+ amount_due: 100,
313
+ status: null,
314
+ invoice_pdf: null,
315
+ hosted_invoice_url: null,
316
+ },
317
+ ],
304
318
  } as unknown as Stripe.Response<Stripe.ApiList<Stripe.Invoice>>);
305
319
 
306
320
  const result = await processor.listInvoices("tenant-1");
307
321
  expect(result[0].status).toBe("unknown");
322
+ expect(result[0].hostedUrl).toBe("");
308
323
  });
309
324
  });
310
325
 
@@ -216,6 +216,7 @@ export class StripePaymentProcessor implements IPaymentProcessor {
216
216
  amountCents: inv.amount_due,
217
217
  status: inv.status ?? "unknown",
218
218
  downloadUrl: inv.invoice_pdf ?? "",
219
+ hostedUrl: inv.hosted_invoice_url ?? "",
219
220
  }));
220
221
  }
221
222
 
@@ -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
+ });
@@ -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
+ }