@resvary/sdk 0.6.0 → 0.7.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/README.md +1 -1
- package/dist/credits/errors.d.ts +3 -0
- package/dist/credits/errors.d.ts.map +1 -1
- package/dist/credits/errors.js +5 -0
- package/dist/credits/errors.js.map +1 -1
- package/dist/credits/index.d.ts +4 -4
- package/dist/credits/index.d.ts.map +1 -1
- package/dist/credits/index.js +2 -2
- package/dist/credits/index.js.map +1 -1
- package/dist/credits/ledger.d.ts +58 -1
- package/dist/credits/ledger.d.ts.map +1 -1
- package/dist/credits/ledger.js +641 -26
- package/dist/credits/ledger.js.map +1 -1
- package/dist/credits/store.d.ts +31 -3
- package/dist/credits/store.d.ts.map +1 -1
- package/dist/credits/store.js +119 -0
- package/dist/credits/store.js.map +1 -1
- package/dist/credits/types.d.ts +98 -4
- package/dist/credits/types.d.ts.map +1 -1
- package/dist/receipts/types.d.ts +1 -1
- package/dist/receipts/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/credits/ledger.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { creditUnitsToString, parseCreditUnits, toCreditUnits } from './amount.js';
|
|
3
|
-
import { CreditNotFoundError, IdempotencyConflictError, InsufficientCreditsError, InvalidCreditStateError, } from './errors.js';
|
|
4
|
-
import { InMemoryCreditStore } from './store.js';
|
|
3
|
+
import { CreditNotFoundError, IdempotencyConflictError, InsufficientCreditsError, InvalidCreditStateError, UnsupportedCreditStoreCapabilityError, } from './errors.js';
|
|
4
|
+
import { InMemoryCreditStore, isCreditPolicyStore, } from './store.js';
|
|
5
5
|
import { createMeterDefinition, createPriceVersion, rateUsage } from '../pricing/rating.js';
|
|
6
6
|
export class CreditLedger {
|
|
7
7
|
projectId;
|
|
@@ -21,47 +21,218 @@ export class CreditLedger {
|
|
|
21
21
|
const request = { customerId: input.customerId, metadata: input.metadata };
|
|
22
22
|
return this.store.transaction((tx) => this.idempotent(tx, 'ensure_account', input.idempotencyKey, request, async () => this.ensureAccountInTransaction(tx, input.customerId, input.metadata)));
|
|
23
23
|
}
|
|
24
|
-
async
|
|
24
|
+
async createGrantPolicy(input) {
|
|
25
|
+
const store = this.requirePolicyStore();
|
|
25
26
|
const amountUnits = toCreditUnits(input.amount);
|
|
26
27
|
if (amountUnits <= 0n)
|
|
27
|
-
throw new Error('
|
|
28
|
+
throw new Error('Grant policy amount must be positive');
|
|
29
|
+
if (input.type === 'promotion') {
|
|
30
|
+
if (!Number.isSafeInteger(input.expiresInMs) || input.expiresInMs <= 0) {
|
|
31
|
+
throw new Error('Promotion expiresInMs must be a positive integer');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (!['day', 'week', 'month'].includes(input.cadence)) {
|
|
35
|
+
throw new Error('Allowance cadence must be day, week, or month');
|
|
36
|
+
}
|
|
28
37
|
const request = { ...input, amountUnits: amountUnits.toString() };
|
|
29
|
-
return
|
|
38
|
+
return store.transaction((tx) => this.idempotent(tx, 'create_grant_policy', input.idempotencyKey, request, async () => {
|
|
39
|
+
const key = requireText(input.key, 'key');
|
|
40
|
+
const policies = (await tx.listGrantPolicies(this.projectId)).filter((policy) => policy.key === key);
|
|
41
|
+
const version = Math.max(0, ...policies.map((policy) => policy.version)) + 1;
|
|
42
|
+
const base = {
|
|
43
|
+
id: createId('gpol'),
|
|
44
|
+
projectId: this.projectId,
|
|
45
|
+
key,
|
|
46
|
+
version,
|
|
47
|
+
type: input.type,
|
|
48
|
+
amount: creditUnitsToString(amountUnits),
|
|
49
|
+
amountUnits: amountUnits.toString(),
|
|
50
|
+
createdAt: this.now(),
|
|
51
|
+
metadata: input.metadata,
|
|
52
|
+
};
|
|
53
|
+
const policy = input.type === 'allowance'
|
|
54
|
+
? {
|
|
55
|
+
...base,
|
|
56
|
+
type: 'allowance',
|
|
57
|
+
cadence: input.cadence,
|
|
58
|
+
}
|
|
59
|
+
: {
|
|
60
|
+
...base,
|
|
61
|
+
type: 'promotion',
|
|
62
|
+
expiresInMs: input.expiresInMs,
|
|
63
|
+
};
|
|
64
|
+
await tx.saveGrantPolicy(policy);
|
|
65
|
+
await this.saveOutboxEvent(tx, 'credit.policy.created', { policy }, policy.createdAt);
|
|
66
|
+
return policy;
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
async applyAllowance(input) {
|
|
70
|
+
const store = this.requirePolicyStore();
|
|
71
|
+
return store.transaction((tx) => this.idempotent(tx, 'apply_allowance', input.idempotencyKey, input, async () => {
|
|
30
72
|
const now = this.now();
|
|
73
|
+
const policy = await this.requireGrantPolicy(tx, input.policyId, 'allowance');
|
|
74
|
+
await this.expireDueCreditLots(tx, now, input.customerId);
|
|
31
75
|
const current = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
const periodKey = allowancePeriodKey(now, policy.cadence);
|
|
77
|
+
const existing = await tx.getGrantPolicyApplicationByIdentity(policy.id, current.id, periodKey);
|
|
78
|
+
if (existing) {
|
|
79
|
+
const account = await this.requireAccountById(tx, current.id);
|
|
80
|
+
return {
|
|
81
|
+
policy,
|
|
82
|
+
application: existing,
|
|
83
|
+
account,
|
|
84
|
+
grant: existing.grantId ? await tx.getGrant(existing.grantId) : undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const lots = await tx.listCreditLots({
|
|
35
88
|
projectId: this.projectId,
|
|
36
89
|
customerId: current.customerId,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
90
|
+
policyId: policy.id,
|
|
91
|
+
kind: 'allowance',
|
|
92
|
+
});
|
|
93
|
+
const unspentUnits = lots.reduce((total, lot) => total + parseCreditUnits(lot.availableUnits) + parseCreditUnits(lot.reservedUnits), 0n);
|
|
94
|
+
const targetUnits = parseCreditUnits(policy.amountUnits);
|
|
95
|
+
const topUpUnits = targetUnits > unspentUnits ? targetUnits - unspentUnits : 0n;
|
|
96
|
+
let account = current;
|
|
97
|
+
let grant;
|
|
98
|
+
if (topUpUnits > 0n) {
|
|
99
|
+
const created = await this.createGrantInTransaction(tx, {
|
|
100
|
+
customerId: current.customerId,
|
|
101
|
+
amountUnits: topUpUnits,
|
|
102
|
+
source: 'allowance',
|
|
103
|
+
policyId: policy.id,
|
|
104
|
+
lotKind: 'allowance',
|
|
105
|
+
now,
|
|
106
|
+
metadata: input.metadata,
|
|
107
|
+
});
|
|
108
|
+
account = created.account;
|
|
109
|
+
grant = created.grant;
|
|
110
|
+
}
|
|
111
|
+
const application = {
|
|
112
|
+
id: createId('gpa'),
|
|
113
|
+
policyId: policy.id,
|
|
114
|
+
policyType: 'allowance',
|
|
115
|
+
accountId: account.id,
|
|
116
|
+
projectId: this.projectId,
|
|
117
|
+
customerId: account.customerId,
|
|
118
|
+
periodKey,
|
|
119
|
+
grantId: grant?.id,
|
|
120
|
+
grantedAmount: creditUnitsToString(topUpUnits),
|
|
121
|
+
grantedUnits: topUpUnits.toString(),
|
|
41
122
|
createdAt: now,
|
|
42
123
|
metadata: input.metadata,
|
|
43
124
|
};
|
|
44
|
-
|
|
45
|
-
await tx.
|
|
46
|
-
|
|
47
|
-
await this.saveLedgerEntry(tx, account, 'grant', 'posted', amountUnits, 'grant', grant.id, now, input.metadata);
|
|
48
|
-
await this.saveOutboxEvent(tx, 'credit.granted', { account, grant }, now);
|
|
49
|
-
return { account, grant };
|
|
125
|
+
await tx.saveGrantPolicyApplication(application);
|
|
126
|
+
await this.saveOutboxEvent(tx, 'credit.allowance.applied', { policy, application, account, grant }, now);
|
|
127
|
+
return { policy, application, account, grant };
|
|
50
128
|
}));
|
|
51
129
|
}
|
|
130
|
+
async claimPromotion(input) {
|
|
131
|
+
const store = this.requirePolicyStore();
|
|
132
|
+
return store.transaction((tx) => this.idempotent(tx, 'claim_promotion', input.idempotencyKey, input, async () => {
|
|
133
|
+
const now = this.now();
|
|
134
|
+
const policy = await this.requireGrantPolicy(tx, input.policyId, 'promotion');
|
|
135
|
+
await this.expireDueCreditLots(tx, now, input.customerId);
|
|
136
|
+
const current = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
|
|
137
|
+
const periodKey = 'claim';
|
|
138
|
+
const existing = await tx.getGrantPolicyApplicationByIdentity(policy.id, current.id, periodKey);
|
|
139
|
+
if (existing) {
|
|
140
|
+
const account = await this.requireAccountById(tx, current.id);
|
|
141
|
+
return {
|
|
142
|
+
policy,
|
|
143
|
+
application: existing,
|
|
144
|
+
account,
|
|
145
|
+
grant: existing.grantId ? await tx.getGrant(existing.grantId) : undefined,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const expiresAt = now + policy.expiresInMs;
|
|
149
|
+
if (!Number.isSafeInteger(expiresAt)) {
|
|
150
|
+
throw new Error('Promotion expiry exceeds the supported timestamp range');
|
|
151
|
+
}
|
|
152
|
+
const created = await this.createGrantInTransaction(tx, {
|
|
153
|
+
customerId: current.customerId,
|
|
154
|
+
amountUnits: parseCreditUnits(policy.amountUnits),
|
|
155
|
+
source: 'promotion',
|
|
156
|
+
policyId: policy.id,
|
|
157
|
+
expiresAt,
|
|
158
|
+
lotKind: 'promotion',
|
|
159
|
+
now,
|
|
160
|
+
metadata: input.metadata,
|
|
161
|
+
});
|
|
162
|
+
const application = {
|
|
163
|
+
id: createId('gpa'),
|
|
164
|
+
policyId: policy.id,
|
|
165
|
+
policyType: 'promotion',
|
|
166
|
+
accountId: created.account.id,
|
|
167
|
+
projectId: this.projectId,
|
|
168
|
+
customerId: created.account.customerId,
|
|
169
|
+
periodKey,
|
|
170
|
+
grantId: created.grant.id,
|
|
171
|
+
grantedAmount: created.grant.amount,
|
|
172
|
+
grantedUnits: created.grant.amountUnits,
|
|
173
|
+
createdAt: now,
|
|
174
|
+
metadata: input.metadata,
|
|
175
|
+
};
|
|
176
|
+
await tx.saveGrantPolicyApplication(application);
|
|
177
|
+
await this.saveOutboxEvent(tx, 'credit.promotion.claimed', { policy, application, account: created.account, grant: created.grant }, now);
|
|
178
|
+
return { policy, application, account: created.account, grant: created.grant };
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
async sweepExpiredCreditLots(input = {}) {
|
|
182
|
+
const store = this.requirePolicyStore();
|
|
183
|
+
const limit = input.limit ?? 100;
|
|
184
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
185
|
+
throw new Error('Credit lot sweep limit must be a positive integer');
|
|
186
|
+
}
|
|
187
|
+
const before = input.before ?? this.now();
|
|
188
|
+
if (!Number.isSafeInteger(before))
|
|
189
|
+
throw new Error('Credit lot sweep before must be an integer');
|
|
190
|
+
return store.transaction((tx) => this.expireDueCreditLots(tx, before, input.customerId, limit));
|
|
191
|
+
}
|
|
192
|
+
async grantCredits(input) {
|
|
193
|
+
const amountUnits = toCreditUnits(input.amount);
|
|
194
|
+
if (amountUnits <= 0n)
|
|
195
|
+
throw new Error('Credit grant amount must be positive');
|
|
196
|
+
const request = { ...input, amountUnits: amountUnits.toString() };
|
|
197
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'grant_credits', input.idempotencyKey, request, () => this.createGrantInTransaction(tx, {
|
|
198
|
+
customerId: input.customerId,
|
|
199
|
+
amountUnits,
|
|
200
|
+
source: input.source ?? 'manual',
|
|
201
|
+
externalRef: input.externalRef,
|
|
202
|
+
lotKind: 'general',
|
|
203
|
+
now: this.now(),
|
|
204
|
+
metadata: input.metadata,
|
|
205
|
+
})));
|
|
206
|
+
}
|
|
52
207
|
async adjustCredits(input) {
|
|
53
208
|
const deltaUnits = toSignedCreditUnits(input.amount);
|
|
54
209
|
if (deltaUnits === 0n)
|
|
55
210
|
throw new Error('Credit adjustment amount cannot be zero');
|
|
56
211
|
const request = { ...input, deltaUnits: deltaUnits.toString() };
|
|
57
|
-
return this.
|
|
212
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'adjust_credits', input.idempotencyKey, request, async () => {
|
|
58
213
|
const now = this.now();
|
|
214
|
+
if (isPolicyTransaction(tx))
|
|
215
|
+
await this.expireDueCreditLots(tx, now, input.customerId);
|
|
59
216
|
const current = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
|
|
60
217
|
const nextPosted = parseCreditUnits(current.postedUnits) + deltaUnits;
|
|
61
218
|
const reserved = parseCreditUnits(current.reservedUnits);
|
|
62
219
|
if (nextPosted < reserved) {
|
|
63
220
|
throw new InsufficientCreditsError((nextPosted - reserved).toString(), '0');
|
|
64
221
|
}
|
|
222
|
+
if (isPolicyTransaction(tx)) {
|
|
223
|
+
if (deltaUnits > 0n) {
|
|
224
|
+
await tx.saveCreditLot(createCreditLot({
|
|
225
|
+
account: current,
|
|
226
|
+
kind: 'general',
|
|
227
|
+
amountUnits: deltaUnits,
|
|
228
|
+
now,
|
|
229
|
+
metadata: { ...input.metadata, reason: input.reason },
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
await this.consumeAvailableLots(tx, current, -deltaUnits, now);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
65
236
|
const account = withBalances(current, nextPosted, reserved, now);
|
|
66
237
|
await tx.saveAccount(account);
|
|
67
238
|
const entry = await this.saveLedgerEntry(tx, account, 'adjustment', 'posted', deltaUnits, 'adjustment', createId('adj'), now, {
|
|
@@ -149,7 +320,7 @@ export class CreditLedger {
|
|
|
149
320
|
const amountUnits = toCreditUnits(input.amount);
|
|
150
321
|
const rail = input.rail ?? 'arc_direct';
|
|
151
322
|
const externalPaymentId = requireText(input.externalPaymentId ?? input.txHash ?? '', 'externalPaymentId');
|
|
152
|
-
return this.
|
|
323
|
+
return this.withStoreTransaction(async (tx) => {
|
|
153
324
|
const intent = await tx.getFundingIntent(input.fundingIntentId);
|
|
154
325
|
if (!intent || intent.projectId !== this.projectId)
|
|
155
326
|
throw new CreditNotFoundError('Funding intent', input.fundingIntentId);
|
|
@@ -189,6 +360,8 @@ export class CreditLedger {
|
|
|
189
360
|
: `Funding underpayment: ${amountUnits} < ${intent.requestedUnits}`);
|
|
190
361
|
}
|
|
191
362
|
const now = this.now();
|
|
363
|
+
if (isPolicyTransaction(tx))
|
|
364
|
+
await this.expireDueCreditLots(tx, now, intent.customerId);
|
|
192
365
|
const current = await this.requireAccountById(tx, intent.accountId);
|
|
193
366
|
const grant = {
|
|
194
367
|
id: createId('grant'),
|
|
@@ -237,6 +410,16 @@ export class CreditLedger {
|
|
|
237
410
|
metadata: input.metadata,
|
|
238
411
|
};
|
|
239
412
|
await tx.saveGrant(grant);
|
|
413
|
+
if (isPolicyTransaction(tx)) {
|
|
414
|
+
await tx.saveCreditLot(createCreditLot({
|
|
415
|
+
account: current,
|
|
416
|
+
kind: 'general',
|
|
417
|
+
amountUnits,
|
|
418
|
+
grantId: grant.id,
|
|
419
|
+
now,
|
|
420
|
+
metadata: grant.metadata,
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
240
423
|
await tx.saveAccount(account);
|
|
241
424
|
await tx.saveFundingIntent(confirmedIntent);
|
|
242
425
|
await tx.saveFundingTransaction(fundingTransaction);
|
|
@@ -299,9 +482,11 @@ export class CreditLedger {
|
|
|
299
482
|
}));
|
|
300
483
|
}
|
|
301
484
|
async reserveCredits(input) {
|
|
302
|
-
return this.
|
|
485
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'reserve_credits', input.idempotencyKey, input, async () => {
|
|
303
486
|
const now = this.now();
|
|
304
487
|
await this.expireOpenReservations(tx, now, input.customerId);
|
|
488
|
+
if (isPolicyTransaction(tx))
|
|
489
|
+
await this.expireDueCreditLots(tx, now, input.customerId);
|
|
305
490
|
const price = await this.requirePrice(tx, input.priceId);
|
|
306
491
|
const rating = rateUsage(price, input.estimatedUsage);
|
|
307
492
|
const reservedUnits = parseCreditUnits(rating.totalUnits);
|
|
@@ -330,6 +515,9 @@ export class CreditLedger {
|
|
|
330
515
|
};
|
|
331
516
|
const account = withBalances(current, parseCreditUnits(current.postedUnits), parseCreditUnits(current.reservedUnits) + reservedUnits, now);
|
|
332
517
|
await tx.saveReservation(reservation);
|
|
518
|
+
if (isPolicyTransaction(tx)) {
|
|
519
|
+
await this.reserveCreditLots(tx, current, reservation.id, reservedUnits, now);
|
|
520
|
+
}
|
|
333
521
|
await tx.saveAccount(account);
|
|
334
522
|
await this.saveLedgerEntry(tx, account, 'reserve', 'reserved', reservedUnits, 'reservation', reservation.id, now, input.metadata);
|
|
335
523
|
await this.saveOutboxEvent(tx, 'credit.reserved', { account, reservation }, now);
|
|
@@ -337,7 +525,7 @@ export class CreditLedger {
|
|
|
337
525
|
}));
|
|
338
526
|
}
|
|
339
527
|
async commitUsage(input) {
|
|
340
|
-
return this.
|
|
528
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'commit_usage', input.idempotencyKey, input, async () => {
|
|
341
529
|
const now = this.now();
|
|
342
530
|
const reservation = await this.requireReservation(tx, input.reservationId);
|
|
343
531
|
if (reservation.status === 'committed' && reservation.usageReceiptId) {
|
|
@@ -364,10 +552,16 @@ export class CreditLedger {
|
|
|
364
552
|
if (chargeUnits > reservedUnits) {
|
|
365
553
|
throw new InvalidCreditStateError(`Actual charge exceeds reservation: ${rating.totalUnits} > ${reservation.reservedUnits}`);
|
|
366
554
|
}
|
|
555
|
+
if (isPolicyTransaction(tx)) {
|
|
556
|
+
await this.expireDueCreditLots(tx, now, reservation.customerId);
|
|
557
|
+
}
|
|
367
558
|
const accountBefore = await this.requireAccountById(tx, reservation.accountId);
|
|
368
559
|
const postedBefore = parseCreditUnits(accountBefore.postedUnits);
|
|
369
560
|
const reservedBefore = parseCreditUnits(accountBefore.reservedUnits);
|
|
370
|
-
const
|
|
561
|
+
const lotResult = isPolicyTransaction(tx)
|
|
562
|
+
? await this.commitCreditLotAllocations(tx, reservation, chargeUnits, now)
|
|
563
|
+
: { allocations: undefined, expiredReleasedUnits: 0n, expiredLots: [] };
|
|
564
|
+
const account = withBalances(accountBefore, postedBefore - chargeUnits - lotResult.expiredReleasedUnits, reservedBefore - reservedUnits, now);
|
|
371
565
|
const releasedUnits = reservedUnits - chargeUnits;
|
|
372
566
|
const usageEvent = {
|
|
373
567
|
id: requireText(input.usageEventId, 'usageEventId'),
|
|
@@ -398,6 +592,7 @@ export class CreditLedger {
|
|
|
398
592
|
balanceBeforeUnits: accountBefore.availableUnits,
|
|
399
593
|
balanceAfterUnits: account.availableUnits,
|
|
400
594
|
createdAt: now,
|
|
595
|
+
allocations: lotResult.allocations,
|
|
401
596
|
metadata: input.metadata,
|
|
402
597
|
};
|
|
403
598
|
const committedReservation = {
|
|
@@ -416,6 +611,10 @@ export class CreditLedger {
|
|
|
416
611
|
await tx.saveAccount(account);
|
|
417
612
|
await this.saveLedgerEntry(tx, account, 'charge', 'posted', -chargeUnits, 'usage_receipt', receipt.id, now, input.metadata);
|
|
418
613
|
await this.saveLedgerEntry(tx, account, 'release', 'reserved', -reservedUnits, 'usage_receipt', receipt.id, now, input.metadata);
|
|
614
|
+
for (const expired of lotResult.expiredLots) {
|
|
615
|
+
await this.saveLedgerEntry(tx, account, 'expire', 'posted', -expired.units, 'credit_lot', expired.lot.id, now, { reason: 'released_after_expiry', reservationId: reservation.id });
|
|
616
|
+
await this.saveOutboxEvent(tx, 'credit.lot.expired', { account, lot: expired.lot, expiredUnits: expired.units.toString() }, now);
|
|
617
|
+
}
|
|
419
618
|
await this.saveOutboxEvent(tx, 'usage.charged', {
|
|
420
619
|
usageReceiptId: receipt.id,
|
|
421
620
|
accountId: account.id,
|
|
@@ -425,12 +624,13 @@ export class CreditLedger {
|
|
|
425
624
|
amountUnits: receipt.amountUnits,
|
|
426
625
|
releasedAmount: receipt.releasedAmount,
|
|
427
626
|
balanceAfterUnits: receipt.balanceAfterUnits,
|
|
627
|
+
allocations: receipt.allocations,
|
|
428
628
|
}, now);
|
|
429
629
|
return { receipt, reservation: committedReservation, balance: account };
|
|
430
630
|
}));
|
|
431
631
|
}
|
|
432
632
|
async releaseReservation(input) {
|
|
433
|
-
return this.
|
|
633
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'release_reservation', input.idempotencyKey, input, async () => {
|
|
434
634
|
const reservation = await this.requireReservation(tx, input.reservationId);
|
|
435
635
|
if (reservation.status === 'released' || reservation.status === 'expired') {
|
|
436
636
|
return { reservation, balance: await this.requireAccountById(tx, reservation.accountId) };
|
|
@@ -442,7 +642,7 @@ export class CreditLedger {
|
|
|
442
642
|
}));
|
|
443
643
|
}
|
|
444
644
|
async releaseExpiredReservations(input) {
|
|
445
|
-
return this.
|
|
645
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'release_expired', input.idempotencyKey, input, async () => this.expireOpenReservations(tx, input.now ?? this.now())));
|
|
446
646
|
}
|
|
447
647
|
async runMetered(input, callback) {
|
|
448
648
|
const reserved = await this.reserveCredits(input);
|
|
@@ -479,11 +679,59 @@ export class CreditLedger {
|
|
|
479
679
|
return { value: result.value, replayed: false, ...committed };
|
|
480
680
|
}
|
|
481
681
|
async getBalance(customerId) {
|
|
682
|
+
if (isCreditPolicyStore(this.store)) {
|
|
683
|
+
return this.store.transaction(async (tx) => {
|
|
684
|
+
const normalized = requireText(customerId, 'customerId');
|
|
685
|
+
await this.expireDueCreditLots(tx, this.now(), normalized);
|
|
686
|
+
const account = await tx.getAccountByCustomer(this.projectId, normalized);
|
|
687
|
+
if (!account)
|
|
688
|
+
throw new CreditNotFoundError('Credit account', customerId);
|
|
689
|
+
return account;
|
|
690
|
+
});
|
|
691
|
+
}
|
|
482
692
|
const account = await this.store.getAccountByCustomer(this.projectId, requireText(customerId, 'customerId'));
|
|
483
693
|
if (!account)
|
|
484
694
|
throw new CreditNotFoundError('Credit account', customerId);
|
|
485
695
|
return account;
|
|
486
696
|
}
|
|
697
|
+
async getGrantPolicy(id) {
|
|
698
|
+
return this.forCurrentProject(await this.requirePolicyStore().getGrantPolicy(id));
|
|
699
|
+
}
|
|
700
|
+
listGrantPolicies() {
|
|
701
|
+
return this.requirePolicyStore().listGrantPolicies(this.projectId);
|
|
702
|
+
}
|
|
703
|
+
async getCreditLot(id) {
|
|
704
|
+
const store = this.requirePolicyStore();
|
|
705
|
+
const value = this.forCurrentProject(await store.getCreditLot(id));
|
|
706
|
+
if (!value)
|
|
707
|
+
return undefined;
|
|
708
|
+
await this.getBalance(value.customerId);
|
|
709
|
+
return this.forCurrentProject(await store.getCreditLot(id));
|
|
710
|
+
}
|
|
711
|
+
async listCreditLots(filter = {}) {
|
|
712
|
+
const store = this.requirePolicyStore();
|
|
713
|
+
const normalized = typeof filter === 'string' ? { customerId: filter } : filter;
|
|
714
|
+
if (normalized.customerId)
|
|
715
|
+
await this.getBalance(normalized.customerId);
|
|
716
|
+
else {
|
|
717
|
+
await store.transaction((tx) => this.expireDueCreditLots(tx, this.now(), undefined, Number.MAX_SAFE_INTEGER));
|
|
718
|
+
}
|
|
719
|
+
return store.listCreditLots({ ...normalized, projectId: this.projectId });
|
|
720
|
+
}
|
|
721
|
+
async listGrantPolicyApplications(filter = {}) {
|
|
722
|
+
const store = this.requirePolicyStore();
|
|
723
|
+
const normalized = typeof filter === 'string' ? { customerId: filter } : filter;
|
|
724
|
+
if (normalized.customerId)
|
|
725
|
+
await this.getBalance(normalized.customerId);
|
|
726
|
+
return store.listGrantPolicyApplications({ ...normalized, projectId: this.projectId });
|
|
727
|
+
}
|
|
728
|
+
async getGrantPolicyApplication(id) {
|
|
729
|
+
return this.forCurrentProject(await this.requirePolicyStore().getGrantPolicyApplication(id));
|
|
730
|
+
}
|
|
731
|
+
async listCreditLotAllocations(reservationId) {
|
|
732
|
+
const allocations = await this.requirePolicyStore().listCreditLotAllocations(reservationId);
|
|
733
|
+
return this.forCurrentProjectList(allocations);
|
|
734
|
+
}
|
|
487
735
|
async getReservation(id) {
|
|
488
736
|
return this.forCurrentProject(await this.store.getReservation(id));
|
|
489
737
|
}
|
|
@@ -535,6 +783,209 @@ export class CreditLedger {
|
|
|
535
783
|
return delivered;
|
|
536
784
|
}));
|
|
537
785
|
}
|
|
786
|
+
withStoreTransaction(handler) {
|
|
787
|
+
if (isCreditPolicyStore(this.store))
|
|
788
|
+
return this.store.transaction(handler);
|
|
789
|
+
return this.store.transaction(handler);
|
|
790
|
+
}
|
|
791
|
+
requirePolicyStore() {
|
|
792
|
+
if (!isCreditPolicyStore(this.store))
|
|
793
|
+
throw new UnsupportedCreditStoreCapabilityError();
|
|
794
|
+
return this.store;
|
|
795
|
+
}
|
|
796
|
+
async requireGrantPolicy(tx, id, type) {
|
|
797
|
+
const policy = await tx.getGrantPolicy(id);
|
|
798
|
+
if (!policy || policy.projectId !== this.projectId) {
|
|
799
|
+
throw new CreditNotFoundError('Grant policy', id);
|
|
800
|
+
}
|
|
801
|
+
if (policy.type !== type) {
|
|
802
|
+
throw new InvalidCreditStateError(`Grant policy ${policy.id} is ${policy.type}, not ${type}`);
|
|
803
|
+
}
|
|
804
|
+
return policy;
|
|
805
|
+
}
|
|
806
|
+
async createGrantInTransaction(tx, input) {
|
|
807
|
+
if (input.amountUnits <= 0n)
|
|
808
|
+
throw new Error('Credit grant amount must be positive');
|
|
809
|
+
if (isPolicyTransaction(tx)) {
|
|
810
|
+
await this.expireDueCreditLots(tx, input.now, input.customerId);
|
|
811
|
+
}
|
|
812
|
+
const current = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
|
|
813
|
+
const grant = {
|
|
814
|
+
id: createId('grant'),
|
|
815
|
+
accountId: current.id,
|
|
816
|
+
projectId: this.projectId,
|
|
817
|
+
customerId: current.customerId,
|
|
818
|
+
amount: creditUnitsToString(input.amountUnits),
|
|
819
|
+
amountUnits: input.amountUnits.toString(),
|
|
820
|
+
source: input.source,
|
|
821
|
+
externalRef: input.externalRef,
|
|
822
|
+
policyId: input.policyId,
|
|
823
|
+
expiresAt: input.expiresAt,
|
|
824
|
+
createdAt: input.now,
|
|
825
|
+
metadata: input.metadata,
|
|
826
|
+
};
|
|
827
|
+
const account = withBalances(current, parseCreditUnits(current.postedUnits) + input.amountUnits, parseCreditUnits(current.reservedUnits), input.now);
|
|
828
|
+
await tx.saveGrant(grant);
|
|
829
|
+
if (isPolicyTransaction(tx)) {
|
|
830
|
+
await tx.saveCreditLot(createCreditLot({
|
|
831
|
+
account: current,
|
|
832
|
+
kind: input.lotKind,
|
|
833
|
+
amountUnits: input.amountUnits,
|
|
834
|
+
grantId: grant.id,
|
|
835
|
+
policyId: input.policyId,
|
|
836
|
+
expiresAt: input.expiresAt,
|
|
837
|
+
now: input.now,
|
|
838
|
+
metadata: input.metadata,
|
|
839
|
+
}));
|
|
840
|
+
}
|
|
841
|
+
await tx.saveAccount(account);
|
|
842
|
+
await this.saveLedgerEntry(tx, account, 'grant', 'posted', input.amountUnits, 'grant', grant.id, input.now, input.metadata);
|
|
843
|
+
await this.saveOutboxEvent(tx, 'credit.granted', { account, grant }, input.now);
|
|
844
|
+
return { account, grant };
|
|
845
|
+
}
|
|
846
|
+
async expireDueCreditLots(tx, before, customerId, limit = Number.MAX_SAFE_INTEGER) {
|
|
847
|
+
const due = (await tx.listCreditLots({
|
|
848
|
+
projectId: this.projectId,
|
|
849
|
+
customerId,
|
|
850
|
+
expiresBefore: before,
|
|
851
|
+
}))
|
|
852
|
+
.filter((lot) => parseCreditUnits(lot.availableUnits) > 0n)
|
|
853
|
+
.sort(compareCreditLots)
|
|
854
|
+
.slice(0, limit);
|
|
855
|
+
const accounts = new Map();
|
|
856
|
+
const lots = [];
|
|
857
|
+
for (const lot of due) {
|
|
858
|
+
const availableUnits = parseCreditUnits(lot.availableUnits);
|
|
859
|
+
if (availableUnits === 0n)
|
|
860
|
+
continue;
|
|
861
|
+
const accountBefore = accounts.get(lot.accountId) ?? (await this.requireAccountById(tx, lot.accountId));
|
|
862
|
+
const account = withBalances(accountBefore, parseCreditUnits(accountBefore.postedUnits) - availableUnits, parseCreditUnits(accountBefore.reservedUnits), before);
|
|
863
|
+
const expired = withCreditLotBalances(lot, 0n, parseCreditUnits(lot.reservedUnits), parseCreditUnits(lot.consumedUnits), parseCreditUnits(lot.expiredUnits) + availableUnits, before);
|
|
864
|
+
await tx.saveCreditLot(expired);
|
|
865
|
+
await tx.saveAccount(account);
|
|
866
|
+
await this.saveLedgerEntry(tx, account, 'expire', 'posted', -availableUnits, 'credit_lot', lot.id, before, { reason: 'lot_expired' });
|
|
867
|
+
await this.saveOutboxEvent(tx, 'credit.lot.expired', { account, lot: expired, expiredUnits: availableUnits.toString() }, before);
|
|
868
|
+
accounts.set(account.id, account);
|
|
869
|
+
lots.push(expired);
|
|
870
|
+
}
|
|
871
|
+
return { lots, accounts: [...accounts.values()] };
|
|
872
|
+
}
|
|
873
|
+
async reserveCreditLots(tx, account, reservationId, amountUnits, now) {
|
|
874
|
+
let remaining = amountUnits;
|
|
875
|
+
const lots = (await tx.listCreditLots({
|
|
876
|
+
projectId: this.projectId,
|
|
877
|
+
customerId: account.customerId,
|
|
878
|
+
}))
|
|
879
|
+
.filter((lot) => lot.accountId === account.id &&
|
|
880
|
+
parseCreditUnits(lot.availableUnits) > 0n &&
|
|
881
|
+
(lot.expiresAt === undefined || lot.expiresAt > now))
|
|
882
|
+
.sort(compareCreditLots);
|
|
883
|
+
const allocations = [];
|
|
884
|
+
for (const lot of lots) {
|
|
885
|
+
if (remaining === 0n)
|
|
886
|
+
break;
|
|
887
|
+
const available = parseCreditUnits(lot.availableUnits);
|
|
888
|
+
const allocated = available < remaining ? available : remaining;
|
|
889
|
+
const updatedLot = withCreditLotBalances(lot, available - allocated, parseCreditUnits(lot.reservedUnits) + allocated, parseCreditUnits(lot.consumedUnits), parseCreditUnits(lot.expiredUnits), now);
|
|
890
|
+
const allocation = createCreditLotAllocation({
|
|
891
|
+
reservationId,
|
|
892
|
+
lot,
|
|
893
|
+
amountUnits: allocated,
|
|
894
|
+
now,
|
|
895
|
+
});
|
|
896
|
+
await tx.saveCreditLot(updatedLot);
|
|
897
|
+
await tx.saveCreditLotAllocation(allocation);
|
|
898
|
+
allocations.push(allocation);
|
|
899
|
+
remaining -= allocated;
|
|
900
|
+
}
|
|
901
|
+
if (remaining !== 0n) {
|
|
902
|
+
throw new InvalidCreditStateError(`Credit lot balance is missing ${remaining.toString()} units for account ${account.id}`);
|
|
903
|
+
}
|
|
904
|
+
return allocations;
|
|
905
|
+
}
|
|
906
|
+
async commitCreditLotAllocations(tx, reservation, chargeUnits, now) {
|
|
907
|
+
const allocations = await tx.listCreditLotAllocations(reservation.id);
|
|
908
|
+
const allocatedTotal = allocations.reduce((total, allocation) => total + parseCreditUnits(allocation.reservedUnits), 0n);
|
|
909
|
+
if (allocatedTotal !== parseCreditUnits(reservation.reservedUnits)) {
|
|
910
|
+
throw new InvalidCreditStateError(`Reservation lot allocations are incomplete: ${reservation.id}`);
|
|
911
|
+
}
|
|
912
|
+
let remainingCharge = chargeUnits;
|
|
913
|
+
let expiredReleasedUnits = 0n;
|
|
914
|
+
const updated = [];
|
|
915
|
+
const expiredLots = [];
|
|
916
|
+
const allocationLots = await Promise.all(allocations.map(async (allocation) => {
|
|
917
|
+
const lot = await tx.getCreditLot(allocation.lotId);
|
|
918
|
+
if (!lot)
|
|
919
|
+
throw new CreditNotFoundError('Credit lot', allocation.lotId);
|
|
920
|
+
return { allocation, lot };
|
|
921
|
+
}));
|
|
922
|
+
allocationLots.sort((left, right) => compareCreditLots(left.lot, right.lot));
|
|
923
|
+
for (const { allocation, lot } of allocationLots) {
|
|
924
|
+
const reserved = parseCreditUnits(allocation.reservedUnits);
|
|
925
|
+
const consumed = reserved < remainingCharge ? reserved : remainingCharge;
|
|
926
|
+
const remainder = reserved - consumed;
|
|
927
|
+
const expired = lot.expiresAt !== undefined && lot.expiresAt <= now;
|
|
928
|
+
const updatedLot = withCreditLotBalances(lot, parseCreditUnits(lot.availableUnits) + (expired ? 0n : remainder), parseCreditUnits(lot.reservedUnits) - reserved, parseCreditUnits(lot.consumedUnits) + consumed, parseCreditUnits(lot.expiredUnits) + (expired ? remainder : 0n), now);
|
|
929
|
+
const updatedAllocation = withCreditLotAllocationBalances(allocation, 0n, parseCreditUnits(allocation.consumedUnits) + consumed, parseCreditUnits(allocation.releasedUnits) + (expired ? 0n : remainder), parseCreditUnits(allocation.expiredUnits) + (expired ? remainder : 0n), now);
|
|
930
|
+
await tx.saveCreditLot(updatedLot);
|
|
931
|
+
await tx.saveCreditLotAllocation(updatedAllocation);
|
|
932
|
+
updated.push(updatedAllocation);
|
|
933
|
+
remainingCharge -= consumed;
|
|
934
|
+
if (expired && remainder > 0n) {
|
|
935
|
+
expiredReleasedUnits += remainder;
|
|
936
|
+
expiredLots.push({ lot: updatedLot, units: remainder });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (remainingCharge !== 0n) {
|
|
940
|
+
throw new InvalidCreditStateError(`Reservation cannot cover charge: ${reservation.id}`);
|
|
941
|
+
}
|
|
942
|
+
return { allocations: updated, expiredReleasedUnits, expiredLots };
|
|
943
|
+
}
|
|
944
|
+
async releaseCreditLotAllocations(tx, reservation, now) {
|
|
945
|
+
const allocations = await tx.listCreditLotAllocations(reservation.id);
|
|
946
|
+
const allocatedTotal = allocations.reduce((total, allocation) => total + parseCreditUnits(allocation.reservedUnits), 0n);
|
|
947
|
+
if (allocatedTotal !== parseCreditUnits(reservation.reservedUnits)) {
|
|
948
|
+
throw new InvalidCreditStateError(`Reservation lot allocations are incomplete: ${reservation.id}`);
|
|
949
|
+
}
|
|
950
|
+
let expiredReleasedUnits = 0n;
|
|
951
|
+
const expiredLots = [];
|
|
952
|
+
for (const allocation of allocations) {
|
|
953
|
+
const lot = await tx.getCreditLot(allocation.lotId);
|
|
954
|
+
if (!lot)
|
|
955
|
+
throw new CreditNotFoundError('Credit lot', allocation.lotId);
|
|
956
|
+
const reserved = parseCreditUnits(allocation.reservedUnits);
|
|
957
|
+
const expired = lot.expiresAt !== undefined && lot.expiresAt <= now;
|
|
958
|
+
const updatedLot = withCreditLotBalances(lot, parseCreditUnits(lot.availableUnits) + (expired ? 0n : reserved), parseCreditUnits(lot.reservedUnits) - reserved, parseCreditUnits(lot.consumedUnits), parseCreditUnits(lot.expiredUnits) + (expired ? reserved : 0n), now);
|
|
959
|
+
const updatedAllocation = withCreditLotAllocationBalances(allocation, 0n, parseCreditUnits(allocation.consumedUnits), parseCreditUnits(allocation.releasedUnits) + (expired ? 0n : reserved), parseCreditUnits(allocation.expiredUnits) + (expired ? reserved : 0n), now);
|
|
960
|
+
await tx.saveCreditLot(updatedLot);
|
|
961
|
+
await tx.saveCreditLotAllocation(updatedAllocation);
|
|
962
|
+
if (expired && reserved > 0n) {
|
|
963
|
+
expiredReleasedUnits += reserved;
|
|
964
|
+
expiredLots.push({ lot: updatedLot, units: reserved });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return { expiredReleasedUnits, expiredLots };
|
|
968
|
+
}
|
|
969
|
+
async consumeAvailableLots(tx, account, amountUnits, now) {
|
|
970
|
+
let remaining = amountUnits;
|
|
971
|
+
const lots = (await tx.listCreditLots({
|
|
972
|
+
projectId: this.projectId,
|
|
973
|
+
customerId: account.customerId,
|
|
974
|
+
}))
|
|
975
|
+
.filter((lot) => lot.accountId === account.id && parseCreditUnits(lot.availableUnits) > 0n)
|
|
976
|
+
.sort(compareCreditLots);
|
|
977
|
+
for (const lot of lots) {
|
|
978
|
+
if (remaining === 0n)
|
|
979
|
+
break;
|
|
980
|
+
const available = parseCreditUnits(lot.availableUnits);
|
|
981
|
+
const consumed = available < remaining ? available : remaining;
|
|
982
|
+
await tx.saveCreditLot(withCreditLotBalances(lot, available - consumed, parseCreditUnits(lot.reservedUnits), parseCreditUnits(lot.consumedUnits) + consumed, parseCreditUnits(lot.expiredUnits), now));
|
|
983
|
+
remaining -= consumed;
|
|
984
|
+
}
|
|
985
|
+
if (remaining !== 0n) {
|
|
986
|
+
throw new InvalidCreditStateError(`Credit lot balance is missing ${remaining.toString()} units for account ${account.id}`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
538
989
|
async ensureAccountInTransaction(tx, customerIdValue, metadata) {
|
|
539
990
|
const customerId = requireText(customerIdValue, 'customerId');
|
|
540
991
|
const existing = await tx.getAccountByCustomer(this.projectId, customerId);
|
|
@@ -576,9 +1027,15 @@ export class CreditLedger {
|
|
|
576
1027
|
return this.closeReservation(tx, reservation, 'expired', now, 'ttl_expired');
|
|
577
1028
|
}
|
|
578
1029
|
async closeReservation(tx, reservation, status, now, reason) {
|
|
1030
|
+
if (isPolicyTransaction(tx)) {
|
|
1031
|
+
await this.expireDueCreditLots(tx, now, reservation.customerId);
|
|
1032
|
+
}
|
|
579
1033
|
const accountBefore = await this.requireAccountById(tx, reservation.accountId);
|
|
580
1034
|
const reservedUnits = parseCreditUnits(reservation.reservedUnits);
|
|
581
|
-
const
|
|
1035
|
+
const lotResult = isPolicyTransaction(tx)
|
|
1036
|
+
? await this.releaseCreditLotAllocations(tx, reservation, now)
|
|
1037
|
+
: { expiredReleasedUnits: 0n, expiredLots: [] };
|
|
1038
|
+
const account = withBalances(accountBefore, parseCreditUnits(accountBefore.postedUnits) - lotResult.expiredReleasedUnits, parseCreditUnits(accountBefore.reservedUnits) - reservedUnits, now);
|
|
582
1039
|
const closed = {
|
|
583
1040
|
...reservation,
|
|
584
1041
|
status,
|
|
@@ -589,6 +1046,10 @@ export class CreditLedger {
|
|
|
589
1046
|
await tx.saveReservation(closed);
|
|
590
1047
|
await tx.saveAccount(account);
|
|
591
1048
|
await this.saveLedgerEntry(tx, account, 'release', 'reserved', -reservedUnits, 'reservation', reservation.id, now, { reason });
|
|
1049
|
+
for (const expired of lotResult.expiredLots) {
|
|
1050
|
+
await this.saveLedgerEntry(tx, account, 'expire', 'posted', -expired.units, 'credit_lot', expired.lot.id, now, { reason: 'released_after_expiry', reservationId: reservation.id });
|
|
1051
|
+
await this.saveOutboxEvent(tx, 'credit.lot.expired', { account, lot: expired.lot, expiredUnits: expired.units.toString() }, now);
|
|
1052
|
+
}
|
|
592
1053
|
await this.saveOutboxEvent(tx, status === 'expired' ? 'credit.expired' : 'credit.released', { account, reservation: closed, reason }, now);
|
|
593
1054
|
return { reservation: closed, balance: account };
|
|
594
1055
|
}
|
|
@@ -689,6 +1150,160 @@ function withBalances(account, posted, reserved, updatedAt) {
|
|
|
689
1150
|
updatedAt,
|
|
690
1151
|
};
|
|
691
1152
|
}
|
|
1153
|
+
function isPolicyTransaction(transaction) {
|
|
1154
|
+
const value = transaction;
|
|
1155
|
+
return (typeof value.getGrantPolicy === 'function' &&
|
|
1156
|
+
typeof value.listGrantPolicies === 'function' &&
|
|
1157
|
+
typeof value.getCreditLot === 'function' &&
|
|
1158
|
+
typeof value.listCreditLots === 'function' &&
|
|
1159
|
+
typeof value.listCreditLotAllocations === 'function' &&
|
|
1160
|
+
typeof value.getGrantPolicyApplicationByIdentity === 'function' &&
|
|
1161
|
+
typeof value.saveGrantPolicy === 'function' &&
|
|
1162
|
+
typeof value.saveCreditLot === 'function' &&
|
|
1163
|
+
typeof value.saveCreditLotAllocation === 'function' &&
|
|
1164
|
+
typeof value.saveGrantPolicyApplication === 'function');
|
|
1165
|
+
}
|
|
1166
|
+
function createCreditLot(input) {
|
|
1167
|
+
if (input.amountUnits <= 0n) {
|
|
1168
|
+
throw new InvalidCreditStateError('Credit lot amount must be positive');
|
|
1169
|
+
}
|
|
1170
|
+
if (input.expiresAt !== undefined && input.expiresAt <= input.now) {
|
|
1171
|
+
throw new InvalidCreditStateError('Credit lot expiry must be in the future');
|
|
1172
|
+
}
|
|
1173
|
+
const amount = creditUnitsToString(input.amountUnits);
|
|
1174
|
+
return {
|
|
1175
|
+
id: createId('lot'),
|
|
1176
|
+
accountId: input.account.id,
|
|
1177
|
+
projectId: input.account.projectId,
|
|
1178
|
+
customerId: input.account.customerId,
|
|
1179
|
+
kind: input.kind,
|
|
1180
|
+
grantId: input.grantId,
|
|
1181
|
+
policyId: input.policyId,
|
|
1182
|
+
originalAmount: amount,
|
|
1183
|
+
originalUnits: input.amountUnits.toString(),
|
|
1184
|
+
availableAmount: amount,
|
|
1185
|
+
availableUnits: input.amountUnits.toString(),
|
|
1186
|
+
reservedAmount: '0',
|
|
1187
|
+
reservedUnits: '0',
|
|
1188
|
+
consumedAmount: '0',
|
|
1189
|
+
consumedUnits: '0',
|
|
1190
|
+
expiredAmount: '0',
|
|
1191
|
+
expiredUnits: '0',
|
|
1192
|
+
createdAt: input.now,
|
|
1193
|
+
updatedAt: input.now,
|
|
1194
|
+
expiresAt: input.expiresAt,
|
|
1195
|
+
metadata: input.metadata,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
function withCreditLotBalances(lot, available, reserved, consumed, expired, updatedAt) {
|
|
1199
|
+
if (available < 0n || reserved < 0n || consumed < 0n || expired < 0n) {
|
|
1200
|
+
throw new InvalidCreditStateError(`Credit lot balance cannot be negative: ${lot.id}`);
|
|
1201
|
+
}
|
|
1202
|
+
const original = parseCreditUnits(lot.originalUnits);
|
|
1203
|
+
if (available + reserved + consumed + expired !== original) {
|
|
1204
|
+
throw new InvalidCreditStateError(`Credit lot invariant violated: ${lot.id}`);
|
|
1205
|
+
}
|
|
1206
|
+
return {
|
|
1207
|
+
...lot,
|
|
1208
|
+
availableAmount: creditUnitsToString(available),
|
|
1209
|
+
availableUnits: available.toString(),
|
|
1210
|
+
reservedAmount: creditUnitsToString(reserved),
|
|
1211
|
+
reservedUnits: reserved.toString(),
|
|
1212
|
+
consumedAmount: creditUnitsToString(consumed),
|
|
1213
|
+
consumedUnits: consumed.toString(),
|
|
1214
|
+
expiredAmount: creditUnitsToString(expired),
|
|
1215
|
+
expiredUnits: expired.toString(),
|
|
1216
|
+
updatedAt,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
function createCreditLotAllocation(input) {
|
|
1220
|
+
if (input.amountUnits <= 0n) {
|
|
1221
|
+
throw new InvalidCreditStateError('Credit lot allocation amount must be positive');
|
|
1222
|
+
}
|
|
1223
|
+
const amount = creditUnitsToString(input.amountUnits);
|
|
1224
|
+
return {
|
|
1225
|
+
id: `cla_${createHash('sha256')
|
|
1226
|
+
.update(`${input.reservationId}\u0000${input.lot.id}`)
|
|
1227
|
+
.digest('hex')
|
|
1228
|
+
.slice(0, 24)}`,
|
|
1229
|
+
reservationId: input.reservationId,
|
|
1230
|
+
lotId: input.lot.id,
|
|
1231
|
+
accountId: input.lot.accountId,
|
|
1232
|
+
projectId: input.lot.projectId,
|
|
1233
|
+
customerId: input.lot.customerId,
|
|
1234
|
+
allocatedAmount: amount,
|
|
1235
|
+
allocatedUnits: input.amountUnits.toString(),
|
|
1236
|
+
reservedAmount: amount,
|
|
1237
|
+
reservedUnits: input.amountUnits.toString(),
|
|
1238
|
+
consumedAmount: '0',
|
|
1239
|
+
consumedUnits: '0',
|
|
1240
|
+
releasedAmount: '0',
|
|
1241
|
+
releasedUnits: '0',
|
|
1242
|
+
expiredAmount: '0',
|
|
1243
|
+
expiredUnits: '0',
|
|
1244
|
+
createdAt: input.now,
|
|
1245
|
+
updatedAt: input.now,
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
function withCreditLotAllocationBalances(allocation, reserved, consumed, released, expired, updatedAt) {
|
|
1249
|
+
if (reserved < 0n || consumed < 0n || released < 0n || expired < 0n) {
|
|
1250
|
+
throw new InvalidCreditStateError(`Credit lot allocation balance cannot be negative: ${allocation.id}`);
|
|
1251
|
+
}
|
|
1252
|
+
const allocated = parseCreditUnits(allocation.allocatedUnits);
|
|
1253
|
+
if (reserved + consumed + released + expired !== allocated) {
|
|
1254
|
+
throw new InvalidCreditStateError(`Credit lot allocation invariant violated: ${allocation.id}`);
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
...allocation,
|
|
1258
|
+
reservedAmount: creditUnitsToString(reserved),
|
|
1259
|
+
reservedUnits: reserved.toString(),
|
|
1260
|
+
consumedAmount: creditUnitsToString(consumed),
|
|
1261
|
+
consumedUnits: consumed.toString(),
|
|
1262
|
+
releasedAmount: creditUnitsToString(released),
|
|
1263
|
+
releasedUnits: released.toString(),
|
|
1264
|
+
expiredAmount: creditUnitsToString(expired),
|
|
1265
|
+
expiredUnits: expired.toString(),
|
|
1266
|
+
updatedAt,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function compareCreditLots(left, right) {
|
|
1270
|
+
const priority = (lot) => {
|
|
1271
|
+
if (lot.kind === 'promotion')
|
|
1272
|
+
return 0;
|
|
1273
|
+
if (lot.kind === 'allowance')
|
|
1274
|
+
return 1;
|
|
1275
|
+
return 2;
|
|
1276
|
+
};
|
|
1277
|
+
const priorityDifference = priority(left) - priority(right);
|
|
1278
|
+
if (priorityDifference !== 0)
|
|
1279
|
+
return priorityDifference;
|
|
1280
|
+
if (left.kind === 'promotion' && right.kind === 'promotion') {
|
|
1281
|
+
const expiryDifference = (left.expiresAt ?? Number.MAX_SAFE_INTEGER) - (right.expiresAt ?? Number.MAX_SAFE_INTEGER);
|
|
1282
|
+
if (expiryDifference !== 0)
|
|
1283
|
+
return expiryDifference;
|
|
1284
|
+
}
|
|
1285
|
+
return left.createdAt - right.createdAt || left.id.localeCompare(right.id);
|
|
1286
|
+
}
|
|
1287
|
+
function allowancePeriodKey(timestamp, cadence) {
|
|
1288
|
+
if (!Number.isSafeInteger(timestamp))
|
|
1289
|
+
throw new Error('Clock must return an integer timestamp');
|
|
1290
|
+
const date = new Date(timestamp);
|
|
1291
|
+
if (Number.isNaN(date.getTime()))
|
|
1292
|
+
throw new Error('Clock returned an invalid timestamp');
|
|
1293
|
+
const day = date.toISOString().slice(0, 10);
|
|
1294
|
+
if (cadence === 'day')
|
|
1295
|
+
return `day:${day}`;
|
|
1296
|
+
if (cadence === 'month')
|
|
1297
|
+
return `month:${day.slice(0, 7)}`;
|
|
1298
|
+
if (cadence === 'week') {
|
|
1299
|
+
const utcDay = date.getUTCDay();
|
|
1300
|
+
const daysSinceMonday = (utcDay + 6) % 7;
|
|
1301
|
+
const monday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
1302
|
+
monday.setUTCDate(monday.getUTCDate() - daysSinceMonday);
|
|
1303
|
+
return `week:${monday.toISOString().slice(0, 10)}`;
|
|
1304
|
+
}
|
|
1305
|
+
throw new Error(`Unsupported allowance cadence: ${String(cadence)}`);
|
|
1306
|
+
}
|
|
692
1307
|
function normalizeUsage(usage) {
|
|
693
1308
|
return Object.fromEntries(Object.entries(usage).sort(([a], [b]) => a.localeCompare(b)));
|
|
694
1309
|
}
|