@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/core/index.js
CHANGED
|
@@ -167,6 +167,40 @@ function floatSum(values) {
|
|
|
167
167
|
for (const value of values) total += value;
|
|
168
168
|
return total;
|
|
169
169
|
}
|
|
170
|
+
function multiplyAmount(unit, quantity) {
|
|
171
|
+
const cents = toCents(unit);
|
|
172
|
+
const product = cents === null ? Number.NaN : cents * quantity;
|
|
173
|
+
if (cents === null || cents < 0 || !Number.isSafeInteger(quantity) || quantity < 0 || !Number.isSafeInteger(product) || product > MAX_CENTS) {
|
|
174
|
+
throw new CreditError(
|
|
175
|
+
`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}`,
|
|
176
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
177
|
+
{ unit, quantity, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE }
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return product / 100;
|
|
181
|
+
}
|
|
182
|
+
var FLOAT_NOISE_FLOOR = 1e-6;
|
|
183
|
+
var FLOAT_NOISE_RELATIVE = 64 * Number.EPSILON;
|
|
184
|
+
function floatNoise(value) {
|
|
185
|
+
return Math.max(FLOAT_NOISE_FLOOR, Math.abs(value) * FLOAT_NOISE_RELATIVE);
|
|
186
|
+
}
|
|
187
|
+
function canonicalAmount(value) {
|
|
188
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return value;
|
|
189
|
+
const cents = Math.round(value * 100);
|
|
190
|
+
if (!Number.isSafeInteger(cents)) return value;
|
|
191
|
+
const onGrid = cents / 100 + 0;
|
|
192
|
+
return Math.abs(value - onGrid) < floatNoise(value) ? onGrid : value;
|
|
193
|
+
}
|
|
194
|
+
function storedAmountFloor(value) {
|
|
195
|
+
const canonical = canonicalAmount(value);
|
|
196
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
197
|
+
return Math.floor(canonical * 100) / 100;
|
|
198
|
+
}
|
|
199
|
+
function storedAmountCeil(value) {
|
|
200
|
+
const canonical = canonicalAmount(value);
|
|
201
|
+
if (!Number.isFinite(canonical) || toCents(canonical) !== null) return canonical;
|
|
202
|
+
return Math.ceil(canonical * 100) / 100;
|
|
203
|
+
}
|
|
170
204
|
function isValidCreditAmount(value) {
|
|
171
205
|
if (typeof value !== "number") return false;
|
|
172
206
|
const cents = toCents(value);
|
|
@@ -219,6 +253,150 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
219
253
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
220
254
|
return floor;
|
|
221
255
|
}
|
|
256
|
+
function cappedBalance(balance, cap, reserved, bonusCredits) {
|
|
257
|
+
return Math.min(balance, Math.max(cap, backedBalanceFloor(reserved, bonusCredits)));
|
|
258
|
+
}
|
|
259
|
+
function heldExpiredBacking(balance, stored) {
|
|
260
|
+
if (!(stored !== void 0 && stored > 0)) return 0;
|
|
261
|
+
return Math.max(0, Math.min(stored, balance));
|
|
262
|
+
}
|
|
263
|
+
function spendableBalance(balance, expiredHoldBacking) {
|
|
264
|
+
return sumAmounts(balance, -heldExpiredBacking(balance, expiredHoldBacking));
|
|
265
|
+
}
|
|
266
|
+
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
267
|
+
assertValidBalanceTarget(target, "balance target", { reserved, bonusCredits });
|
|
268
|
+
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
269
|
+
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
270
|
+
}
|
|
271
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking) {
|
|
272
|
+
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
273
|
+
const target = Math.min(spendable, cap);
|
|
274
|
+
const kept = Math.max(
|
|
275
|
+
sumAmounts(balance, -spendable),
|
|
276
|
+
sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
|
|
277
|
+
);
|
|
278
|
+
const next = Math.min(balance, sumAmounts(target, kept));
|
|
279
|
+
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
280
|
+
}
|
|
281
|
+
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
282
|
+
const held = heldExpiredBacking(balance, expiredHoldBacking);
|
|
283
|
+
const used = Math.max(0, Math.min(held, holdBacking));
|
|
284
|
+
return { used, remaining: sumAmounts(held, -used) };
|
|
285
|
+
}
|
|
286
|
+
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
287
|
+
const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
|
|
288
|
+
const rest = sumAmounts(amount, -backing.used);
|
|
289
|
+
const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
|
|
290
|
+
const fromBonus = sumAmounts(rest, -fromSpendable);
|
|
291
|
+
return {
|
|
292
|
+
covered: fromBonus <= bonusCredits,
|
|
293
|
+
balance: sumAmounts(balance, -backing.used, -fromSpendable),
|
|
294
|
+
bonusCredits: sumAmounts(bonusCredits, -fromBonus),
|
|
295
|
+
expiredHoldBacking: backing.remaining
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
var NO_EXPIRED_BACKING_PREFIX = {
|
|
299
|
+
holdPlacedAt: "",
|
|
300
|
+
id: "",
|
|
301
|
+
share: 0
|
|
302
|
+
};
|
|
303
|
+
function compareHolds(a, b) {
|
|
304
|
+
if (a.holdPlacedAt !== b.holdPlacedAt) return a.holdPlacedAt < b.holdPlacedAt ? -1 : 1;
|
|
305
|
+
if (a.id === b.id) return 0;
|
|
306
|
+
return a.id < b.id ? -1 : 1;
|
|
307
|
+
}
|
|
308
|
+
function recordedExpiredBacking(hold) {
|
|
309
|
+
const stored = hold.expiredBacking;
|
|
310
|
+
if (typeof stored !== "number" || !Number.isFinite(stored) || stored <= 0) return 0;
|
|
311
|
+
if (!isValidCreditAmount(hold.amount)) return 0;
|
|
312
|
+
return Math.min(storedAmountCeil(stored), hold.amount);
|
|
313
|
+
}
|
|
314
|
+
function allocateExpiredBacking(holds, total) {
|
|
315
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt && isValidCreditAmount(hold.amount)).map((hold) => ({ ...hold, holdPlacedAt: hold.holdPlacedAt })).sort(compareHolds);
|
|
316
|
+
const wanted = Math.max(0, total);
|
|
317
|
+
if (wanted === 0) return { total: 0, frontier: null };
|
|
318
|
+
const recorded = placed.filter((hold) => recordedExpiredBacking(hold) > 0);
|
|
319
|
+
const shares = new Map(recorded.map((hold) => [hold.id, recordedExpiredBacking(hold)]));
|
|
320
|
+
let left = sumAmounts(wanted, ...[...shares.values()].map((share) => -share));
|
|
321
|
+
for (const hold of [...recorded].reverse()) {
|
|
322
|
+
if (left >= 0) break;
|
|
323
|
+
const share = shares.get(hold.id);
|
|
324
|
+
const cut = Math.min(share, -left);
|
|
325
|
+
shares.set(hold.id, sumAmounts(share, -cut));
|
|
326
|
+
left = sumAmounts(left, cut);
|
|
327
|
+
}
|
|
328
|
+
let frontier = NO_EXPIRED_BACKING_PREFIX;
|
|
329
|
+
for (const hold of placed) {
|
|
330
|
+
if (left <= 0) break;
|
|
331
|
+
if (shares.has(hold.id)) continue;
|
|
332
|
+
const share = Math.min(hold.amount, left);
|
|
333
|
+
frontier = { holdPlacedAt: hold.holdPlacedAt, id: hold.id, share };
|
|
334
|
+
left = sumAmounts(left, -share);
|
|
335
|
+
}
|
|
336
|
+
for (const hold of recorded) {
|
|
337
|
+
if (left <= 0) break;
|
|
338
|
+
const share = shares.get(hold.id);
|
|
339
|
+
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
340
|
+
shares.set(hold.id, sumAmounts(share, added));
|
|
341
|
+
left = sumAmounts(left, -added);
|
|
342
|
+
}
|
|
343
|
+
const rewrites = recorded.filter((hold) => shares.get(hold.id) !== hold.expiredBacking).map((hold) => ({ id: hold.id, expiredBacking: shares.get(hold.id) }));
|
|
344
|
+
return { total: wanted, frontier, ...rewrites.length > 0 ? { rewrites } : {} };
|
|
345
|
+
}
|
|
346
|
+
function holdExpiredBacking(hold, frontier, rewrites) {
|
|
347
|
+
const rewritten = rewrites?.find((rewrite) => rewrite.id === hold.id);
|
|
348
|
+
const recorded = recordedExpiredBacking(
|
|
349
|
+
rewritten ? { ...hold, expiredBacking: rewritten.expiredBacking } : hold
|
|
350
|
+
);
|
|
351
|
+
if (recorded > 0) return recorded;
|
|
352
|
+
if (!frontier || !hold.holdPlacedAt) return 0;
|
|
353
|
+
const order = compareHolds({ holdPlacedAt: hold.holdPlacedAt, id: hold.id }, frontier);
|
|
354
|
+
if (order > 0) return 0;
|
|
355
|
+
return order < 0 ? hold.amount : Math.min(frontier.share, hold.amount);
|
|
356
|
+
}
|
|
357
|
+
function holdPlacedAtAfter(now, frontier) {
|
|
358
|
+
if (!frontier?.holdPlacedAt) return now;
|
|
359
|
+
const earliest = new Date(Date.parse(frontier.holdPlacedAt) + 1).toISOString();
|
|
360
|
+
return now > earliest ? now : earliest;
|
|
361
|
+
}
|
|
362
|
+
function expiredBackingPatch(next, stored) {
|
|
363
|
+
const total = Math.max(0, next.total);
|
|
364
|
+
const patch = {};
|
|
365
|
+
const storedTotal = stored.expiredHoldBacking;
|
|
366
|
+
if (total > 0 || storedTotal !== void 0 && storedTotal !== 0) {
|
|
367
|
+
patch.expiredHoldBacking = total;
|
|
368
|
+
}
|
|
369
|
+
if (total <= 0) {
|
|
370
|
+
if (stored.expiredBackingFrontier) patch.expiredBackingFrontier = null;
|
|
371
|
+
} else if (next.frontier) {
|
|
372
|
+
patch.expiredBackingFrontier = next.frontier;
|
|
373
|
+
}
|
|
374
|
+
return patch;
|
|
375
|
+
}
|
|
376
|
+
function assertValidBalanceTarget(value, field, context) {
|
|
377
|
+
assertRepresentableAmount(value, field, context);
|
|
378
|
+
if (value < 0) {
|
|
379
|
+
throw new CreditError(
|
|
380
|
+
`${field} cannot be negative (got ${value})`,
|
|
381
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
382
|
+
{ field, value, ...context }
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function assertValidTierBalanceInput(input, context) {
|
|
387
|
+
if (input.balanceCap === void 0) {
|
|
388
|
+
if (input.balance !== void 0) assertValidBalanceTarget(input.balance, "balance", context);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (input.balance !== void 0) {
|
|
392
|
+
throw new CreditError(
|
|
393
|
+
"A tier update cannot set both balance and balanceCap",
|
|
394
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
395
|
+
{ balance: input.balance, balanceCap: input.balanceCap, ...context }
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
|
|
399
|
+
}
|
|
222
400
|
function assertRepresentableTierAmount(value, field, context) {
|
|
223
401
|
if (value === Number.POSITIVE_INFINITY) return;
|
|
224
402
|
assertRepresentableAmount(value, field, context);
|
|
@@ -621,9 +799,9 @@ var creditSystemConfigSchema = z.object({
|
|
|
621
799
|
*/
|
|
622
800
|
reservationExpiryMs: z.number().positive().default(5 * 60 * 1e3),
|
|
623
801
|
/**
|
|
624
|
-
* Default credits for new users (free tier)
|
|
802
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
625
803
|
*/
|
|
626
|
-
defaultFreeCredits: z.number().
|
|
804
|
+
defaultFreeCredits: z.number().min(0).default(25),
|
|
627
805
|
/**
|
|
628
806
|
* Grace period in days after subscription expires before downgrade
|
|
629
807
|
*/
|
|
@@ -994,9 +1172,11 @@ export {
|
|
|
994
1172
|
CREDIT_AMOUNT_SCALE,
|
|
995
1173
|
CreditError,
|
|
996
1174
|
CreditErrorCode,
|
|
1175
|
+
NO_EXPIRED_BACKING_PREFIX,
|
|
997
1176
|
RESERVATION_STATUSES,
|
|
998
1177
|
RESERVED_JOURNAL_KEY_PREFIX,
|
|
999
1178
|
TERMINAL_RESERVATION_STATUSES,
|
|
1179
|
+
allocateExpiredBacking,
|
|
1000
1180
|
assertDirectStatusWriteAllowed,
|
|
1001
1181
|
assertHoldPlaced,
|
|
1002
1182
|
assertKnownReservationStatus,
|
|
@@ -1006,12 +1186,16 @@ export {
|
|
|
1006
1186
|
assertRepresentableTierAmount,
|
|
1007
1187
|
assertUnkeyedDirectReservation,
|
|
1008
1188
|
assertUnreferencedDirectTransaction,
|
|
1189
|
+
assertValidBalanceTarget,
|
|
1009
1190
|
assertValidCreditAmount,
|
|
1010
1191
|
assertValidIdempotencyKey,
|
|
1011
1192
|
assertValidStoredAmount,
|
|
1012
1193
|
assertValidStoredAmountRaw,
|
|
1194
|
+
assertValidTierBalanceInput,
|
|
1013
1195
|
backedBalanceFloor,
|
|
1014
1196
|
calculateAvailableCredits,
|
|
1197
|
+
canonicalAmount,
|
|
1198
|
+
cappedBalance,
|
|
1015
1199
|
classifyDatabaseError,
|
|
1016
1200
|
commitReservationWithJournal,
|
|
1017
1201
|
createDeferredExecutor,
|
|
@@ -1027,9 +1211,13 @@ export {
|
|
|
1027
1211
|
createUnsupportedOperationError,
|
|
1028
1212
|
createUserNotFoundError,
|
|
1029
1213
|
describePaymentMismatch,
|
|
1214
|
+
expiredBackingPatch,
|
|
1030
1215
|
genericDeferred,
|
|
1031
1216
|
getSqlState,
|
|
1032
1217
|
hasPlacedHold,
|
|
1218
|
+
heldExpiredBacking,
|
|
1219
|
+
holdExpiredBacking,
|
|
1220
|
+
holdPlacedAtAfter,
|
|
1033
1221
|
isCreditError,
|
|
1034
1222
|
isCreditedOutcome,
|
|
1035
1223
|
isIdempotencyConflictError,
|
|
@@ -1045,12 +1233,21 @@ export {
|
|
|
1045
1233
|
isValidCreditAmount,
|
|
1046
1234
|
isValidIdempotencyKey,
|
|
1047
1235
|
isWinningOutcome,
|
|
1236
|
+
multiplyAmount,
|
|
1048
1237
|
noopDeferred,
|
|
1049
1238
|
normalizePaymentRef,
|
|
1050
1239
|
numericToCents,
|
|
1240
|
+
planBalanceCap,
|
|
1241
|
+
planBalanceTarget,
|
|
1242
|
+
planHoldCommit,
|
|
1243
|
+
recordedExpiredBacking,
|
|
1051
1244
|
releaseReservationWithJournal,
|
|
1052
1245
|
reserveCreditsForOperation,
|
|
1053
1246
|
sameAmount,
|
|
1247
|
+
settleExpiredHoldBacking,
|
|
1248
|
+
spendableBalance,
|
|
1249
|
+
storedAmountCeil,
|
|
1250
|
+
storedAmountFloor,
|
|
1054
1251
|
storedMonthlyLimit,
|
|
1055
1252
|
sumAmounts,
|
|
1056
1253
|
synchronousDeferred,
|