payment-kit 1.22.17 → 1.22.19

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.
@@ -11,6 +11,8 @@ import {
11
11
  CheckOrderStatusResult,
12
12
  FulfillOrderParams,
13
13
  FulfillOrderResult,
14
+ RefundRequestParams,
15
+ RefundRequestResult,
14
16
  ReturnRequestParams,
15
17
  ReturnRequestResult,
16
18
  VendorAdapter,
@@ -377,16 +379,7 @@ export class DidnamesAdapter implements VendorAdapter {
377
379
 
378
380
  const didNamesResult = await response.json();
379
381
 
380
- logger.info('domain return to DID Names processed', {
381
- url,
382
- orderId: params.orderId,
383
- status: didNamesResult.status,
384
- });
385
-
386
- return {
387
- status: didNamesResult.status || 'requested',
388
- message: didNamesResult.message || 'Domain unbinding requested',
389
- };
382
+ return didNamesResult;
390
383
  } catch (error: any) {
391
384
  logger.error('Failed to process return request', {
392
385
  error: error.message,
@@ -397,6 +390,39 @@ export class DidnamesAdapter implements VendorAdapter {
397
390
  }
398
391
  }
399
392
 
393
+ async requestRefund(params: RefundRequestParams): Promise<RefundRequestResult> {
394
+ logger.info('Requesting refund for payout', { params });
395
+
396
+ const vendorConfig = await this.getVendorConfig();
397
+ const { headers, body } = await VendorAuth.signRequestWithHeaders(params);
398
+
399
+ const response = await fetch(formatVendorUrl(vendorConfig, '/api/vendor/refund'), {
400
+ method: 'POST',
401
+ headers,
402
+ body,
403
+ });
404
+
405
+ if (!response.ok) {
406
+ const errorBody = await response.text();
407
+ logger.error('Vendor refund API error', {
408
+ status: response.status,
409
+ statusText: response.statusText,
410
+ body: errorBody,
411
+ orderId: params.orderId,
412
+ });
413
+ throw new Error(`Vendor refund API error: ${response.status} ${response.statusText}`);
414
+ }
415
+
416
+ const result = await response.json();
417
+
418
+ logger.info('Vendor refund request completed', {
419
+ orderId: params.orderId,
420
+ status: result.status,
421
+ });
422
+
423
+ return result;
424
+ }
425
+
400
426
  async checkOrderStatus(params: CheckOrderStatusParams): Promise<CheckOrderStatusResult> {
401
427
  logger.info('Checking Didnames order status', {
402
428
  orderId: params.orderId,
@@ -11,6 +11,8 @@ import {
11
11
  CheckOrderStatusResult,
12
12
  FulfillOrderParams,
13
13
  FulfillOrderResult,
14
+ RefundRequestParams,
15
+ RefundRequestResult,
14
16
  ReturnRequestParams,
15
17
  ReturnRequestResult,
16
18
  VendorAdapter,
@@ -262,6 +264,39 @@ export class LauncherAdapter implements VendorAdapter {
262
264
  return launcherResult;
263
265
  }
264
266
 
267
+ async requestRefund(params: RefundRequestParams): Promise<RefundRequestResult> {
268
+ logger.info('Requesting refund for payout', { params });
269
+
270
+ const vendorConfig = await this.getVendorConfig();
271
+ const { headers, body } = await VendorAuth.signRequestWithHeaders(params);
272
+
273
+ const response = await fetch(formatVendorUrl(vendorConfig, '/api/vendor/refund'), {
274
+ method: 'POST',
275
+ headers,
276
+ body,
277
+ });
278
+
279
+ if (!response.ok) {
280
+ const errorBody = await response.text();
281
+ logger.error('Vendor refund API error', {
282
+ status: response.status,
283
+ statusText: response.statusText,
284
+ body: errorBody,
285
+ orderId: params.orderId,
286
+ });
287
+ throw new Error(`Vendor refund API error: ${response.status} ${response.statusText}`);
288
+ }
289
+
290
+ const result = await response.json();
291
+
292
+ logger.info('Vendor refund request completed', {
293
+ orderId: params.orderId,
294
+ status: result.status,
295
+ });
296
+
297
+ return result;
298
+ }
299
+
265
300
  async checkOrderStatus(params: CheckOrderStatusParams): Promise<CheckOrderStatusResult> {
266
301
  try {
267
302
  const blockletInfo = await getBlockletInfo(params.appUrl);
@@ -72,9 +72,24 @@ export interface ReturnRequestParams {
72
72
  }
73
73
 
74
74
  export interface ReturnRequestResult {
75
- status: 'requested' | 'accepted' | 'rejected' | 'failed';
75
+ code: string;
76
76
  message?: string;
77
- success?: boolean;
77
+ data?: Record<string, any>;
78
+ }
79
+
80
+ export interface RefundRequestParams {
81
+ orderId: string;
82
+ amount: string;
83
+ livemode: number;
84
+ contract: string;
85
+ chainType: string;
86
+ chainApiHost: string;
87
+ }
88
+
89
+ export interface RefundRequestResult {
90
+ code?: string;
91
+ message?: string;
92
+ data?: Record<string, any>;
78
93
  }
79
94
 
80
95
  export interface CheckOrderStatusParams extends Record<string, any> {}
@@ -86,6 +101,7 @@ export interface CheckOrderStatusResult {
86
101
  export interface VendorAdapter {
87
102
  fulfillOrder(params: FulfillOrderParams): Promise<FulfillOrderResult>;
88
103
  requestReturn(params: ReturnRequestParams): Promise<ReturnRequestResult>;
104
+ requestRefund(params: RefundRequestParams): Promise<RefundRequestResult>;
89
105
  checkOrderStatus(params: CheckOrderStatusParams): Promise<CheckOrderStatusResult>;
90
106
  getOrder(vendor: ProductVendor, orderId: string, option?: Record<string, any>): Promise<any>;
91
107
  getOrderStatus(vendor: ProductVendor, orderId: string, option?: Record<string, any>): Promise<any>;
@@ -0,0 +1,46 @@
1
+ import { PaymentCurrency, PaymentMethod, Payout } from '../../store/models';
2
+
3
+ export interface RefundInfo {
4
+ livemode: number;
5
+ amount: string;
6
+ contract: string;
7
+ chainType: string;
8
+ chainApiHost: string;
9
+ }
10
+
11
+ /**
12
+ * Build refund info from payout record
13
+ * Extracts contract, chain type, and chain API host from payout's associated payment method and currency
14
+ */
15
+ export async function buildRefundInfoFromPayout(payout: Payout): Promise<RefundInfo> {
16
+ const paymentMethod = await PaymentMethod.findByPk(payout.payment_method_id);
17
+ if (!paymentMethod) {
18
+ throw new Error(`PaymentMethod not found: ${payout.payment_method_id} | Payout ID: ${payout.id}`);
19
+ }
20
+
21
+ const paymentCurrency = await PaymentCurrency.findByPk(payout.currency_id);
22
+ if (!paymentCurrency?.contract) {
23
+ throw new Error(`PaymentCurrency not found: ${payout.currency_id} | Payout ID: ${payout.id}`);
24
+ }
25
+
26
+ const chainType = paymentMethod.type;
27
+ const chainSettings = paymentMethod.settings[chainType as keyof typeof paymentMethod.settings] as
28
+ | {
29
+ api_host: string;
30
+ }
31
+ | undefined;
32
+ const chainApiHost = chainSettings?.api_host || '';
33
+ if (!chainApiHost) {
34
+ throw new Error(
35
+ `Chain API host not found for payment method: ${paymentMethod.id} | Chain type: ${chainType} | Payout ID: ${payout.id}`
36
+ );
37
+ }
38
+
39
+ return {
40
+ livemode: payout.livemode ? 1 : 0,
41
+ amount: payout.amount,
42
+ contract: paymentCurrency.contract,
43
+ chainType,
44
+ chainApiHost,
45
+ };
46
+ }
@@ -852,6 +852,7 @@ async function requestReturnFromSingleVendor(
852
852
  throw new Error(`No adapter found for vendor: ${vendor.vendor_id}`);
853
853
  }
854
854
 
855
+ // revenue has not been split yet, so no refund operation is needed.
855
856
  const returnResult = await vendorAdapter.requestReturn({
856
857
  orderId: vendor.order_id,
857
858
  reason: `Return request due to: ${reason}`,
@@ -861,11 +862,10 @@ async function requestReturnFromSingleVendor(
861
862
  },
862
863
  });
863
864
 
864
- let { status } = returnResult;
865
- if (returnResult.status === 'requested') {
866
- status = 'pending' as 'accepted';
867
- } else if (returnResult.status === 'failed') {
868
- status = 'rejected' as 'rejected';
865
+ const { code } = returnResult;
866
+ let status: 'pending' | 'rejected' = 'pending';
867
+ if (code) {
868
+ status = 'rejected';
869
869
  }
870
870
 
871
871
  await updateSingleVendorInfo(checkoutSession.id, vendor.vendor_id, {
@@ -873,7 +873,7 @@ async function requestReturnFromSingleVendor(
873
873
  returnRequest: {
874
874
  reason,
875
875
  requestedAt: new Date().toISOString(),
876
- status: status as 'pending' | 'accepted' | 'rejected',
876
+ status,
877
877
  returnDetails: returnResult.message,
878
878
  },
879
879
  });
@@ -881,7 +881,7 @@ async function requestReturnFromSingleVendor(
881
881
  logger.info('Return request submitted successfully', {
882
882
  vendorId: vendor.vendor_id,
883
883
  orderId: vendor.order_id,
884
- returnStatus: returnResult.status,
884
+ returnResult,
885
885
  });
886
886
  } catch (error: any) {
887
887
  logger.error('Return request failed', {
@@ -1,7 +1,9 @@
1
1
  import logger from '../../libs/logger';
2
2
  import createQueue from '../../libs/queue';
3
3
  import { VendorFulfillmentService } from '../../libs/vendor-util/fulfillment';
4
- import { CheckoutSession } from '../../store/models';
4
+ import { buildRefundInfoFromPayout } from '../../libs/vendor-util/tool';
5
+ import { CheckoutSession, Payout } from '../../store/models';
6
+ import { payoutQueue } from '../payout';
5
7
  import { VendorInfo } from './fulfillment-coordinator';
6
8
 
7
9
  export const MAX_RETURN_RETRY = 3;
@@ -154,13 +156,83 @@ async function callVendorReturn(vendor: VendorInfo, checkoutSession: CheckoutSes
154
156
  throw new Error(`No adapter found for vendor: ${vendor.vendor_id}`);
155
157
  }
156
158
 
157
- return vendorAdapter.requestReturn({
158
- orderId: vendor.order_id,
159
- reason: 'Subscription canceled',
160
- customParams: {
161
- checkoutSessionId: checkoutSession.id,
162
- subscriptionId: checkoutSession.subscription_id,
163
- vendorKey: vendor.vendor_key,
164
- },
159
+ const payoutInfo = await Payout.findOne({
160
+ where: { vendor_info: { order_id: vendor.order_id, vendor_id: vendor.vendor_id } },
165
161
  });
162
+ let refundInfo;
163
+ if (payoutInfo) {
164
+ if (['pending', 'deferred', 'failed'].includes(payoutInfo.status)) {
165
+ logger.info('[callVendorReturn] Cancelling payout status to canceled', {
166
+ checkoutSessionId: checkoutSession.id,
167
+ vendorId: vendor.vendor_id,
168
+ orderId: vendor.order_id,
169
+ });
170
+ await payoutQueue.delete(payoutInfo.id);
171
+ await payoutInfo.update({ status: 'canceled' });
172
+ } else if (payoutInfo.status === 'paid') {
173
+ try {
174
+ refundInfo = await buildRefundInfoFromPayout(payoutInfo);
175
+ } catch (error: any) {
176
+ logger.error('[callVendorReturn] Error building refund info', {
177
+ checkoutSessionId: checkoutSession.id,
178
+ vendorId: vendor.vendor_id,
179
+ orderId: vendor.order_id,
180
+ error,
181
+ });
182
+ throw error;
183
+ }
184
+ logger.info('[callVendorReturn] Need to request vendor payout refund', {
185
+ checkoutSessionId: checkoutSession.id,
186
+ vendorId: vendor.vendor_id,
187
+ orderId: vendor.order_id,
188
+ refundInfo,
189
+ });
190
+ }
191
+ } else {
192
+ logger.error('[callVendorReturn] No payout info found', {
193
+ checkoutSessionId: checkoutSession.id,
194
+ vendorId: vendor.vendor_id,
195
+ orderId: vendor.order_id,
196
+ });
197
+ }
198
+
199
+ try {
200
+ const result = await vendorAdapter.requestReturn({
201
+ orderId: vendor.order_id,
202
+ reason: 'Subscription canceled',
203
+ customParams: {
204
+ checkoutSessionId: checkoutSession.id,
205
+ subscriptionId: checkoutSession.subscription_id,
206
+ vendorKey: vendor.vendor_key,
207
+ refundInfo,
208
+ },
209
+ });
210
+
211
+ if (payoutInfo && result.data?.payoutResult?.status === 'paid') {
212
+ logger.info('[callVendorReturn] Vendor payout refund successful', {
213
+ checkoutSessionId: checkoutSession.id,
214
+ vendorId: vendor.vendor_id,
215
+ orderId: vendor.order_id,
216
+ });
217
+ await payoutInfo.update({ status: 'reverted' });
218
+ } else {
219
+ logger.error('[callVendorReturn] Vendor payout refund failed', {
220
+ checkoutSessionId: checkoutSession.id,
221
+ vendorId: vendor.vendor_id,
222
+ orderId: vendor.order_id,
223
+ refundInfo,
224
+ payoutResult: result.data?.payoutResult,
225
+ });
226
+ }
227
+
228
+ return result;
229
+ } catch (error: any) {
230
+ logger.error('[callVendorReturn] Error requesting return', {
231
+ checkoutSessionId: checkoutSession.id,
232
+ vendorId: vendor.vendor_id,
233
+ orderId: vendor.order_id,
234
+ error,
235
+ });
236
+ throw error;
237
+ }
166
238
  }
@@ -2,6 +2,7 @@ import { isValid } from '@arcblock/did';
2
2
  import { Router } from 'express';
3
3
  import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
+ import { Op } from 'sequelize';
5
6
 
6
7
  import { BN, fromTokenToUnit } from '@ocap/util';
7
8
  import { syncStripeInvoice } from '../integrations/stripe/handlers/invoice';
@@ -356,6 +357,9 @@ router.get('/:id/refundable-amount', authPortal, async (req, res) => {
356
357
  const payouts = await Payout.findAll({
357
358
  where: {
358
359
  payment_intent_id: doc.id,
360
+ status: {
361
+ [Op.notIn]: ['canceled', 'reverted'],
362
+ },
359
363
  },
360
364
  attributes: ['id', 'amount'],
361
365
  });
@@ -1,21 +1,30 @@
1
1
  import { isValid } from '@arcblock/did';
2
+ import OcapClient from '@ocap/client';
3
+ import { BN } from '@ocap/util';
4
+ import { ethers } from 'ethers';
2
5
  import { Router } from 'express';
3
6
  import Joi from 'joi';
4
7
  import pick from 'lodash/pick';
5
8
 
6
9
  import { sessionMiddleware } from '@blocklet/sdk/lib/middlewares/session';
10
+ import { getWallet } from '@blocklet/sdk/lib/wallet';
7
11
  import type { WhereOptions } from 'sequelize';
12
+ import { Op } from 'sequelize';
13
+ import { sendErc20ToUser } from '../integrations/ethereum/token';
8
14
  import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
15
+ import { wallet } from '../libs/auth';
16
+ import { EVM_CHAIN_TYPES } from '../libs/constants';
17
+ import logger from '../libs/logger';
18
+ import { getGasPayerExtra } from '../libs/payment';
9
19
  import { authenticate } from '../libs/security';
10
20
  import { formatMetadata } from '../libs/util';
21
+ import { PaymentLink, TPaymentIntentExpanded } from '../store/models';
22
+ import { CheckoutSession } from '../store/models/checkout-session';
11
23
  import { Customer } from '../store/models/customer';
12
24
  import { PaymentCurrency } from '../store/models/payment-currency';
13
25
  import { PaymentIntent } from '../store/models/payment-intent';
14
26
  import { PaymentMethod } from '../store/models/payment-method';
15
27
  import { Payout } from '../store/models/payout';
16
- import { PaymentLink, TPaymentIntentExpanded } from '../store/models';
17
- import { CheckoutSession } from '../store/models/checkout-session';
18
- import logger from '../libs/logger';
19
28
 
20
29
  const router = Router();
21
30
  const authAdmin = authenticate<Payout>({ component: true, roles: ['owner', 'admin'] });
@@ -176,6 +185,378 @@ router.get('/mine', sessionMiddleware({ accessKey: true }), async (req, res) =>
176
185
  }
177
186
  });
178
187
 
188
+ const syncPayoutRequestSchema = Joi.object({
189
+ // Payout creation required fields
190
+ livemode: Joi.number().required().description('Testnet (0) or mainnet (1)'),
191
+ amount: Joi.number().required().description('Transfer amount in unit format (already converted)'),
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().valid('arcblock', 'ethereum', 'base', 'bitcoin').required().description('Chain type'),
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 = typeof amount === 'number' ? amount.toString() : 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
+
179
560
  router.get('/:id', authPortal, async (req, res) => {
180
561
  try {
181
562
  const doc = (await Payout.findOne({
@@ -15,7 +15,8 @@ import { authenticate } from '../libs/security';
15
15
  import { formatToShortUrl } from '../libs/url';
16
16
  import { getBlockletJson } from '../libs/util';
17
17
  import { VendorFulfillmentService } from '../libs/vendor-util/fulfillment';
18
- import { CheckoutSession, Invoice, Subscription } from '../store/models';
18
+ import { buildRefundInfoFromPayout } from '../libs/vendor-util/tool';
19
+ import { CheckoutSession, Invoice, Payout, PaymentCurrency, Subscription } from '../store/models';
19
20
  import { ProductVendor } from '../store/models/product-vendor';
20
21
 
21
22
  const VENDOR_DID = {
@@ -655,6 +656,14 @@ async function getVendorSubscription(req: any, res: any) {
655
656
  'description',
656
657
  'invoice_pdf',
657
658
  ],
659
+ include: [
660
+ {
661
+ model: PaymentCurrency,
662
+ as: 'paymentCurrency',
663
+ attributes: ['id', 'symbol', 'decimal', 'name', 'logo'],
664
+ required: false,
665
+ },
666
+ ],
658
667
  limit: 20,
659
668
  });
660
669
 
@@ -675,6 +684,83 @@ async function handleSubscriptionRedirect(req: any, res: any) {
675
684
  return res.redirect(getUrl(`/customer/subscription/${checkoutSession.subscription_id}`));
676
685
  }
677
686
 
687
+ async function vendorRefund(req: any, res: any) {
688
+ const { id } = req.params;
689
+ try {
690
+ const payout = await Payout.findByPk(id);
691
+
692
+ if (!payout) {
693
+ return res.status(404).json({ error: 'Payout not found' });
694
+ }
695
+
696
+ if (payout.status !== 'paid') {
697
+ return res.status(400).json({ error: 'Only paid payouts can be refunded' });
698
+ }
699
+
700
+ if (!payout.vendor_info?.vendor_id || !payout.vendor_info?.order_id) {
701
+ return res.status(400).json({ error: 'Payout has no vendor information' });
702
+ }
703
+
704
+ const vendor = await ProductVendor.findByPk(payout.vendor_info.vendor_id);
705
+ if (!vendor) {
706
+ return res.status(404).json({ error: 'Vendor not found' });
707
+ }
708
+
709
+ let refundInfo;
710
+ try {
711
+ refundInfo = await buildRefundInfoFromPayout(payout);
712
+ } catch (err: any) {
713
+ return res.status(400).json({ error: err.message });
714
+ }
715
+
716
+ const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
717
+ if (!vendorAdapter) {
718
+ return res.status(404).json({ error: `No adapter found for vendor: ${vendor.vendor_key}` });
719
+ }
720
+
721
+ logger.info('Requesting refund from vendor', {
722
+ payoutId: payout.id,
723
+ vendorId: vendor.id,
724
+ orderId: payout.vendor_info.order_id,
725
+ refundInfo,
726
+ requestedBy: req.user?.did,
727
+ });
728
+
729
+ const result = await vendorAdapter.requestRefund({
730
+ ...refundInfo,
731
+ orderId: payout.vendor_info.order_id,
732
+ });
733
+
734
+ if (['paid'].includes(result.data?.payoutResult?.status)) {
735
+ logger.info('Vendor payout refund successful', {
736
+ payoutId: payout.id,
737
+ orderId: payout.vendor_info.order_id,
738
+ payoutResult: result.data?.payoutResult,
739
+ });
740
+ await payout.update({ status: 'reverted' });
741
+ return res.json({ data: result.data?.payoutResult });
742
+ }
743
+ logger.error('Vendor payout refund failed', {
744
+ payoutId: payout.id,
745
+ orderId: payout.vendor_info.order_id,
746
+ refundInfo,
747
+ payoutResult: result.data?.payoutResult,
748
+ });
749
+ return res.status(400).json({
750
+ error: result.message || 'Vendor payout refund failed',
751
+ data: result.data?.payoutResult,
752
+ requestedBy: req.user?.did,
753
+ });
754
+ } catch (err: any) {
755
+ logger.error('Request refund failed', {
756
+ error: err,
757
+ payoutId: req.params.id,
758
+ requestedBy: req.user?.did,
759
+ });
760
+ return res.status(500).json({ error: err.message || 'Failed to request refund' });
761
+ }
762
+ }
763
+
678
764
  function getVendorConnectTest(req: any, res: any) {
679
765
  const sdkVersion = req.headers['x-broker-vendor-version'];
680
766
  if (sdkVersion && gte(sdkVersion, '1.21.4')) {
@@ -710,6 +796,8 @@ router.get(
710
796
  redirectToVendor
711
797
  );
712
798
 
799
+ router.post('/refund/:id', authAdmin, vendorRefund);
800
+
713
801
  router.get('/', getAllVendors);
714
802
  router.get('/:id', authAdmin, validateParams(vendorIdParamSchema), getVendorInfo);
715
803
  router.post('/', authAdmin, createVendor);
@@ -38,7 +38,10 @@ export class Payout extends Model<InferAttributes<Payout>, InferCreationAttribut
38
38
  commission_amount: string;
39
39
  };
40
40
 
41
- declare status: LiteralUnion<'pending' | 'paid' | 'failed' | 'canceled' | 'in_transit' | 'deferred', string>;
41
+ declare status: LiteralUnion<
42
+ 'pending' | 'paid' | 'failed' | 'canceled' | 'in_transit' | 'deferred' | 'reverted',
43
+ string
44
+ >;
42
45
 
43
46
  // retry logic
44
47
  declare failure_message?: string;
@@ -118,7 +121,7 @@ export class Payout extends Model<InferAttributes<Payout>, InferCreationAttribut
118
121
  allowNull: true,
119
122
  },
120
123
  status: {
121
- type: DataTypes.ENUM('paid', 'pending', 'failed', 'canceled', 'in_transit', 'deferred'),
124
+ type: DataTypes.ENUM('paid', 'pending', 'failed', 'canceled', 'in_transit', 'deferred', 'reverted'),
122
125
  allowNull: false,
123
126
  },
124
127
  failure_message: {
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.17
17
+ version: 1.22.19
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.17",
3
+ "version": "1.22.19",
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.17",
61
- "@blocklet/payment-react": "1.22.17",
62
- "@blocklet/payment-vendor": "1.22.17",
60
+ "@blocklet/payment-broker-client": "1.22.19",
61
+ "@blocklet/payment-react": "1.22.19",
62
+ "@blocklet/payment-vendor": "1.22.19",
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.17",
132
+ "@blocklet/payment-types": "1.22.19",
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": "5de77cb79635cdd8616113c587379fb430530d44"
179
+ "gitHead": "2a79b0a9d2a27f1605d8f59bec8c0d0d963ba334"
180
180
  }
@@ -1,5 +1,8 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import Toast from '@arcblock/ux/lib/Toast';
3
+ import { ConfirmDialog, api, formatError } from '@blocklet/payment-react';
2
4
  import type { TPayoutExpanded } from '@blocklet/payment-types';
5
+ import { useState } from 'react';
3
6
  import { useNavigate } from 'react-router-dom';
4
7
  import type { LiteralUnion } from 'type-fest';
5
8
 
@@ -14,6 +17,31 @@ type Props = {
14
17
  export default function PayoutActions({ data, variant = 'compact' }: Props) {
15
18
  const { t } = useLocaleContext();
16
19
  const navigate = useNavigate();
20
+ const [loading, setLoading] = useState(false);
21
+ const [showRefundDialog, setShowRefundDialog] = useState(false);
22
+
23
+ const handleRequestRefund = async () => {
24
+ setLoading(true);
25
+ try {
26
+ const { data: refundData } = await api.post(`/api/vendors/refund/${data.id}`, {
27
+ reason: 'Refund requested by admin',
28
+ });
29
+
30
+ if (!refundData.error) {
31
+ Toast.success(t('admin.payout.refundRequested'));
32
+ setShowRefundDialog(false);
33
+ // Optionally refresh the page or update the UI
34
+ window.location.reload();
35
+ } else {
36
+ throw new Error(refundData.error || 'Failed to request refund');
37
+ }
38
+ } catch (error: any) {
39
+ Toast.error(formatError(error as any));
40
+ } finally {
41
+ setLoading(false);
42
+ }
43
+ };
44
+
17
45
  const actions = [
18
46
  {
19
47
  label: t('admin.customer.view'),
@@ -22,6 +50,17 @@ export default function PayoutActions({ data, variant = 'compact' }: Props) {
22
50
  disabled: false,
23
51
  },
24
52
  ];
53
+
54
+ // 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) {
56
+ actions.push({
57
+ label: t('admin.payout.requestRefund'),
58
+ handler: () => setShowRefundDialog(true),
59
+ color: 'warning',
60
+ disabled: loading || data.metadata?.refund_requested,
61
+ });
62
+ }
63
+
25
64
  if (variant === 'compact') {
26
65
  actions.push({
27
66
  label: t('admin.paymentIntent.view'),
@@ -34,6 +73,15 @@ export default function PayoutActions({ data, variant = 'compact' }: Props) {
34
73
  return (
35
74
  <ClickBoundary>
36
75
  <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
+ )}
37
85
  </ClickBoundary>
38
86
  );
39
87
  }
@@ -1059,6 +1059,9 @@ export default flat({
1059
1059
  view: 'View payout',
1060
1060
  empty: 'No payout',
1061
1061
  attention: 'Failed payouts',
1062
+ requestRefund: 'Request Refund',
1063
+ confirmRefund: 'Are you sure you want to request a refund from the vendor?',
1064
+ refundRequested: 'Refund request sent to vendor',
1062
1065
  status: {
1063
1066
  active: 'Active',
1064
1067
  paid: 'Paid',
@@ -960,6 +960,9 @@ export default flat({
960
960
  view: '查看对外支付',
961
961
  empty: '没有记录',
962
962
  attention: '失败的对外支付',
963
+ requestRefund: '请求退款',
964
+ confirmRefund: '确定要向供应商请求退款吗?',
965
+ refundRequested: '退款请求已发送给供应商',
963
966
  status: {
964
967
  active: '生效中',
965
968
  paid: '已支付',
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable react/no-unstable-nested-components */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import { isValid } from '@arcblock/did';
3
4
  import Toast from '@arcblock/ux/lib/Toast';
4
5
  import {
5
6
  Amount,
@@ -116,6 +117,8 @@ export default function PayoutDetail(props: { id: string }) {
116
117
 
117
118
  const isAnonymousPayer = paymentIntent?.customer?.metadata?.anonymous;
118
119
 
120
+ const customerDid = paymentIntent?.customer?.did || paymentIntent?.customer_id || '';
121
+
119
122
  return (
120
123
  <Root direction="column" spacing={2.5} mb={4}>
121
124
  <Box>
@@ -269,10 +272,9 @@ export default function PayoutDetail(props: { id: string }) {
269
272
  </Typography>
270
273
  }
271
274
  description={
272
- <DID
273
- did={paymentIntent?.customer?.did}
274
- {...(isMobile ? { responsive: false, compact: true } : {})}
275
- />
275
+ isValid(customerDid) && (
276
+ <DID did={customerDid} {...(isMobile ? { responsive: false, compact: true } : {})} />
277
+ )
276
278
  }
277
279
  size={40}
278
280
  />