@wopr-network/platform-core 1.39.2 → 1.39.4

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.
@@ -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", () => {
@@ -59,69 +59,95 @@ export async function runRuntimeDeductions(cfg) {
59
59
  for (const { tenantId, balance } of tenants) {
60
60
  try {
61
61
  const runtimeRef = `runtime:${cfg.date}:${tenantId}`;
62
- if (await cfg.ledger.hasReferenceId(runtimeRef)) {
63
- result.skipped.push(tenantId);
64
- continue;
65
- }
62
+ const runtimeAlreadyBilled = await cfg.ledger.hasReferenceId(runtimeRef);
66
63
  const botCount = await cfg.getActiveBotCount(tenantId);
67
- if (botCount <= 0)
64
+ if (botCount <= 0) {
65
+ if (runtimeAlreadyBilled)
66
+ result.skipped.push(tenantId);
68
67
  continue;
68
+ }
69
69
  const totalCost = DAILY_BOT_COST.multiply(botCount);
70
- if (!balance.lessThan(totalCost)) {
71
- // Full deduction
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
- });
76
- // Debit resource tier surcharges (if any)
77
- if (cfg.getResourceTierCosts) {
70
+ let didBillAnything = false;
71
+ // Bill runtime debit (skipped if already billed on a previous run)
72
+ if (!runtimeAlreadyBilled) {
73
+ if (!balance.lessThan(totalCost)) {
74
+ // Full deduction
75
+ await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
76
+ description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
77
+ referenceId: runtimeRef,
78
+ });
79
+ }
80
+ else {
81
+ // Partial deduction — balance insufficient to cover full cost; debit what's available and suspend
82
+ if (balance.greaterThan(Credit.ZERO)) {
83
+ await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
84
+ description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
85
+ referenceId: runtimeRef,
86
+ });
87
+ }
88
+ if (!result.suspended.includes(tenantId)) {
89
+ result.suspended.push(tenantId);
90
+ if (cfg.onSuspend)
91
+ await cfg.onSuspend(tenantId);
92
+ }
93
+ }
94
+ didBillAnything = true;
95
+ }
96
+ // Debit resource tier surcharges (if any) — independent idempotency
97
+ if (cfg.getResourceTierCosts) {
98
+ const tierRef = `runtime-tier:${cfg.date}:${tenantId}`;
99
+ if (!(await cfg.ledger.hasReferenceId(tierRef))) {
78
100
  const tierCost = await cfg.getResourceTierCosts(tenantId);
79
101
  if (!tierCost.isZero()) {
80
102
  const balanceAfterRuntime = await cfg.ledger.balance(tenantId);
81
103
  if (!balanceAfterRuntime.lessThan(tierCost)) {
82
104
  await cfg.ledger.debit(tenantId, tierCost, "resource_upgrade", {
83
105
  description: "Daily resource tier surcharge",
84
- referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
106
+ referenceId: tierRef,
85
107
  });
86
108
  }
87
109
  else if (balanceAfterRuntime.greaterThan(Credit.ZERO)) {
88
110
  await cfg.ledger.debit(tenantId, balanceAfterRuntime, "resource_upgrade", {
89
111
  description: "Partial resource tier surcharge (balance exhausted)",
90
- referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
112
+ referenceId: tierRef,
91
113
  });
92
114
  }
115
+ didBillAnything = true;
93
116
  }
94
117
  }
95
- const newBalance = await cfg.ledger.balance(tenantId);
96
- // Fire onLowBalance if balance crossed below threshold from above
97
- if (newBalance.greaterThan(Credit.ZERO) &&
98
- !newBalance.greaterThan(LOW_BALANCE_THRESHOLD) &&
99
- balance.greaterThan(LOW_BALANCE_THRESHOLD) &&
100
- cfg.onLowBalance) {
101
- await cfg.onLowBalance(tenantId, newBalance);
102
- }
103
- // Fire onCreditsExhausted if balance just hit 0
104
- if (!newBalance.greaterThan(Credit.ZERO) && balance.greaterThan(Credit.ZERO) && cfg.onCreditsExhausted) {
105
- await cfg.onCreditsExhausted(tenantId);
106
- }
107
- // Suspend tenant when balance hits zero after full deduction (zero-crossing guard)
108
- if (!newBalance.greaterThan(Credit.ZERO) &&
109
- balance.greaterThan(Credit.ZERO) &&
110
- !result.suspended.includes(tenantId)) {
111
- result.suspended.push(tenantId);
112
- if (cfg.onSuspend) {
113
- await cfg.onSuspend(tenantId);
114
- }
118
+ }
119
+ const newBalance = await cfg.ledger.balance(tenantId);
120
+ // Fire onLowBalance if balance crossed below threshold from above
121
+ if (newBalance.greaterThan(Credit.ZERO) &&
122
+ !newBalance.greaterThan(LOW_BALANCE_THRESHOLD) &&
123
+ balance.greaterThan(LOW_BALANCE_THRESHOLD) &&
124
+ cfg.onLowBalance) {
125
+ await cfg.onLowBalance(tenantId, newBalance);
126
+ }
127
+ // Fire onCreditsExhausted if balance just hit 0
128
+ if (!newBalance.greaterThan(Credit.ZERO) && balance.greaterThan(Credit.ZERO) && cfg.onCreditsExhausted) {
129
+ await cfg.onCreditsExhausted(tenantId);
130
+ }
131
+ // Suspend tenant when balance hits zero (zero-crossing guard)
132
+ if (!newBalance.greaterThan(Credit.ZERO) &&
133
+ balance.greaterThan(Credit.ZERO) &&
134
+ !result.suspended.includes(tenantId)) {
135
+ result.suspended.push(tenantId);
136
+ if (cfg.onSuspend) {
137
+ await cfg.onSuspend(tenantId);
115
138
  }
116
- // Debit storage tier surcharges (if any)
117
- if (cfg.getStorageTierCosts) {
139
+ }
140
+ // Debit storage tier surcharges (if any) — independent idempotency
141
+ if (cfg.getStorageTierCosts) {
142
+ const storageRef = `runtime-storage:${cfg.date}:${tenantId}`;
143
+ if (!(await cfg.ledger.hasReferenceId(storageRef))) {
118
144
  const storageCost = await cfg.getStorageTierCosts(tenantId);
119
145
  if (!storageCost.isZero()) {
120
146
  const currentBalance = await cfg.ledger.balance(tenantId);
121
147
  if (!currentBalance.lessThan(storageCost)) {
122
148
  await cfg.ledger.debit(tenantId, storageCost, "storage_upgrade", {
123
149
  description: "Daily storage tier surcharge",
124
- referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
150
+ referenceId: storageRef,
125
151
  });
126
152
  }
127
153
  else {
@@ -129,7 +155,7 @@ export async function runRuntimeDeductions(cfg) {
129
155
  if (currentBalance.greaterThan(Credit.ZERO)) {
130
156
  await cfg.ledger.debit(tenantId, currentBalance, "storage_upgrade", {
131
157
  description: "Partial storage tier surcharge (balance exhausted)",
132
- referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
158
+ referenceId: storageRef,
133
159
  });
134
160
  }
135
161
  if (!result.suspended.includes(tenantId)) {
@@ -138,17 +164,21 @@ export async function runRuntimeDeductions(cfg) {
138
164
  await cfg.onSuspend(tenantId);
139
165
  }
140
166
  }
167
+ didBillAnything = true;
141
168
  }
142
169
  }
143
- // Debit infrastructure add-on costs (if any)
144
- if (cfg.getAddonCosts) {
170
+ }
171
+ // Debit infrastructure add-on costs (if any) — independent idempotency
172
+ if (cfg.getAddonCosts) {
173
+ const addonRef = `runtime-addon:${cfg.date}:${tenantId}`;
174
+ if (!(await cfg.ledger.hasReferenceId(addonRef))) {
145
175
  const addonCost = await cfg.getAddonCosts(tenantId);
146
176
  if (!addonCost.isZero()) {
147
177
  const currentBalance = await cfg.ledger.balance(tenantId);
148
178
  if (!currentBalance.lessThan(addonCost)) {
149
179
  await cfg.ledger.debit(tenantId, addonCost, "addon", {
150
180
  description: "Daily infrastructure add-on charges",
151
- referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
181
+ referenceId: addonRef,
152
182
  });
153
183
  }
154
184
  else {
@@ -156,7 +186,7 @@ export async function runRuntimeDeductions(cfg) {
156
186
  if (currentBalance.greaterThan(Credit.ZERO)) {
157
187
  await cfg.ledger.debit(tenantId, currentBalance, "addon", {
158
188
  description: "Partial add-on charges (balance exhausted)",
159
- referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
189
+ referenceId: addonRef,
160
190
  });
161
191
  }
162
192
  if (!result.suspended.includes(tenantId)) {
@@ -165,26 +195,16 @@ export async function runRuntimeDeductions(cfg) {
165
195
  await cfg.onSuspend(tenantId);
166
196
  }
167
197
  }
198
+ didBillAnything = true;
168
199
  }
169
200
  }
170
201
  }
202
+ if (didBillAnything) {
203
+ result.processed++;
204
+ }
171
205
  else {
172
- // Partial deduction — debit remaining balance, then suspend
173
- if (balance.greaterThan(Credit.ZERO)) {
174
- await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
175
- description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
176
- referenceId: runtimeRef,
177
- });
178
- }
179
- if (cfg.onCreditsExhausted) {
180
- await cfg.onCreditsExhausted(tenantId);
181
- }
182
- result.suspended.push(tenantId);
183
- if (cfg.onSuspend) {
184
- await cfg.onSuspend(tenantId);
185
- }
206
+ result.skipped.push(tenantId);
186
207
  }
187
- result.processed++;
188
208
  }
189
209
  catch (err) {
190
210
  if (err instanceof InsufficientBalanceError) {
@@ -314,4 +314,110 @@ describe("runRuntimeDeductions", () => {
314
314
  // Balance unchanged after second run
315
315
  expect((await ledger.balance("tenant-1")).toCents()).toBe(500 - 17);
316
316
  });
317
+ it("bills surcharges on retry when runtime was already billed (crash recovery)", async () => {
318
+ // Setup: tenant with enough balance for runtime + tier + storage + addon
319
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
320
+ const cfg = {
321
+ ledger,
322
+ date: "2025-07-01",
323
+ getActiveBotCount: async () => 1,
324
+ getResourceTierCosts: async () => Credit.fromCents(10),
325
+ getStorageTierCosts: async () => Credit.fromCents(8),
326
+ getAddonCosts: async () => Credit.fromCents(5),
327
+ };
328
+ // First run — bills everything
329
+ const first = await runRuntimeDeductions(cfg);
330
+ expect(first.processed).toBe(1);
331
+ // 1000 - 17 (runtime) - 10 (tier) - 8 (storage) - 5 (addon) = 960
332
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
333
+ // Second run — all already billed, should skip
334
+ const second = await runRuntimeDeductions(cfg);
335
+ expect(second.skipped).toContain("tenant-1");
336
+ expect(second.processed).toBe(0);
337
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
338
+ });
339
+ it("bills remaining surcharges when runtime was billed but surcharges were not (simulated crash)", async () => {
340
+ // Setup: tenant with enough balance
341
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
342
+ // Simulate crash: manually debit only the runtime charge (as if the cron crashed after this)
343
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
344
+ description: "Daily runtime: 1 bot(s) x $0.17",
345
+ referenceId: `runtime:2025-07-02:tenant-1`,
346
+ });
347
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(983); // 1000 - 17
348
+ // Retry run — runtime already billed, but surcharges should still be billed
349
+ const result = await runRuntimeDeductions({
350
+ ledger,
351
+ date: "2025-07-02",
352
+ getActiveBotCount: async () => 1,
353
+ getResourceTierCosts: async () => Credit.fromCents(10),
354
+ getStorageTierCosts: async () => Credit.fromCents(8),
355
+ getAddonCosts: async () => Credit.fromCents(5),
356
+ });
357
+ expect(result.processed).toBe(1);
358
+ expect(result.skipped).not.toContain("tenant-1");
359
+ // 983 - 10 (tier) - 8 (storage) - 5 (addon) = 960
360
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
361
+ });
362
+ it("bills only missing surcharges when some were already committed (simulated partial crash)", async () => {
363
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
364
+ // Simulate: runtime + tier already billed, storage + addon not yet
365
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
366
+ description: "Daily runtime: 1 bot(s) x $0.17",
367
+ referenceId: `runtime:2025-07-03:tenant-1`,
368
+ });
369
+ await ledger.debit("tenant-1", Credit.fromCents(10), "resource_upgrade", {
370
+ description: "Daily resource tier surcharge",
371
+ referenceId: `runtime-tier:2025-07-03:tenant-1`,
372
+ });
373
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(973); // 1000 - 17 - 10
374
+ const result = await runRuntimeDeductions({
375
+ ledger,
376
+ date: "2025-07-03",
377
+ getActiveBotCount: async () => 1,
378
+ getResourceTierCosts: async () => Credit.fromCents(10),
379
+ getStorageTierCosts: async () => Credit.fromCents(8),
380
+ getAddonCosts: async () => Credit.fromCents(5),
381
+ });
382
+ expect(result.processed).toBe(1);
383
+ // 973 - 8 (storage) - 5 (addon) = 960
384
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
385
+ });
386
+ it("does not double-debit runtime on retry after partial deduction + crash", async () => {
387
+ await ledger.credit("tenant-1", Credit.fromCents(10), "purchase", { description: "top-up" });
388
+ // Simulate: partial runtime debit already committed (balance was 10, cost was 17)
389
+ await ledger.debit("tenant-1", Credit.fromCents(10), "bot_runtime", {
390
+ description: "Partial daily runtime (balance exhausted): 1 bot(s)",
391
+ referenceId: `runtime:2025-07-04:tenant-1`,
392
+ allowNegative: true,
393
+ });
394
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
395
+ // Retry — runtime already billed, balance is 0, nothing should happen
396
+ const result = await runRuntimeDeductions({
397
+ ledger,
398
+ date: "2025-07-04",
399
+ getActiveBotCount: async () => 1,
400
+ });
401
+ // Tenant still has 0 balance (tenantsWithBalance returns only positive), so won't be processed
402
+ expect(result.processed).toBe(0);
403
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
404
+ });
405
+ it("trial balance remains balanced after crash-recovery billing", async () => {
406
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
407
+ // Simulate crash: only runtime billed
408
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
409
+ description: "Daily runtime: 1 bot(s) x $0.17",
410
+ referenceId: `runtime:2025-07-05:tenant-1`,
411
+ });
412
+ // Retry — surcharges billed
413
+ await runRuntimeDeductions({
414
+ ledger,
415
+ date: "2025-07-05",
416
+ getActiveBotCount: async () => 1,
417
+ getResourceTierCosts: async () => Credit.fromCents(10),
418
+ getStorageTierCosts: async () => Credit.fromCents(8),
419
+ });
420
+ const tb = await ledger.trialBalance();
421
+ expect(tb.balanced).toBe(true);
422
+ });
317
423
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.39.2",
3
+ "version": "1.39.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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. */
@@ -356,4 +356,130 @@ describe("runRuntimeDeductions", () => {
356
356
  // Balance unchanged after second run
357
357
  expect((await ledger.balance("tenant-1")).toCents()).toBe(500 - 17);
358
358
  });
359
+
360
+ it("bills surcharges on retry when runtime was already billed (crash recovery)", async () => {
361
+ // Setup: tenant with enough balance for runtime + tier + storage + addon
362
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
363
+
364
+ const cfg = {
365
+ ledger,
366
+ date: "2025-07-01",
367
+ getActiveBotCount: async () => 1,
368
+ getResourceTierCosts: async () => Credit.fromCents(10),
369
+ getStorageTierCosts: async () => Credit.fromCents(8),
370
+ getAddonCosts: async () => Credit.fromCents(5),
371
+ };
372
+
373
+ // First run — bills everything
374
+ const first = await runRuntimeDeductions(cfg);
375
+ expect(first.processed).toBe(1);
376
+ // 1000 - 17 (runtime) - 10 (tier) - 8 (storage) - 5 (addon) = 960
377
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
378
+
379
+ // Second run — all already billed, should skip
380
+ const second = await runRuntimeDeductions(cfg);
381
+ expect(second.skipped).toContain("tenant-1");
382
+ expect(second.processed).toBe(0);
383
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
384
+ });
385
+
386
+ it("bills remaining surcharges when runtime was billed but surcharges were not (simulated crash)", async () => {
387
+ // Setup: tenant with enough balance
388
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
389
+
390
+ // Simulate crash: manually debit only the runtime charge (as if the cron crashed after this)
391
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
392
+ description: "Daily runtime: 1 bot(s) x $0.17",
393
+ referenceId: `runtime:2025-07-02:tenant-1`,
394
+ });
395
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(983); // 1000 - 17
396
+
397
+ // Retry run — runtime already billed, but surcharges should still be billed
398
+ const result = await runRuntimeDeductions({
399
+ ledger,
400
+ date: "2025-07-02",
401
+ getActiveBotCount: async () => 1,
402
+ getResourceTierCosts: async () => Credit.fromCents(10),
403
+ getStorageTierCosts: async () => Credit.fromCents(8),
404
+ getAddonCosts: async () => Credit.fromCents(5),
405
+ });
406
+
407
+ expect(result.processed).toBe(1);
408
+ expect(result.skipped).not.toContain("tenant-1");
409
+ // 983 - 10 (tier) - 8 (storage) - 5 (addon) = 960
410
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
411
+ });
412
+
413
+ it("bills only missing surcharges when some were already committed (simulated partial crash)", async () => {
414
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
415
+
416
+ // Simulate: runtime + tier already billed, storage + addon not yet
417
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
418
+ description: "Daily runtime: 1 bot(s) x $0.17",
419
+ referenceId: `runtime:2025-07-03:tenant-1`,
420
+ });
421
+ await ledger.debit("tenant-1", Credit.fromCents(10), "resource_upgrade", {
422
+ description: "Daily resource tier surcharge",
423
+ referenceId: `runtime-tier:2025-07-03:tenant-1`,
424
+ });
425
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(973); // 1000 - 17 - 10
426
+
427
+ const result = await runRuntimeDeductions({
428
+ ledger,
429
+ date: "2025-07-03",
430
+ getActiveBotCount: async () => 1,
431
+ getResourceTierCosts: async () => Credit.fromCents(10),
432
+ getStorageTierCosts: async () => Credit.fromCents(8),
433
+ getAddonCosts: async () => Credit.fromCents(5),
434
+ });
435
+
436
+ expect(result.processed).toBe(1);
437
+ // 973 - 8 (storage) - 5 (addon) = 960
438
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
439
+ });
440
+
441
+ it("does not double-debit runtime on retry after partial deduction + crash", async () => {
442
+ await ledger.credit("tenant-1", Credit.fromCents(10), "purchase", { description: "top-up" });
443
+
444
+ // Simulate: partial runtime debit already committed (balance was 10, cost was 17)
445
+ await ledger.debit("tenant-1", Credit.fromCents(10), "bot_runtime", {
446
+ description: "Partial daily runtime (balance exhausted): 1 bot(s)",
447
+ referenceId: `runtime:2025-07-04:tenant-1`,
448
+ allowNegative: true,
449
+ });
450
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
451
+
452
+ // Retry — runtime already billed, balance is 0, nothing should happen
453
+ const result = await runRuntimeDeductions({
454
+ ledger,
455
+ date: "2025-07-04",
456
+ getActiveBotCount: async () => 1,
457
+ });
458
+
459
+ // Tenant still has 0 balance (tenantsWithBalance returns only positive), so won't be processed
460
+ expect(result.processed).toBe(0);
461
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
462
+ });
463
+
464
+ it("trial balance remains balanced after crash-recovery billing", async () => {
465
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
466
+
467
+ // Simulate crash: only runtime billed
468
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
469
+ description: "Daily runtime: 1 bot(s) x $0.17",
470
+ referenceId: `runtime:2025-07-05:tenant-1`,
471
+ });
472
+
473
+ // Retry — surcharges billed
474
+ await runRuntimeDeductions({
475
+ ledger,
476
+ date: "2025-07-05",
477
+ getActiveBotCount: async () => 1,
478
+ getResourceTierCosts: async () => Credit.fromCents(10),
479
+ getStorageTierCosts: async () => Credit.fromCents(8),
480
+ });
481
+
482
+ const tb = await ledger.trialBalance();
483
+ expect(tb.balanced).toBe(true);
484
+ });
359
485
  });
@@ -110,87 +110,111 @@ export async function runRuntimeDeductions(cfg: RuntimeCronConfig): Promise<Runt
110
110
  for (const { tenantId, balance } of tenants) {
111
111
  try {
112
112
  const runtimeRef = `runtime:${cfg.date}:${tenantId}`;
113
- if (await cfg.ledger.hasReferenceId(runtimeRef)) {
114
- result.skipped.push(tenantId);
115
- continue;
116
- }
113
+ const runtimeAlreadyBilled = await cfg.ledger.hasReferenceId(runtimeRef);
117
114
 
118
115
  const botCount = await cfg.getActiveBotCount(tenantId);
119
- if (botCount <= 0) continue;
116
+ if (botCount <= 0) {
117
+ if (runtimeAlreadyBilled) result.skipped.push(tenantId);
118
+ continue;
119
+ }
120
120
 
121
121
  const totalCost = DAILY_BOT_COST.multiply(botCount);
122
+ let didBillAnything = false;
122
123
 
123
- if (!balance.lessThan(totalCost)) {
124
- // Full deduction
125
- await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
126
- description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
127
- referenceId: runtimeRef,
128
- });
124
+ // Bill runtime debit (skipped if already billed on a previous run)
125
+ if (!runtimeAlreadyBilled) {
126
+ if (!balance.lessThan(totalCost)) {
127
+ // Full deduction
128
+ await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
129
+ description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
130
+ referenceId: runtimeRef,
131
+ });
132
+ } else {
133
+ // Partial deduction — balance insufficient to cover full cost; debit what's available and suspend
134
+ if (balance.greaterThan(Credit.ZERO)) {
135
+ await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
136
+ description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
137
+ referenceId: runtimeRef,
138
+ });
139
+ }
140
+ if (!result.suspended.includes(tenantId)) {
141
+ result.suspended.push(tenantId);
142
+ if (cfg.onSuspend) await cfg.onSuspend(tenantId);
143
+ }
144
+ }
145
+ didBillAnything = true;
146
+ }
129
147
 
130
- // Debit resource tier surcharges (if any)
131
- if (cfg.getResourceTierCosts) {
148
+ // Debit resource tier surcharges (if any) — independent idempotency
149
+ if (cfg.getResourceTierCosts) {
150
+ const tierRef = `runtime-tier:${cfg.date}:${tenantId}`;
151
+ if (!(await cfg.ledger.hasReferenceId(tierRef))) {
132
152
  const tierCost = await cfg.getResourceTierCosts(tenantId);
133
153
  if (!tierCost.isZero()) {
134
154
  const balanceAfterRuntime = await cfg.ledger.balance(tenantId);
135
155
  if (!balanceAfterRuntime.lessThan(tierCost)) {
136
156
  await cfg.ledger.debit(tenantId, tierCost, "resource_upgrade", {
137
157
  description: "Daily resource tier surcharge",
138
- referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
158
+ referenceId: tierRef,
139
159
  });
140
160
  } else if (balanceAfterRuntime.greaterThan(Credit.ZERO)) {
141
161
  await cfg.ledger.debit(tenantId, balanceAfterRuntime, "resource_upgrade", {
142
162
  description: "Partial resource tier surcharge (balance exhausted)",
143
- referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
163
+ referenceId: tierRef,
144
164
  });
145
165
  }
166
+ didBillAnything = true;
146
167
  }
147
168
  }
169
+ }
148
170
 
149
- const newBalance = await cfg.ledger.balance(tenantId);
171
+ const newBalance = await cfg.ledger.balance(tenantId);
150
172
 
151
- // Fire onLowBalance if balance crossed below threshold from above
152
- if (
153
- newBalance.greaterThan(Credit.ZERO) &&
154
- !newBalance.greaterThan(LOW_BALANCE_THRESHOLD) &&
155
- balance.greaterThan(LOW_BALANCE_THRESHOLD) &&
156
- cfg.onLowBalance
157
- ) {
158
- await cfg.onLowBalance(tenantId, newBalance);
159
- }
173
+ // Fire onLowBalance if balance crossed below threshold from above
174
+ if (
175
+ newBalance.greaterThan(Credit.ZERO) &&
176
+ !newBalance.greaterThan(LOW_BALANCE_THRESHOLD) &&
177
+ balance.greaterThan(LOW_BALANCE_THRESHOLD) &&
178
+ cfg.onLowBalance
179
+ ) {
180
+ await cfg.onLowBalance(tenantId, newBalance);
181
+ }
160
182
 
161
- // Fire onCreditsExhausted if balance just hit 0
162
- if (!newBalance.greaterThan(Credit.ZERO) && balance.greaterThan(Credit.ZERO) && cfg.onCreditsExhausted) {
163
- await cfg.onCreditsExhausted(tenantId);
164
- }
183
+ // Fire onCreditsExhausted if balance just hit 0
184
+ if (!newBalance.greaterThan(Credit.ZERO) && balance.greaterThan(Credit.ZERO) && cfg.onCreditsExhausted) {
185
+ await cfg.onCreditsExhausted(tenantId);
186
+ }
165
187
 
166
- // Suspend tenant when balance hits zero after full deduction (zero-crossing guard)
167
- if (
168
- !newBalance.greaterThan(Credit.ZERO) &&
169
- balance.greaterThan(Credit.ZERO) &&
170
- !result.suspended.includes(tenantId)
171
- ) {
172
- result.suspended.push(tenantId);
173
- if (cfg.onSuspend) {
174
- await cfg.onSuspend(tenantId);
175
- }
188
+ // Suspend tenant when balance hits zero (zero-crossing guard)
189
+ if (
190
+ !newBalance.greaterThan(Credit.ZERO) &&
191
+ balance.greaterThan(Credit.ZERO) &&
192
+ !result.suspended.includes(tenantId)
193
+ ) {
194
+ result.suspended.push(tenantId);
195
+ if (cfg.onSuspend) {
196
+ await cfg.onSuspend(tenantId);
176
197
  }
198
+ }
177
199
 
178
- // Debit storage tier surcharges (if any)
179
- if (cfg.getStorageTierCosts) {
200
+ // Debit storage tier surcharges (if any) — independent idempotency
201
+ if (cfg.getStorageTierCosts) {
202
+ const storageRef = `runtime-storage:${cfg.date}:${tenantId}`;
203
+ if (!(await cfg.ledger.hasReferenceId(storageRef))) {
180
204
  const storageCost = await cfg.getStorageTierCosts(tenantId);
181
205
  if (!storageCost.isZero()) {
182
206
  const currentBalance = await cfg.ledger.balance(tenantId);
183
207
  if (!currentBalance.lessThan(storageCost)) {
184
208
  await cfg.ledger.debit(tenantId, storageCost, "storage_upgrade", {
185
209
  description: "Daily storage tier surcharge",
186
- referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
210
+ referenceId: storageRef,
187
211
  });
188
212
  } else {
189
213
  // Partial debit — take what's left, then suspend
190
214
  if (currentBalance.greaterThan(Credit.ZERO)) {
191
215
  await cfg.ledger.debit(tenantId, currentBalance, "storage_upgrade", {
192
216
  description: "Partial storage tier surcharge (balance exhausted)",
193
- referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
217
+ referenceId: storageRef,
194
218
  });
195
219
  }
196
220
  if (!result.suspended.includes(tenantId)) {
@@ -198,25 +222,29 @@ export async function runRuntimeDeductions(cfg: RuntimeCronConfig): Promise<Runt
198
222
  if (cfg.onSuspend) await cfg.onSuspend(tenantId);
199
223
  }
200
224
  }
225
+ didBillAnything = true;
201
226
  }
202
227
  }
228
+ }
203
229
 
204
- // Debit infrastructure add-on costs (if any)
205
- if (cfg.getAddonCosts) {
230
+ // Debit infrastructure add-on costs (if any) — independent idempotency
231
+ if (cfg.getAddonCosts) {
232
+ const addonRef = `runtime-addon:${cfg.date}:${tenantId}`;
233
+ if (!(await cfg.ledger.hasReferenceId(addonRef))) {
206
234
  const addonCost = await cfg.getAddonCosts(tenantId);
207
235
  if (!addonCost.isZero()) {
208
236
  const currentBalance = await cfg.ledger.balance(tenantId);
209
237
  if (!currentBalance.lessThan(addonCost)) {
210
238
  await cfg.ledger.debit(tenantId, addonCost, "addon", {
211
239
  description: "Daily infrastructure add-on charges",
212
- referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
240
+ referenceId: addonRef,
213
241
  });
214
242
  } else {
215
243
  // Partial debit — take what's left, then suspend
216
244
  if (currentBalance.greaterThan(Credit.ZERO)) {
217
245
  await cfg.ledger.debit(tenantId, currentBalance, "addon", {
218
246
  description: "Partial add-on charges (balance exhausted)",
219
- referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
247
+ referenceId: addonRef,
220
248
  });
221
249
  }
222
250
  if (!result.suspended.includes(tenantId)) {
@@ -224,28 +252,16 @@ export async function runRuntimeDeductions(cfg: RuntimeCronConfig): Promise<Runt
224
252
  if (cfg.onSuspend) await cfg.onSuspend(tenantId);
225
253
  }
226
254
  }
255
+ didBillAnything = true;
227
256
  }
228
257
  }
229
- } else {
230
- // Partial deduction — debit remaining balance, then suspend
231
- if (balance.greaterThan(Credit.ZERO)) {
232
- await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
233
- description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
234
- referenceId: runtimeRef,
235
- });
236
- }
237
-
238
- if (cfg.onCreditsExhausted) {
239
- await cfg.onCreditsExhausted(tenantId);
240
- }
241
-
242
- result.suspended.push(tenantId);
243
- if (cfg.onSuspend) {
244
- await cfg.onSuspend(tenantId);
245
- }
246
258
  }
247
259
 
248
- result.processed++;
260
+ if (didBillAnything) {
261
+ result.processed++;
262
+ } else {
263
+ result.skipped.push(tenantId);
264
+ }
249
265
  } catch (err) {
250
266
  if (err instanceof InsufficientBalanceError) {
251
267
  result.suspended.push(tenantId);
package/vitest.config.ts CHANGED
@@ -1,6 +1,15 @@
1
+ import { resolve } from "node:path";
1
2
  import { defineConfig } from "vitest/config";
2
3
 
3
4
  export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ "@wopr-network/platform-core/billing": resolve(__dirname, "src/billing/index.ts"),
8
+ "@wopr-network/platform-core/credits": resolve(__dirname, "src/credits/index.ts"),
9
+ "@wopr-network/platform-core/email": resolve(__dirname, "src/email/index.ts"),
10
+ "@wopr-network/platform-core/metering": resolve(__dirname, "src/metering/index.ts"),
11
+ },
12
+ },
4
13
  test: {
5
14
  testTimeout: 30000,
6
15
  hookTimeout: 30000,