@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.
- package/dist/billing/payment-processor.d.ts +2 -0
- 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.js +1 -0
- package/dist/billing/stripe/stripe-payment-processor.test.js +16 -1
- 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.js +1 -0
- package/dist/monetization/stripe/stripe-payment-processor.test.js +16 -1
- package/package.json +1 -1
- package/src/billing/payment-processor.ts +2 -0
- 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 +16 -1
- package/src/billing/stripe/stripe-payment-processor.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 +16 -1
- package/src/monetization/stripe/stripe-payment-processor.ts +1 -0
|
@@ -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
|
+
}
|
|
@@ -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: [
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|