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.
@@ -84,6 +84,7 @@ export interface RefundRequestParams {
84
84
  contract: string;
85
85
  chainType: string;
86
86
  chainApiHost: string;
87
+ reason?: string;
87
88
  }
88
89
 
89
90
  export interface RefundRequestResult {
@@ -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?.payoutResult?.status === 'paid') {
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
- payoutResult: result.data?.payoutResult,
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 { BN, fromTokenToUnit } from '@ocap/util';
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: [
@@ -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 (['paid'].includes(result.data?.payoutResult?.status)) {
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
- payoutResult: result.data?.payoutResult,
739
+ refundResult: result.data?.refundResult,
739
740
  });
740
- await payout.update({ status: 'reverted' });
741
- return res.json({ data: result.data?.payoutResult });
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
- payoutResult: result.data?.payoutResult,
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?.payoutResult,
752
+ data: result.data?.refundResult,
752
753
  requestedBy: req.user?.did,
753
754
  });
754
755
  } catch (err: any) {
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.22.20
17
+ version: 1.22.22
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.22.20",
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.20",
61
- "@blocklet/payment-react": "1.22.20",
62
- "@blocklet/payment-vendor": "1.22.20",
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.20",
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": "2928d141aea59ed8c5b332a017b40d639fafd976"
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 { ConfirmDialog, api, formatError } from '@blocklet/payment-react';
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: 'Refund requested by admin',
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
- {showRefundDialog && (
77
- <ConfirmDialog
78
- onConfirm={handleRequestRefund}
79
- onCancel={() => setShowRefundDialog(false)}
80
- title={t('admin.payout.requestRefund')}
81
- message={t('admin.payout.confirmRefund')}
82
- loading={loading}
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
  },
@@ -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',
@@ -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 label={t('common.customer')} value={<CustomerLink customer={data.customer} />} divider />
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 features={{ toolbar: false }} payment_intent_id={data?.id} />
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?.customer_id || '';
120
+ const customerDid = paymentIntent?.customer?.did || paymentIntent?.metadata?.payerDid || '';
121
121
 
122
122
  return (
123
123
  <Root direction="column" spacing={2.5} mb={4}>