@nehorai/credits 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/adapters/index.cjs +3 -2
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +2 -2
- package/dist/adapters/index.d.ts +2 -2
- package/dist/adapters/index.js +3 -2
- package/dist/adapters/index.js.map +1 -1
- package/dist/core/index.cjs +218 -2
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +183 -5
- package/dist/core/index.d.ts +183 -5
- package/dist/core/index.js +199 -2
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +477 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +456 -59
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +394 -44
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +36 -7
- package/dist/repository/index.d.ts +36 -7
- package/dist/repository/index.js +391 -43
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs +1 -0
- package/dist/sdk/index.cjs.map +1 -1
- package/dist/sdk/index.d.cts +2 -2
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +33 -20
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +2 -2
- package/dist/service/index.d.ts +2 -2
- package/dist/service/index.js +33 -20
- package/dist/service/index.js.map +1 -1
- package/dist/{types-Dzc443vy.d.cts → types-C3sxDvNb.d.cts} +43 -2
- package/dist/{types-Dzc443vy.d.ts → types-C3sxDvNb.d.ts} +43 -2
- package/dist/{types-BhCdep_t.d.ts → types-CY-oHIMA.d.ts} +55 -4
- package/dist/{types-wtksMa4n.d.cts → types-DOjguhEX.d.cts} +55 -4
- package/package.json +70 -71
package/dist/index.cjs
CHANGED
|
@@ -33,6 +33,7 @@ __export(src_exports, {
|
|
|
33
33
|
CreditsSDKError: () => CreditsSDKError,
|
|
34
34
|
CreditsService: () => CreditsService,
|
|
35
35
|
InMemoryCreditRepository: () => InMemoryCreditRepository,
|
|
36
|
+
NO_EXPIRED_BACKING_PREFIX: () => NO_EXPIRED_BACKING_PREFIX,
|
|
36
37
|
NetworkError: () => NetworkError,
|
|
37
38
|
RESERVATION_STATUSES: () => RESERVATION_STATUSES,
|
|
38
39
|
RESERVED_JOURNAL_KEY_PREFIX: () => RESERVED_JOURNAL_KEY_PREFIX,
|
|
@@ -45,6 +46,7 @@ __export(src_exports, {
|
|
|
45
46
|
UNLIMITED_BALANCE_SENTINEL: () => UNLIMITED_BALANCE_SENTINEL,
|
|
46
47
|
ValidationError: () => ValidationError,
|
|
47
48
|
addCreditsThroughRepository: () => addCreditsThroughRepository,
|
|
49
|
+
allocateExpiredBacking: () => allocateExpiredBacking,
|
|
48
50
|
assertDirectStatusWriteAllowed: () => assertDirectStatusWriteAllowed,
|
|
49
51
|
assertHoldPlaced: () => assertHoldPlaced,
|
|
50
52
|
assertKnownReservationStatus: () => assertKnownReservationStatus,
|
|
@@ -54,12 +56,17 @@ __export(src_exports, {
|
|
|
54
56
|
assertRepresentableTierAmount: () => assertRepresentableTierAmount,
|
|
55
57
|
assertUnkeyedDirectReservation: () => assertUnkeyedDirectReservation,
|
|
56
58
|
assertUnreferencedDirectTransaction: () => assertUnreferencedDirectTransaction,
|
|
59
|
+
assertValidBalanceTarget: () => assertValidBalanceTarget,
|
|
60
|
+
assertValidBalanceUpdate: () => assertValidBalanceUpdate,
|
|
57
61
|
assertValidCreditAmount: () => assertValidCreditAmount,
|
|
58
62
|
assertValidIdempotencyKey: () => assertValidIdempotencyKey,
|
|
59
63
|
assertValidStoredAmount: () => assertValidStoredAmount,
|
|
60
64
|
assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
|
|
65
|
+
assertValidTierBalanceInput: () => assertValidTierBalanceInput,
|
|
61
66
|
backedBalanceFloor: () => backedBalanceFloor,
|
|
62
67
|
calculateAvailableCredits: () => calculateAvailableCredits,
|
|
68
|
+
canonicalAmount: () => canonicalAmount,
|
|
69
|
+
cappedBalance: () => cappedBalance,
|
|
63
70
|
checkAndNotifyLowBalance: () => checkAndNotifyLowBalance,
|
|
64
71
|
classifyDatabaseError: () => classifyDatabaseError,
|
|
65
72
|
clearConfigProvider: () => clearConfigProvider,
|
|
@@ -85,6 +92,7 @@ __export(src_exports, {
|
|
|
85
92
|
createUnsupportedOperationError: () => createUnsupportedOperationError,
|
|
86
93
|
createUserNotFoundError: () => createUserNotFoundError,
|
|
87
94
|
describePaymentMismatch: () => describePaymentMismatch,
|
|
95
|
+
expiredBackingPatch: () => expiredBackingPatch,
|
|
88
96
|
generateId: () => generateId,
|
|
89
97
|
generateRequestId: () => generateRequestId2,
|
|
90
98
|
genericDeferred: () => genericDeferred,
|
|
@@ -110,6 +118,9 @@ __export(src_exports, {
|
|
|
110
118
|
getValidOperationTypes: () => getValidOperationTypes,
|
|
111
119
|
getValidTiers: () => getValidTiers,
|
|
112
120
|
hasPlacedHold: () => hasPlacedHold,
|
|
121
|
+
heldExpiredBacking: () => heldExpiredBacking,
|
|
122
|
+
holdExpiredBacking: () => holdExpiredBacking,
|
|
123
|
+
holdPlacedAtAfter: () => holdPlacedAtAfter,
|
|
113
124
|
initializeConfig: () => initializeConfig,
|
|
114
125
|
isCreditError: () => isCreditError,
|
|
115
126
|
isCreditErrorMessage: () => isCreditErrorMessage,
|
|
@@ -135,6 +146,7 @@ __export(src_exports, {
|
|
|
135
146
|
isWinningOutcome: () => isWinningOutcome,
|
|
136
147
|
loadConfigFromEnv: () => loadConfigFromEnv,
|
|
137
148
|
monthlyResetBalance: () => monthlyResetBalance,
|
|
149
|
+
multiplyAmount: () => multiplyAmount,
|
|
138
150
|
noopDeferred: () => noopDeferred,
|
|
139
151
|
normalizePaymentRef: () => normalizePaymentRef,
|
|
140
152
|
notifySubscriptionExpired: () => notifySubscriptionExpired,
|
|
@@ -143,6 +155,10 @@ __export(src_exports, {
|
|
|
143
155
|
parseApiError: () => parseApiError,
|
|
144
156
|
parseCreditError: () => parseCreditError,
|
|
145
157
|
parseTier: () => parseTier,
|
|
158
|
+
planBalanceCap: () => planBalanceCap,
|
|
159
|
+
planBalanceTarget: () => planBalanceTarget,
|
|
160
|
+
planHoldCommit: () => planHoldCommit,
|
|
161
|
+
recordedExpiredBacking: () => recordedExpiredBacking,
|
|
146
162
|
registerConfigProvider: () => registerConfigProvider,
|
|
147
163
|
registerNotificationHandler: () => registerNotificationHandler,
|
|
148
164
|
releaseReservationWithJournal: () => releaseReservationWithJournal,
|
|
@@ -152,6 +168,10 @@ __export(src_exports, {
|
|
|
152
168
|
reserveThroughRepository: () => reserveThroughRepository,
|
|
153
169
|
resetConfig: () => resetConfig,
|
|
154
170
|
sameAmount: () => sameAmount,
|
|
171
|
+
settleExpiredHoldBacking: () => settleExpiredHoldBacking,
|
|
172
|
+
spendableBalance: () => spendableBalance,
|
|
173
|
+
storedAmountCeil: () => storedAmountCeil,
|
|
174
|
+
storedAmountFloor: () => storedAmountFloor,
|
|
155
175
|
storedMonthlyLimit: () => storedMonthlyLimit,
|
|
156
176
|
sumAmounts: () => sumAmounts,
|
|
157
177
|
supportsCreditsV2: () => supportsCreditsV2,
|
|
@@ -163,6 +183,7 @@ __export(src_exports, {
|
|
|
163
183
|
toClientUserCredits: () => toClientUserCredits,
|
|
164
184
|
toDate: () => toDate,
|
|
165
185
|
toPortableTimestamp: () => toPortableTimestamp,
|
|
186
|
+
validateBalanceUpdate: () => validateBalanceUpdate,
|
|
166
187
|
verifyAdminApiKey: () => verifyAdminApiKey,
|
|
167
188
|
verifyBearerToken: () => verifyBearerToken
|
|
168
189
|
});
|
|
@@ -337,6 +358,40 @@ function floatSum(values) {
|
|
|
337
358
|
for (const value of values) total += value;
|
|
338
359
|
return total;
|
|
339
360
|
}
|
|
361
|
+
function multiplyAmount(unit, quantity) {
|
|
362
|
+
const cents = toCents(unit);
|
|
363
|
+
const product = cents === null ? Number.NaN : cents * quantity;
|
|
364
|
+
if (cents === null || cents < 0 || !Number.isSafeInteger(quantity) || quantity < 0 || !Number.isSafeInteger(product) || product > MAX_CENTS) {
|
|
365
|
+
throw new CreditError(
|
|
366
|
+
`Cannot price ${quantity} units at ${unit}: the price must be a non-negative amount exact to ${CREDIT_AMOUNT_SCALE} decimals, the quantity a whole number, and the result at most ${CREDIT_AMOUNT_MAX}`,
|
|
367
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
368
|
+
{ unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return product / 100;
|
|
372
|
+
}
|
|
373
|
+
var FLOAT_NOISE_FLOOR = 1e-6;
|
|
374
|
+
var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
|
|
375
|
+
function floatNoise(value) {
|
|
376
|
+
return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
|
|
377
|
+
}
|
|
378
|
+
function canonicalAmount(value) {
|
|
379
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return value;
|
|
380
|
+
const cents = Math.round(value * 100);
|
|
381
|
+
if (!Number.isSafeInteger(cents)) return value;
|
|
382
|
+
const onGrid = cents / 100 + 0;
|
|
383
|
+
return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
|
|
384
|
+
}
|
|
385
|
+
function storedAmountFloor(value) {
|
|
386
|
+
const canonical = canonicalAmount(value);
|
|
387
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
388
|
+
return Math.floor(canonical * 100) / 100;
|
|
389
|
+
}
|
|
390
|
+
function storedAmountCeil(value) {
|
|
391
|
+
const canonical = canonicalAmount(value);
|
|
392
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
393
|
+
return Math.ceil(canonical * 100) / 100;
|
|
394
|
+
}
|
|
340
395
|
function isValidCreditAmount(value) {
|
|
341
396
|
if (typeof value !== "number") return false;
|
|
342
397
|
const cents = toCents(value);
|
|
@@ -389,6 +444,150 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
389
444
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
390
445
|
return floor;
|
|
391
446
|
}
|
|
447
|
+
function cappedBalance(balance, cap, reserved, bonusCredits) {
|
|
448
|
+
return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
|
|
449
|
+
}
|
|
450
|
+
function heldExpiredBacking(balance, stored) {
|
|
451
|
+
if (!(stored !== void 0 && stored > 0)) return 0;
|
|
452
|
+
return Math.max(0, Math.min(stored, balance));
|
|
453
|
+
}
|
|
454
|
+
function spendableBalance(balance, expiredHoldBacking) {
|
|
455
|
+
return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
|
|
456
|
+
}
|
|
457
|
+
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
458
|
+
assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
|
|
459
|
+
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
460
|
+
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
461
|
+
}
|
|
462
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
|
|
463
|
+
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
464
|
+
const target = Math.min(spendable, cap);
|
|
465
|
+
const kept = Math.max(
|
|
466
|
+
sumAmounts(balance, -spendable),
|
|
467
|
+
sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
|
|
468
|
+
);
|
|
469
|
+
const next = Math.min(balance, sumAmounts(target, kept));
|
|
470
|
+
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
471
|
+
}
|
|
472
|
+
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
473
|
+
const held = heldExpiredBacking(balance, expiredHoldBacking);
|
|
474
|
+
const used = Math.max(0, Math.min(held, holdBacking));
|
|
475
|
+
return { used, remaining: sumAmounts(held, -used) };
|
|
476
|
+
}
|
|
477
|
+
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
478
|
+
const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
|
|
479
|
+
const rest = sumAmounts(amount, -backing.used);
|
|
480
|
+
const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
|
|
481
|
+
const fromBonus = sumAmounts(rest, -fromSpendable);
|
|
482
|
+
return {
|
|
483
|
+
covered: fromBonus <= bonusCredits,
|
|
484
|
+
balance: sumAmounts(balance, -backing.used, -fromSpendable),
|
|
485
|
+
bonusCredits: sumAmounts(bonusCredits, -fromBonus),
|
|
486
|
+
expiredHoldBacking: backing.remaining
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
var NO_EXPIRED_BACKING_PREFIX = {
|
|
490
|
+
holdPlacedAt: "",
|
|
491
|
+
id: "",
|
|
492
|
+
share: 0
|
|
493
|
+
};
|
|
494
|
+
function compareHolds(a, b) {
|
|
495
|
+
if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
|
|
496
|
+
if (a.id === b.id) return 0;
|
|
497
|
+
return a.id < b.id ? -1 : 1;
|
|
498
|
+
}
|
|
499
|
+
function recordedExpiredBacking(hold) {
|
|
500
|
+
const stored = hold.expiredBacking;
|
|
501
|
+
if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
|
|
502
|
+
if (!isValidCreditAmount(hold.amount)) return 0;
|
|
503
|
+
return Math.min(storedAmountCeil(stored), hold.amount);
|
|
504
|
+
}
|
|
505
|
+
function allocateExpiredBacking(holds, total) {
|
|
506
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
|
|
507
|
+
const wanted = Math.max(0, total);
|
|
508
|
+
if (wanted === 0) return { total: 0, frontier: null };
|
|
509
|
+
const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
|
|
510
|
+
const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
|
|
511
|
+
let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
|
|
512
|
+
for (const hold of [...recorded].reverse()) {
|
|
513
|
+
if (left >= 0) break;
|
|
514
|
+
const share = shares.get(hold.id);
|
|
515
|
+
const cut = Math.min(share, -left);
|
|
516
|
+
shares.set(hold.id, sumAmounts(share, -cut));
|
|
517
|
+
left = sumAmounts(left, cut);
|
|
518
|
+
}
|
|
519
|
+
let frontier = NO_EXPIRED_BACKING_PREFIX;
|
|
520
|
+
for (const hold of placed) {
|
|
521
|
+
if (left <= 0) break;
|
|
522
|
+
if (shares.has(hold.id)) continue;
|
|
523
|
+
const share = Math.min(hold.amount, left);
|
|
524
|
+
frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
|
|
525
|
+
left = sumAmounts(left, -share);
|
|
526
|
+
}
|
|
527
|
+
for (const hold of recorded) {
|
|
528
|
+
if (left <= 0) break;
|
|
529
|
+
const share = shares.get(hold.id);
|
|
530
|
+
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
531
|
+
shares.set(hold.id, sumAmounts(share, added));
|
|
532
|
+
left = sumAmounts(left, -added);
|
|
533
|
+
}
|
|
534
|
+
const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
|
|
535
|
+
return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
|
|
536
|
+
}
|
|
537
|
+
function holdExpiredBacking(hold, frontier, rewrites) {
|
|
538
|
+
const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
|
|
539
|
+
const recorded = recordedExpiredBacking(
|
|
540
|
+
rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
|
|
541
|
+
);
|
|
542
|
+
if (recorded > 0) return recorded;
|
|
543
|
+
if (!frontier || !hold.holdPlacedAt) return 0;
|
|
544
|
+
const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
|
|
545
|
+
if (order > 0) return 0;
|
|
546
|
+
return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
|
|
547
|
+
}
|
|
548
|
+
function holdPlacedAtAfter(now, frontier) {
|
|
549
|
+
if (!frontier?.holdPlacedAt) return now;
|
|
550
|
+
const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
|
|
551
|
+
return now > earliest ? now : earliest;
|
|
552
|
+
}
|
|
553
|
+
function expiredBackingPatch(next, stored) {
|
|
554
|
+
const total = Math.max(0, next.total);
|
|
555
|
+
const patch = {};
|
|
556
|
+
const storedTotal = stored.expiredHoldBacking;
|
|
557
|
+
if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
|
|
558
|
+
patch.expiredHoldBacking = total;
|
|
559
|
+
}
|
|
560
|
+
if (total <= 0) {
|
|
561
|
+
if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
|
|
562
|
+
} else if (next.frontier) {
|
|
563
|
+
patch.expiredBackingFrontier = next.frontier;
|
|
564
|
+
}
|
|
565
|
+
return patch;
|
|
566
|
+
}
|
|
567
|
+
function assertValidBalanceTarget(value, field, context) {
|
|
568
|
+
assertRepresentableAmount(value, field, context);
|
|
569
|
+
if (value < 0) {
|
|
570
|
+
throw new CreditError(
|
|
571
|
+
`${field} cannot be negative (got ${value})`,
|
|
572
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
573
|
+
{ field, value, ...context }
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function assertValidTierBalanceInput(input, context) {
|
|
578
|
+
if (input.balanceCap === void 0) {
|
|
579
|
+
if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (input.balance !== void 0) {
|
|
583
|
+
throw new CreditError(
|
|
584
|
+
"A tier update cannot set both balance and balanceCap",
|
|
585
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
586
|
+
{ balance: input.balance, balanceCap: input.balanceCap, ...context }
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
|
|
590
|
+
}
|
|
392
591
|
function assertRepresentableTierAmount(value, field, context) {
|
|
393
592
|
if (value === Number.POSITIVE_INFINITY) return;
|
|
394
593
|
assertRepresentableAmount(value, field, context);
|
|
@@ -797,9 +996,9 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
797
996
|
*/
|
|
798
997
|
reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
|
|
799
998
|
/**
|
|
800
|
-
* Default credits for new users (free tier)
|
|
999
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
801
1000
|
*/
|
|
802
|
-
defaultFreeCredits: import_zod.z.number().
|
|
1001
|
+
defaultFreeCredits: import_zod.z.number().min(0).default(25),
|
|
803
1002
|
/**
|
|
804
1003
|
* Grace period in days after subscription expires before downgrade
|
|
805
1004
|
*/
|
|
@@ -1099,6 +1298,8 @@ function toClientUserCredits(credits) {
|
|
|
1099
1298
|
monthlyUsed: credits.monthlyUsed,
|
|
1100
1299
|
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
1101
1300
|
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
1301
|
+
...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
|
|
1302
|
+
...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
|
|
1102
1303
|
createdAt: toISOString(credits.createdAt),
|
|
1103
1304
|
updatedAt: toISOString(credits.updatedAt)
|
|
1104
1305
|
};
|
|
@@ -1330,11 +1531,36 @@ function toDate2(value) {
|
|
|
1330
1531
|
return /* @__PURE__ */ new Date();
|
|
1331
1532
|
}
|
|
1332
1533
|
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
next
|
|
1337
|
-
|
|
1534
|
+
return new Date(from.getFullYear(), from.getMonth() + 1, 1);
|
|
1535
|
+
}
|
|
1536
|
+
function validateBalanceUpdate(current, updates) {
|
|
1537
|
+
const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
|
|
1538
|
+
const balance = next(current.balance, updates.balance, updates.balanceIncrement);
|
|
1539
|
+
const bonusCredits = next(
|
|
1540
|
+
current.bonusCredits ?? 0,
|
|
1541
|
+
updates.bonusCredits,
|
|
1542
|
+
updates.bonusCreditsIncrement
|
|
1543
|
+
);
|
|
1544
|
+
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
1545
|
+
const errors = [];
|
|
1546
|
+
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
1547
|
+
const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
|
|
1548
|
+
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
1549
|
+
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
1550
|
+
}
|
|
1551
|
+
if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
|
|
1552
|
+
if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
|
|
1553
|
+
const covering = sumAmounts(balance, bonusCredits);
|
|
1554
|
+
if (reserved > covering) {
|
|
1555
|
+
errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
|
|
1556
|
+
}
|
|
1557
|
+
return { valid: errors.length === 0, errors };
|
|
1558
|
+
}
|
|
1559
|
+
function assertValidBalanceUpdate(current, updates) {
|
|
1560
|
+
const result = validateBalanceUpdate(current, updates);
|
|
1561
|
+
if (!result.valid) {
|
|
1562
|
+
throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
|
|
1563
|
+
}
|
|
1338
1564
|
}
|
|
1339
1565
|
|
|
1340
1566
|
// src/repository/memory/snapshot.ts
|
|
@@ -1470,6 +1696,7 @@ function commitTransitionJournal(store, input, existingId) {
|
|
|
1470
1696
|
userId: input.userId,
|
|
1471
1697
|
entryType: input.entryType,
|
|
1472
1698
|
amount: input.amount,
|
|
1699
|
+
balanceBefore: input.balanceBefore,
|
|
1473
1700
|
balanceAfter: input.balanceAfter,
|
|
1474
1701
|
source: input.source,
|
|
1475
1702
|
referenceId: input.reservationId,
|
|
@@ -1514,6 +1741,34 @@ function assertTrustworthy(reservation, transition) {
|
|
|
1514
1741
|
assertLockedAmount(reservation, transition);
|
|
1515
1742
|
assertHoldPlaced(reservation, transition);
|
|
1516
1743
|
}
|
|
1744
|
+
function openHolds(store, userId) {
|
|
1745
|
+
return [...store.reservations.get(userId)?.values() ?? []].filter(
|
|
1746
|
+
(reservation) => reservation.status === "reserved"
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
function expiredBackingInForce(store, credits) {
|
|
1750
|
+
const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1751
|
+
if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
|
|
1752
|
+
return allocateExpiredBacking(openHolds(store, credits.userId), total);
|
|
1753
|
+
}
|
|
1754
|
+
return { total, frontier: credits.expiredBackingFrontier ?? null };
|
|
1755
|
+
}
|
|
1756
|
+
function writeRecordedShares(store, userId, allocation) {
|
|
1757
|
+
for (const { id, expiredBacking } of allocation.rewrites ?? []) {
|
|
1758
|
+
const hold = getReservation(store, userId, id);
|
|
1759
|
+
if (hold) hold.expiredBacking = expiredBacking;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
function writeExpiredHoldBacking(store, credits, next) {
|
|
1763
|
+
if (next <= 0) {
|
|
1764
|
+
Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
if (next === credits.expiredHoldBacking) return;
|
|
1768
|
+
const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
|
|
1769
|
+
writeRecordedShares(store, credits.userId, allocation);
|
|
1770
|
+
Object.assign(credits, expiredBackingPatch(allocation, credits));
|
|
1771
|
+
}
|
|
1517
1772
|
function getReservation(store, userId, reservationId) {
|
|
1518
1773
|
return store.reservations.get(userId)?.get(reservationId);
|
|
1519
1774
|
}
|
|
@@ -1561,6 +1816,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1561
1816
|
};
|
|
1562
1817
|
}
|
|
1563
1818
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1819
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1564
1820
|
const reservation = {
|
|
1565
1821
|
id: generateId(),
|
|
1566
1822
|
userId: input.userId,
|
|
@@ -1573,7 +1829,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1573
1829
|
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1574
1830
|
// critical section that raises `reserved` below, so the row and the hold
|
|
1575
1831
|
// become visible together. `createReservation` cannot set it.
|
|
1576
|
-
holdPlacedAt: now
|
|
1832
|
+
holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
|
|
1577
1833
|
};
|
|
1578
1834
|
if (!store.reservations.has(input.userId)) {
|
|
1579
1835
|
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
@@ -1591,6 +1847,8 @@ async function reserveCreditsV2(store, input) {
|
|
|
1591
1847
|
);
|
|
1592
1848
|
}
|
|
1593
1849
|
credits.reserved = nextReserved;
|
|
1850
|
+
writeRecordedShares(store, input.userId, backing);
|
|
1851
|
+
Object.assign(credits, expiredBackingPatch(backing, credits));
|
|
1594
1852
|
credits.updatedAt = now;
|
|
1595
1853
|
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1596
1854
|
});
|
|
@@ -1611,22 +1869,30 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1611
1869
|
const amount = reservation.amount;
|
|
1612
1870
|
await yieldPoint(store);
|
|
1613
1871
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1614
|
-
|
|
1872
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1873
|
+
const paid = planHoldCommit(
|
|
1874
|
+
credits.balance,
|
|
1875
|
+
credits.bonusCredits,
|
|
1876
|
+
backing.total,
|
|
1877
|
+
holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
|
|
1878
|
+
amount
|
|
1879
|
+
);
|
|
1880
|
+
if (!paid.covered) {
|
|
1615
1881
|
throw new CreditError(
|
|
1616
1882
|
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1617
1883
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1618
1884
|
{ userId, reservationId, required: amount }
|
|
1619
1885
|
);
|
|
1620
1886
|
}
|
|
1621
|
-
const
|
|
1622
|
-
const
|
|
1623
|
-
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1887
|
+
const nextBalance = paid.balance;
|
|
1888
|
+
const nextBonusCredits = paid.bonusCredits;
|
|
1624
1889
|
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1625
1890
|
const journal = {
|
|
1626
1891
|
userId,
|
|
1627
1892
|
operation: "commitReservation",
|
|
1628
1893
|
entryType: "debit",
|
|
1629
1894
|
amount,
|
|
1895
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1630
1896
|
balanceAfter,
|
|
1631
1897
|
source: "operation_commit",
|
|
1632
1898
|
reservationId,
|
|
@@ -1654,6 +1920,11 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1654
1920
|
credits.bonusCredits = nextBonusCredits;
|
|
1655
1921
|
credits.reserved = nextReserved;
|
|
1656
1922
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1923
|
+
writeRecordedShares(store, userId, backing);
|
|
1924
|
+
Object.assign(
|
|
1925
|
+
credits,
|
|
1926
|
+
expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
|
|
1927
|
+
);
|
|
1657
1928
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1658
1929
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1659
1930
|
return {
|
|
@@ -1733,19 +2004,28 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1733
2004
|
reservation.id,
|
|
1734
2005
|
reservation.amount
|
|
1735
2006
|
);
|
|
2007
|
+
const inForce = expiredBackingInForce(store, credits);
|
|
2008
|
+
const backing = settleExpiredHoldBacking(
|
|
2009
|
+
credits.balance,
|
|
2010
|
+
inForce.total,
|
|
2011
|
+
holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
|
|
2012
|
+
);
|
|
2013
|
+
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1736
2014
|
const journal = {
|
|
1737
2015
|
userId: reservation.userId,
|
|
1738
2016
|
operation: `${transition.transition}Reservation`,
|
|
1739
2017
|
entryType: "credit",
|
|
1740
2018
|
amount: 0,
|
|
1741
|
-
|
|
2019
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
2020
|
+
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1742
2021
|
source: transition.source,
|
|
1743
2022
|
reservationId: reservation.id,
|
|
1744
2023
|
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1745
2024
|
metadata: {
|
|
1746
2025
|
...transition.options?.metadata,
|
|
1747
2026
|
operationType: reservation.operationType,
|
|
1748
|
-
amount: reservation.amount
|
|
2027
|
+
amount: reservation.amount,
|
|
2028
|
+
...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
|
|
1749
2029
|
},
|
|
1750
2030
|
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1751
2031
|
};
|
|
@@ -1758,6 +2038,12 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1758
2038
|
reservation.status = transition.status;
|
|
1759
2039
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1760
2040
|
credits.reserved = nextReserved;
|
|
2041
|
+
credits.balance = nextBalance;
|
|
2042
|
+
writeRecordedShares(store, reservation.userId, inForce);
|
|
2043
|
+
Object.assign(
|
|
2044
|
+
credits,
|
|
2045
|
+
expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
|
|
2046
|
+
);
|
|
1761
2047
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1762
2048
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1763
2049
|
}
|
|
@@ -1768,6 +2054,10 @@ function isoOf(value) {
|
|
|
1768
2054
|
}
|
|
1769
2055
|
var InMemoryCreditRepository = class {
|
|
1770
2056
|
store = new MemoryStore();
|
|
2057
|
+
options;
|
|
2058
|
+
constructor(options = {}) {
|
|
2059
|
+
this.options = options;
|
|
2060
|
+
}
|
|
1771
2061
|
// ==================== User Credits ====================
|
|
1772
2062
|
/**
|
|
1773
2063
|
* A copy, never the live record.
|
|
@@ -1784,7 +2074,8 @@ var InMemoryCreditRepository = class {
|
|
|
1784
2074
|
return copyRecord(credits) ?? null;
|
|
1785
2075
|
}
|
|
1786
2076
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1787
|
-
|
|
2077
|
+
const existing = this.store.users.get(userId);
|
|
2078
|
+
return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
|
|
1788
2079
|
}
|
|
1789
2080
|
/**
|
|
1790
2081
|
* The synchronous body of `initializeUserCredits`.
|
|
@@ -1795,7 +2086,7 @@ var InMemoryCreditRepository = class {
|
|
|
1795
2086
|
* simultaneous first deliveries of the same reference would then both credit.
|
|
1796
2087
|
*/
|
|
1797
2088
|
seedUser(userId, tier, initialBalance) {
|
|
1798
|
-
|
|
2089
|
+
assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
|
|
1799
2090
|
const configured = getConfigMonthlyLimit(tier);
|
|
1800
2091
|
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1801
2092
|
const monthlyLimit = storedMonthlyLimit(configured);
|
|
@@ -1835,8 +2126,11 @@ var InMemoryCreditRepository = class {
|
|
|
1835
2126
|
if (!credits) {
|
|
1836
2127
|
throw new Error(`User ${userId} not found`);
|
|
1837
2128
|
}
|
|
2129
|
+
assertValidBalanceUpdate(credits, updates);
|
|
1838
2130
|
const next = { ...credits };
|
|
1839
|
-
if (updates.balance !== void 0)
|
|
2131
|
+
if (updates.balance !== void 0) {
|
|
2132
|
+
next.balance = updates.balance;
|
|
2133
|
+
}
|
|
1840
2134
|
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1841
2135
|
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1842
2136
|
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
@@ -1873,6 +2167,11 @@ var InMemoryCreditRepository = class {
|
|
|
1873
2167
|
userId,
|
|
1874
2168
|
operation: "updateUserCredits"
|
|
1875
2169
|
});
|
|
2170
|
+
writeExpiredHoldBacking(
|
|
2171
|
+
this.store,
|
|
2172
|
+
next,
|
|
2173
|
+
updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
|
|
2174
|
+
);
|
|
1876
2175
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1877
2176
|
this.store.users.set(userId, next);
|
|
1878
2177
|
}
|
|
@@ -1881,18 +2180,34 @@ var InMemoryCreditRepository = class {
|
|
|
1881
2180
|
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1882
2181
|
{ userId, operation: "updateUserTier" }
|
|
1883
2182
|
);
|
|
2183
|
+
assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
|
|
1884
2184
|
const credits = this.store.users.get(userId);
|
|
1885
2185
|
if (!credits) {
|
|
1886
2186
|
throw new Error(`User ${userId} not found`);
|
|
1887
2187
|
}
|
|
2188
|
+
if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
|
|
2189
|
+
const capped = planBalanceCap(
|
|
2190
|
+
credits.balance,
|
|
2191
|
+
input.balanceCap,
|
|
2192
|
+
credits.reserved,
|
|
2193
|
+
credits.bonusCredits,
|
|
2194
|
+
credits.expiredHoldBacking
|
|
2195
|
+
);
|
|
2196
|
+
credits.balance = capped.balance;
|
|
2197
|
+
writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
|
|
2198
|
+
}
|
|
1888
2199
|
credits.tier = input.tier;
|
|
1889
2200
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1890
2201
|
if (input.balance !== void 0) {
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
);
|
|
2202
|
+
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
2203
|
+
credits.balance = planned.balance;
|
|
2204
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1895
2205
|
}
|
|
2206
|
+
writeExpiredHoldBacking(
|
|
2207
|
+
this.store,
|
|
2208
|
+
credits,
|
|
2209
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
2210
|
+
);
|
|
1896
2211
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1897
2212
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
1898
2213
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -2054,6 +2369,11 @@ var InMemoryCreditRepository = class {
|
|
|
2054
2369
|
{ userId, operation: "addCredits" }
|
|
2055
2370
|
);
|
|
2056
2371
|
credits.bonusCredits = nextBonusCredits;
|
|
2372
|
+
writeExpiredHoldBacking(
|
|
2373
|
+
this.store,
|
|
2374
|
+
credits,
|
|
2375
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
2376
|
+
);
|
|
2057
2377
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2058
2378
|
this.store.users.set(userId, credits);
|
|
2059
2379
|
const transaction = this.recordTransaction({
|
|
@@ -2073,6 +2393,7 @@ var InMemoryCreditRepository = class {
|
|
|
2073
2393
|
userId,
|
|
2074
2394
|
entryType: "credit",
|
|
2075
2395
|
amount,
|
|
2396
|
+
balanceBefore: previousTotal,
|
|
2076
2397
|
balanceAfter: newTotal,
|
|
2077
2398
|
source: payload.source,
|
|
2078
2399
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
@@ -2106,19 +2427,23 @@ var InMemoryCreditRepository = class {
|
|
|
2106
2427
|
});
|
|
2107
2428
|
}
|
|
2108
2429
|
}
|
|
2109
|
-
async deductCreditsAtomic(userId, amount) {
|
|
2430
|
+
async deductCreditsAtomic(userId, amount, journal) {
|
|
2110
2431
|
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
2111
2432
|
const credits = this.store.users.get(userId);
|
|
2112
2433
|
if (!credits) {
|
|
2113
2434
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
2114
2435
|
}
|
|
2115
|
-
const
|
|
2436
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2437
|
+
const available = Math.min(
|
|
2438
|
+
sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
|
|
2439
|
+
sumAmounts(spendable, credits.bonusCredits)
|
|
2440
|
+
);
|
|
2116
2441
|
if (available < amount) {
|
|
2117
2442
|
throw new Error(
|
|
2118
2443
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
2119
2444
|
);
|
|
2120
2445
|
}
|
|
2121
|
-
const balanceDeduction = Math.min(
|
|
2446
|
+
const balanceDeduction = Math.min(spendable, amount);
|
|
2122
2447
|
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
2123
2448
|
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2124
2449
|
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
@@ -2128,11 +2453,26 @@ var InMemoryCreditRepository = class {
|
|
|
2128
2453
|
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
2129
2454
|
{ userId, operation: "deductCredits" }
|
|
2130
2455
|
);
|
|
2456
|
+
const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
2131
2457
|
credits.balance = nextBalance;
|
|
2132
2458
|
credits.bonusCredits = nextBonusCredits;
|
|
2459
|
+
writeExpiredHoldBacking(this.store, credits, held);
|
|
2133
2460
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2134
2461
|
this.store.users.set(userId, credits);
|
|
2135
|
-
return { previousBalance, newBalance };
|
|
2462
|
+
if (!journal) return { previousBalance, newBalance };
|
|
2463
|
+
const entry = this.recordJournalEntry({
|
|
2464
|
+
userId,
|
|
2465
|
+
entryType: "debit",
|
|
2466
|
+
amount,
|
|
2467
|
+
balanceBefore: previousBalance,
|
|
2468
|
+
balanceAfter: newBalance,
|
|
2469
|
+
source: journal.source ?? "operation_commit",
|
|
2470
|
+
referenceId: journal.referenceId,
|
|
2471
|
+
referenceType: journal.referenceType ?? "adjustment",
|
|
2472
|
+
description: journal.description,
|
|
2473
|
+
metadata: journal.metadata
|
|
2474
|
+
});
|
|
2475
|
+
return { previousBalance, newBalance, journalEntryId: entry.id };
|
|
2136
2476
|
}
|
|
2137
2477
|
// ==================== Transactions ====================
|
|
2138
2478
|
async createTransaction(input) {
|
|
@@ -2246,6 +2586,11 @@ var InMemoryCreditRepository = class {
|
|
|
2246
2586
|
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
2247
2587
|
userId: input.userId
|
|
2248
2588
|
});
|
|
2589
|
+
if (input.balanceBefore !== void 0) {
|
|
2590
|
+
assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
|
|
2591
|
+
userId: input.userId
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2249
2594
|
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
2250
2595
|
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
2251
2596
|
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
@@ -2261,6 +2606,7 @@ var InMemoryCreditRepository = class {
|
|
|
2261
2606
|
userId: input.userId,
|
|
2262
2607
|
entryType: input.entryType,
|
|
2263
2608
|
amount: input.amount,
|
|
2609
|
+
...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
|
|
2264
2610
|
balanceAfter: input.balanceAfter,
|
|
2265
2611
|
source: input.source,
|
|
2266
2612
|
referenceId: input.referenceId,
|
|
@@ -2355,7 +2701,11 @@ var InMemoryCreditRepository = class {
|
|
|
2355
2701
|
return { expiredCount, creditsReleased, errors };
|
|
2356
2702
|
}
|
|
2357
2703
|
// ==================== Atomic Monthly Reset ====================
|
|
2358
|
-
|
|
2704
|
+
/**
|
|
2705
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
2706
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
2707
|
+
*/
|
|
2708
|
+
async atomicMonthlyReset(userId, _tier, expectedResetAt) {
|
|
2359
2709
|
const credits = this.store.users.get(userId);
|
|
2360
2710
|
if (!credits) {
|
|
2361
2711
|
throw new Error(`User ${userId} not found`);
|
|
@@ -2365,17 +2715,41 @@ var InMemoryCreditRepository = class {
|
|
|
2365
2715
|
if (currentResetAt !== expected) {
|
|
2366
2716
|
return { wasReset: false, credits: { ...credits } };
|
|
2367
2717
|
}
|
|
2368
|
-
|
|
2718
|
+
const tier = credits.tier;
|
|
2719
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2720
|
+
const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
|
|
2721
|
+
if (decision === null) {
|
|
2722
|
+
return { wasReset: false, credits: { ...credits } };
|
|
2723
|
+
}
|
|
2724
|
+
if (decision) {
|
|
2725
|
+
assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
|
|
2726
|
+
if (decision.monthlyLimit !== void 0) {
|
|
2727
|
+
assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
|
|
2728
|
+
userId,
|
|
2729
|
+
tier
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
} else {
|
|
2733
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
2734
|
+
}
|
|
2369
2735
|
const nextReset = getNextMonthlyReset();
|
|
2370
|
-
const target = monthlyResetBalance(tier);
|
|
2371
|
-
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
2372
2736
|
const previousBalance = credits.balance;
|
|
2373
|
-
|
|
2737
|
+
let target;
|
|
2738
|
+
if (decision) {
|
|
2739
|
+
target = decision.balance;
|
|
2740
|
+
} else {
|
|
2741
|
+
const configured = monthlyResetBalance(tier);
|
|
2742
|
+
target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
|
|
2743
|
+
}
|
|
2744
|
+
const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
|
|
2745
|
+
const newBalance = planned.balance;
|
|
2374
2746
|
const change = sumAmounts(newBalance, -previousBalance);
|
|
2375
2747
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2376
2748
|
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
2377
2749
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2378
2750
|
credits.balance = newBalance;
|
|
2751
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2752
|
+
if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
|
|
2379
2753
|
credits.monthlyUsed = 0;
|
|
2380
2754
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
2381
2755
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2385,6 +2759,7 @@ var InMemoryCreditRepository = class {
|
|
|
2385
2759
|
userId,
|
|
2386
2760
|
entryType: change > 0 ? "credit" : "debit",
|
|
2387
2761
|
amount: Math.abs(change),
|
|
2762
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2388
2763
|
balanceAfter,
|
|
2389
2764
|
source: "monthly_reset",
|
|
2390
2765
|
referenceId: `reset-${Date.now()}`,
|
|
@@ -2429,23 +2804,34 @@ var InMemoryCreditRepository = class {
|
|
|
2429
2804
|
};
|
|
2430
2805
|
}
|
|
2431
2806
|
const defaultTier = getDefaultTier();
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
tier: defaultTier
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2807
|
+
let limit;
|
|
2808
|
+
if (this.options.getDowngradeBalanceCap) {
|
|
2809
|
+
limit = this.options.getDowngradeBalanceCap();
|
|
2810
|
+
assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
|
|
2811
|
+
} else {
|
|
2812
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2813
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2814
|
+
userId,
|
|
2815
|
+
tier: defaultTier
|
|
2816
|
+
});
|
|
2817
|
+
limit = storedMonthlyLimit(configured);
|
|
2818
|
+
}
|
|
2438
2819
|
const previousTier = credits.tier;
|
|
2439
2820
|
const previousBalance = credits.balance;
|
|
2440
|
-
const
|
|
2441
|
-
|
|
2442
|
-
|
|
2821
|
+
const planned = planBalanceCap(
|
|
2822
|
+
credits.balance,
|
|
2823
|
+
limit,
|
|
2824
|
+
credits.reserved,
|
|
2825
|
+
credits.bonusCredits,
|
|
2826
|
+
credits.expiredHoldBacking
|
|
2443
2827
|
);
|
|
2828
|
+
const newBalance = planned.balance;
|
|
2444
2829
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2445
2830
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2446
2831
|
credits.tier = defaultTier;
|
|
2447
2832
|
credits.monthlyLimit = limit;
|
|
2448
2833
|
credits.balance = newBalance;
|
|
2834
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2449
2835
|
credits.subscriptionExpiresAt = null;
|
|
2450
2836
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2451
2837
|
this.store.users.set(userId, credits);
|
|
@@ -2454,6 +2840,7 @@ var InMemoryCreditRepository = class {
|
|
|
2454
2840
|
entryType: "debit",
|
|
2455
2841
|
amount: 0,
|
|
2456
2842
|
// No credits deducted, just tier change
|
|
2843
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2457
2844
|
balanceAfter,
|
|
2458
2845
|
source: "subscription_downgrade",
|
|
2459
2846
|
referenceId: `downgrade-${Date.now()}`,
|
|
@@ -2503,8 +2890,8 @@ var InMemoryCreditRepository = class {
|
|
|
2503
2890
|
return copyRecords(Array.from(userReservations.values()));
|
|
2504
2891
|
}
|
|
2505
2892
|
};
|
|
2506
|
-
function createInMemoryCreditRepository() {
|
|
2507
|
-
return new InMemoryCreditRepository();
|
|
2893
|
+
function createInMemoryCreditRepository(options = {}) {
|
|
2894
|
+
return new InMemoryCreditRepository(options);
|
|
2508
2895
|
}
|
|
2509
2896
|
|
|
2510
2897
|
// src/auth/api-key.ts
|
|
@@ -2617,6 +3004,7 @@ var CreditsService = class {
|
|
|
2617
3004
|
entryType: "debit",
|
|
2618
3005
|
amount: 0,
|
|
2619
3006
|
// No credits deducted, just tier change
|
|
3007
|
+
balanceBefore: sumAmounts(data.balance, data.bonusCredits),
|
|
2620
3008
|
// The ledger balance, matching every other journal writer: the V2
|
|
2621
3009
|
// transitions and add/deduct all record `balance + bonusCredits`, so
|
|
2622
3010
|
// recording `balance` alone made the audit trail disagree with itself
|
|
@@ -2660,6 +3048,7 @@ var CreditsService = class {
|
|
|
2660
3048
|
userId,
|
|
2661
3049
|
entryType: balanceChange > 0 ? "credit" : "debit",
|
|
2662
3050
|
amount: Math.abs(balanceChange),
|
|
3051
|
+
balanceBefore: sumAmounts(data.balance, data.bonusCredits),
|
|
2663
3052
|
balanceAfter: sumAmounts(
|
|
2664
3053
|
resetResult.credits.balance,
|
|
2665
3054
|
resetResult.credits.bonusCredits
|
|
@@ -2667,9 +3056,11 @@ var CreditsService = class {
|
|
|
2667
3056
|
source: "monthly_reset",
|
|
2668
3057
|
referenceId: `reset-${Date.now()}`,
|
|
2669
3058
|
referenceType: "reset",
|
|
2670
|
-
|
|
3059
|
+
// The tier the reset ran on, which a concurrent downgrade may have
|
|
3060
|
+
// changed since `data` was read.
|
|
3061
|
+
description: `Monthly credit reset for ${resetResult.credits.tier} tier.`,
|
|
2671
3062
|
metadata: {
|
|
2672
|
-
tier:
|
|
3063
|
+
tier: resetResult.credits.tier,
|
|
2673
3064
|
previousBalance: data.balance,
|
|
2674
3065
|
newBalance: resetResult.credits.balance
|
|
2675
3066
|
}
|
|
@@ -2892,9 +3283,18 @@ var CreditsService = class {
|
|
|
2892
3283
|
*/
|
|
2893
3284
|
async deductCredits(userId, amount, options) {
|
|
2894
3285
|
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
3286
|
+
const operationType = options?.operationType;
|
|
3287
|
+
const journal = {
|
|
3288
|
+
referenceId: options?.requestId ?? `deduct-${Date.now()}`,
|
|
3289
|
+
description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
|
|
3290
|
+
metadata: {
|
|
3291
|
+
...options?.metadata,
|
|
3292
|
+
...operationType && { operationType }
|
|
3293
|
+
}
|
|
3294
|
+
};
|
|
2895
3295
|
let deduction;
|
|
2896
3296
|
try {
|
|
2897
|
-
deduction = await this.repository.deductCreditsAtomic(userId, amount);
|
|
3297
|
+
deduction = await this.repository.deductCreditsAtomic(userId, amount, journal);
|
|
2898
3298
|
} catch (error) {
|
|
2899
3299
|
if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
|
|
2900
3300
|
const credits = await this.repository.getUserCredits(userId);
|
|
@@ -2910,21 +3310,18 @@ var CreditsService = class {
|
|
|
2910
3310
|
shortfall: sumAmounts(amount, -available)
|
|
2911
3311
|
};
|
|
2912
3312
|
}
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
...operationType && { operationType }
|
|
2926
|
-
}
|
|
2927
|
-
});
|
|
3313
|
+
if (deduction.journalEntryId === void 0) {
|
|
3314
|
+
await this.repository.createJournalEntry({
|
|
3315
|
+
userId,
|
|
3316
|
+
entryType: "debit",
|
|
3317
|
+
amount,
|
|
3318
|
+
balanceBefore: deduction.previousBalance,
|
|
3319
|
+
balanceAfter: deduction.newBalance,
|
|
3320
|
+
source: "operation_commit",
|
|
3321
|
+
referenceType: "adjustment",
|
|
3322
|
+
...journal
|
|
3323
|
+
});
|
|
3324
|
+
}
|
|
2928
3325
|
if (operationType) {
|
|
2929
3326
|
await this.logUsage({
|
|
2930
3327
|
userId,
|
|
@@ -3935,6 +4332,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
3935
4332
|
CreditsSDKError,
|
|
3936
4333
|
CreditsService,
|
|
3937
4334
|
InMemoryCreditRepository,
|
|
4335
|
+
NO_EXPIRED_BACKING_PREFIX,
|
|
3938
4336
|
NetworkError,
|
|
3939
4337
|
RESERVATION_STATUSES,
|
|
3940
4338
|
RESERVED_JOURNAL_KEY_PREFIX,
|
|
@@ -3947,6 +4345,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
3947
4345
|
UNLIMITED_BALANCE_SENTINEL,
|
|
3948
4346
|
ValidationError,
|
|
3949
4347
|
addCreditsThroughRepository,
|
|
4348
|
+
allocateExpiredBacking,
|
|
3950
4349
|
assertDirectStatusWriteAllowed,
|
|
3951
4350
|
assertHoldPlaced,
|
|
3952
4351
|
assertKnownReservationStatus,
|
|
@@ -3956,12 +4355,17 @@ function createDummyReservation2(userId, operationType) {
|
|
|
3956
4355
|
assertRepresentableTierAmount,
|
|
3957
4356
|
assertUnkeyedDirectReservation,
|
|
3958
4357
|
assertUnreferencedDirectTransaction,
|
|
4358
|
+
assertValidBalanceTarget,
|
|
4359
|
+
assertValidBalanceUpdate,
|
|
3959
4360
|
assertValidCreditAmount,
|
|
3960
4361
|
assertValidIdempotencyKey,
|
|
3961
4362
|
assertValidStoredAmount,
|
|
3962
4363
|
assertValidStoredAmountRaw,
|
|
4364
|
+
assertValidTierBalanceInput,
|
|
3963
4365
|
backedBalanceFloor,
|
|
3964
4366
|
calculateAvailableCredits,
|
|
4367
|
+
canonicalAmount,
|
|
4368
|
+
cappedBalance,
|
|
3965
4369
|
checkAndNotifyLowBalance,
|
|
3966
4370
|
classifyDatabaseError,
|
|
3967
4371
|
clearConfigProvider,
|
|
@@ -3987,6 +4391,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
3987
4391
|
createUnsupportedOperationError,
|
|
3988
4392
|
createUserNotFoundError,
|
|
3989
4393
|
describePaymentMismatch,
|
|
4394
|
+
expiredBackingPatch,
|
|
3990
4395
|
generateId,
|
|
3991
4396
|
generateRequestId,
|
|
3992
4397
|
genericDeferred,
|
|
@@ -4012,6 +4417,9 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4012
4417
|
getValidOperationTypes,
|
|
4013
4418
|
getValidTiers,
|
|
4014
4419
|
hasPlacedHold,
|
|
4420
|
+
heldExpiredBacking,
|
|
4421
|
+
holdExpiredBacking,
|
|
4422
|
+
holdPlacedAtAfter,
|
|
4015
4423
|
initializeConfig,
|
|
4016
4424
|
isCreditError,
|
|
4017
4425
|
isCreditErrorMessage,
|
|
@@ -4037,6 +4445,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4037
4445
|
isWinningOutcome,
|
|
4038
4446
|
loadConfigFromEnv,
|
|
4039
4447
|
monthlyResetBalance,
|
|
4448
|
+
multiplyAmount,
|
|
4040
4449
|
noopDeferred,
|
|
4041
4450
|
normalizePaymentRef,
|
|
4042
4451
|
notifySubscriptionExpired,
|
|
@@ -4045,6 +4454,10 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4045
4454
|
parseApiError,
|
|
4046
4455
|
parseCreditError,
|
|
4047
4456
|
parseTier,
|
|
4457
|
+
planBalanceCap,
|
|
4458
|
+
planBalanceTarget,
|
|
4459
|
+
planHoldCommit,
|
|
4460
|
+
recordedExpiredBacking,
|
|
4048
4461
|
registerConfigProvider,
|
|
4049
4462
|
registerNotificationHandler,
|
|
4050
4463
|
releaseReservationWithJournal,
|
|
@@ -4054,6 +4467,10 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4054
4467
|
reserveThroughRepository,
|
|
4055
4468
|
resetConfig,
|
|
4056
4469
|
sameAmount,
|
|
4470
|
+
settleExpiredHoldBacking,
|
|
4471
|
+
spendableBalance,
|
|
4472
|
+
storedAmountCeil,
|
|
4473
|
+
storedAmountFloor,
|
|
4057
4474
|
storedMonthlyLimit,
|
|
4058
4475
|
sumAmounts,
|
|
4059
4476
|
supportsCreditsV2,
|
|
@@ -4065,6 +4482,7 @@ function createDummyReservation2(userId, operationType) {
|
|
|
4065
4482
|
toClientUserCredits,
|
|
4066
4483
|
toDate,
|
|
4067
4484
|
toPortableTimestamp,
|
|
4485
|
+
validateBalanceUpdate,
|
|
4068
4486
|
verifyAdminApiKey,
|
|
4069
4487
|
verifyBearerToken
|
|
4070
4488
|
});
|