@wopr-network/platform-core 1.45.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/stripe/billing-period-summary-repository.d.ts +22 -0
- package/dist/billing/stripe/billing-period-summary-repository.js +14 -0
- package/dist/billing/stripe/index.d.ts +10 -0
- package/dist/billing/stripe/index.js +5 -0
- package/dist/billing/stripe/metered-price-map.d.ts +26 -0
- package/dist/billing/stripe/metered-price-map.js +75 -0
- package/dist/billing/stripe/stripe-payment-processor.test.js +1 -0
- package/dist/billing/stripe/stripe-usage-reconciliation.d.ts +31 -0
- package/dist/billing/stripe/stripe-usage-reconciliation.js +84 -0
- package/dist/billing/stripe/stripe-usage-reconciliation.test.d.ts +1 -0
- package/dist/billing/stripe/stripe-usage-reconciliation.test.js +109 -0
- package/dist/billing/stripe/tenant-store.d.ts +9 -0
- package/dist/billing/stripe/tenant-store.js +7 -0
- package/dist/billing/stripe/usage-report-repository.d.ts +39 -0
- package/dist/billing/stripe/usage-report-repository.js +30 -0
- package/dist/billing/stripe/usage-report-repository.test.d.ts +1 -0
- package/dist/billing/stripe/usage-report-repository.test.js +77 -0
- package/dist/billing/stripe/usage-report-writer.d.ts +41 -0
- package/dist/billing/stripe/usage-report-writer.js +95 -0
- package/dist/billing/stripe/usage-report-writer.test.d.ts +1 -0
- package/dist/billing/stripe/usage-report-writer.test.js +167 -0
- package/dist/gateway/credit-gate.js +5 -0
- package/dist/gateway/credit-gate.test.js +53 -0
- package/dist/gateway/types.d.ts +2 -0
- package/dist/monetization/stripe/stripe-payment-processor.test.js +1 -0
- package/dist/trpc/index.d.ts +1 -0
- package/dist/trpc/index.js +1 -0
- package/dist/trpc/org-remove-payment-method-router.d.ts +23 -0
- package/dist/trpc/org-remove-payment-method-router.js +61 -0
- package/dist/trpc/org-remove-payment-method-router.test.d.ts +1 -0
- package/dist/trpc/org-remove-payment-method-router.test.js +166 -0
- package/package.json +1 -1
- package/src/billing/stripe/billing-period-summary-repository.ts +32 -0
- package/src/billing/stripe/index.ts +17 -0
- package/src/billing/stripe/metered-price-map.ts +95 -0
- package/src/billing/stripe/stripe-payment-processor.test.ts +1 -0
- package/src/billing/stripe/stripe-usage-reconciliation.test.ts +127 -0
- package/src/billing/stripe/stripe-usage-reconciliation.ts +129 -0
- package/src/billing/stripe/tenant-store.ts +9 -0
- package/src/billing/stripe/usage-report-repository.test.ts +87 -0
- package/src/billing/stripe/usage-report-repository.ts +77 -0
- package/src/billing/stripe/usage-report-writer.test.ts +194 -0
- package/src/billing/stripe/usage-report-writer.ts +139 -0
- package/src/gateway/credit-gate.test.ts +62 -0
- package/src/gateway/credit-gate.ts +6 -0
- package/src/gateway/types.ts +2 -0
- package/src/monetization/stripe/stripe-payment-processor.test.ts +1 -0
- package/src/trpc/index.ts +4 -0
- package/src/trpc/org-remove-payment-method-router.test.ts +188 -0
- package/src/trpc/org-remove-payment-method-router.ts +80 -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
|
+
}
|
|
@@ -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 {};
|
|
@@ -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>;
|