@wopr-network/platform-core 1.13.3 → 1.14.1
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/.github/workflows/dependabot-auto-merge.yml +1 -2
- 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/index.d.ts +1 -0
- package/dist/db/schema/index.js +1 -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/protocol/deps.d.ts +2 -2
- package/dist/gateway/protocol/handlers.test.js +461 -0
- package/dist/gateway/proxy.d.ts +2 -2
- package/dist/gateway/types.d.ts +2 -2
- 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/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/0003_double_entry_ledger.sql +82 -0
- package/drizzle/migrations/meta/_journal.json +7 -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/index.ts +1 -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 +5 -5
- package/src/gateway/protocol/deps.ts +2 -2
- package/src/gateway/protocol/handlers.test.ts +549 -1
- package/src/gateway/proxy.ts +2 -2
- package/src/gateway/route-mounting.test.ts +2 -2
- package/src/gateway/types.ts +2 -2
- 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/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,5 +1,5 @@
|
|
|
1
1
|
import { and, eq, gte, lt, ne, sql } from "drizzle-orm";
|
|
2
|
-
import {
|
|
2
|
+
import { journalEntries, journalLines } from "../db/schema/ledger.js";
|
|
3
3
|
import { usageSummaries } from "../db/schema/meter-events.js";
|
|
4
4
|
export class DrizzleUsageSummaryRepository {
|
|
5
5
|
db;
|
|
@@ -25,19 +25,21 @@ export class DrizzleAdapterUsageRepository {
|
|
|
25
25
|
this.db = db;
|
|
26
26
|
}
|
|
27
27
|
async getAggregatedAdapterUsageDebits(startIso, endIso) {
|
|
28
|
+
// Sum the debit-side journal line amounts for adapter_usage entries.
|
|
29
|
+
// In double-entry: DR tenant liability (2000:<tenantId>), CR revenue:adapter_usage (4010).
|
|
30
|
+
// The debit line on the tenant account represents the charge amount.
|
|
28
31
|
const rows = await this.db
|
|
29
32
|
.select({
|
|
30
|
-
tenantId:
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
// raw SQL: Drizzle cannot express ABS with COALESCE and SUM
|
|
34
|
-
totalDebitRaw: sql `COALESCE(SUM(ABS(amount_credits)), 0)`,
|
|
33
|
+
tenantId: journalEntries.tenantId,
|
|
34
|
+
// raw SQL: Drizzle cannot express COALESCE with SUM aggregation
|
|
35
|
+
totalDebitRaw: sql `COALESCE(SUM(${journalLines.amount}), 0)`,
|
|
35
36
|
})
|
|
36
|
-
.from(
|
|
37
|
-
.
|
|
37
|
+
.from(journalLines)
|
|
38
|
+
.innerJoin(journalEntries, eq(journalEntries.id, journalLines.journalEntryId))
|
|
39
|
+
.where(and(eq(journalEntries.entryType, "adapter_usage"), eq(journalLines.side, "debit"),
|
|
38
40
|
// raw SQL: Drizzle cannot express timestamptz cast for text column date comparison
|
|
39
|
-
sql `${
|
|
40
|
-
.groupBy(
|
|
41
|
+
sql `${journalEntries.postedAt}::timestamptz >= ${startIso}::timestamptz`, sql `${journalEntries.postedAt}::timestamptz < ${endIso}::timestamptz`))
|
|
42
|
+
.groupBy(journalEntries.tenantId);
|
|
41
43
|
return rows.map((r) => ({ tenantId: r.tenantId, totalDebitRaw: Number(r.totalDebitRaw) }));
|
|
42
44
|
}
|
|
43
45
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { Credit } from "../credits/credit.js";
|
|
4
|
-
import {
|
|
4
|
+
import { DrizzleLedger } from "../credits/ledger.js";
|
|
5
5
|
import { createTestDb, seedUsageSummary, truncateAllTables } from "../test/db.js";
|
|
6
6
|
import { DrizzleAdapterUsageRepository, DrizzleUsageSummaryRepository } from "./reconciliation-repository.js";
|
|
7
7
|
let pool;
|
|
@@ -108,7 +108,8 @@ describe("DrizzleAdapterUsageRepository", () => {
|
|
|
108
108
|
beforeEach(async () => {
|
|
109
109
|
await truncateAllTables(pool);
|
|
110
110
|
repo = new DrizzleAdapterUsageRepository(db);
|
|
111
|
-
ledger = new
|
|
111
|
+
ledger = new DrizzleLedger(db);
|
|
112
|
+
await ledger.seedSystemAccounts();
|
|
112
113
|
});
|
|
113
114
|
it("returns empty array when no adapter_usage debits exist", async () => {
|
|
114
115
|
const today = new Date().toISOString().slice(0, 10);
|
|
@@ -121,9 +122,9 @@ describe("DrizzleAdapterUsageRepository", () => {
|
|
|
121
122
|
// Fund tenants
|
|
122
123
|
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
123
124
|
await ledger.credit("t2", Credit.fromCents(1000), "purchase");
|
|
124
|
-
await ledger.debit("t1", Credit.fromCents(30), "adapter_usage", "t1-debit-1");
|
|
125
|
-
await ledger.debit("t1", Credit.fromCents(20), "adapter_usage", "t1-debit-2");
|
|
126
|
-
await ledger.debit("t2", Credit.fromCents(50), "adapter_usage", "t2-debit-1");
|
|
125
|
+
await ledger.debit("t1", Credit.fromCents(30), "adapter_usage", { description: "t1-debit-1" });
|
|
126
|
+
await ledger.debit("t1", Credit.fromCents(20), "adapter_usage", { description: "t1-debit-2" });
|
|
127
|
+
await ledger.debit("t2", Credit.fromCents(50), "adapter_usage", { description: "t2-debit-1" });
|
|
127
128
|
// Query window covering today
|
|
128
129
|
const today = new Date().toISOString().slice(0, 10);
|
|
129
130
|
const startIso = `${today}T00:00:00Z`;
|
|
@@ -137,8 +138,8 @@ describe("DrizzleAdapterUsageRepository", () => {
|
|
|
137
138
|
});
|
|
138
139
|
it("excludes non-adapter_usage debit types", async () => {
|
|
139
140
|
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
140
|
-
await ledger.debit("t1", Credit.fromCents(30), "adapter_usage", "adapter debit");
|
|
141
|
-
await ledger.debit("t1", Credit.fromCents(20), "bot_runtime", "runtime debit");
|
|
141
|
+
await ledger.debit("t1", Credit.fromCents(30), "adapter_usage", { description: "adapter debit" });
|
|
142
|
+
await ledger.debit("t1", Credit.fromCents(20), "bot_runtime", { description: "runtime debit" });
|
|
142
143
|
const today = new Date().toISOString().slice(0, 10);
|
|
143
144
|
const startIso = `${today}T00:00:00Z`;
|
|
144
145
|
const endIso = new Date(new Date(startIso).getTime() + 86400000).toISOString();
|
|
@@ -148,7 +149,7 @@ describe("DrizzleAdapterUsageRepository", () => {
|
|
|
148
149
|
});
|
|
149
150
|
it("excludes credit transactions (positive amounts are not debits)", async () => {
|
|
150
151
|
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
151
|
-
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", "real debit");
|
|
152
|
+
await ledger.debit("t1", Credit.fromCents(10), "adapter_usage", { description: "real debit" });
|
|
152
153
|
const today = new Date().toISOString().slice(0, 10);
|
|
153
154
|
const startIso = `${today}T00:00:00Z`;
|
|
154
155
|
const endIso = new Date(new Date(startIso).getTime() + 86400000).toISOString();
|
|
@@ -3,7 +3,7 @@ import { and, count, desc, eq, gte, isNotNull, sql } from "drizzle-orm";
|
|
|
3
3
|
import { logger } from "../../config/logger.js";
|
|
4
4
|
import { affiliateReferrals } from "../../db/schema/affiliate.js";
|
|
5
5
|
import { affiliateFraudEvents } from "../../db/schema/affiliate-fraud.js";
|
|
6
|
-
import {
|
|
6
|
+
import { journalEntries } from "../../db/schema/ledger.js";
|
|
7
7
|
function parseSignals(raw) {
|
|
8
8
|
try {
|
|
9
9
|
const parsed = JSON.parse(raw);
|
|
@@ -84,10 +84,12 @@ export class DrizzleAffiliateFraudAdminRepository {
|
|
|
84
84
|
}
|
|
85
85
|
async listFingerprintClusters() {
|
|
86
86
|
const rows = (await this.db.execute(sql `
|
|
87
|
-
SELECT
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
SELECT metadata->>'stripeFingerprint' AS stripe_fingerprint,
|
|
88
|
+
array_agg(DISTINCT tenant_id ORDER BY tenant_id) AS tenant_ids
|
|
89
|
+
FROM journal_entries
|
|
90
|
+
WHERE metadata->>'stripeFingerprint' IS NOT NULL
|
|
91
|
+
AND entry_type = 'purchase'
|
|
92
|
+
GROUP BY metadata->>'stripeFingerprint'
|
|
91
93
|
HAVING COUNT(DISTINCT tenant_id) > 1
|
|
92
94
|
ORDER BY COUNT(DISTINCT tenant_id) DESC
|
|
93
95
|
`));
|
|
@@ -98,9 +100,9 @@ export class DrizzleAffiliateFraudAdminRepository {
|
|
|
98
100
|
}
|
|
99
101
|
async blockFingerprint(fingerprint, adminUserId) {
|
|
100
102
|
const rows = await this.db
|
|
101
|
-
.selectDistinct({ tenantId:
|
|
102
|
-
.from(
|
|
103
|
-
.where(eq(
|
|
103
|
+
.selectDistinct({ tenantId: journalEntries.tenantId })
|
|
104
|
+
.from(journalEntries)
|
|
105
|
+
.where(and(eq(journalEntries.entryType, "purchase"), sql `${journalEntries.metadata}->>'stripeFingerprint' = ${fingerprint}`));
|
|
104
106
|
const tenantIds = rows.map((r) => r.tenantId);
|
|
105
107
|
const now = new Date().toISOString();
|
|
106
108
|
for (const tenantId of tenantIds) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { affiliateReferrals } from "../../db/schema/affiliate.js";
|
|
4
4
|
import { affiliateFraudEvents } from "../../db/schema/affiliate-fraud.js";
|
|
5
|
-
import {
|
|
5
|
+
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
6
6
|
import { ADMIN_BLOCK_SENTINEL, DrizzleAffiliateFraudAdminRepository } from "./affiliate-admin-repository.js";
|
|
7
7
|
describe("DrizzleAffiliateFraudAdminRepository", () => {
|
|
8
8
|
let pool;
|
|
@@ -10,14 +10,13 @@ describe("DrizzleAffiliateFraudAdminRepository", () => {
|
|
|
10
10
|
let repo;
|
|
11
11
|
beforeAll(async () => {
|
|
12
12
|
({ db, pool } = await createTestDb());
|
|
13
|
-
await beginTestTransaction(pool);
|
|
14
13
|
});
|
|
15
14
|
afterAll(async () => {
|
|
16
|
-
await endTestTransaction(pool);
|
|
17
15
|
await pool.close();
|
|
18
16
|
});
|
|
19
17
|
beforeEach(async () => {
|
|
20
|
-
await
|
|
18
|
+
await truncateAllTables(pool);
|
|
19
|
+
await new DrizzleLedger(db).seedSystemAccounts();
|
|
21
20
|
repo = new DrizzleAffiliateFraudAdminRepository(db);
|
|
22
21
|
});
|
|
23
22
|
describe("listSuppressions", () => {
|
|
@@ -140,9 +139,17 @@ describe("DrizzleAffiliateFraudAdminRepository", () => {
|
|
|
140
139
|
});
|
|
141
140
|
describe("blockFingerprint", () => {
|
|
142
141
|
it("should insert fraud events with ADMIN_BLOCK as referredTenantId", async () => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
const ledger = new DrizzleLedger(db);
|
|
143
|
+
await ledger.credit("t-alice", Credit.fromCents(1), "purchase", {
|
|
144
|
+
description: "test purchase",
|
|
145
|
+
referenceId: "ref-alice-fp_abc123",
|
|
146
|
+
stripeFingerprint: "fp_abc123",
|
|
147
|
+
});
|
|
148
|
+
await ledger.credit("t-bob", Credit.fromCents(1), "purchase", {
|
|
149
|
+
description: "test purchase",
|
|
150
|
+
referenceId: "ref-bob-fp_abc123",
|
|
151
|
+
stripeFingerprint: "fp_abc123",
|
|
152
|
+
});
|
|
146
153
|
await repo.blockFingerprint("fp_abc123", "admin-user-1");
|
|
147
154
|
const result = await repo.listSuppressions(50, 0);
|
|
148
155
|
expect(result.events).toHaveLength(2);
|
|
@@ -156,9 +163,17 @@ describe("DrizzleAffiliateFraudAdminRepository", () => {
|
|
|
156
163
|
expect(tenantIds).toEqual(["t-alice", "t-bob"]);
|
|
157
164
|
});
|
|
158
165
|
it("should use unique referralId per tenant to avoid unique constraint conflicts", async () => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
166
|
+
const ledger = new DrizzleLedger(db);
|
|
167
|
+
await ledger.credit("t-carol", Credit.fromCents(1), "purchase", {
|
|
168
|
+
description: "test purchase",
|
|
169
|
+
referenceId: "ref-carol-fp_def456",
|
|
170
|
+
stripeFingerprint: "fp_def456",
|
|
171
|
+
});
|
|
172
|
+
await ledger.credit("t-dave", Credit.fromCents(1), "purchase", {
|
|
173
|
+
description: "test purchase",
|
|
174
|
+
referenceId: "ref-dave-fp_def456",
|
|
175
|
+
stripeFingerprint: "fp_def456",
|
|
176
|
+
});
|
|
162
177
|
await repo.blockFingerprint("fp_def456", "admin-user-2");
|
|
163
178
|
const result = await repo.listSuppressions(50, 0);
|
|
164
179
|
expect(result.events).toHaveLength(2);
|
|
@@ -169,8 +184,12 @@ describe("DrizzleAffiliateFraudAdminRepository", () => {
|
|
|
169
184
|
expect(new Set(referralIds).size).toBe(2);
|
|
170
185
|
});
|
|
171
186
|
it("should be idempotent via onConflictDoNothing", async () => {
|
|
172
|
-
|
|
173
|
-
|
|
187
|
+
const ledger = new DrizzleLedger(db);
|
|
188
|
+
await ledger.credit("t-eve", Credit.fromCents(1), "purchase", {
|
|
189
|
+
description: "test purchase",
|
|
190
|
+
referenceId: "ref-eve-fp_ghi789",
|
|
191
|
+
stripeFingerprint: "fp_ghi789",
|
|
192
|
+
});
|
|
174
193
|
await repo.blockFingerprint("fp_ghi789", "admin-user-3");
|
|
175
194
|
await repo.blockFingerprint("fp_ghi789", "admin-user-3");
|
|
176
195
|
const result = await repo.listSuppressions(50, 0);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { Credit,
|
|
1
|
+
import type { Credit, ILedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import type { IAffiliateFraudRepository } from "./affiliate-fraud-repository.js";
|
|
3
3
|
import type { IAffiliateRepository } from "./drizzle-affiliate-repository.js";
|
|
4
4
|
export interface AffiliateCreditMatchDeps {
|
|
5
5
|
tenantId: string;
|
|
6
6
|
purchaseAmount: Credit;
|
|
7
|
-
ledger:
|
|
7
|
+
ledger: ILedger;
|
|
8
8
|
affiliateRepo: IAffiliateRepository;
|
|
9
9
|
matchRate?: number;
|
|
10
10
|
fraudRepo?: IAffiliateFraudRepository;
|
|
@@ -85,7 +85,10 @@ export async function processAffiliateCreditMatch(deps) {
|
|
|
85
85
|
if (matchAmount.isZero() || matchAmount.isNegative())
|
|
86
86
|
return null;
|
|
87
87
|
// 6. Credit the referrer
|
|
88
|
-
await ledger.credit(referral.referrerTenantId, matchAmount, "affiliate_match",
|
|
88
|
+
await ledger.credit(referral.referrerTenantId, matchAmount, "affiliate_match", {
|
|
89
|
+
description: `Affiliate match for referred tenant ${tenantId}`,
|
|
90
|
+
referenceId: refId,
|
|
91
|
+
});
|
|
89
92
|
// 7. Update referral record
|
|
90
93
|
await affiliateRepo.markFirstPurchase(tenantId);
|
|
91
94
|
await affiliateRepo.recordMatch(tenantId, matchAmount);
|
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
import { DrizzleAffiliateFraudRepository } from "./affiliate-fraud-repository.js";
|
|
@@ -18,12 +18,17 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
18
18
|
});
|
|
19
19
|
beforeEach(async () => {
|
|
20
20
|
await truncateAllTables(pool);
|
|
21
|
-
ledger = new
|
|
21
|
+
ledger = new DrizzleLedger(db);
|
|
22
|
+
await ledger.seedSystemAccounts();
|
|
22
23
|
affiliateRepo = new DrizzleAffiliateRepository(db);
|
|
23
24
|
fraudRepo = new DrizzleAffiliateFraudRepository(db);
|
|
24
25
|
});
|
|
25
26
|
it("does nothing when tenant has no referral", async () => {
|
|
26
|
-
await ledger.credit("buyer", Credit.fromCents(1000), "purchase",
|
|
27
|
+
await ledger.credit("buyer", Credit.fromCents(1000), "purchase", {
|
|
28
|
+
description: "first buy",
|
|
29
|
+
referenceId: "session-1",
|
|
30
|
+
fundingSource: "stripe",
|
|
31
|
+
});
|
|
27
32
|
const result = await processAffiliateCreditMatch({
|
|
28
33
|
tenantId: "buyer",
|
|
29
34
|
purchaseAmount: Credit.fromCents(1000),
|
|
@@ -34,8 +39,16 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
34
39
|
});
|
|
35
40
|
it("does nothing when tenant already has prior purchases", async () => {
|
|
36
41
|
await affiliateRepo.recordReferral("referrer", "buyer", "abc123");
|
|
37
|
-
await ledger.credit("buyer", Credit.fromCents(500), "purchase",
|
|
38
|
-
|
|
42
|
+
await ledger.credit("buyer", Credit.fromCents(500), "purchase", {
|
|
43
|
+
description: "old buy",
|
|
44
|
+
referenceId: "session-0",
|
|
45
|
+
fundingSource: "stripe",
|
|
46
|
+
});
|
|
47
|
+
await ledger.credit("buyer", Credit.fromCents(1000), "purchase", {
|
|
48
|
+
description: "new buy",
|
|
49
|
+
referenceId: "session-1",
|
|
50
|
+
fundingSource: "stripe",
|
|
51
|
+
});
|
|
39
52
|
const result = await processAffiliateCreditMatch({
|
|
40
53
|
tenantId: "buyer",
|
|
41
54
|
purchaseAmount: Credit.fromCents(1000),
|
|
@@ -46,7 +59,11 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
46
59
|
});
|
|
47
60
|
it("credits referrer on first purchase with 100% match", async () => {
|
|
48
61
|
await affiliateRepo.recordReferral("referrer", "buyer", "abc123");
|
|
49
|
-
await ledger.credit("buyer", Credit.fromCents(2000), "purchase",
|
|
62
|
+
await ledger.credit("buyer", Credit.fromCents(2000), "purchase", {
|
|
63
|
+
description: "first buy",
|
|
64
|
+
referenceId: "session-1",
|
|
65
|
+
fundingSource: "stripe",
|
|
66
|
+
});
|
|
50
67
|
const result = await processAffiliateCreditMatch({
|
|
51
68
|
tenantId: "buyer",
|
|
52
69
|
purchaseAmount: Credit.fromCents(2000),
|
|
@@ -65,7 +82,11 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
65
82
|
});
|
|
66
83
|
it("respects custom match rate", async () => {
|
|
67
84
|
await affiliateRepo.recordReferral("referrer", "buyer", "abc123");
|
|
68
|
-
await ledger.credit("buyer", Credit.fromCents(2000), "purchase",
|
|
85
|
+
await ledger.credit("buyer", Credit.fromCents(2000), "purchase", {
|
|
86
|
+
description: "first buy",
|
|
87
|
+
referenceId: "session-1",
|
|
88
|
+
fundingSource: "stripe",
|
|
89
|
+
});
|
|
69
90
|
const result = await processAffiliateCreditMatch({
|
|
70
91
|
tenantId: "buyer",
|
|
71
92
|
purchaseAmount: Credit.fromCents(2000),
|
|
@@ -78,7 +99,11 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
78
99
|
});
|
|
79
100
|
it("is idempotent — second call returns null", async () => {
|
|
80
101
|
await affiliateRepo.recordReferral("referrer", "buyer", "abc123");
|
|
81
|
-
await ledger.credit("buyer", Credit.fromCents(1000), "purchase",
|
|
102
|
+
await ledger.credit("buyer", Credit.fromCents(1000), "purchase", {
|
|
103
|
+
description: "first buy",
|
|
104
|
+
referenceId: "session-1",
|
|
105
|
+
fundingSource: "stripe",
|
|
106
|
+
});
|
|
82
107
|
const first = await processAffiliateCreditMatch({
|
|
83
108
|
tenantId: "buyer",
|
|
84
109
|
purchaseAmount: Credit.fromCents(1000),
|
|
@@ -99,7 +124,11 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
99
124
|
signupIp: "1.2.3.4",
|
|
100
125
|
signupEmail: "alice+ref@gmail.com",
|
|
101
126
|
});
|
|
102
|
-
await ledger.credit("buyer", Credit.fromCents(2000), "purchase",
|
|
127
|
+
await ledger.credit("buyer", Credit.fromCents(2000), "purchase", {
|
|
128
|
+
description: "first buy",
|
|
129
|
+
referenceId: "session-1",
|
|
130
|
+
fundingSource: "stripe",
|
|
131
|
+
});
|
|
103
132
|
const result = await processAffiliateCreditMatch({
|
|
104
133
|
tenantId: "buyer",
|
|
105
134
|
purchaseAmount: Credit.fromCents(2000),
|
|
@@ -127,7 +156,11 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
127
156
|
}
|
|
128
157
|
// New referral
|
|
129
158
|
await affiliateRepo.recordReferral("referrer", "buyer", "abc123");
|
|
130
|
-
await ledger.credit("buyer", Credit.fromCents(1000), "purchase",
|
|
159
|
+
await ledger.credit("buyer", Credit.fromCents(1000), "purchase", {
|
|
160
|
+
description: "first buy",
|
|
161
|
+
referenceId: "session-1",
|
|
162
|
+
fundingSource: "stripe",
|
|
163
|
+
});
|
|
131
164
|
const result = await processAffiliateCreditMatch({
|
|
132
165
|
tenantId: "buyer",
|
|
133
166
|
purchaseAmount: Credit.fromCents(1000),
|
|
@@ -151,7 +184,11 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
151
184
|
}
|
|
152
185
|
// New referral
|
|
153
186
|
await affiliateRepo.recordReferral("referrer", "buyer", "abc123");
|
|
154
|
-
await ledger.credit("buyer", Credit.fromCents(1000), "purchase",
|
|
187
|
+
await ledger.credit("buyer", Credit.fromCents(1000), "purchase", {
|
|
188
|
+
description: "first buy",
|
|
189
|
+
referenceId: "session-1",
|
|
190
|
+
fundingSource: "stripe",
|
|
191
|
+
});
|
|
155
192
|
const result = await processAffiliateCreditMatch({
|
|
156
193
|
tenantId: "buyer",
|
|
157
194
|
purchaseAmount: Credit.fromCents(1000),
|
|
@@ -167,7 +204,11 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
167
204
|
});
|
|
168
205
|
it("allows payout when under both caps", async () => {
|
|
169
206
|
await affiliateRepo.recordReferral("referrer", "buyer", "abc123");
|
|
170
|
-
await ledger.credit("buyer", Credit.fromCents(2000), "purchase",
|
|
207
|
+
await ledger.credit("buyer", Credit.fromCents(2000), "purchase", {
|
|
208
|
+
description: "first buy",
|
|
209
|
+
referenceId: "session-1",
|
|
210
|
+
fundingSource: "stripe",
|
|
211
|
+
});
|
|
171
212
|
const result = await processAffiliateCreditMatch({
|
|
172
213
|
tenantId: "buyer",
|
|
173
214
|
purchaseAmount: Credit.fromCents(2000),
|
|
@@ -184,7 +225,11 @@ describe("processAffiliateCreditMatch", () => {
|
|
|
184
225
|
await affiliateRepo.recordReferral("referrer", "buyer", "abc123", {
|
|
185
226
|
signupIp: "1.2.3.4",
|
|
186
227
|
});
|
|
187
|
-
await ledger.credit("buyer", Credit.fromCents(2000), "purchase",
|
|
228
|
+
await ledger.credit("buyer", Credit.fromCents(2000), "purchase", {
|
|
229
|
+
description: "first buy",
|
|
230
|
+
referenceId: "session-1",
|
|
231
|
+
fundingSource: "stripe",
|
|
232
|
+
});
|
|
188
233
|
const result = await processAffiliateCreditMatch({
|
|
189
234
|
tenantId: "buyer",
|
|
190
235
|
purchaseAmount: Credit.fromCents(2000),
|
|
@@ -1,10 +1,10 @@
|
|
|
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 { IAffiliateRepository } from "./drizzle-affiliate-repository.js";
|
|
4
4
|
/** Default bonus rate: 20% of purchase amount. Override with AFFILIATE_NEW_USER_BONUS_RATE env var. */
|
|
5
5
|
export declare const DEFAULT_BONUS_RATE: number;
|
|
6
6
|
export interface NewUserBonusParams {
|
|
7
|
-
ledger:
|
|
7
|
+
ledger: ILedger;
|
|
8
8
|
affiliateRepo: IAffiliateRepository;
|
|
9
9
|
referredTenantId: string;
|
|
10
10
|
purchaseAmount: Credit;
|
|
@@ -34,6 +34,9 @@ export async function grantNewUserBonus(params) {
|
|
|
34
34
|
// 5. Mark first purchase on the referral row (no-op if already set)
|
|
35
35
|
await affiliateRepo.markFirstPurchase(referredTenantId);
|
|
36
36
|
// 6. Credit the bonus
|
|
37
|
-
await ledger.credit(referredTenantId, bonus, "affiliate_bonus",
|
|
37
|
+
await ledger.credit(referredTenantId, bonus, "affiliate_bonus", {
|
|
38
|
+
description: `New user first-purchase bonus (${Math.round(rate * 100)}%)`,
|
|
39
|
+
referenceId: refId,
|
|
40
|
+
});
|
|
38
41
|
return { granted: true, bonus };
|
|
39
42
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
import { DrizzleAffiliateRepository } from "./drizzle-affiliate-repository.js";
|
|
@@ -16,7 +16,8 @@ describe("grantNewUserBonus", () => {
|
|
|
16
16
|
});
|
|
17
17
|
beforeEach(async () => {
|
|
18
18
|
await truncateAllTables(pool);
|
|
19
|
-
ledger = new
|
|
19
|
+
ledger = new DrizzleLedger(db);
|
|
20
|
+
await ledger.seedSystemAccounts();
|
|
20
21
|
affiliateRepo = new DrizzleAffiliateRepository(db);
|
|
21
22
|
});
|
|
22
23
|
it("DEFAULT_BONUS_RATE equals 0.20", () => {
|
|
@@ -37,7 +38,7 @@ describe("grantNewUserBonus", () => {
|
|
|
37
38
|
expect((await ledger.balance("referred-1")).toCents()).toBe(1000);
|
|
38
39
|
const txns = await ledger.history("referred-1");
|
|
39
40
|
expect(txns).toHaveLength(1);
|
|
40
|
-
expect(txns[0].
|
|
41
|
+
expect(txns[0].entryType).toBe("affiliate_bonus");
|
|
41
42
|
expect(txns[0].referenceId).toBe("affiliate-bonus:referred-1");
|
|
42
43
|
expect(txns[0].description).toContain("first-purchase bonus");
|
|
43
44
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ITenantCustomerRepository } from "@wopr-network/platform-core/billing";
|
|
2
|
-
import type { Credit,
|
|
2
|
+
import type { Credit, ILedger } from "@wopr-network/platform-core/credits";
|
|
3
3
|
import Stripe from "stripe";
|
|
4
4
|
import type { IAutoTopupEventLogRepository } from "./auto-topup-event-log-repository.js";
|
|
5
5
|
/** After this many consecutive Stripe failures, the auto-topup mode is disabled. */
|
|
@@ -7,7 +7,7 @@ export declare const MAX_CONSECUTIVE_FAILURES = 3;
|
|
|
7
7
|
export interface AutoTopupChargeDeps {
|
|
8
8
|
stripe: Stripe;
|
|
9
9
|
tenantRepo: ITenantCustomerRepository;
|
|
10
|
-
creditLedger:
|
|
10
|
+
creditLedger: ILedger;
|
|
11
11
|
eventLogRepo: IAutoTopupEventLogRepository;
|
|
12
12
|
}
|
|
13
13
|
export interface AutoTopupChargeResult {
|
|
@@ -113,7 +113,11 @@ export async function chargeAutoTopup(deps, tenantId, amount, source) {
|
|
|
113
113
|
// 5. Credit the ledger (idempotent via referenceId = PI ID)
|
|
114
114
|
try {
|
|
115
115
|
if (!(await deps.creditLedger.hasReferenceId(paymentIntent.id))) {
|
|
116
|
-
await deps.creditLedger.credit(tenantId, amount, "purchase",
|
|
116
|
+
await deps.creditLedger.credit(tenantId, amount, "purchase", {
|
|
117
|
+
description: `Auto-topup (${source})`,
|
|
118
|
+
referenceId: paymentIntent.id,
|
|
119
|
+
fundingSource: "stripe",
|
|
120
|
+
});
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
catch (err) {
|
|
@@ -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 Stripe from "stripe";
|
|
4
4
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { creditAutoTopup } from "../../db/schema/credit-auto-topup.js";
|
|
@@ -46,7 +46,8 @@ describe("chargeAutoTopup", () => {
|
|
|
46
46
|
});
|
|
47
47
|
beforeEach(async () => {
|
|
48
48
|
await truncateAllTables(pool);
|
|
49
|
-
ledger = new
|
|
49
|
+
ledger = new DrizzleLedger(db);
|
|
50
|
+
await ledger.seedSystemAccounts();
|
|
50
51
|
});
|
|
51
52
|
it("charges Stripe and credits ledger on success", async () => {
|
|
52
53
|
const stripe = mockStripe();
|
|
@@ -62,8 +63,8 @@ describe("chargeAutoTopup", () => {
|
|
|
62
63
|
expect(result.paymentReference).toEqual(expect.any(String));
|
|
63
64
|
expect((await ledger.balance("t1")).toCents()).toBe(500);
|
|
64
65
|
const history = await ledger.history("t1");
|
|
65
|
-
expect(history[0].
|
|
66
|
-
expect(history[0].fundingSource).toBe("stripe");
|
|
66
|
+
expect(history[0].entryType).toBe("purchase");
|
|
67
|
+
expect(history[0].metadata?.fundingSource).toBe("stripe");
|
|
67
68
|
});
|
|
68
69
|
it("writes success event to credit_auto_topup log", async () => {
|
|
69
70
|
const stripe = mockStripe();
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Credit, IAutoTopupSettingsRepository,
|
|
1
|
+
import type { Credit, IAutoTopupSettingsRepository, ILedger } from "@wopr-network/platform-core/credits";
|
|
2
2
|
import type { AutoTopupChargeResult } from "./auto-topup-charge.js";
|
|
3
3
|
export interface UsageTopupDeps {
|
|
4
4
|
settingsRepo: IAutoTopupSettingsRepository;
|
|
5
|
-
creditLedger:
|
|
5
|
+
creditLedger: ILedger;
|
|
6
6
|
/** Injected charge function (allows mocking in tests). */
|
|
7
7
|
chargeAutoTopup: (tenantId: string, amount: Credit, source: string) => Promise<AutoTopupChargeResult>;
|
|
8
8
|
/** Optional tenant status check. If provided and returns non-null, skip the charge. */
|