@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.
@@ -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 };
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.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, {
@@ -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.store.transaction(async (tx) => {
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.store.transaction((tx) => this.idempotent(tx, 'reserve_credits', input.idempotencyKey, input, async () => {
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.store.transaction((tx) => this.idempotent(tx, 'commit_usage', input.idempotencyKey, input, async () => {
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 account = withBalances(accountBefore, postedBefore - chargeUnits, reservedBefore - reservedUnits, now);
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.store.transaction((tx) => this.idempotent(tx, 'release_reservation', input.idempotencyKey, input, async () => {
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.store.transaction((tx) => this.idempotent(tx, 'release_expired', input.idempotencyKey, input, async () => this.expireOpenReservations(tx, input.now ?? this.now())));
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 account = withBalances(accountBefore, parseCreditUnits(accountBefore.postedUnits), parseCreditUnits(accountBefore.reservedUnits) - reservedUnits, now);
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
  }