@resvary/sdk 0.6.1 → 0.8.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 +3 -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 +66 -1
- package/dist/credits/ledger.d.ts.map +1 -1
- package/dist/credits/ledger.js +650 -29
- 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 +134 -4
- package/dist/credits/types.d.ts.map +1 -1
- package/dist/pricing/index.d.ts +1 -1
- package/dist/pricing/index.d.ts.map +1 -1
- package/dist/pricing/rating.d.ts +15 -8
- package/dist/pricing/rating.d.ts.map +1 -1
- package/dist/pricing/rating.js +126 -16
- package/dist/pricing/rating.js.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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
125
|
+
await tx.saveGrantPolicyApplication(application);
|
|
126
|
+
await this.saveOutboxEvent(tx, 'credit.allowance.applied', { policy, application, account, grant }, now);
|
|
127
|
+
return { policy, application, account, grant };
|
|
128
|
+
}));
|
|
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 };
|
|
50
179
|
}));
|
|
51
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, {
|
|
@@ -100,15 +271,21 @@ export class CreditLedger {
|
|
|
100
271
|
if (!meter)
|
|
101
272
|
throw new CreditNotFoundError('Meter', input.meterKey);
|
|
102
273
|
const versions = await tx.listPriceVersions(meter.id);
|
|
103
|
-
const
|
|
274
|
+
const base = {
|
|
104
275
|
id: createId('price'),
|
|
105
276
|
projectId: this.projectId,
|
|
106
277
|
meter,
|
|
107
278
|
version: versions.reduce((max, item) => Math.max(max, item.version), 0) + 1,
|
|
108
|
-
rates: input.rates,
|
|
109
279
|
createdAt: this.now(),
|
|
110
280
|
metadata: input.metadata,
|
|
111
|
-
}
|
|
281
|
+
};
|
|
282
|
+
const price = 'components' in input
|
|
283
|
+
? createPriceVersion({
|
|
284
|
+
...base,
|
|
285
|
+
rates: input.rates,
|
|
286
|
+
components: input.components,
|
|
287
|
+
})
|
|
288
|
+
: createPriceVersion({ ...base, rates: input.rates });
|
|
112
289
|
await tx.savePriceVersion(price);
|
|
113
290
|
return price;
|
|
114
291
|
}));
|
|
@@ -149,7 +326,7 @@ export class CreditLedger {
|
|
|
149
326
|
const amountUnits = toCreditUnits(input.amount);
|
|
150
327
|
const rail = input.rail ?? 'arc_direct';
|
|
151
328
|
const externalPaymentId = requireText(input.externalPaymentId ?? input.txHash ?? '', 'externalPaymentId');
|
|
152
|
-
return this.
|
|
329
|
+
return this.withStoreTransaction(async (tx) => {
|
|
153
330
|
const intent = await tx.getFundingIntent(input.fundingIntentId);
|
|
154
331
|
if (!intent || intent.projectId !== this.projectId)
|
|
155
332
|
throw new CreditNotFoundError('Funding intent', input.fundingIntentId);
|
|
@@ -189,6 +366,8 @@ export class CreditLedger {
|
|
|
189
366
|
: `Funding underpayment: ${amountUnits} < ${intent.requestedUnits}`);
|
|
190
367
|
}
|
|
191
368
|
const now = this.now();
|
|
369
|
+
if (isPolicyTransaction(tx))
|
|
370
|
+
await this.expireDueCreditLots(tx, now, intent.customerId);
|
|
192
371
|
const current = await this.requireAccountById(tx, intent.accountId);
|
|
193
372
|
const grant = {
|
|
194
373
|
id: createId('grant'),
|
|
@@ -237,6 +416,16 @@ export class CreditLedger {
|
|
|
237
416
|
metadata: input.metadata,
|
|
238
417
|
};
|
|
239
418
|
await tx.saveGrant(grant);
|
|
419
|
+
if (isPolicyTransaction(tx)) {
|
|
420
|
+
await tx.saveCreditLot(createCreditLot({
|
|
421
|
+
account: current,
|
|
422
|
+
kind: 'general',
|
|
423
|
+
amountUnits,
|
|
424
|
+
grantId: grant.id,
|
|
425
|
+
now,
|
|
426
|
+
metadata: grant.metadata,
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
240
429
|
await tx.saveAccount(account);
|
|
241
430
|
await tx.saveFundingIntent(confirmedIntent);
|
|
242
431
|
await tx.saveFundingTransaction(fundingTransaction);
|
|
@@ -299,9 +488,11 @@ export class CreditLedger {
|
|
|
299
488
|
}));
|
|
300
489
|
}
|
|
301
490
|
async reserveCredits(input) {
|
|
302
|
-
return this.
|
|
491
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'reserve_credits', input.idempotencyKey, input, async () => {
|
|
303
492
|
const now = this.now();
|
|
304
493
|
await this.expireOpenReservations(tx, now, input.customerId);
|
|
494
|
+
if (isPolicyTransaction(tx))
|
|
495
|
+
await this.expireDueCreditLots(tx, now, input.customerId);
|
|
305
496
|
const price = await this.requirePrice(tx, input.priceId);
|
|
306
497
|
const rating = rateUsage(price, input.estimatedUsage);
|
|
307
498
|
const reservedUnits = parseCreditUnits(rating.totalUnits);
|
|
@@ -330,6 +521,9 @@ export class CreditLedger {
|
|
|
330
521
|
};
|
|
331
522
|
const account = withBalances(current, parseCreditUnits(current.postedUnits), parseCreditUnits(current.reservedUnits) + reservedUnits, now);
|
|
332
523
|
await tx.saveReservation(reservation);
|
|
524
|
+
if (isPolicyTransaction(tx)) {
|
|
525
|
+
await this.reserveCreditLots(tx, current, reservation.id, reservedUnits, now);
|
|
526
|
+
}
|
|
333
527
|
await tx.saveAccount(account);
|
|
334
528
|
await this.saveLedgerEntry(tx, account, 'reserve', 'reserved', reservedUnits, 'reservation', reservation.id, now, input.metadata);
|
|
335
529
|
await this.saveOutboxEvent(tx, 'credit.reserved', { account, reservation }, now);
|
|
@@ -337,7 +531,7 @@ export class CreditLedger {
|
|
|
337
531
|
}));
|
|
338
532
|
}
|
|
339
533
|
async commitUsage(input) {
|
|
340
|
-
return this.
|
|
534
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'commit_usage', input.idempotencyKey, input, async () => {
|
|
341
535
|
const now = this.now();
|
|
342
536
|
const reservation = await this.requireReservation(tx, input.reservationId);
|
|
343
537
|
if (reservation.status === 'committed' && reservation.usageReceiptId) {
|
|
@@ -364,10 +558,16 @@ export class CreditLedger {
|
|
|
364
558
|
if (chargeUnits > reservedUnits) {
|
|
365
559
|
throw new InvalidCreditStateError(`Actual charge exceeds reservation: ${rating.totalUnits} > ${reservation.reservedUnits}`);
|
|
366
560
|
}
|
|
561
|
+
if (isPolicyTransaction(tx)) {
|
|
562
|
+
await this.expireDueCreditLots(tx, now, reservation.customerId);
|
|
563
|
+
}
|
|
367
564
|
const accountBefore = await this.requireAccountById(tx, reservation.accountId);
|
|
368
565
|
const postedBefore = parseCreditUnits(accountBefore.postedUnits);
|
|
369
566
|
const reservedBefore = parseCreditUnits(accountBefore.reservedUnits);
|
|
370
|
-
const
|
|
567
|
+
const lotResult = isPolicyTransaction(tx)
|
|
568
|
+
? await this.commitCreditLotAllocations(tx, reservation, chargeUnits, now)
|
|
569
|
+
: { allocations: undefined, expiredReleasedUnits: 0n, expiredLots: [] };
|
|
570
|
+
const account = withBalances(accountBefore, postedBefore - chargeUnits - lotResult.expiredReleasedUnits, reservedBefore - reservedUnits, now);
|
|
371
571
|
const releasedUnits = reservedUnits - chargeUnits;
|
|
372
572
|
const usageEvent = {
|
|
373
573
|
id: requireText(input.usageEventId, 'usageEventId'),
|
|
@@ -398,6 +598,7 @@ export class CreditLedger {
|
|
|
398
598
|
balanceBeforeUnits: accountBefore.availableUnits,
|
|
399
599
|
balanceAfterUnits: account.availableUnits,
|
|
400
600
|
createdAt: now,
|
|
601
|
+
allocations: lotResult.allocations,
|
|
401
602
|
metadata: input.metadata,
|
|
402
603
|
};
|
|
403
604
|
const committedReservation = {
|
|
@@ -416,6 +617,10 @@ export class CreditLedger {
|
|
|
416
617
|
await tx.saveAccount(account);
|
|
417
618
|
await this.saveLedgerEntry(tx, account, 'charge', 'posted', -chargeUnits, 'usage_receipt', receipt.id, now, input.metadata);
|
|
418
619
|
await this.saveLedgerEntry(tx, account, 'release', 'reserved', -reservedUnits, 'usage_receipt', receipt.id, now, input.metadata);
|
|
620
|
+
for (const expired of lotResult.expiredLots) {
|
|
621
|
+
await this.saveLedgerEntry(tx, account, 'expire', 'posted', -expired.units, 'credit_lot', expired.lot.id, now, { reason: 'released_after_expiry', reservationId: reservation.id });
|
|
622
|
+
await this.saveOutboxEvent(tx, 'credit.lot.expired', { account, lot: expired.lot, expiredUnits: expired.units.toString() }, now);
|
|
623
|
+
}
|
|
419
624
|
await this.saveOutboxEvent(tx, 'usage.charged', {
|
|
420
625
|
usageReceiptId: receipt.id,
|
|
421
626
|
accountId: account.id,
|
|
@@ -425,12 +630,13 @@ export class CreditLedger {
|
|
|
425
630
|
amountUnits: receipt.amountUnits,
|
|
426
631
|
releasedAmount: receipt.releasedAmount,
|
|
427
632
|
balanceAfterUnits: receipt.balanceAfterUnits,
|
|
633
|
+
allocations: receipt.allocations,
|
|
428
634
|
}, now);
|
|
429
635
|
return { receipt, reservation: committedReservation, balance: account };
|
|
430
636
|
}));
|
|
431
637
|
}
|
|
432
638
|
async releaseReservation(input) {
|
|
433
|
-
return this.
|
|
639
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'release_reservation', input.idempotencyKey, input, async () => {
|
|
434
640
|
const reservation = await this.requireReservation(tx, input.reservationId);
|
|
435
641
|
if (reservation.status === 'released' || reservation.status === 'expired') {
|
|
436
642
|
return { reservation, balance: await this.requireAccountById(tx, reservation.accountId) };
|
|
@@ -442,7 +648,7 @@ export class CreditLedger {
|
|
|
442
648
|
}));
|
|
443
649
|
}
|
|
444
650
|
async releaseExpiredReservations(input) {
|
|
445
|
-
return this.
|
|
651
|
+
return this.withStoreTransaction((tx) => this.idempotent(tx, 'release_expired', input.idempotencyKey, input, async () => this.expireOpenReservations(tx, input.now ?? this.now())));
|
|
446
652
|
}
|
|
447
653
|
async runMetered(input, callback) {
|
|
448
654
|
const reserved = await this.reserveCredits(input);
|
|
@@ -479,11 +685,59 @@ export class CreditLedger {
|
|
|
479
685
|
return { value: result.value, replayed: false, ...committed };
|
|
480
686
|
}
|
|
481
687
|
async getBalance(customerId) {
|
|
688
|
+
if (isCreditPolicyStore(this.store)) {
|
|
689
|
+
return this.store.transaction(async (tx) => {
|
|
690
|
+
const normalized = requireText(customerId, 'customerId');
|
|
691
|
+
await this.expireDueCreditLots(tx, this.now(), normalized);
|
|
692
|
+
const account = await tx.getAccountByCustomer(this.projectId, normalized);
|
|
693
|
+
if (!account)
|
|
694
|
+
throw new CreditNotFoundError('Credit account', customerId);
|
|
695
|
+
return account;
|
|
696
|
+
});
|
|
697
|
+
}
|
|
482
698
|
const account = await this.store.getAccountByCustomer(this.projectId, requireText(customerId, 'customerId'));
|
|
483
699
|
if (!account)
|
|
484
700
|
throw new CreditNotFoundError('Credit account', customerId);
|
|
485
701
|
return account;
|
|
486
702
|
}
|
|
703
|
+
async getGrantPolicy(id) {
|
|
704
|
+
return this.forCurrentProject(await this.requirePolicyStore().getGrantPolicy(id));
|
|
705
|
+
}
|
|
706
|
+
listGrantPolicies() {
|
|
707
|
+
return this.requirePolicyStore().listGrantPolicies(this.projectId);
|
|
708
|
+
}
|
|
709
|
+
async getCreditLot(id) {
|
|
710
|
+
const store = this.requirePolicyStore();
|
|
711
|
+
const value = this.forCurrentProject(await store.getCreditLot(id));
|
|
712
|
+
if (!value)
|
|
713
|
+
return undefined;
|
|
714
|
+
await this.getBalance(value.customerId);
|
|
715
|
+
return this.forCurrentProject(await store.getCreditLot(id));
|
|
716
|
+
}
|
|
717
|
+
async listCreditLots(filter = {}) {
|
|
718
|
+
const store = this.requirePolicyStore();
|
|
719
|
+
const normalized = typeof filter === 'string' ? { customerId: filter } : filter;
|
|
720
|
+
if (normalized.customerId)
|
|
721
|
+
await this.getBalance(normalized.customerId);
|
|
722
|
+
else {
|
|
723
|
+
await store.transaction((tx) => this.expireDueCreditLots(tx, this.now(), undefined, Number.MAX_SAFE_INTEGER));
|
|
724
|
+
}
|
|
725
|
+
return store.listCreditLots({ ...normalized, projectId: this.projectId });
|
|
726
|
+
}
|
|
727
|
+
async listGrantPolicyApplications(filter = {}) {
|
|
728
|
+
const store = this.requirePolicyStore();
|
|
729
|
+
const normalized = typeof filter === 'string' ? { customerId: filter } : filter;
|
|
730
|
+
if (normalized.customerId)
|
|
731
|
+
await this.getBalance(normalized.customerId);
|
|
732
|
+
return store.listGrantPolicyApplications({ ...normalized, projectId: this.projectId });
|
|
733
|
+
}
|
|
734
|
+
async getGrantPolicyApplication(id) {
|
|
735
|
+
return this.forCurrentProject(await this.requirePolicyStore().getGrantPolicyApplication(id));
|
|
736
|
+
}
|
|
737
|
+
async listCreditLotAllocations(reservationId) {
|
|
738
|
+
const allocations = await this.requirePolicyStore().listCreditLotAllocations(reservationId);
|
|
739
|
+
return this.forCurrentProjectList(allocations);
|
|
740
|
+
}
|
|
487
741
|
async getReservation(id) {
|
|
488
742
|
return this.forCurrentProject(await this.store.getReservation(id));
|
|
489
743
|
}
|
|
@@ -535,6 +789,209 @@ export class CreditLedger {
|
|
|
535
789
|
return delivered;
|
|
536
790
|
}));
|
|
537
791
|
}
|
|
792
|
+
withStoreTransaction(handler) {
|
|
793
|
+
if (isCreditPolicyStore(this.store))
|
|
794
|
+
return this.store.transaction(handler);
|
|
795
|
+
return this.store.transaction(handler);
|
|
796
|
+
}
|
|
797
|
+
requirePolicyStore() {
|
|
798
|
+
if (!isCreditPolicyStore(this.store))
|
|
799
|
+
throw new UnsupportedCreditStoreCapabilityError();
|
|
800
|
+
return this.store;
|
|
801
|
+
}
|
|
802
|
+
async requireGrantPolicy(tx, id, type) {
|
|
803
|
+
const policy = await tx.getGrantPolicy(id);
|
|
804
|
+
if (!policy || policy.projectId !== this.projectId) {
|
|
805
|
+
throw new CreditNotFoundError('Grant policy', id);
|
|
806
|
+
}
|
|
807
|
+
if (policy.type !== type) {
|
|
808
|
+
throw new InvalidCreditStateError(`Grant policy ${policy.id} is ${policy.type}, not ${type}`);
|
|
809
|
+
}
|
|
810
|
+
return policy;
|
|
811
|
+
}
|
|
812
|
+
async createGrantInTransaction(tx, input) {
|
|
813
|
+
if (input.amountUnits <= 0n)
|
|
814
|
+
throw new Error('Credit grant amount must be positive');
|
|
815
|
+
if (isPolicyTransaction(tx)) {
|
|
816
|
+
await this.expireDueCreditLots(tx, input.now, input.customerId);
|
|
817
|
+
}
|
|
818
|
+
const current = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
|
|
819
|
+
const grant = {
|
|
820
|
+
id: createId('grant'),
|
|
821
|
+
accountId: current.id,
|
|
822
|
+
projectId: this.projectId,
|
|
823
|
+
customerId: current.customerId,
|
|
824
|
+
amount: creditUnitsToString(input.amountUnits),
|
|
825
|
+
amountUnits: input.amountUnits.toString(),
|
|
826
|
+
source: input.source,
|
|
827
|
+
externalRef: input.externalRef,
|
|
828
|
+
policyId: input.policyId,
|
|
829
|
+
expiresAt: input.expiresAt,
|
|
830
|
+
createdAt: input.now,
|
|
831
|
+
metadata: input.metadata,
|
|
832
|
+
};
|
|
833
|
+
const account = withBalances(current, parseCreditUnits(current.postedUnits) + input.amountUnits, parseCreditUnits(current.reservedUnits), input.now);
|
|
834
|
+
await tx.saveGrant(grant);
|
|
835
|
+
if (isPolicyTransaction(tx)) {
|
|
836
|
+
await tx.saveCreditLot(createCreditLot({
|
|
837
|
+
account: current,
|
|
838
|
+
kind: input.lotKind,
|
|
839
|
+
amountUnits: input.amountUnits,
|
|
840
|
+
grantId: grant.id,
|
|
841
|
+
policyId: input.policyId,
|
|
842
|
+
expiresAt: input.expiresAt,
|
|
843
|
+
now: input.now,
|
|
844
|
+
metadata: input.metadata,
|
|
845
|
+
}));
|
|
846
|
+
}
|
|
847
|
+
await tx.saveAccount(account);
|
|
848
|
+
await this.saveLedgerEntry(tx, account, 'grant', 'posted', input.amountUnits, 'grant', grant.id, input.now, input.metadata);
|
|
849
|
+
await this.saveOutboxEvent(tx, 'credit.granted', { account, grant }, input.now);
|
|
850
|
+
return { account, grant };
|
|
851
|
+
}
|
|
852
|
+
async expireDueCreditLots(tx, before, customerId, limit = Number.MAX_SAFE_INTEGER) {
|
|
853
|
+
const due = (await tx.listCreditLots({
|
|
854
|
+
projectId: this.projectId,
|
|
855
|
+
customerId,
|
|
856
|
+
expiresBefore: before,
|
|
857
|
+
}))
|
|
858
|
+
.filter((lot) => parseCreditUnits(lot.availableUnits) > 0n)
|
|
859
|
+
.sort(compareCreditLots)
|
|
860
|
+
.slice(0, limit);
|
|
861
|
+
const accounts = new Map();
|
|
862
|
+
const lots = [];
|
|
863
|
+
for (const lot of due) {
|
|
864
|
+
const availableUnits = parseCreditUnits(lot.availableUnits);
|
|
865
|
+
if (availableUnits === 0n)
|
|
866
|
+
continue;
|
|
867
|
+
const accountBefore = accounts.get(lot.accountId) ?? (await this.requireAccountById(tx, lot.accountId));
|
|
868
|
+
const account = withBalances(accountBefore, parseCreditUnits(accountBefore.postedUnits) - availableUnits, parseCreditUnits(accountBefore.reservedUnits), before);
|
|
869
|
+
const expired = withCreditLotBalances(lot, 0n, parseCreditUnits(lot.reservedUnits), parseCreditUnits(lot.consumedUnits), parseCreditUnits(lot.expiredUnits) + availableUnits, before);
|
|
870
|
+
await tx.saveCreditLot(expired);
|
|
871
|
+
await tx.saveAccount(account);
|
|
872
|
+
await this.saveLedgerEntry(tx, account, 'expire', 'posted', -availableUnits, 'credit_lot', lot.id, before, { reason: 'lot_expired' });
|
|
873
|
+
await this.saveOutboxEvent(tx, 'credit.lot.expired', { account, lot: expired, expiredUnits: availableUnits.toString() }, before);
|
|
874
|
+
accounts.set(account.id, account);
|
|
875
|
+
lots.push(expired);
|
|
876
|
+
}
|
|
877
|
+
return { lots, accounts: [...accounts.values()] };
|
|
878
|
+
}
|
|
879
|
+
async reserveCreditLots(tx, account, reservationId, amountUnits, now) {
|
|
880
|
+
let remaining = amountUnits;
|
|
881
|
+
const lots = (await tx.listCreditLots({
|
|
882
|
+
projectId: this.projectId,
|
|
883
|
+
customerId: account.customerId,
|
|
884
|
+
}))
|
|
885
|
+
.filter((lot) => lot.accountId === account.id &&
|
|
886
|
+
parseCreditUnits(lot.availableUnits) > 0n &&
|
|
887
|
+
(lot.expiresAt === undefined || lot.expiresAt > now))
|
|
888
|
+
.sort(compareCreditLots);
|
|
889
|
+
const allocations = [];
|
|
890
|
+
for (const lot of lots) {
|
|
891
|
+
if (remaining === 0n)
|
|
892
|
+
break;
|
|
893
|
+
const available = parseCreditUnits(lot.availableUnits);
|
|
894
|
+
const allocated = available < remaining ? available : remaining;
|
|
895
|
+
const updatedLot = withCreditLotBalances(lot, available - allocated, parseCreditUnits(lot.reservedUnits) + allocated, parseCreditUnits(lot.consumedUnits), parseCreditUnits(lot.expiredUnits), now);
|
|
896
|
+
const allocation = createCreditLotAllocation({
|
|
897
|
+
reservationId,
|
|
898
|
+
lot,
|
|
899
|
+
amountUnits: allocated,
|
|
900
|
+
now,
|
|
901
|
+
});
|
|
902
|
+
await tx.saveCreditLot(updatedLot);
|
|
903
|
+
await tx.saveCreditLotAllocation(allocation);
|
|
904
|
+
allocations.push(allocation);
|
|
905
|
+
remaining -= allocated;
|
|
906
|
+
}
|
|
907
|
+
if (remaining !== 0n) {
|
|
908
|
+
throw new InvalidCreditStateError(`Credit lot balance is missing ${remaining.toString()} units for account ${account.id}`);
|
|
909
|
+
}
|
|
910
|
+
return allocations;
|
|
911
|
+
}
|
|
912
|
+
async commitCreditLotAllocations(tx, reservation, chargeUnits, now) {
|
|
913
|
+
const allocations = await tx.listCreditLotAllocations(reservation.id);
|
|
914
|
+
const allocatedTotal = allocations.reduce((total, allocation) => total + parseCreditUnits(allocation.reservedUnits), 0n);
|
|
915
|
+
if (allocatedTotal !== parseCreditUnits(reservation.reservedUnits)) {
|
|
916
|
+
throw new InvalidCreditStateError(`Reservation lot allocations are incomplete: ${reservation.id}`);
|
|
917
|
+
}
|
|
918
|
+
let remainingCharge = chargeUnits;
|
|
919
|
+
let expiredReleasedUnits = 0n;
|
|
920
|
+
const updated = [];
|
|
921
|
+
const expiredLots = [];
|
|
922
|
+
const allocationLots = await Promise.all(allocations.map(async (allocation) => {
|
|
923
|
+
const lot = await tx.getCreditLot(allocation.lotId);
|
|
924
|
+
if (!lot)
|
|
925
|
+
throw new CreditNotFoundError('Credit lot', allocation.lotId);
|
|
926
|
+
return { allocation, lot };
|
|
927
|
+
}));
|
|
928
|
+
allocationLots.sort((left, right) => compareCreditLots(left.lot, right.lot));
|
|
929
|
+
for (const { allocation, lot } of allocationLots) {
|
|
930
|
+
const reserved = parseCreditUnits(allocation.reservedUnits);
|
|
931
|
+
const consumed = reserved < remainingCharge ? reserved : remainingCharge;
|
|
932
|
+
const remainder = reserved - consumed;
|
|
933
|
+
const expired = lot.expiresAt !== undefined && lot.expiresAt <= now;
|
|
934
|
+
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);
|
|
935
|
+
const updatedAllocation = withCreditLotAllocationBalances(allocation, 0n, parseCreditUnits(allocation.consumedUnits) + consumed, parseCreditUnits(allocation.releasedUnits) + (expired ? 0n : remainder), parseCreditUnits(allocation.expiredUnits) + (expired ? remainder : 0n), now);
|
|
936
|
+
await tx.saveCreditLot(updatedLot);
|
|
937
|
+
await tx.saveCreditLotAllocation(updatedAllocation);
|
|
938
|
+
updated.push(updatedAllocation);
|
|
939
|
+
remainingCharge -= consumed;
|
|
940
|
+
if (expired && remainder > 0n) {
|
|
941
|
+
expiredReleasedUnits += remainder;
|
|
942
|
+
expiredLots.push({ lot: updatedLot, units: remainder });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (remainingCharge !== 0n) {
|
|
946
|
+
throw new InvalidCreditStateError(`Reservation cannot cover charge: ${reservation.id}`);
|
|
947
|
+
}
|
|
948
|
+
return { allocations: updated, expiredReleasedUnits, expiredLots };
|
|
949
|
+
}
|
|
950
|
+
async releaseCreditLotAllocations(tx, reservation, now) {
|
|
951
|
+
const allocations = await tx.listCreditLotAllocations(reservation.id);
|
|
952
|
+
const allocatedTotal = allocations.reduce((total, allocation) => total + parseCreditUnits(allocation.reservedUnits), 0n);
|
|
953
|
+
if (allocatedTotal !== parseCreditUnits(reservation.reservedUnits)) {
|
|
954
|
+
throw new InvalidCreditStateError(`Reservation lot allocations are incomplete: ${reservation.id}`);
|
|
955
|
+
}
|
|
956
|
+
let expiredReleasedUnits = 0n;
|
|
957
|
+
const expiredLots = [];
|
|
958
|
+
for (const allocation of allocations) {
|
|
959
|
+
const lot = await tx.getCreditLot(allocation.lotId);
|
|
960
|
+
if (!lot)
|
|
961
|
+
throw new CreditNotFoundError('Credit lot', allocation.lotId);
|
|
962
|
+
const reserved = parseCreditUnits(allocation.reservedUnits);
|
|
963
|
+
const expired = lot.expiresAt !== undefined && lot.expiresAt <= now;
|
|
964
|
+
const updatedLot = withCreditLotBalances(lot, parseCreditUnits(lot.availableUnits) + (expired ? 0n : reserved), parseCreditUnits(lot.reservedUnits) - reserved, parseCreditUnits(lot.consumedUnits), parseCreditUnits(lot.expiredUnits) + (expired ? reserved : 0n), now);
|
|
965
|
+
const updatedAllocation = withCreditLotAllocationBalances(allocation, 0n, parseCreditUnits(allocation.consumedUnits), parseCreditUnits(allocation.releasedUnits) + (expired ? 0n : reserved), parseCreditUnits(allocation.expiredUnits) + (expired ? reserved : 0n), now);
|
|
966
|
+
await tx.saveCreditLot(updatedLot);
|
|
967
|
+
await tx.saveCreditLotAllocation(updatedAllocation);
|
|
968
|
+
if (expired && reserved > 0n) {
|
|
969
|
+
expiredReleasedUnits += reserved;
|
|
970
|
+
expiredLots.push({ lot: updatedLot, units: reserved });
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return { expiredReleasedUnits, expiredLots };
|
|
974
|
+
}
|
|
975
|
+
async consumeAvailableLots(tx, account, amountUnits, now) {
|
|
976
|
+
let remaining = amountUnits;
|
|
977
|
+
const lots = (await tx.listCreditLots({
|
|
978
|
+
projectId: this.projectId,
|
|
979
|
+
customerId: account.customerId,
|
|
980
|
+
}))
|
|
981
|
+
.filter((lot) => lot.accountId === account.id && parseCreditUnits(lot.availableUnits) > 0n)
|
|
982
|
+
.sort(compareCreditLots);
|
|
983
|
+
for (const lot of lots) {
|
|
984
|
+
if (remaining === 0n)
|
|
985
|
+
break;
|
|
986
|
+
const available = parseCreditUnits(lot.availableUnits);
|
|
987
|
+
const consumed = available < remaining ? available : remaining;
|
|
988
|
+
await tx.saveCreditLot(withCreditLotBalances(lot, available - consumed, parseCreditUnits(lot.reservedUnits), parseCreditUnits(lot.consumedUnits) + consumed, parseCreditUnits(lot.expiredUnits), now));
|
|
989
|
+
remaining -= consumed;
|
|
990
|
+
}
|
|
991
|
+
if (remaining !== 0n) {
|
|
992
|
+
throw new InvalidCreditStateError(`Credit lot balance is missing ${remaining.toString()} units for account ${account.id}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
538
995
|
async ensureAccountInTransaction(tx, customerIdValue, metadata) {
|
|
539
996
|
const customerId = requireText(customerIdValue, 'customerId');
|
|
540
997
|
const existing = await tx.getAccountByCustomer(this.projectId, customerId);
|
|
@@ -576,9 +1033,15 @@ export class CreditLedger {
|
|
|
576
1033
|
return this.closeReservation(tx, reservation, 'expired', now, 'ttl_expired');
|
|
577
1034
|
}
|
|
578
1035
|
async closeReservation(tx, reservation, status, now, reason) {
|
|
1036
|
+
if (isPolicyTransaction(tx)) {
|
|
1037
|
+
await this.expireDueCreditLots(tx, now, reservation.customerId);
|
|
1038
|
+
}
|
|
579
1039
|
const accountBefore = await this.requireAccountById(tx, reservation.accountId);
|
|
580
1040
|
const reservedUnits = parseCreditUnits(reservation.reservedUnits);
|
|
581
|
-
const
|
|
1041
|
+
const lotResult = isPolicyTransaction(tx)
|
|
1042
|
+
? await this.releaseCreditLotAllocations(tx, reservation, now)
|
|
1043
|
+
: { expiredReleasedUnits: 0n, expiredLots: [] };
|
|
1044
|
+
const account = withBalances(accountBefore, parseCreditUnits(accountBefore.postedUnits) - lotResult.expiredReleasedUnits, parseCreditUnits(accountBefore.reservedUnits) - reservedUnits, now);
|
|
582
1045
|
const closed = {
|
|
583
1046
|
...reservation,
|
|
584
1047
|
status,
|
|
@@ -589,6 +1052,10 @@ export class CreditLedger {
|
|
|
589
1052
|
await tx.saveReservation(closed);
|
|
590
1053
|
await tx.saveAccount(account);
|
|
591
1054
|
await this.saveLedgerEntry(tx, account, 'release', 'reserved', -reservedUnits, 'reservation', reservation.id, now, { reason });
|
|
1055
|
+
for (const expired of lotResult.expiredLots) {
|
|
1056
|
+
await this.saveLedgerEntry(tx, account, 'expire', 'posted', -expired.units, 'credit_lot', expired.lot.id, now, { reason: 'released_after_expiry', reservationId: reservation.id });
|
|
1057
|
+
await this.saveOutboxEvent(tx, 'credit.lot.expired', { account, lot: expired.lot, expiredUnits: expired.units.toString() }, now);
|
|
1058
|
+
}
|
|
592
1059
|
await this.saveOutboxEvent(tx, status === 'expired' ? 'credit.expired' : 'credit.released', { account, reservation: closed, reason }, now);
|
|
593
1060
|
return { reservation: closed, balance: account };
|
|
594
1061
|
}
|
|
@@ -689,6 +1156,160 @@ function withBalances(account, posted, reserved, updatedAt) {
|
|
|
689
1156
|
updatedAt,
|
|
690
1157
|
};
|
|
691
1158
|
}
|
|
1159
|
+
function isPolicyTransaction(transaction) {
|
|
1160
|
+
const value = transaction;
|
|
1161
|
+
return (typeof value.getGrantPolicy === 'function' &&
|
|
1162
|
+
typeof value.listGrantPolicies === 'function' &&
|
|
1163
|
+
typeof value.getCreditLot === 'function' &&
|
|
1164
|
+
typeof value.listCreditLots === 'function' &&
|
|
1165
|
+
typeof value.listCreditLotAllocations === 'function' &&
|
|
1166
|
+
typeof value.getGrantPolicyApplicationByIdentity === 'function' &&
|
|
1167
|
+
typeof value.saveGrantPolicy === 'function' &&
|
|
1168
|
+
typeof value.saveCreditLot === 'function' &&
|
|
1169
|
+
typeof value.saveCreditLotAllocation === 'function' &&
|
|
1170
|
+
typeof value.saveGrantPolicyApplication === 'function');
|
|
1171
|
+
}
|
|
1172
|
+
function createCreditLot(input) {
|
|
1173
|
+
if (input.amountUnits <= 0n) {
|
|
1174
|
+
throw new InvalidCreditStateError('Credit lot amount must be positive');
|
|
1175
|
+
}
|
|
1176
|
+
if (input.expiresAt !== undefined && input.expiresAt <= input.now) {
|
|
1177
|
+
throw new InvalidCreditStateError('Credit lot expiry must be in the future');
|
|
1178
|
+
}
|
|
1179
|
+
const amount = creditUnitsToString(input.amountUnits);
|
|
1180
|
+
return {
|
|
1181
|
+
id: createId('lot'),
|
|
1182
|
+
accountId: input.account.id,
|
|
1183
|
+
projectId: input.account.projectId,
|
|
1184
|
+
customerId: input.account.customerId,
|
|
1185
|
+
kind: input.kind,
|
|
1186
|
+
grantId: input.grantId,
|
|
1187
|
+
policyId: input.policyId,
|
|
1188
|
+
originalAmount: amount,
|
|
1189
|
+
originalUnits: input.amountUnits.toString(),
|
|
1190
|
+
availableAmount: amount,
|
|
1191
|
+
availableUnits: input.amountUnits.toString(),
|
|
1192
|
+
reservedAmount: '0',
|
|
1193
|
+
reservedUnits: '0',
|
|
1194
|
+
consumedAmount: '0',
|
|
1195
|
+
consumedUnits: '0',
|
|
1196
|
+
expiredAmount: '0',
|
|
1197
|
+
expiredUnits: '0',
|
|
1198
|
+
createdAt: input.now,
|
|
1199
|
+
updatedAt: input.now,
|
|
1200
|
+
expiresAt: input.expiresAt,
|
|
1201
|
+
metadata: input.metadata,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
function withCreditLotBalances(lot, available, reserved, consumed, expired, updatedAt) {
|
|
1205
|
+
if (available < 0n || reserved < 0n || consumed < 0n || expired < 0n) {
|
|
1206
|
+
throw new InvalidCreditStateError(`Credit lot balance cannot be negative: ${lot.id}`);
|
|
1207
|
+
}
|
|
1208
|
+
const original = parseCreditUnits(lot.originalUnits);
|
|
1209
|
+
if (available + reserved + consumed + expired !== original) {
|
|
1210
|
+
throw new InvalidCreditStateError(`Credit lot invariant violated: ${lot.id}`);
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
...lot,
|
|
1214
|
+
availableAmount: creditUnitsToString(available),
|
|
1215
|
+
availableUnits: available.toString(),
|
|
1216
|
+
reservedAmount: creditUnitsToString(reserved),
|
|
1217
|
+
reservedUnits: reserved.toString(),
|
|
1218
|
+
consumedAmount: creditUnitsToString(consumed),
|
|
1219
|
+
consumedUnits: consumed.toString(),
|
|
1220
|
+
expiredAmount: creditUnitsToString(expired),
|
|
1221
|
+
expiredUnits: expired.toString(),
|
|
1222
|
+
updatedAt,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
function createCreditLotAllocation(input) {
|
|
1226
|
+
if (input.amountUnits <= 0n) {
|
|
1227
|
+
throw new InvalidCreditStateError('Credit lot allocation amount must be positive');
|
|
1228
|
+
}
|
|
1229
|
+
const amount = creditUnitsToString(input.amountUnits);
|
|
1230
|
+
return {
|
|
1231
|
+
id: `cla_${createHash('sha256')
|
|
1232
|
+
.update(`${input.reservationId}\u0000${input.lot.id}`)
|
|
1233
|
+
.digest('hex')
|
|
1234
|
+
.slice(0, 24)}`,
|
|
1235
|
+
reservationId: input.reservationId,
|
|
1236
|
+
lotId: input.lot.id,
|
|
1237
|
+
accountId: input.lot.accountId,
|
|
1238
|
+
projectId: input.lot.projectId,
|
|
1239
|
+
customerId: input.lot.customerId,
|
|
1240
|
+
allocatedAmount: amount,
|
|
1241
|
+
allocatedUnits: input.amountUnits.toString(),
|
|
1242
|
+
reservedAmount: amount,
|
|
1243
|
+
reservedUnits: input.amountUnits.toString(),
|
|
1244
|
+
consumedAmount: '0',
|
|
1245
|
+
consumedUnits: '0',
|
|
1246
|
+
releasedAmount: '0',
|
|
1247
|
+
releasedUnits: '0',
|
|
1248
|
+
expiredAmount: '0',
|
|
1249
|
+
expiredUnits: '0',
|
|
1250
|
+
createdAt: input.now,
|
|
1251
|
+
updatedAt: input.now,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
function withCreditLotAllocationBalances(allocation, reserved, consumed, released, expired, updatedAt) {
|
|
1255
|
+
if (reserved < 0n || consumed < 0n || released < 0n || expired < 0n) {
|
|
1256
|
+
throw new InvalidCreditStateError(`Credit lot allocation balance cannot be negative: ${allocation.id}`);
|
|
1257
|
+
}
|
|
1258
|
+
const allocated = parseCreditUnits(allocation.allocatedUnits);
|
|
1259
|
+
if (reserved + consumed + released + expired !== allocated) {
|
|
1260
|
+
throw new InvalidCreditStateError(`Credit lot allocation invariant violated: ${allocation.id}`);
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
...allocation,
|
|
1264
|
+
reservedAmount: creditUnitsToString(reserved),
|
|
1265
|
+
reservedUnits: reserved.toString(),
|
|
1266
|
+
consumedAmount: creditUnitsToString(consumed),
|
|
1267
|
+
consumedUnits: consumed.toString(),
|
|
1268
|
+
releasedAmount: creditUnitsToString(released),
|
|
1269
|
+
releasedUnits: released.toString(),
|
|
1270
|
+
expiredAmount: creditUnitsToString(expired),
|
|
1271
|
+
expiredUnits: expired.toString(),
|
|
1272
|
+
updatedAt,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
function compareCreditLots(left, right) {
|
|
1276
|
+
const priority = (lot) => {
|
|
1277
|
+
if (lot.kind === 'promotion')
|
|
1278
|
+
return 0;
|
|
1279
|
+
if (lot.kind === 'allowance')
|
|
1280
|
+
return 1;
|
|
1281
|
+
return 2;
|
|
1282
|
+
};
|
|
1283
|
+
const priorityDifference = priority(left) - priority(right);
|
|
1284
|
+
if (priorityDifference !== 0)
|
|
1285
|
+
return priorityDifference;
|
|
1286
|
+
if (left.kind === 'promotion' && right.kind === 'promotion') {
|
|
1287
|
+
const expiryDifference = (left.expiresAt ?? Number.MAX_SAFE_INTEGER) - (right.expiresAt ?? Number.MAX_SAFE_INTEGER);
|
|
1288
|
+
if (expiryDifference !== 0)
|
|
1289
|
+
return expiryDifference;
|
|
1290
|
+
}
|
|
1291
|
+
return left.createdAt - right.createdAt || left.id.localeCompare(right.id);
|
|
1292
|
+
}
|
|
1293
|
+
function allowancePeriodKey(timestamp, cadence) {
|
|
1294
|
+
if (!Number.isSafeInteger(timestamp))
|
|
1295
|
+
throw new Error('Clock must return an integer timestamp');
|
|
1296
|
+
const date = new Date(timestamp);
|
|
1297
|
+
if (Number.isNaN(date.getTime()))
|
|
1298
|
+
throw new Error('Clock returned an invalid timestamp');
|
|
1299
|
+
const day = date.toISOString().slice(0, 10);
|
|
1300
|
+
if (cadence === 'day')
|
|
1301
|
+
return `day:${day}`;
|
|
1302
|
+
if (cadence === 'month')
|
|
1303
|
+
return `month:${day.slice(0, 7)}`;
|
|
1304
|
+
if (cadence === 'week') {
|
|
1305
|
+
const utcDay = date.getUTCDay();
|
|
1306
|
+
const daysSinceMonday = (utcDay + 6) % 7;
|
|
1307
|
+
const monday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
1308
|
+
monday.setUTCDate(monday.getUTCDate() - daysSinceMonday);
|
|
1309
|
+
return `week:${monday.toISOString().slice(0, 10)}`;
|
|
1310
|
+
}
|
|
1311
|
+
throw new Error(`Unsupported allowance cadence: ${String(cadence)}`);
|
|
1312
|
+
}
|
|
692
1313
|
function normalizeUsage(usage) {
|
|
693
1314
|
return Object.fromEntries(Object.entries(usage).sort(([a], [b]) => a.localeCompare(b)));
|
|
694
1315
|
}
|