@wopr-network/platform-core 1.13.2 → 1.14.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/api/routes/admin-credits.d.ts +2 -2
- package/dist/api/routes/admin-credits.js +9 -4
- package/dist/api/routes/quota.d.ts +2 -2
- package/dist/api/routes/verify-email.d.ts +3 -3
- package/dist/backup/on-demand-snapshot-service.d.ts +2 -2
- package/dist/billing/payram/webhook.d.ts +3 -3
- package/dist/billing/payram/webhook.js +5 -1
- package/dist/billing/payram/webhook.test.js +5 -4
- package/dist/billing/stripe/stripe-payment-processor.d.ts +2 -2
- package/dist/billing/stripe/stripe-payment-processor.test.js +7 -0
- package/dist/billing/stripe/tenant-store.d.ts +1 -1
- package/dist/billing/stripe/tenant-store.js +1 -1
- package/dist/credits/auto-topup-charge.d.ts +2 -2
- package/dist/credits/auto-topup-charge.js +5 -1
- package/dist/credits/auto-topup-charge.test.js +5 -4
- package/dist/credits/auto-topup-usage.d.ts +2 -2
- package/dist/credits/auto-topup-usage.test.js +53 -12
- package/dist/credits/credit-expiry-cron.d.ts +2 -2
- package/dist/credits/credit-expiry-cron.js +7 -4
- package/dist/credits/credit-expiry-cron.test.js +25 -8
- package/dist/credits/credit-ledger.d.ts +2 -2
- package/dist/credits/credit-ledger.js +1 -1
- package/dist/credits/dividend-cron.d.ts +4 -6
- package/dist/credits/dividend-cron.js +10 -16
- package/dist/credits/dividend-cron.test.js +31 -44
- package/dist/credits/dividend-repository.js +19 -22
- package/dist/credits/dividend-repository.test.js +4 -3
- package/dist/credits/index.d.ts +4 -2
- package/dist/credits/index.js +2 -1
- package/dist/credits/ledger.d.ts +195 -0
- package/dist/credits/ledger.js +561 -0
- package/dist/credits/ledger.test.js +418 -0
- package/dist/credits/signup-grant.d.ts +2 -2
- package/dist/credits/signup-grant.js +4 -4
- package/dist/credits/signup-grant.test.js +5 -3
- package/dist/credits/trial-balance-cron.d.ts +19 -0
- package/dist/credits/trial-balance-cron.js +30 -0
- package/dist/credits/trial-balance-cron.test.js +55 -0
- package/dist/db/schema/gateway-service-keys.d.ts +109 -0
- package/dist/db/schema/gateway-service-keys.js +18 -0
- package/dist/db/schema/index.d.ts +2 -0
- package/dist/db/schema/index.js +2 -0
- package/dist/db/schema/ledger.d.ts +442 -0
- package/dist/db/schema/ledger.js +76 -0
- package/dist/gateway/credit-gate.d.ts +2 -2
- package/dist/gateway/credit-gate.js +5 -1
- package/dist/gateway/credit-gate.test.js +35 -33
- package/dist/gateway/gateway-routes.test.js +1 -1
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +1 -0
- package/dist/gateway/protocol/anthropic.js +1 -1
- package/dist/gateway/protocol/deps.d.ts +5 -5
- package/dist/gateway/protocol/openai.js +1 -1
- package/dist/gateway/proxy.d.ts +4 -4
- package/dist/gateway/route-mounting.test.js +1 -1
- package/dist/gateway/service-key-auth.d.ts +1 -1
- package/dist/gateway/service-key-auth.js +1 -1
- package/dist/gateway/service-key-repository.d.ts +27 -0
- package/dist/gateway/service-key-repository.js +64 -0
- package/dist/gateway/types.d.ts +5 -5
- package/dist/metering/reconciliation-cron.test.js +9 -8
- package/dist/metering/reconciliation-repository.js +12 -10
- package/dist/metering/reconciliation-repository.test.js +9 -8
- package/dist/monetization/affiliate/affiliate-admin-repository.js +10 -8
- package/dist/monetization/affiliate/affiliate-admin-repository.test.js +32 -13
- package/dist/monetization/affiliate/credit-match.d.ts +2 -2
- package/dist/monetization/affiliate/credit-match.js +4 -1
- package/dist/monetization/affiliate/credit-match.test.js +58 -13
- package/dist/monetization/affiliate/new-user-bonus.d.ts +2 -2
- package/dist/monetization/affiliate/new-user-bonus.js +4 -1
- package/dist/monetization/affiliate/new-user-bonus.test.js +4 -3
- package/dist/monetization/credits/auto-topup-charge.d.ts +2 -2
- package/dist/monetization/credits/auto-topup-charge.js +5 -1
- package/dist/monetization/credits/auto-topup-charge.test.js +5 -4
- package/dist/monetization/credits/auto-topup-usage.d.ts +2 -2
- package/dist/monetization/credits/auto-topup-usage.test.js +53 -12
- package/dist/monetization/credits/bot-billing.d.ts +3 -3
- package/dist/monetization/credits/bot-billing.test.js +18 -5
- package/dist/monetization/credits/credit-expiry-cron.test.js +25 -8
- package/dist/monetization/credits/dividend-cron.d.ts +2 -4
- package/dist/monetization/credits/dividend-cron.js +7 -4
- package/dist/monetization/credits/dividend-cron.test.js +26 -46
- package/dist/monetization/credits/dividend-repository.js +15 -24
- package/dist/monetization/credits/dividend-repository.test.js +4 -3
- package/dist/monetization/credits/index.d.ts +2 -2
- package/dist/monetization/credits/index.js +1 -1
- package/dist/monetization/credits/member-usage.test.js +23 -10
- package/dist/monetization/credits/phone-billing.d.ts +2 -2
- package/dist/monetization/credits/phone-billing.js +5 -1
- package/dist/monetization/credits/phone-billing.test.js +9 -12
- package/dist/monetization/credits/runtime-cron.d.ts +2 -2
- package/dist/monetization/credits/runtime-cron.js +32 -8
- package/dist/monetization/credits/runtime-cron.test.js +28 -27
- package/dist/monetization/credits/runtime-scheduler.d.ts +2 -2
- package/dist/monetization/credits/runtime-scheduler.test.js +1 -1
- package/dist/monetization/credits/signup-grant.test.js +5 -3
- package/dist/monetization/credits/storage-tier-cron.test.js +3 -2
- package/dist/monetization/credits/trial-balance-cron.test.js +42 -0
- package/dist/monetization/feature-gate.d.ts +3 -3
- package/dist/monetization/index.d.ts +3 -3
- package/dist/monetization/index.js +1 -1
- package/dist/monetization/metering/reconciliation-cron.test.js +9 -8
- package/dist/monetization/metering/reconciliation-repository.js +11 -10
- package/dist/monetization/metering/reconciliation-repository.test.js +9 -8
- package/dist/monetization/payram/webhook.d.ts +2 -2
- package/dist/monetization/payram/webhook.js +5 -1
- package/dist/monetization/payram/webhook.test.js +5 -4
- package/dist/monetization/promotions/engine.d.ts +2 -2
- package/dist/monetization/promotions/engine.js +4 -1
- package/dist/monetization/promotions/engine.test.js +3 -1
- package/dist/monetization/repository-types.d.ts +1 -1
- package/dist/monetization/socket/socket.d.ts +3 -3
- package/dist/monetization/stripe/stripe-payment-processor.d.ts +2 -2
- package/dist/monetization/stripe/stripe-payment-processor.test.js +7 -0
- package/dist/monetization/stripe/webhook.d.ts +2 -2
- package/dist/monetization/stripe/webhook.js +70 -6
- package/dist/monetization/stripe/webhook.test.js +20 -15
- package/dist/onboarding/onboarding-service.d.ts +2 -2
- package/dist/onboarding/onboarding-service.js +6 -2
- package/drizzle/migrations/0002_gateway_service_keys.sql +14 -0
- package/drizzle/migrations/0003_double_entry_ledger.sql +82 -0
- package/drizzle/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/api/routes/admin-credits.ts +11 -14
- package/src/api/routes/quota.ts +2 -2
- package/src/api/routes/verify-email.ts +4 -4
- package/src/backup/on-demand-snapshot-service.test.ts +3 -3
- package/src/backup/on-demand-snapshot-service.ts +3 -3
- package/src/billing/payram/webhook.test.ts +7 -5
- package/src/billing/payram/webhook.ts +8 -11
- package/src/billing/stripe/stripe-payment-processor.test.ts +10 -3
- package/src/billing/stripe/stripe-payment-processor.ts +3 -3
- package/src/billing/stripe/tenant-store.ts +1 -1
- package/src/credits/auto-topup-charge.test.ts +7 -5
- package/src/credits/auto-topup-charge.ts +7 -10
- package/src/credits/auto-topup-usage.test.ts +55 -13
- package/src/credits/auto-topup-usage.ts +2 -2
- package/src/credits/credit-expiry-cron.test.ts +26 -45
- package/src/credits/credit-expiry-cron.ts +9 -12
- package/src/credits/credit-ledger.ts +3 -3
- package/src/credits/dividend-cron.test.ts +38 -45
- package/src/credits/dividend-cron.ts +12 -26
- package/src/credits/dividend-repository.test.ts +4 -3
- package/src/credits/dividend-repository.ts +21 -23
- package/src/credits/index.ts +23 -4
- package/src/credits/ledger.test.ts +514 -0
- package/src/credits/ledger.ts +851 -0
- package/src/credits/signup-grant.test.ts +7 -4
- package/src/credits/signup-grant.ts +6 -12
- package/src/credits/trial-balance-cron.test.ts +68 -0
- package/src/credits/trial-balance-cron.ts +46 -0
- package/src/db/schema/gateway-service-keys.ts +23 -0
- package/src/db/schema/index.ts +2 -0
- package/src/db/schema/ledger.ts +94 -0
- package/src/gateway/credit-gate-wiring.test.ts +3 -3
- package/src/gateway/credit-gate.test.ts +35 -33
- package/src/gateway/credit-gate.ts +6 -10
- package/src/gateway/gateway-routes.test.ts +6 -6
- package/src/gateway/index.ts +2 -0
- package/src/gateway/protocol/anthropic.ts +2 -2
- package/src/gateway/protocol/deps.ts +5 -5
- package/src/gateway/protocol/openai.ts +2 -2
- package/src/gateway/proxy.ts +4 -4
- package/src/gateway/route-mounting.test.ts +3 -3
- package/src/gateway/service-key-auth.ts +4 -2
- package/src/gateway/service-key-repository.ts +87 -0
- package/src/gateway/types.ts +5 -5
- package/src/metering/reconciliation-cron.test.ts +10 -9
- package/src/metering/reconciliation-repository.test.ts +10 -9
- package/src/metering/reconciliation-repository.ts +14 -11
- package/src/monetization/affiliate/affiliate-admin-repository.test.ts +32 -19
- package/src/monetization/affiliate/affiliate-admin-repository.ts +16 -8
- package/src/monetization/affiliate/credit-match.test.ts +60 -14
- package/src/monetization/affiliate/credit-match.ts +6 -9
- package/src/monetization/affiliate/new-user-bonus.test.ts +6 -4
- package/src/monetization/affiliate/new-user-bonus.ts +6 -9
- package/src/monetization/credits/auto-topup-charge.test.ts +7 -5
- package/src/monetization/credits/auto-topup-charge.ts +7 -10
- package/src/monetization/credits/auto-topup-usage.test.ts +55 -13
- package/src/monetization/credits/auto-topup-usage.ts +2 -2
- package/src/monetization/credits/bot-billing.test.ts +20 -6
- package/src/monetization/credits/bot-billing.ts +3 -3
- package/src/monetization/credits/credit-expiry-cron.test.ts +26 -45
- package/src/monetization/credits/dividend-cron.test.ts +34 -48
- package/src/monetization/credits/dividend-cron.ts +9 -14
- package/src/monetization/credits/dividend-repository.test.ts +4 -3
- package/src/monetization/credits/dividend-repository.ts +19 -25
- package/src/monetization/credits/index.ts +4 -4
- package/src/monetization/credits/member-usage.test.ts +25 -11
- package/src/monetization/credits/phone-billing.test.ts +18 -26
- package/src/monetization/credits/phone-billing.ts +7 -10
- package/src/monetization/credits/runtime-cron.test.ts +29 -28
- package/src/monetization/credits/runtime-cron.ts +34 -58
- package/src/monetization/credits/runtime-scheduler.test.ts +1 -1
- package/src/monetization/credits/runtime-scheduler.ts +2 -2
- package/src/monetization/credits/signup-grant.test.ts +7 -4
- package/src/monetization/credits/storage-tier-cron.test.ts +5 -3
- package/src/monetization/credits/trial-balance-cron.test.ts +52 -0
- package/src/monetization/feature-gate.ts +3 -3
- package/src/monetization/index.ts +4 -4
- package/src/monetization/metering/reconciliation-cron.test.ts +10 -9
- package/src/monetization/metering/reconciliation-repository.test.ts +11 -9
- package/src/monetization/metering/reconciliation-repository.ts +13 -11
- package/src/monetization/payram/webhook.test.ts +7 -5
- package/src/monetization/payram/webhook.ts +7 -10
- package/src/monetization/promotions/engine.test.ts +6 -5
- package/src/monetization/promotions/engine.ts +6 -3
- package/src/monetization/repository-types.ts +1 -1
- package/src/monetization/socket/socket.ts +4 -4
- package/src/monetization/stripe/stripe-payment-processor.test.ts +10 -3
- package/src/monetization/stripe/stripe-payment-processor.ts +3 -3
- package/src/monetization/stripe/webhook.test.ts +22 -16
- package/src/monetization/stripe/webhook.ts +75 -50
- package/src/onboarding/onboarding-service.ts +8 -11
- package/dist/credits/credit-ledger-extra.test.js +0 -40
- package/dist/credits/credit-ledger.bench.js +0 -33
- package/dist/credits/credit-ledger.test.d.ts +0 -4
- package/dist/credits/credit-ledger.test.js +0 -203
- package/dist/credits/credit-transaction-repository.test.js +0 -232
- package/dist/monetization/credits/credit-ledger-extra.test.d.ts +0 -1
- package/dist/monetization/credits/credit-ledger-extra.test.js +0 -39
- package/dist/monetization/credits/credit-ledger.bench.d.ts +0 -1
- package/dist/monetization/credits/credit-ledger.bench.js +0 -32
- package/dist/monetization/credits/credit-ledger.test.d.ts +0 -4
- package/dist/monetization/credits/credit-ledger.test.js +0 -202
- package/dist/monetization/credits/credit-transaction-repository.test.d.ts +0 -1
- package/dist/monetization/credits/credit-transaction-repository.test.js +0 -232
- package/src/credits/credit-ledger-extra.test.ts +0 -57
- package/src/credits/credit-ledger.bench.ts +0 -56
- package/src/credits/credit-ledger.test.ts +0 -276
- package/src/credits/credit-transaction-repository.test.ts +0 -274
- package/src/monetization/credits/credit-ledger-extra.test.ts +0 -56
- package/src/monetization/credits/credit-ledger.bench.ts +0 -55
- package/src/monetization/credits/credit-ledger.test.ts +0 -275
- package/src/monetization/credits/credit-transaction-repository.test.ts +0 -274
- /package/dist/credits/{credit-ledger-extra.test.d.ts → ledger.test.d.ts} +0 -0
- /package/dist/credits/{credit-ledger.bench.d.ts → trial-balance-cron.test.d.ts} +0 -0
- /package/dist/{credits/credit-transaction-repository.test.d.ts → monetization/credits/trial-balance-cron.test.d.ts} +0 -0
|
@@ -71,7 +71,11 @@ export async function debitCredits(deps, tenantId, costUsd, margin, capability,
|
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
73
73
|
try {
|
|
74
|
-
await deps.creditLedger.debit(tenantId, chargeCredit, "adapter_usage",
|
|
74
|
+
await deps.creditLedger.debit(tenantId, chargeCredit, "adapter_usage", {
|
|
75
|
+
description: `Gateway ${capability} via ${provider}`,
|
|
76
|
+
allowNegative: true,
|
|
77
|
+
attributedUserId,
|
|
78
|
+
});
|
|
75
79
|
// Only fire on first zero-crossing (balance was positive before, now ≤ 0)
|
|
76
80
|
if (deps.onBalanceExhausted) {
|
|
77
81
|
const newBalance = await deps.creditLedger.balance(tenantId);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for gateway credit gate — grace buffer and credits_exhausted behavior (WOP-821).
|
|
3
3
|
*/
|
|
4
|
-
import { Credit,
|
|
4
|
+
import { Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
@@ -39,35 +39,36 @@ afterAll(async () => {
|
|
|
39
39
|
describe("creditBalanceCheck grace buffer", () => {
|
|
40
40
|
beforeEach(async () => {
|
|
41
41
|
await truncateAllTables(pool);
|
|
42
|
+
await new DrizzleLedger(db).seedSystemAccounts();
|
|
42
43
|
});
|
|
43
44
|
it("returns null when balance is above estimated cost (passes)", async () => {
|
|
44
|
-
const ledger = new
|
|
45
|
-
await ledger.credit("t1", Credit.fromCents(500), "purchase", "setup");
|
|
45
|
+
const ledger = new DrizzleLedger(db);
|
|
46
|
+
await ledger.credit("t1", Credit.fromCents(500), "purchase", { description: "setup" });
|
|
46
47
|
const c = await buildHonoContext("t1");
|
|
47
48
|
const deps = { creditLedger: ledger, topUpUrl: "/billing" };
|
|
48
49
|
expect(await creditBalanceCheck(c, deps, 1)).toBeNull();
|
|
49
50
|
});
|
|
50
51
|
it("returns null when balance is zero but within default grace buffer (passes)", async () => {
|
|
51
52
|
// Balance at exactly 0 — within the -50 grace buffer
|
|
52
|
-
const ledger = new
|
|
53
|
-
await ledger.credit("t1", Credit.fromCents(10), "purchase", "setup");
|
|
54
|
-
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", "drain");
|
|
53
|
+
const ledger = new DrizzleLedger(db);
|
|
54
|
+
await ledger.credit("t1", Credit.fromCents(10), "purchase", { description: "setup" });
|
|
55
|
+
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", { description: "drain" });
|
|
55
56
|
const c = await buildHonoContext("t1");
|
|
56
57
|
const deps = { creditLedger: ledger, topUpUrl: "/billing" };
|
|
57
58
|
expect(await creditBalanceCheck(c, deps, 0)).toBeNull();
|
|
58
59
|
});
|
|
59
60
|
it("returns null when balance is -49 (within 50-cent grace buffer)", async () => {
|
|
60
|
-
const ledger = new
|
|
61
|
-
await ledger.credit("t1", Credit.fromCents(1), "purchase", "setup");
|
|
62
|
-
await ledger.debit("t1", Credit.fromCents(50), "adapter_usage", "drain",
|
|
61
|
+
const ledger = new DrizzleLedger(db);
|
|
62
|
+
await ledger.credit("t1", Credit.fromCents(1), "purchase", { description: "setup" });
|
|
63
|
+
await ledger.debit("t1", Credit.fromCents(50), "adapter_usage", { description: "drain", allowNegative: true }); // balance = -49
|
|
63
64
|
const c = await buildHonoContext("t1");
|
|
64
65
|
const deps = { creditLedger: ledger, topUpUrl: "/billing" };
|
|
65
66
|
expect(await creditBalanceCheck(c, deps, 0)).toBeNull();
|
|
66
67
|
});
|
|
67
68
|
it("returns credits_exhausted when balance is at -50 (at grace buffer limit)", async () => {
|
|
68
|
-
const ledger = new
|
|
69
|
-
await ledger.credit("t1", Credit.fromCents(1), "purchase", "setup");
|
|
70
|
-
await ledger.debit("t1", Credit.fromCents(51), "adapter_usage", "drain",
|
|
69
|
+
const ledger = new DrizzleLedger(db);
|
|
70
|
+
await ledger.credit("t1", Credit.fromCents(1), "purchase", { description: "setup" });
|
|
71
|
+
await ledger.debit("t1", Credit.fromCents(51), "adapter_usage", { description: "drain", allowNegative: true }); // balance = -50
|
|
71
72
|
const c = await buildHonoContext("t1");
|
|
72
73
|
const deps = { creditLedger: ledger, topUpUrl: "/billing" };
|
|
73
74
|
const result = await creditBalanceCheck(c, deps, 0);
|
|
@@ -75,9 +76,9 @@ describe("creditBalanceCheck grace buffer", () => {
|
|
|
75
76
|
expect(result?.code).toBe("credits_exhausted");
|
|
76
77
|
});
|
|
77
78
|
it("returns credits_exhausted when balance is at -51 (beyond grace buffer)", async () => {
|
|
78
|
-
const ledger = new
|
|
79
|
-
await ledger.credit("t1", Credit.fromCents(1), "purchase", "setup");
|
|
80
|
-
await ledger.debit("t1", Credit.fromCents(52), "adapter_usage", "drain",
|
|
79
|
+
const ledger = new DrizzleLedger(db);
|
|
80
|
+
await ledger.credit("t1", Credit.fromCents(1), "purchase", { description: "setup" });
|
|
81
|
+
await ledger.debit("t1", Credit.fromCents(52), "adapter_usage", { description: "drain", allowNegative: true }); // balance = -51
|
|
81
82
|
const c = await buildHonoContext("t1");
|
|
82
83
|
const deps = { creditLedger: ledger, topUpUrl: "/billing" };
|
|
83
84
|
const result = await creditBalanceCheck(c, deps, 0);
|
|
@@ -85,9 +86,9 @@ describe("creditBalanceCheck grace buffer", () => {
|
|
|
85
86
|
expect(result?.code).toBe("credits_exhausted");
|
|
86
87
|
});
|
|
87
88
|
it("returns credits_exhausted when custom graceBufferCents=0 and balance is 0", async () => {
|
|
88
|
-
const ledger = new
|
|
89
|
-
await ledger.credit("t1", Credit.fromCents(10), "purchase", "setup");
|
|
90
|
-
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", "drain"); // balance = 0
|
|
89
|
+
const ledger = new DrizzleLedger(db);
|
|
90
|
+
await ledger.credit("t1", Credit.fromCents(10), "purchase", { description: "setup" });
|
|
91
|
+
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", { description: "drain" }); // balance = 0
|
|
91
92
|
const c = await buildHonoContext("t1");
|
|
92
93
|
const deps = { creditLedger: ledger, topUpUrl: "/billing", graceBufferCents: 0 };
|
|
93
94
|
const result = await creditBalanceCheck(c, deps, 0);
|
|
@@ -95,8 +96,8 @@ describe("creditBalanceCheck grace buffer", () => {
|
|
|
95
96
|
expect(result?.code).toBe("credits_exhausted");
|
|
96
97
|
});
|
|
97
98
|
it("returns insufficient_credits when balance positive but below estimated cost", async () => {
|
|
98
|
-
const ledger = new
|
|
99
|
-
await ledger.credit("t1", Credit.fromCents(5), "purchase", "setup");
|
|
99
|
+
const ledger = new DrizzleLedger(db);
|
|
100
|
+
await ledger.credit("t1", Credit.fromCents(5), "purchase", { description: "setup" });
|
|
100
101
|
const c = await buildHonoContext("t1");
|
|
101
102
|
const deps = { creditLedger: ledger, topUpUrl: "/billing" };
|
|
102
103
|
const result = await creditBalanceCheck(c, deps, 10);
|
|
@@ -110,26 +111,27 @@ describe("creditBalanceCheck grace buffer", () => {
|
|
|
110
111
|
describe("debitCredits with allowNegative and onBalanceExhausted", () => {
|
|
111
112
|
beforeEach(async () => {
|
|
112
113
|
await truncateAllTables(pool);
|
|
114
|
+
await new DrizzleLedger(db).seedSystemAccounts();
|
|
113
115
|
});
|
|
114
116
|
it("debit with cost that would exceed balance succeeds (allowNegative=true)", async () => {
|
|
115
|
-
const ledger = new
|
|
116
|
-
await ledger.credit("t1", Credit.fromCents(5), "purchase", "setup"); // balance = 5 cents
|
|
117
|
+
const ledger = new DrizzleLedger(db);
|
|
118
|
+
await ledger.credit("t1", Credit.fromCents(5), "purchase", { description: "setup" }); // balance = 5 cents
|
|
117
119
|
// costUsd = $0.10 = 10 cents, margin = 1.0
|
|
118
120
|
// This should push balance negative without throwing
|
|
119
121
|
await expect(debitCredits({ creditLedger: ledger, topUpUrl: "/billing" }, "t1", 0.1, 1.0, "chat-completions", "openrouter")).resolves.not.toThrow();
|
|
120
122
|
expect((await ledger.balance("t1")).isNegative()).toBe(true);
|
|
121
123
|
});
|
|
122
124
|
it("fires onBalanceExhausted when debit causes balance to cross zero", async () => {
|
|
123
|
-
const ledger = new
|
|
124
|
-
await ledger.credit("t1", Credit.fromCents(5), "purchase", "setup"); // balance = 5 cents
|
|
125
|
+
const ledger = new DrizzleLedger(db);
|
|
126
|
+
await ledger.credit("t1", Credit.fromCents(5), "purchase", { description: "setup" }); // balance = 5 cents
|
|
125
127
|
const onBalanceExhausted = vi.fn();
|
|
126
128
|
// costUsd = $0.10 = 10 cents with margin 1.0 → chargeCents = 10, pushes balance to -5
|
|
127
129
|
await debitCredits({ creditLedger: ledger, topUpUrl: "/billing", onBalanceExhausted }, "t1", 0.1, 1.0, "chat-completions", "openrouter");
|
|
128
130
|
expect(onBalanceExhausted).toHaveBeenCalledWith("t1", -5);
|
|
129
131
|
});
|
|
130
132
|
it("does NOT fire onBalanceExhausted when balance stays positive after debit", async () => {
|
|
131
|
-
const ledger = new
|
|
132
|
-
await ledger.credit("t1", Credit.fromCents(500), "purchase", "setup"); // balance = 500 cents
|
|
133
|
+
const ledger = new DrizzleLedger(db);
|
|
134
|
+
await ledger.credit("t1", Credit.fromCents(500), "purchase", { description: "setup" }); // balance = 500 cents
|
|
133
135
|
const onBalanceExhausted = vi.fn();
|
|
134
136
|
// costUsd = $0.01 = 1 cent → balance stays at 499
|
|
135
137
|
await debitCredits({ creditLedger: ledger, topUpUrl: "/billing", onBalanceExhausted }, "t1", 0.01, 1.0, "chat-completions", "openrouter");
|
|
@@ -137,8 +139,8 @@ describe("debitCredits with allowNegative and onBalanceExhausted", () => {
|
|
|
137
139
|
expect((await ledger.balance("t1")).greaterThan(Credit.ZERO)).toBe(true);
|
|
138
140
|
});
|
|
139
141
|
it("onBalanceExhausted callback receives correct tenantId and negative balance", async () => {
|
|
140
|
-
const ledger = new
|
|
141
|
-
await ledger.credit("t1", Credit.fromCents(3), "purchase", "setup"); // balance = 3 cents
|
|
142
|
+
const ledger = new DrizzleLedger(db);
|
|
143
|
+
await ledger.credit("t1", Credit.fromCents(3), "purchase", { description: "setup" }); // balance = 3 cents
|
|
142
144
|
const onBalanceExhausted = vi.fn();
|
|
143
145
|
// costUsd = $0.05 = 5 cents with margin 1.0 → pushes balance to -2
|
|
144
146
|
await debitCredits({ creditLedger: ledger, topUpUrl: "/billing", onBalanceExhausted }, "t1", 0.05, 1.0, "chat-completions", "openrouter");
|
|
@@ -146,17 +148,17 @@ describe("debitCredits with allowNegative and onBalanceExhausted", () => {
|
|
|
146
148
|
expect(onBalanceExhausted).toHaveBeenCalledWith("t1", -2);
|
|
147
149
|
});
|
|
148
150
|
it("calls onSpendAlertCrossed after successful debit", async () => {
|
|
149
|
-
const ledger = new
|
|
150
|
-
await ledger.credit("t1", Credit.fromCents(500), "purchase", "setup");
|
|
151
|
+
const ledger = new DrizzleLedger(db);
|
|
152
|
+
await ledger.credit("t1", Credit.fromCents(500), "purchase", { description: "setup" });
|
|
151
153
|
const onSpendAlertCrossed = vi.fn();
|
|
152
154
|
await debitCredits({ creditLedger: ledger, topUpUrl: "/billing", onSpendAlertCrossed }, "t1", 0.05, 1.0, "chat-completions", "openrouter");
|
|
153
155
|
expect(onSpendAlertCrossed).toHaveBeenCalledWith("t1");
|
|
154
156
|
});
|
|
155
157
|
it("does NOT fire onBalanceExhausted when balance was already negative before debit", async () => {
|
|
156
|
-
const ledger = new
|
|
158
|
+
const ledger = new DrizzleLedger(db);
|
|
157
159
|
// Start with negative balance: credit 5, debit 10 → balance = -5
|
|
158
|
-
await ledger.credit("t1", Credit.fromCents(5), "purchase", "setup");
|
|
159
|
-
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", "drain",
|
|
160
|
+
await ledger.credit("t1", Credit.fromCents(5), "purchase", { description: "setup" });
|
|
161
|
+
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", { description: "drain", allowNegative: true });
|
|
160
162
|
const onBalanceExhausted = vi.fn();
|
|
161
163
|
// Another debit of 1 cent — balance goes from -5 to -6, but was already negative
|
|
162
164
|
await debitCredits({ creditLedger: ledger, topUpUrl: "/billing", onBalanceExhausted }, "t1", 0.01, 1.0, "chat-completions", "openrouter");
|
package/dist/gateway/index.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { anthropicToOpenAI, createAnthropicRoutes, createOpenAIRoutes, estimateA
|
|
|
16
16
|
export { buildProxyDeps, type ProxyDeps, phoneNumberList, phoneNumberProvision, phoneNumberRelease, smsDeliveryStatus, smsInbound, smsOutbound, } from "./proxy.js";
|
|
17
17
|
export { createGatewayRoutes } from "./routes.js";
|
|
18
18
|
export { type GatewayAuthEnv, serviceKeyAuth } from "./service-key-auth.js";
|
|
19
|
+
export type { IServiceKeyRepository } from "./service-key-repository.js";
|
|
20
|
+
export { DrizzleServiceKeyRepository } from "./service-key-repository.js";
|
|
19
21
|
export { type SpendingCapConfig, type SpendingCaps, spendingCapCheck } from "./spending-cap.js";
|
|
20
22
|
export type { ISpendingCapStore, SpendingCapRecord } from "./spending-cap-store.js";
|
|
21
23
|
export { proxySSEStream } from "./streaming.js";
|
package/dist/gateway/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export { anthropicToOpenAI, createAnthropicRoutes, createOpenAIRoutes, estimateA
|
|
|
16
16
|
export { buildProxyDeps, phoneNumberList, phoneNumberProvision, phoneNumberRelease, smsDeliveryStatus, smsInbound, smsOutbound, } from "./proxy.js";
|
|
17
17
|
export { createGatewayRoutes } from "./routes.js";
|
|
18
18
|
export { serviceKeyAuth } from "./service-key-auth.js";
|
|
19
|
+
export { DrizzleServiceKeyRepository } from "./service-key-repository.js";
|
|
19
20
|
export { spendingCapCheck } from "./spending-cap.js";
|
|
20
21
|
export { proxySSEStream } from "./streaming.js";
|
|
21
22
|
export { validateTwilioSignature } from "./twilio-signature.js";
|
|
@@ -46,7 +46,7 @@ function anthropicAuth(resolveServiceKey) {
|
|
|
46
46
|
},
|
|
47
47
|
}, 401);
|
|
48
48
|
}
|
|
49
|
-
const tenant = resolveServiceKey(key);
|
|
49
|
+
const tenant = await resolveServiceKey(key);
|
|
50
50
|
if (!tenant) {
|
|
51
51
|
logger.warn("Invalid service key attempted (anthropic handler)");
|
|
52
52
|
return c.json({
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Both the Anthropic and OpenAI handlers need the same set of services:
|
|
5
5
|
* budget checking, metering, provider configs, fetch, and service key resolution.
|
|
6
6
|
*/
|
|
7
|
-
import type { Credit,
|
|
7
|
+
import type { Credit, ILedger } from "@wopr-network/platform-core/credits";
|
|
8
8
|
import type { MeterEmitter } from "@wopr-network/platform-core/metering";
|
|
9
9
|
import type { IRateLimitRepository } from "../../api/rate-limit-repository.js";
|
|
10
|
-
import type {
|
|
10
|
+
import type { IBudgetChecker } from "../../monetization/budget/budget-checker.js";
|
|
11
11
|
import type { CapabilityRateLimitConfig } from "../capability-rate-limit.js";
|
|
12
12
|
import type { CircuitBreakerConfig } from "../circuit-breaker.js";
|
|
13
13
|
import type { ICircuitBreakerRepository } from "../circuit-breaker-repository.js";
|
|
@@ -15,14 +15,14 @@ import type { SellRateLookupFn } from "../rate-lookup.js";
|
|
|
15
15
|
import type { FetchFn, GatewayTenant, ProviderConfig } from "../types.js";
|
|
16
16
|
export interface ProtocolDeps {
|
|
17
17
|
meter: MeterEmitter;
|
|
18
|
-
budgetChecker:
|
|
19
|
-
creditLedger?:
|
|
18
|
+
budgetChecker: IBudgetChecker;
|
|
19
|
+
creditLedger?: ILedger;
|
|
20
20
|
topUpUrl: string;
|
|
21
21
|
graceBufferCents?: number;
|
|
22
22
|
providers: ProviderConfig;
|
|
23
23
|
defaultMargin: number;
|
|
24
24
|
fetchFn: FetchFn;
|
|
25
|
-
resolveServiceKey: (key: string) => GatewayTenant | null
|
|
25
|
+
resolveServiceKey: (key: string) => GatewayTenant | null | Promise<GatewayTenant | null>;
|
|
26
26
|
/** Apply margin to a cost. Defaults to withMargin from adapters/types. */
|
|
27
27
|
withMarginFn: (cost: Credit, margin: number) => Credit;
|
|
28
28
|
rateLookupFn?: SellRateLookupFn;
|
|
@@ -53,7 +53,7 @@ function openaiAuth(resolveServiceKey) {
|
|
|
53
53
|
},
|
|
54
54
|
}, 401);
|
|
55
55
|
}
|
|
56
|
-
const tenant = resolveServiceKey(key);
|
|
56
|
+
const tenant = await resolveServiceKey(key);
|
|
57
57
|
if (!tenant) {
|
|
58
58
|
logger.warn("Invalid service key attempted (openai handler)", {
|
|
59
59
|
keyPrefix: `${key.slice(0, 8)}...`,
|
package/dist/gateway/proxy.d.ts
CHANGED
|
@@ -9,18 +9,18 @@
|
|
|
9
9
|
* 4. Emit meter event
|
|
10
10
|
* 5. Return response to bot
|
|
11
11
|
*/
|
|
12
|
-
import type {
|
|
12
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
13
13
|
import type { MeterEmitter } from "@wopr-network/platform-core/metering";
|
|
14
14
|
import type { Context } from "hono";
|
|
15
|
-
import type {
|
|
15
|
+
import type { IBudgetChecker } from "../monetization/budget/budget-checker.js";
|
|
16
16
|
import type { SellRateLookupFn } from "./rate-lookup.js";
|
|
17
17
|
import type { GatewayAuthEnv } from "./service-key-auth.js";
|
|
18
18
|
import type { FetchFn, GatewayConfig, ProviderConfig } from "./types.js";
|
|
19
19
|
/** Shared state for all proxy handlers. */
|
|
20
20
|
export interface ProxyDeps {
|
|
21
21
|
meter: MeterEmitter;
|
|
22
|
-
budgetChecker:
|
|
23
|
-
creditLedger?:
|
|
22
|
+
budgetChecker: IBudgetChecker;
|
|
23
|
+
creditLedger?: ILedger;
|
|
24
24
|
topUpUrl: string;
|
|
25
25
|
graceBufferCents?: number;
|
|
26
26
|
providers: ProviderConfig;
|
|
@@ -22,7 +22,7 @@ export interface GatewayAuthEnv {
|
|
|
22
22
|
*
|
|
23
23
|
* @param resolveServiceKey - Function that maps a service key to a tenant (or null)
|
|
24
24
|
*/
|
|
25
|
-
export declare function serviceKeyAuth(resolveServiceKey: (key: string) => GatewayTenant | null): (c: Context<GatewayAuthEnv>, next: Next) => Promise<void | (Response & import("hono").TypedResponse<{
|
|
25
|
+
export declare function serviceKeyAuth(resolveServiceKey: (key: string) => GatewayTenant | null | Promise<GatewayTenant | null>): (c: Context<GatewayAuthEnv>, next: Next) => Promise<void | (Response & import("hono").TypedResponse<{
|
|
26
26
|
error: {
|
|
27
27
|
message: string;
|
|
28
28
|
type: string;
|
|
@@ -47,7 +47,7 @@ export function serviceKeyAuth(resolveServiceKey) {
|
|
|
47
47
|
},
|
|
48
48
|
}, 401);
|
|
49
49
|
}
|
|
50
|
-
const tenant = resolveServiceKey(serviceKey);
|
|
50
|
+
const tenant = await resolveServiceKey(serviceKey);
|
|
51
51
|
if (!tenant) {
|
|
52
52
|
logger.warn("Invalid service key attempted", {
|
|
53
53
|
keyPrefix: `${serviceKey.slice(0, 8)}...`,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway service key repository.
|
|
3
|
+
*
|
|
4
|
+
* Stores SHA-256 hashes of per-instance service keys used to authenticate
|
|
5
|
+
* tenant containers against the metered inference gateway. Raw keys are
|
|
6
|
+
* NEVER stored — only hashes.
|
|
7
|
+
*/
|
|
8
|
+
import type { PlatformDb } from "../db/index.js";
|
|
9
|
+
import type { GatewayTenant } from "./types.js";
|
|
10
|
+
export interface IServiceKeyRepository {
|
|
11
|
+
/** Generate a new service key for an instance. Returns the raw key (caller must store it). */
|
|
12
|
+
generate(tenantId: string, instanceId: string): Promise<string>;
|
|
13
|
+
/** Resolve a raw bearer token to a GatewayTenant. Returns null if not found or revoked. */
|
|
14
|
+
resolve(rawKey: string): Promise<GatewayTenant | null>;
|
|
15
|
+
/** Revoke the service key for a specific instance. */
|
|
16
|
+
revokeByInstance(instanceId: string): Promise<void>;
|
|
17
|
+
/** Revoke all service keys for a tenant (used when tenant is deleted). */
|
|
18
|
+
revokeByTenant(tenantId: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export declare class DrizzleServiceKeyRepository implements IServiceKeyRepository {
|
|
21
|
+
private readonly db;
|
|
22
|
+
constructor(db: PlatformDb);
|
|
23
|
+
generate(tenantId: string, instanceId: string): Promise<string>;
|
|
24
|
+
resolve(rawKey: string): Promise<GatewayTenant | null>;
|
|
25
|
+
revokeByInstance(instanceId: string): Promise<void>;
|
|
26
|
+
revokeByTenant(tenantId: string): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway service key repository.
|
|
3
|
+
*
|
|
4
|
+
* Stores SHA-256 hashes of per-instance service keys used to authenticate
|
|
5
|
+
* tenant containers against the metered inference gateway. Raw keys are
|
|
6
|
+
* NEVER stored — only hashes.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
10
|
+
import { gatewayServiceKeys } from "../db/schema/gateway-service-keys.js";
|
|
11
|
+
/** Hash a raw key for storage/lookup. */
|
|
12
|
+
function hashKey(raw) {
|
|
13
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
export class DrizzleServiceKeyRepository {
|
|
16
|
+
db;
|
|
17
|
+
constructor(db) {
|
|
18
|
+
this.db = db;
|
|
19
|
+
}
|
|
20
|
+
async generate(tenantId, instanceId) {
|
|
21
|
+
const raw = randomBytes(32).toString("hex");
|
|
22
|
+
const hash = hashKey(raw);
|
|
23
|
+
const id = randomBytes(16).toString("hex");
|
|
24
|
+
await this.db.insert(gatewayServiceKeys).values({
|
|
25
|
+
id,
|
|
26
|
+
keyHash: hash,
|
|
27
|
+
tenantId,
|
|
28
|
+
instanceId,
|
|
29
|
+
createdAt: Date.now(),
|
|
30
|
+
});
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
async resolve(rawKey) {
|
|
34
|
+
const hash = hashKey(rawKey);
|
|
35
|
+
const rows = await this.db
|
|
36
|
+
.select({
|
|
37
|
+
tenantId: gatewayServiceKeys.tenantId,
|
|
38
|
+
instanceId: gatewayServiceKeys.instanceId,
|
|
39
|
+
})
|
|
40
|
+
.from(gatewayServiceKeys)
|
|
41
|
+
.where(and(eq(gatewayServiceKeys.keyHash, hash), isNull(gatewayServiceKeys.revokedAt)))
|
|
42
|
+
.limit(1);
|
|
43
|
+
const row = rows[0];
|
|
44
|
+
if (!row)
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
id: row.tenantId,
|
|
48
|
+
instanceId: row.instanceId,
|
|
49
|
+
spendLimits: { maxSpendPerHour: null, maxSpendPerMonth: null },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async revokeByInstance(instanceId) {
|
|
53
|
+
await this.db
|
|
54
|
+
.update(gatewayServiceKeys)
|
|
55
|
+
.set({ revokedAt: Date.now() })
|
|
56
|
+
.where(and(eq(gatewayServiceKeys.instanceId, instanceId), isNull(gatewayServiceKeys.revokedAt)));
|
|
57
|
+
}
|
|
58
|
+
async revokeByTenant(tenantId) {
|
|
59
|
+
await this.db
|
|
60
|
+
.update(gatewayServiceKeys)
|
|
61
|
+
.set({ revokedAt: Date.now() })
|
|
62
|
+
.where(and(eq(gatewayServiceKeys.tenantId, tenantId), isNull(gatewayServiceKeys.revokedAt)));
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* /v1/... endpoints using WOPR service keys. The gateway authenticates,
|
|
6
6
|
* budget-checks, proxies to upstream providers, meters usage, and responds.
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
8
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
9
9
|
import type { MeterEmitter } from "@wopr-network/platform-core/metering";
|
|
10
10
|
import type { IRateLimitRepository } from "../api/rate-limit-repository.js";
|
|
11
|
-
import type {
|
|
11
|
+
import type { IBudgetChecker, SpendLimits } from "../monetization/budget/budget-checker.js";
|
|
12
12
|
import type { CapabilityRateLimitConfig } from "./capability-rate-limit.js";
|
|
13
13
|
import type { CircuitBreakerConfig } from "./circuit-breaker.js";
|
|
14
14
|
import type { ICircuitBreakerRepository } from "./circuit-breaker-repository.js";
|
|
@@ -101,9 +101,9 @@ export interface GatewayConfig {
|
|
|
101
101
|
/** MeterEmitter instance for usage tracking */
|
|
102
102
|
meter: MeterEmitter;
|
|
103
103
|
/** BudgetChecker instance for pre-call budget validation */
|
|
104
|
-
budgetChecker:
|
|
104
|
+
budgetChecker: IBudgetChecker;
|
|
105
105
|
/** CreditLedger instance for deducting credits after proxy calls (optional — if absent, credit deduction is skipped) */
|
|
106
|
-
creditLedger?:
|
|
106
|
+
creditLedger?: ILedger;
|
|
107
107
|
/** URL to direct users to when they need to add credits (default: "/dashboard/credits") */
|
|
108
108
|
topUpUrl?: string;
|
|
109
109
|
/** Maximum negative credit balance (in cents) before hard-stop. Default: 50 (-$0.50). */
|
|
@@ -119,7 +119,7 @@ export interface GatewayConfig {
|
|
|
119
119
|
/** Optional cached rate lookup for model-specific token pricing (WOP-646) */
|
|
120
120
|
rateLookupFn?: import("./rate-lookup.js").SellRateLookupFn;
|
|
121
121
|
/** Function to resolve a service key to a tenant */
|
|
122
|
-
resolveServiceKey: (key: string) => GatewayTenant | null
|
|
122
|
+
resolveServiceKey: (key: string) => GatewayTenant | null | Promise<GatewayTenant | null>;
|
|
123
123
|
/** Base URL for Twilio webhook signature verification (e.g., https://api.wopr.network/v1). Required for Twilio/Telnyx webhook endpoints. */
|
|
124
124
|
webhookBaseUrl?: string;
|
|
125
125
|
/** Resolve a tenant from an inbound webhook request (e.g., from a tenantId URL path param). Required when webhookBaseUrl is set. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { Credit } from "../credits/credit.js";
|
|
4
|
-
import {
|
|
4
|
+
import { DrizzleLedger } from "../credits/ledger.js";
|
|
5
5
|
import { usageSummaries } from "../db/schema/meter-events.js";
|
|
6
6
|
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
7
7
|
import { runReconciliation } from "./reconciliation-cron.js";
|
|
@@ -21,7 +21,7 @@ describe("runReconciliation", () => {
|
|
|
21
21
|
const t = await createTestDb();
|
|
22
22
|
pool = t.pool;
|
|
23
23
|
db = t.db;
|
|
24
|
-
ledger = new
|
|
24
|
+
ledger = new DrizzleLedger(db);
|
|
25
25
|
usageSummaryRepo = new DrizzleUsageSummaryRepository(db);
|
|
26
26
|
adapterUsageRepo = new DrizzleAdapterUsageRepository(db);
|
|
27
27
|
});
|
|
@@ -30,6 +30,7 @@ describe("runReconciliation", () => {
|
|
|
30
30
|
});
|
|
31
31
|
beforeEach(async () => {
|
|
32
32
|
await truncateAllTables(pool);
|
|
33
|
+
await ledger.seedSystemAccounts();
|
|
33
34
|
});
|
|
34
35
|
/** Insert a usage_summaries row directly. */
|
|
35
36
|
async function insertSummary(opts) {
|
|
@@ -57,7 +58,7 @@ describe("runReconciliation", () => {
|
|
|
57
58
|
const charge = Credit.fromCents(50);
|
|
58
59
|
await insertSummary({ tenant: "t1", totalCharge: charge.toRaw() });
|
|
59
60
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
60
|
-
await ledger.debit("t1", charge, "adapter_usage", "chat usage");
|
|
61
|
+
await ledger.debit("t1", charge, "adapter_usage", { description: "chat usage" });
|
|
61
62
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
62
63
|
expect(result.tenantsChecked).toBe(1);
|
|
63
64
|
expect(result.discrepancies).toEqual([]);
|
|
@@ -65,7 +66,7 @@ describe("runReconciliation", () => {
|
|
|
65
66
|
it("detects drift when metered charge exceeds ledger debit", async () => {
|
|
66
67
|
await insertSummary({ tenant: "t1", totalCharge: Credit.fromCents(100).toRaw() });
|
|
67
68
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
68
|
-
await ledger.debit("t1", Credit.fromCents(80), "adapter_usage", "chat usage");
|
|
69
|
+
await ledger.debit("t1", Credit.fromCents(80), "adapter_usage", { description: "chat usage" });
|
|
69
70
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
70
71
|
expect(result.tenantsChecked).toBe(1);
|
|
71
72
|
expect(result.discrepancies).toHaveLength(1);
|
|
@@ -91,7 +92,7 @@ describe("runReconciliation", () => {
|
|
|
91
92
|
await insertSummary({ tenant: "t1", totalCharge: Credit.fromCents(20).toRaw() });
|
|
92
93
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
93
94
|
// Debit as bot_runtime — should NOT count toward reconciliation
|
|
94
|
-
await ledger.debit("t1", Credit.fromCents(20), "bot_runtime", "daily runtime");
|
|
95
|
+
await ledger.debit("t1", Credit.fromCents(20), "bot_runtime", { description: "daily runtime" });
|
|
95
96
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
96
97
|
// Metered 20c, ledger adapter_usage = 0 => drift = 20c
|
|
97
98
|
expect(result.discrepancies).toHaveLength(1);
|
|
@@ -119,11 +120,11 @@ describe("runReconciliation", () => {
|
|
|
119
120
|
// t1: balanced
|
|
120
121
|
await insertSummary({ tenant: "t1", totalCharge: Credit.fromCents(50).toRaw() });
|
|
121
122
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
122
|
-
await ledger.debit("t1", Credit.fromCents(50), "adapter_usage", "chat");
|
|
123
|
+
await ledger.debit("t1", Credit.fromCents(50), "adapter_usage", { description: "chat" });
|
|
123
124
|
// t2: drifted
|
|
124
125
|
await insertSummary({ tenant: "t2", totalCharge: Credit.fromCents(100).toRaw() });
|
|
125
126
|
await ledger.credit("t2", Credit.fromCents(500), "purchase");
|
|
126
|
-
await ledger.debit("t2", Credit.fromCents(60), "adapter_usage", "chat");
|
|
127
|
+
await ledger.debit("t2", Credit.fromCents(60), "adapter_usage", { description: "chat" });
|
|
127
128
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
128
129
|
expect(result.tenantsChecked).toBe(2);
|
|
129
130
|
expect(result.discrepancies).toHaveLength(1);
|
|
@@ -154,7 +155,7 @@ describe("runReconciliation", () => {
|
|
|
154
155
|
// Metered 50c but debited 80c (over-billed)
|
|
155
156
|
await insertSummary({ tenant: "t1", totalCharge: Credit.fromCents(50).toRaw() });
|
|
156
157
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
157
|
-
await ledger.debit("t1", Credit.fromCents(80), "adapter_usage", "chat usage");
|
|
158
|
+
await ledger.debit("t1", Credit.fromCents(80), "adapter_usage", { description: "chat usage" });
|
|
158
159
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
159
160
|
expect(result.discrepancies).toHaveLength(1);
|
|
160
161
|
expect(result.discrepancies[0].driftRaw).toBe(Credit.fromCents(-30).toRaw());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, eq, gte, lt, ne, sql } from "drizzle-orm";
|
|
2
|
-
import {
|
|
2
|
+
import { journalEntries, journalLines } from "../db/schema/ledger.js";
|
|
3
3
|
import { usageSummaries } from "../db/schema/meter-events.js";
|
|
4
4
|
export class DrizzleUsageSummaryRepository {
|
|
5
5
|
db;
|
|
@@ -25,19 +25,21 @@ export class DrizzleAdapterUsageRepository {
|
|
|
25
25
|
this.db = db;
|
|
26
26
|
}
|
|
27
27
|
async getAggregatedAdapterUsageDebits(startIso, endIso) {
|
|
28
|
+
// Sum the debit-side journal line amounts for adapter_usage entries.
|
|
29
|
+
// In double-entry: DR tenant liability (2000:<tenantId>), CR revenue:adapter_usage (4010).
|
|
30
|
+
// The debit line on the tenant account represents the charge amount.
|
|
28
31
|
const rows = await this.db
|
|
29
32
|
.select({
|
|
30
|
-
tenantId:
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
// raw SQL: Drizzle cannot express ABS with COALESCE and SUM
|
|
34
|
-
totalDebitRaw: sql `COALESCE(SUM(ABS(amount_credits)), 0)`,
|
|
33
|
+
tenantId: journalEntries.tenantId,
|
|
34
|
+
// raw SQL: Drizzle cannot express COALESCE with SUM aggregation
|
|
35
|
+
totalDebitRaw: sql `COALESCE(SUM(${journalLines.amount}), 0)`,
|
|
35
36
|
})
|
|
36
|
-
.from(
|
|
37
|
-
.
|
|
37
|
+
.from(journalLines)
|
|
38
|
+
.innerJoin(journalEntries, eq(journalEntries.id, journalLines.journalEntryId))
|
|
39
|
+
.where(and(eq(journalEntries.entryType, "adapter_usage"), eq(journalLines.side, "debit"),
|
|
38
40
|
// raw SQL: Drizzle cannot express timestamptz cast for text column date comparison
|
|
39
|
-
sql `${
|
|
40
|
-
.groupBy(
|
|
41
|
+
sql `${journalEntries.postedAt}::timestamptz >= ${startIso}::timestamptz`, sql `${journalEntries.postedAt}::timestamptz < ${endIso}::timestamptz`))
|
|
42
|
+
.groupBy(journalEntries.tenantId);
|
|
41
43
|
return rows.map((r) => ({ tenantId: r.tenantId, totalDebitRaw: Number(r.totalDebitRaw) }));
|
|
42
44
|
}
|
|
43
45
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { Credit } from "../credits/credit.js";
|
|
4
|
-
import {
|
|
4
|
+
import { DrizzleLedger } from "../credits/ledger.js";
|
|
5
5
|
import { createTestDb, seedUsageSummary, truncateAllTables } from "../test/db.js";
|
|
6
6
|
import { DrizzleAdapterUsageRepository, DrizzleUsageSummaryRepository } from "./reconciliation-repository.js";
|
|
7
7
|
let pool;
|
|
@@ -108,7 +108,8 @@ describe("DrizzleAdapterUsageRepository", () => {
|
|
|
108
108
|
beforeEach(async () => {
|
|
109
109
|
await truncateAllTables(pool);
|
|
110
110
|
repo = new DrizzleAdapterUsageRepository(db);
|
|
111
|
-
ledger = new
|
|
111
|
+
ledger = new DrizzleLedger(db);
|
|
112
|
+
await ledger.seedSystemAccounts();
|
|
112
113
|
});
|
|
113
114
|
it("returns empty array when no adapter_usage debits exist", async () => {
|
|
114
115
|
const today = new Date().toISOString().slice(0, 10);
|
|
@@ -121,9 +122,9 @@ describe("DrizzleAdapterUsageRepository", () => {
|
|
|
121
122
|
// Fund tenants
|
|
122
123
|
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
123
124
|
await ledger.credit("t2", Credit.fromCents(1000), "purchase");
|
|
124
|
-
await ledger.debit("t1", Credit.fromCents(30), "adapter_usage", "t1-debit-1");
|
|
125
|
-
await ledger.debit("t1", Credit.fromCents(20), "adapter_usage", "t1-debit-2");
|
|
126
|
-
await ledger.debit("t2", Credit.fromCents(50), "adapter_usage", "t2-debit-1");
|
|
125
|
+
await ledger.debit("t1", Credit.fromCents(30), "adapter_usage", { description: "t1-debit-1" });
|
|
126
|
+
await ledger.debit("t1", Credit.fromCents(20), "adapter_usage", { description: "t1-debit-2" });
|
|
127
|
+
await ledger.debit("t2", Credit.fromCents(50), "adapter_usage", { description: "t2-debit-1" });
|
|
127
128
|
// Query window covering today
|
|
128
129
|
const today = new Date().toISOString().slice(0, 10);
|
|
129
130
|
const startIso = `${today}T00:00:00Z`;
|
|
@@ -137,8 +138,8 @@ describe("DrizzleAdapterUsageRepository", () => {
|
|
|
137
138
|
});
|
|
138
139
|
it("excludes non-adapter_usage debit types", async () => {
|
|
139
140
|
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
140
|
-
await ledger.debit("t1", Credit.fromCents(30), "adapter_usage", "adapter debit");
|
|
141
|
-
await ledger.debit("t1", Credit.fromCents(20), "bot_runtime", "runtime debit");
|
|
141
|
+
await ledger.debit("t1", Credit.fromCents(30), "adapter_usage", { description: "adapter debit" });
|
|
142
|
+
await ledger.debit("t1", Credit.fromCents(20), "bot_runtime", { description: "runtime debit" });
|
|
142
143
|
const today = new Date().toISOString().slice(0, 10);
|
|
143
144
|
const startIso = `${today}T00:00:00Z`;
|
|
144
145
|
const endIso = new Date(new Date(startIso).getTime() + 86400000).toISOString();
|
|
@@ -148,7 +149,7 @@ describe("DrizzleAdapterUsageRepository", () => {
|
|
|
148
149
|
});
|
|
149
150
|
it("excludes credit transactions (positive amounts are not debits)", async () => {
|
|
150
151
|
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
151
|
-
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", "real debit");
|
|
152
|
+
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", { description: "real debit" });
|
|
152
153
|
const today = new Date().toISOString().slice(0, 10);
|
|
153
154
|
const startIso = `${today}T00:00:00Z`;
|
|
154
155
|
const endIso = new Date(new Date(startIso).getTime() + 86400000).toISOString();
|