payment-kit 1.13.296 → 1.13.298
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/api.ts +17 -0
- package/api/src/libs/refund.ts +59 -0
- package/api/src/routes/payment-intents.ts +121 -8
- package/api/src/routes/payment-links.ts +8 -1
- package/api/src/routes/products.ts +2 -1
- package/api/src/routes/refunds.ts +66 -1
- package/api/src/store/models/index.ts +4 -0
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/scripts/sdk.js +41 -0
- package/src/components/payment-intent/actions.tsx +175 -8
- package/src/components/payment-intent/list.tsx +8 -4
- package/src/locales/en.tsx +16 -0
- package/src/locales/zh.tsx +16 -0
- package/src/pages/admin/payments/intents/detail.tsx +8 -1
- package/src/pages/admin/products/links/index.tsx +2 -1
package/api/src/libs/api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
1
2
|
import Joi from 'joi';
|
|
2
3
|
import { Op } from 'sequelize';
|
|
3
4
|
import SqlWhereParser from 'sql-where-parser';
|
|
@@ -136,3 +137,19 @@ export function createListParamSchema<T>(schema: any, pageSize: number = 20) {
|
|
|
136
137
|
...schema,
|
|
137
138
|
});
|
|
138
139
|
}
|
|
140
|
+
|
|
141
|
+
export const BNPositiveValidator = Joi.string().custom((value, helpers) => {
|
|
142
|
+
if (!/^\d+(\.\d+)?$/.test(value)) {
|
|
143
|
+
return helpers.error('any.invalid');
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const valueInWei = fromTokenToUnit(value, 18).toString();
|
|
147
|
+
const bnValue = new BN(valueInWei);
|
|
148
|
+
if (bnValue.isNeg() || bnValue.isZero()) {
|
|
149
|
+
return helpers.error('any.invalid');
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return helpers.error('any.invalid');
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}, 'BN Positive Validation');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
2
|
+
import type { WhereOptions } from 'sequelize';
|
|
3
|
+
import { PaymentIntent, Refund } from '../store/models';
|
|
4
|
+
|
|
5
|
+
export async function getRefundAmountSetup({
|
|
6
|
+
currencyId,
|
|
7
|
+
customerId,
|
|
8
|
+
subscriptionId,
|
|
9
|
+
PaymentMethodId,
|
|
10
|
+
paymentIntentId,
|
|
11
|
+
}: {
|
|
12
|
+
currencyId?: string;
|
|
13
|
+
customerId?: string;
|
|
14
|
+
subscriptionId?: string;
|
|
15
|
+
PaymentMethodId?: string;
|
|
16
|
+
paymentIntentId: string;
|
|
17
|
+
}) {
|
|
18
|
+
const paymentIntent = await PaymentIntent.findByPk(paymentIntentId);
|
|
19
|
+
if (!paymentIntent) {
|
|
20
|
+
throw new Error('PaymentIntent not found');
|
|
21
|
+
}
|
|
22
|
+
const where: WhereOptions<Refund> = {
|
|
23
|
+
status: ['succeeded'],
|
|
24
|
+
payment_intent_id: paymentIntentId,
|
|
25
|
+
};
|
|
26
|
+
if (currencyId) {
|
|
27
|
+
where.currency_id = currencyId;
|
|
28
|
+
}
|
|
29
|
+
if (customerId) {
|
|
30
|
+
where.customer_id = customerId;
|
|
31
|
+
}
|
|
32
|
+
if (subscriptionId) {
|
|
33
|
+
where.subscription_id = subscriptionId;
|
|
34
|
+
}
|
|
35
|
+
if (PaymentMethodId) {
|
|
36
|
+
where.payment_method_id = PaymentMethodId;
|
|
37
|
+
}
|
|
38
|
+
const { rows: list, count } = await Refund.findAndCountAll({
|
|
39
|
+
where,
|
|
40
|
+
include: [],
|
|
41
|
+
});
|
|
42
|
+
if (count === 0) {
|
|
43
|
+
return {
|
|
44
|
+
amount: paymentIntent.amount_received,
|
|
45
|
+
totalAmount: paymentIntent.amount_received,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
let refundedAmountBN = new BN('0');
|
|
49
|
+
list.forEach((refund) => {
|
|
50
|
+
if (refund) {
|
|
51
|
+
refundedAmountBN = refundedAmountBN.add(new BN(refund.amount || '0'));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
amount: new BN(paymentIntent.amount_received).sub(refundedAmountBN).toString(),
|
|
57
|
+
totalAmount: paymentIntent.amount_received,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -3,9 +3,10 @@ import { Router } from 'express';
|
|
|
3
3
|
import Joi from 'joi';
|
|
4
4
|
import pick from 'lodash/pick';
|
|
5
5
|
|
|
6
|
+
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
6
7
|
import { syncStripeInvoice } from '../integrations/stripe/handlers/invoice';
|
|
7
8
|
import { syncStripePayment } from '../integrations/stripe/handlers/payment-intent';
|
|
8
|
-
import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery } from '../libs/api';
|
|
9
|
+
import { BNPositiveValidator, createListParamSchema, getWhereFromKvQuery, getWhereFromQuery } from '../libs/api';
|
|
9
10
|
import { authenticate } from '../libs/security';
|
|
10
11
|
import { formatMetadata } from '../libs/util';
|
|
11
12
|
import { paymentQueue } from '../queues/payment';
|
|
@@ -16,6 +17,9 @@ import { PaymentCurrency } from '../store/models/payment-currency';
|
|
|
16
17
|
import { PaymentIntent } from '../store/models/payment-intent';
|
|
17
18
|
import { PaymentMethod } from '../store/models/payment-method';
|
|
18
19
|
import { Subscription } from '../store/models/subscription';
|
|
20
|
+
import logger from '../libs/logger';
|
|
21
|
+
import { Refund } from '../store/models';
|
|
22
|
+
import { getRefundAmountSetup } from '../libs/refund';
|
|
19
23
|
|
|
20
24
|
const router = Router();
|
|
21
25
|
const authAdmin = authenticate<PaymentIntent>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -30,6 +34,16 @@ const authPortal = authenticate<PaymentIntent>({
|
|
|
30
34
|
},
|
|
31
35
|
});
|
|
32
36
|
|
|
37
|
+
async function syncStripePaymentAndInvoice(paymentIntent: PaymentIntent, invoice: Invoice | null, req: any) {
|
|
38
|
+
const shouldSync = paymentIntent.status !== 'succeeded' || req.query.sync === '1';
|
|
39
|
+
if (shouldSync) {
|
|
40
|
+
await syncStripePayment(paymentIntent);
|
|
41
|
+
}
|
|
42
|
+
if (invoice?.metadata?.stripe_id) {
|
|
43
|
+
await syncStripeInvoice(invoice);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
// list payment intents
|
|
34
48
|
const paginationSchema = createListParamSchema<{
|
|
35
49
|
status?: string;
|
|
@@ -156,14 +170,8 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
156
170
|
let subscription;
|
|
157
171
|
|
|
158
172
|
if (doc) {
|
|
159
|
-
const shouldSync = doc.status !== 'succeeded' || req.query.sync === '1';
|
|
160
|
-
if (shouldSync) {
|
|
161
|
-
await syncStripePayment(doc);
|
|
162
|
-
}
|
|
163
173
|
invoice = await Invoice.findByPk(doc.invoice_id);
|
|
164
|
-
|
|
165
|
-
await syncStripeInvoice(invoice);
|
|
166
|
-
}
|
|
174
|
+
await syncStripePaymentAndInvoice(doc, invoice, req);
|
|
167
175
|
|
|
168
176
|
checkoutSession = await CheckoutSession.findOne({ where: { payment_intent_id: doc.id } });
|
|
169
177
|
if (invoice && invoice.subscription_id) {
|
|
@@ -229,4 +237,109 @@ router.get('/:id/retry', authAdmin, async (req, res) => {
|
|
|
229
237
|
}
|
|
230
238
|
});
|
|
231
239
|
|
|
240
|
+
const refundRequestSchema = Joi.object({
|
|
241
|
+
amount: BNPositiveValidator.required(),
|
|
242
|
+
reason: Joi.string()
|
|
243
|
+
.valid('duplicate', 'requested_by_customer', 'requested_by_admin', 'fraudulent', 'expired_uncaptured_charge')
|
|
244
|
+
.required(),
|
|
245
|
+
description: Joi.string().required(),
|
|
246
|
+
metadata: Joi.object().optional(),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
router.put('/:id/refund', authAdmin, async (req, res) => {
|
|
250
|
+
try {
|
|
251
|
+
const { error } = refundRequestSchema.validate(req.body);
|
|
252
|
+
if (error) {
|
|
253
|
+
res.status(400).json({ error: `payment intent refund request invalid: ${error.message}` });
|
|
254
|
+
}
|
|
255
|
+
const doc = await PaymentIntent.findOne({
|
|
256
|
+
where: { id: req.params.id },
|
|
257
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
258
|
+
});
|
|
259
|
+
if (!doc) {
|
|
260
|
+
throw new Error('PaymentIntent not found');
|
|
261
|
+
}
|
|
262
|
+
const invoice = await Invoice.findByPk(doc.invoice_id);
|
|
263
|
+
await syncStripePaymentAndInvoice(doc, invoice, req);
|
|
264
|
+
// @ts-ignore
|
|
265
|
+
const result = await getRefundAmountSetup({
|
|
266
|
+
paymentIntentId: doc.id,
|
|
267
|
+
currencyId: doc.currency_id,
|
|
268
|
+
customerId: doc.customer_id,
|
|
269
|
+
subscriptionId: invoice?.subscription_id,
|
|
270
|
+
});
|
|
271
|
+
if (!result) {
|
|
272
|
+
throw new Error('refund amount setup failed');
|
|
273
|
+
}
|
|
274
|
+
// @ts-ignore
|
|
275
|
+
const amount = fromTokenToUnit(req.body.amount, doc.paymentCurrency?.decimal).toString();
|
|
276
|
+
const amountBN = new BN(amount);
|
|
277
|
+
|
|
278
|
+
if (amountBN.gt(new BN(result.amount))) {
|
|
279
|
+
throw new Error('refund amount exceeds the available amount');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const item = await Refund.create({
|
|
283
|
+
type: 'refund',
|
|
284
|
+
livemode: doc.livemode,
|
|
285
|
+
amount,
|
|
286
|
+
description: req.body.description || 'payment_intent_cancel',
|
|
287
|
+
status: 'pending',
|
|
288
|
+
reason: req.body.reason || 'requested_by_admin',
|
|
289
|
+
currency_id: doc.currency_id,
|
|
290
|
+
customer_id: doc.customer_id || '',
|
|
291
|
+
payment_method_id: doc.payment_method_id,
|
|
292
|
+
payment_intent_id: doc.id,
|
|
293
|
+
invoice_id: doc.invoice_id,
|
|
294
|
+
subscription_id: invoice?.subscription_id,
|
|
295
|
+
attempt_count: 0,
|
|
296
|
+
attempted: false,
|
|
297
|
+
next_attempt: 0,
|
|
298
|
+
last_attempt_error: null,
|
|
299
|
+
starting_balance: '0',
|
|
300
|
+
ending_balance: '0',
|
|
301
|
+
starting_token_balance: {},
|
|
302
|
+
ending_token_balance: {},
|
|
303
|
+
metadata: {
|
|
304
|
+
requested_by: req.user?.did,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
logger.info('payment intent refund created', {
|
|
308
|
+
...req.params,
|
|
309
|
+
...req.body,
|
|
310
|
+
...pick(result, ['amount', 'totalAmount']),
|
|
311
|
+
item: item.toJSON(),
|
|
312
|
+
});
|
|
313
|
+
res.json(item.toJSON());
|
|
314
|
+
} catch (err) {
|
|
315
|
+
console.error(err);
|
|
316
|
+
res.status(500).json({ error: `Failed to payment intent refund: ${err.message}` });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
router.get('/:id/refundable-amount', authPortal, async (req, res) => {
|
|
321
|
+
try {
|
|
322
|
+
const doc = await PaymentIntent.findOne({
|
|
323
|
+
where: { id: req.params.id },
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (doc) {
|
|
327
|
+
const invoice = await Invoice.findByPk(doc.invoice_id);
|
|
328
|
+
await syncStripePaymentAndInvoice(doc, invoice, req);
|
|
329
|
+
const result = await getRefundAmountSetup({
|
|
330
|
+
paymentIntentId: doc.id,
|
|
331
|
+
currencyId: doc.currency_id,
|
|
332
|
+
customerId: doc.customer_id,
|
|
333
|
+
subscriptionId: invoice?.subscription_id,
|
|
334
|
+
});
|
|
335
|
+
res.json(result);
|
|
336
|
+
} else {
|
|
337
|
+
res.status(404).json(null);
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error(err);
|
|
341
|
+
res.status(500).json({ error: `Failed to get payment intent refundable amount: ${err.message}` });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
232
345
|
export default router;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Joi } from '@arcblock/validator';
|
|
2
2
|
import { Router } from 'express';
|
|
3
3
|
import pick from 'lodash/pick';
|
|
4
|
+
import { Op } from 'sequelize';
|
|
4
5
|
import type { WhereOptions } from 'sequelize';
|
|
5
6
|
|
|
6
7
|
import { createListParamSchema } from '../libs/api';
|
|
@@ -153,7 +154,10 @@ router.post('/', auth, async (req, res) => {
|
|
|
153
154
|
});
|
|
154
155
|
|
|
155
156
|
// list payment links
|
|
156
|
-
const paginationSchema = createListParamSchema<{ active?: boolean }>({
|
|
157
|
+
const paginationSchema = createListParamSchema<{ active?: boolean; donation?: string }>({
|
|
158
|
+
active: Joi.boolean().empty(''),
|
|
159
|
+
donation: Joi.string().empty(''),
|
|
160
|
+
});
|
|
157
161
|
router.get('/', auth, async (req, res) => {
|
|
158
162
|
const { page, pageSize, ...query } = await paginationSchema.validateAsync(req.query, { stripUnknown: true });
|
|
159
163
|
const where: WhereOptions<PaymentLink> = { 'metadata.preview': null };
|
|
@@ -164,6 +168,9 @@ router.get('/', auth, async (req, res) => {
|
|
|
164
168
|
if (typeof query.livemode === 'boolean') {
|
|
165
169
|
where.livemode = query.livemode;
|
|
166
170
|
}
|
|
171
|
+
if (query.donation === 'hide') {
|
|
172
|
+
where.submit_type = { [Op.not]: 'donate' };
|
|
173
|
+
}
|
|
167
174
|
|
|
168
175
|
try {
|
|
169
176
|
const { rows: list, count } = await PaymentLink.findAndCountAll({
|
|
@@ -137,6 +137,7 @@ const paginationSchema = createListParamSchema<{
|
|
|
137
137
|
status: Joi.string().empty(''),
|
|
138
138
|
name: Joi.string().empty(''),
|
|
139
139
|
description: Joi.string().empty(''),
|
|
140
|
+
donation: Joi.string().empty(''),
|
|
140
141
|
});
|
|
141
142
|
router.get('/', auth, async (req, res) => {
|
|
142
143
|
const { page, pageSize, active, livemode, name, description, ...query } = await paginationSchema.validateAsync(
|
|
@@ -213,7 +214,7 @@ router.get('/search', auth, async (req, res) => {
|
|
|
213
214
|
});
|
|
214
215
|
|
|
215
216
|
// get product detail
|
|
216
|
-
router.get('/:id',
|
|
217
|
+
router.get('/:id', async (req, res) => {
|
|
217
218
|
const doc = await Product.expand(req.params.id as string);
|
|
218
219
|
if (doc) {
|
|
219
220
|
res.json(doc);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
1
2
|
/* eslint-disable consistent-return */
|
|
2
3
|
import { Router } from 'express';
|
|
3
4
|
import Joi from 'joi';
|
|
4
5
|
import pick from 'lodash/pick';
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
+
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
8
|
+
import { BNPositiveValidator, createListParamSchema, getWhereFromKvQuery } from '../libs/api';
|
|
7
9
|
import { authenticate } from '../libs/security';
|
|
8
10
|
import { formatMetadata } from '../libs/util';
|
|
9
11
|
import {
|
|
@@ -15,6 +17,8 @@ import {
|
|
|
15
17
|
Refund,
|
|
16
18
|
Subscription,
|
|
17
19
|
} from '../store/models';
|
|
20
|
+
import logger from '../libs/logger';
|
|
21
|
+
import { getRefundAmountSetup } from '../libs/refund';
|
|
18
22
|
|
|
19
23
|
const router = Router();
|
|
20
24
|
const authAdmin = authenticate<Invoice>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -103,6 +107,67 @@ router.get('/', auth, async (req, res) => {
|
|
|
103
107
|
});
|
|
104
108
|
|
|
105
109
|
const searchSchema = createListParamSchema<{}>({});
|
|
110
|
+
const refundRequestSchema = Joi.object({
|
|
111
|
+
amount: BNPositiveValidator.required(),
|
|
112
|
+
currency_id: Joi.string().required(),
|
|
113
|
+
customer_id: Joi.string().required(),
|
|
114
|
+
payment_method_id: Joi.string().required(),
|
|
115
|
+
payment_intent_id: Joi.string().required(),
|
|
116
|
+
reason: Joi.string()
|
|
117
|
+
.valid('duplicate', 'requested_by_customer', 'requested_by_admin', 'fraudulent', 'expired_uncaptured_charge')
|
|
118
|
+
.required(),
|
|
119
|
+
description: Joi.string().required(),
|
|
120
|
+
metadata: Joi.object().optional(),
|
|
121
|
+
invoice_id: Joi.string().optional(),
|
|
122
|
+
subscription_id: Joi.string().optional(),
|
|
123
|
+
});
|
|
124
|
+
router.post('/', authAdmin, async (req, res) => {
|
|
125
|
+
const { error } = refundRequestSchema.validate(req.body);
|
|
126
|
+
if (error) {
|
|
127
|
+
return res.status(400).json({ error: `Refund request invalid: ${error.message}` });
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const paymentCurrency = await PaymentCurrency.findByPk(req.body.currency_id);
|
|
131
|
+
if (!paymentCurrency) {
|
|
132
|
+
throw new Error('payment currency not found');
|
|
133
|
+
}
|
|
134
|
+
const amount = fromTokenToUnit(req.body.amount, paymentCurrency?.decimal).toString();
|
|
135
|
+
const amountBN = new BN(amount);
|
|
136
|
+
const result = await getRefundAmountSetup({
|
|
137
|
+
paymentIntentId: req.body.payment_intent_id,
|
|
138
|
+
});
|
|
139
|
+
if (amountBN.gt(new BN(result.amount))) {
|
|
140
|
+
throw new Error('refund amount exceeds the available amount');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const item = await Refund.create({
|
|
144
|
+
...req.body,
|
|
145
|
+
type: 'refund',
|
|
146
|
+
livemode: !!req.livemode,
|
|
147
|
+
amount,
|
|
148
|
+
metadata: formatMetadata(req.body.metadata),
|
|
149
|
+
attempt_count: 0,
|
|
150
|
+
attempted: false,
|
|
151
|
+
next_attempt: 0,
|
|
152
|
+
last_attempt_error: null,
|
|
153
|
+
starting_balance: '0',
|
|
154
|
+
ending_balance: '0',
|
|
155
|
+
status: 'pending',
|
|
156
|
+
starting_token_balance: {},
|
|
157
|
+
ending_token_balance: {},
|
|
158
|
+
});
|
|
159
|
+
logger.info('refund created', {
|
|
160
|
+
...req.params,
|
|
161
|
+
...req.body,
|
|
162
|
+
result: item.toJSON(),
|
|
163
|
+
});
|
|
164
|
+
res.json(item);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
logger.error('create refund error', err);
|
|
167
|
+
res.status(400).json({ error: err.message });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
106
171
|
router.get('/search', auth, async (req, res) => {
|
|
107
172
|
const { page, pageSize, livemode, q, o } = await searchSchema.validateAsync(req.query, {
|
|
108
173
|
stripUnknown: false,
|
|
@@ -153,6 +153,10 @@ export type TPaymentIntentExpanded = TPaymentIntent & {
|
|
|
153
153
|
checkoutSession?: TCheckoutSession;
|
|
154
154
|
subscription?: TSubscription;
|
|
155
155
|
invoice?: TInvoice;
|
|
156
|
+
refund?: {
|
|
157
|
+
amount: string;
|
|
158
|
+
totalAmount: string;
|
|
159
|
+
};
|
|
156
160
|
};
|
|
157
161
|
|
|
158
162
|
export type TSubscriptionExpanded = TSubscription & {
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.298",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@arcblock/validator": "^1.18.124",
|
|
53
53
|
"@blocklet/js-sdk": "1.16.28",
|
|
54
54
|
"@blocklet/logger": "1.16.28",
|
|
55
|
-
"@blocklet/payment-react": "1.13.
|
|
55
|
+
"@blocklet/payment-react": "1.13.298",
|
|
56
56
|
"@blocklet/sdk": "1.16.28",
|
|
57
57
|
"@blocklet/ui-react": "^2.10.3",
|
|
58
58
|
"@blocklet/uploader": "^0.1.18",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@abtnode/types": "1.16.28",
|
|
120
120
|
"@arcblock/eslint-config-ts": "^0.3.2",
|
|
121
|
-
"@blocklet/payment-types": "1.13.
|
|
121
|
+
"@blocklet/payment-types": "1.13.298",
|
|
122
122
|
"@types/cookie-parser": "^1.4.7",
|
|
123
123
|
"@types/cors": "^2.8.17",
|
|
124
124
|
"@types/debug": "^4.1.12",
|
|
@@ -158,5 +158,5 @@
|
|
|
158
158
|
"parser": "typescript"
|
|
159
159
|
}
|
|
160
160
|
},
|
|
161
|
-
"gitHead": "
|
|
161
|
+
"gitHead": "85dd6105a19723e06405eebb4bcc6ffddc2cd7aa"
|
|
162
162
|
}
|
package/scripts/sdk.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// run sdk test: # blocklet exec /scripts/sdk.js --app-id=appid
|
|
2
|
+
|
|
3
|
+
/* eslint-disable import/no-extraneous-dependencies */
|
|
4
|
+
/* eslint-disable no-console */
|
|
5
|
+
const payment = require('@blocklet/payment-js').default;
|
|
6
|
+
|
|
7
|
+
(async () => {
|
|
8
|
+
payment.environments.setTestMode(true);
|
|
9
|
+
const paymentIntent = await payment.paymentIntents.retrieve('pi_ybTOCWweEnb9grWZsTH7MCVi');
|
|
10
|
+
|
|
11
|
+
console.log('paymentIntent', paymentIntent);
|
|
12
|
+
|
|
13
|
+
// const refundResult = await payment.paymentIntents.refund('pi_ybTOCWweEnb9grWZsTH7MCVi', {
|
|
14
|
+
// amount: '0.001',
|
|
15
|
+
// reason: 'requested_by_customer',
|
|
16
|
+
// description: 'Refund Test',
|
|
17
|
+
// });
|
|
18
|
+
// console.log('refundResult', refundResult);
|
|
19
|
+
|
|
20
|
+
const refund = await payment.refunds.retrieve('re_loHv143R78cSe38uGjxRBsfv');
|
|
21
|
+
console.log('🚀 ~ refund:', refund);
|
|
22
|
+
|
|
23
|
+
const refunds = await payment.refunds.list({
|
|
24
|
+
invoice_id: 'in_EidRR3yL3PL5tnOgd17eyGJr',
|
|
25
|
+
});
|
|
26
|
+
console.log('🚀 ~ refunds:', refunds);
|
|
27
|
+
|
|
28
|
+
// const customRefundResult = await payment.refunds.create({
|
|
29
|
+
// amount: '0.001',
|
|
30
|
+
// reason: 'requested_by_admin',
|
|
31
|
+
// description: 'Custom Refund Test',
|
|
32
|
+
// invoice_id: paymentIntent.invoice_id,
|
|
33
|
+
// payment_intent_id: paymentIntent.id,
|
|
34
|
+
// currency_id: paymentIntent.currency_id,
|
|
35
|
+
// payment_method_id: paymentIntent.payment_method_id,
|
|
36
|
+
// customer_id: paymentIntent.customer_id,
|
|
37
|
+
// });
|
|
38
|
+
// console.log('🚀 ~ customRefundResult:', customRefundResult);
|
|
39
|
+
|
|
40
|
+
process.exit(0);
|
|
41
|
+
})();
|
|
@@ -1,36 +1,176 @@
|
|
|
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 { ConfirmDialog, api, formatBNStr, formatError } from '@blocklet/payment-react';
|
|
4
4
|
import type { TPaymentIntentExpanded } from '@blocklet/payment-types';
|
|
5
|
-
import { useSetState } from 'ahooks';
|
|
5
|
+
import { useRequest, useSetState } from 'ahooks';
|
|
6
6
|
import { useNavigate } from 'react-router-dom';
|
|
7
7
|
import type { LiteralUnion } from 'type-fest';
|
|
8
|
+
import {
|
|
9
|
+
FormControl,
|
|
10
|
+
FormControlLabel,
|
|
11
|
+
FormHelperText,
|
|
12
|
+
InputAdornment,
|
|
13
|
+
Radio,
|
|
14
|
+
RadioGroup,
|
|
15
|
+
Stack,
|
|
16
|
+
TextField,
|
|
17
|
+
} from '@mui/material';
|
|
18
|
+
import { Controller, FormProvider, useForm, useFormContext } from 'react-hook-form';
|
|
8
19
|
|
|
20
|
+
import { useState } from 'react';
|
|
9
21
|
import Actions from '../actions';
|
|
10
22
|
import ClickBoundary from '../click-boundary';
|
|
11
23
|
|
|
12
24
|
type Props = {
|
|
13
25
|
data: TPaymentIntentExpanded;
|
|
14
26
|
variant?: LiteralUnion<'compact' | 'normal', string>;
|
|
27
|
+
onChange: (action: string) => void;
|
|
15
28
|
};
|
|
16
29
|
|
|
30
|
+
PaymentIntentActionsInner.defaultProps = {
|
|
31
|
+
variant: 'compact',
|
|
32
|
+
};
|
|
17
33
|
PaymentIntentActions.defaultProps = {
|
|
18
34
|
variant: 'compact',
|
|
19
35
|
};
|
|
20
36
|
|
|
21
|
-
|
|
37
|
+
const fetchRefundData = (id: string) => {
|
|
38
|
+
return api.get(`/api/payment-intents/${id}/refundable-amount`).then((res) => res.data);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function RefundForm({ data, refundMaxAmount }: { data: TPaymentIntentExpanded; refundMaxAmount: string }) {
|
|
42
|
+
const { t } = useLocaleContext();
|
|
43
|
+
const { control, getFieldState, setValue } = useFormContext();
|
|
44
|
+
|
|
45
|
+
const positive = (v: number) => {
|
|
46
|
+
return Number(v) > 0 && Number(v) <= Number(refundMaxAmount);
|
|
47
|
+
};
|
|
48
|
+
return (
|
|
49
|
+
<Stack direction="column" spacing={1} alignItems="flex-start" sx={{ width: 400 }}>
|
|
50
|
+
<Controller
|
|
51
|
+
name="refund.reason"
|
|
52
|
+
control={control}
|
|
53
|
+
render={({ field }) => (
|
|
54
|
+
<RadioGroup {...field} sx={{ ml: '5px !important' }}>
|
|
55
|
+
<FormControlLabel
|
|
56
|
+
value="duplicate"
|
|
57
|
+
control={<Radio checked={field.value === 'duplicate'} />}
|
|
58
|
+
label={t('admin.paymentIntent.refundForm.duplicate')}
|
|
59
|
+
onClick={() => setValue('refund.reason', 'duplicate')}
|
|
60
|
+
/>
|
|
61
|
+
<FormControlLabel
|
|
62
|
+
value="requested_by_customer"
|
|
63
|
+
control={<Radio checked={field.value === 'requested_by_customer'} />}
|
|
64
|
+
label={t('admin.paymentIntent.refundForm.requestedByCustomer')}
|
|
65
|
+
onClick={() => setValue('refund.reason', 'requested_by_customer')}
|
|
66
|
+
/>
|
|
67
|
+
<FormControlLabel
|
|
68
|
+
value="requested_by_admin"
|
|
69
|
+
control={<Radio checked={field.value === 'requested_by_admin'} />}
|
|
70
|
+
label={t('admin.paymentIntent.refundForm.requestedByAdmin')}
|
|
71
|
+
onClick={() => setValue('refund.reason', 'requested_by_admin')}
|
|
72
|
+
/>
|
|
73
|
+
<FormControlLabel
|
|
74
|
+
value="fraudulent"
|
|
75
|
+
control={<Radio checked={field.value === 'fraudulent'} />}
|
|
76
|
+
label={t('admin.paymentIntent.refundForm.fraudulent')}
|
|
77
|
+
onClick={() => setValue('refund.reason', 'fraudulent')}
|
|
78
|
+
/>
|
|
79
|
+
<FormControlLabel
|
|
80
|
+
value="expired_uncaptured_charge"
|
|
81
|
+
control={<Radio checked={field.value === 'expired_uncaptured_charge'} />}
|
|
82
|
+
label={t('admin.paymentIntent.refundForm.expiredUncapturedCharge')}
|
|
83
|
+
onClick={() => setValue('refund.reason', 'expired_uncaptured_charge')}
|
|
84
|
+
/>
|
|
85
|
+
</RadioGroup>
|
|
86
|
+
)}
|
|
87
|
+
/>
|
|
88
|
+
|
|
89
|
+
<FormControl fullWidth component="fieldset" variant="outlined">
|
|
90
|
+
<Controller
|
|
91
|
+
name="refund.amount"
|
|
92
|
+
control={control}
|
|
93
|
+
render={({ field }) => (
|
|
94
|
+
<TextField
|
|
95
|
+
{...field}
|
|
96
|
+
variant="outlined"
|
|
97
|
+
size="small"
|
|
98
|
+
type="number"
|
|
99
|
+
fullWidth
|
|
100
|
+
placeholder={t('admin.paymentIntent.refundForm.amount')}
|
|
101
|
+
error={!!getFieldState('refund.amount').error || !positive(field.value)}
|
|
102
|
+
helperText={
|
|
103
|
+
!positive(field.value)
|
|
104
|
+
? t('admin.paymentIntent.refundForm.amountRange', {
|
|
105
|
+
min: 0,
|
|
106
|
+
max: refundMaxAmount,
|
|
107
|
+
symbol: data.paymentCurrency.symbol,
|
|
108
|
+
})
|
|
109
|
+
: getFieldState('refund.amount').error?.message
|
|
110
|
+
}
|
|
111
|
+
InputProps={{
|
|
112
|
+
endAdornment: <InputAdornment position="end">{data.paymentCurrency.symbol}</InputAdornment>,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
/>
|
|
117
|
+
<FormHelperText>
|
|
118
|
+
{t('admin.paymentIntent.refundForm.amountHelper', {
|
|
119
|
+
min: 0,
|
|
120
|
+
max: refundMaxAmount,
|
|
121
|
+
symbol: data.paymentCurrency.symbol,
|
|
122
|
+
})}
|
|
123
|
+
</FormHelperText>
|
|
124
|
+
</FormControl>
|
|
125
|
+
|
|
126
|
+
<Controller
|
|
127
|
+
name="refund.description"
|
|
128
|
+
control={control}
|
|
129
|
+
render={({ field }) => (
|
|
130
|
+
<TextField
|
|
131
|
+
{...field}
|
|
132
|
+
variant="outlined"
|
|
133
|
+
size="small"
|
|
134
|
+
fullWidth
|
|
135
|
+
multiline
|
|
136
|
+
minRows={2}
|
|
137
|
+
maxRows={4}
|
|
138
|
+
placeholder={t('admin.paymentIntent.refundForm.description')}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
/>
|
|
142
|
+
</Stack>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function PaymentIntentActionsInner({ data, variant, onChange }: Props) {
|
|
22
147
|
const { t } = useLocaleContext();
|
|
23
148
|
const navigate = useNavigate();
|
|
149
|
+
const { reset, getValues, setValue } = useFormContext();
|
|
24
150
|
const [state, setState] = useSetState({
|
|
25
151
|
action: '',
|
|
26
152
|
loading: false,
|
|
27
153
|
});
|
|
154
|
+
const [refundMaxAmount, setRefundMaxAmount] = useState('0');
|
|
155
|
+
const { runAsync: runRefundAmountAsync } = useRequest(() => fetchRefundData(data.id), {
|
|
156
|
+
onSuccess: (res) => {
|
|
157
|
+
const amount = formatBNStr(res?.amount, data.paymentCurrency.decimal);
|
|
158
|
+
setRefundMaxAmount(amount);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
28
161
|
|
|
29
162
|
const onRefund = async () => {
|
|
163
|
+
const { refund } = getValues();
|
|
164
|
+
if (!refund.amount || !refund.reason || !refund.description) {
|
|
165
|
+
Toast.warning(t('admin.paymentIntent.refundForm.required'));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
30
168
|
try {
|
|
31
169
|
setState({ loading: true });
|
|
32
|
-
await api.put(`/api/payment-intents/${data.id}/refund
|
|
33
|
-
Toast.success(t('
|
|
170
|
+
await api.put(`/api/payment-intents/${data.id}/refund`, refund).then((res) => res.data);
|
|
171
|
+
Toast.success(t('admin.paymentIntent.refundSuccess'));
|
|
172
|
+
onChange('refund');
|
|
173
|
+
runRefundAmountAsync();
|
|
34
174
|
} catch (err) {
|
|
35
175
|
console.error(err);
|
|
36
176
|
Toast.error(formatError(err));
|
|
@@ -42,9 +182,20 @@ export default function PaymentIntentActions({ data, variant }: Props) {
|
|
|
42
182
|
const actions = [
|
|
43
183
|
{
|
|
44
184
|
label: t('admin.paymentIntent.refund'),
|
|
45
|
-
handler: () =>
|
|
185
|
+
handler: () => {
|
|
186
|
+
runRefundAmountAsync().then((res) => {
|
|
187
|
+
reset();
|
|
188
|
+
const curAmount = formatBNStr(res?.amount, data.paymentCurrency.decimal);
|
|
189
|
+
if (Number(curAmount) <= 0) {
|
|
190
|
+
Toast.info(t('admin.paymentIntent.refund.empty'));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
setValue('refund.amount', curAmount);
|
|
194
|
+
setState({ action: 'refund' });
|
|
195
|
+
});
|
|
196
|
+
},
|
|
46
197
|
color: 'primary',
|
|
47
|
-
disabled:
|
|
198
|
+
disabled: Number(refundMaxAmount) <= 0,
|
|
48
199
|
divider: true,
|
|
49
200
|
},
|
|
50
201
|
{
|
|
@@ -71,10 +222,26 @@ export default function PaymentIntentActions({ data, variant }: Props) {
|
|
|
71
222
|
onConfirm={onRefund}
|
|
72
223
|
onCancel={() => setState({ action: '' })}
|
|
73
224
|
title={t('admin.paymentIntent.refund')}
|
|
74
|
-
message={
|
|
225
|
+
message={<RefundForm data={data} refundMaxAmount={refundMaxAmount} />}
|
|
75
226
|
loading={state.loading}
|
|
76
227
|
/>
|
|
77
228
|
)}
|
|
78
229
|
</ClickBoundary>
|
|
79
230
|
);
|
|
80
231
|
}
|
|
232
|
+
|
|
233
|
+
export default function PaymentIntentActions(props: Props) {
|
|
234
|
+
const methods = useForm({
|
|
235
|
+
defaultValues: {
|
|
236
|
+
refund: {
|
|
237
|
+
reason: 'requested_by_admin',
|
|
238
|
+
description: '',
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
return (
|
|
243
|
+
<FormProvider {...methods}>
|
|
244
|
+
<PaymentIntentActionsInner {...props} />
|
|
245
|
+
</FormProvider>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -84,11 +84,15 @@ export default function PaymentList({ customer_id, invoice_id, features }: ListP
|
|
|
84
84
|
|
|
85
85
|
const [data, setData] = useState({}) as any;
|
|
86
86
|
|
|
87
|
+
const fetchListData = () => {
|
|
88
|
+
fetchData(search).then((res: any) => {
|
|
89
|
+
setData(res);
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
87
93
|
useEffect(() => {
|
|
88
94
|
debounce(() => {
|
|
89
|
-
|
|
90
|
-
setData(res);
|
|
91
|
-
});
|
|
95
|
+
fetchListData();
|
|
92
96
|
}, 300)();
|
|
93
97
|
}, [search]);
|
|
94
98
|
|
|
@@ -176,7 +180,7 @@ export default function PaymentList({ customer_id, invoice_id, features }: ListP
|
|
|
176
180
|
options: {
|
|
177
181
|
customBodyRenderLite: (_: string, index: number) => {
|
|
178
182
|
const item = data.list[index] as TPaymentIntentExpanded;
|
|
179
|
-
return <PaymentIntentActions data={item} />;
|
|
183
|
+
return <PaymentIntentActions data={item} onChange={fetchListData} />;
|
|
180
184
|
},
|
|
181
185
|
},
|
|
182
186
|
},
|
package/src/locales/en.tsx
CHANGED
|
@@ -247,6 +247,22 @@ export default flat({
|
|
|
247
247
|
refund: 'Refund payment',
|
|
248
248
|
received: 'Received',
|
|
249
249
|
attention: 'Failed payments',
|
|
250
|
+
refundError: 'Failed to refund payment',
|
|
251
|
+
refundSuccess: 'Refund application has been successfully created',
|
|
252
|
+
refundForm: {
|
|
253
|
+
reason: 'Refund reason',
|
|
254
|
+
amount: 'Refund amount',
|
|
255
|
+
description: 'Refund description',
|
|
256
|
+
duplicate: 'Duplicate payment',
|
|
257
|
+
requestedByCustomer: 'Requested by customer',
|
|
258
|
+
requestedByAdmin: 'Requested by admin',
|
|
259
|
+
fraudulent: 'Fraudulent',
|
|
260
|
+
expiredUncapturedCharge: 'Expired uncaptured charge',
|
|
261
|
+
amountRange: 'Refund amount must be between {min} and {max} {symbol}',
|
|
262
|
+
amountHelper: 'Refund amount must be less than or equal to {max} {symbol}',
|
|
263
|
+
required: 'please fill in the refund information',
|
|
264
|
+
empty: 'The current order has been fully refunded',
|
|
265
|
+
},
|
|
250
266
|
},
|
|
251
267
|
payout: {
|
|
252
268
|
list: 'Payouts',
|
package/src/locales/zh.tsx
CHANGED
|
@@ -246,6 +246,22 @@ export default flat({
|
|
|
246
246
|
refund: '退款',
|
|
247
247
|
received: '实收金额',
|
|
248
248
|
attention: '失败的付款',
|
|
249
|
+
refundError: '退款申请失败',
|
|
250
|
+
refundSuccess: '退款申请已成功创建',
|
|
251
|
+
refundForm: {
|
|
252
|
+
reason: '退款原因',
|
|
253
|
+
amount: '退款金额',
|
|
254
|
+
description: '退款说明',
|
|
255
|
+
duplicate: '订单因重复下单已被取消并退款',
|
|
256
|
+
requestedByCustomer: '订单已被客户取消并退款',
|
|
257
|
+
requestedByAdmin: '订单已被管理员取消并退款',
|
|
258
|
+
fraudulent: '订单因涉嫌欺诈已被取消并退款',
|
|
259
|
+
expiredUncapturedCharge: '订单因支付未完成已被取消并退款',
|
|
260
|
+
amountRange: '退款金额必须在 {min} {symbol} 到 {max} {symbol} 之间',
|
|
261
|
+
amountHelper: '目前最大退款金额为{max} {symbol}',
|
|
262
|
+
required: '请完整填写退款信息',
|
|
263
|
+
empty: '当前订单已全部退款完成',
|
|
264
|
+
},
|
|
249
265
|
},
|
|
250
266
|
paymentMethod: {
|
|
251
267
|
_name: '支付方式',
|
|
@@ -31,6 +31,7 @@ import PaymentIntentActions from '../../../../components/payment-intent/actions'
|
|
|
31
31
|
import PayoutList from '../../../../components/payouts/list';
|
|
32
32
|
import SectionHeader from '../../../../components/section/header';
|
|
33
33
|
import { goBackOrFallback } from '../../../../libs/util';
|
|
34
|
+
import RefundList from '../../../../components/refund/list';
|
|
34
35
|
|
|
35
36
|
const fetchData = (id: string): Promise<TPaymentIntentExpanded> => {
|
|
36
37
|
return api.get(`/api/payment-intents/${id}`).then((res) => res.data);
|
|
@@ -105,7 +106,7 @@ export default function PaymentIntentDetail(props: { id: string }) {
|
|
|
105
106
|
<Amount amount={received} sx={{ my: 0, fontSize: '2rem', lineHeight: '1rem' }} />
|
|
106
107
|
<Status label={data.status} color={getPaymentIntentStatusColor(data.status)} sx={{ ml: 2 }} />
|
|
107
108
|
</Stack>
|
|
108
|
-
<PaymentIntentActions data={data} variant="normal" />
|
|
109
|
+
<PaymentIntentActions data={data} variant="normal" onChange={runAsync} />
|
|
109
110
|
</Stack>
|
|
110
111
|
<Stack
|
|
111
112
|
className="section-body"
|
|
@@ -217,6 +218,12 @@ export default function PaymentIntentDetail(props: { id: string }) {
|
|
|
217
218
|
<PayoutList features={{ toolbar: false }} payment_intent_id={data.id} />
|
|
218
219
|
</Box>
|
|
219
220
|
</Box>
|
|
221
|
+
<Box className="section">
|
|
222
|
+
<SectionHeader title={t('admin.refunds')} mb={0} />
|
|
223
|
+
<Box className="section-body">
|
|
224
|
+
<RefundList features={{ customer: false, toolbar: false }} invoice_id={data.invoice_id} />
|
|
225
|
+
</Box>
|
|
226
|
+
</Box>
|
|
220
227
|
<Box className="section">
|
|
221
228
|
<SectionHeader title={t('admin.events')} />
|
|
222
229
|
<Box className="section-body">
|
|
@@ -30,10 +30,11 @@ function PaymentLinks() {
|
|
|
30
30
|
const { settings } = usePaymentContext();
|
|
31
31
|
|
|
32
32
|
const persisted = getDurableData(listKey);
|
|
33
|
-
const [search, setSearch] = useState<{ active: string; pageSize: number; page: number }>({
|
|
33
|
+
const [search, setSearch] = useState<{ active: string; pageSize: number; page: number; donation?: string }>({
|
|
34
34
|
active: '',
|
|
35
35
|
pageSize: persisted.rowsPerPage || 20,
|
|
36
36
|
page: persisted.page ? persisted.page + 1 : 1,
|
|
37
|
+
donation: 'hide',
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
const { loading, error, data, refresh } = useRequest(() => fetchData(search));
|