payment-kit 1.14.5 → 1.14.7

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.
@@ -24,6 +24,9 @@ export async function getRefundAmountSetup({
24
24
  [Op.not]: 'canceled',
25
25
  },
26
26
  payment_intent_id: paymentIntentId,
27
+ amount: {
28
+ [Op.gt]: '0',
29
+ },
27
30
  };
28
31
  if (currencyId) {
29
32
  where.currency_id = currencyId;
@@ -248,3 +248,15 @@ export function getConnectQueryParam({ userDid }: { userDid: string }): {
248
248
  '__did-connect__': Buffer.from(JSON.stringify(data), 'utf8').toString('base64'),
249
249
  };
250
250
  }
251
+
252
+ export function formatAmountPrecisionLimit(
253
+ amount: string,
254
+ precision: number = 6,
255
+ amountLabel: LiteralUnion<'Amount' | 'Price', string> = 'Amount'
256
+ ) {
257
+ const [, decimal] = amount.split('.');
258
+ if (decimal && decimal.length > precision) {
259
+ return `${amountLabel} decimal places must be less than or equal to ${precision}`;
260
+ }
261
+ return '';
262
+ }
@@ -42,7 +42,7 @@ import {
42
42
  getDaysUntilDue,
43
43
  getSubscriptionCreateSetup,
44
44
  } from '../libs/subscription';
45
- import { CHECKOUT_SESSION_TTL, formatMetadata, getDataObjectFromQuery } from '../libs/util';
45
+ import { CHECKOUT_SESSION_TTL, formatAmountPrecisionLimit, formatMetadata, getDataObjectFromQuery } from '../libs/util';
46
46
  import { invoiceQueue } from '../queues/invoice';
47
47
  import { paymentQueue } from '../queues/payment';
48
48
  import type { LineItem, TPriceExpanded, TProductExpanded } from '../store/models';
@@ -1189,6 +1189,14 @@ router.put('/:id/amount', ensureCheckoutSessionOpen, async (req, res) => {
1189
1189
  if (input > Number(maximum)) {
1190
1190
  return res.status(400).json({ error: 'Custom amount should not be smaller than maximum' });
1191
1191
  }
1192
+ const precisionError = formatAmountPrecisionLimit(
1193
+ input.toString(),
1194
+ currency.maximum_precision || 6,
1195
+ 'Custom amount'
1196
+ );
1197
+ if (precisionError) {
1198
+ return res.status(400).json({ error: precisionError });
1199
+ }
1192
1200
  } else if (presets?.some((x) => Number(x) === input) === false) {
1193
1201
  return res.status(400).json({ error: 'Custom amount must be one of the presets' });
1194
1202
  }
@@ -246,11 +246,12 @@ const refundRequestSchema = Joi.object({
246
246
  metadata: Joi.object().optional(),
247
247
  });
248
248
 
249
+ // eslint-disable-next-line consistent-return
249
250
  router.put('/:id/refund', authAdmin, async (req, res) => {
250
251
  try {
251
252
  const { error } = refundRequestSchema.validate(req.body);
252
253
  if (error) {
253
- res.status(400).json({ error: `payment intent refund request invalid: ${error.message}` });
254
+ return res.status(400).json({ error: `payment intent refund request invalid: ${error.message}` });
254
255
  }
255
256
  const doc = await PaymentIntent.findOne({
256
257
  where: { id: req.params.id },
@@ -106,8 +106,8 @@ router.post('/', auth, async (req, res) => {
106
106
  raw.settings.ethereum.chain_id = network.chainId.toString();
107
107
  logger.info('ethereum api endpoint verified', { settings: raw.settings.ethereum, network, blockNumber });
108
108
  } catch (err) {
109
- console.error(err);
110
- return res.status(400).json({ error: 'ethereum api_host is required' });
109
+ logger.error('verify ethereum api endpoint failed', err);
110
+ return res.status(400).json({ error: err.message });
111
111
  }
112
112
 
113
113
  const exist = await PaymentMethod.findOne({
@@ -8,7 +8,7 @@ import { PaymentMethod } from '../store/models/payment-method';
8
8
  const router = Router();
9
9
 
10
10
  router.get('/', async (req, res) => {
11
- const attributes = ['id', 'name', 'symbol', 'decimal', 'logo', 'payment_method_id'];
11
+ const attributes = ['id', 'name', 'symbol', 'decimal', 'logo', 'payment_method_id', 'maximum_precision'];
12
12
  const where: WhereOptions<PaymentMethod> = { livemode: req.livemode, active: true };
13
13
 
14
14
  const methods = await PaymentMethod.findAll({
@@ -26,7 +26,7 @@ router.get('/', async (req, res) => {
26
26
 
27
27
  res.json({
28
28
  paymentMethods: methods.map((x) =>
29
- pick(x, ['id', 'name', 'type', 'logo', 'payment_currencies', 'default_currency_id'])
29
+ pick(x, ['id', 'name', 'type', 'logo', 'payment_currencies', 'default_currency_id', 'maximum_precision'])
30
30
  ),
31
31
  baseCurrency: await PaymentCurrency.findOne({
32
32
  where: { is_base_currency: true, livemode: req.livemode },
@@ -0,0 +1,22 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { Migration, safeApplyColumnChanges } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ await safeApplyColumnChanges(context, {
7
+ payment_links: [
8
+ {
9
+ name: 'maximum_precision',
10
+ field: {
11
+ type: DataTypes.NUMBER,
12
+ allowNull: true,
13
+ defaultValue: 6,
14
+ },
15
+ },
16
+ ],
17
+ });
18
+ };
19
+
20
+ export const down: Migration = async ({ context }) => {
21
+ await context.removeColumn('payment_currencies', 'maximum_precision');
22
+ };
@@ -0,0 +1,26 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { Migration, safeApplyColumnChanges } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ await safeApplyColumnChanges(context, {
7
+ payment_currencies: [
8
+ {
9
+ name: 'maximum_precision',
10
+ field: {
11
+ type: DataTypes.NUMBER,
12
+ allowNull: true,
13
+ defaultValue: 6,
14
+ },
15
+ },
16
+ ],
17
+ });
18
+ const schema = await context.describeTable('payment_links');
19
+ if (schema.maximum_precision) {
20
+ await context.removeColumn('payment_links', 'maximum_precision');
21
+ }
22
+ };
23
+
24
+ export const down: Migration = async ({ context }) => {
25
+ await context.removeColumn('payment_currencies', 'maximum_precision');
26
+ };
@@ -29,6 +29,7 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
29
29
  declare logo: string;
30
30
  declare symbol: string;
31
31
  declare decimal: number;
32
+ declare maximum_precision?: number;
32
33
 
33
34
  declare minimum_payment_amount: string;
34
35
  declare maximum_payment_amount: string;
@@ -87,6 +88,11 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
87
88
  type: DataTypes.NUMBER,
88
89
  defaultValue: 2,
89
90
  },
91
+ maximum_precision: {
92
+ type: DataTypes.NUMBER,
93
+ allowNull: true,
94
+ defaultValue: 6,
95
+ },
90
96
  minimum_payment_amount: {
91
97
  type: DataTypes.STRING(32),
92
98
  defaultValue: '0',
@@ -5,6 +5,7 @@ import dayjs from '../../src/libs/dayjs';
5
5
  import {
6
6
  createCodeGenerator,
7
7
  createIdGenerator,
8
+ formatAmountPrecisionLimit,
8
9
  formatMetadata,
9
10
  getDataObjectFromQuery,
10
11
  getNextRetry,
@@ -202,3 +203,40 @@ describe('getWhereFromKvQuery', () => {
202
203
  });
203
204
  });
204
205
  });
206
+
207
+ describe('formatAmountPrecisionLimit', () => {
208
+ it('should return an empty string if the decimal places are within the precision limit', () => {
209
+ const result = formatAmountPrecisionLimit('123.456', 6);
210
+ expect(result).toBe('');
211
+ });
212
+
213
+ it('should return an error message if the decimal places exceed the precision limit', () => {
214
+ const result = formatAmountPrecisionLimit('123.456789', 5);
215
+ expect(result).toBe('Amount decimal places must be less than or equal to 5');
216
+ });
217
+
218
+ it('should use the default precision of 6 if not provided', () => {
219
+ const result = formatAmountPrecisionLimit('123.456789');
220
+ expect(result).toBe('');
221
+ });
222
+
223
+ it('should use the provided amountLabel in the error message', () => {
224
+ const result = formatAmountPrecisionLimit('123.456789', 5, 'Price');
225
+ expect(result).toBe('Price decimal places must be less than or equal to 5');
226
+ });
227
+
228
+ it('should return an empty string if there are no decimal places', () => {
229
+ const result = formatAmountPrecisionLimit('123');
230
+ expect(result).toBe('');
231
+ });
232
+
233
+ it('should handle an empty string as amount', () => {
234
+ const result = formatAmountPrecisionLimit('');
235
+ expect(result).toBe('');
236
+ });
237
+
238
+ it('should handle an amount with no decimal part', () => {
239
+ const result = formatAmountPrecisionLimit('123.');
240
+ expect(result).toBe('');
241
+ });
242
+ });
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.14.5
17
+ version: 1.14.7
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.14.5",
3
+ "version": "1.14.7",
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.14.5",
55
+ "@blocklet/payment-react": "1.14.7",
56
56
  "@blocklet/sdk": "1.16.28",
57
57
  "@blocklet/ui-react": "^2.10.3",
58
58
  "@blocklet/uploader": "^0.1.20",
@@ -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.14.5",
121
+ "@blocklet/payment-types": "1.14.7",
122
122
  "@types/cookie-parser": "^1.4.7",
123
123
  "@types/cors": "^2.8.17",
124
124
  "@types/debug": "^4.1.12",
@@ -160,5 +160,5 @@
160
160
  "parser": "typescript"
161
161
  }
162
162
  },
163
- "gitHead": "e4715805a87b2e4e3622564f4bc26578f19211e4"
163
+ "gitHead": "683097ae01a97a7b303194d68cdabbae24201c14"
164
164
  }
@@ -1,6 +1,6 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import Toast from '@arcblock/ux/lib/Toast';
3
- import { ConfirmDialog, api, formatBNStr, formatError } from '@blocklet/payment-react';
3
+ import { ConfirmDialog, api, formatAmountPrecisionLimit, formatBNStr, formatError } from '@blocklet/payment-react';
4
4
  import type { TPaymentIntentExpanded } from '@blocklet/payment-types';
5
5
  import { useRequest, useSetState } from 'ahooks';
6
6
  import { useNavigate } from 'react-router-dom';
@@ -39,11 +39,26 @@ const fetchRefundData = (id: string) => {
39
39
  };
40
40
 
41
41
  function RefundForm({ data, refundMaxAmount }: { data: TPaymentIntentExpanded; refundMaxAmount: string }) {
42
- const { t } = useLocaleContext();
42
+ const { t, locale } = useLocaleContext();
43
43
  const { control, getFieldState, setValue } = useFormContext();
44
44
 
45
- const positive = (v: number) => {
46
- return Number(v) > 0 && Number(v) <= Number(refundMaxAmount);
45
+ const validateAmount = (v: number) => {
46
+ if (Number(v) <= 0 || Number(v) > Number(refundMaxAmount)) {
47
+ return t('admin.paymentIntent.refundForm.amountRange', {
48
+ min: 0,
49
+ max: refundMaxAmount,
50
+ symbol: data.paymentCurrency.symbol,
51
+ });
52
+ }
53
+ const validPrecision = formatAmountPrecisionLimit(
54
+ v.toString(),
55
+ locale,
56
+ data.paymentCurrency.maximum_precision || 6
57
+ );
58
+ if (validPrecision) {
59
+ return validPrecision;
60
+ }
61
+ return true;
47
62
  };
48
63
  return (
49
64
  <Stack direction="column" spacing={1} alignItems="flex-start" sx={{ width: 400 }}>
@@ -90,6 +105,9 @@ function RefundForm({ data, refundMaxAmount }: { data: TPaymentIntentExpanded; r
90
105
  <Controller
91
106
  name="refund.amount"
92
107
  control={control}
108
+ rules={{
109
+ validate: validateAmount,
110
+ }}
93
111
  render={({ field }) => (
94
112
  <TextField
95
113
  {...field}
@@ -98,16 +116,8 @@ function RefundForm({ data, refundMaxAmount }: { data: TPaymentIntentExpanded; r
98
116
  type="number"
99
117
  fullWidth
100
118
  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
- }
119
+ error={!!getFieldState('refund.amount').error}
120
+ helperText={getFieldState('refund.amount').error?.message}
111
121
  InputProps={{
112
122
  endAdornment: <InputAdornment position="end">{data.paymentCurrency.symbol}</InputAdornment>,
113
123
  }}
@@ -126,6 +136,9 @@ function RefundForm({ data, refundMaxAmount }: { data: TPaymentIntentExpanded; r
126
136
  <Controller
127
137
  name="refund.description"
128
138
  control={control}
139
+ rules={{
140
+ required: t('common.required'),
141
+ }}
129
142
  render={({ field }) => (
130
143
  <TextField
131
144
  {...field}
@@ -136,6 +149,8 @@ function RefundForm({ data, refundMaxAmount }: { data: TPaymentIntentExpanded; r
136
149
  minRows={2}
137
150
  maxRows={4}
138
151
  placeholder={t('admin.paymentIntent.refundForm.description')}
152
+ error={!!getFieldState('refund.description').error}
153
+ helperText={getFieldState('refund.description').error?.message}
139
154
  />
140
155
  )}
141
156
  />
@@ -146,7 +161,7 @@ function RefundForm({ data, refundMaxAmount }: { data: TPaymentIntentExpanded; r
146
161
  export function PaymentIntentActionsInner({ data, variant, onChange }: Props) {
147
162
  const { t } = useLocaleContext();
148
163
  const navigate = useNavigate();
149
- const { reset, getValues, setValue } = useFormContext();
164
+ const { reset, getValues, setValue, handleSubmit } = useFormContext();
150
165
  const [state, setState] = useSetState({
151
166
  action: '',
152
167
  loading: false,
@@ -219,7 +234,7 @@ export function PaymentIntentActionsInner({ data, variant, onChange }: Props) {
219
234
  <Actions variant={variant} actions={actions} />
220
235
  {state.action === 'refund' && (
221
236
  <ConfirmDialog
222
- onConfirm={onRefund}
237
+ onConfirm={handleSubmit(onRefund)}
223
238
  onCancel={() => setState({ action: '' })}
224
239
  title={t('admin.paymentIntent.refund')}
225
240
  message={<RefundForm data={data} refundMaxAmount={refundMaxAmount} />}
@@ -232,6 +247,7 @@ export function PaymentIntentActionsInner({ data, variant, onChange }: Props) {
232
247
 
233
248
  export default function PaymentIntentActions(props: Props) {
234
249
  const methods = useForm({
250
+ mode: 'onChange',
235
251
  defaultValues: {
236
252
  refund: {
237
253
  reason: 'requested_by_admin',
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable no-nested-ternary */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
- import { findCurrency, usePaymentContext } from '@blocklet/payment-react';
3
+ import { findCurrency, formatAmountPrecisionLimit, usePaymentContext } from '@blocklet/payment-react';
4
4
  import type {
5
5
  InferFormType,
6
6
  PriceRecurring,
@@ -98,7 +98,7 @@ function stripeCurrencyValidate(v: number, currency: TPaymentCurrencyExpanded |
98
98
  export default function PriceForm({ prefix, simple }: PriceFormProps) {
99
99
  const getFieldName = (name: string) => (prefix ? `${prefix}.${name}` : name);
100
100
 
101
- const { t } = useLocaleContext();
101
+ const { t, locale } = useLocaleContext();
102
102
  const { control, setValue, getFieldState } = useFormContext();
103
103
  const { settings, livemode } = usePaymentContext();
104
104
 
@@ -109,7 +109,6 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
109
109
  const isMetered = useWatch({ control, name: getFieldName('recurring.usage_type') }) === 'metered';
110
110
  const isCustomInterval = useWatch({ control, name: getFieldName('recurring.interval_config') }) === 'month_2';
111
111
  const model = useWatch({ control, name: getFieldName('model') });
112
- const positive = (v: number) => v >= 0;
113
112
  const quantityPositive = (v: number | undefined) => !v || v.toString().match(/^(0|[1-9]\d*)$/);
114
113
  const intervalCountPositive = (v: number) => Number.isInteger(Number(v)) && v > 0;
115
114
 
@@ -119,6 +118,17 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
119
118
 
120
119
  const isLocked = priceLocked && window.blocklet?.PAYMENT_CHANGE_LOCKED_PRICE !== '1';
121
120
 
121
+ const validateAmount = (v: number, currency: { maximum_precision?: number }) => {
122
+ if (Number(v) < 0) {
123
+ return t('admin.price.unit_amount.positive');
124
+ }
125
+ const validPrecision = formatAmountPrecisionLimit(v.toString(), locale, currency?.maximum_precision || 6);
126
+ if (validPrecision) {
127
+ return validPrecision;
128
+ }
129
+ return true;
130
+ };
131
+
122
132
  return (
123
133
  <Root direction="column" alignItems="flex-start" spacing={2}>
124
134
  {isLocked && <Alert severity="info">{t('admin.price.locked')}</Alert>}
@@ -154,7 +164,7 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
154
164
  control={control}
155
165
  rules={{
156
166
  required: t('admin.price.unit_amount.required'),
157
- validate: (v) => (Number(v) > 0 ? true : t('admin.price.unit_amount.positive')),
167
+ validate: (v) => validateAmount(v, settings.baseCurrency),
158
168
  }}
159
169
  disabled={isLocked}
160
170
  render={({ field }) => (
@@ -174,12 +184,8 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
174
184
  type="number"
175
185
  size="small"
176
186
  sx={{ width: INPUT_WIDTH }}
177
- error={!!getFieldState(getFieldName('unit_amount')).error || !positive(field.value)}
178
- helperText={
179
- !positive(field.value)
180
- ? t('admin.price.unit_amount.positive')
181
- : getFieldState(getFieldName('unit_amount')).error?.message
182
- }
187
+ error={!!getFieldState(getFieldName('unit_amount')).error}
188
+ helperText={getFieldState(getFieldName('unit_amount')).error?.message}
183
189
  InputProps={{
184
190
  endAdornment: (
185
191
  <InputAdornment position="end">
@@ -227,7 +233,10 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
227
233
  <Controller
228
234
  name={fieldName}
229
235
  control={control}
230
- rules={{ required: t('admin.price.unit_amount.required') }}
236
+ rules={{
237
+ required: t('admin.price.unit_amount.required'),
238
+ validate: (v) => validateAmount(v, currency ?? {}),
239
+ }}
231
240
  disabled={isLocked}
232
241
  render={({ field }) => {
233
242
  const hasStripError = !stripeCurrencyValidate(field.value, currency);
@@ -19,14 +19,22 @@ export default function AddPrice({
19
19
  const { t } = useLocaleContext();
20
20
  const { settings } = usePaymentContext();
21
21
  const methods = useForm<Price>({
22
+ mode: 'onChange',
22
23
  defaultValues: {
23
24
  ...DEFAULT_PRICE,
24
25
  currency_id: settings.baseCurrency.id,
25
26
  },
26
27
  });
27
28
 
28
- const { handleSubmit, reset } = methods;
29
+ const {
30
+ handleSubmit,
31
+ reset,
32
+ formState: { errors },
33
+ } = methods;
29
34
  const onSubmit = () => {
35
+ if (Object.keys(errors).length > 0) {
36
+ return;
37
+ }
30
38
  handleSubmit(async (formData: any) => {
31
39
  await onSave(formData);
32
40
  reset();
@@ -23,6 +23,7 @@ export default function CreateProduct({
23
23
  const { settings } = usePaymentContext();
24
24
  const [state, setState] = useSetState({ loading: false });
25
25
  const methods = useForm<Product>({
26
+ mode: 'onChange',
26
27
  defaultValues: {
27
28
  type: 'service',
28
29
  name: '',
@@ -36,7 +37,12 @@ export default function CreateProduct({
36
37
  },
37
38
  });
38
39
 
39
- const { control, handleSubmit, reset } = methods;
40
+ const {
41
+ control,
42
+ handleSubmit,
43
+ reset,
44
+ formState: { errors },
45
+ } = methods;
40
46
  const prices = useFieldArray({ control, name: 'prices' });
41
47
 
42
48
  const onCreate = (data: Product) => {
@@ -56,6 +62,9 @@ export default function CreateProduct({
56
62
  };
57
63
 
58
64
  const onSubmit = () => {
65
+ if (Object.keys(errors).length > 0) {
66
+ return;
67
+ }
59
68
  handleSubmit(async (formData: any) => {
60
69
  await onCreate(formData);
61
70
  await reset();
@@ -24,6 +24,7 @@ export default function EditPrice({
24
24
  }) {
25
25
  const { t } = useLocaleContext();
26
26
  const methods = useForm<Price>({
27
+ mode: 'onChange',
27
28
  defaultValues: {
28
29
  ...price,
29
30
  unit_amount: fromUnitToToken(price.unit_amount, price.currency.decimal),
@@ -46,8 +47,15 @@ export default function EditPrice({
46
47
  },
47
48
  });
48
49
 
49
- const { handleSubmit, reset } = methods;
50
+ const {
51
+ handleSubmit,
52
+ reset,
53
+ formState: { errors },
54
+ } = methods;
50
55
  const onSubmit = () => {
56
+ if (Object.keys(errors).length > 0) {
57
+ return;
58
+ }
51
59
  handleSubmit(async (formData: any) => {
52
60
  if (
53
61
  Number(formData.quantity_available) > 0 &&
@@ -19,6 +19,7 @@ import { styled } from '@mui/system';
19
19
  import { useRequest, useSetState } from 'ahooks';
20
20
  import { Link } from 'react-router-dom';
21
21
 
22
+ import { startCase } from 'lodash';
22
23
  import Copyable from '../../../../components/copyable';
23
24
  import Currency from '../../../../components/currency';
24
25
  import CustomerLink from '../../../../components/customer/link';
@@ -128,7 +129,21 @@ export default function RefundDetail(props: { id: string }) {
128
129
  <Stack direction="row" alignItems="center" spacing={1}>
129
130
  <Status label={data.status} color={getRefundStatusColor(data.status)} />
130
131
  {data.last_attempt_error && (
131
- <Tooltip title={<pre>{JSON.stringify(data.last_attempt_error, null, 2)}</pre>}>
132
+ <Tooltip
133
+ title={
134
+ <pre>
135
+ {JSON.stringify(
136
+ {
137
+ ...data.last_attempt_error,
138
+ next_attempt: data.next_attempt ? formatTime(data.next_attempt * 1000) : '-',
139
+ attempt_count: data.attempt_count,
140
+ type: startCase(data.last_attempt_error?.type),
141
+ },
142
+ null,
143
+ 2
144
+ )}
145
+ </pre>
146
+ }>
132
147
  <InfoOutlined fontSize="small" color="error" />
133
148
  </Tooltip>
134
149
  )}
@@ -131,8 +131,8 @@ export default function PriceActions({ data, onChange, variant, setAsDefault }:
131
131
  color: 'text.primary',
132
132
  disabled: true,
133
133
  },
134
- { label: 'Create payment link', handler: onCreatePaymentLink, color: 'primary' },
135
- { label: 'Create pricing table', handler: onCreatePricingTable, color: 'primary' },
134
+ { label: t('admin.paymentLink.add'), handler: onCreatePaymentLink, color: 'primary' },
135
+ { label: t('admin.pricingTable.add'), handler: onCreatePricingTable, color: 'primary' },
136
136
  ];
137
137
 
138
138
  if (setAsDefault) {
@@ -100,7 +100,7 @@ export default function PricesList({ product, onChange }: { product: Product; on
100
100
  sort: false,
101
101
  customBodyRenderLite: (_: any, index: number) => {
102
102
  const price = product.prices[index] as any;
103
- return <PriceActions data={price} onChange={onChange} setAsDefault={price.id !== product.default_price_id} />;
103
+ return <PriceActions data={price} onChange={onChange} setAsDefault={price.id === product.default_price_id} />;
104
104
  },
105
105
  },
106
106
  },
@@ -20,6 +20,7 @@ export default function ProductsCreate() {
20
20
  const { settings } = usePaymentContext();
21
21
 
22
22
  const methods = useForm<Product>({
23
+ mode: 'onChange',
23
24
  defaultValues: {
24
25
  type: 'service',
25
26
  name: '',
@@ -32,7 +33,7 @@ export default function ProductsCreate() {
32
33
  metadata: [],
33
34
  },
34
35
  });
35
- const { control, handleSubmit, getValues } = methods;
36
+ const { control, handleSubmit, getValues, clearErrors } = methods;
36
37
 
37
38
  const prices = useFieldArray({ control, name: 'prices' });
38
39
  const getPrice = (index: number) => methods.getValues().prices[index];
@@ -56,6 +57,7 @@ export default function ProductsCreate() {
56
57
  <DrawerForm
57
58
  icon={<AddOutlined />}
58
59
  text={t('admin.product.add')}
60
+ onClose={() => clearErrors()}
59
61
  width={640}
60
62
  addons={
61
63
  <Button variant="contained" size="small" onClick={handleSubmit(onSubmit)}>