@nehorai/credits 2.1.0 → 2.2.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 +2 -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 +2 -2
- package/dist/adapters/index.js.map +1 -1
- package/dist/core/index.cjs +102 -2
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +92 -5
- package/dist/core/index.d.ts +92 -5
- package/dist/core/index.js +92 -2
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +272 -39
- 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 +260 -39
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +247 -39
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +34 -6
- package/dist/repository/index.d.ts +34 -6
- package/dist/repository/index.js +244 -38
- package/dist/repository/index.js.map +1 -1
- 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.map +1 -1
- package/dist/service/index.cjs +8 -4
- 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 +8 -4
- package/dist/service/index.js.map +1 -1
- package/dist/{types-BhCdep_t.d.ts → types-3c-IAwZI.d.ts} +38 -3
- package/dist/{types-Dzc443vy.d.cts → types-MaViK5tD.d.cts} +18 -0
- package/dist/{types-Dzc443vy.d.ts → types-MaViK5tD.d.ts} +18 -0
- package/dist/{types-wtksMa4n.d.cts → types-XSjNXO5l.d.cts} +38 -3
- package/package.json +70 -71
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome,
|
|
2
|
-
export {
|
|
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, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-XSjNXO5l.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-XSjNXO5l.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-MaViK5tD.cjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* One reservation flow for both repository generations.
|
|
@@ -62,6 +62,7 @@ declare function addCreditsThroughRepository(repository: ICreditRepository, inpu
|
|
|
62
62
|
* These are pure functions with no server-only dependencies,
|
|
63
63
|
* making them safe to use in any repository implementation.
|
|
64
64
|
*/
|
|
65
|
+
|
|
65
66
|
/**
|
|
66
67
|
* Generate a unique ID
|
|
67
68
|
* Uses timestamp + random string for uniqueness
|
|
@@ -77,6 +78,27 @@ declare function toDate(value: Date | string | unknown): Date;
|
|
|
77
78
|
* Used for calculating monthly reset dates
|
|
78
79
|
*/
|
|
79
80
|
declare function getNextMonthlyReset(from?: Date): Date;
|
|
81
|
+
/** Result of validating a balance update. */
|
|
82
|
+
interface BalanceValidationResult {
|
|
83
|
+
valid: boolean;
|
|
84
|
+
errors: string[];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether an update leaves a valid account: no negative field, and `reserved` covered
|
|
88
|
+
* by `balance + bonusCredits`, which is the floor outstanding holds put under the
|
|
89
|
+
* balance. An increment is applied on top of the absolute value when a call names both.
|
|
90
|
+
*
|
|
91
|
+
* An update is a delta the caller asked for, so one that cannot be honoured is refused,
|
|
92
|
+
* as a deduct is. Only tier writes and resets stop at the floor: they state a target.
|
|
93
|
+
*
|
|
94
|
+
* A negative balance increment is a charge, so it draws only on the balance above
|
|
95
|
+
* `expiredHoldBacking`: those credits are expired and leave when their hold closes, so
|
|
96
|
+
* charging them would hand the charge back. An absolute balance replaces the field and
|
|
97
|
+
* the marks with it, so only an increment on the stored balance meets this limit.
|
|
98
|
+
*/
|
|
99
|
+
declare function validateBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): BalanceValidationResult;
|
|
100
|
+
/** Refuse an update {@link validateBalanceUpdate} finds invalid. */
|
|
101
|
+
declare function assertValidBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): void;
|
|
80
102
|
|
|
81
103
|
/**
|
|
82
104
|
* In-Memory Credit Repository Implementation
|
|
@@ -92,6 +114,8 @@ declare function getNextMonthlyReset(from?: Date): Date;
|
|
|
92
114
|
|
|
93
115
|
declare class InMemoryCreditRepository implements ICreditRepository {
|
|
94
116
|
private readonly store;
|
|
117
|
+
private readonly options;
|
|
118
|
+
constructor(options?: CreditPolicyOptions);
|
|
95
119
|
/**
|
|
96
120
|
* A copy, never the live record.
|
|
97
121
|
*
|
|
@@ -192,7 +216,11 @@ declare class InMemoryCreditRepository implements ICreditRepository {
|
|
|
192
216
|
creditsReleased: number;
|
|
193
217
|
errors: string[];
|
|
194
218
|
}>;
|
|
195
|
-
|
|
219
|
+
/**
|
|
220
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
221
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
222
|
+
*/
|
|
223
|
+
atomicMonthlyReset(userId: string, _tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
|
|
196
224
|
checkAndHandleSubscriptionExpiry(userId: string, gracePeriodDays?: number): Promise<SubscriptionExpiryResult>;
|
|
197
225
|
/**
|
|
198
226
|
* Clear all data (useful for testing)
|
|
@@ -221,6 +249,6 @@ declare class InMemoryCreditRepository implements ICreditRepository {
|
|
|
221
249
|
* Create a new in-memory repository instance
|
|
222
250
|
* Each call creates a fresh, isolated instance
|
|
223
251
|
*/
|
|
224
|
-
declare function createInMemoryCreditRepository(): InMemoryCreditRepository;
|
|
252
|
+
declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
|
|
225
253
|
|
|
226
|
-
export { AddCreditsAtomicOptions, AddCreditsV2Input, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate };
|
|
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 };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { I as ICreditRepository, b as AddCreditsV2Input, a as AddCreditsOutcome,
|
|
2
|
-
export {
|
|
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, e as CreateTransactionInput, f as CreateUsageLogInput, U as UsageLogQuery, c as CreateJournalEntryInput, J as JournalEntryQuery } from '../types-3c-IAwZI.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-3c-IAwZI.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-MaViK5tD.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* One reservation flow for both repository generations.
|
|
@@ -62,6 +62,7 @@ declare function addCreditsThroughRepository(repository: ICreditRepository, inpu
|
|
|
62
62
|
* These are pure functions with no server-only dependencies,
|
|
63
63
|
* making them safe to use in any repository implementation.
|
|
64
64
|
*/
|
|
65
|
+
|
|
65
66
|
/**
|
|
66
67
|
* Generate a unique ID
|
|
67
68
|
* Uses timestamp + random string for uniqueness
|
|
@@ -77,6 +78,27 @@ declare function toDate(value: Date | string | unknown): Date;
|
|
|
77
78
|
* Used for calculating monthly reset dates
|
|
78
79
|
*/
|
|
79
80
|
declare function getNextMonthlyReset(from?: Date): Date;
|
|
81
|
+
/** Result of validating a balance update. */
|
|
82
|
+
interface BalanceValidationResult {
|
|
83
|
+
valid: boolean;
|
|
84
|
+
errors: string[];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether an update leaves a valid account: no negative field, and `reserved` covered
|
|
88
|
+
* by `balance + bonusCredits`, which is the floor outstanding holds put under the
|
|
89
|
+
* balance. An increment is applied on top of the absolute value when a call names both.
|
|
90
|
+
*
|
|
91
|
+
* An update is a delta the caller asked for, so one that cannot be honoured is refused,
|
|
92
|
+
* as a deduct is. Only tier writes and resets stop at the floor: they state a target.
|
|
93
|
+
*
|
|
94
|
+
* A negative balance increment is a charge, so it draws only on the balance above
|
|
95
|
+
* `expiredHoldBacking`: those credits are expired and leave when their hold closes, so
|
|
96
|
+
* charging them would hand the charge back. An absolute balance replaces the field and
|
|
97
|
+
* the marks with it, so only an increment on the stored balance meets this limit.
|
|
98
|
+
*/
|
|
99
|
+
declare function validateBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): BalanceValidationResult;
|
|
100
|
+
/** Refuse an update {@link validateBalanceUpdate} finds invalid. */
|
|
101
|
+
declare function assertValidBalanceUpdate(current: Pick<PortableUserCredits, "balance" | "bonusCredits" | "reserved" | "expiredHoldBacking">, updates: CreditBalanceUpdate): void;
|
|
80
102
|
|
|
81
103
|
/**
|
|
82
104
|
* In-Memory Credit Repository Implementation
|
|
@@ -92,6 +114,8 @@ declare function getNextMonthlyReset(from?: Date): Date;
|
|
|
92
114
|
|
|
93
115
|
declare class InMemoryCreditRepository implements ICreditRepository {
|
|
94
116
|
private readonly store;
|
|
117
|
+
private readonly options;
|
|
118
|
+
constructor(options?: CreditPolicyOptions);
|
|
95
119
|
/**
|
|
96
120
|
* A copy, never the live record.
|
|
97
121
|
*
|
|
@@ -192,7 +216,11 @@ declare class InMemoryCreditRepository implements ICreditRepository {
|
|
|
192
216
|
creditsReleased: number;
|
|
193
217
|
errors: string[];
|
|
194
218
|
}>;
|
|
195
|
-
|
|
219
|
+
/**
|
|
220
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
221
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
222
|
+
*/
|
|
223
|
+
atomicMonthlyReset(userId: string, _tier: SubscriptionTier, expectedResetAt: Date | string): Promise<MonthlyResetResult>;
|
|
196
224
|
checkAndHandleSubscriptionExpiry(userId: string, gracePeriodDays?: number): Promise<SubscriptionExpiryResult>;
|
|
197
225
|
/**
|
|
198
226
|
* Clear all data (useful for testing)
|
|
@@ -221,6 +249,6 @@ declare class InMemoryCreditRepository implements ICreditRepository {
|
|
|
221
249
|
* Create a new in-memory repository instance
|
|
222
250
|
* Each call creates a fresh, isolated instance
|
|
223
251
|
*/
|
|
224
|
-
declare function createInMemoryCreditRepository(): InMemoryCreditRepository;
|
|
252
|
+
declare function createInMemoryCreditRepository(options?: CreditPolicyOptions): InMemoryCreditRepository;
|
|
225
253
|
|
|
226
|
-
export { AddCreditsAtomicOptions, AddCreditsV2Input, CreateJournalEntryInput, CreateReservationInput, CreateTransactionInput, CreateUsageLogInput, CreditBalanceUpdate, ExpireReservationV2Options, ICreditRepository, InMemoryCreditRepository, JournalEntryQuery, ReservationTransitionOptions, ReserveCreditsV2Input, TierUpdateInput, UsageLogQuery, addCreditsThroughRepository, commitThroughRepository, createInMemoryCreditRepository, generateId, getNextMonthlyReset, releaseThroughRepository, reserveThroughRepository, toDate };
|
|
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 };
|
package/dist/repository/index.js
CHANGED
|
@@ -16,6 +16,8 @@ function toClientUserCredits(credits) {
|
|
|
16
16
|
monthlyUsed: credits.monthlyUsed,
|
|
17
17
|
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
18
18
|
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
19
|
+
...credits.expiredHoldBacking !== void 0 ? { expiredHoldBacking: credits.expiredHoldBacking } : {},
|
|
20
|
+
...credits.signupGrant !== void 0 ? { signupGrant: credits.signupGrant } : {},
|
|
19
21
|
createdAt: toISOString(credits.createdAt),
|
|
20
22
|
updatedAt: toISOString(credits.updatedAt)
|
|
21
23
|
};
|
|
@@ -106,6 +108,83 @@ function backedBalanceFloor(reserved, bonusCredits) {
|
|
|
106
108
|
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
107
109
|
return floor;
|
|
108
110
|
}
|
|
111
|
+
function spendableBalance(balance, expiredHoldBacking = 0) {
|
|
112
|
+
return sumAmounts(balance, -Math.min(expiredHoldBacking, balance));
|
|
113
|
+
}
|
|
114
|
+
function planBalanceTarget(target, reserved, bonusCredits) {
|
|
115
|
+
const balance = Math.max(target, backedBalanceFloor(reserved, bonusCredits));
|
|
116
|
+
return { balance, expiredHoldBacking: sumAmounts(balance, -target) };
|
|
117
|
+
}
|
|
118
|
+
function planBalanceCap(balance, cap, reserved, bonusCredits, expiredHoldBacking = 0) {
|
|
119
|
+
const spendable = spendableBalance(balance, expiredHoldBacking);
|
|
120
|
+
const target = Math.min(spendable, cap);
|
|
121
|
+
const kept = Math.max(
|
|
122
|
+
sumAmounts(balance, -spendable),
|
|
123
|
+
sumAmounts(backedBalanceFloor(reserved, bonusCredits), -target)
|
|
124
|
+
);
|
|
125
|
+
const next = Math.min(balance, sumAmounts(target, kept));
|
|
126
|
+
return { balance: next, expiredHoldBacking: Math.max(0, sumAmounts(next, -target)) };
|
|
127
|
+
}
|
|
128
|
+
function settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking) {
|
|
129
|
+
const held = Math.min(expiredHoldBacking, balance);
|
|
130
|
+
const used = Math.min(held, holdBacking);
|
|
131
|
+
return { used, remaining: sumAmounts(held, -used) };
|
|
132
|
+
}
|
|
133
|
+
function planHoldCommit(balance, bonusCredits, expiredHoldBacking, holdBacking, amount) {
|
|
134
|
+
const backing = settleExpiredHoldBacking(balance, expiredHoldBacking, holdBacking);
|
|
135
|
+
const rest = sumAmounts(amount, -backing.used);
|
|
136
|
+
const fromSpendable = Math.min(spendableBalance(balance, expiredHoldBacking), rest);
|
|
137
|
+
const fromBonus = sumAmounts(rest, -fromSpendable);
|
|
138
|
+
return {
|
|
139
|
+
covered: fromBonus <= bonusCredits,
|
|
140
|
+
balance: sumAmounts(balance, -backing.used, -fromSpendable),
|
|
141
|
+
bonusCredits: sumAmounts(bonusCredits, -fromBonus),
|
|
142
|
+
expiredHoldBacking: backing.remaining
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function attributeExpiredBacking(holds, total) {
|
|
146
|
+
const placed = holds.filter((hold) => hold.holdPlacedAt).sort(
|
|
147
|
+
(a, b) => a.holdPlacedAt === b.holdPlacedAt ? a.id < b.id ? -1 : 1 : a.holdPlacedAt < b.holdPlacedAt ? -1 : 1
|
|
148
|
+
);
|
|
149
|
+
const shares = /* @__PURE__ */ new Map();
|
|
150
|
+
let left = total;
|
|
151
|
+
for (const hold of placed) {
|
|
152
|
+
const kept = Math.min(hold.expiredBacking ?? 0, hold.amount, left);
|
|
153
|
+
shares.set(hold.id, kept);
|
|
154
|
+
left = sumAmounts(left, -kept);
|
|
155
|
+
}
|
|
156
|
+
for (const hold of placed) {
|
|
157
|
+
const share = shares.get(hold.id);
|
|
158
|
+
const added = Math.min(sumAmounts(hold.amount, -share), left);
|
|
159
|
+
shares.set(hold.id, sumAmounts(share, added));
|
|
160
|
+
left = sumAmounts(left, -added);
|
|
161
|
+
}
|
|
162
|
+
return shares;
|
|
163
|
+
}
|
|
164
|
+
function expiredHoldBackingPatch(next, stored = 0) {
|
|
165
|
+
return next > 0 || stored > 0 ? { expiredHoldBacking: next } : {};
|
|
166
|
+
}
|
|
167
|
+
function assertValidBalanceTarget(value, field, context) {
|
|
168
|
+
assertRepresentableAmount(value, field, context);
|
|
169
|
+
if (value < 0) {
|
|
170
|
+
throw new CreditError(
|
|
171
|
+
`${field} cannot be negative (got ${value})`,
|
|
172
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
173
|
+
{ field, value, ...context }
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function assertValidTierBalanceInput(input, context) {
|
|
178
|
+
if (input.balanceCap === void 0) return;
|
|
179
|
+
if (input.balance !== void 0) {
|
|
180
|
+
throw new CreditError(
|
|
181
|
+
"A tier update cannot set both balance and balanceCap",
|
|
182
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
183
|
+
{ balance: input.balance, balanceCap: input.balanceCap, ...context }
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
assertValidBalanceTarget(input.balanceCap, "balanceCap", context);
|
|
187
|
+
}
|
|
109
188
|
function assertRepresentableTierAmount(value, field, context) {
|
|
110
189
|
if (value === Number.POSITIVE_INFINITY) return;
|
|
111
190
|
assertRepresentableAmount(value, field, context);
|
|
@@ -323,9 +402,9 @@ var creditSystemConfigSchema = z.object({
|
|
|
323
402
|
*/
|
|
324
403
|
reservationExpiryMs: z.number().positive().default(5 * 60 * 1e3),
|
|
325
404
|
/**
|
|
326
|
-
* Default credits for new users (free tier)
|
|
405
|
+
* Default credits for new users (free tier). 0 means no signup grant.
|
|
327
406
|
*/
|
|
328
|
-
defaultFreeCredits: z.number().
|
|
407
|
+
defaultFreeCredits: z.number().min(0).default(25),
|
|
329
408
|
/**
|
|
330
409
|
* Grace period in days after subscription expires before downgrade
|
|
331
410
|
*/
|
|
@@ -755,11 +834,36 @@ function toDate(value) {
|
|
|
755
834
|
return /* @__PURE__ */ new Date();
|
|
756
835
|
}
|
|
757
836
|
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
next
|
|
762
|
-
|
|
837
|
+
return new Date(from.getFullYear(), from.getMonth() + 1, 1);
|
|
838
|
+
}
|
|
839
|
+
function validateBalanceUpdate(current, updates) {
|
|
840
|
+
const next = (base, absolute, increment) => sumAmounts(absolute ?? base, increment ?? 0);
|
|
841
|
+
const balance = next(current.balance, updates.balance, updates.balanceIncrement);
|
|
842
|
+
const bonusCredits = next(
|
|
843
|
+
current.bonusCredits ?? 0,
|
|
844
|
+
updates.bonusCredits,
|
|
845
|
+
updates.bonusCreditsIncrement
|
|
846
|
+
);
|
|
847
|
+
const reserved = next(current.reserved ?? 0, updates.reserved, updates.reservedIncrement);
|
|
848
|
+
const errors = [];
|
|
849
|
+
if (balance < 0) errors.push(`Balance cannot be negative: ${balance}`);
|
|
850
|
+
const marked = updates.balance === void 0 ? current.expiredHoldBacking ?? 0 : 0;
|
|
851
|
+
if ((updates.balanceIncrement ?? 0) < 0 && balance >= 0 && balance < marked) {
|
|
852
|
+
errors.push(`Balance (${balance}) cannot go below expired hold backing (${marked})`);
|
|
853
|
+
}
|
|
854
|
+
if (bonusCredits < 0) errors.push(`BonusCredits cannot be negative: ${bonusCredits}`);
|
|
855
|
+
if (reserved < 0) errors.push(`Reserved cannot be negative: ${reserved}`);
|
|
856
|
+
const covering = sumAmounts(balance, bonusCredits);
|
|
857
|
+
if (reserved > covering) {
|
|
858
|
+
errors.push(`Reserved (${reserved}) exceeds balance + bonusCredits (${covering})`);
|
|
859
|
+
}
|
|
860
|
+
return { valid: errors.length === 0, errors };
|
|
861
|
+
}
|
|
862
|
+
function assertValidBalanceUpdate(current, updates) {
|
|
863
|
+
const result = validateBalanceUpdate(current, updates);
|
|
864
|
+
if (!result.valid) {
|
|
865
|
+
throw new Error(`Invalid balance update: ${result.errors.join("; ")}`);
|
|
866
|
+
}
|
|
763
867
|
}
|
|
764
868
|
|
|
765
869
|
// src/repository/memory/snapshot.ts
|
|
@@ -1040,6 +1144,22 @@ function assertTrustworthy(reservation, transition) {
|
|
|
1040
1144
|
assertLockedAmount(reservation, transition);
|
|
1041
1145
|
assertHoldPlaced(reservation, transition);
|
|
1042
1146
|
}
|
|
1147
|
+
function openHolds(store, userId) {
|
|
1148
|
+
return [...store.reservations.get(userId)?.values() ?? []].filter(
|
|
1149
|
+
(reservation) => reservation.status === "reserved"
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
function writeExpiredHoldBacking(store, credits, next) {
|
|
1153
|
+
const stored = credits.expiredHoldBacking;
|
|
1154
|
+
if (next <= 0 && !stored) return;
|
|
1155
|
+
const holds = openHolds(store, credits.userId);
|
|
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;
|
|
1160
|
+
}
|
|
1161
|
+
Object.assign(credits, expiredHoldBackingPatch(sumAmounts(...shares.values()), stored));
|
|
1162
|
+
}
|
|
1043
1163
|
function getReservation(store, userId, reservationId) {
|
|
1044
1164
|
return store.reservations.get(userId)?.get(reservationId);
|
|
1045
1165
|
}
|
|
@@ -1137,16 +1257,22 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1137
1257
|
const amount = reservation.amount;
|
|
1138
1258
|
await yieldPoint(store);
|
|
1139
1259
|
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1140
|
-
|
|
1260
|
+
const paid = planHoldCommit(
|
|
1261
|
+
credits.balance,
|
|
1262
|
+
credits.bonusCredits,
|
|
1263
|
+
credits.expiredHoldBacking ?? 0,
|
|
1264
|
+
reservation.expiredBacking ?? 0,
|
|
1265
|
+
amount
|
|
1266
|
+
);
|
|
1267
|
+
if (!paid.covered) {
|
|
1141
1268
|
throw new CreditError(
|
|
1142
1269
|
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1143
1270
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1144
1271
|
{ userId, reservationId, required: amount }
|
|
1145
1272
|
);
|
|
1146
1273
|
}
|
|
1147
|
-
const
|
|
1148
|
-
const
|
|
1149
|
-
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1274
|
+
const nextBalance = paid.balance;
|
|
1275
|
+
const nextBonusCredits = paid.bonusCredits;
|
|
1150
1276
|
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1151
1277
|
const journal = {
|
|
1152
1278
|
userId,
|
|
@@ -1180,6 +1306,10 @@ async function commitReservationV2(store, userId, reservationId, options) {
|
|
|
1180
1306
|
credits.bonusCredits = nextBonusCredits;
|
|
1181
1307
|
credits.reserved = nextReserved;
|
|
1182
1308
|
credits.monthlyUsed = nextMonthlyUsed;
|
|
1309
|
+
Object.assign(
|
|
1310
|
+
credits,
|
|
1311
|
+
expiredHoldBackingPatch(paid.expiredHoldBacking, credits.expiredHoldBacking)
|
|
1312
|
+
);
|
|
1183
1313
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1184
1314
|
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1185
1315
|
return {
|
|
@@ -1259,19 +1389,26 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1259
1389
|
reservation.id,
|
|
1260
1390
|
reservation.amount
|
|
1261
1391
|
);
|
|
1392
|
+
const backing = settleExpiredHoldBacking(
|
|
1393
|
+
credits.balance,
|
|
1394
|
+
credits.expiredHoldBacking ?? 0,
|
|
1395
|
+
reservation.expiredBacking ?? 0
|
|
1396
|
+
);
|
|
1397
|
+
const nextBalance = sumAmounts(credits.balance, -backing.used);
|
|
1262
1398
|
const journal = {
|
|
1263
1399
|
userId: reservation.userId,
|
|
1264
1400
|
operation: `${transition.transition}Reservation`,
|
|
1265
1401
|
entryType: "credit",
|
|
1266
1402
|
amount: 0,
|
|
1267
|
-
balanceAfter: sumAmounts(
|
|
1403
|
+
balanceAfter: sumAmounts(nextBalance, credits.bonusCredits),
|
|
1268
1404
|
source: transition.source,
|
|
1269
1405
|
reservationId: reservation.id,
|
|
1270
1406
|
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1271
1407
|
metadata: {
|
|
1272
1408
|
...transition.options?.metadata,
|
|
1273
1409
|
operationType: reservation.operationType,
|
|
1274
|
-
amount: reservation.amount
|
|
1410
|
+
amount: reservation.amount,
|
|
1411
|
+
...backing.used > 0 ? { expiredCreditsRemoved: backing.used } : {}
|
|
1275
1412
|
},
|
|
1276
1413
|
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1277
1414
|
};
|
|
@@ -1284,6 +1421,8 @@ function finishUnspent(store, reservation, transition) {
|
|
|
1284
1421
|
reservation.status = transition.status;
|
|
1285
1422
|
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1286
1423
|
credits.reserved = nextReserved;
|
|
1424
|
+
credits.balance = nextBalance;
|
|
1425
|
+
Object.assign(credits, expiredHoldBackingPatch(backing.remaining, credits.expiredHoldBacking));
|
|
1287
1426
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1288
1427
|
return commitTransitionJournal(store, journal, existingEntryId);
|
|
1289
1428
|
}
|
|
@@ -1294,6 +1433,10 @@ function isoOf(value) {
|
|
|
1294
1433
|
}
|
|
1295
1434
|
var InMemoryCreditRepository = class {
|
|
1296
1435
|
store = new MemoryStore();
|
|
1436
|
+
options;
|
|
1437
|
+
constructor(options = {}) {
|
|
1438
|
+
this.options = options;
|
|
1439
|
+
}
|
|
1297
1440
|
// ==================== User Credits ====================
|
|
1298
1441
|
/**
|
|
1299
1442
|
* A copy, never the live record.
|
|
@@ -1310,7 +1453,8 @@ var InMemoryCreditRepository = class {
|
|
|
1310
1453
|
return copyRecord(credits) ?? null;
|
|
1311
1454
|
}
|
|
1312
1455
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1313
|
-
|
|
1456
|
+
const existing = this.store.users.get(userId);
|
|
1457
|
+
return copyRecord(existing ?? this.seedUser(userId, tier, initialBalance));
|
|
1314
1458
|
}
|
|
1315
1459
|
/**
|
|
1316
1460
|
* The synchronous body of `initializeUserCredits`.
|
|
@@ -1361,8 +1505,11 @@ var InMemoryCreditRepository = class {
|
|
|
1361
1505
|
if (!credits) {
|
|
1362
1506
|
throw new Error(`User ${userId} not found`);
|
|
1363
1507
|
}
|
|
1508
|
+
assertValidBalanceUpdate(credits, updates);
|
|
1364
1509
|
const next = { ...credits };
|
|
1365
|
-
if (updates.balance !== void 0)
|
|
1510
|
+
if (updates.balance !== void 0) {
|
|
1511
|
+
next.balance = updates.balance;
|
|
1512
|
+
}
|
|
1366
1513
|
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1367
1514
|
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1368
1515
|
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
@@ -1399,6 +1546,9 @@ var InMemoryCreditRepository = class {
|
|
|
1399
1546
|
userId,
|
|
1400
1547
|
operation: "updateUserCredits"
|
|
1401
1548
|
});
|
|
1549
|
+
if ((credits.expiredHoldBacking ?? 0) > 0 && updates.balance !== void 0) {
|
|
1550
|
+
writeExpiredHoldBacking(this.store, next, 0);
|
|
1551
|
+
}
|
|
1402
1552
|
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1403
1553
|
this.store.users.set(userId, next);
|
|
1404
1554
|
}
|
|
@@ -1407,17 +1557,28 @@ var InMemoryCreditRepository = class {
|
|
|
1407
1557
|
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1408
1558
|
{ userId, operation: "updateUserTier" }
|
|
1409
1559
|
);
|
|
1560
|
+
assertValidTierBalanceInput(input, { userId, operation: "updateUserTier" });
|
|
1410
1561
|
const credits = this.store.users.get(userId);
|
|
1411
1562
|
if (!credits) {
|
|
1412
1563
|
throw new Error(`User ${userId} not found`);
|
|
1413
1564
|
}
|
|
1565
|
+
if (input.balanceCap !== void 0 && !isFreeTier(credits.tier)) {
|
|
1566
|
+
const capped = planBalanceCap(
|
|
1567
|
+
credits.balance,
|
|
1568
|
+
input.balanceCap,
|
|
1569
|
+
credits.reserved,
|
|
1570
|
+
credits.bonusCredits,
|
|
1571
|
+
credits.expiredHoldBacking
|
|
1572
|
+
);
|
|
1573
|
+
credits.balance = capped.balance;
|
|
1574
|
+
writeExpiredHoldBacking(this.store, credits, capped.expiredHoldBacking);
|
|
1575
|
+
}
|
|
1414
1576
|
credits.tier = input.tier;
|
|
1415
1577
|
credits.monthlyLimit = input.monthlyLimit;
|
|
1416
1578
|
if (input.balance !== void 0) {
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
);
|
|
1579
|
+
const planned = planBalanceTarget(input.balance, credits.reserved, credits.bonusCredits);
|
|
1580
|
+
credits.balance = planned.balance;
|
|
1581
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1421
1582
|
}
|
|
1422
1583
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
1423
1584
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
@@ -1638,13 +1799,17 @@ var InMemoryCreditRepository = class {
|
|
|
1638
1799
|
if (!credits) {
|
|
1639
1800
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
1640
1801
|
}
|
|
1641
|
-
const
|
|
1802
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
1803
|
+
const available = Math.min(
|
|
1804
|
+
sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved),
|
|
1805
|
+
sumAmounts(spendable, credits.bonusCredits)
|
|
1806
|
+
);
|
|
1642
1807
|
if (available < amount) {
|
|
1643
1808
|
throw new Error(
|
|
1644
1809
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
1645
1810
|
);
|
|
1646
1811
|
}
|
|
1647
|
-
const balanceDeduction = Math.min(
|
|
1812
|
+
const balanceDeduction = Math.min(spendable, amount);
|
|
1648
1813
|
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
1649
1814
|
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
1650
1815
|
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
@@ -1881,7 +2046,11 @@ var InMemoryCreditRepository = class {
|
|
|
1881
2046
|
return { expiredCount, creditsReleased, errors };
|
|
1882
2047
|
}
|
|
1883
2048
|
// ==================== Atomic Monthly Reset ====================
|
|
1884
|
-
|
|
2049
|
+
/**
|
|
2050
|
+
* @param _tier - Not trusted. The caller read it earlier and the account may
|
|
2051
|
+
* have changed tier since, so the reset uses the tier stored on the account.
|
|
2052
|
+
*/
|
|
2053
|
+
async atomicMonthlyReset(userId, _tier, expectedResetAt) {
|
|
1885
2054
|
const credits = this.store.users.get(userId);
|
|
1886
2055
|
if (!credits) {
|
|
1887
2056
|
throw new Error(`User ${userId} not found`);
|
|
@@ -1891,17 +2060,41 @@ var InMemoryCreditRepository = class {
|
|
|
1891
2060
|
if (currentResetAt !== expected) {
|
|
1892
2061
|
return { wasReset: false, credits: { ...credits } };
|
|
1893
2062
|
}
|
|
1894
|
-
|
|
2063
|
+
const tier = credits.tier;
|
|
2064
|
+
const spendable = spendableBalance(credits.balance, credits.expiredHoldBacking);
|
|
2065
|
+
const decision = this.options.resolveMonthlyReset?.({ tier, balance: spendable });
|
|
2066
|
+
if (decision === null) {
|
|
2067
|
+
return { wasReset: false, credits: { ...credits } };
|
|
2068
|
+
}
|
|
2069
|
+
if (decision) {
|
|
2070
|
+
assertValidBalanceTarget(decision.balance, "resolveMonthlyReset balance", { userId, tier });
|
|
2071
|
+
if (decision.monthlyLimit !== void 0) {
|
|
2072
|
+
assertValidBalanceTarget(decision.monthlyLimit, "resolveMonthlyReset monthlyLimit", {
|
|
2073
|
+
userId,
|
|
2074
|
+
tier
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
} else {
|
|
2078
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
2079
|
+
}
|
|
1895
2080
|
const nextReset = getNextMonthlyReset();
|
|
1896
|
-
const target = monthlyResetBalance(tier);
|
|
1897
|
-
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
1898
2081
|
const previousBalance = credits.balance;
|
|
1899
|
-
|
|
2082
|
+
let target;
|
|
2083
|
+
if (decision) {
|
|
2084
|
+
target = decision.balance;
|
|
2085
|
+
} else {
|
|
2086
|
+
const configured = monthlyResetBalance(tier);
|
|
2087
|
+
target = configured.kind === "atLeast" ? Math.max(spendable, configured.value) : configured.value;
|
|
2088
|
+
}
|
|
2089
|
+
const planned = planBalanceTarget(target, credits.reserved, credits.bonusCredits);
|
|
2090
|
+
const newBalance = planned.balance;
|
|
1900
2091
|
const change = sumAmounts(newBalance, -previousBalance);
|
|
1901
2092
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
1902
2093
|
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
1903
2094
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1904
2095
|
credits.balance = newBalance;
|
|
2096
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
2097
|
+
if (decision?.monthlyLimit !== void 0) credits.monthlyLimit = decision.monthlyLimit;
|
|
1905
2098
|
credits.monthlyUsed = 0;
|
|
1906
2099
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
1907
2100
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1955,23 +2148,34 @@ var InMemoryCreditRepository = class {
|
|
|
1955
2148
|
};
|
|
1956
2149
|
}
|
|
1957
2150
|
const defaultTier = getDefaultTier();
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
tier: defaultTier
|
|
1962
|
-
}
|
|
1963
|
-
|
|
2151
|
+
let limit;
|
|
2152
|
+
if (this.options.getDowngradeBalanceCap) {
|
|
2153
|
+
limit = this.options.getDowngradeBalanceCap();
|
|
2154
|
+
assertValidBalanceTarget(limit, "getDowngradeBalanceCap", { userId, tier: defaultTier });
|
|
2155
|
+
} else {
|
|
2156
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2157
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2158
|
+
userId,
|
|
2159
|
+
tier: defaultTier
|
|
2160
|
+
});
|
|
2161
|
+
limit = storedMonthlyLimit(configured);
|
|
2162
|
+
}
|
|
1964
2163
|
const previousTier = credits.tier;
|
|
1965
2164
|
const previousBalance = credits.balance;
|
|
1966
|
-
const
|
|
1967
|
-
|
|
1968
|
-
|
|
2165
|
+
const planned = planBalanceCap(
|
|
2166
|
+
credits.balance,
|
|
2167
|
+
limit,
|
|
2168
|
+
credits.reserved,
|
|
2169
|
+
credits.bonusCredits,
|
|
2170
|
+
credits.expiredHoldBacking
|
|
1969
2171
|
);
|
|
2172
|
+
const newBalance = planned.balance;
|
|
1970
2173
|
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
1971
2174
|
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1972
2175
|
credits.tier = defaultTier;
|
|
1973
2176
|
credits.monthlyLimit = limit;
|
|
1974
2177
|
credits.balance = newBalance;
|
|
2178
|
+
writeExpiredHoldBacking(this.store, credits, planned.expiredHoldBacking);
|
|
1975
2179
|
credits.subscriptionExpiresAt = null;
|
|
1976
2180
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1977
2181
|
this.store.users.set(userId, credits);
|
|
@@ -2029,12 +2233,13 @@ var InMemoryCreditRepository = class {
|
|
|
2029
2233
|
return copyRecords(Array.from(userReservations.values()));
|
|
2030
2234
|
}
|
|
2031
2235
|
};
|
|
2032
|
-
function createInMemoryCreditRepository() {
|
|
2033
|
-
return new InMemoryCreditRepository();
|
|
2236
|
+
function createInMemoryCreditRepository(options = {}) {
|
|
2237
|
+
return new InMemoryCreditRepository(options);
|
|
2034
2238
|
}
|
|
2035
2239
|
export {
|
|
2036
2240
|
InMemoryCreditRepository,
|
|
2037
2241
|
addCreditsThroughRepository,
|
|
2242
|
+
assertValidBalanceUpdate,
|
|
2038
2243
|
commitThroughRepository,
|
|
2039
2244
|
createInMemoryCreditRepository,
|
|
2040
2245
|
generateId,
|
|
@@ -2045,6 +2250,7 @@ export {
|
|
|
2045
2250
|
supportsCreditsV2,
|
|
2046
2251
|
supportsIdempotentCredit,
|
|
2047
2252
|
toClientUserCredits,
|
|
2048
|
-
toDate
|
|
2253
|
+
toDate,
|
|
2254
|
+
validateBalanceUpdate
|
|
2049
2255
|
};
|
|
2050
2256
|
//# sourceMappingURL=index.js.map
|