payment-kit 1.22.20 → 1.22.22
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/libs/vendor-util/adapters/types.ts +1 -0
- package/api/src/libs/vendor-util/tool.ts +3 -1
- package/api/src/queues/vendors/return-processor.ts +2 -2
- package/api/src/routes/payouts.ts +0 -381
- package/api/src/routes/refunds.ts +453 -1
- package/api/src/routes/vendor.ts +8 -7
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/components/customer/link.tsx +11 -0
- package/src/components/payouts/actions.tsx +88 -13
- package/src/components/payouts/list.tsx +3 -1
- package/src/locales/en.tsx +5 -0
- package/src/locales/zh.tsx +5 -0
- package/src/pages/admin/payments/intents/detail.tsx +20 -2
- package/src/pages/admin/payments/payouts/detail.tsx +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PaymentCurrency, PaymentMethod, Payout } from '../../store/models';
|
|
2
2
|
|
|
3
3
|
export interface RefundInfo {
|
|
4
|
+
reason?: string;
|
|
4
5
|
livemode: number;
|
|
5
6
|
amount: string;
|
|
6
7
|
contract: string;
|
|
@@ -12,7 +13,7 @@ export interface RefundInfo {
|
|
|
12
13
|
* Build refund info from payout record
|
|
13
14
|
* Extracts contract, chain type, and chain API host from payout's associated payment method and currency
|
|
14
15
|
*/
|
|
15
|
-
export async function buildRefundInfoFromPayout(payout: Payout): Promise<RefundInfo> {
|
|
16
|
+
export async function buildRefundInfoFromPayout(payout: Payout, reason?: string): Promise<RefundInfo> {
|
|
16
17
|
const paymentMethod = await PaymentMethod.findByPk(payout.payment_method_id);
|
|
17
18
|
if (!paymentMethod) {
|
|
18
19
|
throw new Error(`PaymentMethod not found: ${payout.payment_method_id} | Payout ID: ${payout.id}`);
|
|
@@ -42,5 +43,6 @@ export async function buildRefundInfoFromPayout(payout: Payout): Promise<RefundI
|
|
|
42
43
|
contract: paymentCurrency.contract,
|
|
43
44
|
chainType,
|
|
44
45
|
chainApiHost,
|
|
46
|
+
reason,
|
|
45
47
|
};
|
|
46
48
|
}
|
|
@@ -208,7 +208,7 @@ async function callVendorReturn(vendor: VendorInfo, checkoutSession: CheckoutSes
|
|
|
208
208
|
},
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
if (payoutInfo && result.data?.
|
|
211
|
+
if (payoutInfo && result.data?.refundResult?.status === 'succeeded') {
|
|
212
212
|
logger.info('[callVendorReturn] Vendor payout refund successful', {
|
|
213
213
|
checkoutSessionId: checkoutSession.id,
|
|
214
214
|
vendorId: vendor.vendor_id,
|
|
@@ -221,7 +221,7 @@ async function callVendorReturn(vendor: VendorInfo, checkoutSession: CheckoutSes
|
|
|
221
221
|
vendorId: vendor.vendor_id,
|
|
222
222
|
orderId: vendor.order_id,
|
|
223
223
|
refundInfo,
|
|
224
|
-
|
|
224
|
+
refundResult: result.data?.refundResult,
|
|
225
225
|
});
|
|
226
226
|
}
|
|
227
227
|
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
import { isValid } from '@arcblock/did';
|
|
2
|
-
import OcapClient from '@ocap/client';
|
|
3
|
-
import { BN } from '@ocap/util';
|
|
4
|
-
import { ethers } from 'ethers';
|
|
5
2
|
import { Router } from 'express';
|
|
6
3
|
import Joi from 'joi';
|
|
7
4
|
import pick from 'lodash/pick';
|
|
8
5
|
|
|
9
6
|
import { sessionMiddleware } from '@blocklet/sdk/lib/middlewares/session';
|
|
10
|
-
import { getWallet } from '@blocklet/sdk/lib/wallet';
|
|
11
7
|
import type { WhereOptions } from 'sequelize';
|
|
12
|
-
import { Op } from 'sequelize';
|
|
13
|
-
import { sendErc20ToUser } from '../integrations/ethereum/token';
|
|
14
8
|
import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
|
|
15
|
-
import { wallet } from '../libs/auth';
|
|
16
|
-
import { EVM_CHAIN_TYPES } from '../libs/constants';
|
|
17
9
|
import logger from '../libs/logger';
|
|
18
|
-
import { getGasPayerExtra } from '../libs/payment';
|
|
19
10
|
import { authenticate } from '../libs/security';
|
|
20
11
|
import { formatMetadata } from '../libs/util';
|
|
21
12
|
import { PaymentLink, TPaymentIntentExpanded } from '../store/models';
|
|
@@ -185,378 +176,6 @@ router.get('/mine', sessionMiddleware({ accessKey: true }), async (req, res) =>
|
|
|
185
176
|
}
|
|
186
177
|
});
|
|
187
178
|
|
|
188
|
-
const syncPayoutRequestSchema = Joi.object({
|
|
189
|
-
// Payout creation required fields
|
|
190
|
-
livemode: Joi.number().required().description('Testnet (0) or mainnet (1)'),
|
|
191
|
-
amount: Joi.string().required().description('Transfer amount in unit format'),
|
|
192
|
-
destination: Joi.string().required().description('Output account address (to address)'),
|
|
193
|
-
|
|
194
|
-
// Transfer execution required fields
|
|
195
|
-
contract: Joi.string().required().description('Token contract address for transfer'),
|
|
196
|
-
chainType: Joi.string().required().description('Chain type, e.g. "arcblock", "ethereum", "base", "bitcoin"'),
|
|
197
|
-
chainApiHost: Joi.string().required().description('RPC API endpoint for the chain'),
|
|
198
|
-
|
|
199
|
-
// Optional fields
|
|
200
|
-
description: Joi.string().max(512).optional().description('Payout description (optional)'),
|
|
201
|
-
orderId: Joi.string().optional().description('Order ID for duplicate check (optional)'),
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
// Helper function: Query payment method and currency by contract and chain type
|
|
205
|
-
async function queryPaymentMethodAndCurrency(
|
|
206
|
-
chainType: string,
|
|
207
|
-
livemode: boolean,
|
|
208
|
-
contract: string
|
|
209
|
-
): Promise<{
|
|
210
|
-
paymentCurrency: PaymentCurrency | null;
|
|
211
|
-
paymentMethodId: string;
|
|
212
|
-
currencyId: string;
|
|
213
|
-
}> {
|
|
214
|
-
let paymentMethod: PaymentMethod | null = null;
|
|
215
|
-
let paymentCurrency: PaymentCurrency | null = null;
|
|
216
|
-
let paymentMethodId = '';
|
|
217
|
-
let currencyId = '';
|
|
218
|
-
|
|
219
|
-
paymentMethod = await PaymentMethod.findOne({
|
|
220
|
-
where: { type: chainType, livemode },
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
if (paymentMethod) {
|
|
224
|
-
paymentMethodId = paymentMethod.id;
|
|
225
|
-
paymentCurrency = await PaymentCurrency.findOne({
|
|
226
|
-
where: { contract, payment_method_id: paymentMethod.id },
|
|
227
|
-
});
|
|
228
|
-
if (paymentCurrency) {
|
|
229
|
-
currencyId = paymentCurrency.id;
|
|
230
|
-
logger.info('PaymentMethod and Currency ID queried by contract and chain_type', {
|
|
231
|
-
paymentMethodId,
|
|
232
|
-
currencyId,
|
|
233
|
-
contract,
|
|
234
|
-
chainType,
|
|
235
|
-
});
|
|
236
|
-
} else {
|
|
237
|
-
logger.warn('PaymentCurrency not found by contract and chain_type', {
|
|
238
|
-
contract,
|
|
239
|
-
chainType,
|
|
240
|
-
paymentMethodId: paymentMethod.id,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
} else {
|
|
244
|
-
logger.warn('PaymentMethod not found by chain_type', { chainType, livemode });
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return { paymentCurrency, paymentMethodId, currencyId };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Helper function: Resolve or create payment intent
|
|
251
|
-
async function resolveOrCreatePaymentIntent(
|
|
252
|
-
finalPaymentMethodId: string,
|
|
253
|
-
finalCurrencyId: string,
|
|
254
|
-
customerId: string,
|
|
255
|
-
livemode: boolean,
|
|
256
|
-
amount: string,
|
|
257
|
-
chainType: string,
|
|
258
|
-
destination: string,
|
|
259
|
-
description: string | undefined,
|
|
260
|
-
metadata: Record<string, any> | undefined
|
|
261
|
-
): Promise<string> {
|
|
262
|
-
if (!finalPaymentMethodId || !finalCurrencyId) {
|
|
263
|
-
logger.warn('Cannot create payment intent: missing payment_method_id or currency_id', {
|
|
264
|
-
hasPaymentMethod: !!finalPaymentMethodId,
|
|
265
|
-
hasCurrency: !!finalCurrencyId,
|
|
266
|
-
});
|
|
267
|
-
return '';
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const newPaymentIntent = await PaymentIntent.create({
|
|
271
|
-
livemode,
|
|
272
|
-
amount,
|
|
273
|
-
amount_received: amount,
|
|
274
|
-
amount_capturable: '0',
|
|
275
|
-
currency_id: finalCurrencyId,
|
|
276
|
-
payment_method_id: finalPaymentMethodId,
|
|
277
|
-
customer_id: customerId,
|
|
278
|
-
description: description || `Payout to ${destination}`,
|
|
279
|
-
status: 'succeeded',
|
|
280
|
-
capture_method: 'automatic',
|
|
281
|
-
confirmation_method: 'automatic',
|
|
282
|
-
payment_method_types: [chainType],
|
|
283
|
-
statement_descriptor: description || `Payout to ${destination}`,
|
|
284
|
-
statement_descriptor_suffix: '',
|
|
285
|
-
metadata: formatMetadata(metadata || {}),
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
logger.info('Created new payment intent for payout', {
|
|
289
|
-
paymentIntentId: newPaymentIntent.id,
|
|
290
|
-
payoutAmount: amount,
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
return newPaymentIntent.id;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Returns transaction hash and payment details
|
|
297
|
-
async function processDirectTransfer({
|
|
298
|
-
payout,
|
|
299
|
-
contract,
|
|
300
|
-
chainType,
|
|
301
|
-
chainApiHost,
|
|
302
|
-
description,
|
|
303
|
-
}: {
|
|
304
|
-
payout: Payout;
|
|
305
|
-
contract: string;
|
|
306
|
-
chainType: string;
|
|
307
|
-
chainApiHost: string;
|
|
308
|
-
description: string;
|
|
309
|
-
}): Promise<{ txHash: string; paymentDetails: any }> {
|
|
310
|
-
if (chainType === 'arcblock') {
|
|
311
|
-
const client = new OcapClient(chainApiHost);
|
|
312
|
-
const signed = await client.signTransferV2Tx({
|
|
313
|
-
tx: {
|
|
314
|
-
itx: {
|
|
315
|
-
to: payout.destination,
|
|
316
|
-
value: '0',
|
|
317
|
-
assets: [],
|
|
318
|
-
tokens: [{ address: contract, value: payout.amount }],
|
|
319
|
-
data: {
|
|
320
|
-
typeUrl: 'json',
|
|
321
|
-
// @ts-ignore
|
|
322
|
-
value: {
|
|
323
|
-
appId: wallet.address,
|
|
324
|
-
reason: description || `Payout to ${payout.destination}`,
|
|
325
|
-
payoutId: payout.id,
|
|
326
|
-
},
|
|
327
|
-
},
|
|
328
|
-
},
|
|
329
|
-
},
|
|
330
|
-
wallet,
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
// @ts-ignore
|
|
334
|
-
const { buffer } = await client.encodeTransferV2Tx({ tx: signed });
|
|
335
|
-
// @ts-ignore
|
|
336
|
-
const txHash = await client.sendTransferV2Tx({ tx: signed, wallet }, await getGasPayerExtra(buffer));
|
|
337
|
-
|
|
338
|
-
return {
|
|
339
|
-
txHash,
|
|
340
|
-
paymentDetails: {
|
|
341
|
-
arcblock: {
|
|
342
|
-
tx_hash: txHash,
|
|
343
|
-
payer: wallet.address,
|
|
344
|
-
type: 'transfer',
|
|
345
|
-
},
|
|
346
|
-
},
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
if (EVM_CHAIN_TYPES.includes(chainType)) {
|
|
350
|
-
const provider = new ethers.JsonRpcProvider(chainApiHost);
|
|
351
|
-
const receipt = await sendErc20ToUser(provider, contract, payout.destination, payout.amount);
|
|
352
|
-
const tx = await provider.getTransaction(receipt.hash);
|
|
353
|
-
const payerAddress = tx?.from || '';
|
|
354
|
-
|
|
355
|
-
const paymentDetails: any = {
|
|
356
|
-
[chainType]: {
|
|
357
|
-
tx_hash: receipt.hash,
|
|
358
|
-
payer: payerAddress,
|
|
359
|
-
type: 'transfer',
|
|
360
|
-
},
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
if (receipt) {
|
|
364
|
-
paymentDetails[chainType].block_height = receipt.blockNumber.toString();
|
|
365
|
-
paymentDetails[chainType].gas_used = receipt.gasUsed.toString();
|
|
366
|
-
paymentDetails[chainType].gas_price = receipt.gasPrice?.toString() || '0';
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return {
|
|
370
|
-
txHash: receipt.hash,
|
|
371
|
-
paymentDetails,
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
throw new Error(`Unsupported chain type: ${chainType}`);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
router.post('/sync', async (req, res) => {
|
|
378
|
-
const { error } = syncPayoutRequestSchema.validate(req.body);
|
|
379
|
-
if (error) {
|
|
380
|
-
return res.status(400).json({ error: `Payout request invalid: ${error.message}` });
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Extract and validate request data
|
|
384
|
-
const { livemode, amount, destination, contract, chainType, chainApiHost } = req.body;
|
|
385
|
-
const { description, metadata, orderId } = req.body;
|
|
386
|
-
|
|
387
|
-
try {
|
|
388
|
-
// Convert and validate input types
|
|
389
|
-
const livemodeBoolean = !!livemode;
|
|
390
|
-
const amountString = String(amount);
|
|
391
|
-
const amountBN = new BN(amountString);
|
|
392
|
-
if (amountBN.lte(new BN('0'))) {
|
|
393
|
-
throw new Error('Payout amount must be greater than 0');
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Query payment method and currency first (needed for duplicate check)
|
|
397
|
-
const { paymentMethodId: finalPaymentMethodId, currencyId: finalCurrencyId } = await queryPaymentMethodAndCurrency(
|
|
398
|
-
chainType,
|
|
399
|
-
livemodeBoolean,
|
|
400
|
-
contract
|
|
401
|
-
);
|
|
402
|
-
|
|
403
|
-
// Check for existing payout with same orderId in metadata if provided
|
|
404
|
-
if (orderId && orderId.trim()) {
|
|
405
|
-
const existingPayout = await Payout.findOne({
|
|
406
|
-
where: {
|
|
407
|
-
metadata: { orderId: orderId.trim() },
|
|
408
|
-
status: { [Op.in]: ['paid', 'in_transit'] },
|
|
409
|
-
},
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
if (existingPayout) {
|
|
413
|
-
logger.info('Order payout already completed or in-progress. Current request interrupted/terminated', {
|
|
414
|
-
orderId,
|
|
415
|
-
payoutId: existingPayout.id,
|
|
416
|
-
status: existingPayout.status,
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
return res.json(existingPayout);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Get system DID as default payer (发款方)
|
|
424
|
-
const systemDid = getWallet().address;
|
|
425
|
-
|
|
426
|
-
// Resolve or create payment intent
|
|
427
|
-
const finalPaymentIntentId = await resolveOrCreatePaymentIntent(
|
|
428
|
-
finalPaymentMethodId,
|
|
429
|
-
finalCurrencyId,
|
|
430
|
-
systemDid,
|
|
431
|
-
livemodeBoolean,
|
|
432
|
-
amountString,
|
|
433
|
-
chainType,
|
|
434
|
-
destination,
|
|
435
|
-
description,
|
|
436
|
-
metadata
|
|
437
|
-
);
|
|
438
|
-
|
|
439
|
-
// Build metadata with orderId only
|
|
440
|
-
const payoutMetadata = {
|
|
441
|
-
...formatMetadata(metadata || {}),
|
|
442
|
-
...(orderId && orderId.trim() ? { orderId: orderId.trim() } : {}),
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
// Create payout record
|
|
446
|
-
const payout = await Payout.create({
|
|
447
|
-
livemode: livemodeBoolean,
|
|
448
|
-
automatic: false,
|
|
449
|
-
description: description || `Transfer to ${destination}`,
|
|
450
|
-
amount: amountString,
|
|
451
|
-
destination,
|
|
452
|
-
payment_intent_id: finalPaymentIntentId || '',
|
|
453
|
-
customer_id: systemDid,
|
|
454
|
-
currency_id: finalCurrencyId || '',
|
|
455
|
-
payment_method_id: finalPaymentMethodId || '',
|
|
456
|
-
status: 'in_transit',
|
|
457
|
-
attempt_count: 0,
|
|
458
|
-
attempted: false,
|
|
459
|
-
next_attempt: 0,
|
|
460
|
-
last_attempt_error: null,
|
|
461
|
-
metadata: payoutMetadata,
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
logger.info('Synchronous payout created, processing transfer directly', {
|
|
465
|
-
payoutId: payout.id,
|
|
466
|
-
destination,
|
|
467
|
-
amount,
|
|
468
|
-
chainType,
|
|
469
|
-
requestedBy: req.user?.did,
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
// Process transfer
|
|
473
|
-
try {
|
|
474
|
-
const { txHash, paymentDetails } = await processDirectTransfer({
|
|
475
|
-
payout,
|
|
476
|
-
contract,
|
|
477
|
-
chainType,
|
|
478
|
-
chainApiHost,
|
|
479
|
-
description,
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
await payout.update({
|
|
483
|
-
status: 'paid',
|
|
484
|
-
last_attempt_error: null,
|
|
485
|
-
attempt_count: payout.attempt_count + 1,
|
|
486
|
-
attempted: true,
|
|
487
|
-
payment_details: paymentDetails,
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
logger.info('Synchronous payout transfer completed', {
|
|
491
|
-
payoutId: payout.id,
|
|
492
|
-
txHash,
|
|
493
|
-
requestedBy: req.user?.did,
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
// Reload and enrich payout data
|
|
497
|
-
const updatedPayout = await Payout.findByPk(payout.id, {
|
|
498
|
-
include: [
|
|
499
|
-
{ model: PaymentCurrency, as: 'paymentCurrency', required: false },
|
|
500
|
-
{ model: PaymentIntent, as: 'paymentIntent', required: false },
|
|
501
|
-
{ model: PaymentMethod, as: 'paymentMethod', required: false },
|
|
502
|
-
],
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
if (!updatedPayout) {
|
|
506
|
-
throw new Error('Payout not found after processing');
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return res.json(updatedPayout.toJSON());
|
|
510
|
-
} catch (processError: any) {
|
|
511
|
-
logger.error('Synchronous payout transfer failed', {
|
|
512
|
-
error: processError,
|
|
513
|
-
requestedBy: req.user?.did,
|
|
514
|
-
});
|
|
515
|
-
// Handle transfer failure
|
|
516
|
-
await payout.update({
|
|
517
|
-
status: 'failed',
|
|
518
|
-
last_attempt_error: {
|
|
519
|
-
type: 'api_error',
|
|
520
|
-
code: processError.code || 'api_error',
|
|
521
|
-
message: processError.message || 'Transfer failed',
|
|
522
|
-
},
|
|
523
|
-
attempt_count: payout.attempt_count + 1,
|
|
524
|
-
attempted: true,
|
|
525
|
-
failure_message: processError.message || 'Transfer failed',
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
logger.error('Synchronous payout transfer failed', {
|
|
529
|
-
payoutId: payout.id,
|
|
530
|
-
error: processError.message || processError.error?.message,
|
|
531
|
-
requestedBy: req.user?.did,
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
// Reload and enrich failed payout data
|
|
535
|
-
const failedPayout = await Payout.findByPk(payout.id, {
|
|
536
|
-
include: [
|
|
537
|
-
{ model: PaymentCurrency, as: 'paymentCurrency', required: false },
|
|
538
|
-
{ model: PaymentIntent, as: 'paymentIntent', required: false },
|
|
539
|
-
{ model: PaymentMethod, as: 'paymentMethod', required: false },
|
|
540
|
-
],
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
if (failedPayout) {
|
|
544
|
-
return res.status(400).json({
|
|
545
|
-
...failedPayout.toJSON(),
|
|
546
|
-
error: processError.message || processError.error?.message || 'Payout transfer failed',
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
throw processError;
|
|
550
|
-
}
|
|
551
|
-
} catch (err: any) {
|
|
552
|
-
logger.error('Create synchronous payout failed', {
|
|
553
|
-
error: err,
|
|
554
|
-
requestedBy: req.user?.did,
|
|
555
|
-
});
|
|
556
|
-
return res.status(400).json({ error: err.message });
|
|
557
|
-
}
|
|
558
|
-
});
|
|
559
|
-
|
|
560
179
|
router.get('/:id', authPortal, async (req, res) => {
|
|
561
180
|
try {
|
|
562
181
|
const doc = (await Payout.findOne({
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
2
|
/* eslint-disable consistent-return */
|
|
3
|
+
import OcapClient from '@ocap/client';
|
|
4
|
+
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
5
|
+
import { ethers } from 'ethers';
|
|
3
6
|
import { Router } from 'express';
|
|
4
7
|
import Joi from 'joi';
|
|
5
8
|
import pick from 'lodash/pick';
|
|
6
9
|
|
|
7
|
-
import {
|
|
10
|
+
import { getWallet } from '@blocklet/sdk/lib/wallet';
|
|
11
|
+
import { Op } from 'sequelize';
|
|
12
|
+
import { sendErc20ToUser } from '../integrations/ethereum/token';
|
|
8
13
|
import { BNPositiveValidator, createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
|
|
14
|
+
import { wallet } from '../libs/auth';
|
|
15
|
+
import { EVM_CHAIN_TYPES } from '../libs/constants';
|
|
9
16
|
import { authenticate } from '../libs/security';
|
|
10
17
|
import { formatMetadata } from '../libs/util';
|
|
11
18
|
import {
|
|
@@ -18,6 +25,7 @@ import {
|
|
|
18
25
|
Subscription,
|
|
19
26
|
} from '../store/models';
|
|
20
27
|
import logger from '../libs/logger';
|
|
28
|
+
import { getGasPayerExtra } from '../libs/payment';
|
|
21
29
|
import { getRefundAmountSetup } from '../libs/refund';
|
|
22
30
|
|
|
23
31
|
const router = Router();
|
|
@@ -208,6 +216,450 @@ router.get('/search', auth, async (req, res) => {
|
|
|
208
216
|
res.json({ count, list, paging: { page, pageSize } });
|
|
209
217
|
});
|
|
210
218
|
|
|
219
|
+
// Helper function: Query payment method and currency by contract and chain type
|
|
220
|
+
async function queryPaymentMethodAndCurrency(
|
|
221
|
+
chainType: string,
|
|
222
|
+
livemode: boolean,
|
|
223
|
+
contract: string
|
|
224
|
+
): Promise<{
|
|
225
|
+
paymentCurrency: PaymentCurrency | null;
|
|
226
|
+
paymentMethodId: string;
|
|
227
|
+
currencyId: string;
|
|
228
|
+
}> {
|
|
229
|
+
let paymentMethod: PaymentMethod | null = null;
|
|
230
|
+
let paymentCurrency: PaymentCurrency | null = null;
|
|
231
|
+
let paymentMethodId = '';
|
|
232
|
+
let currencyId = '';
|
|
233
|
+
|
|
234
|
+
paymentMethod = await PaymentMethod.findOne({
|
|
235
|
+
where: { type: chainType, livemode },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (paymentMethod) {
|
|
239
|
+
paymentMethodId = paymentMethod.id;
|
|
240
|
+
paymentCurrency = await PaymentCurrency.findOne({
|
|
241
|
+
where: { contract, payment_method_id: paymentMethod.id },
|
|
242
|
+
});
|
|
243
|
+
if (paymentCurrency) {
|
|
244
|
+
currencyId = paymentCurrency.id;
|
|
245
|
+
logger.info('PaymentMethod and Currency ID queried by contract and chain_type', {
|
|
246
|
+
paymentMethodId,
|
|
247
|
+
currencyId,
|
|
248
|
+
contract,
|
|
249
|
+
chainType,
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
logger.warn('PaymentCurrency not found by contract and chain_type', {
|
|
253
|
+
contract,
|
|
254
|
+
chainType,
|
|
255
|
+
paymentMethodId: paymentMethod.id,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
logger.warn('PaymentMethod not found by chain_type', { chainType, livemode });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { paymentCurrency, paymentMethodId, currencyId };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Helper function: Resolve or create payment intent
|
|
266
|
+
async function resolveOrCreatePaymentIntent(
|
|
267
|
+
customerId: string,
|
|
268
|
+
finalPaymentMethodId: string,
|
|
269
|
+
finalCurrencyId: string,
|
|
270
|
+
livemode: boolean,
|
|
271
|
+
amount: string,
|
|
272
|
+
chainType: string,
|
|
273
|
+
destination: string,
|
|
274
|
+
description: string | undefined,
|
|
275
|
+
metadata: Record<string, any> | undefined
|
|
276
|
+
): Promise<string> {
|
|
277
|
+
if (!finalPaymentMethodId || !finalCurrencyId) {
|
|
278
|
+
logger.warn('Cannot create payment intent: missing payment_method_id or currency_id', {
|
|
279
|
+
hasPaymentMethod: !!finalPaymentMethodId,
|
|
280
|
+
hasCurrency: !!finalCurrencyId,
|
|
281
|
+
});
|
|
282
|
+
return '';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Validate and normalize customer_id - must be non-empty string if provided
|
|
286
|
+
let finalCustomerId: string | undefined;
|
|
287
|
+
if (customerId && customerId.trim().length > 0) {
|
|
288
|
+
finalCustomerId = customerId.trim();
|
|
289
|
+
} else if (customerId && customerId.trim().length === 0) {
|
|
290
|
+
logger.warn('Invalid customer_id: empty string, will not set customer_id', {
|
|
291
|
+
customerId,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const createData: any = {
|
|
296
|
+
livemode,
|
|
297
|
+
amount,
|
|
298
|
+
amount_received: amount,
|
|
299
|
+
amount_capturable: '0',
|
|
300
|
+
currency_id: finalCurrencyId,
|
|
301
|
+
payment_method_id: finalPaymentMethodId,
|
|
302
|
+
description: description || `Refund to ${destination}`,
|
|
303
|
+
status: 'succeeded',
|
|
304
|
+
capture_method: 'automatic',
|
|
305
|
+
confirmation_method: 'automatic',
|
|
306
|
+
payment_method_types: [chainType],
|
|
307
|
+
statement_descriptor: '',
|
|
308
|
+
statement_descriptor_suffix: '',
|
|
309
|
+
metadata: formatMetadata(metadata || {}),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Only set customer_id if it's a valid non-empty string
|
|
313
|
+
if (finalCustomerId) {
|
|
314
|
+
createData.customer_id = finalCustomerId;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const newPaymentIntent = await PaymentIntent.create(createData);
|
|
319
|
+
|
|
320
|
+
logger.info('Created new payment intent for refund', {
|
|
321
|
+
paymentIntentId: newPaymentIntent.id,
|
|
322
|
+
refundAmount: amount,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
return newPaymentIntent.id;
|
|
326
|
+
} catch (error: any) {
|
|
327
|
+
logger.error('Failed to create payment intent for refund', {
|
|
328
|
+
error,
|
|
329
|
+
createData: {
|
|
330
|
+
...createData,
|
|
331
|
+
customer_id: createData.customer_id || 'null',
|
|
332
|
+
customer_id_length: createData.customer_id?.length || 0,
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Returns transaction hash and payment details
|
|
340
|
+
async function processDirectTransfer({
|
|
341
|
+
destination,
|
|
342
|
+
amount,
|
|
343
|
+
recordId,
|
|
344
|
+
contract,
|
|
345
|
+
chainType,
|
|
346
|
+
chainApiHost,
|
|
347
|
+
description,
|
|
348
|
+
}: {
|
|
349
|
+
destination: string;
|
|
350
|
+
amount: string;
|
|
351
|
+
recordId: string;
|
|
352
|
+
contract: string;
|
|
353
|
+
chainType: string;
|
|
354
|
+
chainApiHost: string;
|
|
355
|
+
description: string;
|
|
356
|
+
}): Promise<{ txHash: string; paymentDetails: any }> {
|
|
357
|
+
if (chainType === 'arcblock') {
|
|
358
|
+
const client = new OcapClient(chainApiHost);
|
|
359
|
+
const signed = await client.signTransferV2Tx({
|
|
360
|
+
tx: {
|
|
361
|
+
itx: {
|
|
362
|
+
to: destination,
|
|
363
|
+
value: '0',
|
|
364
|
+
assets: [],
|
|
365
|
+
tokens: [{ address: contract, value: amount }],
|
|
366
|
+
data: {
|
|
367
|
+
typeUrl: 'json',
|
|
368
|
+
// @ts-ignore
|
|
369
|
+
value: {
|
|
370
|
+
appId: wallet.address,
|
|
371
|
+
reason: description || `Refund to ${destination}`,
|
|
372
|
+
refundId: recordId,
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
wallet,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// @ts-ignore
|
|
381
|
+
const { buffer } = await client.encodeTransferV2Tx({ tx: signed });
|
|
382
|
+
// @ts-ignore
|
|
383
|
+
const txHash = await client.sendTransferV2Tx({ tx: signed, wallet }, await getGasPayerExtra(buffer));
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
txHash,
|
|
387
|
+
paymentDetails: {
|
|
388
|
+
arcblock: {
|
|
389
|
+
tx_hash: txHash,
|
|
390
|
+
payer: wallet.address,
|
|
391
|
+
type: 'transfer',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (EVM_CHAIN_TYPES.includes(chainType)) {
|
|
397
|
+
const provider = new ethers.JsonRpcProvider(chainApiHost);
|
|
398
|
+
const receipt = await sendErc20ToUser(provider, contract, destination, amount);
|
|
399
|
+
const tx = await provider.getTransaction(receipt.hash);
|
|
400
|
+
const payerAddress = tx?.from || '';
|
|
401
|
+
|
|
402
|
+
const paymentDetails: any = {
|
|
403
|
+
[chainType]: {
|
|
404
|
+
tx_hash: receipt.hash,
|
|
405
|
+
payer: payerAddress,
|
|
406
|
+
type: 'transfer',
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
if (receipt) {
|
|
411
|
+
paymentDetails[chainType].block_height = receipt.blockNumber.toString();
|
|
412
|
+
paymentDetails[chainType].gas_used = receipt.gasUsed.toString();
|
|
413
|
+
paymentDetails[chainType].gas_price = receipt.gasPrice?.toString() || '0';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
txHash: receipt.hash,
|
|
418
|
+
paymentDetails,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
throw new Error(`Unsupported chain type: ${chainType}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const syncRefundRequestSchema = Joi.object({
|
|
425
|
+
// Refund creation required fields
|
|
426
|
+
livemode: Joi.number().required().description('Testnet (0) or mainnet (1)'),
|
|
427
|
+
amount: Joi.string().required().description('Transfer amount in unit format'),
|
|
428
|
+
destination: Joi.string().required().description('Output account address (to address)'),
|
|
429
|
+
|
|
430
|
+
// Transfer execution required fields
|
|
431
|
+
contract: Joi.string().required().description('Token contract address for transfer'),
|
|
432
|
+
chainType: Joi.string().required().description('Chain type, e.g. "arcblock", "ethereum", "base", "bitcoin"'),
|
|
433
|
+
chainApiHost: Joi.string().required().description('RPC API endpoint for the chain'),
|
|
434
|
+
|
|
435
|
+
// Optional fields
|
|
436
|
+
description: Joi.string().max(512).optional().description('Refund description (optional)'),
|
|
437
|
+
orderId: Joi.string().optional().description('Order ID for duplicate check (optional)'),
|
|
438
|
+
customerName: Joi.string().optional().description('Customer name for mock user (default: "Broker")'),
|
|
439
|
+
metadata: MetadataSchema.optional(),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
router.post('/sync', authAdmin, async (req: any, res: any) => {
|
|
443
|
+
const { error } = syncRefundRequestSchema.validate(req.body);
|
|
444
|
+
if (error) {
|
|
445
|
+
return res.status(400).json({ error: `Refund request invalid: ${error.message}` });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Extract and validate request data
|
|
449
|
+
const { livemode, amount, destination, contract, chainType, chainApiHost } = req.body;
|
|
450
|
+
const { description, metadata, orderId, customerName } = req.body;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
// Convert and validate input types
|
|
454
|
+
const livemodeBoolean = !!livemode;
|
|
455
|
+
const amountString = String(amount);
|
|
456
|
+
const amountBN = new BN(amountString);
|
|
457
|
+
if (amountBN.lte(new BN('0'))) {
|
|
458
|
+
throw new Error('Refund amount must be greater than 0');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Query payment method and currency first (needed for duplicate check)
|
|
462
|
+
const { paymentMethodId: finalPaymentMethodId, currencyId: finalCurrencyId } = await queryPaymentMethodAndCurrency(
|
|
463
|
+
chainType,
|
|
464
|
+
livemodeBoolean,
|
|
465
|
+
contract
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
// Check for existing refund with same orderId in metadata if provided
|
|
469
|
+
if (orderId && orderId.trim()) {
|
|
470
|
+
const existingRefund = await Refund.findOne({
|
|
471
|
+
where: {
|
|
472
|
+
metadata: { orderId: orderId.trim() },
|
|
473
|
+
status: { [Op.in]: ['succeeded', 'pending'] },
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (existingRefund) {
|
|
478
|
+
logger.info('Order refund already completed or in-progress. Current request interrupted/terminated', {
|
|
479
|
+
orderId,
|
|
480
|
+
refundId: existingRefund.id,
|
|
481
|
+
status: existingRefund.status,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
return res.json(existingRefund);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Get system DID as default payer (发款方)
|
|
489
|
+
const systemDid = getWallet().address;
|
|
490
|
+
|
|
491
|
+
// Create mock customer if customerName is provided, otherwise use "Broker" as default
|
|
492
|
+
const finalCustomerName = customerName || 'Broker';
|
|
493
|
+
let finalCustomer = await Customer.findOne({
|
|
494
|
+
where: { did: systemDid },
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
if (!finalCustomer) {
|
|
498
|
+
finalCustomer = await Customer.create({
|
|
499
|
+
livemode: livemodeBoolean,
|
|
500
|
+
did: systemDid,
|
|
501
|
+
name: finalCustomerName,
|
|
502
|
+
email: '',
|
|
503
|
+
phone: '',
|
|
504
|
+
address: Customer.formatAddressFromUser(null),
|
|
505
|
+
description: 'Broker customer for refund',
|
|
506
|
+
metadata: { isBroker: true },
|
|
507
|
+
balance: '0',
|
|
508
|
+
next_invoice_sequence: 1,
|
|
509
|
+
delinquent: false,
|
|
510
|
+
invoice_prefix: Customer.getInvoicePrefix(),
|
|
511
|
+
});
|
|
512
|
+
logger.info('Created mock customer for refund', {
|
|
513
|
+
customerId: finalCustomer.id,
|
|
514
|
+
customerName: finalCustomerName,
|
|
515
|
+
did: systemDid,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (finalCustomerName !== finalCustomer?.name) {
|
|
520
|
+
finalCustomer = await finalCustomer.update({ name: finalCustomerName, metadata: { isBroker: true } });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Resolve or create payment intent
|
|
524
|
+
const finalPaymentIntentId = await resolveOrCreatePaymentIntent(
|
|
525
|
+
finalCustomer.id,
|
|
526
|
+
finalPaymentMethodId,
|
|
527
|
+
finalCurrencyId,
|
|
528
|
+
livemodeBoolean,
|
|
529
|
+
amountString,
|
|
530
|
+
chainType,
|
|
531
|
+
destination,
|
|
532
|
+
description,
|
|
533
|
+
{ ...metadata, payerDid: systemDid }
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
// Build metadata with orderId and destination (Refund model doesn't have destination field)
|
|
537
|
+
const refundMetadata = {
|
|
538
|
+
...formatMetadata(metadata || {}),
|
|
539
|
+
...(orderId && orderId.trim() ? { orderId: orderId.trim() } : {}),
|
|
540
|
+
payerDid: systemDid,
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
// Create refund record
|
|
544
|
+
const refund = await Refund.create({
|
|
545
|
+
livemode: livemodeBoolean,
|
|
546
|
+
description: description || `Refund to ${destination}`,
|
|
547
|
+
amount: amountString,
|
|
548
|
+
payment_intent_id: finalPaymentIntentId || '',
|
|
549
|
+
customer_id: finalCustomer.id,
|
|
550
|
+
currency_id: finalCurrencyId || '',
|
|
551
|
+
payment_method_id: finalPaymentMethodId || '',
|
|
552
|
+
status: 'pending',
|
|
553
|
+
attempt_count: 0,
|
|
554
|
+
attempted: false,
|
|
555
|
+
next_attempt: 0,
|
|
556
|
+
last_attempt_error: null,
|
|
557
|
+
starting_balance: '0',
|
|
558
|
+
ending_balance: '0',
|
|
559
|
+
starting_token_balance: {},
|
|
560
|
+
ending_token_balance: {},
|
|
561
|
+
type: 'refund',
|
|
562
|
+
metadata: refundMetadata,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
logger.info('Synchronous refund created, processing transfer directly', {
|
|
566
|
+
refundId: refund.id,
|
|
567
|
+
destination,
|
|
568
|
+
amount,
|
|
569
|
+
chainType,
|
|
570
|
+
requestedBy: req.user?.did,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Process transfer
|
|
574
|
+
try {
|
|
575
|
+
const { txHash, paymentDetails } = await processDirectTransfer({
|
|
576
|
+
destination,
|
|
577
|
+
amount: amountString,
|
|
578
|
+
recordId: refund.id,
|
|
579
|
+
contract,
|
|
580
|
+
chainType,
|
|
581
|
+
chainApiHost,
|
|
582
|
+
description: description || `Refund to ${destination}`,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
await refund.update({
|
|
586
|
+
status: 'succeeded',
|
|
587
|
+
last_attempt_error: null,
|
|
588
|
+
attempt_count: refund.attempt_count + 1,
|
|
589
|
+
attempted: true,
|
|
590
|
+
payment_details: paymentDetails,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
logger.info('Synchronous refund transfer completed', {
|
|
594
|
+
refundId: refund.id,
|
|
595
|
+
txHash,
|
|
596
|
+
requestedBy: req.user?.did,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// Reload and enrich refund data
|
|
600
|
+
const updatedRefund = await Refund.findByPk(refund.id, {
|
|
601
|
+
include: [
|
|
602
|
+
{ model: PaymentCurrency, as: 'paymentCurrency', required: false },
|
|
603
|
+
{ model: PaymentIntent, as: 'paymentIntent', required: false },
|
|
604
|
+
{ model: PaymentMethod, as: 'paymentMethod', required: false },
|
|
605
|
+
],
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
if (!updatedRefund) {
|
|
609
|
+
throw new Error('Refund not found after processing');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return res.json(updatedRefund.toJSON());
|
|
613
|
+
} catch (processError: any) {
|
|
614
|
+
logger.error('Synchronous refund transfer failed', {
|
|
615
|
+
error: processError,
|
|
616
|
+
requestedBy: req.user?.did,
|
|
617
|
+
});
|
|
618
|
+
// Handle transfer failure
|
|
619
|
+
await refund.update({
|
|
620
|
+
status: 'failed',
|
|
621
|
+
last_attempt_error: {
|
|
622
|
+
type: 'api_error',
|
|
623
|
+
code: processError.code || 'api_error',
|
|
624
|
+
message: processError.message || 'Transfer failed',
|
|
625
|
+
},
|
|
626
|
+
attempt_count: refund.attempt_count + 1,
|
|
627
|
+
attempted: true,
|
|
628
|
+
failure_reason: 'unknown',
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
logger.error('Synchronous refund transfer failed', {
|
|
632
|
+
refundId: refund.id,
|
|
633
|
+
error: processError.message || processError.error?.message,
|
|
634
|
+
requestedBy: req.user?.did,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Reload and enrich failed refund data
|
|
638
|
+
const failedRefund = await Refund.findByPk(refund.id, {
|
|
639
|
+
include: [
|
|
640
|
+
{ model: PaymentCurrency, as: 'paymentCurrency', required: false },
|
|
641
|
+
{ model: PaymentIntent, as: 'paymentIntent', required: false },
|
|
642
|
+
{ model: PaymentMethod, as: 'paymentMethod', required: false },
|
|
643
|
+
],
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
if (failedRefund) {
|
|
647
|
+
return res.status(400).json({
|
|
648
|
+
...failedRefund.toJSON(),
|
|
649
|
+
error: processError.message || processError.error?.message || 'Refund transfer failed',
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
throw processError;
|
|
653
|
+
}
|
|
654
|
+
} catch (err: any) {
|
|
655
|
+
logger.error('Create synchronous refund failed', {
|
|
656
|
+
error: err,
|
|
657
|
+
requestedBy: req.user?.did,
|
|
658
|
+
});
|
|
659
|
+
return res.status(400).json({ error: err.message });
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
211
663
|
router.get('/:id', auth, async (req, res) => {
|
|
212
664
|
const doc = await Refund.findByPk(req.params.id as string, {
|
|
213
665
|
include: [
|
package/api/src/routes/vendor.ts
CHANGED
|
@@ -686,6 +686,7 @@ async function handleSubscriptionRedirect(req: any, res: any) {
|
|
|
686
686
|
|
|
687
687
|
async function vendorRefund(req: any, res: any) {
|
|
688
688
|
const { id } = req.params;
|
|
689
|
+
const { reason } = req.body;
|
|
689
690
|
try {
|
|
690
691
|
const payout = await Payout.findByPk(id);
|
|
691
692
|
|
|
@@ -708,7 +709,7 @@ async function vendorRefund(req: any, res: any) {
|
|
|
708
709
|
|
|
709
710
|
let refundInfo;
|
|
710
711
|
try {
|
|
711
|
-
refundInfo = await buildRefundInfoFromPayout(payout);
|
|
712
|
+
refundInfo = await buildRefundInfoFromPayout(payout, reason);
|
|
712
713
|
} catch (err: any) {
|
|
713
714
|
return res.status(400).json({ error: err.message });
|
|
714
715
|
}
|
|
@@ -731,24 +732,24 @@ async function vendorRefund(req: any, res: any) {
|
|
|
731
732
|
orderId: payout.vendor_info.order_id,
|
|
732
733
|
});
|
|
733
734
|
|
|
734
|
-
if (
|
|
735
|
+
if (result.data?.refundResult?.status === 'succeeded') {
|
|
735
736
|
logger.info('Vendor payout refund successful', {
|
|
736
737
|
payoutId: payout.id,
|
|
737
738
|
orderId: payout.vendor_info.order_id,
|
|
738
|
-
|
|
739
|
+
refundResult: result.data?.refundResult,
|
|
739
740
|
});
|
|
740
|
-
await payout.update({ status: 'reverted' });
|
|
741
|
-
return res.json({ data: result.data?.
|
|
741
|
+
await payout.update({ status: 'reverted', description: reason || 'Refund requested by admin' });
|
|
742
|
+
return res.json({ data: result.data?.refundResult });
|
|
742
743
|
}
|
|
743
744
|
logger.error('Vendor payout refund failed', {
|
|
744
745
|
payoutId: payout.id,
|
|
745
746
|
orderId: payout.vendor_info.order_id,
|
|
746
747
|
refundInfo,
|
|
747
|
-
|
|
748
|
+
refundResult: result.data?.refundResult,
|
|
748
749
|
});
|
|
749
750
|
return res.status(400).json({
|
|
750
751
|
error: result.message || 'Vendor payout refund failed',
|
|
751
|
-
data: result.data?.
|
|
752
|
+
data: result.data?.refundResult,
|
|
752
753
|
requestedBy: req.user?.did,
|
|
753
754
|
});
|
|
754
755
|
} catch (err: any) {
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.22",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"@blocklet/error": "^0.3.3",
|
|
58
58
|
"@blocklet/js-sdk": "^1.17.3-beta-20251119-102907-28b69b76",
|
|
59
59
|
"@blocklet/logger": "^1.17.3-beta-20251119-102907-28b69b76",
|
|
60
|
-
"@blocklet/payment-broker-client": "1.22.
|
|
61
|
-
"@blocklet/payment-react": "1.22.
|
|
62
|
-
"@blocklet/payment-vendor": "1.22.
|
|
60
|
+
"@blocklet/payment-broker-client": "1.22.22",
|
|
61
|
+
"@blocklet/payment-react": "1.22.22",
|
|
62
|
+
"@blocklet/payment-vendor": "1.22.22",
|
|
63
63
|
"@blocklet/sdk": "^1.17.3-beta-20251119-102907-28b69b76",
|
|
64
64
|
"@blocklet/ui-react": "^3.2.7",
|
|
65
65
|
"@blocklet/uploader": "^0.3.11",
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
"devDependencies": {
|
|
130
130
|
"@abtnode/types": "^1.17.3-beta-20251119-102907-28b69b76",
|
|
131
131
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
132
|
-
"@blocklet/payment-types": "1.22.
|
|
132
|
+
"@blocklet/payment-types": "1.22.22",
|
|
133
133
|
"@types/cookie-parser": "^1.4.9",
|
|
134
134
|
"@types/cors": "^2.8.19",
|
|
135
135
|
"@types/debug": "^4.1.12",
|
|
@@ -176,5 +176,5 @@
|
|
|
176
176
|
"parser": "typescript"
|
|
177
177
|
}
|
|
178
178
|
},
|
|
179
|
-
"gitHead": "
|
|
179
|
+
"gitHead": "4324caf5e314593d0a56b4893f503932ae83d8ae"
|
|
180
180
|
}
|
|
@@ -3,6 +3,8 @@ import { Link } from 'react-router-dom';
|
|
|
3
3
|
import UserCard from '@arcblock/ux/lib/UserCard';
|
|
4
4
|
import { getCustomerAvatar } from '@blocklet/payment-react';
|
|
5
5
|
import { InfoType, UserCardProps } from '@arcblock/ux/lib/UserCard/types';
|
|
6
|
+
import DID from '@arcblock/ux/lib/DID';
|
|
7
|
+
import { Box, Typography } from '@mui/material';
|
|
6
8
|
|
|
7
9
|
export default function CustomerLink({
|
|
8
10
|
customer,
|
|
@@ -21,6 +23,15 @@ export default function CustomerLink({
|
|
|
21
23
|
if (!customer) {
|
|
22
24
|
return null;
|
|
23
25
|
}
|
|
26
|
+
|
|
27
|
+
if (customer?.metadata?.isBroker) {
|
|
28
|
+
return (
|
|
29
|
+
<Box>
|
|
30
|
+
<Typography variant="body2">{customer.name}</Typography>
|
|
31
|
+
<DID did={customer.did} {...(size !== 'small' ? { responsive: false, compact: true } : {})} />
|
|
32
|
+
</Box>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
24
35
|
const CustomerCard = (
|
|
25
36
|
// @ts-ignore
|
|
26
37
|
<UserCard
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
2
|
import Toast from '@arcblock/ux/lib/Toast';
|
|
3
|
-
import {
|
|
3
|
+
import { api, formatError } from '@blocklet/payment-react';
|
|
4
4
|
import type { TPayoutExpanded } from '@blocklet/payment-types';
|
|
5
5
|
import { useState } from 'react';
|
|
6
6
|
import { useNavigate } from 'react-router-dom';
|
|
7
7
|
import type { LiteralUnion } from 'type-fest';
|
|
8
|
+
import Dialog from '@arcblock/ux/lib/Dialog';
|
|
9
|
+
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
|
|
8
10
|
|
|
9
11
|
import Actions from '../actions';
|
|
10
12
|
import ClickBoundary from '../click-boundary';
|
|
@@ -12,24 +14,56 @@ import ClickBoundary from '../click-boundary';
|
|
|
12
14
|
type Props = {
|
|
13
15
|
data: TPayoutExpanded;
|
|
14
16
|
variant?: LiteralUnion<'compact' | 'normal', string>;
|
|
17
|
+
isCanceledSubscription?: boolean;
|
|
15
18
|
};
|
|
16
19
|
|
|
17
|
-
export default function PayoutActions({ data, variant = 'compact' }: Props) {
|
|
20
|
+
export default function PayoutActions({ data, variant = 'compact', isCanceledSubscription = false }: Props) {
|
|
18
21
|
const { t } = useLocaleContext();
|
|
19
22
|
const navigate = useNavigate();
|
|
20
23
|
const [loading, setLoading] = useState(false);
|
|
21
24
|
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
|
25
|
+
const [refundReason, setRefundReason] = useState('');
|
|
26
|
+
const [refundReasonError, setRefundReasonError] = useState('');
|
|
27
|
+
|
|
28
|
+
const MIN_REASON_LENGTH = 5;
|
|
29
|
+
|
|
30
|
+
const validateRefundReason = (value: string) => {
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
if (!trimmed) {
|
|
33
|
+
setRefundReasonError(t('admin.payout.refundReasonRequired'));
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (trimmed.length < MIN_REASON_LENGTH) {
|
|
37
|
+
setRefundReasonError(t('admin.payout.refundReasonMinLength', { min: MIN_REASON_LENGTH }));
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
setRefundReasonError('');
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleRefundReasonChange = (value: string) => {
|
|
45
|
+
setRefundReason(value);
|
|
46
|
+
if (refundReasonError) {
|
|
47
|
+
validateRefundReason(value);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
22
50
|
|
|
23
51
|
const handleRequestRefund = async () => {
|
|
52
|
+
if (!validateRefundReason(refundReason)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
24
56
|
setLoading(true);
|
|
25
57
|
try {
|
|
26
58
|
const { data: refundData } = await api.post(`/api/vendors/refund/${data.id}`, {
|
|
27
|
-
reason:
|
|
59
|
+
reason: refundReason.trim(),
|
|
28
60
|
});
|
|
29
61
|
|
|
30
62
|
if (!refundData.error) {
|
|
31
63
|
Toast.success(t('admin.payout.refundRequested'));
|
|
32
64
|
setShowRefundDialog(false);
|
|
65
|
+
setRefundReason('');
|
|
66
|
+
setRefundReasonError('');
|
|
33
67
|
// Optionally refresh the page or update the UI
|
|
34
68
|
window.location.reload();
|
|
35
69
|
} else {
|
|
@@ -42,6 +76,14 @@ export default function PayoutActions({ data, variant = 'compact' }: Props) {
|
|
|
42
76
|
}
|
|
43
77
|
};
|
|
44
78
|
|
|
79
|
+
const handleCloseDialog = () => {
|
|
80
|
+
setShowRefundDialog(false);
|
|
81
|
+
setRefundReason('');
|
|
82
|
+
setRefundReasonError('');
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isRefundReasonValid = refundReason.trim().length >= MIN_REASON_LENGTH;
|
|
86
|
+
|
|
45
87
|
const actions = [
|
|
46
88
|
{
|
|
47
89
|
label: t('admin.customer.view'),
|
|
@@ -52,7 +94,7 @@ export default function PayoutActions({ data, variant = 'compact' }: Props) {
|
|
|
52
94
|
];
|
|
53
95
|
|
|
54
96
|
// Add Request Refund button if payout is paid and has vendor info
|
|
55
|
-
if (data.status === 'paid' && data.vendor_info?.vendor_id && data.vendor_info?.order_id) {
|
|
97
|
+
if (isCanceledSubscription && data.status === 'paid' && data.vendor_info?.vendor_id && data.vendor_info?.order_id) {
|
|
56
98
|
actions.push({
|
|
57
99
|
label: t('admin.payout.requestRefund'),
|
|
58
100
|
handler: () => setShowRefundDialog(true),
|
|
@@ -73,15 +115,48 @@ export default function PayoutActions({ data, variant = 'compact' }: Props) {
|
|
|
73
115
|
return (
|
|
74
116
|
<ClickBoundary>
|
|
75
117
|
<Actions variant={variant} actions={actions} />
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
<Dialog
|
|
119
|
+
open={showRefundDialog}
|
|
120
|
+
onClose={handleCloseDialog}
|
|
121
|
+
title={t('admin.payout.requestRefund')}
|
|
122
|
+
fullWidth
|
|
123
|
+
actions={
|
|
124
|
+
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
|
125
|
+
<Button onClick={handleCloseDialog} disabled={loading} size="small" variant="outlined">
|
|
126
|
+
{t('common.cancel')}
|
|
127
|
+
</Button>
|
|
128
|
+
<Button
|
|
129
|
+
onClick={handleRequestRefund}
|
|
130
|
+
disabled={loading || !isRefundReasonValid}
|
|
131
|
+
variant="contained"
|
|
132
|
+
color="warning"
|
|
133
|
+
size="small">
|
|
134
|
+
{t('common.confirm')}
|
|
135
|
+
</Button>
|
|
136
|
+
</Box>
|
|
137
|
+
}>
|
|
138
|
+
<Stack spacing={2}>
|
|
139
|
+
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
|
|
140
|
+
{t('admin.payout.confirmRefund')}
|
|
141
|
+
</Typography>
|
|
142
|
+
<TextField
|
|
143
|
+
value={refundReason}
|
|
144
|
+
onChange={(e) => handleRefundReasonChange(e.target.value)}
|
|
145
|
+
onBlur={() => validateRefundReason(refundReason)}
|
|
146
|
+
label={t('admin.payout.refundReason')}
|
|
147
|
+
placeholder={t('admin.payout.refundReasonPlaceholder')}
|
|
148
|
+
multiline
|
|
149
|
+
minRows={4}
|
|
150
|
+
maxRows={8}
|
|
151
|
+
fullWidth
|
|
152
|
+
required
|
|
153
|
+
disabled={loading}
|
|
154
|
+
error={!!refundReasonError}
|
|
155
|
+
helperText={refundReasonError || t('admin.payout.refundReasonHelper', { min: MIN_REASON_LENGTH })}
|
|
156
|
+
slotProps={{ htmlInput: { maxLength: 500 } }}
|
|
157
|
+
/>
|
|
158
|
+
</Stack>
|
|
159
|
+
</Dialog>
|
|
85
160
|
</ClickBoundary>
|
|
86
161
|
);
|
|
87
162
|
}
|
|
@@ -58,6 +58,7 @@ type ListProps = {
|
|
|
58
58
|
status?: string;
|
|
59
59
|
customer_id?: string;
|
|
60
60
|
payment_intent_id?: string;
|
|
61
|
+
isCanceledSubscription?: boolean;
|
|
61
62
|
};
|
|
62
63
|
|
|
63
64
|
const getListKey = (props: ListProps) => {
|
|
@@ -79,6 +80,7 @@ export default function PayoutList({
|
|
|
79
80
|
customer: true,
|
|
80
81
|
filter: true,
|
|
81
82
|
},
|
|
83
|
+
isCanceledSubscription = false,
|
|
82
84
|
}: ListProps) {
|
|
83
85
|
const { t } = useLocaleContext();
|
|
84
86
|
const [searchParams] = useSearchParams();
|
|
@@ -219,7 +221,7 @@ export default function PayoutList({
|
|
|
219
221
|
options: {
|
|
220
222
|
customBodyRenderLite: (_: string, index: number) => {
|
|
221
223
|
const item = data.list[index] as TPayoutExpanded;
|
|
222
|
-
return <PayoutActions data={item} />;
|
|
224
|
+
return <PayoutActions data={item} isCanceledSubscription={isCanceledSubscription} />;
|
|
223
225
|
},
|
|
224
226
|
},
|
|
225
227
|
},
|
package/src/locales/en.tsx
CHANGED
|
@@ -1062,6 +1062,11 @@ export default flat({
|
|
|
1062
1062
|
requestRefund: 'Request Refund',
|
|
1063
1063
|
confirmRefund: 'Are you sure you want to request a refund from the vendor?',
|
|
1064
1064
|
refundRequested: 'Refund request sent to vendor',
|
|
1065
|
+
refundReason: 'Refund Reason',
|
|
1066
|
+
refundReasonPlaceholder: 'Please enter the reason for refund (at least 5 characters)',
|
|
1067
|
+
refundReasonRequired: 'Refund reason is required',
|
|
1068
|
+
refundReasonMinLength: 'Refund reason must be at least {min} characters',
|
|
1069
|
+
refundReasonHelper: 'Please enter at least {min} characters',
|
|
1065
1070
|
status: {
|
|
1066
1071
|
active: 'Active',
|
|
1067
1072
|
paid: 'Paid',
|
package/src/locales/zh.tsx
CHANGED
|
@@ -963,6 +963,11 @@ export default flat({
|
|
|
963
963
|
requestRefund: '请求退款',
|
|
964
964
|
confirmRefund: '确定要向供应商请求退款吗?',
|
|
965
965
|
refundRequested: '退款请求已发送给供应商',
|
|
966
|
+
refundReason: '退款原因',
|
|
967
|
+
refundReasonPlaceholder: '请输入退款原因(至少 5 个字符)',
|
|
968
|
+
refundReasonRequired: '退款原因为必填项',
|
|
969
|
+
refundReasonMinLength: '退款原因至少需要 {min} 个字符',
|
|
970
|
+
refundReasonHelper: '请输入至少 {min} 个字符',
|
|
966
971
|
status: {
|
|
967
972
|
active: '生效中',
|
|
968
973
|
paid: '已支付',
|
|
@@ -19,8 +19,10 @@ import { Alert, Avatar, Box, Button, CircularProgress, Divider, Stack, Tooltip,
|
|
|
19
19
|
import { styled } from '@mui/system';
|
|
20
20
|
import { useRequest, useSetState } from 'ahooks';
|
|
21
21
|
import { Link } from 'react-router-dom';
|
|
22
|
+
import { isValid } from '@arcblock/did';
|
|
22
23
|
|
|
23
24
|
import { startCase } from 'lodash';
|
|
25
|
+
import DID from '@arcblock/ux/lib/DID';
|
|
24
26
|
import Copyable from '../../../../components/copyable';
|
|
25
27
|
import CustomerLink from '../../../../components/customer/link';
|
|
26
28
|
import EventList from '../../../../components/event/list';
|
|
@@ -93,6 +95,8 @@ export default function PaymentIntentDetail(props: { id: string }) {
|
|
|
93
95
|
const handleEditMetadata = () => {
|
|
94
96
|
setState((prev) => ({ editing: { ...prev.editing, metadata: true } }));
|
|
95
97
|
};
|
|
98
|
+
|
|
99
|
+
const canceledSubscription = data.subscription?.status === 'canceled';
|
|
96
100
|
return (
|
|
97
101
|
<Root direction="column" spacing={2.5} mb={4}>
|
|
98
102
|
<Box>
|
|
@@ -207,7 +211,17 @@ export default function PaymentIntentDetail(props: { id: string }) {
|
|
|
207
211
|
/>
|
|
208
212
|
{/* <InfoMetric label={t('common.createdAt')} value={formatTime(data.created_at)} divider />
|
|
209
213
|
<InfoMetric label={t('common.updatedAt')} value={formatTime(data.updated_at)} divider /> */}
|
|
210
|
-
<InfoMetric
|
|
214
|
+
<InfoMetric
|
|
215
|
+
label={t('common.customer')}
|
|
216
|
+
value={
|
|
217
|
+
data.metadata?.payerDid && isValid(data.metadata?.payerDid) ? (
|
|
218
|
+
<DID did={data.metadata?.payerDid} {...(isMobile ? { responsive: false, compact: true } : {})} />
|
|
219
|
+
) : (
|
|
220
|
+
<CustomerLink customer={data.customer} />
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
divider
|
|
224
|
+
/>
|
|
211
225
|
</Stack>
|
|
212
226
|
</Box>
|
|
213
227
|
<Divider />
|
|
@@ -318,7 +332,11 @@ export default function PaymentIntentDetail(props: { id: string }) {
|
|
|
318
332
|
<Box className="section">
|
|
319
333
|
<SectionHeader title={t('admin.payouts')} />
|
|
320
334
|
<Box className="section-body">
|
|
321
|
-
<PayoutList
|
|
335
|
+
<PayoutList
|
|
336
|
+
features={{ toolbar: false }}
|
|
337
|
+
payment_intent_id={data?.id}
|
|
338
|
+
isCanceledSubscription={canceledSubscription}
|
|
339
|
+
/>
|
|
322
340
|
</Box>
|
|
323
341
|
</Box>
|
|
324
342
|
<Divider />
|
|
@@ -117,7 +117,7 @@ export default function PayoutDetail(props: { id: string }) {
|
|
|
117
117
|
|
|
118
118
|
const isAnonymousPayer = paymentIntent?.customer?.metadata?.anonymous;
|
|
119
119
|
|
|
120
|
-
const customerDid = paymentIntent?.customer?.did || paymentIntent?.
|
|
120
|
+
const customerDid = paymentIntent?.customer?.did || paymentIntent?.metadata?.payerDid || '';
|
|
121
121
|
|
|
122
122
|
return (
|
|
123
123
|
<Root direction="column" spacing={2.5} mb={4}>
|