payment-kit 1.22.32 → 1.23.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 (49) hide show
  1. package/api/src/index.ts +4 -0
  2. package/api/src/integrations/arcblock/token.ts +599 -0
  3. package/api/src/libs/credit-grant.ts +7 -6
  4. package/api/src/libs/util.ts +34 -0
  5. package/api/src/queues/credit-consume.ts +29 -4
  6. package/api/src/queues/credit-grant.ts +245 -50
  7. package/api/src/queues/credit-reconciliation.ts +253 -0
  8. package/api/src/queues/refund.ts +263 -30
  9. package/api/src/queues/token-transfer.ts +331 -0
  10. package/api/src/routes/checkout-sessions.ts +94 -29
  11. package/api/src/routes/credit-grants.ts +35 -9
  12. package/api/src/routes/credit-tokens.ts +38 -0
  13. package/api/src/routes/credit-transactions.ts +20 -3
  14. package/api/src/routes/index.ts +2 -0
  15. package/api/src/routes/meter-events.ts +4 -0
  16. package/api/src/routes/meters.ts +32 -10
  17. package/api/src/routes/payment-currencies.ts +103 -0
  18. package/api/src/routes/payment-links.ts +3 -1
  19. package/api/src/routes/products.ts +2 -2
  20. package/api/src/routes/settings.ts +4 -3
  21. package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
  22. package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
  23. package/api/src/store/migrations/20251211-optimize-slow-queries.ts +33 -0
  24. package/api/src/store/models/credit-grant.ts +47 -9
  25. package/api/src/store/models/credit-transaction.ts +18 -1
  26. package/api/src/store/models/index.ts +2 -1
  27. package/api/src/store/models/payment-currency.ts +31 -4
  28. package/api/src/store/models/refund.ts +12 -2
  29. package/api/src/store/models/types.ts +48 -0
  30. package/api/src/store/sequelize.ts +1 -0
  31. package/api/third.d.ts +2 -0
  32. package/blocklet.yml +1 -1
  33. package/package.json +7 -6
  34. package/src/app.tsx +10 -0
  35. package/src/components/customer/credit-overview.tsx +19 -3
  36. package/src/components/meter/form.tsx +191 -18
  37. package/src/components/price/form.tsx +49 -37
  38. package/src/locales/en.tsx +25 -1
  39. package/src/locales/zh.tsx +27 -1
  40. package/src/pages/admin/billing/meters/create.tsx +42 -13
  41. package/src/pages/admin/billing/meters/detail.tsx +56 -5
  42. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +13 -0
  43. package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +324 -0
  44. package/src/pages/admin/customers/index.tsx +5 -0
  45. package/src/pages/customer/credit-grant/detail.tsx +14 -1
  46. package/src/pages/customer/credit-transaction/detail.tsx +289 -0
  47. package/src/pages/customer/invoice/detail.tsx +1 -1
  48. package/src/pages/customer/recharge/subscription.tsx +1 -1
  49. package/src/pages/customer/subscription/detail.tsx +1 -1
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Credit Token Balance Reconciliation Queue
3
+ *
4
+ * Verifies that customer's on-chain token balance matches database records.
5
+ * Triggered after mint (grant) and burn (expire) operations.
6
+ */
7
+
8
+ import { BN } from '@ocap/util';
9
+ import logger from '../libs/logger';
10
+ import createQueue from '../libs/queue';
11
+ import { getCustomerTokenBalance } from '../integrations/arcblock/token';
12
+ import { CreditGrant, CreditTransaction, Customer, PaymentCurrency } from '../store/models';
13
+ import { events } from '../libs/event';
14
+
15
+ type ReconciliationJob = {
16
+ customerId: string;
17
+ currencyId: string;
18
+ creditGrantId: string;
19
+ trigger: 'mint' | 'burn';
20
+ };
21
+
22
+ type BalanceInfo = {
23
+ // Sum of remaining_amount in all granted grants
24
+ dbRemainingAmount: string;
25
+ // Sum of credit_amount in pending transfers (consumed but not yet transferred on-chain)
26
+ pendingTransferAmount: string;
27
+ // Expected on-chain balance = dbRemainingAmount + pendingTransferAmount
28
+ expectedChainBalance: string;
29
+ };
30
+
31
+ /**
32
+ * Calculate customer's balance info from database
33
+ *
34
+ * The expected chain balance should be:
35
+ * - DB remaining_amount (what's left in grants that have been minted)
36
+ * - Plus pending transfer amount (consumed in DB but not yet transferred on-chain)
37
+ *
38
+ * Only counts grants with chain_status === 'mint_completed' since those have on-chain tokens.
39
+ */
40
+ async function getBalanceInfo(customerId: string, currencyId: string): Promise<BalanceInfo> {
41
+ // Get all granted credit grants that have been minted on-chain
42
+ const grants = await CreditGrant.findAll({
43
+ where: {
44
+ customer_id: customerId,
45
+ currency_id: currencyId,
46
+ status: 'granted',
47
+ chain_status: 'mint_completed', // Only count grants with on-chain tokens
48
+ },
49
+ });
50
+
51
+ const dbRemainingAmount = grants
52
+ .reduce((sum, grant) => sum.add(new BN(grant.remaining_amount)), new BN(0))
53
+ .toString();
54
+
55
+ // Get pending transfers (consumed but token not yet transferred on-chain)
56
+ const pendingTransactions = await CreditTransaction.findAll({
57
+ where: {
58
+ customer_id: customerId,
59
+ transfer_status: 'pending',
60
+ },
61
+ include: [
62
+ {
63
+ model: CreditGrant,
64
+ as: 'creditGrant',
65
+ where: { currency_id: currencyId },
66
+ required: true,
67
+ attributes: [],
68
+ },
69
+ ],
70
+ });
71
+
72
+ const pendingTransferAmount = pendingTransactions
73
+ .reduce((sum, tx) => sum.add(new BN(tx.credit_amount)), new BN(0))
74
+ .toString();
75
+
76
+ // Expected chain balance = remaining in grants + pending transfers
77
+ // Because: consumed credit is deducted from grant but token is still in user's wallet
78
+ const expectedChainBalance = new BN(dbRemainingAmount).add(new BN(pendingTransferAmount)).toString();
79
+
80
+ return {
81
+ dbRemainingAmount,
82
+ pendingTransferAmount,
83
+ expectedChainBalance,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Handle reconciliation job
89
+ */
90
+ async function handleReconciliation(job: ReconciliationJob) {
91
+ const { customerId, currencyId, creditGrantId, trigger } = job;
92
+
93
+ logger.info('Starting credit balance reconciliation', {
94
+ customerId,
95
+ currencyId,
96
+ creditGrantId,
97
+ trigger,
98
+ });
99
+
100
+ try {
101
+ const customer = await Customer.findByPk(customerId);
102
+ if (!customer) {
103
+ logger.warn('Customer not found for reconciliation', { customerId });
104
+ return;
105
+ }
106
+
107
+ const currency = await PaymentCurrency.findByPk(currencyId);
108
+ if (!currency || !currency.isOnChainCredit()) {
109
+ logger.warn('Currency not found or not on-chain credit', { currencyId });
110
+ return;
111
+ }
112
+
113
+ // Get balance info (considering pending transfers)
114
+ const balanceInfo = await getBalanceInfo(customerId, currencyId);
115
+ const chainBalance = await getCustomerTokenBalance(customer.did, currency);
116
+
117
+ const difference = new BN(chainBalance).sub(new BN(balanceInfo.expectedChainBalance));
118
+ const isMatched = difference.eq(new BN(0));
119
+
120
+ if (isMatched) {
121
+ logger.info('Credit balance reconciliation passed', {
122
+ customerId,
123
+ currencyId,
124
+ creditGrantId,
125
+ trigger,
126
+ dbRemainingAmount: balanceInfo.dbRemainingAmount,
127
+ pendingTransferAmount: balanceInfo.pendingTransferAmount,
128
+ expectedChainBalance: balanceInfo.expectedChainBalance,
129
+ actualChainBalance: chainBalance,
130
+ });
131
+ } else {
132
+ logger.error('Credit balance mismatch detected', {
133
+ customerId,
134
+ customerDid: customer.did,
135
+ currencyId,
136
+ creditGrantId,
137
+ trigger,
138
+ dbRemainingAmount: balanceInfo.dbRemainingAmount,
139
+ pendingTransferAmount: balanceInfo.pendingTransferAmount,
140
+ expectedChainBalance: balanceInfo.expectedChainBalance,
141
+ actualChainBalance: chainBalance,
142
+ difference: difference.toString(),
143
+ });
144
+
145
+ // Emit event for monitoring/alerting
146
+ events.emit('customer.credit.reconciliation.mismatch', {
147
+ customerId,
148
+ customerDid: customer.did,
149
+ currencyId,
150
+ creditGrantId,
151
+ trigger,
152
+ dbRemainingAmount: balanceInfo.dbRemainingAmount,
153
+ pendingTransferAmount: balanceInfo.pendingTransferAmount,
154
+ expectedChainBalance: balanceInfo.expectedChainBalance,
155
+ actualChainBalance: chainBalance,
156
+ difference: difference.toString(),
157
+ });
158
+ }
159
+ } catch (error: any) {
160
+ logger.error('Credit balance reconciliation failed', {
161
+ customerId,
162
+ currencyId,
163
+ creditGrantId,
164
+ trigger,
165
+ error: error.message,
166
+ });
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Reconciliation queue
172
+ */
173
+ export const reconciliationQueue = createQueue<ReconciliationJob>({
174
+ name: 'credit-reconciliation',
175
+ onJob: handleReconciliation,
176
+ options: {
177
+ concurrency: 3,
178
+ maxRetries: 2,
179
+ retryDelay: 5000,
180
+ enableScheduledJob: true,
181
+ },
182
+ });
183
+
184
+ reconciliationQueue.on('finished', ({ id, job }) => {
185
+ logger.debug('Reconciliation job completed', { id, customerId: job.customerId });
186
+ });
187
+
188
+ reconciliationQueue.on('failed', ({ id, job, error }) => {
189
+ logger.error('Reconciliation job failed', { id, customerId: job.customerId, error: error.message });
190
+ });
191
+
192
+ /**
193
+ * Add reconciliation job to queue
194
+ * @param delay - delay in seconds (default: 5)
195
+ */
196
+ async function addReconciliationJob(
197
+ customerId: string,
198
+ currencyId: string,
199
+ creditGrantId: string,
200
+ trigger: 'mint' | 'burn',
201
+ delay: number = 5
202
+ ): Promise<void> {
203
+ const jobId = `reconcile-${customerId}-${currencyId}`;
204
+
205
+ // Skip if already queued
206
+ const existingJob = await reconciliationQueue.get(jobId);
207
+ if (existingJob) {
208
+ logger.debug('Reconciliation job already queued', { jobId });
209
+ return;
210
+ }
211
+
212
+ await reconciliationQueue.push({
213
+ id: jobId,
214
+ job: { customerId, currencyId, creditGrantId, trigger },
215
+ delay,
216
+ });
217
+
218
+ logger.info('Reconciliation job added', { jobId, trigger, delay });
219
+ }
220
+
221
+ /**
222
+ * Start reconciliation queue and register event listeners
223
+ */
224
+ // eslint-disable-next-line require-await
225
+ export async function startReconciliationQueue() {
226
+ logger.info('Starting credit reconciliation queue');
227
+
228
+ // Trigger reconciliation after successful mint
229
+ events.on('customer.credit_grant.minted', async (creditGrant: CreditGrant) => {
230
+ try {
231
+ await addReconciliationJob(creditGrant.customer_id, creditGrant.currency_id, creditGrant.id, 'mint');
232
+ } catch (error: any) {
233
+ logger.error('Failed to add reconciliation job after mint', {
234
+ creditGrantId: creditGrant.id,
235
+ error: error.message,
236
+ });
237
+ }
238
+ });
239
+
240
+ // Trigger reconciliation after successful burn
241
+ events.on('customer.credit_grant.burned', async (creditGrant: CreditGrant) => {
242
+ try {
243
+ await addReconciliationJob(creditGrant.customer_id, creditGrant.currency_id, creditGrant.id, 'burn');
244
+ } catch (error: any) {
245
+ logger.error('Failed to add reconciliation job after burn', {
246
+ creditGrantId: creditGrant.id,
247
+ error: error.message,
248
+ });
249
+ }
250
+ });
251
+
252
+ logger.info('Credit reconciliation queue started');
253
+ }
@@ -1,3 +1,4 @@
1
+ import { BN, fromUnitToToken } from '@ocap/util';
1
2
  import { isRefundReasonSupportedByStripe } from '../libs/refund';
2
3
  import { checkRemainingStake, getSubscriptionStakeAddress } from '../libs/subscription';
3
4
  import { sendErc20ToUser } from '../integrations/ethereum/token';
@@ -14,6 +15,9 @@ import { PaymentIntent } from '../store/models/payment-intent';
14
15
  import { PaymentMethod } from '../store/models/payment-method';
15
16
  import { Refund } from '../store/models/refund';
16
17
  import { Subscription } from '../store/models/subscription';
18
+ import { CreditGrant } from '../store/models/credit-grant';
19
+ import { Invoice } from '../store/models/invoice';
20
+ import { burnToken } from '../integrations/arcblock/token';
17
21
  import type { EVMChainType, PaymentError } from '../store/models/types';
18
22
  import { EVM_CHAIN_TYPES } from '../libs/constants';
19
23
 
@@ -111,6 +115,45 @@ export const handleRefund = async (job: RefundJob) => {
111
115
  }
112
116
  };
113
117
 
118
+ /**
119
+ * Finalize refund after transfer succeeds
120
+ * For credit purchase refunds: set processing -> burn credit -> set succeeded/failed
121
+ * For other types: directly set succeeded
122
+ */
123
+ async function finalizeRefundAfterTransfer(refund: Refund, paymentDetails: any): Promise<void> {
124
+ try {
125
+ // Try to burn credit tokens if this refund is for a credit purchase
126
+ const totalBurned = await handleOnchainCreditRefund(refund);
127
+
128
+ // All succeeded, update status
129
+ await refund.update({
130
+ status: 'succeeded',
131
+ last_attempt_error: null,
132
+ payment_details: paymentDetails,
133
+ });
134
+ logger.info('Refund succeeded', {
135
+ id: refund.id,
136
+ totalBurned,
137
+ hasCreditBurn: !!totalBurned && totalBurned !== '0',
138
+ });
139
+ } catch (error: any) {
140
+ // Burn failed, set processing status and record error
141
+ await refund.update({
142
+ status: 'processing',
143
+ payment_details: paymentDetails,
144
+ last_attempt_error: {
145
+ type: 'credit_burn_error',
146
+ code: 'BURN_FAILED',
147
+ message: error.message || 'Failed to burn credit tokens',
148
+ },
149
+ });
150
+ logger.error('Credit burn failed, refund set to processing', {
151
+ id: refund.id,
152
+ error: error.message,
153
+ });
154
+ }
155
+ }
156
+
114
157
  const handleRefundJob = async (
115
158
  job: RefundJob,
116
159
  refund: Refund,
@@ -170,18 +213,13 @@ const handleRefundJob = async (
170
213
  const txHash = await client.sendTransferV2Tx({ tx: signed, wallet }, await getGasPayerExtra(buffer));
171
214
 
172
215
  logger.info('refund transfer done', { id: refund.id, txHash });
173
- await refund.update({
174
- status: 'succeeded',
175
- last_attempt_error: null,
176
- payment_details: {
177
- arcblock: {
178
- tx_hash: txHash,
179
- payer: wallet.address,
180
- type: 'transfer',
181
- },
216
+ await finalizeRefundAfterTransfer(refund, {
217
+ arcblock: {
218
+ tx_hash: txHash,
219
+ payer: wallet.address,
220
+ type: 'transfer',
182
221
  },
183
222
  });
184
- logger.info('Refund status updated to succeeded', { id: refund.id, txHash });
185
223
  }
186
224
 
187
225
  if (EVM_CHAIN_TYPES.includes(paymentMethod.type)) {
@@ -203,18 +241,14 @@ const handleRefundJob = async (
203
241
  const receipt = await sendErc20ToUser(client, paymentCurrency.contract, payer, refund.amount);
204
242
  logger.info('refund transfer done', { id: refund.id, txHash: receipt.hash });
205
243
 
206
- await refund.update({
207
- status: 'succeeded',
208
- last_attempt_error: null,
209
- payment_details: {
210
- [paymentType]: {
211
- tx_hash: receipt.hash,
212
- payer: wallet.address,
213
- block_height: receipt.blockNumber.toString(),
214
- gas_used: receipt.gasUsed.toString(),
215
- gas_price: receipt.gasPrice.toString(),
216
- type: 'transfer',
217
- },
244
+ await finalizeRefundAfterTransfer(refund, {
245
+ [paymentType]: {
246
+ tx_hash: receipt.hash,
247
+ payer: wallet.address,
248
+ block_height: receipt.blockNumber.toString(),
249
+ gas_used: receipt.gasUsed.toString(),
250
+ gas_price: receipt.gasPrice.toString(),
251
+ type: 'transfer',
218
252
  },
219
253
  });
220
254
  }
@@ -243,14 +277,10 @@ const handleRefundJob = async (
243
277
  ...(isRefundReasonSupportedByStripe(refund.reason) ? { reason: refund.reason } : {}),
244
278
  });
245
279
  if (stripeRefund.status === 'succeeded') {
246
- await refund.update({
247
- status: 'succeeded',
248
- last_attempt_error: null,
249
- payment_details: {
250
- stripe: {
251
- payment_intent_id: stripePaymentIntentId,
252
- refund_id: stripeRefund.id,
253
- },
280
+ await finalizeRefundAfterTransfer(refund, {
281
+ stripe: {
282
+ payment_intent_id: stripePaymentIntentId,
283
+ refund_id: stripeRefund.id,
254
284
  },
255
285
  });
256
286
  }
@@ -441,3 +471,206 @@ events.on('refund.created', (refund: Refund) => {
441
471
  logger.info('schedule refund', { id: refund.id });
442
472
  refundQueue.push({ id: refund.id, job: { refundId: refund.id } });
443
473
  });
474
+
475
+ /**
476
+ * Handle onchain credit refund - burn user-held tokens for credit grants
477
+ *
478
+ * When a refund is issued for a credit purchase:
479
+ * 1. Find credit grants associated with the invoice
480
+ * 2. For each grant, check if its currency is an onchain credit
481
+ * 3. Calculate how much to burn based on refund amount
482
+ * 4. Only burn user-held tokens (remaining_amount in user's wallet)
483
+ * 5. If user has consumed some credit, only burn what they have left
484
+ * (the consumed portion stays in system wallet as it corresponds to used services)
485
+ * 6. Update credit grant status
486
+ *
487
+ * @param refund - The refund record
488
+ * @param paymentDetails - Payment details to update on refund
489
+ * @returns Total amount burned (as string), '0' if nothing to burn
490
+ * @throws Error if burn operation fails
491
+ */
492
+ /**
493
+ * Handle onchain credit refund - burn user-held tokens for credit grants
494
+ *
495
+ * @param refund - The refund record
496
+ * @returns Total amount burned (as string), undefined if nothing to burn
497
+ * @throws Error if burn operation fails
498
+ */
499
+ export async function handleOnchainCreditRefund(refund: Refund): Promise<string | undefined> {
500
+ logger.info('Processing onchain credit refund', { refundId: refund.id, invoiceId: refund.invoice_id });
501
+
502
+ // If no invoice_id, no credit grants to burn
503
+ if (!refund.invoice_id) {
504
+ return undefined;
505
+ }
506
+
507
+ const invoice = await Invoice.findByPk(refund.invoice_id);
508
+ const customer = await Customer.findByPk(refund.customer_id);
509
+
510
+ if (!invoice || !customer?.did) {
511
+ throw new Error('Invoice or customer not found for refund');
512
+ }
513
+
514
+ const invoiceTotal = new BN(invoice.total || '0');
515
+ if (invoiceTotal.lte(new BN(0))) {
516
+ throw new Error(`Invoice ${refund.invoice_id} total is zero or negative`);
517
+ }
518
+
519
+ // Find ALL credit grants related to this invoice (not filtered by currency_id)
520
+ const allGrants = await CreditGrant.findAll({
521
+ where: {
522
+ customer_id: refund.customer_id,
523
+ status: ['pending', 'granted'],
524
+ },
525
+ });
526
+ const creditGrants = allGrants.filter(
527
+ (grant) => grant.metadata?.invoice_id === refund.invoice_id && CreditGrant.hasOnchainToken(grant)
528
+ );
529
+
530
+ if (creditGrants.length === 0) {
531
+ logger.info('No active credit grants found for refund', { refundId: refund.id, invoiceId: refund.invoice_id });
532
+ return undefined;
533
+ }
534
+
535
+ logger.info('Found credit grants to process for refund', {
536
+ refundId: refund.id,
537
+ creditGrantCount: creditGrants.length,
538
+ invoiceTotal: invoice.total,
539
+ refundAmount: refund.amount,
540
+ });
541
+
542
+ const refundAmount = new BN(refund.amount);
543
+ let totalBurnedFromUser = new BN(0);
544
+ let lastError: string | null = null;
545
+
546
+ // Process each credit grant
547
+ // eslint-disable-next-line no-restricted-syntax
548
+ for (const creditGrant of creditGrants) {
549
+ // Get the currency for THIS grant
550
+ // eslint-disable-next-line no-await-in-loop
551
+ const grantCurrency = await PaymentCurrency.findByPk(creditGrant.currency_id);
552
+
553
+ // Verify currency has token config for burn operation
554
+ if (!grantCurrency?.token_config) {
555
+ logger.error('Payment currency token config not found for burn', {
556
+ creditGrantId: creditGrant.id,
557
+ currencyId: creditGrant.currency_id,
558
+ });
559
+ // eslint-disable-next-line no-continue
560
+ continue;
561
+ }
562
+
563
+ const grantTotalAmount = new BN(creditGrant.amount);
564
+ const grantRemainingAmount = new BN(creditGrant.remaining_amount || '0');
565
+
566
+ // Calculate how much credit should be burned from this grant based on refund ratio
567
+ // shouldBurnFromGrant = grantTotalAmount × refundAmount / invoiceTotal (multiply first to avoid precision loss)
568
+ const shouldBurnFromGrant = grantTotalAmount.mul(refundAmount).div(invoiceTotal);
569
+
570
+ // Actual burn is limited by what user actually has in their wallet
571
+ const actualBurnAmount = BN.min(shouldBurnFromGrant, grantRemainingAmount);
572
+ // The difference (if any) is consumed credit that stays in system wallet
573
+ const systemRetainedAmount = shouldBurnFromGrant.sub(actualBurnAmount);
574
+
575
+ logger.info('Processing credit grant for refund', {
576
+ creditGrantId: creditGrant.id,
577
+ currencyId: grantCurrency.id,
578
+ grantTotal: grantTotalAmount.toString(),
579
+ grantRemaining: grantRemainingAmount.toString(),
580
+ shouldBurn: shouldBurnFromGrant.toString(),
581
+ willBurnFromUser: actualBurnAmount.toString(),
582
+ systemRetained: systemRetainedAmount.toString(),
583
+ });
584
+
585
+ let burnHash: string | null = null;
586
+ let burnError: string | null = null;
587
+
588
+ // Only burn if user has remaining tokens
589
+ if (actualBurnAmount.gt(new BN(0))) {
590
+ try {
591
+ // eslint-disable-next-line no-await-in-loop
592
+ burnHash = await burnToken({
593
+ paymentCurrency: grantCurrency,
594
+ amount: fromUnitToToken(actualBurnAmount.toString(), grantCurrency.decimal),
595
+ sender: customer.did,
596
+ data: {
597
+ reason: 'credit_grant_refund',
598
+ creditGrantId: creditGrant.id,
599
+ refundId: refund.id,
600
+ },
601
+ });
602
+
603
+ totalBurnedFromUser = totalBurnedFromUser.add(actualBurnAmount);
604
+
605
+ logger.info('Successfully burned user tokens for refund', {
606
+ creditGrantId: creditGrant.id,
607
+ burnHash,
608
+ amount: actualBurnAmount.toString(),
609
+ });
610
+
611
+ // Emit burned event to trigger reconciliation
612
+ events.emit('customer.credit_grant.burned', creditGrant, {
613
+ burnHash,
614
+ burnedAmount: actualBurnAmount.toString(),
615
+ });
616
+ } catch (error: any) {
617
+ logger.error('Failed to burn user tokens for refund', {
618
+ creditGrantId: creditGrant.id,
619
+ refundId: refund.id,
620
+ error: error.message,
621
+ });
622
+ burnError = error.message;
623
+ lastError = error.message;
624
+ }
625
+ }
626
+
627
+ // Update credit grant using new chain_detail field
628
+ const newRemainingAmount = burnHash
629
+ ? grantRemainingAmount.sub(actualBurnAmount).toString()
630
+ : grantRemainingAmount.toString();
631
+ const isFullyVoided = new BN(newRemainingAmount).eq(new BN(0));
632
+ const chainDetail = creditGrant.chain_detail || {};
633
+
634
+ // eslint-disable-next-line no-await-in-loop
635
+ await creditGrant.update({
636
+ status: isFullyVoided ? 'voided' : 'granted',
637
+ remaining_amount: newRemainingAmount,
638
+ voided_at: isFullyVoided ? Math.floor(Date.now() / 1000) : undefined,
639
+ // eslint-disable-next-line no-nested-ternary
640
+ chain_status: burnHash ? 'burn_completed' : burnError ? 'burn_failed' : undefined,
641
+ chain_detail: {
642
+ ...chainDetail,
643
+ refund: {
644
+ id: refund.id,
645
+ burn_hash: burnHash || undefined,
646
+ burned_amount: burnHash ? actualBurnAmount.toString() : '0',
647
+ system_retained: systemRetainedAmount.toString(),
648
+ burn_error: burnError || undefined,
649
+ },
650
+ ...(isFullyVoided ? { voided_reason: 'refund' } : {}),
651
+ },
652
+ });
653
+
654
+ logger.info('Credit grant processed for refund', {
655
+ creditGrantId: creditGrant.id,
656
+ newStatus: isFullyVoided ? 'voided' : 'granted',
657
+ newRemainingAmount,
658
+ burnedFromUser: burnHash ? actualBurnAmount.toString() : '0',
659
+ systemRetained: systemRetainedAmount.toString(),
660
+ });
661
+ }
662
+
663
+ logger.info('Completed onchain credit refund processing', {
664
+ refundId: refund.id,
665
+ processedCount: creditGrants.length,
666
+ totalBurnedFromUser: totalBurnedFromUser.toString(),
667
+ refundAmount: refund.amount,
668
+ hasError: !!lastError,
669
+ });
670
+
671
+ if (lastError) {
672
+ throw new Error(lastError);
673
+ }
674
+
675
+ return totalBurnedFromUser.toString();
676
+ }