@wopr-network/platform-core 1.39.0 → 1.39.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/crypto/btc/address-gen.js +1 -1
- package/dist/credits/credit-expiry-cron.test.js +25 -0
- package/dist/credits/ledger.d.ts +6 -0
- package/dist/credits/ledger.js +22 -2
- package/dist/credits/ledger.test.js +76 -0
- package/package.json +1 -1
- package/src/billing/crypto/btc/address-gen.ts +1 -1
- package/src/credits/credit-expiry-cron.test.ts +29 -0
- package/src/credits/ledger.test.ts +90 -0
- package/src/credits/ledger.ts +28 -2
|
@@ -81,4 +81,29 @@ describe("runCreditExpiryCron", () => {
|
|
|
81
81
|
const balanceAfterSecond = await ledger.balance("tenant-1");
|
|
82
82
|
expect(balanceAfterSecond.toCents()).toBe(balanceAfterFirst.toCents());
|
|
83
83
|
});
|
|
84
|
+
it("does not return unknown entry type even with expiresAt metadata", async () => {
|
|
85
|
+
// Simulate a hypothetical new entry type that has expiresAt in metadata.
|
|
86
|
+
// With the old denylist approach, this would be incorrectly returned.
|
|
87
|
+
// With the allowlist, it must be excluded.
|
|
88
|
+
const entry = await ledger.post({
|
|
89
|
+
entryType: "marketplace_fee",
|
|
90
|
+
tenantId: "tenant-1",
|
|
91
|
+
description: "Hypothetical new debit type with expiresAt",
|
|
92
|
+
metadata: { expiresAt: "2026-01-10T00:00:00Z" },
|
|
93
|
+
lines: [
|
|
94
|
+
{ accountCode: "2000:tenant-1", amount: Credit.fromCents(100), side: "debit" },
|
|
95
|
+
{ accountCode: "4000", amount: Credit.fromCents(100), side: "credit" },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
// Give tenant a balance first so it's not filtered by zero-balance
|
|
99
|
+
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", {
|
|
100
|
+
description: "Top-up",
|
|
101
|
+
});
|
|
102
|
+
const expired = await ledger.expiredCredits("2026-01-15T00:00:00Z");
|
|
103
|
+
const ids = expired.map((e) => e.entryId);
|
|
104
|
+
expect(ids).not.toContain(entry.id);
|
|
105
|
+
// Full cron should also not touch it
|
|
106
|
+
const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
|
|
107
|
+
expect(result.processed).toBe(0);
|
|
108
|
+
});
|
|
84
109
|
});
|
package/dist/credits/ledger.d.ts
CHANGED
|
@@ -99,6 +99,12 @@ export interface DebitOpts {
|
|
|
99
99
|
export declare const CREDIT_TYPE_ACCOUNT: Record<CreditType, string>;
|
|
100
100
|
/** Maps debit (money-out) types to the credit-side account code. */
|
|
101
101
|
export declare const DEBIT_TYPE_ACCOUNT: Record<DebitType, string>;
|
|
102
|
+
/**
|
|
103
|
+
* Entry types eligible for credit expiry (allowlist).
|
|
104
|
+
* Only journal entries with these types can be returned by expiredCredits().
|
|
105
|
+
* Derived from CreditType — if you add a new CreditType, add it here too.
|
|
106
|
+
*/
|
|
107
|
+
export declare const EXPIRABLE_CREDIT_TYPES: readonly ["signup_grant", "admin_grant", "purchase", "bounty", "referral", "promo", "community_dividend", "affiliate_bonus", "affiliate_match", "correction"];
|
|
102
108
|
export interface SystemAccount {
|
|
103
109
|
code: string;
|
|
104
110
|
name: string;
|
package/dist/credits/ledger.js
CHANGED
|
@@ -54,6 +54,23 @@ export const DEBIT_TYPE_ACCOUNT = {
|
|
|
54
54
|
refund: "1000", // CR cash (money out)
|
|
55
55
|
correction: "5070", // CR expense:correction
|
|
56
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Entry types eligible for credit expiry (allowlist).
|
|
59
|
+
* Only journal entries with these types can be returned by expiredCredits().
|
|
60
|
+
* Derived from CreditType — if you add a new CreditType, add it here too.
|
|
61
|
+
*/
|
|
62
|
+
export const EXPIRABLE_CREDIT_TYPES = [
|
|
63
|
+
"signup_grant",
|
|
64
|
+
"admin_grant",
|
|
65
|
+
"purchase",
|
|
66
|
+
"bounty",
|
|
67
|
+
"referral",
|
|
68
|
+
"promo",
|
|
69
|
+
"community_dividend",
|
|
70
|
+
"affiliate_bonus",
|
|
71
|
+
"affiliate_match",
|
|
72
|
+
"correction",
|
|
73
|
+
];
|
|
57
74
|
export const SYSTEM_ACCOUNTS = [
|
|
58
75
|
// Assets
|
|
59
76
|
{ code: "1000", name: "Cash", type: "asset", normalSide: "debit" },
|
|
@@ -182,8 +199,11 @@ export class DrizzleLedger {
|
|
|
182
199
|
});
|
|
183
200
|
// Phase 1: resolve all account IDs with row locks so concurrent transactions
|
|
184
201
|
// are serialized before any balance check or update.
|
|
202
|
+
// Sort by accountCode to establish a consistent global lock ordering and
|
|
203
|
+
// prevent deadlocks when concurrent transactions lock overlapping accounts.
|
|
204
|
+
const sortedLines = [...input.lines].sort((a, b) => a.accountCode < b.accountCode ? -1 : a.accountCode > b.accountCode ? 1 : 0);
|
|
185
205
|
const resolvedLines = [];
|
|
186
|
-
for (const line of
|
|
206
|
+
for (const line of sortedLines) {
|
|
187
207
|
let accountId;
|
|
188
208
|
if (line.accountCode.startsWith("2000:")) {
|
|
189
209
|
accountId = await this.ensureTenantAccountLocked(tx, line.accountCode.slice(5));
|
|
@@ -476,7 +496,7 @@ export class DrizzleLedger {
|
|
|
476
496
|
)`,
|
|
477
497
|
})
|
|
478
498
|
.from(journalEntries)
|
|
479
|
-
.where(and(isNotNull(sql `${journalEntries.metadata}->>'expiresAt'`), sql `(${journalEntries.metadata}->>'expiresAt') <= ${now}`, sql `${journalEntries.entryType}
|
|
499
|
+
.where(and(isNotNull(sql `${journalEntries.metadata}->>'expiresAt'`), sql `(${journalEntries.metadata}->>'expiresAt') <= ${now}`, sql `${journalEntries.entryType} IN (${sql.join(EXPIRABLE_CREDIT_TYPES.map((t) => sql `${t}`), sql `, `)})`));
|
|
480
500
|
const result = [];
|
|
481
501
|
for (const row of rows) {
|
|
482
502
|
if (!row.amount)
|
|
@@ -111,6 +111,27 @@ describe("DrizzleLedger", () => {
|
|
|
111
111
|
});
|
|
112
112
|
expect(entry.lines).toHaveLength(3);
|
|
113
113
|
});
|
|
114
|
+
it("acquires locks in consistent order regardless of input line order", async () => {
|
|
115
|
+
// Post a 3-line entry with accounts in descending order.
|
|
116
|
+
// If lock ordering works, this should succeed without deadlock
|
|
117
|
+
// even when concurrent transactions use a different line order.
|
|
118
|
+
const entry = await ledger.post({
|
|
119
|
+
entryType: "correction",
|
|
120
|
+
tenantId: "t1",
|
|
121
|
+
lines: [
|
|
122
|
+
{ accountCode: "5070", amount: Credit.fromCents(500), side: "debit" },
|
|
123
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(200), side: "credit" },
|
|
124
|
+
{ accountCode: "1000", amount: Credit.fromCents(300), side: "credit" },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
// Entry should succeed and contain all 3 lines
|
|
128
|
+
expect(entry.lines).toHaveLength(3);
|
|
129
|
+
// Verify balances are correct (lock order doesn't affect correctness)
|
|
130
|
+
const cashBal = await ledger.accountBalance("1000");
|
|
131
|
+
expect(cashBal.toCentsRounded()).toBe(-300); // credit side on debit-normal = negative
|
|
132
|
+
const tb = await ledger.trialBalance();
|
|
133
|
+
expect(tb.balanced).toBe(true);
|
|
134
|
+
});
|
|
114
135
|
});
|
|
115
136
|
// -----------------------------------------------------------------------
|
|
116
137
|
// credit() — convenience
|
|
@@ -342,6 +363,61 @@ describe("DrizzleLedger", () => {
|
|
|
342
363
|
});
|
|
343
364
|
});
|
|
344
365
|
// -----------------------------------------------------------------------
|
|
366
|
+
// expiredCredits()
|
|
367
|
+
// -----------------------------------------------------------------------
|
|
368
|
+
describe("expiredCredits()", () => {
|
|
369
|
+
it("returns entries with expirable types whose expiresAt has passed", async () => {
|
|
370
|
+
// Post a signup_grant (in EXPIRABLE_CREDIT_TYPES) with an expiresAt in the past
|
|
371
|
+
await ledger.credit("t1", Credit.fromCents(100), "signup_grant", {
|
|
372
|
+
expiresAt: "2020-01-01T00:00:00Z",
|
|
373
|
+
});
|
|
374
|
+
const expired = await ledger.expiredCredits(new Date().toISOString());
|
|
375
|
+
expect(expired).toHaveLength(1);
|
|
376
|
+
expect(expired[0].tenantId).toBe("t1");
|
|
377
|
+
expect(expired[0].amount.toCentsRounded()).toBe(100);
|
|
378
|
+
});
|
|
379
|
+
it("excludes entries whose type is not in EXPIRABLE_CREDIT_TYPES", async () => {
|
|
380
|
+
// Post a credit-side entry on the liability account with an unknown entry type
|
|
381
|
+
// so the liability credit line is present (the subquery finds an amount) but
|
|
382
|
+
// the allowlist filter must exclude it.
|
|
383
|
+
await ledger.post({
|
|
384
|
+
entryType: "marketplace_fee", // NOT in EXPIRABLE_CREDIT_TYPES
|
|
385
|
+
tenantId: "t1",
|
|
386
|
+
metadata: { expiresAt: "2020-01-01T00:00:00Z" },
|
|
387
|
+
lines: [
|
|
388
|
+
{ accountCode: "1000", amount: Credit.fromCents(100), side: "debit" },
|
|
389
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(100), side: "credit" },
|
|
390
|
+
],
|
|
391
|
+
});
|
|
392
|
+
const expired = await ledger.expiredCredits(new Date().toISOString());
|
|
393
|
+
expect(expired).toHaveLength(0);
|
|
394
|
+
});
|
|
395
|
+
it("excludes entries whose expiresAt is in the future", async () => {
|
|
396
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase", {
|
|
397
|
+
expiresAt: "2099-01-01T00:00:00Z",
|
|
398
|
+
});
|
|
399
|
+
const expired = await ledger.expiredCredits(new Date().toISOString());
|
|
400
|
+
expect(expired).toHaveLength(0);
|
|
401
|
+
});
|
|
402
|
+
it("excludes entries already clawed back (idempotency)", async () => {
|
|
403
|
+
const entry = await ledger.credit("t1", Credit.fromCents(100), "purchase", {
|
|
404
|
+
expiresAt: "2020-01-01T00:00:00Z",
|
|
405
|
+
});
|
|
406
|
+
// Simulate a prior expiry entry by posting with the expiry referenceId
|
|
407
|
+
await ledger.post({
|
|
408
|
+
entryType: "credit_expiry",
|
|
409
|
+
tenantId: "t1",
|
|
410
|
+
referenceId: `expiry:${entry.id}`,
|
|
411
|
+
lines: [
|
|
412
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(100), side: "debit" },
|
|
413
|
+
{ accountCode: "4060", amount: Credit.fromCents(100), side: "credit" },
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
const expired = await ledger.expiredCredits(new Date().toISOString());
|
|
417
|
+
expect(expired).toHaveLength(0);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
// -----------------------------------------------------------------------
|
|
345
421
|
// memberUsage()
|
|
346
422
|
// -----------------------------------------------------------------------
|
|
347
423
|
describe("memberUsage()", () => {
|
package/package.json
CHANGED
|
@@ -103,4 +103,33 @@ describe("runCreditExpiryCron", () => {
|
|
|
103
103
|
const balanceAfterSecond = await ledger.balance("tenant-1");
|
|
104
104
|
expect(balanceAfterSecond.toCents()).toBe(balanceAfterFirst.toCents());
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
it("does not return unknown entry type even with expiresAt metadata", async () => {
|
|
108
|
+
// Simulate a hypothetical new entry type that has expiresAt in metadata.
|
|
109
|
+
// With the old denylist approach, this would be incorrectly returned.
|
|
110
|
+
// With the allowlist, it must be excluded.
|
|
111
|
+
const entry = await ledger.post({
|
|
112
|
+
entryType: "marketplace_fee",
|
|
113
|
+
tenantId: "tenant-1",
|
|
114
|
+
description: "Hypothetical new debit type with expiresAt",
|
|
115
|
+
metadata: { expiresAt: "2026-01-10T00:00:00Z" },
|
|
116
|
+
lines: [
|
|
117
|
+
{ accountCode: "2000:tenant-1", amount: Credit.fromCents(100), side: "debit" },
|
|
118
|
+
{ accountCode: "4000", amount: Credit.fromCents(100), side: "credit" },
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Give tenant a balance first so it's not filtered by zero-balance
|
|
123
|
+
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", {
|
|
124
|
+
description: "Top-up",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const expired = await ledger.expiredCredits("2026-01-15T00:00:00Z");
|
|
128
|
+
const ids = expired.map((e) => e.entryId);
|
|
129
|
+
expect(ids).not.toContain(entry.id);
|
|
130
|
+
|
|
131
|
+
// Full cron should also not touch it
|
|
132
|
+
const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
|
|
133
|
+
expect(result.processed).toBe(0);
|
|
134
|
+
});
|
|
106
135
|
});
|
|
@@ -139,6 +139,31 @@ describe("DrizzleLedger", () => {
|
|
|
139
139
|
|
|
140
140
|
expect(entry.lines).toHaveLength(3);
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
it("acquires locks in consistent order regardless of input line order", async () => {
|
|
144
|
+
// Post a 3-line entry with accounts in descending order.
|
|
145
|
+
// If lock ordering works, this should succeed without deadlock
|
|
146
|
+
// even when concurrent transactions use a different line order.
|
|
147
|
+
const entry = await ledger.post({
|
|
148
|
+
entryType: "correction",
|
|
149
|
+
tenantId: "t1",
|
|
150
|
+
lines: [
|
|
151
|
+
{ accountCode: "5070", amount: Credit.fromCents(500), side: "debit" },
|
|
152
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(200), side: "credit" },
|
|
153
|
+
{ accountCode: "1000", amount: Credit.fromCents(300), side: "credit" },
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Entry should succeed and contain all 3 lines
|
|
158
|
+
expect(entry.lines).toHaveLength(3);
|
|
159
|
+
|
|
160
|
+
// Verify balances are correct (lock order doesn't affect correctness)
|
|
161
|
+
const cashBal = await ledger.accountBalance("1000");
|
|
162
|
+
expect(cashBal.toCentsRounded()).toBe(-300); // credit side on debit-normal = negative
|
|
163
|
+
|
|
164
|
+
const tb = await ledger.trialBalance();
|
|
165
|
+
expect(tb.balanced).toBe(true);
|
|
166
|
+
});
|
|
142
167
|
});
|
|
143
168
|
|
|
144
169
|
// -----------------------------------------------------------------------
|
|
@@ -424,6 +449,71 @@ describe("DrizzleLedger", () => {
|
|
|
424
449
|
});
|
|
425
450
|
});
|
|
426
451
|
|
|
452
|
+
// -----------------------------------------------------------------------
|
|
453
|
+
// expiredCredits()
|
|
454
|
+
// -----------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
describe("expiredCredits()", () => {
|
|
457
|
+
it("returns entries with expirable types whose expiresAt has passed", async () => {
|
|
458
|
+
// Post a signup_grant (in EXPIRABLE_CREDIT_TYPES) with an expiresAt in the past
|
|
459
|
+
await ledger.credit("t1", Credit.fromCents(100), "signup_grant", {
|
|
460
|
+
expiresAt: "2020-01-01T00:00:00Z",
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const expired = await ledger.expiredCredits(new Date().toISOString());
|
|
464
|
+
expect(expired).toHaveLength(1);
|
|
465
|
+
expect(expired[0].tenantId).toBe("t1");
|
|
466
|
+
expect(expired[0].amount.toCentsRounded()).toBe(100);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("excludes entries whose type is not in EXPIRABLE_CREDIT_TYPES", async () => {
|
|
470
|
+
// Post a credit-side entry on the liability account with an unknown entry type
|
|
471
|
+
// so the liability credit line is present (the subquery finds an amount) but
|
|
472
|
+
// the allowlist filter must exclude it.
|
|
473
|
+
await ledger.post({
|
|
474
|
+
entryType: "marketplace_fee", // NOT in EXPIRABLE_CREDIT_TYPES
|
|
475
|
+
tenantId: "t1",
|
|
476
|
+
metadata: { expiresAt: "2020-01-01T00:00:00Z" },
|
|
477
|
+
lines: [
|
|
478
|
+
{ accountCode: "1000", amount: Credit.fromCents(100), side: "debit" },
|
|
479
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(100), side: "credit" },
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const expired = await ledger.expiredCredits(new Date().toISOString());
|
|
484
|
+
expect(expired).toHaveLength(0);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("excludes entries whose expiresAt is in the future", async () => {
|
|
488
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase", {
|
|
489
|
+
expiresAt: "2099-01-01T00:00:00Z",
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const expired = await ledger.expiredCredits(new Date().toISOString());
|
|
493
|
+
expect(expired).toHaveLength(0);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("excludes entries already clawed back (idempotency)", async () => {
|
|
497
|
+
const entry = await ledger.credit("t1", Credit.fromCents(100), "purchase", {
|
|
498
|
+
expiresAt: "2020-01-01T00:00:00Z",
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Simulate a prior expiry entry by posting with the expiry referenceId
|
|
502
|
+
await ledger.post({
|
|
503
|
+
entryType: "credit_expiry",
|
|
504
|
+
tenantId: "t1",
|
|
505
|
+
referenceId: `expiry:${entry.id}`,
|
|
506
|
+
lines: [
|
|
507
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(100), side: "debit" },
|
|
508
|
+
{ accountCode: "4060", amount: Credit.fromCents(100), side: "credit" },
|
|
509
|
+
],
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const expired = await ledger.expiredCredits(new Date().toISOString());
|
|
513
|
+
expect(expired).toHaveLength(0);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
427
517
|
// -----------------------------------------------------------------------
|
|
428
518
|
// memberUsage()
|
|
429
519
|
// -----------------------------------------------------------------------
|
package/src/credits/ledger.ts
CHANGED
|
@@ -172,6 +172,24 @@ export const DEBIT_TYPE_ACCOUNT: Record<DebitType, string> = {
|
|
|
172
172
|
correction: "5070", // CR expense:correction
|
|
173
173
|
};
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Entry types eligible for credit expiry (allowlist).
|
|
177
|
+
* Only journal entries with these types can be returned by expiredCredits().
|
|
178
|
+
* Derived from CreditType — if you add a new CreditType, add it here too.
|
|
179
|
+
*/
|
|
180
|
+
export const EXPIRABLE_CREDIT_TYPES = [
|
|
181
|
+
"signup_grant",
|
|
182
|
+
"admin_grant",
|
|
183
|
+
"purchase",
|
|
184
|
+
"bounty",
|
|
185
|
+
"referral",
|
|
186
|
+
"promo",
|
|
187
|
+
"community_dividend",
|
|
188
|
+
"affiliate_bonus",
|
|
189
|
+
"affiliate_match",
|
|
190
|
+
"correction",
|
|
191
|
+
] as const satisfies readonly CreditType[];
|
|
192
|
+
|
|
175
193
|
// ---------------------------------------------------------------------------
|
|
176
194
|
// System account seeds
|
|
177
195
|
// ---------------------------------------------------------------------------
|
|
@@ -387,8 +405,13 @@ export class DrizzleLedger implements ILedger {
|
|
|
387
405
|
|
|
388
406
|
// Phase 1: resolve all account IDs with row locks so concurrent transactions
|
|
389
407
|
// are serialized before any balance check or update.
|
|
408
|
+
// Sort by accountCode to establish a consistent global lock ordering and
|
|
409
|
+
// prevent deadlocks when concurrent transactions lock overlapping accounts.
|
|
410
|
+
const sortedLines = [...input.lines].sort((a, b) =>
|
|
411
|
+
a.accountCode < b.accountCode ? -1 : a.accountCode > b.accountCode ? 1 : 0,
|
|
412
|
+
);
|
|
390
413
|
const resolvedLines: Array<JournalLine & { accountId: string }> = [];
|
|
391
|
-
for (const line of
|
|
414
|
+
for (const line of sortedLines) {
|
|
392
415
|
let accountId: string;
|
|
393
416
|
if (line.accountCode.startsWith("2000:")) {
|
|
394
417
|
accountId = await this.ensureTenantAccountLocked(tx, line.accountCode.slice(5));
|
|
@@ -745,7 +768,10 @@ export class DrizzleLedger implements ILedger {
|
|
|
745
768
|
and(
|
|
746
769
|
isNotNull(sql`${journalEntries.metadata}->>'expiresAt'`),
|
|
747
770
|
sql`(${journalEntries.metadata}->>'expiresAt') <= ${now}`,
|
|
748
|
-
sql`${journalEntries.entryType}
|
|
771
|
+
sql`${journalEntries.entryType} IN (${sql.join(
|
|
772
|
+
EXPIRABLE_CREDIT_TYPES.map((t) => sql`${t}`),
|
|
773
|
+
sql`, `,
|
|
774
|
+
)})`,
|
|
749
775
|
),
|
|
750
776
|
);
|
|
751
777
|
|