@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Credit,
|
|
1
|
+
import { Credit, DrizzleLedger, runCreditExpiryCron } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
4
4
|
describe("runCreditExpiryCron", () => {
|
|
@@ -7,13 +7,14 @@ describe("runCreditExpiryCron", () => {
|
|
|
7
7
|
beforeAll(async () => {
|
|
8
8
|
const { db, pool: p } = await createTestDb();
|
|
9
9
|
pool = p;
|
|
10
|
-
ledger = new
|
|
10
|
+
ledger = new DrizzleLedger(db);
|
|
11
11
|
});
|
|
12
12
|
afterAll(async () => {
|
|
13
13
|
await pool.close();
|
|
14
14
|
});
|
|
15
15
|
beforeEach(async () => {
|
|
16
16
|
await truncateAllTables(pool);
|
|
17
|
+
await ledger.seedSystemAccounts();
|
|
17
18
|
});
|
|
18
19
|
// All tests pass an explicit `now` parameter — hardcoded dates are time-independent
|
|
19
20
|
// because runCreditExpiryCron never reads the system clock.
|
|
@@ -24,7 +25,11 @@ describe("runCreditExpiryCron", () => {
|
|
|
24
25
|
expect(result.errors).toEqual([]);
|
|
25
26
|
});
|
|
26
27
|
it("debits expired promotional credit grant", async () => {
|
|
27
|
-
await ledger.credit("tenant-1", Credit.fromCents(500), "promo",
|
|
28
|
+
await ledger.credit("tenant-1", Credit.fromCents(500), "promo", {
|
|
29
|
+
description: "New user bonus",
|
|
30
|
+
referenceId: "promo:tenant-1",
|
|
31
|
+
expiresAt: "2026-01-10T00:00:00Z",
|
|
32
|
+
});
|
|
28
33
|
const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
|
|
29
34
|
expect(result.processed).toBe(1);
|
|
30
35
|
expect(result.expired).toContain("tenant-1");
|
|
@@ -32,29 +37,41 @@ describe("runCreditExpiryCron", () => {
|
|
|
32
37
|
expect(balance.toCents()).toBe(0);
|
|
33
38
|
});
|
|
34
39
|
it("does not debit non-expired credits", async () => {
|
|
35
|
-
await ledger.credit("tenant-1", Credit.fromCents(500), "promo",
|
|
40
|
+
await ledger.credit("tenant-1", Credit.fromCents(500), "promo", {
|
|
41
|
+
description: "Future bonus",
|
|
42
|
+
referenceId: "promo:tenant-1-future",
|
|
43
|
+
expiresAt: "2026-02-01T00:00:00Z",
|
|
44
|
+
});
|
|
36
45
|
const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
|
|
37
46
|
expect(result.processed).toBe(0);
|
|
38
47
|
const balance = await ledger.balance("tenant-1");
|
|
39
48
|
expect(balance.toCents()).toBe(500);
|
|
40
49
|
});
|
|
41
50
|
it("does not debit credits without expires_at", async () => {
|
|
42
|
-
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", "Top-up");
|
|
51
|
+
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "Top-up" });
|
|
43
52
|
const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
|
|
44
53
|
expect(result.processed).toBe(0);
|
|
45
54
|
const balance = await ledger.balance("tenant-1");
|
|
46
55
|
expect(balance.toCents()).toBe(500);
|
|
47
56
|
});
|
|
48
57
|
it("only debits up to available balance when partially consumed", async () => {
|
|
49
|
-
await ledger.credit("tenant-1", Credit.fromCents(500), "promo",
|
|
50
|
-
|
|
58
|
+
await ledger.credit("tenant-1", Credit.fromCents(500), "promo", {
|
|
59
|
+
description: "Promo",
|
|
60
|
+
referenceId: "promo:partial",
|
|
61
|
+
expiresAt: "2026-01-10T00:00:00Z",
|
|
62
|
+
});
|
|
63
|
+
await ledger.debit("tenant-1", Credit.fromCents(300), "bot_runtime", { description: "Runtime" });
|
|
51
64
|
const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
|
|
52
65
|
expect(result.processed).toBe(1);
|
|
53
66
|
const balance = await ledger.balance("tenant-1");
|
|
54
67
|
expect(balance.toCents()).toBe(0);
|
|
55
68
|
});
|
|
56
69
|
it("is idempotent -- does not double-debit on second run", async () => {
|
|
57
|
-
await ledger.credit("tenant-1", Credit.fromCents(500), "promo",
|
|
70
|
+
await ledger.credit("tenant-1", Credit.fromCents(500), "promo", {
|
|
71
|
+
description: "Promo",
|
|
72
|
+
referenceId: "promo:idemp",
|
|
73
|
+
expiresAt: "2026-01-10T00:00:00Z",
|
|
74
|
+
});
|
|
58
75
|
await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
|
|
59
76
|
const balanceAfterFirst = await ledger.balance("tenant-1");
|
|
60
77
|
const result2 = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { Credit } from "@wopr-network/platform-core/credits";
|
|
3
|
-
import type { ICreditTransactionRepository } from "./credit-transaction-repository.js";
|
|
4
3
|
export interface DividendCronConfig {
|
|
5
|
-
|
|
6
|
-
ledger: ICreditLedger;
|
|
4
|
+
ledger: ILedger;
|
|
7
5
|
/** Fraction of daily purchases matched as dividend pool. Default 1.0 (100%). */
|
|
8
6
|
matchRate: number;
|
|
9
7
|
/** The date to compute dividend for, as YYYY-MM-DD string. Typically yesterday. */
|
|
@@ -21,7 +21,7 @@ export async function runDividendCron(cfg) {
|
|
|
21
21
|
// Idempotency: check if any per-tenant dividend was already distributed for this date.
|
|
22
22
|
// We look for any referenceId matching "dividend:YYYY-MM-DD:*".
|
|
23
23
|
const sentinelPrefix = `dividend:${cfg.targetDate}:`;
|
|
24
|
-
const alreadyRan = await cfg.
|
|
24
|
+
const alreadyRan = await cfg.ledger.existsByReferenceIdLike(`${sentinelPrefix}%`);
|
|
25
25
|
if (alreadyRan) {
|
|
26
26
|
result.skippedAlreadyRun = true;
|
|
27
27
|
logger.info("Dividend cron already ran for this date", { targetDate: cfg.targetDate });
|
|
@@ -30,14 +30,14 @@ export async function runDividendCron(cfg) {
|
|
|
30
30
|
// Step 1: Sum all purchase amounts for the target date.
|
|
31
31
|
const dayStart = `${cfg.targetDate} 00:00:00`;
|
|
32
32
|
const dayEnd = `${cfg.targetDate} 24:00:00`;
|
|
33
|
-
const dailyPurchaseTotalCredit = await cfg.
|
|
33
|
+
const dailyPurchaseTotalCredit = await cfg.ledger.sumPurchasesForPeriod(dayStart, dayEnd);
|
|
34
34
|
result.pool = dailyPurchaseTotalCredit.multiply(cfg.matchRate);
|
|
35
35
|
// Step 2: Find all active tenants (purchased in last 7 days from target date).
|
|
36
36
|
// The 7-day window is: [targetDate - 6 days 00:00:00, targetDate 24:00:00)
|
|
37
37
|
// This gives a full 7-day range ending at the end of targetDate.
|
|
38
38
|
const windowStart = subtractDays(cfg.targetDate, 6);
|
|
39
39
|
const windowStartTs = `${windowStart} 00:00:00`;
|
|
40
|
-
const activeTenantIds = await cfg.
|
|
40
|
+
const activeTenantIds = await cfg.ledger.getActiveTenantIdsInWindow(windowStartTs, dayEnd);
|
|
41
41
|
result.activeCount = activeTenantIds.length;
|
|
42
42
|
// Step 3: Compute per-user share.
|
|
43
43
|
if (result.pool.isZero() || result.activeCount <= 0) {
|
|
@@ -61,7 +61,10 @@ export async function runDividendCron(cfg) {
|
|
|
61
61
|
for (const tenantId of activeTenantIds) {
|
|
62
62
|
const perUserRef = `dividend:${cfg.targetDate}:${tenantId}`;
|
|
63
63
|
try {
|
|
64
|
-
await cfg.ledger.credit(tenantId, result.perUser, "community_dividend",
|
|
64
|
+
await cfg.ledger.credit(tenantId, result.perUser, "community_dividend", {
|
|
65
|
+
description: `Community dividend for ${cfg.targetDate}: pool ${result.pool.toCents()}c / ${result.activeCount} users`,
|
|
66
|
+
referenceId: perUserRef,
|
|
67
|
+
});
|
|
65
68
|
result.distributed++;
|
|
66
69
|
}
|
|
67
70
|
catch (err) {
|
|
@@ -1,40 +1,25 @@
|
|
|
1
|
-
import { Credit,
|
|
1
|
+
import { CREDIT_TYPE_ACCOUNT, Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
-
import { creditBalances, creditTransactions } from "../../db/schema/credits.js";
|
|
4
3
|
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
5
|
-
import { DrizzleCreditTransactionRepository } from "./credit-transaction-repository.js";
|
|
6
4
|
import { runDividendCron } from "./dividend-cron.js";
|
|
7
|
-
async function insertPurchase(
|
|
8
|
-
const id = `test-${tenantId}-${Date.now()}-${Math.random()}`;
|
|
5
|
+
async function insertPurchase(ledger, tenantId, amountCents, postedAt) {
|
|
9
6
|
const amount = Credit.fromCents(amountCents);
|
|
10
|
-
await
|
|
11
|
-
|
|
7
|
+
await ledger.post({
|
|
8
|
+
entryType: "purchase",
|
|
12
9
|
tenantId,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
description: `Test purchase ${amountCents}¢`,
|
|
11
|
+
referenceId: `test-purchase:${tenantId}:${postedAt}:${Math.random()}`,
|
|
12
|
+
postedAt,
|
|
13
|
+
lines: [
|
|
14
|
+
{ accountCode: CREDIT_TYPE_ACCOUNT.purchase, amount, side: "debit" },
|
|
15
|
+
{ accountCode: `2000:${tenantId}`, amount, side: "credit" },
|
|
16
|
+
],
|
|
17
17
|
});
|
|
18
|
-
// Upsert credit_balances
|
|
19
|
-
const existing = await db
|
|
20
|
-
.select()
|
|
21
|
-
.from(creditBalances)
|
|
22
|
-
.where((await import("drizzle-orm")).eq(creditBalances.tenantId, tenantId));
|
|
23
|
-
if (existing.length > 0) {
|
|
24
|
-
await db
|
|
25
|
-
.update(creditBalances)
|
|
26
|
-
.set({ balance: existing[0].balance.add(amount) })
|
|
27
|
-
.where((await import("drizzle-orm")).eq(creditBalances.tenantId, tenantId));
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
await db.insert(creditBalances).values({ tenantId, balance: amount });
|
|
31
|
-
}
|
|
32
18
|
}
|
|
33
19
|
describe("runDividendCron", () => {
|
|
34
20
|
let pool;
|
|
35
21
|
let db;
|
|
36
22
|
let ledger;
|
|
37
|
-
let creditTransactionRepo;
|
|
38
23
|
beforeAll(async () => {
|
|
39
24
|
({ db, pool } = await createTestDb());
|
|
40
25
|
});
|
|
@@ -43,12 +28,11 @@ describe("runDividendCron", () => {
|
|
|
43
28
|
});
|
|
44
29
|
beforeEach(async () => {
|
|
45
30
|
await truncateAllTables(pool);
|
|
46
|
-
ledger = new
|
|
47
|
-
|
|
31
|
+
ledger = new DrizzleLedger(db);
|
|
32
|
+
await ledger.seedSystemAccounts();
|
|
48
33
|
});
|
|
49
34
|
function makeConfig(overrides) {
|
|
50
35
|
return {
|
|
51
|
-
creditTransactionRepo,
|
|
52
36
|
ledger,
|
|
53
37
|
matchRate: 1.0,
|
|
54
38
|
targetDate: "2026-02-20",
|
|
@@ -56,7 +40,7 @@ describe("runDividendCron", () => {
|
|
|
56
40
|
};
|
|
57
41
|
}
|
|
58
42
|
it("distributes dividend to eligible tenants", async () => {
|
|
59
|
-
await insertPurchase(
|
|
43
|
+
await insertPurchase(ledger, "t1", 1000, "2026-02-20 12:00:00");
|
|
60
44
|
const result = await runDividendCron(makeConfig());
|
|
61
45
|
expect(result.distributed).toBe(1);
|
|
62
46
|
expect(result.pool.toCents()).toBe(1000);
|
|
@@ -64,7 +48,7 @@ describe("runDividendCron", () => {
|
|
|
64
48
|
expect(result.activeCount).toBe(1);
|
|
65
49
|
});
|
|
66
50
|
it("is idempotent — skips if already ran for the date", async () => {
|
|
67
|
-
await insertPurchase(
|
|
51
|
+
await insertPurchase(ledger, "t1", 1000, "2026-02-20 12:00:00");
|
|
68
52
|
const result1 = await runDividendCron(makeConfig());
|
|
69
53
|
expect(result1.distributed).toBe(1);
|
|
70
54
|
expect(result1.skippedAlreadyRun).toBe(false);
|
|
@@ -75,20 +59,19 @@ describe("runDividendCron", () => {
|
|
|
75
59
|
expect((await ledger.balance("t1")).equals(balanceAfterFirst)).toBe(true);
|
|
76
60
|
});
|
|
77
61
|
it("handles floor rounding — remainder is not distributed", async () => {
|
|
78
|
-
await insertPurchase(
|
|
79
|
-
await insertPurchase(
|
|
80
|
-
await insertPurchase(
|
|
62
|
+
await insertPurchase(ledger, "t1", 50, "2026-02-20 12:00:00");
|
|
63
|
+
await insertPurchase(ledger, "t2", 30, "2026-02-20 12:00:00");
|
|
64
|
+
await insertPurchase(ledger, "t3", 20, "2026-02-20 12:00:00");
|
|
81
65
|
const result = await runDividendCron(makeConfig());
|
|
82
66
|
expect(result.pool.toCents()).toBe(100);
|
|
83
67
|
expect(result.activeCount).toBe(3);
|
|
84
68
|
// Nanodollar precision: floor(1_000_000_000 raw / 3) = 333_333_333 raw each
|
|
85
|
-
// Remainder = 1 nanodollar (not 1 cent — far less wasted with higher scale)
|
|
86
69
|
expect(result.perUser.toRaw()).toBe(333_333_333);
|
|
87
70
|
expect(result.distributed).toBe(3);
|
|
88
71
|
});
|
|
89
72
|
it("skips distribution when pool is zero", async () => {
|
|
90
73
|
// Tenant purchased within 7 days but NOT on target date -> pool = 0
|
|
91
|
-
await insertPurchase(
|
|
74
|
+
await insertPurchase(ledger, "t1", 500, "2026-02-18 12:00:00");
|
|
92
75
|
const result = await runDividendCron(makeConfig());
|
|
93
76
|
expect(result.pool.toCents()).toBe(0);
|
|
94
77
|
expect(result.activeCount).toBe(1);
|
|
@@ -96,11 +79,9 @@ describe("runDividendCron", () => {
|
|
|
96
79
|
expect(result.distributed).toBe(0);
|
|
97
80
|
});
|
|
98
81
|
it("distributes sub-cent amounts at nanodollar precision", async () => {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
await insertPurchase(
|
|
102
|
-
await insertPurchase(db, "t2", 500, "2026-02-18 12:00:00");
|
|
103
|
-
await insertPurchase(db, "t3", 500, "2026-02-17 12:00:00");
|
|
82
|
+
await insertPurchase(ledger, "t1", 1, "2026-02-20 12:00:00");
|
|
83
|
+
await insertPurchase(ledger, "t2", 500, "2026-02-18 12:00:00");
|
|
84
|
+
await insertPurchase(ledger, "t3", 500, "2026-02-17 12:00:00");
|
|
104
85
|
const result = await runDividendCron(makeConfig({ matchRate: 1.0 }));
|
|
105
86
|
expect(result.pool.toCents()).toBe(1);
|
|
106
87
|
expect(result.activeCount).toBe(3);
|
|
@@ -108,18 +89,17 @@ describe("runDividendCron", () => {
|
|
|
108
89
|
expect(result.distributed).toBe(3);
|
|
109
90
|
});
|
|
110
91
|
it("records transactions with correct type and referenceId", async () => {
|
|
111
|
-
await insertPurchase(
|
|
92
|
+
await insertPurchase(ledger, "t1", 1000, "2026-02-20 12:00:00");
|
|
112
93
|
await runDividendCron(makeConfig());
|
|
113
94
|
const history = await ledger.history("t1", { type: "community_dividend" });
|
|
114
95
|
expect(history).toHaveLength(1);
|
|
115
|
-
expect(history[0].
|
|
96
|
+
expect(history[0].entryType).toBe("community_dividend");
|
|
116
97
|
expect(history[0].referenceId).toBe("dividend:2026-02-20:t1");
|
|
117
|
-
expect(history[0].amount.toCents()).toBe(1000);
|
|
118
98
|
expect(history[0].description).toContain("Community dividend");
|
|
119
99
|
});
|
|
120
100
|
it("collects errors without stopping distribution to other tenants", async () => {
|
|
121
|
-
await insertPurchase(
|
|
122
|
-
await insertPurchase(
|
|
101
|
+
await insertPurchase(ledger, "t1", 500, "2026-02-20 12:00:00");
|
|
102
|
+
await insertPurchase(ledger, "t2", 500, "2026-02-20 12:00:00");
|
|
123
103
|
const result = await runDividendCron(makeConfig());
|
|
124
104
|
expect(result.distributed).toBe(2);
|
|
125
105
|
expect(result.errors).toEqual([]);
|
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
import { Credit } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { and, desc, eq, gte, lt, sql } from "drizzle-orm";
|
|
3
3
|
import { adminUsers } from "../../db/schema/admin-users.js";
|
|
4
|
-
import { creditTransactions } from "../../db/schema/credits.js";
|
|
5
4
|
import { dividendDistributions } from "../../db/schema/dividend-distributions.js";
|
|
5
|
+
import { journalEntries, journalLines } from "../../db/schema/ledger.js";
|
|
6
6
|
export class DrizzleDividendRepository {
|
|
7
7
|
db;
|
|
8
8
|
constructor(db) {
|
|
9
9
|
this.db = db;
|
|
10
10
|
}
|
|
11
11
|
async getStats(tenantId) {
|
|
12
|
-
// 1. Pool = sum of purchase amounts from yesterday UTC
|
|
12
|
+
// 1. Pool = sum of purchase credit amounts from yesterday UTC
|
|
13
13
|
const poolRow = (await this.db
|
|
14
|
-
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
.where(and(eq(
|
|
18
|
-
|
|
19
|
-
sql `${creditTransactions.createdAt}::timestamp >= date_trunc('day', timezone('UTC', now())) - INTERVAL '1 day'`, sql `${creditTransactions.createdAt}::timestamp < date_trunc('day', timezone('UTC', now()))`)))[0];
|
|
20
|
-
const poolCents = poolRow?.total ?? 0;
|
|
21
|
-
const pool = Credit.fromCents(poolCents);
|
|
14
|
+
.select({ total: sql `COALESCE(SUM(${journalLines.amount}), 0)` })
|
|
15
|
+
.from(journalLines)
|
|
16
|
+
.innerJoin(journalEntries, eq(journalEntries.id, journalLines.journalEntryId))
|
|
17
|
+
.where(and(eq(journalEntries.entryType, "purchase"), eq(journalLines.side, "credit"), sql `${journalEntries.postedAt}::timestamp >= date_trunc('day', timezone('UTC', now())) - INTERVAL '1 day'`, sql `${journalEntries.postedAt}::timestamp < date_trunc('day', timezone('UTC', now()))`)))[0];
|
|
18
|
+
const pool = Credit.fromRaw(Number(poolRow?.total ?? 0));
|
|
22
19
|
// 2. Active users = distinct tenants with a purchase in the last 7 days
|
|
23
20
|
const activeRow = (await this.db
|
|
24
|
-
|
|
25
|
-
.
|
|
26
|
-
.
|
|
27
|
-
.where(and(eq(creditTransactions.type, "purchase"),
|
|
28
|
-
// raw SQL: Drizzle cannot express timestamp comparison with interval arithmetic
|
|
29
|
-
sql `${creditTransactions.createdAt}::timestamp >= timezone('UTC', now()) - INTERVAL '7 days'`)))[0];
|
|
21
|
+
.select({ count: sql `COUNT(DISTINCT ${journalEntries.tenantId})` })
|
|
22
|
+
.from(journalEntries)
|
|
23
|
+
.where(and(eq(journalEntries.entryType, "purchase"), sql `${journalEntries.postedAt}::timestamp >= timezone('UTC', now()) - INTERVAL '7 days'`)))[0];
|
|
30
24
|
const activeUsers = activeRow?.count ?? 0;
|
|
31
25
|
// 3. Per-user projection (avoid division by zero)
|
|
32
26
|
const perUser = activeUsers > 0 ? Credit.fromRaw(Math.floor(pool.toRaw() / activeUsers)) : Credit.ZERO;
|
|
@@ -36,19 +30,16 @@ export class DrizzleDividendRepository {
|
|
|
36
30
|
const nextDistributionAt = nextMidnight.toISOString();
|
|
37
31
|
// 5. User eligibility — last purchase within 7 days
|
|
38
32
|
const userPurchaseRow = (await this.db
|
|
39
|
-
.select({
|
|
40
|
-
.from(
|
|
41
|
-
.where(and(eq(
|
|
42
|
-
.orderBy(desc(
|
|
33
|
+
.select({ postedAt: journalEntries.postedAt })
|
|
34
|
+
.from(journalEntries)
|
|
35
|
+
.where(and(eq(journalEntries.tenantId, tenantId), eq(journalEntries.entryType, "purchase")))
|
|
36
|
+
.orderBy(desc(journalEntries.postedAt))
|
|
43
37
|
.limit(1))[0];
|
|
44
38
|
let userEligible = false;
|
|
45
39
|
let userLastPurchaseAt = null;
|
|
46
40
|
let userWindowExpiresAt = null;
|
|
47
41
|
if (userPurchaseRow) {
|
|
48
|
-
const
|
|
49
|
-
// Parse the timestamp directly. PGlite may return ISO strings with or without
|
|
50
|
-
// timezone suffix. JavaScript's Date constructor handles ISO 8601 strings natively.
|
|
51
|
-
const lastPurchase = new Date(rawTs);
|
|
42
|
+
const lastPurchase = new Date(userPurchaseRow.postedAt);
|
|
52
43
|
userLastPurchaseAt = lastPurchase.toISOString();
|
|
53
44
|
const windowExpiry = new Date(lastPurchase.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
54
45
|
userWindowExpiresAt = windowExpiry.toISOString();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import { Credit,
|
|
2
|
+
import { Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
|
|
3
3
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
4
4
|
import { adminUsers } from "../../db/schema/admin-users.js";
|
|
5
5
|
import { dividendDistributions } from "../../db/schema/dividend-distributions.js";
|
|
@@ -39,6 +39,7 @@ describe("DrizzleDividendRepository", () => {
|
|
|
39
39
|
let repo;
|
|
40
40
|
beforeEach(async () => {
|
|
41
41
|
await truncateAllTables(pool);
|
|
42
|
+
await new DrizzleLedger(db).seedSystemAccounts();
|
|
42
43
|
repo = new DrizzleDividendRepository(db);
|
|
43
44
|
});
|
|
44
45
|
// --- getHistory() ---
|
|
@@ -152,8 +153,8 @@ describe("DrizzleDividendRepository", () => {
|
|
|
152
153
|
expect(stats.nextDistributionAt).toEqual(expect.any(String));
|
|
153
154
|
});
|
|
154
155
|
it("marks user as eligible when they have a recent purchase", async () => {
|
|
155
|
-
const ledger = new
|
|
156
|
-
await ledger.credit("t1", Credit.fromCents(100), "purchase", "recent buy");
|
|
156
|
+
const ledger = new DrizzleLedger(db);
|
|
157
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase", { description: "recent buy" });
|
|
157
158
|
const stats = await repo.getStats("t1");
|
|
158
159
|
expect(stats.userEligible).toBe(true);
|
|
159
160
|
expect(stats.userLastPurchaseAt).toEqual(expect.any(String));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type { AutoTopupSettings, CreditExpiryCronConfig, CreditExpiryCronResult,
|
|
2
|
-
export { ALLOWED_SCHEDULE_INTERVALS, ALLOWED_THRESHOLDS, ALLOWED_TOPUP_AMOUNTS,
|
|
1
|
+
export type { AutoTopupSettings, CreditExpiryCronConfig, CreditExpiryCronResult, CreditType, DebitType, HistoryOptions, IAutoTopupSettingsRepository, ILedger, JournalEntry, TransactionType, } from "@wopr-network/platform-core/credits";
|
|
2
|
+
export { ALLOWED_SCHEDULE_INTERVALS, ALLOWED_THRESHOLDS, ALLOWED_TOPUP_AMOUNTS, computeNextScheduleAt, DrizzleAutoTopupSettingsRepository, DrizzleLedger, grantSignupCredits, InsufficientBalanceError, Ledger, runCreditExpiryCron, SIGNUP_GRANT, } from "@wopr-network/platform-core/credits";
|
|
3
3
|
export type { BillingState, IBotBilling } from "./bot-billing.js";
|
|
4
4
|
export { BotBilling, DrizzleBotBilling, SUSPENSION_GRACE_DAYS } from "./bot-billing.js";
|
|
5
5
|
export type { DividendDigestConfig, DividendDigestResult } from "./dividend-digest-cron.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { ALLOWED_SCHEDULE_INTERVALS, ALLOWED_THRESHOLDS, ALLOWED_TOPUP_AMOUNTS,
|
|
1
|
+
export { ALLOWED_SCHEDULE_INTERVALS, ALLOWED_THRESHOLDS, ALLOWED_TOPUP_AMOUNTS, computeNextScheduleAt, DrizzleAutoTopupSettingsRepository, DrizzleLedger, grantSignupCredits, InsufficientBalanceError, Ledger, runCreditExpiryCron, SIGNUP_GRANT, } from "@wopr-network/platform-core/credits";
|
|
2
2
|
export { BotBilling, DrizzleBotBilling, SUSPENSION_GRACE_DAYS } from "./bot-billing.js";
|
|
3
3
|
export { runDividendDigestCron } from "./dividend-digest-cron.js";
|
|
4
4
|
export { buildResourceTierCosts, DAILY_BOT_COST, runRuntimeDeductions } from "./runtime-cron.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Credit,
|
|
1
|
+
import { Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
4
|
-
describe("
|
|
4
|
+
describe("DrizzleDrizzleLedger.memberUsage", () => {
|
|
5
5
|
let pool;
|
|
6
6
|
let db;
|
|
7
7
|
let ledger;
|
|
@@ -13,13 +13,23 @@ describe("DrizzleCreditLedger.memberUsage", () => {
|
|
|
13
13
|
});
|
|
14
14
|
beforeEach(async () => {
|
|
15
15
|
await truncateAllTables(pool);
|
|
16
|
-
ledger = new
|
|
16
|
+
ledger = new DrizzleLedger(db);
|
|
17
|
+
await ledger.seedSystemAccounts();
|
|
17
18
|
});
|
|
18
19
|
it("should aggregate debit totals per attributed user", async () => {
|
|
19
|
-
await ledger.credit("org-1", Credit.fromCents(10000), "purchase", "Seed");
|
|
20
|
-
await ledger.debit("org-1", Credit.fromCents(100), "adapter_usage",
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
await ledger.credit("org-1", Credit.fromCents(10000), "purchase", { description: "Seed" });
|
|
21
|
+
await ledger.debit("org-1", Credit.fromCents(100), "adapter_usage", {
|
|
22
|
+
description: "Chat",
|
|
23
|
+
attributedUserId: "user-a",
|
|
24
|
+
});
|
|
25
|
+
await ledger.debit("org-1", Credit.fromCents(200), "adapter_usage", {
|
|
26
|
+
description: "Chat",
|
|
27
|
+
attributedUserId: "user-a",
|
|
28
|
+
});
|
|
29
|
+
await ledger.debit("org-1", Credit.fromCents(300), "adapter_usage", {
|
|
30
|
+
description: "Chat",
|
|
31
|
+
attributedUserId: "user-b",
|
|
32
|
+
});
|
|
23
33
|
const result = await ledger.memberUsage("org-1");
|
|
24
34
|
expect(result).toHaveLength(2);
|
|
25
35
|
const userA = result.find((r) => r.userId === "user-a");
|
|
@@ -30,9 +40,12 @@ describe("DrizzleCreditLedger.memberUsage", () => {
|
|
|
30
40
|
expect(userB?.transactionCount).toBe(1);
|
|
31
41
|
});
|
|
32
42
|
it("should exclude transactions with null attributedUserId", async () => {
|
|
33
|
-
await ledger.credit("org-1", Credit.fromCents(10000), "purchase", "Seed");
|
|
34
|
-
await ledger.debit("org-1", Credit.fromCents(100), "bot_runtime", "Cron"); // no attributedUserId
|
|
35
|
-
await ledger.debit("org-1", Credit.fromCents(200), "adapter_usage",
|
|
43
|
+
await ledger.credit("org-1", Credit.fromCents(10000), "purchase", { description: "Seed" });
|
|
44
|
+
await ledger.debit("org-1", Credit.fromCents(100), "bot_runtime", { description: "Cron" }); // no attributedUserId
|
|
45
|
+
await ledger.debit("org-1", Credit.fromCents(200), "adapter_usage", {
|
|
46
|
+
description: "Chat",
|
|
47
|
+
attributedUserId: "user-a",
|
|
48
|
+
});
|
|
36
49
|
const result = await ledger.memberUsage("org-1");
|
|
37
50
|
expect(result).toHaveLength(1);
|
|
38
51
|
expect(result[0]?.userId).toBe("user-a");
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import type { IMeterEmitter } from "@wopr-network/platform-core/metering";
|
|
3
3
|
import type { IPhoneNumberRepository } from "./drizzle-phone-number-repository.js";
|
|
4
4
|
export type { IPhoneNumberRepository } from "./drizzle-phone-number-repository.js";
|
|
5
5
|
/** Phone number monthly wholesale cost in USD. Exported for proxy.ts to import. */
|
|
6
6
|
export declare const PHONE_NUMBER_MONTHLY_COST = 1.15;
|
|
7
|
-
export declare function runMonthlyPhoneBilling(phoneRepo: IPhoneNumberRepository, ledger:
|
|
7
|
+
export declare function runMonthlyPhoneBilling(phoneRepo: IPhoneNumberRepository, ledger: ILedger, meter: IMeterEmitter): Promise<{
|
|
8
8
|
processed: number;
|
|
9
9
|
billed: {
|
|
10
10
|
tenantId: string;
|
|
@@ -23,7 +23,11 @@ export async function runMonthlyPhoneBilling(phoneRepo, ledger, meter) {
|
|
|
23
23
|
try {
|
|
24
24
|
const costCredit = Credit.fromDollars(PHONE_NUMBER_MONTHLY_COST);
|
|
25
25
|
const chargeCredit = withMargin(costCredit, PHONE_NUMBER_MARGIN);
|
|
26
|
-
await ledger.debit(number.tenantId, chargeCredit, "addon",
|
|
26
|
+
await ledger.debit(number.tenantId, chargeCredit, "addon", {
|
|
27
|
+
description: "Monthly phone number fee",
|
|
28
|
+
referenceId: `phone-billing:${number.sid}:${now.toISOString().slice(0, 7)}`,
|
|
29
|
+
allowNegative: true,
|
|
30
|
+
});
|
|
27
31
|
meter.emit({
|
|
28
32
|
tenant: number.tenantId,
|
|
29
33
|
cost: costCredit,
|
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
import { Credit, InsufficientBalanceError
|
|
1
|
+
import { Credit, InsufficientBalanceError } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { PHONE_NUMBER_MONTHLY_COST, runMonthlyPhoneBilling } from "./phone-billing.js";
|
|
4
4
|
function makeTx(tenantId) {
|
|
5
5
|
return {
|
|
6
6
|
id: "tx-1",
|
|
7
|
+
postedAt: new Date().toISOString(),
|
|
8
|
+
entryType: "addon",
|
|
7
9
|
tenantId,
|
|
8
|
-
amount: Credit.fromDollars(1),
|
|
9
|
-
balanceAfter: Credit.fromDollars(100),
|
|
10
|
-
type: "addon",
|
|
11
10
|
description: "Monthly phone number fee",
|
|
12
11
|
referenceId: null,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
createdAt: new Date().toISOString(),
|
|
16
|
-
expiresAt: null,
|
|
12
|
+
metadata: null,
|
|
13
|
+
lines: [],
|
|
17
14
|
};
|
|
18
15
|
}
|
|
19
16
|
function makeNumber(overrides = {}) {
|
|
@@ -85,16 +82,16 @@ describe("runMonthlyPhoneBilling", () => {
|
|
|
85
82
|
expect(result.failed).toEqual([]);
|
|
86
83
|
// Verify debit was called with the margined charge amount
|
|
87
84
|
expect(ledger.debit).toHaveBeenCalledOnce();
|
|
88
|
-
const [tenantId, chargeAmount, type,
|
|
85
|
+
const [tenantId, chargeAmount, type, opts] = ledger.debit.mock.calls[0];
|
|
89
86
|
expect(tenantId).toBe("tenant-1");
|
|
90
87
|
// chargeCredit = Credit.fromDollars(1.15).multiply(2.6)
|
|
91
88
|
const expectedCharge = Credit.fromDollars(1.15).multiply(2.6);
|
|
92
89
|
expect(chargeAmount.toRaw()).toBe(expectedCharge.toRaw());
|
|
93
90
|
expect(type).toBe("addon");
|
|
94
|
-
expect(description).toBe("Monthly phone number fee");
|
|
91
|
+
expect(opts.description).toBe("Monthly phone number fee");
|
|
95
92
|
const expectedMonth = `${NOW.getFullYear()}-${String(NOW.getMonth() + 1).padStart(2, "0")}`;
|
|
96
|
-
expect(referenceId).toMatch(new RegExp(`^phone-billing:PN-abc123:${expectedMonth}$`));
|
|
97
|
-
expect(allowNegative).toBe(true);
|
|
93
|
+
expect(opts.referenceId).toMatch(new RegExp(`^phone-billing:PN-abc123:${expectedMonth}$`));
|
|
94
|
+
expect(opts.allowNegative).toBe(true);
|
|
98
95
|
// Verify meter emission
|
|
99
96
|
expect(meter.emit).toHaveBeenCalledOnce();
|
|
100
97
|
const event = meter.emit.mock.calls[0][0];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { Credit } from "@wopr-network/platform-core/credits";
|
|
3
3
|
import type { IBotInstanceRepository } from "../../fleet/bot-instance-repository.js";
|
|
4
4
|
/**
|
|
@@ -13,7 +13,7 @@ export type GetActiveBotCount = (tenantId: string) => number | Promise<number>;
|
|
|
13
13
|
/** Low balance threshold ($1.00 = 20% of signup grant). */
|
|
14
14
|
export declare const LOW_BALANCE_THRESHOLD: Credit;
|
|
15
15
|
export interface RuntimeCronConfig {
|
|
16
|
-
ledger:
|
|
16
|
+
ledger: ILedger;
|
|
17
17
|
getActiveBotCount: GetActiveBotCount;
|
|
18
18
|
/** The date being billed, as YYYY-MM-DD. Used for idempotency. */
|
|
19
19
|
date: string;
|
|
@@ -69,17 +69,26 @@ export async function runRuntimeDeductions(cfg) {
|
|
|
69
69
|
const totalCost = DAILY_BOT_COST.multiply(botCount);
|
|
70
70
|
if (!balance.lessThan(totalCost)) {
|
|
71
71
|
// Full deduction
|
|
72
|
-
await cfg.ledger.debit(tenantId, totalCost, "bot_runtime",
|
|
72
|
+
await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
|
|
73
|
+
description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
|
|
74
|
+
referenceId: runtimeRef,
|
|
75
|
+
});
|
|
73
76
|
// Debit resource tier surcharges (if any)
|
|
74
77
|
if (cfg.getResourceTierCosts) {
|
|
75
78
|
const tierCost = await cfg.getResourceTierCosts(tenantId);
|
|
76
79
|
if (!tierCost.isZero()) {
|
|
77
80
|
const balanceAfterRuntime = await cfg.ledger.balance(tenantId);
|
|
78
81
|
if (!balanceAfterRuntime.lessThan(tierCost)) {
|
|
79
|
-
await cfg.ledger.debit(tenantId, tierCost, "resource_upgrade",
|
|
82
|
+
await cfg.ledger.debit(tenantId, tierCost, "resource_upgrade", {
|
|
83
|
+
description: "Daily resource tier surcharge",
|
|
84
|
+
referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
|
|
85
|
+
});
|
|
80
86
|
}
|
|
81
87
|
else if (balanceAfterRuntime.greaterThan(Credit.ZERO)) {
|
|
82
|
-
await cfg.ledger.debit(tenantId, balanceAfterRuntime, "resource_upgrade",
|
|
88
|
+
await cfg.ledger.debit(tenantId, balanceAfterRuntime, "resource_upgrade", {
|
|
89
|
+
description: "Partial resource tier surcharge (balance exhausted)",
|
|
90
|
+
referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
|
|
91
|
+
});
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
}
|
|
@@ -110,12 +119,18 @@ export async function runRuntimeDeductions(cfg) {
|
|
|
110
119
|
if (!storageCost.isZero()) {
|
|
111
120
|
const currentBalance = await cfg.ledger.balance(tenantId);
|
|
112
121
|
if (!currentBalance.lessThan(storageCost)) {
|
|
113
|
-
await cfg.ledger.debit(tenantId, storageCost, "storage_upgrade",
|
|
122
|
+
await cfg.ledger.debit(tenantId, storageCost, "storage_upgrade", {
|
|
123
|
+
description: "Daily storage tier surcharge",
|
|
124
|
+
referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
|
|
125
|
+
});
|
|
114
126
|
}
|
|
115
127
|
else {
|
|
116
128
|
// Partial debit — take what's left, then suspend
|
|
117
129
|
if (currentBalance.greaterThan(Credit.ZERO)) {
|
|
118
|
-
await cfg.ledger.debit(tenantId, currentBalance, "storage_upgrade",
|
|
130
|
+
await cfg.ledger.debit(tenantId, currentBalance, "storage_upgrade", {
|
|
131
|
+
description: "Partial storage tier surcharge (balance exhausted)",
|
|
132
|
+
referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
|
|
133
|
+
});
|
|
119
134
|
}
|
|
120
135
|
if (!result.suspended.includes(tenantId)) {
|
|
121
136
|
result.suspended.push(tenantId);
|
|
@@ -131,12 +146,18 @@ export async function runRuntimeDeductions(cfg) {
|
|
|
131
146
|
if (!addonCost.isZero()) {
|
|
132
147
|
const currentBalance = await cfg.ledger.balance(tenantId);
|
|
133
148
|
if (!currentBalance.lessThan(addonCost)) {
|
|
134
|
-
await cfg.ledger.debit(tenantId, addonCost, "addon",
|
|
149
|
+
await cfg.ledger.debit(tenantId, addonCost, "addon", {
|
|
150
|
+
description: "Daily infrastructure add-on charges",
|
|
151
|
+
referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
|
|
152
|
+
});
|
|
135
153
|
}
|
|
136
154
|
else {
|
|
137
155
|
// Partial debit — take what's left, then suspend
|
|
138
156
|
if (currentBalance.greaterThan(Credit.ZERO)) {
|
|
139
|
-
await cfg.ledger.debit(tenantId, currentBalance, "addon",
|
|
157
|
+
await cfg.ledger.debit(tenantId, currentBalance, "addon", {
|
|
158
|
+
description: "Partial add-on charges (balance exhausted)",
|
|
159
|
+
referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
|
|
160
|
+
});
|
|
140
161
|
}
|
|
141
162
|
if (!result.suspended.includes(tenantId)) {
|
|
142
163
|
result.suspended.push(tenantId);
|
|
@@ -150,7 +171,10 @@ export async function runRuntimeDeductions(cfg) {
|
|
|
150
171
|
else {
|
|
151
172
|
// Partial deduction — debit remaining balance, then suspend
|
|
152
173
|
if (balance.greaterThan(Credit.ZERO)) {
|
|
153
|
-
await cfg.ledger.debit(tenantId, balance, "bot_runtime",
|
|
174
|
+
await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
|
|
175
|
+
description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
|
|
176
|
+
referenceId: runtimeRef,
|
|
177
|
+
});
|
|
154
178
|
}
|
|
155
179
|
if (cfg.onCreditsExhausted) {
|
|
156
180
|
await cfg.onCreditsExhausted(tenantId);
|