@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,77 @@
1
+ import crypto from "node:crypto";
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
3
+ import { createTestDb, truncateAllTables } from "../../test/db.js";
4
+ import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
5
+ describe("DrizzleStripeUsageReportRepository", () => {
6
+ let db;
7
+ let pool;
8
+ let repo;
9
+ beforeAll(async () => {
10
+ const t = await createTestDb();
11
+ pool = t.pool;
12
+ db = t.db;
13
+ repo = new DrizzleStripeUsageReportRepository(db);
14
+ });
15
+ beforeEach(async () => {
16
+ await truncateAllTables(pool);
17
+ });
18
+ afterAll(async () => {
19
+ await pool.close();
20
+ });
21
+ it("inserts a usage report row", async () => {
22
+ const row = {
23
+ id: crypto.randomUUID(),
24
+ tenant: "t1",
25
+ capability: "chat-completions",
26
+ provider: "openrouter",
27
+ periodStart: 1700000000000,
28
+ periodEnd: 1700003600000,
29
+ eventName: "chat_completions_usage",
30
+ valueCents: 150,
31
+ reportedAt: Date.now(),
32
+ };
33
+ await repo.insert(row);
34
+ const found = await repo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", 1700000000000);
35
+ expect(found).toBeTruthy();
36
+ expect(found?.valueCents).toBe(150);
37
+ });
38
+ it("returns null for non-existent report", async () => {
39
+ const found = await repo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", 1700000000000);
40
+ expect(found).toBeNull();
41
+ });
42
+ it("rejects duplicate (tenant, capability, provider, periodStart)", async () => {
43
+ const base = {
44
+ tenant: "t1",
45
+ capability: "chat-completions",
46
+ provider: "openrouter",
47
+ periodStart: 1700000000000,
48
+ periodEnd: 1700003600000,
49
+ eventName: "chat_completions_usage",
50
+ valueCents: 100,
51
+ reportedAt: Date.now(),
52
+ };
53
+ await repo.insert({ ...base, id: crypto.randomUUID() });
54
+ await expect(repo.insert({ ...base, id: crypto.randomUUID() })).rejects.toThrow();
55
+ });
56
+ it("lists reports for a tenant in a date range", async () => {
57
+ const base = {
58
+ tenant: "t1",
59
+ capability: "chat-completions",
60
+ provider: "openrouter",
61
+ eventName: "chat_completions_usage",
62
+ valueCents: 50,
63
+ };
64
+ await repo.insert({ ...base, id: crypto.randomUUID(), periodStart: 100, periodEnd: 200, reportedAt: 300 });
65
+ await repo.insert({ ...base, id: crypto.randomUUID(), periodStart: 200, periodEnd: 300, reportedAt: 400 });
66
+ await repo.insert({
67
+ ...base,
68
+ id: crypto.randomUUID(),
69
+ periodStart: 300,
70
+ periodEnd: 400,
71
+ reportedAt: 500,
72
+ tenant: "t2",
73
+ });
74
+ const results = await repo.listByTenant("t1", { since: 0, until: 500 });
75
+ expect(results).toHaveLength(2);
76
+ });
77
+ });
@@ -0,0 +1,41 @@
1
+ import type Stripe from "stripe";
2
+ import type { IBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
3
+ import type { MeteredPriceConfig } from "./metered-price-map.js";
4
+ import type { ITenantCustomerRepository } from "./tenant-store.js";
5
+ import type { IStripeUsageReportRepository } from "./usage-report-repository.js";
6
+ export interface UsageReportWriterConfig {
7
+ stripe: Stripe;
8
+ tenantRepo: ITenantCustomerRepository;
9
+ billingPeriodSummaryRepo: IBillingPeriodSummaryRepository;
10
+ usageReportRepo: IStripeUsageReportRepository;
11
+ meteredPriceMap: ReadonlyMap<string, MeteredPriceConfig>;
12
+ /** Start of the billing period to report (unix epoch ms, inclusive). */
13
+ periodStart: number;
14
+ /** End of the billing period to report (unix epoch ms, exclusive). */
15
+ periodEnd: number;
16
+ }
17
+ export interface UsageReportResult {
18
+ tenantsProcessed: number;
19
+ reportsCreated: number;
20
+ reportsSkipped: number;
21
+ errors: Array<{
22
+ tenant: string;
23
+ capability: string;
24
+ error: string;
25
+ }>;
26
+ }
27
+ /**
28
+ * Report metered usage to Stripe for all metered tenants in a given billing period.
29
+ *
30
+ * Uses Stripe's Billing Meters API (stripe.billing.meterEvents.create) — the v20
31
+ * replacement for the legacy subscriptionItems.createUsageRecord API.
32
+ *
33
+ * Flow:
34
+ * 1. Query billingPeriodSummaries for the period window
35
+ * 2. Filter to tenants with inferenceMode === "metered"
36
+ * 3. For each (tenant, capability, provider) tuple:
37
+ * a. Check if already reported (idempotent via stripeUsageReports unique index)
38
+ * b. Submit meter event to Stripe with idempotency identifier
39
+ * c. Insert into stripeUsageReports
40
+ */
41
+ export declare function runUsageReportWriter(cfg: UsageReportWriterConfig): Promise<UsageReportResult>;
@@ -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
+ });
@@ -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>;
@@ -140,6 +140,7 @@ export class StripePaymentProcessor {
140
140
  amountCents: inv.amount_due,
141
141
  status: inv.status ?? "unknown",
142
142
  downloadUrl: inv.invoice_pdf ?? "",
143
+ hostedUrl: inv.hosted_invoice_url ?? "",
143
144
  }));
144
145
  }
145
146
  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(),
@@ -216,6 +217,7 @@ describe("StripePaymentProcessor", () => {
216
217
  amount_due: 500,
217
218
  status: "paid",
218
219
  invoice_pdf: "https://stripe.com/invoice.pdf",
220
+ hosted_invoice_url: "https://invoice.stripe.com/i/in_1",
219
221
  },
220
222
  {
221
223
  id: "in_2",
@@ -223,6 +225,7 @@ describe("StripePaymentProcessor", () => {
223
225
  amount_due: 1000,
224
226
  status: "open",
225
227
  invoice_pdf: null,
228
+ hosted_invoice_url: "https://invoice.stripe.com/i/in_2",
226
229
  },
227
230
  ],
228
231
  });
@@ -234,6 +237,7 @@ describe("StripePaymentProcessor", () => {
234
237
  amountCents: 500,
235
238
  status: "paid",
236
239
  downloadUrl: "https://stripe.com/invoice.pdf",
240
+ hostedUrl: "https://invoice.stripe.com/i/in_1",
237
241
  },
238
242
  {
239
243
  id: "in_2",
@@ -241,6 +245,7 @@ describe("StripePaymentProcessor", () => {
241
245
  amountCents: 1000,
242
246
  status: "open",
243
247
  downloadUrl: "",
248
+ hostedUrl: "https://invoice.stripe.com/i/in_2",
244
249
  },
245
250
  ]);
246
251
  expect(mocks.stripe.invoices.list).toHaveBeenCalledWith({
@@ -251,10 +256,20 @@ describe("StripePaymentProcessor", () => {
251
256
  it("uses 'unknown' when invoice status is null", async () => {
252
257
  vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
253
258
  vi.mocked(mocks.stripe.invoices.list).mockResolvedValue({
254
- data: [{ id: "in_3", created: 1700000000, amount_due: 100, status: null, invoice_pdf: null }],
259
+ data: [
260
+ {
261
+ id: "in_3",
262
+ created: 1700000000,
263
+ amount_due: 100,
264
+ status: null,
265
+ invoice_pdf: null,
266
+ hosted_invoice_url: null,
267
+ },
268
+ ],
255
269
  });
256
270
  const result = await processor.listInvoices("tenant-1");
257
271
  expect(result[0].status).toBe("unknown");
272
+ expect(result[0].hostedUrl).toBe("");
258
273
  });
259
274
  });
260
275
  // --- charge ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.44.1",
3
+ "version": "1.46.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -147,4 +147,6 @@ export interface Invoice {
147
147
  status: string;
148
148
  /** URL to download the invoice PDF. Empty string if unavailable. */
149
149
  downloadUrl: string;
150
+ /** URL to the hosted invoice page. Empty string if unavailable. */
151
+ hostedUrl: string;
150
152
  }
@@ -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";