@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
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
noOpReplayGuard,
|
|
12
12
|
TenantCustomerRepository,
|
|
13
13
|
} from "@wopr-network/platform-core/billing";
|
|
14
|
-
import { Credit,
|
|
14
|
+
import { Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
|
|
15
15
|
import type Stripe from "stripe";
|
|
16
16
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
17
|
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
@@ -28,7 +28,7 @@ function makeReplayGuard() {
|
|
|
28
28
|
|
|
29
29
|
describe("handleWebhookEvent (credit model)", () => {
|
|
30
30
|
let tenantRepo: TenantCustomerRepository;
|
|
31
|
-
let creditLedger:
|
|
31
|
+
let creditLedger: DrizzleLedger;
|
|
32
32
|
let deps: WebhookDeps;
|
|
33
33
|
|
|
34
34
|
beforeAll(async () => {
|
|
@@ -44,7 +44,9 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
44
44
|
beforeEach(async () => {
|
|
45
45
|
await truncateAllTables(pool);
|
|
46
46
|
tenantRepo = new TenantCustomerRepository(db);
|
|
47
|
-
creditLedger = new
|
|
47
|
+
creditLedger = new DrizzleLedger(db);
|
|
48
|
+
|
|
49
|
+
await creditLedger.seedSystemAccounts();
|
|
48
50
|
deps = { tenantRepo, creditLedger, replayGuard: noOpReplayGuard };
|
|
49
51
|
});
|
|
50
52
|
|
|
@@ -227,7 +229,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
227
229
|
const txns = await creditLedger.history("tenant-123");
|
|
228
230
|
expect(txns).toHaveLength(1);
|
|
229
231
|
expect(txns[0].description).toContain("cs_test_abc");
|
|
230
|
-
expect(txns[0].
|
|
232
|
+
expect(txns[0].entryType).toBe("purchase");
|
|
231
233
|
expect(txns[0].referenceId).toBe("cs_test_abc");
|
|
232
234
|
});
|
|
233
235
|
|
|
@@ -870,7 +872,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
870
872
|
const txns = await creditLedger.history("tenant-renew-ref");
|
|
871
873
|
expect(txns).toHaveLength(1);
|
|
872
874
|
expect(txns[0].referenceId).toBe("in_renewal_abc");
|
|
873
|
-
expect(txns[0].
|
|
875
|
+
expect(txns[0].entryType).toBe("purchase");
|
|
874
876
|
expect(txns[0].description).toContain("in_renewal_abc");
|
|
875
877
|
});
|
|
876
878
|
|
|
@@ -913,7 +915,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
913
915
|
|
|
914
916
|
it("debits the credit ledger for the refunded amount", async () => {
|
|
915
917
|
await tenantRepo.upsert({ tenant: "tenant-ref-1", processorCustomerId: "cus_ref_abc" });
|
|
916
|
-
await creditLedger.credit("tenant-ref-1", Credit.fromCents(5000), "purchase", "seed");
|
|
918
|
+
await creditLedger.credit("tenant-ref-1", Credit.fromCents(5000), "purchase", { description: "seed" });
|
|
917
919
|
|
|
918
920
|
const result = await handleWebhookEvent(deps, makeChargeRefundedEvent());
|
|
919
921
|
|
|
@@ -940,7 +942,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
940
942
|
|
|
941
943
|
it("is idempotent — skips duplicate refund for same charge ID", async () => {
|
|
942
944
|
await tenantRepo.upsert({ tenant: "tenant-ref-idem", processorCustomerId: "cus_ref_idem" });
|
|
943
|
-
await creditLedger.credit("tenant-ref-idem", Credit.fromCents(5000), "purchase", "seed");
|
|
945
|
+
await creditLedger.credit("tenant-ref-idem", Credit.fromCents(5000), "purchase", { description: "seed" });
|
|
944
946
|
|
|
945
947
|
const event = makeChargeRefundedEvent({ customer: "cus_ref_idem" });
|
|
946
948
|
|
|
@@ -973,7 +975,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
973
975
|
|
|
974
976
|
it("handles customer object instead of string", async () => {
|
|
975
977
|
await tenantRepo.upsert({ tenant: "tenant-ref-obj", processorCustomerId: "cus_ref_obj" });
|
|
976
|
-
await creditLedger.credit("tenant-ref-obj", Credit.fromCents(3000), "purchase", "seed");
|
|
978
|
+
await creditLedger.credit("tenant-ref-obj", Credit.fromCents(3000), "purchase", { description: "seed" });
|
|
977
979
|
|
|
978
980
|
const result = await handleWebhookEvent(
|
|
979
981
|
deps,
|
|
@@ -986,14 +988,14 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
986
988
|
|
|
987
989
|
it("records event ID as referenceId in the ledger transaction", async () => {
|
|
988
990
|
await tenantRepo.upsert({ tenant: "tenant-ref-txn", processorCustomerId: "cus_ref_txn" });
|
|
989
|
-
await creditLedger.credit("tenant-ref-txn", Credit.fromCents(5000), "purchase", "seed");
|
|
991
|
+
await creditLedger.credit("tenant-ref-txn", Credit.fromCents(5000), "purchase", { description: "seed" });
|
|
990
992
|
|
|
991
993
|
await handleWebhookEvent(deps, makeChargeRefundedEvent({ customer: "cus_ref_txn", id: "ch_ref_txn_123" }));
|
|
992
994
|
|
|
993
995
|
const txns = await creditLedger.history("tenant-ref-txn", { type: "refund" });
|
|
994
996
|
expect(txns).toHaveLength(1);
|
|
995
997
|
expect(txns[0].referenceId).toBe("evt_charge_ref_1");
|
|
996
|
-
expect(txns[0].
|
|
998
|
+
expect(txns[0].entryType).toBe("refund");
|
|
997
999
|
expect(txns[0].description).toContain("ch_ref_txn_123");
|
|
998
1000
|
});
|
|
999
1001
|
});
|
|
@@ -1076,7 +1078,11 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
1076
1078
|
|
|
1077
1079
|
it("skips credit when referenceId already exists (inline grant ran first)", async () => {
|
|
1078
1080
|
// Simulate inline grant already happened
|
|
1079
|
-
await creditLedger.credit("t1", Credit.fromCents(500), "purchase",
|
|
1081
|
+
await creditLedger.credit("t1", Credit.fromCents(500), "purchase", {
|
|
1082
|
+
description: "Auto-topup",
|
|
1083
|
+
referenceId: "pi_already_granted",
|
|
1084
|
+
fundingSource: "stripe",
|
|
1085
|
+
});
|
|
1080
1086
|
|
|
1081
1087
|
const event = {
|
|
1082
1088
|
id: "evt_pi_success_2",
|
|
@@ -1172,7 +1178,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
1172
1178
|
|
|
1173
1179
|
it("freezes tenant credits and suspends bots on dispute", async () => {
|
|
1174
1180
|
await tenantRepo.upsert({ tenant: "tenant-dispute-1", processorCustomerId: "cus_dispute_abc" });
|
|
1175
|
-
await creditLedger.credit("tenant-dispute-1", Credit.fromCents(5000), "purchase", "seed");
|
|
1181
|
+
await creditLedger.credit("tenant-dispute-1", Credit.fromCents(5000), "purchase", { description: "seed" });
|
|
1176
1182
|
|
|
1177
1183
|
const botBilling = {
|
|
1178
1184
|
suspendAllForTenant: vi.fn(async () => ["bot-d1"]),
|
|
@@ -1201,7 +1207,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
1201
1207
|
|
|
1202
1208
|
it("is idempotent — skips duplicate debit for same dispute ID", async () => {
|
|
1203
1209
|
await tenantRepo.upsert({ tenant: "tenant-dispute-idem", processorCustomerId: "cus_dispute_idem" });
|
|
1204
|
-
await creditLedger.credit("tenant-dispute-idem", Credit.fromCents(5000), "purchase", "seed");
|
|
1210
|
+
await creditLedger.credit("tenant-dispute-idem", Credit.fromCents(5000), "purchase", { description: "seed" });
|
|
1205
1211
|
|
|
1206
1212
|
const event = makeDisputeCreatedEvent("cus_dispute_idem");
|
|
1207
1213
|
|
|
@@ -1214,7 +1220,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
1214
1220
|
|
|
1215
1221
|
it("sends admin notification when notificationService is available", async () => {
|
|
1216
1222
|
await tenantRepo.upsert({ tenant: "tenant-dispute-notify", processorCustomerId: "cus_dispute_notify" });
|
|
1217
|
-
await creditLedger.credit("tenant-dispute-notify", Credit.fromCents(5000), "purchase", "seed");
|
|
1223
|
+
await creditLedger.credit("tenant-dispute-notify", Credit.fromCents(5000), "purchase", { description: "seed" });
|
|
1218
1224
|
|
|
1219
1225
|
const notifyFn = vi.fn();
|
|
1220
1226
|
const notificationService = {
|
|
@@ -1267,7 +1273,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
1267
1273
|
|
|
1268
1274
|
it("handles customer object (expanded) inside charge", async () => {
|
|
1269
1275
|
await tenantRepo.upsert({ tenant: "tenant-dispute-obj", processorCustomerId: "cus_dispute_obj" });
|
|
1270
|
-
await creditLedger.credit("tenant-dispute-obj", Credit.fromCents(3000), "purchase", "seed");
|
|
1276
|
+
await creditLedger.credit("tenant-dispute-obj", Credit.fromCents(3000), "purchase", { description: "seed" });
|
|
1271
1277
|
|
|
1272
1278
|
const result = await handleWebhookEvent(deps, makeDisputeCreatedEvent({ id: "cus_dispute_obj" }));
|
|
1273
1279
|
|
|
@@ -1277,7 +1283,7 @@ describe("handleWebhookEvent (credit model)", () => {
|
|
|
1277
1283
|
|
|
1278
1284
|
it("works without botBilling (no suspension, still handled)", async () => {
|
|
1279
1285
|
await tenantRepo.upsert({ tenant: "tenant-dispute-no-bb", processorCustomerId: "cus_dispute_no_bb" });
|
|
1280
|
-
await creditLedger.credit("tenant-dispute-no-bb", Credit.fromCents(5000), "purchase", "seed");
|
|
1286
|
+
await creditLedger.credit("tenant-dispute-no-bb", Credit.fromCents(5000), "purchase", { description: "seed" });
|
|
1281
1287
|
|
|
1282
1288
|
const result = await handleWebhookEvent(deps, makeDisputeCreatedEvent("cus_dispute_no_bb"));
|
|
1283
1289
|
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
IWebhookSeenRepository,
|
|
4
4
|
TenantCustomerRepository,
|
|
5
5
|
} from "@wopr-network/platform-core/billing";
|
|
6
|
-
import type {
|
|
6
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
7
7
|
import { Credit } from "@wopr-network/platform-core/credits";
|
|
8
8
|
import type { NotificationService } from "@wopr-network/platform-core/email";
|
|
9
9
|
import type Stripe from "stripe";
|
|
@@ -43,7 +43,7 @@ export interface WebhookResult {
|
|
|
43
43
|
*/
|
|
44
44
|
export interface WebhookDeps {
|
|
45
45
|
tenantRepo: TenantCustomerRepository;
|
|
46
|
-
creditLedger:
|
|
46
|
+
creditLedger: ILedger;
|
|
47
47
|
/** Map of Stripe Price ID -> CreditPricePoint for bonus calculation. */
|
|
48
48
|
priceMap?: CreditPriceMap;
|
|
49
49
|
/** Bot billing manager for reactivation after credit purchase (WOP-447). */
|
|
@@ -62,6 +62,46 @@ export interface WebhookDeps {
|
|
|
62
62
|
promotionEngine?: PromotionEngine;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Extract the card fingerprint from a Stripe webhook payload object.
|
|
67
|
+
* Works for PaymentIntent (via latest_charge or charges.data[0]) and
|
|
68
|
+
* Invoice (via charge) objects where the Charge is expanded.
|
|
69
|
+
* Returns undefined when the nested object is only a string ID (not expanded).
|
|
70
|
+
*/
|
|
71
|
+
function extractStripeFingerprint(obj: unknown): string | undefined {
|
|
72
|
+
if (!obj || typeof obj !== "object") return undefined;
|
|
73
|
+
const o = obj as Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
// PaymentIntent: pi.latest_charge expanded → Charge.payment_method_details.card.fingerprint
|
|
76
|
+
const latestCharge = o.latest_charge;
|
|
77
|
+
if (latestCharge && typeof latestCharge === "object") {
|
|
78
|
+
const pmd = (latestCharge as Record<string, unknown>).payment_method_details as Record<string, unknown> | undefined;
|
|
79
|
+
const fp = (pmd?.card as Record<string, unknown> | undefined)?.fingerprint;
|
|
80
|
+
if (typeof fp === "string") return fp;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// PaymentIntent (older API): pi.charges.data[0].payment_method_details.card.fingerprint
|
|
84
|
+
const charges = o.charges as { data?: Array<Record<string, unknown>> } | undefined;
|
|
85
|
+
const charge0 = charges?.data?.[0];
|
|
86
|
+
if (charge0) {
|
|
87
|
+
const pmd = charge0.payment_method_details as Record<string, unknown> | undefined;
|
|
88
|
+
const fp = (pmd?.card as Record<string, unknown> | undefined)?.fingerprint;
|
|
89
|
+
if (typeof fp === "string") return fp;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Invoice: invoice.charge expanded → Charge.payment_method_details.card.fingerprint
|
|
93
|
+
const invoiceCharge = o.charge;
|
|
94
|
+
if (invoiceCharge && typeof invoiceCharge === "object") {
|
|
95
|
+
const pmd = (invoiceCharge as Record<string, unknown>).payment_method_details as
|
|
96
|
+
| Record<string, unknown>
|
|
97
|
+
| undefined;
|
|
98
|
+
const fp = (pmd?.card as Record<string, unknown> | undefined)?.fingerprint;
|
|
99
|
+
if (typeof fp === "string") return fp;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
/**
|
|
66
106
|
* Process a Stripe webhook event.
|
|
67
107
|
*
|
|
@@ -133,14 +173,12 @@ export async function handleWebhookEvent(deps: WebhookDeps, event: Stripe.Event)
|
|
|
133
173
|
}
|
|
134
174
|
|
|
135
175
|
// Credit the ledger with session ID as reference for idempotency.
|
|
136
|
-
await deps.creditLedger.credit(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
"stripe",
|
|
143
|
-
);
|
|
176
|
+
await deps.creditLedger.credit(tenant, Credit.fromCents(creditCents), "purchase", {
|
|
177
|
+
description: `Stripe credit purchase (session: ${stripeSessionId})`,
|
|
178
|
+
referenceId: stripeSessionId,
|
|
179
|
+
fundingSource: "stripe",
|
|
180
|
+
stripeFingerprint: extractStripeFingerprint(session),
|
|
181
|
+
});
|
|
144
182
|
|
|
145
183
|
// New-user first-purchase bonus for referred users (WOP-950).
|
|
146
184
|
// Must run before credit match so markFirstPurchase hasn't been called yet.
|
|
@@ -292,14 +330,12 @@ export async function handleWebhookEvent(deps: WebhookDeps, event: Stripe.Event)
|
|
|
292
330
|
|
|
293
331
|
// Fallback grant — inline path failed or process crashed before granting.
|
|
294
332
|
const source = pi.metadata?.wopr_source ?? "auto_topup_webhook_fallback";
|
|
295
|
-
await deps.creditLedger.credit(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
"
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
"stripe",
|
|
302
|
-
);
|
|
333
|
+
await deps.creditLedger.credit(tenant, Credit.fromCents(pi.amount), "purchase", {
|
|
334
|
+
description: `Auto-topup webhook fallback (${source})`,
|
|
335
|
+
referenceId: pi.id,
|
|
336
|
+
fundingSource: "stripe",
|
|
337
|
+
stripeFingerprint: extractStripeFingerprint(pi),
|
|
338
|
+
});
|
|
303
339
|
|
|
304
340
|
result = {
|
|
305
341
|
handled: true,
|
|
@@ -379,14 +415,12 @@ export async function handleWebhookEvent(deps: WebhookDeps, event: Stripe.Event)
|
|
|
379
415
|
break;
|
|
380
416
|
}
|
|
381
417
|
|
|
382
|
-
await deps.creditLedger.credit(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
"
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
"stripe",
|
|
389
|
-
);
|
|
418
|
+
await deps.creditLedger.credit(tenant, Credit.fromCents(amountPaid), "purchase", {
|
|
419
|
+
description: `Stripe subscription renewal (invoice: ${invoice.id})`,
|
|
420
|
+
referenceId: invoice.id,
|
|
421
|
+
fundingSource: "stripe",
|
|
422
|
+
stripeFingerprint: extractStripeFingerprint(invoice),
|
|
423
|
+
});
|
|
390
424
|
|
|
391
425
|
// Reactivate suspended bots now that balance is positive (WOP-447).
|
|
392
426
|
let reactivatedBots: string[] | undefined;
|
|
@@ -437,14 +471,11 @@ export async function handleWebhookEvent(deps: WebhookDeps, event: Stripe.Event)
|
|
|
437
471
|
}
|
|
438
472
|
|
|
439
473
|
// Debit the ledger. Allow negative balance — refund must always succeed.
|
|
440
|
-
await deps.creditLedger.debit(
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
event.id,
|
|
446
|
-
true, // allowNegative
|
|
447
|
-
);
|
|
474
|
+
await deps.creditLedger.debit(tenant, Credit.fromCents(refundedCents), "refund", {
|
|
475
|
+
description: `Stripe refund (charge: ${charge.id})`,
|
|
476
|
+
referenceId: event.id,
|
|
477
|
+
allowNegative: true,
|
|
478
|
+
});
|
|
448
479
|
|
|
449
480
|
logger.warn("Charge refunded — credits debited", { tenant, customerId, chargeId: charge.id, refundedCents });
|
|
450
481
|
|
|
@@ -483,14 +514,11 @@ export async function handleWebhookEvent(deps: WebhookDeps, event: Stripe.Event)
|
|
|
483
514
|
|
|
484
515
|
// Debit disputed amount (allow negative). Idempotent via disputeId.
|
|
485
516
|
if (disputedCents > 0 && !(await deps.creditLedger.hasReferenceId(disputeId))) {
|
|
486
|
-
await deps.creditLedger.debit(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
disputeId,
|
|
492
|
-
true, // allowNegative
|
|
493
|
-
);
|
|
517
|
+
await deps.creditLedger.debit(tenant, Credit.fromCents(disputedCents), "correction", {
|
|
518
|
+
description: `Stripe dispute (dispute: ${disputeId}, reason: ${dispute.reason})`,
|
|
519
|
+
referenceId: disputeId,
|
|
520
|
+
allowNegative: true,
|
|
521
|
+
});
|
|
494
522
|
}
|
|
495
523
|
|
|
496
524
|
// Suspend all bots (non-fatal if botBilling not provided).
|
|
@@ -554,14 +582,11 @@ export async function handleWebhookEvent(deps: WebhookDeps, event: Stripe.Event)
|
|
|
554
582
|
// Re-credit the disputed amount. Idempotent via reversal referenceId.
|
|
555
583
|
const reversalRef = `${disputeId}:reversal`;
|
|
556
584
|
if (disputedCents > 0 && !(await deps.creditLedger.hasReferenceId(reversalRef))) {
|
|
557
|
-
await deps.creditLedger.credit(
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
"
|
|
561
|
-
|
|
562
|
-
reversalRef,
|
|
563
|
-
"stripe",
|
|
564
|
-
);
|
|
585
|
+
await deps.creditLedger.credit(tenant, Credit.fromCents(disputedCents), "correction", {
|
|
586
|
+
description: `Stripe dispute won — credits restored (dispute: ${disputeId})`,
|
|
587
|
+
referenceId: reversalRef,
|
|
588
|
+
fundingSource: "stripe",
|
|
589
|
+
});
|
|
565
590
|
}
|
|
566
591
|
|
|
567
592
|
// Reactivate bots (non-fatal).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
3
3
|
import { Credit } from "@wopr-network/platform-core/credits";
|
|
4
4
|
import type { BudgetTier } from "../inference/budget-guard.js";
|
|
5
5
|
import { checkSessionBudget } from "../inference/budget-guard.js";
|
|
@@ -30,7 +30,7 @@ export class OnboardingService {
|
|
|
30
30
|
private readonly daemon: IDaemonManager,
|
|
31
31
|
private readonly usageRepo?: ISessionUsageRepository,
|
|
32
32
|
private readonly scriptRepo: IOnboardingScriptRepository = OnboardingService.DEFAULT_SCRIPT_REPO,
|
|
33
|
-
private readonly creditLedger?:
|
|
33
|
+
private readonly creditLedger?: ILedger,
|
|
34
34
|
private readonly resolveTenantId?: (userId: string) => Promise<string | null>,
|
|
35
35
|
) {}
|
|
36
36
|
|
|
@@ -137,15 +137,12 @@ export class OnboardingService {
|
|
|
137
137
|
if (costCents > 0) {
|
|
138
138
|
const tenantId = await this.resolveTenantId(session.userId);
|
|
139
139
|
if (tenantId) {
|
|
140
|
-
await this.creditLedger.debit(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
true, // allowNegative — don't block onboarding mid-conversation
|
|
147
|
-
session.userId,
|
|
148
|
-
);
|
|
140
|
+
await this.creditLedger.debit(tenantId, Credit.fromCents(costCents), "onboarding_llm", {
|
|
141
|
+
description: `Onboarding session ${sessionId}`,
|
|
142
|
+
referenceId: `onboarding-${sessionId}-${Date.now()}`,
|
|
143
|
+
allowNegative: true,
|
|
144
|
+
attributedUserId: session.userId,
|
|
145
|
+
});
|
|
149
146
|
}
|
|
150
147
|
}
|
|
151
148
|
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../test/db.js";
|
|
3
|
-
import { Credit } from "./credit.js";
|
|
4
|
-
import { CreditLedger, InsufficientBalanceError } from "./credit-ledger.js";
|
|
5
|
-
let pool;
|
|
6
|
-
let db;
|
|
7
|
-
beforeAll(async () => {
|
|
8
|
-
({ db, pool } = await createTestDb());
|
|
9
|
-
await beginTestTransaction(pool);
|
|
10
|
-
});
|
|
11
|
-
afterAll(async () => {
|
|
12
|
-
await endTestTransaction(pool);
|
|
13
|
-
await pool.close();
|
|
14
|
-
});
|
|
15
|
-
describe("CreditLedger concurrent debit safety", () => {
|
|
16
|
-
let ledger;
|
|
17
|
-
beforeEach(async () => {
|
|
18
|
-
await rollbackTestTransaction(pool);
|
|
19
|
-
ledger = new CreditLedger(db);
|
|
20
|
-
});
|
|
21
|
-
it("concurrent debits do not overdraw — at least one should fail with InsufficientBalanceError", async () => {
|
|
22
|
-
// Fund with exactly 100 cents
|
|
23
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
24
|
-
// Fire two 100-cent debits concurrently — only one can succeed
|
|
25
|
-
const results = await Promise.allSettled([
|
|
26
|
-
ledger.debit("t1", Credit.fromCents(100), "bot_runtime", "debit-1"),
|
|
27
|
-
ledger.debit("t1", Credit.fromCents(100), "bot_runtime", "debit-2"),
|
|
28
|
-
]);
|
|
29
|
-
const fulfilled = results.filter((r) => r.status === "fulfilled");
|
|
30
|
-
const rejected = results.filter((r) => r.status === "rejected");
|
|
31
|
-
// Exactly one succeeds, one fails (PGlite serializes transactions so this is deterministic)
|
|
32
|
-
expect(fulfilled).toHaveLength(1);
|
|
33
|
-
expect(rejected).toHaveLength(1);
|
|
34
|
-
const err = rejected[0].reason;
|
|
35
|
-
expect(err).toBeInstanceOf(InsufficientBalanceError);
|
|
36
|
-
// Final balance must be exactly 0, not negative
|
|
37
|
-
const bal = await ledger.balance("t1");
|
|
38
|
-
expect(bal.toCents()).toBe(0);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, beforeEach, bench, describe } from "vitest";
|
|
2
|
-
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
3
|
-
import { Credit } from "./credit.js";
|
|
4
|
-
import { CreditLedger } from "./credit-ledger.js";
|
|
5
|
-
let db;
|
|
6
|
-
let pool;
|
|
7
|
-
beforeAll(async () => {
|
|
8
|
-
({ db, pool } = await createTestDb());
|
|
9
|
-
});
|
|
10
|
-
afterAll(async () => {
|
|
11
|
-
await pool.close();
|
|
12
|
-
});
|
|
13
|
-
describe("CreditLedger throughput", () => {
|
|
14
|
-
let ledger;
|
|
15
|
-
beforeEach(async () => {
|
|
16
|
-
await truncateAllTables(pool);
|
|
17
|
-
ledger = new CreditLedger(db);
|
|
18
|
-
});
|
|
19
|
-
let creditIdx = 0;
|
|
20
|
-
let debitIdx = 0;
|
|
21
|
-
bench("credit operation", async () => {
|
|
22
|
-
const tenant = `tenant-${creditIdx++ % 100}`;
|
|
23
|
-
await ledger.credit(tenant, Credit.fromCents(100), "purchase", "bench");
|
|
24
|
-
}, { iterations: 1_000 });
|
|
25
|
-
bench("debit operation", async () => {
|
|
26
|
-
const tenant = `tenant-${debitIdx++ % 100}`;
|
|
27
|
-
await ledger.debit(tenant, Credit.fromCents(1), "adapter_usage", "bench");
|
|
28
|
-
}, { iterations: 1_000 });
|
|
29
|
-
bench("balance query", async () => {
|
|
30
|
-
const tenant = `tenant-${debitIdx++ % 100}`;
|
|
31
|
-
await ledger.balance(tenant);
|
|
32
|
-
}, { iterations: 5_000 });
|
|
33
|
-
});
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for CreditLedger — including the allowNegative debit parameter (WOP-821).
|
|
3
|
-
*/
|
|
4
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
6
|
-
import { Credit } from "./credit.js";
|
|
7
|
-
import { CreditLedger, InsufficientBalanceError } from "./credit-ledger.js";
|
|
8
|
-
// TOP OF FILE - shared across ALL describes
|
|
9
|
-
let pool;
|
|
10
|
-
let db;
|
|
11
|
-
beforeAll(async () => {
|
|
12
|
-
({ db, pool } = await createTestDb());
|
|
13
|
-
});
|
|
14
|
-
afterAll(async () => {
|
|
15
|
-
await pool.close();
|
|
16
|
-
});
|
|
17
|
-
describe("CreditLedger core methods", () => {
|
|
18
|
-
let ledger;
|
|
19
|
-
beforeEach(async () => {
|
|
20
|
-
await truncateAllTables(pool);
|
|
21
|
-
ledger = new CreditLedger(db);
|
|
22
|
-
});
|
|
23
|
-
// --- credit() ---
|
|
24
|
-
describe("credit()", () => {
|
|
25
|
-
it("happy path: credits a tenant and returns correct transaction fields", async () => {
|
|
26
|
-
const txn = await ledger.credit("t1", Credit.fromCents(100), "purchase", "Initial deposit", "ref-001", "stripe", "user-abc");
|
|
27
|
-
expect(txn.tenantId).toBe("t1");
|
|
28
|
-
expect(txn.amount.toCents()).toBe(100);
|
|
29
|
-
expect(txn.balanceAfter.toCents()).toBe(100);
|
|
30
|
-
expect(txn.type).toBe("purchase");
|
|
31
|
-
expect(txn.description).toBe("Initial deposit");
|
|
32
|
-
expect(txn.referenceId).toBe("ref-001");
|
|
33
|
-
expect(txn.fundingSource).toBe("stripe");
|
|
34
|
-
expect(txn.attributedUserId).toBe("user-abc");
|
|
35
|
-
expect(txn.id).toEqual(expect.any(String));
|
|
36
|
-
expect(txn.createdAt).toEqual(expect.any(String));
|
|
37
|
-
});
|
|
38
|
-
it("multiple credits accumulate balance correctly", async () => {
|
|
39
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
40
|
-
await ledger.credit("t1", Credit.fromCents(50), "promo");
|
|
41
|
-
const bal = await ledger.balance("t1");
|
|
42
|
-
expect(bal.toCents()).toBe(150);
|
|
43
|
-
});
|
|
44
|
-
it("rejects zero amount", async () => {
|
|
45
|
-
await expect(ledger.credit("t1", Credit.fromCents(0), "purchase")).rejects.toThrow("amount must be positive for credits");
|
|
46
|
-
});
|
|
47
|
-
it("rejects negative amount", async () => {
|
|
48
|
-
await expect(ledger.credit("t1", Credit.fromRaw(-1), "purchase")).rejects.toThrow("amount must be positive for credits");
|
|
49
|
-
});
|
|
50
|
-
it("optional fields default to null", async () => {
|
|
51
|
-
const txn = await ledger.credit("t1", Credit.fromCents(10), "signup_grant");
|
|
52
|
-
expect(txn.description).toBeNull();
|
|
53
|
-
expect(txn.referenceId).toBeNull();
|
|
54
|
-
expect(txn.fundingSource).toBeNull();
|
|
55
|
-
expect(txn.attributedUserId).toBeNull();
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
// --- balance() ---
|
|
59
|
-
describe("balance()", () => {
|
|
60
|
-
it("returns Credit.ZERO for a tenant with no transactions", async () => {
|
|
61
|
-
const bal = await ledger.balance("nonexistent");
|
|
62
|
-
expect(bal.toCents()).toBe(0);
|
|
63
|
-
expect(bal.isZero()).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
it("reflects credits and debits accurately", async () => {
|
|
66
|
-
await ledger.credit("t1", Credit.fromCents(200), "purchase");
|
|
67
|
-
await ledger.debit("t1", Credit.fromCents(50), "bot_runtime");
|
|
68
|
-
const bal = await ledger.balance("t1");
|
|
69
|
-
expect(bal.toCents()).toBe(150);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
// --- history() ---
|
|
73
|
-
describe("history()", () => {
|
|
74
|
-
it("returns transactions in reverse chronological order (newest first)", async () => {
|
|
75
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase", "first");
|
|
76
|
-
await ledger.credit("t1", Credit.fromCents(200), "promo", "second");
|
|
77
|
-
await ledger.debit("t1", Credit.fromCents(50), "bot_runtime", "third");
|
|
78
|
-
const hist = await ledger.history("t1");
|
|
79
|
-
expect(hist).toHaveLength(3);
|
|
80
|
-
// newest first
|
|
81
|
-
expect(hist[0].description).toBe("third");
|
|
82
|
-
expect(hist[1].description).toBe("second");
|
|
83
|
-
expect(hist[2].description).toBe("first");
|
|
84
|
-
});
|
|
85
|
-
it("all CreditTransaction fields are populated", async () => {
|
|
86
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase", "desc", "ref-1", "stripe", "user-1");
|
|
87
|
-
const hist = await ledger.history("t1");
|
|
88
|
-
expect(hist).toHaveLength(1);
|
|
89
|
-
const txn = hist[0];
|
|
90
|
-
expect(txn.id).toEqual(expect.any(String));
|
|
91
|
-
expect(txn.tenantId).toBe("t1");
|
|
92
|
-
expect(txn.amount.toCents()).toBe(100);
|
|
93
|
-
expect(txn.balanceAfter.toCents()).toBe(100);
|
|
94
|
-
expect(txn.type).toBe("purchase");
|
|
95
|
-
expect(txn.description).toBe("desc");
|
|
96
|
-
expect(txn.referenceId).toBe("ref-1");
|
|
97
|
-
expect(txn.fundingSource).toBe("stripe");
|
|
98
|
-
expect(txn.attributedUserId).toBe("user-1");
|
|
99
|
-
expect(txn.createdAt).toEqual(expect.any(String));
|
|
100
|
-
});
|
|
101
|
-
it("respects limit and offset for pagination", async () => {
|
|
102
|
-
// Insert 5 transactions
|
|
103
|
-
for (let i = 1; i <= 5; i++) {
|
|
104
|
-
await ledger.credit("t1", Credit.fromCents(10 * i), "purchase", `txn-${i}`);
|
|
105
|
-
}
|
|
106
|
-
const page1 = await ledger.history("t1", { limit: 2, offset: 0 });
|
|
107
|
-
expect(page1).toHaveLength(2);
|
|
108
|
-
expect(page1[0].description).toBe("txn-5"); // newest first
|
|
109
|
-
expect(page1[1].description).toBe("txn-4");
|
|
110
|
-
const page2 = await ledger.history("t1", { limit: 2, offset: 2 });
|
|
111
|
-
expect(page2).toHaveLength(2);
|
|
112
|
-
expect(page2[0].description).toBe("txn-3");
|
|
113
|
-
expect(page2[1].description).toBe("txn-2");
|
|
114
|
-
});
|
|
115
|
-
it("filters by type when provided", async () => {
|
|
116
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase", "buy");
|
|
117
|
-
await ledger.credit("t1", Credit.fromCents(50), "promo", "free");
|
|
118
|
-
await ledger.debit("t1", Credit.fromCents(10), "bot_runtime", "usage");
|
|
119
|
-
const purchases = await ledger.history("t1", { type: "purchase" });
|
|
120
|
-
expect(purchases).toHaveLength(1);
|
|
121
|
-
expect(purchases[0].description).toBe("buy");
|
|
122
|
-
});
|
|
123
|
-
it("returns empty array for tenant with no transactions", async () => {
|
|
124
|
-
const hist = await ledger.history("nonexistent");
|
|
125
|
-
expect(hist).toEqual([]);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
// --- hasReferenceId() ---
|
|
129
|
-
describe("hasReferenceId()", () => {
|
|
130
|
-
it("returns false for a reference ID that does not exist", async () => {
|
|
131
|
-
expect(await ledger.hasReferenceId("nonexistent-ref")).toBe(false);
|
|
132
|
-
});
|
|
133
|
-
it("returns true for a reference ID used in a credit", async () => {
|
|
134
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase", "desc", "ref-unique");
|
|
135
|
-
expect(await ledger.hasReferenceId("ref-unique")).toBe(true);
|
|
136
|
-
});
|
|
137
|
-
it("returns true for a reference ID used in a debit", async () => {
|
|
138
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
139
|
-
await ledger.debit("t1", Credit.fromCents(10), "bot_runtime", "desc", "debit-ref");
|
|
140
|
-
expect(await ledger.hasReferenceId("debit-ref")).toBe(true);
|
|
141
|
-
});
|
|
142
|
-
it("detects reference IDs across different tenants", async () => {
|
|
143
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase", "desc", "cross-tenant-ref");
|
|
144
|
-
// hasReferenceId is global, not tenant-scoped
|
|
145
|
-
expect(await ledger.hasReferenceId("cross-tenant-ref")).toBe(true);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
// --- tenantsWithBalance() ---
|
|
149
|
-
describe("tenantsWithBalance()", () => {
|
|
150
|
-
it("returns empty array when no tenants exist", async () => {
|
|
151
|
-
const result = await ledger.tenantsWithBalance();
|
|
152
|
-
expect(result).toEqual([]);
|
|
153
|
-
});
|
|
154
|
-
it("returns only tenants with positive balance", async () => {
|
|
155
|
-
// t1: positive balance (100 cents)
|
|
156
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
157
|
-
// t2: zero balance (credit then debit same amount)
|
|
158
|
-
await ledger.credit("t2", Credit.fromCents(50), "purchase");
|
|
159
|
-
await ledger.debit("t2", Credit.fromCents(50), "bot_runtime");
|
|
160
|
-
// t3: negative balance (via allowNegative)
|
|
161
|
-
await ledger.credit("t3", Credit.fromCents(10), "purchase");
|
|
162
|
-
await ledger.debit("t3", Credit.fromCents(20), "bot_runtime", undefined, undefined, true);
|
|
163
|
-
// t4: positive balance (200 cents)
|
|
164
|
-
await ledger.credit("t4", Credit.fromCents(200), "signup_grant");
|
|
165
|
-
const result = await ledger.tenantsWithBalance();
|
|
166
|
-
const tenantIds = result.map((r) => r.tenantId).sort();
|
|
167
|
-
expect(tenantIds).toEqual(["t1", "t4"]);
|
|
168
|
-
const t1 = result.find((r) => r.tenantId === "t1");
|
|
169
|
-
expect(t1?.balance.toCents()).toBe(100);
|
|
170
|
-
const t4 = result.find((r) => r.tenantId === "t4");
|
|
171
|
-
expect(t4?.balance.toCents()).toBe(200);
|
|
172
|
-
});
|
|
173
|
-
it("excludes tenants with exactly zero balance", async () => {
|
|
174
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
175
|
-
await ledger.debit("t1", Credit.fromCents(100), "bot_runtime");
|
|
176
|
-
const result = await ledger.tenantsWithBalance();
|
|
177
|
-
expect(result).toEqual([]);
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
describe("CreditLedger.debit with allowNegative", () => {
|
|
182
|
-
let ledger;
|
|
183
|
-
beforeEach(async () => {
|
|
184
|
-
await truncateAllTables(pool);
|
|
185
|
-
ledger = new CreditLedger(db);
|
|
186
|
-
});
|
|
187
|
-
it("debit with allowNegative=false (default) throws InsufficientBalanceError when balance insufficient", async () => {
|
|
188
|
-
await ledger.credit("t1", Credit.fromCents(5), "purchase", "setup");
|
|
189
|
-
await expect(ledger.debit("t1", Credit.fromCents(10), "adapter_usage", "test")).rejects.toThrow(InsufficientBalanceError);
|
|
190
|
-
});
|
|
191
|
-
it("debit with allowNegative=true allows negative balance", async () => {
|
|
192
|
-
await ledger.credit("t1", Credit.fromCents(5), "purchase", "setup");
|
|
193
|
-
const txn = await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", "test", undefined, true);
|
|
194
|
-
expect(txn).not.toBeNull();
|
|
195
|
-
expect((await ledger.balance("t1")).toCents()).toBe(-5);
|
|
196
|
-
});
|
|
197
|
-
it("debit with allowNegative=true records correct transaction with negative amount and negative balanceAfter", async () => {
|
|
198
|
-
await ledger.credit("t1", Credit.fromCents(5), "purchase", "setup");
|
|
199
|
-
const txn = await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", "test", undefined, true);
|
|
200
|
-
expect(txn.amount.toCents()).toBe(-10);
|
|
201
|
-
expect(txn.balanceAfter.toCents()).toBe(-5);
|
|
202
|
-
});
|
|
203
|
-
});
|