@wopr-network/platform-core 1.39.1 → 1.39.3

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.
@@ -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
  });
@@ -91,15 +91,27 @@ export class Credit {
91
91
  }
92
92
  /** Add another Credit, returning a new Credit. */
93
93
  add(other) {
94
- return new Credit(this.raw + other.raw);
94
+ const result = this.raw + other.raw;
95
+ if (!Number.isSafeInteger(result)) {
96
+ throw new RangeError(`Credit.add overflow: ${this.raw} + ${other.raw} = ${result}`);
97
+ }
98
+ return new Credit(result);
95
99
  }
96
100
  /** Subtract another Credit, returning a new Credit (may be negative). */
97
101
  subtract(other) {
98
- return new Credit(this.raw - other.raw);
102
+ const result = this.raw - other.raw;
103
+ if (!Number.isSafeInteger(result)) {
104
+ throw new RangeError(`Credit.subtract overflow: ${this.raw} - ${other.raw} = ${result}`);
105
+ }
106
+ return new Credit(result);
99
107
  }
100
108
  /** Multiply by a factor, rounding to nearest raw unit. */
101
109
  multiply(factor) {
102
- return new Credit(Math.round(this.raw * factor));
110
+ const result = Math.round(this.raw * factor);
111
+ if (!Number.isSafeInteger(result)) {
112
+ throw new RangeError(`Credit.multiply overflow: ${this.raw} * ${factor} = ${result}`);
113
+ }
114
+ return new Credit(result);
103
115
  }
104
116
  /** True if this credit is negative. */
105
117
  isNegative() {
@@ -103,6 +103,38 @@ describe("Credit", () => {
103
103
  it("multiply by zero gives zero", () => {
104
104
  expect(Credit.fromDollars(5).multiply(0).isZero()).toBe(true);
105
105
  });
106
+ it("add throws RangeError on positive overflow", () => {
107
+ const a = Credit.fromRaw(Number.MAX_SAFE_INTEGER);
108
+ const b = Credit.fromRaw(1);
109
+ expect(() => a.add(b)).toThrow(RangeError);
110
+ });
111
+ it("subtract throws RangeError on negative overflow", () => {
112
+ const a = Credit.fromRaw(-Number.MAX_SAFE_INTEGER);
113
+ const b = Credit.fromRaw(1);
114
+ expect(() => a.subtract(b)).toThrow(RangeError);
115
+ });
116
+ it("multiply throws RangeError on overflow", () => {
117
+ const a = Credit.fromRaw(Number.MAX_SAFE_INTEGER);
118
+ expect(() => a.multiply(2)).toThrow(RangeError);
119
+ });
120
+ it("multiply throws RangeError on Infinity factor", () => {
121
+ const a = Credit.fromRaw(1);
122
+ expect(() => a.multiply(Infinity)).toThrow(RangeError);
123
+ });
124
+ it("multiply throws RangeError on NaN factor", () => {
125
+ const a = Credit.fromRaw(1);
126
+ expect(() => a.multiply(NaN)).toThrow(RangeError);
127
+ });
128
+ it("add does not throw for values within safe range", () => {
129
+ const a = Credit.fromRaw(Number.MAX_SAFE_INTEGER - 1);
130
+ const b = Credit.fromRaw(1);
131
+ expect(a.add(b).toRaw()).toBe(Number.MAX_SAFE_INTEGER);
132
+ });
133
+ it("subtract does not throw for values within safe range", () => {
134
+ const a = Credit.fromRaw(-(Number.MAX_SAFE_INTEGER - 1));
135
+ const b = Credit.fromRaw(1);
136
+ expect(a.subtract(b).toRaw()).toBe(-Number.MAX_SAFE_INTEGER);
137
+ });
106
138
  });
107
139
  describe("comparison", () => {
108
140
  it("isNegative returns true for negative", () => {
@@ -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;
@@ -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" },
@@ -479,7 +496,7 @@ export class DrizzleLedger {
479
496
  )`,
480
497
  })
481
498
  .from(journalEntries)
482
- .where(and(isNotNull(sql `${journalEntries.metadata}->>'expiresAt'`), sql `(${journalEntries.metadata}->>'expiresAt') <= ${now}`, sql `${journalEntries.entryType} NOT IN ('credit_expiry', 'bot_runtime', 'adapter_usage', 'addon', 'refund')`));
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 `, `)})`));
483
500
  const result = [];
484
501
  for (const row of rows) {
485
502
  if (!row.amount)
@@ -363,6 +363,61 @@ describe("DrizzleLedger", () => {
363
363
  });
364
364
  });
365
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
+ // -----------------------------------------------------------------------
366
421
  // memberUsage()
367
422
  // -----------------------------------------------------------------------
368
423
  describe("memberUsage()", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.39.1",
3
+ "version": "1.39.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
  });
@@ -124,6 +124,45 @@ describe("Credit", () => {
124
124
  it("multiply by zero gives zero", () => {
125
125
  expect(Credit.fromDollars(5).multiply(0).isZero()).toBe(true);
126
126
  });
127
+
128
+ it("add throws RangeError on positive overflow", () => {
129
+ const a = Credit.fromRaw(Number.MAX_SAFE_INTEGER);
130
+ const b = Credit.fromRaw(1);
131
+ expect(() => a.add(b)).toThrow(RangeError);
132
+ });
133
+
134
+ it("subtract throws RangeError on negative overflow", () => {
135
+ const a = Credit.fromRaw(-Number.MAX_SAFE_INTEGER);
136
+ const b = Credit.fromRaw(1);
137
+ expect(() => a.subtract(b)).toThrow(RangeError);
138
+ });
139
+
140
+ it("multiply throws RangeError on overflow", () => {
141
+ const a = Credit.fromRaw(Number.MAX_SAFE_INTEGER);
142
+ expect(() => a.multiply(2)).toThrow(RangeError);
143
+ });
144
+
145
+ it("multiply throws RangeError on Infinity factor", () => {
146
+ const a = Credit.fromRaw(1);
147
+ expect(() => a.multiply(Infinity)).toThrow(RangeError);
148
+ });
149
+
150
+ it("multiply throws RangeError on NaN factor", () => {
151
+ const a = Credit.fromRaw(1);
152
+ expect(() => a.multiply(NaN)).toThrow(RangeError);
153
+ });
154
+
155
+ it("add does not throw for values within safe range", () => {
156
+ const a = Credit.fromRaw(Number.MAX_SAFE_INTEGER - 1);
157
+ const b = Credit.fromRaw(1);
158
+ expect(a.add(b).toRaw()).toBe(Number.MAX_SAFE_INTEGER);
159
+ });
160
+
161
+ it("subtract does not throw for values within safe range", () => {
162
+ const a = Credit.fromRaw(-(Number.MAX_SAFE_INTEGER - 1));
163
+ const b = Credit.fromRaw(1);
164
+ expect(a.subtract(b).toRaw()).toBe(-Number.MAX_SAFE_INTEGER);
165
+ });
127
166
  });
128
167
 
129
168
  describe("comparison", () => {
@@ -101,17 +101,29 @@ export class Credit {
101
101
 
102
102
  /** Add another Credit, returning a new Credit. */
103
103
  add(other: Credit): Credit {
104
- return new Credit(this.raw + other.raw);
104
+ const result = this.raw + other.raw;
105
+ if (!Number.isSafeInteger(result)) {
106
+ throw new RangeError(`Credit.add overflow: ${this.raw} + ${other.raw} = ${result}`);
107
+ }
108
+ return new Credit(result);
105
109
  }
106
110
 
107
111
  /** Subtract another Credit, returning a new Credit (may be negative). */
108
112
  subtract(other: Credit): Credit {
109
- return new Credit(this.raw - other.raw);
113
+ const result = this.raw - other.raw;
114
+ if (!Number.isSafeInteger(result)) {
115
+ throw new RangeError(`Credit.subtract overflow: ${this.raw} - ${other.raw} = ${result}`);
116
+ }
117
+ return new Credit(result);
110
118
  }
111
119
 
112
120
  /** Multiply by a factor, rounding to nearest raw unit. */
113
121
  multiply(factor: number): Credit {
114
- return new Credit(Math.round(this.raw * factor));
122
+ const result = Math.round(this.raw * factor);
123
+ if (!Number.isSafeInteger(result)) {
124
+ throw new RangeError(`Credit.multiply overflow: ${this.raw} * ${factor} = ${result}`);
125
+ }
126
+ return new Credit(result);
115
127
  }
116
128
 
117
129
  /** True if this credit is negative. */
@@ -449,6 +449,71 @@ describe("DrizzleLedger", () => {
449
449
  });
450
450
  });
451
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
+
452
517
  // -----------------------------------------------------------------------
453
518
  // memberUsage()
454
519
  // -----------------------------------------------------------------------
@@ -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
  // ---------------------------------------------------------------------------
@@ -750,7 +768,10 @@ export class DrizzleLedger implements ILedger {
750
768
  and(
751
769
  isNotNull(sql`${journalEntries.metadata}->>'expiresAt'`),
752
770
  sql`(${journalEntries.metadata}->>'expiresAt') <= ${now}`,
753
- sql`${journalEntries.entryType} NOT IN ('credit_expiry', 'bot_runtime', 'adapter_usage', 'addon', 'refund')`,
771
+ sql`${journalEntries.entryType} IN (${sql.join(
772
+ EXPIRABLE_CREDIT_TYPES.map((t) => sql`${t}`),
773
+ sql`, `,
774
+ )})`,
754
775
  ),
755
776
  );
756
777