@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.
@@ -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 grantCredits(input) {
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('Credit grant amount must be positive');
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 this.store.transaction((tx) => this.idempotent(tx, 'grant_credits', input.idempotencyKey, request, async () => {
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 grant = {
33
- id: createId('grant'),
34
- accountId: current.id,
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
- amount: creditUnitsToString(amountUnits),
38
- amountUnits: amountUnits.toString(),
39
- source: input.source ?? 'manual',
40
- externalRef: input.externalRef,
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
- const account = withBalances(current, parseCreditUnits(current.postedUnits) + amountUnits, parseCreditUnits(current.reservedUnits), now);
45
- await tx.saveGrant(grant);
46
- await tx.saveAccount(account);
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 };
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.store.transaction((tx) => this.idempotent(tx, 'adjust_credits', input.idempotencyKey, request, async () => {
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 price = createPriceVersion({
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.store.transaction(async (tx) => {
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.store.transaction((tx) => this.idempotent(tx, 'reserve_credits', input.idempotencyKey, input, async () => {
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.store.transaction((tx) => this.idempotent(tx, 'commit_usage', input.idempotencyKey, input, async () => {
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 account = withBalances(accountBefore, postedBefore - chargeUnits, reservedBefore - reservedUnits, now);
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.store.transaction((tx) => this.idempotent(tx, 'release_reservation', input.idempotencyKey, input, async () => {
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.store.transaction((tx) => this.idempotent(tx, 'release_expired', input.idempotencyKey, input, async () => this.expireOpenReservations(tx, input.now ?? this.now())));
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 account = withBalances(accountBefore, parseCreditUnits(accountBefore.postedUnits), parseCreditUnits(accountBefore.reservedUnits) - reservedUnits, now);
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
  }