@resvary/sdk 0.5.0-alpha.1

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.
Files changed (171) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +17 -0
  3. package/dist/billing/index.d.ts +3 -0
  4. package/dist/billing/index.d.ts.map +1 -0
  5. package/dist/billing/index.js +3 -0
  6. package/dist/billing/index.js.map +1 -0
  7. package/dist/billing/meter.d.ts +97 -0
  8. package/dist/billing/meter.d.ts.map +1 -0
  9. package/dist/billing/meter.js +109 -0
  10. package/dist/billing/meter.js.map +1 -0
  11. package/dist/billing/plans.d.ts +77 -0
  12. package/dist/billing/plans.d.ts.map +1 -0
  13. package/dist/billing/plans.js +65 -0
  14. package/dist/billing/plans.js.map +1 -0
  15. package/dist/client/buyer.d.ts +82 -0
  16. package/dist/client/buyer.d.ts.map +1 -0
  17. package/dist/client/buyer.js +168 -0
  18. package/dist/client/buyer.js.map +1 -0
  19. package/dist/client/index.d.ts +2 -0
  20. package/dist/client/index.d.ts.map +1 -0
  21. package/dist/client/index.js +2 -0
  22. package/dist/client/index.js.map +1 -0
  23. package/dist/constants.d.ts +36 -0
  24. package/dist/constants.d.ts.map +1 -0
  25. package/dist/constants.js +42 -0
  26. package/dist/constants.js.map +1 -0
  27. package/dist/credits/amount.d.ts +7 -0
  28. package/dist/credits/amount.d.ts.map +1 -0
  29. package/dist/credits/amount.js +36 -0
  30. package/dist/credits/amount.js.map +1 -0
  31. package/dist/credits/errors.d.ts +20 -0
  32. package/dist/credits/errors.d.ts.map +1 -0
  33. package/dist/credits/errors.js +35 -0
  34. package/dist/credits/errors.js.map +1 -0
  35. package/dist/credits/index.d.ts +7 -0
  36. package/dist/credits/index.d.ts.map +1 -0
  37. package/dist/credits/index.js +6 -0
  38. package/dist/credits/index.js.map +1 -0
  39. package/dist/credits/ledger.d.ts +175 -0
  40. package/dist/credits/ledger.d.ts.map +1 -0
  41. package/dist/credits/ledger.js +705 -0
  42. package/dist/credits/ledger.js.map +1 -0
  43. package/dist/credits/store.d.ts +104 -0
  44. package/dist/credits/store.d.ts.map +1 -0
  45. package/dist/credits/store.js +420 -0
  46. package/dist/credits/store.js.map +1 -0
  47. package/dist/credits/types.d.ts +234 -0
  48. package/dist/credits/types.d.ts.map +1 -0
  49. package/dist/credits/types.js +2 -0
  50. package/dist/credits/types.js.map +1 -0
  51. package/dist/credits/webhooks.d.ts +5 -0
  52. package/dist/credits/webhooks.d.ts.map +1 -0
  53. package/dist/credits/webhooks.js +13 -0
  54. package/dist/credits/webhooks.js.map +1 -0
  55. package/dist/funding/arc-worker.d.ts +55 -0
  56. package/dist/funding/arc-worker.d.ts.map +1 -0
  57. package/dist/funding/arc-worker.js +133 -0
  58. package/dist/funding/arc-worker.js.map +1 -0
  59. package/dist/funding/arc.d.ts +38 -0
  60. package/dist/funding/arc.d.ts.map +1 -0
  61. package/dist/funding/arc.js +83 -0
  62. package/dist/funding/arc.js.map +1 -0
  63. package/dist/funding/index.d.ts +3 -0
  64. package/dist/funding/index.d.ts.map +1 -0
  65. package/dist/funding/index.js +3 -0
  66. package/dist/funding/index.js.map +1 -0
  67. package/dist/gateway/client.d.ts +51 -0
  68. package/dist/gateway/client.d.ts.map +1 -0
  69. package/dist/gateway/client.js +59 -0
  70. package/dist/gateway/client.js.map +1 -0
  71. package/dist/gateway/index.d.ts +2 -0
  72. package/dist/gateway/index.d.ts.map +1 -0
  73. package/dist/gateway/index.js +2 -0
  74. package/dist/gateway/index.js.map +1 -0
  75. package/dist/index.d.ts +21 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +20 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/middleware/express.d.ts +25 -0
  80. package/dist/middleware/express.d.ts.map +1 -0
  81. package/dist/middleware/express.js +61 -0
  82. package/dist/middleware/express.js.map +1 -0
  83. package/dist/middleware/index.d.ts +4 -0
  84. package/dist/middleware/index.d.ts.map +1 -0
  85. package/dist/middleware/index.js +4 -0
  86. package/dist/middleware/index.js.map +1 -0
  87. package/dist/middleware/next.d.ts +27 -0
  88. package/dist/middleware/next.d.ts.map +1 -0
  89. package/dist/middleware/next.js +67 -0
  90. package/dist/middleware/next.js.map +1 -0
  91. package/dist/middleware/paywall.d.ts +54 -0
  92. package/dist/middleware/paywall.d.ts.map +1 -0
  93. package/dist/middleware/paywall.js +102 -0
  94. package/dist/middleware/paywall.js.map +1 -0
  95. package/dist/pricing/index.d.ts +3 -0
  96. package/dist/pricing/index.d.ts.map +1 -0
  97. package/dist/pricing/index.js +2 -0
  98. package/dist/pricing/index.js.map +1 -0
  99. package/dist/pricing/rating.d.ts +21 -0
  100. package/dist/pricing/rating.d.ts.map +1 -0
  101. package/dist/pricing/rating.js +109 -0
  102. package/dist/pricing/rating.js.map +1 -0
  103. package/dist/receipts/amount.d.ts +8 -0
  104. package/dist/receipts/amount.d.ts.map +1 -0
  105. package/dist/receipts/amount.js +27 -0
  106. package/dist/receipts/amount.js.map +1 -0
  107. package/dist/receipts/index.d.ts +16 -0
  108. package/dist/receipts/index.d.ts.map +1 -0
  109. package/dist/receipts/index.js +15 -0
  110. package/dist/receipts/index.js.map +1 -0
  111. package/dist/receipts/invoice.d.ts +18 -0
  112. package/dist/receipts/invoice.d.ts.map +1 -0
  113. package/dist/receipts/invoice.js +112 -0
  114. package/dist/receipts/invoice.js.map +1 -0
  115. package/dist/receipts/ledger.d.ts +30 -0
  116. package/dist/receipts/ledger.d.ts.map +1 -0
  117. package/dist/receipts/ledger.js +109 -0
  118. package/dist/receipts/ledger.js.map +1 -0
  119. package/dist/receipts/memo-payment.d.ts +100 -0
  120. package/dist/receipts/memo-payment.d.ts.map +1 -0
  121. package/dist/receipts/memo-payment.js +91 -0
  122. package/dist/receipts/memo-payment.js.map +1 -0
  123. package/dist/receipts/memo.d.ts +14 -0
  124. package/dist/receipts/memo.d.ts.map +1 -0
  125. package/dist/receipts/memo.js +39 -0
  126. package/dist/receipts/memo.js.map +1 -0
  127. package/dist/receipts/persistent-ledger.d.ts +27 -0
  128. package/dist/receipts/persistent-ledger.d.ts.map +1 -0
  129. package/dist/receipts/persistent-ledger.js +111 -0
  130. package/dist/receipts/persistent-ledger.js.map +1 -0
  131. package/dist/receipts/persistent-webhook-inbox.d.ts +32 -0
  132. package/dist/receipts/persistent-webhook-inbox.d.ts.map +1 -0
  133. package/dist/receipts/persistent-webhook-inbox.js +93 -0
  134. package/dist/receipts/persistent-webhook-inbox.js.map +1 -0
  135. package/dist/receipts/proof.d.ts +80 -0
  136. package/dist/receipts/proof.d.ts.map +1 -0
  137. package/dist/receipts/proof.js +223 -0
  138. package/dist/receipts/proof.js.map +1 -0
  139. package/dist/receipts/serialization.d.ts +3 -0
  140. package/dist/receipts/serialization.d.ts.map +1 -0
  141. package/dist/receipts/serialization.js +24 -0
  142. package/dist/receipts/serialization.js.map +1 -0
  143. package/dist/receipts/store.d.ts +69 -0
  144. package/dist/receipts/store.d.ts.map +1 -0
  145. package/dist/receipts/store.js +85 -0
  146. package/dist/receipts/store.js.map +1 -0
  147. package/dist/receipts/types.d.ts +134 -0
  148. package/dist/receipts/types.d.ts.map +1 -0
  149. package/dist/receipts/types.js +5 -0
  150. package/dist/receipts/types.js.map +1 -0
  151. package/dist/receipts/watcher.d.ts +92 -0
  152. package/dist/receipts/watcher.d.ts.map +1 -0
  153. package/dist/receipts/watcher.js +323 -0
  154. package/dist/receipts/watcher.js.map +1 -0
  155. package/dist/receipts/webhook-inbox.d.ts +34 -0
  156. package/dist/receipts/webhook-inbox.d.ts.map +1 -0
  157. package/dist/receipts/webhook-inbox.js +101 -0
  158. package/dist/receipts/webhook-inbox.js.map +1 -0
  159. package/dist/receipts/webhook-route.d.ts +22 -0
  160. package/dist/receipts/webhook-route.d.ts.map +1 -0
  161. package/dist/receipts/webhook-route.js +37 -0
  162. package/dist/receipts/webhook-route.js.map +1 -0
  163. package/dist/receipts/webhooks.d.ts +20 -0
  164. package/dist/receipts/webhooks.d.ts.map +1 -0
  165. package/dist/receipts/webhooks.js +72 -0
  166. package/dist/receipts/webhooks.js.map +1 -0
  167. package/dist/types.d.ts +101 -0
  168. package/dist/types.d.ts.map +1 -0
  169. package/dist/types.js +5 -0
  170. package/dist/types.js.map +1 -0
  171. package/package.json +116 -0
@@ -0,0 +1,705 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { creditUnitsToString, parseCreditUnits, toCreditUnits } from './amount.js';
3
+ import { CreditNotFoundError, IdempotencyConflictError, InsufficientCreditsError, InvalidCreditStateError, } from './errors.js';
4
+ import { InMemoryCreditStore } from './store.js';
5
+ import { createMeterDefinition, createPriceVersion, rateUsage } from '../pricing/rating.js';
6
+ export class CreditLedger {
7
+ projectId;
8
+ store;
9
+ reservationTtlMs;
10
+ now;
11
+ constructor(config) {
12
+ this.projectId = requireText(config.projectId, 'projectId');
13
+ this.store = config.store ?? new InMemoryCreditStore();
14
+ this.reservationTtlMs = config.reservationTtlMs ?? 15 * 60 * 1000;
15
+ this.now = config.now ?? Date.now;
16
+ if (!Number.isSafeInteger(this.reservationTtlMs) || this.reservationTtlMs <= 0) {
17
+ throw new Error('reservationTtlMs must be a positive integer');
18
+ }
19
+ }
20
+ async ensureAccount(input) {
21
+ const request = { customerId: input.customerId, metadata: input.metadata };
22
+ return this.store.transaction((tx) => this.idempotent(tx, 'ensure_account', input.idempotencyKey, request, async () => this.ensureAccountInTransaction(tx, input.customerId, input.metadata)));
23
+ }
24
+ async grantCredits(input) {
25
+ const amountUnits = toCreditUnits(input.amount);
26
+ if (amountUnits <= 0n)
27
+ throw new Error('Credit grant amount must be positive');
28
+ const request = { ...input, amountUnits: amountUnits.toString() };
29
+ return this.store.transaction((tx) => this.idempotent(tx, 'grant_credits', input.idempotencyKey, request, async () => {
30
+ const now = this.now();
31
+ const current = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
32
+ const grant = {
33
+ id: createId('grant'),
34
+ accountId: current.id,
35
+ projectId: this.projectId,
36
+ customerId: current.customerId,
37
+ amount: creditUnitsToString(amountUnits),
38
+ amountUnits: amountUnits.toString(),
39
+ source: input.source ?? 'manual',
40
+ externalRef: input.externalRef,
41
+ createdAt: now,
42
+ metadata: input.metadata,
43
+ };
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 };
50
+ }));
51
+ }
52
+ async adjustCredits(input) {
53
+ const deltaUnits = toSignedCreditUnits(input.amount);
54
+ if (deltaUnits === 0n)
55
+ throw new Error('Credit adjustment amount cannot be zero');
56
+ const request = { ...input, deltaUnits: deltaUnits.toString() };
57
+ return this.store.transaction((tx) => this.idempotent(tx, 'adjust_credits', input.idempotencyKey, request, async () => {
58
+ const now = this.now();
59
+ const current = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
60
+ const nextPosted = parseCreditUnits(current.postedUnits) + deltaUnits;
61
+ const reserved = parseCreditUnits(current.reservedUnits);
62
+ if (nextPosted < reserved) {
63
+ throw new InsufficientCreditsError((nextPosted - reserved).toString(), '0');
64
+ }
65
+ const account = withBalances(current, nextPosted, reserved, now);
66
+ await tx.saveAccount(account);
67
+ const entry = await this.saveLedgerEntry(tx, account, 'adjustment', 'posted', deltaUnits, 'adjustment', createId('adj'), now, {
68
+ ...input.metadata,
69
+ reason: requireText(input.reason, 'reason'),
70
+ });
71
+ await this.saveOutboxEvent(tx, 'credit.adjusted', { account, entry }, now);
72
+ return { account, entry };
73
+ }));
74
+ }
75
+ async registerMeter(input) {
76
+ return this.store.transaction((tx) => this.idempotent(tx, 'register_meter', input.idempotencyKey, input, async () => {
77
+ const existing = await tx.getMeterByKey(this.projectId, input.key);
78
+ if (existing) {
79
+ if (stableStringify(existing.dimensions) !== stableStringify([...new Set(input.dimensions)])) {
80
+ throw new InvalidCreditStateError(`Meter is immutable after creation: ${input.key}`);
81
+ }
82
+ return existing;
83
+ }
84
+ const meter = createMeterDefinition({
85
+ id: createId('meter'),
86
+ projectId: this.projectId,
87
+ key: input.key,
88
+ name: input.name,
89
+ dimensions: input.dimensions,
90
+ createdAt: this.now(),
91
+ metadata: input.metadata,
92
+ });
93
+ await tx.saveMeter(meter);
94
+ return meter;
95
+ }));
96
+ }
97
+ async createPriceVersion(input) {
98
+ return this.store.transaction((tx) => this.idempotent(tx, 'create_price_version', input.idempotencyKey, input, async () => {
99
+ const meter = await tx.getMeterByKey(this.projectId, input.meterKey);
100
+ if (!meter)
101
+ throw new CreditNotFoundError('Meter', input.meterKey);
102
+ const versions = await tx.listPriceVersions(meter.id);
103
+ const price = createPriceVersion({
104
+ id: createId('price'),
105
+ projectId: this.projectId,
106
+ meter,
107
+ version: versions.reduce((max, item) => Math.max(max, item.version), 0) + 1,
108
+ rates: input.rates,
109
+ createdAt: this.now(),
110
+ metadata: input.metadata,
111
+ });
112
+ await tx.savePriceVersion(price);
113
+ return price;
114
+ }));
115
+ }
116
+ async createFundingIntent(input) {
117
+ const requestedUnits = toCreditUnits(input.amount);
118
+ if (requestedUnits <= 0n)
119
+ throw new Error('Funding amount must be positive');
120
+ return this.store.transaction((tx) => this.idempotent(tx, 'create_funding_intent', input.idempotencyKey, input, async () => {
121
+ const now = this.now();
122
+ const account = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
123
+ const intent = {
124
+ id: input.id ?? createId('fund'),
125
+ projectId: this.projectId,
126
+ customerId: account.customerId,
127
+ accountId: account.id,
128
+ status: 'pending',
129
+ requestedAmount: creditUnitsToString(requestedUnits),
130
+ requestedUnits: requestedUnits.toString(),
131
+ rail: input.rail ?? 'arc_direct',
132
+ network: requireText(input.network, 'network'),
133
+ invoiceId: requireText(input.invoiceId, 'invoiceId'),
134
+ createdAt: now,
135
+ expiresAt: input.expiresAt,
136
+ metadata: input.metadata,
137
+ };
138
+ await tx.saveFundingIntent(intent);
139
+ await this.saveOutboxEvent(tx, 'funding.intent.created', { fundingIntent: intent }, now);
140
+ return intent;
141
+ }));
142
+ }
143
+ async confirmFunding(input) {
144
+ const amountUnits = toCreditUnits(input.amount);
145
+ const rail = input.rail ?? 'arc_direct';
146
+ const externalPaymentId = requireText(input.externalPaymentId ?? input.txHash ?? '', 'externalPaymentId');
147
+ return this.store.transaction(async (tx) => {
148
+ const intent = await tx.getFundingIntent(input.fundingIntentId);
149
+ if (!intent || intent.projectId !== this.projectId)
150
+ throw new CreditNotFoundError('Funding intent', input.fundingIntentId);
151
+ if (intent.network !== input.network)
152
+ throw new InvalidCreditStateError('Funding network does not match the intent');
153
+ if (intent.rail !== rail)
154
+ throw new InvalidCreditStateError('Funding rail does not match the intent');
155
+ if (intent.expiresAt !== undefined && intent.expiresAt <= this.now())
156
+ throw new InvalidCreditStateError(`Funding intent is expired: ${intent.id}`);
157
+ const existing = await tx.getFundingTransactionByExternalPayment(rail, input.network, externalPaymentId);
158
+ if (existing) {
159
+ if (existing.fundingIntentId !== intent.id) {
160
+ throw new InvalidCreditStateError(`Funding payment is already assigned: ${externalPaymentId}`);
161
+ }
162
+ const account = await this.requireAccountById(tx, intent.accountId);
163
+ const grant = await tx.getGrant(existing.grantId);
164
+ if (!grant)
165
+ throw new CreditNotFoundError('Credit grant', existing.grantId);
166
+ return { account, grant, fundingIntent: intent, fundingTransaction: existing };
167
+ }
168
+ return this.idempotent(tx, 'confirm_funding', input.idempotencyKey, input, async () => {
169
+ if (intent.status !== 'pending')
170
+ throw new InvalidCreditStateError(`Funding intent is ${intent.status}: ${intent.id}`);
171
+ const requestedUnits = parseCreditUnits(intent.requestedUnits);
172
+ if (input.requireExactAmount ? amountUnits !== requestedUnits : amountUnits < requestedUnits) {
173
+ throw new InvalidCreditStateError(input.requireExactAmount
174
+ ? `Funding amount mismatch: ${amountUnits} !== ${intent.requestedUnits}`
175
+ : `Funding underpayment: ${amountUnits} < ${intent.requestedUnits}`);
176
+ }
177
+ const now = this.now();
178
+ const current = await this.requireAccountById(tx, intent.accountId);
179
+ const grant = {
180
+ id: createId('grant'),
181
+ accountId: current.id,
182
+ projectId: this.projectId,
183
+ customerId: current.customerId,
184
+ amount: creditUnitsToString(amountUnits),
185
+ amountUnits: amountUnits.toString(),
186
+ source: rail === 'arc_direct' ? 'arc' : 'circle_gateway_nanopayment',
187
+ externalRef: `${rail}:${input.network}:${externalPaymentId.toLowerCase()}`,
188
+ createdAt: now,
189
+ metadata: {
190
+ ...input.metadata,
191
+ fundingIntentId: intent.id,
192
+ paymentReceiptId: input.paymentReceiptId,
193
+ },
194
+ };
195
+ const account = withBalances(current, parseCreditUnits(current.postedUnits) + amountUnits, parseCreditUnits(current.reservedUnits), now);
196
+ const confirmedIntent = { ...intent, status: 'confirmed', confirmedAt: now };
197
+ const settlementStatus = input.settlementStatus ?? 'settled';
198
+ const settledAt = settlementStatus === 'settled' ? Math.max(input.settledAt ?? now, now) : undefined;
199
+ const fundingTransaction = {
200
+ id: createId('ftx'),
201
+ fundingIntentId: intent.id,
202
+ projectId: this.projectId,
203
+ customerId: account.customerId,
204
+ accountId: account.id,
205
+ rail,
206
+ network: input.network,
207
+ externalPaymentId,
208
+ txHash: input.txHash,
209
+ amount: grant.amount,
210
+ amountUnits: grant.amountUnits,
211
+ paymentReceiptId: input.paymentReceiptId,
212
+ grantId: grant.id,
213
+ payer: input.payer ?? input.evidence?.payer,
214
+ settlementStatus,
215
+ acceptedAt: now,
216
+ settledAt,
217
+ evidence: {
218
+ ...input.evidence,
219
+ amountUnits: amountUnits.toString(),
220
+ payer: input.payer ?? input.evidence?.payer,
221
+ },
222
+ createdAt: now,
223
+ metadata: input.metadata,
224
+ };
225
+ await tx.saveGrant(grant);
226
+ await tx.saveAccount(account);
227
+ await tx.saveFundingIntent(confirmedIntent);
228
+ await tx.saveFundingTransaction(fundingTransaction);
229
+ await this.saveLedgerEntry(tx, account, 'grant', 'posted', amountUnits, 'funding', fundingTransaction.id, now, grant.metadata);
230
+ await this.saveOutboxEvent(tx, 'funding.accepted', { fundingIntent: confirmedIntent, fundingTransaction }, now);
231
+ await this.saveOutboxEvent(tx, 'funding.confirmed', { fundingIntent: confirmedIntent, fundingTransaction, account, grant }, now);
232
+ if (settlementStatus === 'settled') {
233
+ await this.saveOutboxEvent(tx, 'funding.settled', { fundingIntent: confirmedIntent, fundingTransaction }, fundingTransaction.settledAt);
234
+ }
235
+ return { account, grant, fundingIntent: confirmedIntent, fundingTransaction };
236
+ });
237
+ });
238
+ }
239
+ async updateFundingSettlement(input) {
240
+ return this.store.transaction((tx) => this.idempotent(tx, 'update_funding_settlement', input.idempotencyKey, input, async () => {
241
+ const transaction = await tx.getFundingTransaction(input.fundingTransactionId);
242
+ if (!transaction || transaction.projectId !== this.projectId) {
243
+ throw new CreditNotFoundError('Funding transaction', input.fundingTransactionId);
244
+ }
245
+ if (transaction.settlementStatus === input.status)
246
+ return transaction;
247
+ if (transaction.settlementStatus === 'settled' &&
248
+ input.status !== 'reconciliation_required') {
249
+ throw new InvalidCreditStateError(`Funding transaction is already settled: ${transaction.id}`);
250
+ }
251
+ const now = this.now();
252
+ const updated = {
253
+ ...transaction,
254
+ settlementStatus: input.status,
255
+ settledAt: input.status === 'settled' ? (input.settledAt ?? now) : transaction.settledAt,
256
+ metadata: {
257
+ ...transaction.metadata,
258
+ ...input.metadata,
259
+ reconciliationReason: input.reason,
260
+ },
261
+ };
262
+ await tx.saveFundingTransaction(updated);
263
+ await this.saveOutboxEvent(tx, input.status === 'settled' ? 'funding.settled' : 'funding.reconciliation_required', { fundingTransaction: updated, reason: input.reason }, now);
264
+ return updated;
265
+ }));
266
+ }
267
+ async failFundingIntent(input) {
268
+ return this.store.transaction((tx) => this.idempotent(tx, 'fail_funding', input.idempotencyKey, input, async () => {
269
+ const intent = await tx.getFundingIntent(input.fundingIntentId);
270
+ if (!intent || intent.projectId !== this.projectId)
271
+ throw new CreditNotFoundError('Funding intent', input.fundingIntentId);
272
+ if (intent.status === 'failed')
273
+ return intent;
274
+ if (intent.status !== 'pending')
275
+ throw new InvalidCreditStateError(`Funding intent is ${intent.status}: ${intent.id}`);
276
+ const failed = {
277
+ ...intent,
278
+ status: 'failed',
279
+ failedAt: this.now(),
280
+ failureReason: requireText(input.reason, 'reason'),
281
+ };
282
+ await tx.saveFundingIntent(failed);
283
+ await this.saveOutboxEvent(tx, 'funding.failed', { fundingIntent: failed }, failed.failedAt);
284
+ return failed;
285
+ }));
286
+ }
287
+ async reserveCredits(input) {
288
+ return this.store.transaction((tx) => this.idempotent(tx, 'reserve_credits', input.idempotencyKey, input, async () => {
289
+ const now = this.now();
290
+ await this.expireOpenReservations(tx, now, input.customerId);
291
+ const price = await this.requirePrice(tx, input.priceId);
292
+ const rating = rateUsage(price, input.estimatedUsage);
293
+ const reservedUnits = parseCreditUnits(rating.totalUnits);
294
+ const current = await this.ensureAccountInTransaction(tx, input.customerId, input.metadata);
295
+ const available = parseCreditUnits(current.availableUnits);
296
+ if (available < reservedUnits) {
297
+ throw new InsufficientCreditsError(available.toString(), reservedUnits.toString());
298
+ }
299
+ const expiresAt = input.expiresAt ?? now + this.reservationTtlMs;
300
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) {
301
+ throw new Error('Reservation expiresAt must be in the future');
302
+ }
303
+ const reservation = {
304
+ id: createId('rsv'),
305
+ accountId: current.id,
306
+ projectId: this.projectId,
307
+ customerId: current.customerId,
308
+ priceId: price.id,
309
+ status: 'open',
310
+ estimatedUsage: normalizeUsage(input.estimatedUsage),
311
+ reservedAmount: rating.totalAmount,
312
+ reservedUnits: rating.totalUnits,
313
+ createdAt: now,
314
+ expiresAt,
315
+ metadata: input.metadata,
316
+ };
317
+ const account = withBalances(current, parseCreditUnits(current.postedUnits), parseCreditUnits(current.reservedUnits) + reservedUnits, now);
318
+ await tx.saveReservation(reservation);
319
+ await tx.saveAccount(account);
320
+ await this.saveLedgerEntry(tx, account, 'reserve', 'reserved', reservedUnits, 'reservation', reservation.id, now, input.metadata);
321
+ await this.saveOutboxEvent(tx, 'credit.reserved', { account, reservation }, now);
322
+ return reservation;
323
+ }));
324
+ }
325
+ async commitUsage(input) {
326
+ return this.store.transaction((tx) => this.idempotent(tx, 'commit_usage', input.idempotencyKey, input, async () => {
327
+ const now = this.now();
328
+ const reservation = await this.requireReservation(tx, input.reservationId);
329
+ if (reservation.status === 'committed' && reservation.usageReceiptId) {
330
+ const receipt = await tx.getUsageReceipt(reservation.usageReceiptId);
331
+ const balance = await tx.getAccount(reservation.accountId);
332
+ if (receipt && balance)
333
+ return { receipt, reservation, balance };
334
+ }
335
+ if (reservation.status !== 'open') {
336
+ throw new InvalidCreditStateError(`Reservation is ${reservation.status}: ${reservation.id}`);
337
+ }
338
+ if (reservation.expiresAt <= now) {
339
+ await this.expireReservation(tx, reservation, now);
340
+ throw new InvalidCreditStateError(`Reservation expired: ${reservation.id}`);
341
+ }
342
+ const existingUsage = await tx.getUsageEvent(input.usageEventId);
343
+ if (existingUsage) {
344
+ throw new InvalidCreditStateError(`Usage event already exists: ${input.usageEventId}`);
345
+ }
346
+ const price = await this.requirePrice(tx, reservation.priceId);
347
+ const rating = rateUsage(price, input.actualUsage);
348
+ const chargeUnits = parseCreditUnits(rating.totalUnits);
349
+ const reservedUnits = parseCreditUnits(reservation.reservedUnits);
350
+ if (chargeUnits > reservedUnits) {
351
+ throw new InvalidCreditStateError(`Actual charge exceeds reservation: ${rating.totalUnits} > ${reservation.reservedUnits}`);
352
+ }
353
+ const accountBefore = await this.requireAccountById(tx, reservation.accountId);
354
+ const postedBefore = parseCreditUnits(accountBefore.postedUnits);
355
+ const reservedBefore = parseCreditUnits(accountBefore.reservedUnits);
356
+ const account = withBalances(accountBefore, postedBefore - chargeUnits, reservedBefore - reservedUnits, now);
357
+ const releasedUnits = reservedUnits - chargeUnits;
358
+ const usageEvent = {
359
+ id: requireText(input.usageEventId, 'usageEventId'),
360
+ accountId: account.id,
361
+ projectId: this.projectId,
362
+ customerId: account.customerId,
363
+ reservationId: reservation.id,
364
+ priceId: price.id,
365
+ quantities: normalizeUsage(input.actualUsage),
366
+ occurredAt: input.occurredAt ?? now,
367
+ receivedAt: now,
368
+ metadata: input.metadata,
369
+ };
370
+ const receipt = {
371
+ id: createId('urcpt'),
372
+ accountId: account.id,
373
+ projectId: this.projectId,
374
+ customerId: account.customerId,
375
+ reservationId: reservation.id,
376
+ usageEventId: usageEvent.id,
377
+ priceId: price.id,
378
+ currency: 'USD',
379
+ amount: rating.totalAmount,
380
+ amountUnits: rating.totalUnits,
381
+ releasedAmount: creditUnitsToString(releasedUnits),
382
+ releasedUnits: releasedUnits.toString(),
383
+ lineItems: rating.lineItems,
384
+ balanceBeforeUnits: accountBefore.availableUnits,
385
+ balanceAfterUnits: account.availableUnits,
386
+ createdAt: now,
387
+ metadata: input.metadata,
388
+ };
389
+ const committedReservation = {
390
+ ...reservation,
391
+ status: 'committed',
392
+ committedAmount: rating.totalAmount,
393
+ committedUnits: rating.totalUnits,
394
+ releasedAmount: receipt.releasedAmount,
395
+ releasedUnits: receipt.releasedUnits,
396
+ usageReceiptId: receipt.id,
397
+ closedAt: now,
398
+ };
399
+ await tx.saveUsageEvent(usageEvent);
400
+ await tx.saveUsageReceipt(receipt);
401
+ await tx.saveReservation(committedReservation);
402
+ await tx.saveAccount(account);
403
+ await this.saveLedgerEntry(tx, account, 'charge', 'posted', -chargeUnits, 'usage_receipt', receipt.id, now, input.metadata);
404
+ await this.saveLedgerEntry(tx, account, 'release', 'reserved', -reservedUnits, 'usage_receipt', receipt.id, now, input.metadata);
405
+ await this.saveOutboxEvent(tx, 'usage.charged', {
406
+ usageReceiptId: receipt.id,
407
+ accountId: account.id,
408
+ customerId: account.customerId,
409
+ reservationId: reservation.id,
410
+ amount: receipt.amount,
411
+ amountUnits: receipt.amountUnits,
412
+ releasedAmount: receipt.releasedAmount,
413
+ balanceAfterUnits: receipt.balanceAfterUnits,
414
+ }, now);
415
+ return { receipt, reservation: committedReservation, balance: account };
416
+ }));
417
+ }
418
+ async releaseReservation(input) {
419
+ return this.store.transaction((tx) => this.idempotent(tx, 'release_reservation', input.idempotencyKey, input, async () => {
420
+ const reservation = await this.requireReservation(tx, input.reservationId);
421
+ if (reservation.status === 'released' || reservation.status === 'expired') {
422
+ return { reservation, balance: await this.requireAccountById(tx, reservation.accountId) };
423
+ }
424
+ if (reservation.status !== 'open') {
425
+ throw new InvalidCreditStateError(`Cannot release ${reservation.status} reservation: ${reservation.id}`);
426
+ }
427
+ return this.closeReservation(tx, reservation, 'released', this.now(), input.reason);
428
+ }));
429
+ }
430
+ async releaseExpiredReservations(input) {
431
+ return this.store.transaction((tx) => this.idempotent(tx, 'release_expired', input.idempotencyKey, input, async () => this.expireOpenReservations(tx, input.now ?? this.now())));
432
+ }
433
+ async runMetered(input, callback) {
434
+ const reserved = await this.reserveCredits(input);
435
+ const reservation = (await this.store.getReservation(reserved.id)) ?? reserved;
436
+ if (reservation.status === 'committed' && reservation.usageReceiptId) {
437
+ const receipt = await this.requireUsageReceipt(reservation.usageReceiptId);
438
+ return {
439
+ replayed: true,
440
+ reservation,
441
+ receipt,
442
+ balance: await this.getBalance(reservation.customerId),
443
+ };
444
+ }
445
+ let result;
446
+ try {
447
+ result = await callback();
448
+ }
449
+ catch (error) {
450
+ await this.releaseReservation({
451
+ reservationId: reservation.id,
452
+ idempotencyKey: `${input.idempotencyKey}:provider_error`,
453
+ reason: 'provider_error',
454
+ });
455
+ throw error;
456
+ }
457
+ const committed = await this.commitUsage({
458
+ reservationId: reservation.id,
459
+ usageEventId: result.usageEventId,
460
+ actualUsage: result.actualUsage,
461
+ occurredAt: result.occurredAt,
462
+ metadata: result.metadata,
463
+ idempotencyKey: `${input.idempotencyKey}:commit`,
464
+ });
465
+ return { value: result.value, replayed: false, ...committed };
466
+ }
467
+ async getBalance(customerId) {
468
+ const account = await this.store.getAccountByCustomer(this.projectId, requireText(customerId, 'customerId'));
469
+ if (!account)
470
+ throw new CreditNotFoundError('Credit account', customerId);
471
+ return account;
472
+ }
473
+ getReservation(id) {
474
+ return this.store.getReservation(id);
475
+ }
476
+ getUsageReceipt(id) {
477
+ return this.store.getUsageReceipt(id);
478
+ }
479
+ getFundingIntent(id) {
480
+ return this.store.getFundingIntent(id);
481
+ }
482
+ listFundingIntents() {
483
+ return this.store.listFundingIntents(this.projectId);
484
+ }
485
+ getFundingTransaction(id) {
486
+ return this.store.getFundingTransaction(id);
487
+ }
488
+ listFundingTransactions(fundingIntentId) {
489
+ return this.store.listFundingTransactions(fundingIntentId);
490
+ }
491
+ listUsageReceipts(customerId) {
492
+ return customerId
493
+ ? this.getBalance(customerId).then((account) => this.store.listUsageReceipts(account.id))
494
+ : this.store.listUsageReceipts();
495
+ }
496
+ listLedgerEntries(customerId) {
497
+ return customerId
498
+ ? this.getBalance(customerId).then((account) => this.store.listLedgerEntries(account.id))
499
+ : this.store.listLedgerEntries();
500
+ }
501
+ listOutboxEvents(filter = {}) {
502
+ return this.store.listOutboxEvents({ ...filter, projectId: this.projectId });
503
+ }
504
+ async markOutboxEventDelivered(input) {
505
+ return this.store.transaction((tx) => this.idempotent(tx, 'deliver_outbox', input.idempotencyKey, input, async () => {
506
+ const event = await tx.getOutboxEvent(input.eventId);
507
+ if (!event || event.projectId !== this.projectId)
508
+ throw new CreditNotFoundError('Outbox event', input.eventId);
509
+ if (event.status === 'delivered')
510
+ return event;
511
+ const delivered = {
512
+ ...event,
513
+ status: 'delivered',
514
+ deliveredAt: input.deliveredAt ?? this.now(),
515
+ leaseOwner: undefined,
516
+ leaseExpiresAt: undefined,
517
+ };
518
+ await tx.saveOutboxEvent(delivered);
519
+ return delivered;
520
+ }));
521
+ }
522
+ async ensureAccountInTransaction(tx, customerIdValue, metadata) {
523
+ const customerId = requireText(customerIdValue, 'customerId');
524
+ const existing = await tx.getAccountByCustomer(this.projectId, customerId);
525
+ if (existing)
526
+ return existing;
527
+ const now = this.now();
528
+ const account = withBalances({
529
+ id: deterministicAccountId(this.projectId, customerId),
530
+ projectId: this.projectId,
531
+ customerId,
532
+ currency: 'USD',
533
+ postedUnits: '0',
534
+ reservedUnits: '0',
535
+ availableUnits: '0',
536
+ postedAmount: '0',
537
+ reservedAmount: '0',
538
+ availableAmount: '0',
539
+ createdAt: now,
540
+ updatedAt: now,
541
+ metadata,
542
+ }, 0n, 0n, now);
543
+ await tx.saveAccount(account);
544
+ return account;
545
+ }
546
+ async expireOpenReservations(tx, now, customerId) {
547
+ const open = await tx.listReservations({
548
+ projectId: this.projectId,
549
+ customerId,
550
+ status: 'open',
551
+ });
552
+ const expired = [];
553
+ for (const reservation of open.filter((item) => item.expiresAt <= now)) {
554
+ const result = await this.expireReservation(tx, reservation, now);
555
+ expired.push(result.reservation);
556
+ }
557
+ return expired;
558
+ }
559
+ expireReservation(tx, reservation, now) {
560
+ return this.closeReservation(tx, reservation, 'expired', now, 'ttl_expired');
561
+ }
562
+ async closeReservation(tx, reservation, status, now, reason) {
563
+ const accountBefore = await this.requireAccountById(tx, reservation.accountId);
564
+ const reservedUnits = parseCreditUnits(reservation.reservedUnits);
565
+ const account = withBalances(accountBefore, parseCreditUnits(accountBefore.postedUnits), parseCreditUnits(accountBefore.reservedUnits) - reservedUnits, now);
566
+ const closed = {
567
+ ...reservation,
568
+ status,
569
+ releasedAmount: reservation.reservedAmount,
570
+ releasedUnits: reservation.reservedUnits,
571
+ closedAt: now,
572
+ };
573
+ await tx.saveReservation(closed);
574
+ await tx.saveAccount(account);
575
+ await this.saveLedgerEntry(tx, account, 'release', 'reserved', -reservedUnits, 'reservation', reservation.id, now, { reason });
576
+ await this.saveOutboxEvent(tx, status === 'expired' ? 'credit.expired' : 'credit.released', { account, reservation: closed, reason }, now);
577
+ return { reservation: closed, balance: account };
578
+ }
579
+ async idempotent(tx, operation, keyValue, request, handler) {
580
+ const key = requireText(keyValue, 'idempotencyKey');
581
+ const scope = `${this.projectId}:${operation}`;
582
+ const requestHash = hashRequest(request);
583
+ const existing = await tx.getIdempotencyRecord(scope, key);
584
+ if (existing) {
585
+ if (existing.requestHash !== requestHash)
586
+ throw new IdempotencyConflictError(key);
587
+ return structuredClone(existing.result);
588
+ }
589
+ const result = await handler();
590
+ await tx.saveIdempotencyRecord({
591
+ scope,
592
+ key,
593
+ requestHash,
594
+ result: structuredClone(result),
595
+ createdAt: this.now(),
596
+ });
597
+ return result;
598
+ }
599
+ async saveLedgerEntry(tx, account, type, bucket, delta, referenceType, referenceId, createdAt, metadata) {
600
+ const entry = {
601
+ id: createId('le'),
602
+ accountId: account.id,
603
+ projectId: account.projectId,
604
+ customerId: account.customerId,
605
+ type,
606
+ bucket,
607
+ deltaUnits: delta.toString(),
608
+ balanceAfterUnits: bucket === 'posted' ? account.postedUnits : account.reservedUnits,
609
+ referenceType,
610
+ referenceId,
611
+ createdAt,
612
+ metadata,
613
+ };
614
+ await tx.saveLedgerEntry(entry);
615
+ return entry;
616
+ }
617
+ async saveOutboxEvent(tx, type, data, createdAt) {
618
+ await tx.saveOutboxEvent({
619
+ id: createId('evt'),
620
+ projectId: this.projectId,
621
+ type,
622
+ data,
623
+ status: 'pending',
624
+ createdAt,
625
+ attemptCount: 0,
626
+ nextAttemptAt: createdAt,
627
+ });
628
+ }
629
+ async requirePrice(tx, id) {
630
+ const price = await tx.getPriceVersion(id);
631
+ if (!price || price.projectId !== this.projectId)
632
+ throw new CreditNotFoundError('Price version', id);
633
+ return price;
634
+ }
635
+ async requireReservation(tx, id) {
636
+ const value = await tx.getReservation(id);
637
+ if (!value || value.projectId !== this.projectId)
638
+ throw new CreditNotFoundError('Reservation', id);
639
+ return value;
640
+ }
641
+ async requireAccountById(tx, id) {
642
+ const value = await tx.getAccount(id);
643
+ if (!value || value.projectId !== this.projectId)
644
+ throw new CreditNotFoundError('Credit account', id);
645
+ return value;
646
+ }
647
+ async requireUsageReceipt(id) {
648
+ const value = await this.store.getUsageReceipt(id);
649
+ if (!value || value.projectId !== this.projectId)
650
+ throw new CreditNotFoundError('Usage receipt', id);
651
+ return value;
652
+ }
653
+ }
654
+ function withBalances(account, posted, reserved, updatedAt) {
655
+ if (posted < 0n || reserved < 0n || reserved > posted) {
656
+ throw new InvalidCreditStateError('Credit account invariant violated');
657
+ }
658
+ const available = posted - reserved;
659
+ return {
660
+ ...account,
661
+ postedUnits: posted.toString(),
662
+ reservedUnits: reserved.toString(),
663
+ availableUnits: available.toString(),
664
+ postedAmount: creditUnitsToString(posted),
665
+ reservedAmount: creditUnitsToString(reserved),
666
+ availableAmount: creditUnitsToString(available),
667
+ updatedAt,
668
+ };
669
+ }
670
+ function normalizeUsage(usage) {
671
+ return Object.fromEntries(Object.entries(usage).sort(([a], [b]) => a.localeCompare(b)));
672
+ }
673
+ function toSignedCreditUnits(amount) {
674
+ const value = amount.trim();
675
+ return value.startsWith('-') ? -toCreditUnits(value.slice(1)) : toCreditUnits(value);
676
+ }
677
+ function deterministicAccountId(projectId, customerId) {
678
+ return `acct_${createHash('sha256').update(`${projectId}\u0000${customerId}`).digest('hex').slice(0, 24)}`;
679
+ }
680
+ function createId(prefix) {
681
+ return `${prefix}_${randomBytes(12).toString('hex')}`;
682
+ }
683
+ function requireText(value, label) {
684
+ const normalized = value.trim();
685
+ if (!normalized)
686
+ throw new Error(`${label} is required`);
687
+ return normalized;
688
+ }
689
+ function hashRequest(value) {
690
+ return createHash('sha256').update(stableStringify(value)).digest('hex');
691
+ }
692
+ function stableStringify(value) {
693
+ if (value === undefined)
694
+ return 'null';
695
+ if (value === null || typeof value !== 'object')
696
+ return JSON.stringify(value);
697
+ if (Array.isArray(value))
698
+ return `[${value.map(stableStringify).join(',')}]`;
699
+ return `{${Object.entries(value)
700
+ .filter(([, item]) => item !== undefined)
701
+ .sort(([a], [b]) => a.localeCompare(b))
702
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
703
+ .join(',')}}`;
704
+ }
705
+ //# sourceMappingURL=ledger.js.map