@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
|
@@ -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";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metered billing price configuration.
|
|
3
|
+
*
|
|
4
|
+
* Maps capability names to their Stripe Billing Meter event names.
|
|
5
|
+
* Stripe v20 uses the Billing Meters API (stripe.billing.meterEvents.create)
|
|
6
|
+
* instead of the legacy subscriptionItems.createUsageRecord API.
|
|
7
|
+
*
|
|
8
|
+
* Pattern follows loadCreditPriceMap() in credit-prices.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Maps capability name to its metered Stripe configuration. */
|
|
12
|
+
export interface MeteredPriceConfig {
|
|
13
|
+
/** Stripe Billing Meter event name (e.g., "chat_completions_usage"). */
|
|
14
|
+
eventName: string;
|
|
15
|
+
/** Stripe Billing Meter ID for reconciliation lookups (e.g., mtr_xxx). Optional. */
|
|
16
|
+
meterId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Maps capability name to MeteredPriceConfig. */
|
|
20
|
+
export type MeteredPriceMap = ReadonlyMap<string, MeteredPriceConfig>;
|
|
21
|
+
|
|
22
|
+
const METERED_CAPABILITIES: Array<{
|
|
23
|
+
capability: string;
|
|
24
|
+
eventEnvVar: string;
|
|
25
|
+
meterEnvVar: string;
|
|
26
|
+
defaultEvent: string;
|
|
27
|
+
}> = [
|
|
28
|
+
{
|
|
29
|
+
capability: "chat-completions",
|
|
30
|
+
eventEnvVar: "STRIPE_METERED_EVENT_CHAT",
|
|
31
|
+
meterEnvVar: "STRIPE_METERED_METER_CHAT",
|
|
32
|
+
defaultEvent: "chat_completions_usage",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
capability: "tts",
|
|
36
|
+
eventEnvVar: "STRIPE_METERED_EVENT_TTS",
|
|
37
|
+
meterEnvVar: "STRIPE_METERED_METER_TTS",
|
|
38
|
+
defaultEvent: "tts_usage",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
capability: "transcription",
|
|
42
|
+
eventEnvVar: "STRIPE_METERED_EVENT_TRANSCRIPTION",
|
|
43
|
+
meterEnvVar: "STRIPE_METERED_METER_TRANSCRIPTION",
|
|
44
|
+
defaultEvent: "transcription_usage",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
capability: "image-generation",
|
|
48
|
+
eventEnvVar: "STRIPE_METERED_EVENT_IMAGE",
|
|
49
|
+
meterEnvVar: "STRIPE_METERED_METER_IMAGE",
|
|
50
|
+
defaultEvent: "image_generation_usage",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
capability: "embeddings",
|
|
54
|
+
eventEnvVar: "STRIPE_METERED_EVENT_EMBEDDINGS",
|
|
55
|
+
meterEnvVar: "STRIPE_METERED_METER_EMBEDDINGS",
|
|
56
|
+
defaultEvent: "embeddings_usage",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
capability: "phone-inbound",
|
|
60
|
+
eventEnvVar: "STRIPE_METERED_EVENT_PHONE_IN",
|
|
61
|
+
meterEnvVar: "STRIPE_METERED_METER_PHONE_IN",
|
|
62
|
+
defaultEvent: "phone_inbound_usage",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
capability: "phone-outbound",
|
|
66
|
+
eventEnvVar: "STRIPE_METERED_EVENT_PHONE_OUT",
|
|
67
|
+
meterEnvVar: "STRIPE_METERED_METER_PHONE_OUT",
|
|
68
|
+
defaultEvent: "phone_outbound_usage",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
capability: "sms",
|
|
72
|
+
eventEnvVar: "STRIPE_METERED_EVENT_SMS",
|
|
73
|
+
meterEnvVar: "STRIPE_METERED_METER_SMS",
|
|
74
|
+
defaultEvent: "sms_usage",
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load metered price mappings from environment variables.
|
|
80
|
+
*
|
|
81
|
+
* Returns a Map from capability name -> MeteredPriceConfig.
|
|
82
|
+
* All capabilities are included with default event names.
|
|
83
|
+
* meterId is only populated when the env var is set.
|
|
84
|
+
*/
|
|
85
|
+
export function loadMeteredPriceMap(): MeteredPriceMap {
|
|
86
|
+
const map = new Map<string, MeteredPriceConfig>();
|
|
87
|
+
|
|
88
|
+
for (const { capability, eventEnvVar, meterEnvVar, defaultEvent } of METERED_CAPABILITIES) {
|
|
89
|
+
const eventName = process.env[eventEnvVar] ?? defaultEvent;
|
|
90
|
+
const meterId = process.env[meterEnvVar];
|
|
91
|
+
map.set(capability, { eventName, ...(meterId ? { meterId } : {}) });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
4
|
+
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
5
|
+
import { runStripeUsageReconciliation } from "./stripe-usage-reconciliation.js";
|
|
6
|
+
import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../config/logger.js", () => ({
|
|
9
|
+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("runStripeUsageReconciliation", () => {
|
|
13
|
+
let db: PlatformDb;
|
|
14
|
+
let pool: import("@electric-sql/pglite").PGlite;
|
|
15
|
+
let reportRepo: DrizzleStripeUsageReportRepository;
|
|
16
|
+
|
|
17
|
+
const NOW = Date.now();
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
const t = await createTestDb();
|
|
21
|
+
pool = t.pool;
|
|
22
|
+
db = t.db;
|
|
23
|
+
reportRepo = new DrizzleStripeUsageReportRepository(db);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
await truncateAllTables(pool);
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await pool.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns empty result when no local reports exist", async () => {
|
|
36
|
+
const mockStripe = {
|
|
37
|
+
billing: {
|
|
38
|
+
meters: { listEventSummaries: vi.fn().mockResolvedValue({ data: [] }) },
|
|
39
|
+
},
|
|
40
|
+
} as unknown as import("stripe").default;
|
|
41
|
+
|
|
42
|
+
const result = await runStripeUsageReconciliation({
|
|
43
|
+
stripe: mockStripe,
|
|
44
|
+
usageReportRepo: reportRepo,
|
|
45
|
+
targetDate: new Date().toISOString().slice(0, 10),
|
|
46
|
+
flagThresholdCents: 10,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.tenantsChecked).toBe(0);
|
|
50
|
+
expect(result.discrepancies).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("detects drift when local valueCents differs from Stripe aggregated_value", async () => {
|
|
54
|
+
await reportRepo.insert({
|
|
55
|
+
id: crypto.randomUUID(),
|
|
56
|
+
tenant: "t1",
|
|
57
|
+
capability: "chat-completions",
|
|
58
|
+
provider: "openrouter",
|
|
59
|
+
periodStart: 1700000000000,
|
|
60
|
+
periodEnd: 1700003600000,
|
|
61
|
+
eventName: "chat_completions_usage",
|
|
62
|
+
valueCents: 100,
|
|
63
|
+
reportedAt: NOW,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Stripe says it only received 80
|
|
67
|
+
const mockStripe = {
|
|
68
|
+
billing: {
|
|
69
|
+
meters: {
|
|
70
|
+
listEventSummaries: vi.fn().mockResolvedValue({
|
|
71
|
+
data: [{ aggregated_value: 80 }],
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
} as unknown as import("stripe").default;
|
|
76
|
+
|
|
77
|
+
const targetDate = new Date(NOW).toISOString().slice(0, 10);
|
|
78
|
+
// meterLookup returns "meterId:customerId"
|
|
79
|
+
const result = await runStripeUsageReconciliation({
|
|
80
|
+
stripe: mockStripe,
|
|
81
|
+
usageReportRepo: reportRepo,
|
|
82
|
+
meterLookup: vi.fn().mockResolvedValue({ meterId: "mtr_123", customerId: "cus_t1" }),
|
|
83
|
+
targetDate,
|
|
84
|
+
flagThresholdCents: 10,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result.discrepancies).toHaveLength(1);
|
|
88
|
+
expect(result.discrepancies[0].driftCents).toBe(20);
|
|
89
|
+
expect(result.flagged).toContain("t1");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("no discrepancy when values match", async () => {
|
|
93
|
+
await reportRepo.insert({
|
|
94
|
+
id: crypto.randomUUID(),
|
|
95
|
+
tenant: "t1",
|
|
96
|
+
capability: "chat-completions",
|
|
97
|
+
provider: "openrouter",
|
|
98
|
+
periodStart: 1700000000000,
|
|
99
|
+
periodEnd: 1700003600000,
|
|
100
|
+
eventName: "chat_completions_usage",
|
|
101
|
+
valueCents: 100,
|
|
102
|
+
reportedAt: NOW,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const mockStripe = {
|
|
106
|
+
billing: {
|
|
107
|
+
meters: {
|
|
108
|
+
listEventSummaries: vi.fn().mockResolvedValue({
|
|
109
|
+
data: [{ aggregated_value: 100 }],
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
} as unknown as import("stripe").default;
|
|
114
|
+
|
|
115
|
+
const targetDate = new Date(NOW).toISOString().slice(0, 10);
|
|
116
|
+
const result = await runStripeUsageReconciliation({
|
|
117
|
+
stripe: mockStripe,
|
|
118
|
+
usageReportRepo: reportRepo,
|
|
119
|
+
meterLookup: vi.fn().mockResolvedValue({ meterId: "mtr_123", customerId: "cus_t1" }),
|
|
120
|
+
targetDate,
|
|
121
|
+
flagThresholdCents: 10,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result.discrepancies).toHaveLength(0);
|
|
125
|
+
expect(result.flagged).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { logger } from "../../config/logger.js";
|
|
3
|
+
import type { IStripeUsageReportRepository, StripeUsageReportRow } from "./usage-report-repository.js";
|
|
4
|
+
|
|
5
|
+
export interface StripeUsageReconciliationConfig {
|
|
6
|
+
stripe: Stripe;
|
|
7
|
+
usageReportRepo: IStripeUsageReportRepository;
|
|
8
|
+
/** Date to reconcile, as YYYY-MM-DD. */
|
|
9
|
+
targetDate: string;
|
|
10
|
+
/** Drift threshold in cents before flagging a tenant. Default: 10. */
|
|
11
|
+
flagThresholdCents?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Lookup function: (tenant, eventName) -> { meterId, customerId } | null.
|
|
14
|
+
* Used to look up the meter and customer for listEventSummaries.
|
|
15
|
+
*/
|
|
16
|
+
meterLookup?: (tenant: string, eventName: string) => Promise<{ meterId: string; customerId: string } | null>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UsageReconciliationResult {
|
|
20
|
+
date: string;
|
|
21
|
+
tenantsChecked: number;
|
|
22
|
+
discrepancies: Array<{
|
|
23
|
+
tenant: string;
|
|
24
|
+
capability: string;
|
|
25
|
+
localValueCents: number;
|
|
26
|
+
stripeQuantity: number;
|
|
27
|
+
driftCents: number;
|
|
28
|
+
}>;
|
|
29
|
+
flagged: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runStripeUsageReconciliation(
|
|
33
|
+
cfg: StripeUsageReconciliationConfig,
|
|
34
|
+
): Promise<UsageReconciliationResult> {
|
|
35
|
+
const threshold = cfg.flagThresholdCents ?? 10;
|
|
36
|
+
const dayStart = new Date(`${cfg.targetDate}T00:00:00Z`).getTime();
|
|
37
|
+
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
const result: UsageReconciliationResult = {
|
|
40
|
+
date: cfg.targetDate,
|
|
41
|
+
tenantsChecked: 0,
|
|
42
|
+
discrepancies: [],
|
|
43
|
+
flagged: [],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Get all local reports for this day (by reportedAt — when we submitted to Stripe)
|
|
47
|
+
const localReports = await cfg.usageReportRepo.listAll({ since: dayStart, until: dayEnd });
|
|
48
|
+
|
|
49
|
+
if (localReports.length === 0) return result;
|
|
50
|
+
|
|
51
|
+
// Group by tenant+capability+eventName so each distinct meter is reconciled separately
|
|
52
|
+
const grouped = new Map<string, StripeUsageReportRow[]>();
|
|
53
|
+
for (const report of localReports) {
|
|
54
|
+
const key = `${report.tenant}:${report.capability}:${report.eventName}`;
|
|
55
|
+
const arr = grouped.get(key) ?? [];
|
|
56
|
+
arr.push(report);
|
|
57
|
+
grouped.set(key, arr);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const checkedTenants = new Set<string>();
|
|
61
|
+
|
|
62
|
+
for (const [key, reports] of grouped) {
|
|
63
|
+
const parts = key.split(":");
|
|
64
|
+
const tenant = parts[0];
|
|
65
|
+
const capability = parts[1];
|
|
66
|
+
checkedTenants.add(tenant);
|
|
67
|
+
|
|
68
|
+
const localTotal = reports.reduce((sum, r) => sum + r.valueCents, 0);
|
|
69
|
+
|
|
70
|
+
// Need a customer ID and meter lookup to reconcile against Stripe
|
|
71
|
+
if (!cfg.meterLookup) continue;
|
|
72
|
+
|
|
73
|
+
const eventName = reports[0]?.eventName;
|
|
74
|
+
if (!eventName) continue;
|
|
75
|
+
|
|
76
|
+
const lookup = await cfg.meterLookup(tenant, eventName);
|
|
77
|
+
if (!lookup) {
|
|
78
|
+
logger.warn("Cannot reconcile: no meter ID for tenant+capability", { tenant, capability });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { meterId: resolvedMeterId, customerId } = lookup;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const startSec = Math.floor(dayStart / 1000);
|
|
86
|
+
const endSec = Math.floor(dayEnd / 1000);
|
|
87
|
+
|
|
88
|
+
const summaries = await cfg.stripe.billing.meters.listEventSummaries(resolvedMeterId, {
|
|
89
|
+
customer: customerId,
|
|
90
|
+
start_time: startSec,
|
|
91
|
+
end_time: endSec,
|
|
92
|
+
limit: 1,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const stripeTotal = summaries.data[0]?.aggregated_value ?? 0;
|
|
96
|
+
const drift = Math.abs(localTotal - stripeTotal);
|
|
97
|
+
|
|
98
|
+
if (drift > 0) {
|
|
99
|
+
result.discrepancies.push({
|
|
100
|
+
tenant,
|
|
101
|
+
capability,
|
|
102
|
+
localValueCents: localTotal,
|
|
103
|
+
stripeQuantity: stripeTotal,
|
|
104
|
+
driftCents: drift,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (drift >= threshold) {
|
|
108
|
+
if (!result.flagged.includes(tenant)) {
|
|
109
|
+
result.flagged.push(tenant);
|
|
110
|
+
}
|
|
111
|
+
logger.warn("Stripe usage reconciliation: drift exceeds threshold", {
|
|
112
|
+
tenant,
|
|
113
|
+
capability,
|
|
114
|
+
localTotal,
|
|
115
|
+
stripeTotal,
|
|
116
|
+
drift,
|
|
117
|
+
threshold,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
123
|
+
logger.error("Failed to reconcile Stripe usage", { tenant, capability, error: msg });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
result.tenantsChecked = checkedTenants.size;
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
@@ -14,6 +14,7 @@ export interface ITenantCustomerRepository {
|
|
|
14
14
|
setInferenceMode(tenant: string, mode: string): Promise<void>;
|
|
15
15
|
list(): Promise<TenantCustomerRow[]>;
|
|
16
16
|
buildCustomerIdMap(): Promise<Record<string, string>>;
|
|
17
|
+
listMetered(): Promise<Array<{ tenant: string; processorCustomerId: string }>>;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -116,6 +117,14 @@ export class DrizzleTenantCustomerRepository implements ITenantCustomerRepositor
|
|
|
116
117
|
return rows.map(mapRow);
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/** Return all tenants with inferenceMode === "metered". */
|
|
121
|
+
async listMetered(): Promise<Array<{ tenant: string; processorCustomerId: string }>> {
|
|
122
|
+
return this.db
|
|
123
|
+
.select({ tenant: tenantCustomers.tenant, processorCustomerId: tenantCustomers.processorCustomerId })
|
|
124
|
+
.from(tenantCustomers)
|
|
125
|
+
.where(eq(tenantCustomers.inferenceMode, "metered"));
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
/** Build a tenant -> processor_customer_id map for use with UsageAggregationWorker. */
|
|
120
129
|
async buildCustomerIdMap(): Promise<Record<string, string>> {
|
|
121
130
|
const rows = await this.db
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
4
|
+
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
5
|
+
import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
|
|
6
|
+
|
|
7
|
+
describe("DrizzleStripeUsageReportRepository", () => {
|
|
8
|
+
let db: PlatformDb;
|
|
9
|
+
let pool: import("@electric-sql/pglite").PGlite;
|
|
10
|
+
let repo: DrizzleStripeUsageReportRepository;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const t = await createTestDb();
|
|
14
|
+
pool = t.pool;
|
|
15
|
+
db = t.db;
|
|
16
|
+
repo = new DrizzleStripeUsageReportRepository(db);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await truncateAllTables(pool);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await pool.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("inserts a usage report row", async () => {
|
|
28
|
+
const row = {
|
|
29
|
+
id: crypto.randomUUID(),
|
|
30
|
+
tenant: "t1",
|
|
31
|
+
capability: "chat-completions",
|
|
32
|
+
provider: "openrouter",
|
|
33
|
+
periodStart: 1700000000000,
|
|
34
|
+
periodEnd: 1700003600000,
|
|
35
|
+
eventName: "chat_completions_usage",
|
|
36
|
+
valueCents: 150,
|
|
37
|
+
reportedAt: Date.now(),
|
|
38
|
+
};
|
|
39
|
+
await repo.insert(row);
|
|
40
|
+
const found = await repo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", 1700000000000);
|
|
41
|
+
expect(found).toBeTruthy();
|
|
42
|
+
expect(found?.valueCents).toBe(150);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns null for non-existent report", async () => {
|
|
46
|
+
const found = await repo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", 1700000000000);
|
|
47
|
+
expect(found).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects duplicate (tenant, capability, provider, periodStart)", async () => {
|
|
51
|
+
const base = {
|
|
52
|
+
tenant: "t1",
|
|
53
|
+
capability: "chat-completions",
|
|
54
|
+
provider: "openrouter",
|
|
55
|
+
periodStart: 1700000000000,
|
|
56
|
+
periodEnd: 1700003600000,
|
|
57
|
+
eventName: "chat_completions_usage",
|
|
58
|
+
valueCents: 100,
|
|
59
|
+
reportedAt: Date.now(),
|
|
60
|
+
};
|
|
61
|
+
await repo.insert({ ...base, id: crypto.randomUUID() });
|
|
62
|
+
await expect(repo.insert({ ...base, id: crypto.randomUUID() })).rejects.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("lists reports for a tenant in a date range", async () => {
|
|
66
|
+
const base = {
|
|
67
|
+
tenant: "t1",
|
|
68
|
+
capability: "chat-completions",
|
|
69
|
+
provider: "openrouter",
|
|
70
|
+
eventName: "chat_completions_usage",
|
|
71
|
+
valueCents: 50,
|
|
72
|
+
};
|
|
73
|
+
await repo.insert({ ...base, id: crypto.randomUUID(), periodStart: 100, periodEnd: 200, reportedAt: 300 });
|
|
74
|
+
await repo.insert({ ...base, id: crypto.randomUUID(), periodStart: 200, periodEnd: 300, reportedAt: 400 });
|
|
75
|
+
await repo.insert({
|
|
76
|
+
...base,
|
|
77
|
+
id: crypto.randomUUID(),
|
|
78
|
+
periodStart: 300,
|
|
79
|
+
periodEnd: 400,
|
|
80
|
+
reportedAt: 500,
|
|
81
|
+
tenant: "t2",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const results = await repo.listByTenant("t1", { since: 0, until: 500 });
|
|
85
|
+
expect(results).toHaveLength(2);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { and, eq, gte, lt } from "drizzle-orm";
|
|
2
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
3
|
+
import { stripeUsageReports } from "../../db/schema/tenant-customers.js";
|
|
4
|
+
|
|
5
|
+
export interface StripeUsageReportRow {
|
|
6
|
+
id: string;
|
|
7
|
+
tenant: string;
|
|
8
|
+
capability: string;
|
|
9
|
+
provider: string;
|
|
10
|
+
periodStart: number;
|
|
11
|
+
periodEnd: number;
|
|
12
|
+
eventName: string;
|
|
13
|
+
valueCents: number;
|
|
14
|
+
reportedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type StripeUsageReportInsert = StripeUsageReportRow;
|
|
18
|
+
|
|
19
|
+
export interface IStripeUsageReportRepository {
|
|
20
|
+
insert(row: StripeUsageReportInsert): Promise<void>;
|
|
21
|
+
getByTenantAndPeriod(
|
|
22
|
+
tenant: string,
|
|
23
|
+
capability: string,
|
|
24
|
+
provider: string,
|
|
25
|
+
periodStart: number,
|
|
26
|
+
): Promise<StripeUsageReportRow | null>;
|
|
27
|
+
listByTenant(tenant: string, opts: { since: number; until: number }): Promise<StripeUsageReportRow[]>;
|
|
28
|
+
listAll(opts: { since: number; until: number }): Promise<StripeUsageReportRow[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class DrizzleStripeUsageReportRepository implements IStripeUsageReportRepository {
|
|
32
|
+
constructor(private readonly db: PlatformDb) {}
|
|
33
|
+
|
|
34
|
+
async insert(row: StripeUsageReportInsert): Promise<void> {
|
|
35
|
+
await this.db.insert(stripeUsageReports).values(row);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async getByTenantAndPeriod(
|
|
39
|
+
tenant: string,
|
|
40
|
+
capability: string,
|
|
41
|
+
provider: string,
|
|
42
|
+
periodStart: number,
|
|
43
|
+
): Promise<StripeUsageReportRow | null> {
|
|
44
|
+
const rows = await this.db
|
|
45
|
+
.select()
|
|
46
|
+
.from(stripeUsageReports)
|
|
47
|
+
.where(
|
|
48
|
+
and(
|
|
49
|
+
eq(stripeUsageReports.tenant, tenant),
|
|
50
|
+
eq(stripeUsageReports.capability, capability),
|
|
51
|
+
eq(stripeUsageReports.provider, provider),
|
|
52
|
+
eq(stripeUsageReports.periodStart, periodStart),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
return rows[0] ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async listByTenant(tenant: string, opts: { since: number; until: number }): Promise<StripeUsageReportRow[]> {
|
|
59
|
+
return this.db
|
|
60
|
+
.select()
|
|
61
|
+
.from(stripeUsageReports)
|
|
62
|
+
.where(
|
|
63
|
+
and(
|
|
64
|
+
eq(stripeUsageReports.tenant, tenant),
|
|
65
|
+
gte(stripeUsageReports.reportedAt, opts.since),
|
|
66
|
+
lt(stripeUsageReports.reportedAt, opts.until),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async listAll(opts: { since: number; until: number }): Promise<StripeUsageReportRow[]> {
|
|
72
|
+
return this.db
|
|
73
|
+
.select()
|
|
74
|
+
.from(stripeUsageReports)
|
|
75
|
+
.where(and(gte(stripeUsageReports.reportedAt, opts.since), lt(stripeUsageReports.reportedAt, opts.until)));
|
|
76
|
+
}
|
|
77
|
+
}
|