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.
@@ -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
- if (invoice?.metadata?.stripe_id) {
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 }>({ active: Joi.boolean().empty('') });
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', auth, async (req, res) => {
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 { createListParamSchema, getWhereFromKvQuery } from '../libs/api';
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
@@ -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.13.296
17
+ version: 1.13.298
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.13.296",
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.296",
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.296",
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": "e0f0556ce4ed8833464248e00250c09dbde2c5a9"
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
- export default function PaymentIntentActions({ data, variant }: Props) {
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`).then((res) => res.data);
33
- Toast.success(t('common.saved'));
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: () => setState({ action: 'refund' }),
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: true,
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={t('admin.paymentIntent.refundTip')}
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
- fetchData(search).then((res: any) => {
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
  },
@@ -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',
@@ -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));