payment-kit 1.22.17 → 1.22.18
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/didnames-adapter.ts +35 -0
- package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +35 -0
- package/api/src/libs/vendor-util/adapters/types.ts +17 -1
- package/api/src/libs/vendor-util/tool.ts +46 -0
- package/api/src/queues/vendors/return-processor.ts +81 -9
- package/api/src/routes/payment-intents.ts +4 -0
- package/api/src/routes/payouts.ts +384 -3
- package/api/src/routes/vendor.ts +89 -1
- package/api/src/store/models/payout.ts +5 -2
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/components/payouts/actions.tsx +48 -0
- package/src/locales/en.tsx +3 -0
- package/src/locales/zh.tsx +3 -0
- package/src/pages/admin/payments/payouts/detail.tsx +6 -4
|
@@ -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,
|
|
@@ -397,6 +399,39 @@ export class DidnamesAdapter implements VendorAdapter {
|
|
|
397
399
|
}
|
|
398
400
|
}
|
|
399
401
|
|
|
402
|
+
async requestRefund(params: RefundRequestParams): Promise<RefundRequestResult> {
|
|
403
|
+
logger.info('Requesting refund for payout', { params });
|
|
404
|
+
|
|
405
|
+
const vendorConfig = await this.getVendorConfig();
|
|
406
|
+
const { headers, body } = await VendorAuth.signRequestWithHeaders(params);
|
|
407
|
+
|
|
408
|
+
const response = await fetch(formatVendorUrl(vendorConfig, '/api/vendor/refund'), {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers,
|
|
411
|
+
body,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
const errorBody = await response.text();
|
|
416
|
+
logger.error('Vendor refund API error', {
|
|
417
|
+
status: response.status,
|
|
418
|
+
statusText: response.statusText,
|
|
419
|
+
body: errorBody,
|
|
420
|
+
orderId: params.orderId,
|
|
421
|
+
});
|
|
422
|
+
throw new Error(`Vendor refund API error: ${response.status} ${response.statusText}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const result = await response.json();
|
|
426
|
+
|
|
427
|
+
logger.info('Vendor refund request completed', {
|
|
428
|
+
orderId: params.orderId,
|
|
429
|
+
status: result.status,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
|
|
400
435
|
async checkOrderStatus(params: CheckOrderStatusParams): Promise<CheckOrderStatusResult> {
|
|
401
436
|
logger.info('Checking Didnames order status', {
|
|
402
437
|
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);
|
|
@@ -74,7 +74,22 @@ export interface ReturnRequestParams {
|
|
|
74
74
|
export interface ReturnRequestResult {
|
|
75
75
|
status: 'requested' | 'accepted' | 'rejected' | 'failed';
|
|
76
76
|
message?: string;
|
|
77
|
-
|
|
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
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
158
|
-
|
|
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({
|
package/api/src/routes/vendor.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.18",
|
|
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.18",
|
|
61
|
+
"@blocklet/payment-react": "1.22.18",
|
|
62
|
+
"@blocklet/payment-vendor": "1.22.18",
|
|
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.18",
|
|
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": "24310c99c4d63ab5ec34f82f6c2d2d16a5af469f"
|
|
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
|
}
|
package/src/locales/en.tsx
CHANGED
|
@@ -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',
|
package/src/locales/zh.tsx
CHANGED
|
@@ -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
|
-
|
|
273
|
-
did={
|
|
274
|
-
|
|
275
|
-
/>
|
|
275
|
+
isValid(customerDid) && (
|
|
276
|
+
<DID did={customerDid} {...(isMobile ? { responsive: false, compact: true } : {})} />
|
|
277
|
+
)
|
|
276
278
|
}
|
|
277
279
|
size={40}
|
|
278
280
|
/>
|