@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
@@ -124,4 +124,6 @@ export interface Invoice {
124
124
  status: string;
125
125
  /** URL to download the invoice PDF. Empty string if unavailable. */
126
126
  downloadUrl: string;
127
+ /** URL to the hosted invoice page. Empty string if unavailable. */
128
+ hostedUrl: string;
127
129
  }
@@ -0,0 +1,22 @@
1
+ import type { PlatformDb } from "../../db/index.js";
2
+ export interface BillingPeriodSummaryRow {
3
+ id: string;
4
+ tenant: string;
5
+ capability: string;
6
+ provider: string;
7
+ eventCount: number;
8
+ totalCost: number;
9
+ totalCharge: number;
10
+ totalDuration: number;
11
+ periodStart: number;
12
+ periodEnd: number;
13
+ updatedAt: number;
14
+ }
15
+ export interface IBillingPeriodSummaryRepository {
16
+ listByPeriodWindow(start: number, end: number): Promise<BillingPeriodSummaryRow[]>;
17
+ }
18
+ export declare class DrizzleBillingPeriodSummaryRepository implements IBillingPeriodSummaryRepository {
19
+ private readonly db;
20
+ constructor(db: PlatformDb);
21
+ listByPeriodWindow(start: number, end: number): Promise<BillingPeriodSummaryRow[]>;
22
+ }
@@ -0,0 +1,14 @@
1
+ import { and, gte, lte } from "drizzle-orm";
2
+ import { billingPeriodSummaries } from "../../db/schema/meter-events.js";
3
+ export class DrizzleBillingPeriodSummaryRepository {
4
+ db;
5
+ constructor(db) {
6
+ this.db = db;
7
+ }
8
+ async listByPeriodWindow(start, end) {
9
+ return this.db
10
+ .select()
11
+ .from(billingPeriodSummaries)
12
+ .where(and(gte(billingPeriodSummaries.periodStart, start), lte(billingPeriodSummaries.periodEnd, end)));
13
+ }
14
+ }
@@ -1,7 +1,11 @@
1
+ export type { BillingPeriodSummaryRow, IBillingPeriodSummaryRepository, } from "./billing-period-summary-repository.js";
2
+ export { DrizzleBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
1
3
  export { createCreditCheckoutSession, createVpsCheckoutSession } from "./checkout.js";
2
4
  export { createStripeClient, loadStripeConfig } from "./client.js";
3
5
  export type { CreditPriceMap, CreditPricePoint } from "./credit-prices.js";
4
6
  export { CREDIT_PRICE_POINTS, getConfiguredPriceIds, getCreditAmountForPurchase, loadCreditPriceMap, lookupCreditPrice, } from "./credit-prices.js";
7
+ export type { MeteredPriceConfig, MeteredPriceMap } from "./metered-price-map.js";
8
+ export { loadMeteredPriceMap } from "./metered-price-map.js";
5
9
  export type { DetachPaymentMethodOpts } from "./payment-methods.js";
6
10
  export { detachAllPaymentMethods, detachPaymentMethod } from "./payment-methods.js";
7
11
  export { createPortalSession } from "./portal.js";
@@ -9,6 +13,12 @@ export type { SetupIntentOpts } from "./setup-intent.js";
9
13
  export { createSetupIntent } from "./setup-intent.js";
10
14
  export type { StripePaymentProcessorDeps, StripeWebhookHandlerResult } from "./stripe-payment-processor.js";
11
15
  export { StripePaymentProcessor } from "./stripe-payment-processor.js";
16
+ export type { StripeUsageReconciliationConfig, UsageReconciliationResult } from "./stripe-usage-reconciliation.js";
17
+ export { runStripeUsageReconciliation } from "./stripe-usage-reconciliation.js";
12
18
  export type { ITenantCustomerRepository } from "./tenant-store.js";
13
19
  export { DrizzleTenantCustomerRepository, TenantCustomerRepository } from "./tenant-store.js";
14
20
  export type { CreditCheckoutOpts, PortalSessionOpts, StripeBillingConfig, TenantCustomerRow, VpsCheckoutOpts, } from "./types.js";
21
+ export type { IStripeUsageReportRepository, StripeUsageReportInsert, StripeUsageReportRow, } from "./usage-report-repository.js";
22
+ export { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
23
+ export type { UsageReportResult, UsageReportWriterConfig } from "./usage-report-writer.js";
24
+ export { runUsageReportWriter } from "./usage-report-writer.js";
@@ -1,8 +1,13 @@
1
+ export { DrizzleBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
1
2
  export { createCreditCheckoutSession, createVpsCheckoutSession } from "./checkout.js";
2
3
  export { createStripeClient, loadStripeConfig } from "./client.js";
3
4
  export { CREDIT_PRICE_POINTS, getConfiguredPriceIds, getCreditAmountForPurchase, loadCreditPriceMap, lookupCreditPrice, } from "./credit-prices.js";
5
+ export { loadMeteredPriceMap } from "./metered-price-map.js";
4
6
  export { detachAllPaymentMethods, detachPaymentMethod } from "./payment-methods.js";
5
7
  export { createPortalSession } from "./portal.js";
6
8
  export { createSetupIntent } from "./setup-intent.js";
7
9
  export { StripePaymentProcessor } from "./stripe-payment-processor.js";
10
+ export { runStripeUsageReconciliation } from "./stripe-usage-reconciliation.js";
8
11
  export { DrizzleTenantCustomerRepository, TenantCustomerRepository } from "./tenant-store.js";
12
+ export { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
13
+ export { runUsageReportWriter } from "./usage-report-writer.js";
@@ -0,0 +1,26 @@
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
+ /** Maps capability name to its metered Stripe configuration. */
11
+ export interface MeteredPriceConfig {
12
+ /** Stripe Billing Meter event name (e.g., "chat_completions_usage"). */
13
+ eventName: string;
14
+ /** Stripe Billing Meter ID for reconciliation lookups (e.g., mtr_xxx). Optional. */
15
+ meterId?: string;
16
+ }
17
+ /** Maps capability name to MeteredPriceConfig. */
18
+ export type MeteredPriceMap = ReadonlyMap<string, MeteredPriceConfig>;
19
+ /**
20
+ * Load metered price mappings from environment variables.
21
+ *
22
+ * Returns a Map from capability name -> MeteredPriceConfig.
23
+ * All capabilities are included with default event names.
24
+ * meterId is only populated when the env var is set.
25
+ */
26
+ export declare function loadMeteredPriceMap(): MeteredPriceMap;
@@ -0,0 +1,75 @@
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
+ const METERED_CAPABILITIES = [
11
+ {
12
+ capability: "chat-completions",
13
+ eventEnvVar: "STRIPE_METERED_EVENT_CHAT",
14
+ meterEnvVar: "STRIPE_METERED_METER_CHAT",
15
+ defaultEvent: "chat_completions_usage",
16
+ },
17
+ {
18
+ capability: "tts",
19
+ eventEnvVar: "STRIPE_METERED_EVENT_TTS",
20
+ meterEnvVar: "STRIPE_METERED_METER_TTS",
21
+ defaultEvent: "tts_usage",
22
+ },
23
+ {
24
+ capability: "transcription",
25
+ eventEnvVar: "STRIPE_METERED_EVENT_TRANSCRIPTION",
26
+ meterEnvVar: "STRIPE_METERED_METER_TRANSCRIPTION",
27
+ defaultEvent: "transcription_usage",
28
+ },
29
+ {
30
+ capability: "image-generation",
31
+ eventEnvVar: "STRIPE_METERED_EVENT_IMAGE",
32
+ meterEnvVar: "STRIPE_METERED_METER_IMAGE",
33
+ defaultEvent: "image_generation_usage",
34
+ },
35
+ {
36
+ capability: "embeddings",
37
+ eventEnvVar: "STRIPE_METERED_EVENT_EMBEDDINGS",
38
+ meterEnvVar: "STRIPE_METERED_METER_EMBEDDINGS",
39
+ defaultEvent: "embeddings_usage",
40
+ },
41
+ {
42
+ capability: "phone-inbound",
43
+ eventEnvVar: "STRIPE_METERED_EVENT_PHONE_IN",
44
+ meterEnvVar: "STRIPE_METERED_METER_PHONE_IN",
45
+ defaultEvent: "phone_inbound_usage",
46
+ },
47
+ {
48
+ capability: "phone-outbound",
49
+ eventEnvVar: "STRIPE_METERED_EVENT_PHONE_OUT",
50
+ meterEnvVar: "STRIPE_METERED_METER_PHONE_OUT",
51
+ defaultEvent: "phone_outbound_usage",
52
+ },
53
+ {
54
+ capability: "sms",
55
+ eventEnvVar: "STRIPE_METERED_EVENT_SMS",
56
+ meterEnvVar: "STRIPE_METERED_METER_SMS",
57
+ defaultEvent: "sms_usage",
58
+ },
59
+ ];
60
+ /**
61
+ * Load metered price mappings from environment variables.
62
+ *
63
+ * Returns a Map from capability name -> MeteredPriceConfig.
64
+ * All capabilities are included with default event names.
65
+ * meterId is only populated when the env var is set.
66
+ */
67
+ export function loadMeteredPriceMap() {
68
+ const map = new Map();
69
+ for (const { capability, eventEnvVar, meterEnvVar, defaultEvent } of METERED_CAPABILITIES) {
70
+ const eventName = process.env[eventEnvVar] ?? defaultEvent;
71
+ const meterId = process.env[meterEnvVar];
72
+ map.set(capability, { eventName, ...(meterId ? { meterId } : {}) });
73
+ }
74
+ return map;
75
+ }
@@ -137,6 +137,7 @@ export class StripePaymentProcessor {
137
137
  amountCents: inv.amount_due,
138
138
  status: inv.status ?? "unknown",
139
139
  downloadUrl: inv.invoice_pdf ?? "",
140
+ hostedUrl: inv.hosted_invoice_url ?? "",
140
141
  }));
141
142
  }
142
143
  async charge(opts) {
@@ -43,6 +43,7 @@ function createMocks() {
43
43
  setInferenceMode: vi.fn(),
44
44
  list: vi.fn(),
45
45
  buildCustomerIdMap: vi.fn(),
46
+ listMetered: vi.fn(),
46
47
  };
47
48
  const creditLedger = {
48
49
  post: vi.fn(),
@@ -210,6 +211,7 @@ describe("StripePaymentProcessor", () => {
210
211
  amount_due: 500,
211
212
  status: "paid",
212
213
  invoice_pdf: "https://stripe.com/invoice.pdf",
214
+ hosted_invoice_url: "https://invoice.stripe.com/i/in_1",
213
215
  },
214
216
  {
215
217
  id: "in_2",
@@ -217,6 +219,7 @@ describe("StripePaymentProcessor", () => {
217
219
  amount_due: 1000,
218
220
  status: "open",
219
221
  invoice_pdf: null,
222
+ hosted_invoice_url: "https://invoice.stripe.com/i/in_2",
220
223
  },
221
224
  ],
222
225
  });
@@ -228,6 +231,7 @@ describe("StripePaymentProcessor", () => {
228
231
  amountCents: 500,
229
232
  status: "paid",
230
233
  downloadUrl: "https://stripe.com/invoice.pdf",
234
+ hostedUrl: "https://invoice.stripe.com/i/in_1",
231
235
  },
232
236
  {
233
237
  id: "in_2",
@@ -235,6 +239,7 @@ describe("StripePaymentProcessor", () => {
235
239
  amountCents: 1000,
236
240
  status: "open",
237
241
  downloadUrl: "",
242
+ hostedUrl: "https://invoice.stripe.com/i/in_2",
238
243
  },
239
244
  ]);
240
245
  expect(mocks.stripe.invoices.list).toHaveBeenCalledWith({
@@ -245,10 +250,20 @@ describe("StripePaymentProcessor", () => {
245
250
  it("uses 'unknown' when invoice status is null", async () => {
246
251
  vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
247
252
  vi.mocked(mocks.stripe.invoices.list).mockResolvedValue({
248
- data: [{ id: "in_3", created: 1700000000, amount_due: 100, status: null, invoice_pdf: null }],
253
+ data: [
254
+ {
255
+ id: "in_3",
256
+ created: 1700000000,
257
+ amount_due: 100,
258
+ status: null,
259
+ invoice_pdf: null,
260
+ hosted_invoice_url: null,
261
+ },
262
+ ],
249
263
  });
250
264
  const result = await processor.listInvoices("tenant-1");
251
265
  expect(result[0].status).toBe("unknown");
266
+ expect(result[0].hostedUrl).toBe("");
252
267
  });
253
268
  });
254
269
  // --- charge ---
@@ -0,0 +1,31 @@
1
+ import type Stripe from "stripe";
2
+ import type { IStripeUsageReportRepository } from "./usage-report-repository.js";
3
+ export interface StripeUsageReconciliationConfig {
4
+ stripe: Stripe;
5
+ usageReportRepo: IStripeUsageReportRepository;
6
+ /** Date to reconcile, as YYYY-MM-DD. */
7
+ targetDate: string;
8
+ /** Drift threshold in cents before flagging a tenant. Default: 10. */
9
+ flagThresholdCents?: number;
10
+ /**
11
+ * Lookup function: (tenant, eventName) -> { meterId, customerId } | null.
12
+ * Used to look up the meter and customer for listEventSummaries.
13
+ */
14
+ meterLookup?: (tenant: string, eventName: string) => Promise<{
15
+ meterId: string;
16
+ customerId: string;
17
+ } | null>;
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
+ export declare function runStripeUsageReconciliation(cfg: StripeUsageReconciliationConfig): Promise<UsageReconciliationResult>;
@@ -0,0 +1,84 @@
1
+ import { logger } from "../../config/logger.js";
2
+ export async function runStripeUsageReconciliation(cfg) {
3
+ const threshold = cfg.flagThresholdCents ?? 10;
4
+ const dayStart = new Date(`${cfg.targetDate}T00:00:00Z`).getTime();
5
+ const dayEnd = dayStart + 24 * 60 * 60 * 1000;
6
+ const result = {
7
+ date: cfg.targetDate,
8
+ tenantsChecked: 0,
9
+ discrepancies: [],
10
+ flagged: [],
11
+ };
12
+ // Get all local reports for this day (by reportedAt — when we submitted to Stripe)
13
+ const localReports = await cfg.usageReportRepo.listAll({ since: dayStart, until: dayEnd });
14
+ if (localReports.length === 0)
15
+ return result;
16
+ // Group by tenant+capability+eventName so each distinct meter is reconciled separately
17
+ const grouped = new Map();
18
+ for (const report of localReports) {
19
+ const key = `${report.tenant}:${report.capability}:${report.eventName}`;
20
+ const arr = grouped.get(key) ?? [];
21
+ arr.push(report);
22
+ grouped.set(key, arr);
23
+ }
24
+ const checkedTenants = new Set();
25
+ for (const [key, reports] of grouped) {
26
+ const parts = key.split(":");
27
+ const tenant = parts[0];
28
+ const capability = parts[1];
29
+ checkedTenants.add(tenant);
30
+ const localTotal = reports.reduce((sum, r) => sum + r.valueCents, 0);
31
+ // Need a customer ID and meter lookup to reconcile against Stripe
32
+ if (!cfg.meterLookup)
33
+ continue;
34
+ const eventName = reports[0]?.eventName;
35
+ if (!eventName)
36
+ continue;
37
+ const lookup = await cfg.meterLookup(tenant, eventName);
38
+ if (!lookup) {
39
+ logger.warn("Cannot reconcile: no meter ID for tenant+capability", { tenant, capability });
40
+ continue;
41
+ }
42
+ const { meterId: resolvedMeterId, customerId } = lookup;
43
+ try {
44
+ const startSec = Math.floor(dayStart / 1000);
45
+ const endSec = Math.floor(dayEnd / 1000);
46
+ const summaries = await cfg.stripe.billing.meters.listEventSummaries(resolvedMeterId, {
47
+ customer: customerId,
48
+ start_time: startSec,
49
+ end_time: endSec,
50
+ limit: 1,
51
+ });
52
+ const stripeTotal = summaries.data[0]?.aggregated_value ?? 0;
53
+ const drift = Math.abs(localTotal - stripeTotal);
54
+ if (drift > 0) {
55
+ result.discrepancies.push({
56
+ tenant,
57
+ capability,
58
+ localValueCents: localTotal,
59
+ stripeQuantity: stripeTotal,
60
+ driftCents: drift,
61
+ });
62
+ if (drift >= threshold) {
63
+ if (!result.flagged.includes(tenant)) {
64
+ result.flagged.push(tenant);
65
+ }
66
+ logger.warn("Stripe usage reconciliation: drift exceeds threshold", {
67
+ tenant,
68
+ capability,
69
+ localTotal,
70
+ stripeTotal,
71
+ drift,
72
+ threshold,
73
+ });
74
+ }
75
+ }
76
+ }
77
+ catch (err) {
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ logger.error("Failed to reconcile Stripe usage", { tenant, capability, error: msg });
80
+ }
81
+ }
82
+ result.tenantsChecked = checkedTenants.size;
83
+ return result;
84
+ }
@@ -0,0 +1,109 @@
1
+ import crypto from "node:crypto";
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createTestDb, truncateAllTables } from "../../test/db.js";
4
+ import { runStripeUsageReconciliation } from "./stripe-usage-reconciliation.js";
5
+ import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
6
+ vi.mock("../../config/logger.js", () => ({
7
+ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
8
+ }));
9
+ describe("runStripeUsageReconciliation", () => {
10
+ let db;
11
+ let pool;
12
+ let reportRepo;
13
+ const NOW = Date.now();
14
+ beforeAll(async () => {
15
+ const t = await createTestDb();
16
+ pool = t.pool;
17
+ db = t.db;
18
+ reportRepo = new DrizzleStripeUsageReportRepository(db);
19
+ });
20
+ beforeEach(async () => {
21
+ await truncateAllTables(pool);
22
+ vi.clearAllMocks();
23
+ });
24
+ afterAll(async () => {
25
+ await pool.close();
26
+ });
27
+ it("returns empty result when no local reports exist", async () => {
28
+ const mockStripe = {
29
+ billing: {
30
+ meters: { listEventSummaries: vi.fn().mockResolvedValue({ data: [] }) },
31
+ },
32
+ };
33
+ const result = await runStripeUsageReconciliation({
34
+ stripe: mockStripe,
35
+ usageReportRepo: reportRepo,
36
+ targetDate: new Date().toISOString().slice(0, 10),
37
+ flagThresholdCents: 10,
38
+ });
39
+ expect(result.tenantsChecked).toBe(0);
40
+ expect(result.discrepancies).toEqual([]);
41
+ });
42
+ it("detects drift when local valueCents differs from Stripe aggregated_value", async () => {
43
+ await reportRepo.insert({
44
+ id: crypto.randomUUID(),
45
+ tenant: "t1",
46
+ capability: "chat-completions",
47
+ provider: "openrouter",
48
+ periodStart: 1700000000000,
49
+ periodEnd: 1700003600000,
50
+ eventName: "chat_completions_usage",
51
+ valueCents: 100,
52
+ reportedAt: NOW,
53
+ });
54
+ // Stripe says it only received 80
55
+ const mockStripe = {
56
+ billing: {
57
+ meters: {
58
+ listEventSummaries: vi.fn().mockResolvedValue({
59
+ data: [{ aggregated_value: 80 }],
60
+ }),
61
+ },
62
+ },
63
+ };
64
+ const targetDate = new Date(NOW).toISOString().slice(0, 10);
65
+ // meterLookup returns "meterId:customerId"
66
+ const result = await runStripeUsageReconciliation({
67
+ stripe: mockStripe,
68
+ usageReportRepo: reportRepo,
69
+ meterLookup: vi.fn().mockResolvedValue({ meterId: "mtr_123", customerId: "cus_t1" }),
70
+ targetDate,
71
+ flagThresholdCents: 10,
72
+ });
73
+ expect(result.discrepancies).toHaveLength(1);
74
+ expect(result.discrepancies[0].driftCents).toBe(20);
75
+ expect(result.flagged).toContain("t1");
76
+ });
77
+ it("no discrepancy when values match", async () => {
78
+ await reportRepo.insert({
79
+ id: crypto.randomUUID(),
80
+ tenant: "t1",
81
+ capability: "chat-completions",
82
+ provider: "openrouter",
83
+ periodStart: 1700000000000,
84
+ periodEnd: 1700003600000,
85
+ eventName: "chat_completions_usage",
86
+ valueCents: 100,
87
+ reportedAt: NOW,
88
+ });
89
+ const mockStripe = {
90
+ billing: {
91
+ meters: {
92
+ listEventSummaries: vi.fn().mockResolvedValue({
93
+ data: [{ aggregated_value: 100 }],
94
+ }),
95
+ },
96
+ },
97
+ };
98
+ const targetDate = new Date(NOW).toISOString().slice(0, 10);
99
+ const result = await runStripeUsageReconciliation({
100
+ stripe: mockStripe,
101
+ usageReportRepo: reportRepo,
102
+ meterLookup: vi.fn().mockResolvedValue({ meterId: "mtr_123", customerId: "cus_t1" }),
103
+ targetDate,
104
+ flagThresholdCents: 10,
105
+ });
106
+ expect(result.discrepancies).toHaveLength(0);
107
+ expect(result.flagged).toHaveLength(0);
108
+ });
109
+ });
@@ -15,6 +15,10 @@ export interface ITenantCustomerRepository {
15
15
  setInferenceMode(tenant: string, mode: string): Promise<void>;
16
16
  list(): Promise<TenantCustomerRow[]>;
17
17
  buildCustomerIdMap(): Promise<Record<string, string>>;
18
+ listMetered(): Promise<Array<{
19
+ tenant: string;
20
+ processorCustomerId: string;
21
+ }>>;
18
22
  }
19
23
  /**
20
24
  * Manages tenant-to-payment-processor customer mappings in PostgreSQL.
@@ -50,6 +54,11 @@ export declare class DrizzleTenantCustomerRepository implements ITenantCustomerR
50
54
  setInferenceMode(tenant: string, mode: string): Promise<void>;
51
55
  /** List all tenants with processor mappings. */
52
56
  list(): Promise<TenantCustomerRow[]>;
57
+ /** Return all tenants with inferenceMode === "metered". */
58
+ listMetered(): Promise<Array<{
59
+ tenant: string;
60
+ processorCustomerId: string;
61
+ }>>;
53
62
  /** Build a tenant -> processor_customer_id map for use with UsageAggregationWorker. */
54
63
  buildCustomerIdMap(): Promise<Record<string, string>>;
55
64
  }
@@ -87,6 +87,13 @@ export class DrizzleTenantCustomerRepository {
87
87
  const rows = await this.db.select().from(tenantCustomers).orderBy(desc(tenantCustomers.createdAt));
88
88
  return rows.map(mapRow);
89
89
  }
90
+ /** Return all tenants with inferenceMode === "metered". */
91
+ async listMetered() {
92
+ return this.db
93
+ .select({ tenant: tenantCustomers.tenant, processorCustomerId: tenantCustomers.processorCustomerId })
94
+ .from(tenantCustomers)
95
+ .where(eq(tenantCustomers.inferenceMode, "metered"));
96
+ }
90
97
  /** Build a tenant -> processor_customer_id map for use with UsageAggregationWorker. */
91
98
  async buildCustomerIdMap() {
92
99
  const rows = await this.db
@@ -0,0 +1,39 @@
1
+ import type { PlatformDb } from "../../db/index.js";
2
+ export interface StripeUsageReportRow {
3
+ id: string;
4
+ tenant: string;
5
+ capability: string;
6
+ provider: string;
7
+ periodStart: number;
8
+ periodEnd: number;
9
+ eventName: string;
10
+ valueCents: number;
11
+ reportedAt: number;
12
+ }
13
+ export type StripeUsageReportInsert = StripeUsageReportRow;
14
+ export interface IStripeUsageReportRepository {
15
+ insert(row: StripeUsageReportInsert): Promise<void>;
16
+ getByTenantAndPeriod(tenant: string, capability: string, provider: string, periodStart: number): Promise<StripeUsageReportRow | null>;
17
+ listByTenant(tenant: string, opts: {
18
+ since: number;
19
+ until: number;
20
+ }): Promise<StripeUsageReportRow[]>;
21
+ listAll(opts: {
22
+ since: number;
23
+ until: number;
24
+ }): Promise<StripeUsageReportRow[]>;
25
+ }
26
+ export declare class DrizzleStripeUsageReportRepository implements IStripeUsageReportRepository {
27
+ private readonly db;
28
+ constructor(db: PlatformDb);
29
+ insert(row: StripeUsageReportInsert): Promise<void>;
30
+ getByTenantAndPeriod(tenant: string, capability: string, provider: string, periodStart: number): Promise<StripeUsageReportRow | null>;
31
+ listByTenant(tenant: string, opts: {
32
+ since: number;
33
+ until: number;
34
+ }): Promise<StripeUsageReportRow[]>;
35
+ listAll(opts: {
36
+ since: number;
37
+ until: number;
38
+ }): Promise<StripeUsageReportRow[]>;
39
+ }
@@ -0,0 +1,30 @@
1
+ import { and, eq, gte, lt } from "drizzle-orm";
2
+ import { stripeUsageReports } from "../../db/schema/tenant-customers.js";
3
+ export class DrizzleStripeUsageReportRepository {
4
+ db;
5
+ constructor(db) {
6
+ this.db = db;
7
+ }
8
+ async insert(row) {
9
+ await this.db.insert(stripeUsageReports).values(row);
10
+ }
11
+ async getByTenantAndPeriod(tenant, capability, provider, periodStart) {
12
+ const rows = await this.db
13
+ .select()
14
+ .from(stripeUsageReports)
15
+ .where(and(eq(stripeUsageReports.tenant, tenant), eq(stripeUsageReports.capability, capability), eq(stripeUsageReports.provider, provider), eq(stripeUsageReports.periodStart, periodStart)));
16
+ return rows[0] ?? null;
17
+ }
18
+ async listByTenant(tenant, opts) {
19
+ return this.db
20
+ .select()
21
+ .from(stripeUsageReports)
22
+ .where(and(eq(stripeUsageReports.tenant, tenant), gte(stripeUsageReports.reportedAt, opts.since), lt(stripeUsageReports.reportedAt, opts.until)));
23
+ }
24
+ async listAll(opts) {
25
+ return this.db
26
+ .select()
27
+ .from(stripeUsageReports)
28
+ .where(and(gte(stripeUsageReports.reportedAt, opts.since), lt(stripeUsageReports.reportedAt, opts.until)));
29
+ }
30
+ }