@nehorai/credits 2.2.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/dist/adapters/index.cjs +1 -0
- 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 +1 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/core/index.cjs +139 -23
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +113 -22
- package/dist/core/index.d.ts +113 -22
- package/dist/core/index.js +128 -21
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +246 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +235 -59
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +183 -41
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +6 -5
- package/dist/repository/index.d.ts +6 -5
- package/dist/repository/index.js +183 -41
- 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 +25 -16
- 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 +25 -16
- package/dist/service/index.js.map +1 -1
- package/dist/{types-MaViK5tD.d.cts → types-C3sxDvNb.d.cts} +29 -6
- package/dist/{types-MaViK5tD.d.ts → types-C3sxDvNb.d.ts} +29 -6
- package/dist/{types-3c-IAwZI.d.ts → types-CY-oHIMA.d.ts} +19 -3
- package/dist/{types-XSjNXO5l.d.cts → types-DOjguhEX.d.cts} +19 -3
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, m as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, n as ReserveCreditsV2Input, o as ReserveOutcome, g as CreditBalanceUpdate, h as CreditPolicyOptions, p as TierUpdateInput, d as CreateReservationInput, j as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-
|
|
2
|
-
export { i as CreditRepositoryFactory, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-
|
|
3
|
-
import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-
|
|
1
|
+
import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, m as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, n as ReserveCreditsV2Input, o as ReserveOutcome, g as CreditBalanceUpdate, h as CreditPolicyOptions, p as TierUpdateInput, d as CreateReservationInput, j as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, D as DeductJournalInput, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-DOjguhEX.cjs';
|
|
2
|
+
export { i as CreditRepositoryFactory, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-DOjguhEX.cjs';
|
|
3
|
+
import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-C3sxDvNb.cjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* One reservation flow for both repository generations.
|
|
@@ -181,9 +181,10 @@ declare class InMemoryCreditRepository implements ICreditRepository {
|
|
|
181
181
|
* that *is* the idempotent no-op the caller asked for.
|
|
182
182
|
*/
|
|
183
183
|
addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
|
|
184
|
-
deductCreditsAtomic(userId: string, amount: number): Promise<{
|
|
184
|
+
deductCreditsAtomic(userId: string, amount: number, journal?: DeductJournalInput): Promise<{
|
|
185
185
|
previousBalance: number;
|
|
186
186
|
newBalance: number;
|
|
187
|
+
journalEntryId?: string;
|
|
187
188
|
}>;
|
|
188
189
|
createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
|
|
189
190
|
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
@@ -251,4 +252,4 @@ declare class InMemoryCreditRepository implements ICreditRepository {
|
|
|
251
252
|
*/
|
|
252
253
|
declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
|
|
253
254
|
|
|
254
|
-
export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
|
|
255
|
+
export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, DeductJournalInput, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, m as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, n as ReserveCreditsV2Input, o as ReserveOutcome, g as CreditBalanceUpdate, h as CreditPolicyOptions, p as TierUpdateInput, d as CreateReservationInput, j as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-
|
|
2
|
-
export { i as CreditRepositoryFactory, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-
|
|
3
|
-
import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-
|
|
1
|
+
import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome, m as ReservationTransitionOptions, C as CommitOutcome, R as ReleaseOutcome, n as ReserveCreditsV2Input, o as ReserveOutcome, g as CreditBalanceUpdate, h as CreditPolicyOptions, p as TierUpdateInput, d as CreateReservationInput, j as ExpireReservationV2Options, E as ExpireOutcome, A as AddCreditsAtomicOptions, D as DeductJournalInput, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-CY-oHIMA.js';
|
|
2
|
+
export { i as CreditRepositoryFactory, k as ICreditRepositoryCreditsV2, l as ICreditRepositoryV2, t as reservationJournalKey, u as supportsCreditsV2, v as supportsIdempotentCredit, w as toClientUserCredits } from '../types-CY-oHIMA.js';
|
|
3
|
+
import { c as PortableUserCredits, S as SubscriptionTier, a as PortableReservation, R as ReservationStatus, P as PortableTransaction, d as PortableUsageLog, f as PortableJournalEntry, M as MonthlyResetResult, e as SubscriptionExpiryResult } from '../types-C3sxDvNb.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* One reservation flow for both repository generations.
|
|
@@ -181,9 +181,10 @@ declare class InMemoryCreditRepository implements ICreditRepository {
|
|
|
181
181
|
* that *is* the idempotent no-op the caller asked for.
|
|
182
182
|
*/
|
|
183
183
|
addCreditsAtomic(userId: string, amount: number, description: string, paymentRef?: string, options?: AddCreditsAtomicOptions): Promise<void>;
|
|
184
|
-
deductCreditsAtomic(userId: string, amount: number): Promise<{
|
|
184
|
+
deductCreditsAtomic(userId: string, amount: number, journal?: DeductJournalInput): Promise<{
|
|
185
185
|
previousBalance: number;
|
|
186
186
|
newBalance: number;
|
|
187
|
+
journalEntryId?: string;
|
|
187
188
|
}>;
|
|
188
189
|
createTransaction(input: CreateTransactionInput): Promise<PortableTransaction>;
|
|
189
190
|
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
@@ -251,4 +252,4 @@ declare class InMemoryCreditRepository implements ICreditRepository {
|
|
|
251
252
|
*/
|
|
252
253
|
declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
|
|
253
254
|
|
|
254
|
-
export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
|
|
255
|
+
export { AddCreditsAtomicOptions, AddCreditsV2Input, type BalanceValidationResult, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, DeductJournalInput, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, assertValidBalanceUpdate, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate, validateBalanceUpdate };
|
package/dist/repository/index.js
CHANGED
|
@@ -56,6 +56,23 @@ function floatSum(values) {
|
|
|
56
56
|
for (const value of values) total += value;
|
|
57
57
|
return total;
|
|
58
58
|
}
|
|
59
|
+
var FLOAT_NOISE_FLOOR = 1e-6;
|
|
60
|
+
var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
|
|
61
|
+
function floatNoise(value) {
|
|
62
|
+
return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
|
|
63
|
+
}
|
|
64
|
+
function canonicalAmount(value) {
|
|
65
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return value;
|
|
66
|
+
const cents = Math.round(value * 100);
|
|
67
|
+
if (!Number.isSafeInteger(cents)) return value;
|
|
68
|
+
const onGrid = cents / 100 + 0;
|
|
69
|
+
return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
|
|
70
|
+
}
|
|
71
|
+
function storedAmountCeil(value) {
|
|
72
|
+
const canonical = canonicalAmount(value);
|
|
73
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
74
|
+
return Math.ceil(canonical * 100) / 100;
|
|
75
|
+
}
|
|
59
76
|
function isValidCreditAmount(value) {
|
|
60
77
|
if (typeof value !== "number") return false;
|
|
61
78
|
const cents = toCents(value);
|
|
@@ -108,14 +125,19 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
108
125
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
109
126
|
return floor;
|
|
110
127
|
}
|
|
111
|
-
function
|
|
112
|
-
|
|
128
|
+
function heldExpiredBacking(balance, stored) {
|
|
129
|
+
if (!(stored !== void 0 && stored > 0)) return 0;
|
|
130
|
+
return Math.max(0, Math.min(stored, balance));
|
|
131
|
+
}
|
|
132
|
+
function spendableBalance(balance, expiredHoldBacking) {
|
|
133
|
+
return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
|
|
113
134
|
}
|
|
114
135
|
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
136
|
+
assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
|
|
115
137
|
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
116
138
|
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
117
139
|
}
|
|
118
|
-
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
|
|
140
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
|
|
119
141
|
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
120
142
|
const target = Math.min(spendable, cap);
|
|
121
143
|
const kept = Math.max(
|
|
@@ -126,8 +148,8 @@ function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking
|
|
|
126
148
|
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
127
149
|
}
|
|
128
150
|
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
129
|
-
const held =
|
|
130
|
-
const used = Math.min(held, holdBacking);
|
|
151
|
+
const held = heldExpiredBacking(balance, expiredHoldBacking);
|
|
152
|
+
const used = Math.max(0, Math.min(held, holdBacking));
|
|
131
153
|
return { used, remaining: sumAmounts(held, -used) };
|
|
132
154
|
}
|
|
133
155
|
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
@@ -142,27 +164,83 @@ function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking,
|
|
|
142
164
|
expiredHoldBacking: backing.remaining
|
|
143
165
|
};
|
|
144
166
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
167
|
+
var NO_EXPIRED_BACKING_PREFIX = {
|
|
168
|
+
holdPlacedAt: "",
|
|
169
|
+
id: "",
|
|
170
|
+
share: 0
|
|
171
|
+
};
|
|
172
|
+
function compareHolds(a, b) {
|
|
173
|
+
if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
|
|
174
|
+
if (a.id === b.id) return 0;
|
|
175
|
+
return a.id < b.id ? -1 : 1;
|
|
176
|
+
}
|
|
177
|
+
function recordedExpiredBacking(hold) {
|
|
178
|
+
const stored = hold.expiredBacking;
|
|
179
|
+
if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
|
|
180
|
+
if (!isValidCreditAmount(hold.amount)) return 0;
|
|
181
|
+
return Math.min(storedAmountCeil(stored), hold.amount);
|
|
182
|
+
}
|
|
183
|
+
function allocateExpiredBacking(holds, total) {
|
|
184
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
|
|
185
|
+
const wanted = Math.max(0, total);
|
|
186
|
+
if (wanted === 0) return { total: 0, frontier: null };
|
|
187
|
+
const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
|
|
188
|
+
const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
|
|
189
|
+
let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
|
|
190
|
+
for (const hold of [...recorded].reverse()) {
|
|
191
|
+
if (left >= 0) break;
|
|
192
|
+
const share = shares.get(hold.id);
|
|
193
|
+
const cut = Math.min(share, -left);
|
|
194
|
+
shares.set(hold.id, sumAmounts(share, -cut));
|
|
195
|
+
left = sumAmounts(left, cut);
|
|
155
196
|
}
|
|
197
|
+
let frontier = NO_EXPIRED_BACKING_PREFIX;
|
|
156
198
|
for (const hold of placed) {
|
|
199
|
+
if (left <= 0) break;
|
|
200
|
+
if (shares.has(hold.id)) continue;
|
|
201
|
+
const share = Math.min(hold.amount, left);
|
|
202
|
+
frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
|
|
203
|
+
left = sumAmounts(left, -share);
|
|
204
|
+
}
|
|
205
|
+
for (const hold of recorded) {
|
|
206
|
+
if (left <= 0) break;
|
|
157
207
|
const share = shares.get(hold.id);
|
|
158
208
|
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
159
209
|
shares.set(hold.id, sumAmounts(share, added));
|
|
160
210
|
left = sumAmounts(left, -added);
|
|
161
211
|
}
|
|
162
|
-
|
|
212
|
+
const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
|
|
213
|
+
return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
|
|
163
214
|
}
|
|
164
|
-
function
|
|
165
|
-
|
|
215
|
+
function holdExpiredBacking(hold, frontier, rewrites) {
|
|
216
|
+
const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
|
|
217
|
+
const recorded = recordedExpiredBacking(
|
|
218
|
+
rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
|
|
219
|
+
);
|
|
220
|
+
if (recorded > 0) return recorded;
|
|
221
|
+
if (!frontier || !hold.holdPlacedAt) return 0;
|
|
222
|
+
const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
|
|
223
|
+
if (order > 0) return 0;
|
|
224
|
+
return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
|
|
225
|
+
}
|
|
226
|
+
function holdPlacedAtAfter(now, frontier) {
|
|
227
|
+
if (!frontier?.holdPlacedAt) return now;
|
|
228
|
+
const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
|
|
229
|
+
return now > earliest ? now : earliest;
|
|
230
|
+
}
|
|
231
|
+
function expiredBackingPatch(next, stored) {
|
|
232
|
+
const total = Math.max(0, next.total);
|
|
233
|
+
const patch = {};
|
|
234
|
+
const storedTotal = stored.expiredHoldBacking;
|
|
235
|
+
if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
|
|
236
|
+
patch.expiredHoldBacking = total;
|
|
237
|
+
}
|
|
238
|
+
if (total <= 0) {
|
|
239
|
+
if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
|
|
240
|
+
} else if (next.frontier) {
|
|
241
|
+
patch.expiredBackingFrontier = next.frontier;
|
|
242
|
+
}
|
|
243
|
+
return patch;
|
|
166
244
|
}
|
|
167
245
|
function assertValidBalanceTarget(value, field, context) {
|
|
168
246
|
assertRepresentableAmount(value, field, context);
|
|
@@ -175,7 +253,10 @@ function assertValidBalanceTarget(value, field, context) {
|
|
|
175
253
|
}
|
|
176
254
|
}
|
|
177
255
|
function assertValidTierBalanceInput(input, context) {
|
|
178
|
-
if (input.balanceCap === void 0)
|
|
256
|
+
if (input.balanceCap === void 0) {
|
|
257
|
+
if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
179
260
|
if (input.balance !== void 0) {
|
|
180
261
|
throw new CreditError(
|
|
181
262
|
"A tier update cannot set both balance and balanceCap",
|
|
@@ -847,7 +928,7 @@ function validateBalanceUpdate(current, updates) {
|
|
|
847
928
|
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
848
929
|
const errors = [];
|
|
849
930
|
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
850
|
-
const marked = updates.balance === void 0 ? current.expiredHoldBacking
|
|
931
|
+
const marked = updates.balance === void 0 ? heldExpiredBacking(current.balance, current.expiredHoldBacking) : 0;
|
|
851
932
|
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
852
933
|
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
853
934
|
}
|
|
@@ -1100,6 +1181,7 @@ function commitTransitionJournal(store, input, existingId) {
|
|
|
1100
1181
|
userId: input.userId,
|
|
1101
1182
|
entryType: input.entryType,
|
|
1102
1183
|
amount: input.amount,
|
|
1184
|
+
balanceBefore: input.balanceBefore,
|
|
1103
1185
|
balanceAfter: input.balanceAfter,
|
|
1104
1186
|
source: input.source,
|
|
1105
1187
|
referenceId: input.reservationId,
|
|
@@ -1149,16 +1231,28 @@ function openHolds(store, userId) {
|
|
|
1149
1231
|
(reservation) => reservation.status === "reserved"
|
|
1150
1232
|
);
|
|
1151
1233
|
}
|
|
1234
|
+
function expiredBackingInForce(store, credits) {
|
|
1235
|
+
const total = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1236
|
+
if (total > 0 && (!credits.expiredBackingFrontier || credits.expiredHoldBacking !== total)) {
|
|
1237
|
+
return allocateExpiredBacking(openHolds(store, credits.userId), total);
|
|
1238
|
+
}
|
|
1239
|
+
return { total, frontier: credits.expiredBackingFrontier ?? null };
|
|
1240
|
+
}
|
|
1241
|
+
function writeRecordedShares(store, userId, allocation) {
|
|
1242
|
+
for (const { id, expiredBacking } of allocation.rewrites ?? []) {
|
|
1243
|
+
const hold = getReservation(store, userId, id);
|
|
1244
|
+
if (hold) hold.expiredBacking = expiredBacking;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1152
1247
|
function writeExpiredHoldBacking(store, credits, next) {
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const shares = attributeExpiredBacking(holds, next);
|
|
1157
|
-
for (const hold of holds) {
|
|
1158
|
-
const share = shares.get(hold.id) ?? 0;
|
|
1159
|
-
if (share !== (hold.expiredBacking ?? 0)) hold.expiredBacking = share;
|
|
1248
|
+
if (next <= 0) {
|
|
1249
|
+
Object.assign(credits, expiredBackingPatch({ total: 0 }, credits));
|
|
1250
|
+
return;
|
|
1160
1251
|
}
|
|
1161
|
-
|
|
1252
|
+
if (next === credits.expiredHoldBacking) return;
|
|
1253
|
+
const allocation = allocateExpiredBacking(openHolds(store, credits.userId), next);
|
|
1254
|
+
writeRecordedShares(store, credits.userId, allocation);
|
|
1255
|
+
Object.assign(credits, expiredBackingPatch(allocation, credits));
|
|
1162
1256
|
}
|
|
1163
1257
|
function getReservation(store, userId, reservationId) {
|
|
1164
1258
|
return store.reservations.get(userId)?.get(reservationId);
|
|
@@ -1207,6 +1301,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1207
1301
|
};
|
|
1208
1302
|
}
|
|
1209
1303
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1304
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1210
1305
|
const reservation = {
|
|
1211
1306
|
id: generateId(),
|
|
1212
1307
|
userId: input.userId,
|
|
@@ -1219,7 +1314,7 @@ async function reserveCreditsV2(store, input) {
|
|
|
1219
1314
|
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1220
1315
|
// critical section that raises `reserved` below, so the row and the hold
|
|
1221
1316
|
// become visible together. `createReservation` cannot set it.
|
|
1222
|
-
holdPlacedAt: now
|
|
1317
|
+
holdPlacedAt: holdPlacedAtAfter(now, backing.total > 0 ? backing.frontier : null)
|
|
1223
1318
|
};
|
|
1224
1319
|
if (!store.reservations.has(input.userId)) {
|
|
1225
1320
|
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
@@ -1237,6 +1332,8 @@ async function reserveCreditsV2(store, input) {
|
|
|
1237
1332
|
);
|
|
1238
1333
|
}
|
|
1239
1334
|
credits.reserved = nextReserved;
|
|
1335
|
+
writeRecordedShares(store, input.userId, backing);
|
|
1336
|
+
Object.assign(credits, expiredBackingPatch(backing, credits));
|
|
1240
1337
|
credits.updatedAt = now;
|
|
1241
1338
|
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1242
1339
|
});
|
|
@@ -1257,11 +1354,12 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1257
1354
|
const amount = reservation.amount;
|
|
1258
1355
|
await yieldPoint(store);
|
|
1259
1356
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1357
|
+
const backing = expiredBackingInForce(store, credits);
|
|
1260
1358
|
const paid = planHoldCommit(
|
|
1261
1359
|
credits.balance,
|
|
1262
1360
|
credits.bonusCredits,
|
|
1263
|
-
|
|
1264
|
-
reservation.
|
|
1361
|
+
backing.total,
|
|
1362
|
+
holdExpiredBacking(reservation, backing.frontier, backing.rewrites),
|
|
1265
1363
|
amount
|
|
1266
1364
|
);
|
|
1267
1365
|
if (!paid.covered) {
|
|
@@ -1279,6 +1377,7 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1279
1377
|
operation: "commitReservation",
|
|
1280
1378
|
entryType: "debit",
|
|
1281
1379
|
amount,
|
|
1380
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1282
1381
|
balanceAfter,
|
|
1283
1382
|
source: "operation_commit",
|
|
1284
1383
|
reservationId,
|
|
@@ -1306,9 +1405,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1306
1405
|
credits.bonusCredits = nextBonusCredits;
|
|
1307
1406
|
credits.reserved = nextReserved;
|
|
1308
1407
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1408
|
+
writeRecordedShares(store, userId, backing);
|
|
1309
1409
|
Object.assign(
|
|
1310
1410
|
credits,
|
|
1311
|
-
|
|
1411
|
+
expiredBackingPatch({ total: paid.expiredHoldBacking, frontier: backing.frontier }, credits)
|
|
1312
1412
|
);
|
|
1313
1413
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1314
1414
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
@@ -1389,10 +1489,11 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1389
1489
|
reservation.id,
|
|
1390
1490
|
reservation.amount
|
|
1391
1491
|
);
|
|
1492
|
+
const inForce = expiredBackingInForce(store, credits);
|
|
1392
1493
|
const backing = settleExpiredHoldBacking(
|
|
1393
1494
|
credits.balance,
|
|
1394
|
-
|
|
1395
|
-
reservation.
|
|
1495
|
+
inForce.total,
|
|
1496
|
+
holdExpiredBacking(reservation, inForce.frontier, inForce.rewrites)
|
|
1396
1497
|
);
|
|
1397
1498
|
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1398
1499
|
const journal = {
|
|
@@ -1400,6 +1501,7 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1400
1501
|
operation: `${transition.transition}Reservation`,
|
|
1401
1502
|
entryType: "credit",
|
|
1402
1503
|
amount: 0,
|
|
1504
|
+
balanceBefore: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1403
1505
|
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1404
1506
|
source: transition.source,
|
|
1405
1507
|
reservationId: reservation.id,
|
|
@@ -1422,7 +1524,11 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1422
1524
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1423
1525
|
credits.reserved = nextReserved;
|
|
1424
1526
|
credits.balance = nextBalance;
|
|
1425
|
-
|
|
1527
|
+
writeRecordedShares(store, reservation.userId, inForce);
|
|
1528
|
+
Object.assign(
|
|
1529
|
+
credits,
|
|
1530
|
+
expiredBackingPatch({ total: backing.remaining, frontier: inForce.frontier }, credits)
|
|
1531
|
+
);
|
|
1426
1532
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1427
1533
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1428
1534
|
}
|
|
@@ -1465,7 +1571,7 @@ var InMemoryCreditRepository = class {
|
|
|
1465
1571
|
* simultaneous first deliveries of the same reference would then both credit.
|
|
1466
1572
|
*/
|
|
1467
1573
|
seedUser(userId, tier, initialBalance) {
|
|
1468
|
-
|
|
1574
|
+
assertValidBalanceTarget(initialBalance, "initialBalance", { userId });
|
|
1469
1575
|
const configured = getConfigMonthlyLimit(tier);
|
|
1470
1576
|
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1471
1577
|
const monthlyLimit = storedMonthlyLimit(configured);
|
|
@@ -1546,9 +1652,11 @@ var InMemoryCreditRepository = class {
|
|
|
1546
1652
|
userId,
|
|
1547
1653
|
operation: "updateUserCredits"
|
|
1548
1654
|
});
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1655
|
+
writeExpiredHoldBacking(
|
|
1656
|
+
this.store,
|
|
1657
|
+
next,
|
|
1658
|
+
updates.balance !== void 0 ? 0 : Math.min(heldExpiredBacking(credits.balance, credits.expiredHoldBacking), next.balance)
|
|
1659
|
+
);
|
|
1552
1660
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1553
1661
|
this.store.users.set(userId, next);
|
|
1554
1662
|
}
|
|
@@ -1580,6 +1688,11 @@ var InMemoryCreditRepository = class {
|
|
|
1580
1688
|
credits.balance = planned.balance;
|
|
1581
1689
|
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1582
1690
|
}
|
|
1691
|
+
writeExpiredHoldBacking(
|
|
1692
|
+
this.store,
|
|
1693
|
+
credits,
|
|
1694
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
1695
|
+
);
|
|
1583
1696
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1584
1697
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
1585
1698
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -1741,6 +1854,11 @@ var InMemoryCreditRepository = class {
|
|
|
1741
1854
|
{ userId, operation: "addCredits" }
|
|
1742
1855
|
);
|
|
1743
1856
|
credits.bonusCredits = nextBonusCredits;
|
|
1857
|
+
writeExpiredHoldBacking(
|
|
1858
|
+
this.store,
|
|
1859
|
+
credits,
|
|
1860
|
+
heldExpiredBacking(credits.balance, credits.expiredHoldBacking)
|
|
1861
|
+
);
|
|
1744
1862
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1745
1863
|
this.store.users.set(userId, credits);
|
|
1746
1864
|
const transaction = this.recordTransaction({
|
|
@@ -1760,6 +1878,7 @@ var InMemoryCreditRepository = class {
|
|
|
1760
1878
|
userId,
|
|
1761
1879
|
entryType: "credit",
|
|
1762
1880
|
amount,
|
|
1881
|
+
balanceBefore: previousTotal,
|
|
1763
1882
|
balanceAfter: newTotal,
|
|
1764
1883
|
source: payload.source,
|
|
1765
1884
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
@@ -1793,7 +1912,7 @@ var InMemoryCreditRepository = class {
|
|
|
1793
1912
|
});
|
|
1794
1913
|
}
|
|
1795
1914
|
}
|
|
1796
|
-
async deductCreditsAtomic(userId, amount) {
|
|
1915
|
+
async deductCreditsAtomic(userId, amount, journal) {
|
|
1797
1916
|
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1798
1917
|
const credits = this.store.users.get(userId);
|
|
1799
1918
|
if (!credits) {
|
|
@@ -1819,11 +1938,26 @@ var InMemoryCreditRepository = class {
|
|
|
1819
1938
|
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
1820
1939
|
{ userId, operation: "deductCredits" }
|
|
1821
1940
|
);
|
|
1941
|
+
const held = heldExpiredBacking(credits.balance, credits.expiredHoldBacking);
|
|
1822
1942
|
credits.balance = nextBalance;
|
|
1823
1943
|
credits.bonusCredits = nextBonusCredits;
|
|
1944
|
+
writeExpiredHoldBacking(this.store, credits, held);
|
|
1824
1945
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1825
1946
|
this.store.users.set(userId, credits);
|
|
1826
|
-
return { previousBalance, newBalance };
|
|
1947
|
+
if (!journal) return { previousBalance, newBalance };
|
|
1948
|
+
const entry = this.recordJournalEntry({
|
|
1949
|
+
userId,
|
|
1950
|
+
entryType: "debit",
|
|
1951
|
+
amount,
|
|
1952
|
+
balanceBefore: previousBalance,
|
|
1953
|
+
balanceAfter: newBalance,
|
|
1954
|
+
source: journal.source ?? "operation_commit",
|
|
1955
|
+
referenceId: journal.referenceId,
|
|
1956
|
+
referenceType: journal.referenceType ?? "adjustment",
|
|
1957
|
+
description: journal.description,
|
|
1958
|
+
metadata: journal.metadata
|
|
1959
|
+
});
|
|
1960
|
+
return { previousBalance, newBalance, journalEntryId: entry.id };
|
|
1827
1961
|
}
|
|
1828
1962
|
// ==================== Transactions ====================
|
|
1829
1963
|
async createTransaction(input) {
|
|
@@ -1937,6 +2071,11 @@ var InMemoryCreditRepository = class {
|
|
|
1937
2071
|
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
1938
2072
|
userId: input.userId
|
|
1939
2073
|
});
|
|
2074
|
+
if (input.balanceBefore !== void 0) {
|
|
2075
|
+
assertRepresentableAmount(input.balanceBefore, "journal balanceBefore", {
|
|
2076
|
+
userId: input.userId
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
1940
2079
|
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1941
2080
|
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
1942
2081
|
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
@@ -1952,6 +2091,7 @@ var InMemoryCreditRepository = class {
|
|
|
1952
2091
|
userId: input.userId,
|
|
1953
2092
|
entryType: input.entryType,
|
|
1954
2093
|
amount: input.amount,
|
|
2094
|
+
...input.balanceBefore !== void 0 ? { balanceBefore: input.balanceBefore } : {},
|
|
1955
2095
|
balanceAfter: input.balanceAfter,
|
|
1956
2096
|
source: input.source,
|
|
1957
2097
|
referenceId: input.referenceId,
|
|
@@ -2104,6 +2244,7 @@ var InMemoryCreditRepository = class {
|
|
|
2104
2244
|
userId,
|
|
2105
2245
|
entryType: change > 0 ? "credit" : "debit",
|
|
2106
2246
|
amount: Math.abs(change),
|
|
2247
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2107
2248
|
balanceAfter,
|
|
2108
2249
|
source: "monthly_reset",
|
|
2109
2250
|
referenceId: `reset-${Date.now()}`,
|
|
@@ -2184,6 +2325,7 @@ var InMemoryCreditRepository = class {
|
|
|
2184
2325
|
entryType: "debit",
|
|
2185
2326
|
amount: 0,
|
|
2186
2327
|
// No credits deducted, just tier change
|
|
2328
|
+
balanceBefore: sumAmounts(previousBalance, credits.bonusCredits),
|
|
2187
2329
|
balanceAfter,
|
|
2188
2330
|
source: "subscription_downgrade",
|
|
2189
2331
|
referenceId: `downgrade-${Date.now()}`,
|