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.
- package/api/src/index.ts +4 -0
- package/api/src/integrations/arcblock/token.ts +599 -0
- package/api/src/libs/credit-grant.ts +7 -6
- package/api/src/libs/util.ts +34 -0
- package/api/src/queues/credit-consume.ts +29 -4
- package/api/src/queues/credit-grant.ts +245 -50
- package/api/src/queues/credit-reconciliation.ts +253 -0
- package/api/src/queues/refund.ts +263 -30
- package/api/src/queues/token-transfer.ts +331 -0
- package/api/src/routes/checkout-sessions.ts +94 -29
- package/api/src/routes/credit-grants.ts +35 -9
- package/api/src/routes/credit-tokens.ts +38 -0
- package/api/src/routes/credit-transactions.ts +20 -3
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/meter-events.ts +4 -0
- package/api/src/routes/meters.ts +32 -10
- package/api/src/routes/payment-currencies.ts +103 -0
- package/api/src/routes/payment-links.ts +3 -1
- package/api/src/routes/products.ts +2 -2
- package/api/src/routes/settings.ts +4 -3
- package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
- package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
- package/api/src/store/migrations/20251211-optimize-slow-queries.ts +33 -0
- package/api/src/store/models/credit-grant.ts +47 -9
- package/api/src/store/models/credit-transaction.ts +18 -1
- package/api/src/store/models/index.ts +2 -1
- package/api/src/store/models/payment-currency.ts +31 -4
- package/api/src/store/models/refund.ts +12 -2
- package/api/src/store/models/types.ts +48 -0
- package/api/src/store/sequelize.ts +1 -0
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/app.tsx +10 -0
- package/src/components/customer/credit-overview.tsx +19 -3
- package/src/components/meter/form.tsx +191 -18
- package/src/components/price/form.tsx +49 -37
- package/src/locales/en.tsx +25 -1
- package/src/locales/zh.tsx +27 -1
- package/src/pages/admin/billing/meters/create.tsx +42 -13
- package/src/pages/admin/billing/meters/detail.tsx +56 -5
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +13 -0
- package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +324 -0
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/customer/credit-grant/detail.tsx +14 -1
- package/src/pages/customer/credit-transaction/detail.tsx +289 -0
- package/src/pages/customer/invoice/detail.tsx +1 -1
- package/src/pages/customer/recharge/subscription.tsx +1 -1
- 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
|
+
}
|
package/api/src/queues/refund.ts
CHANGED
|
@@ -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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
+
}
|