payment-kit 1.14.4 → 1.14.6

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/README.md CHANGED
@@ -11,15 +11,19 @@ The decentralized stripe for blocklet platform.
11
11
  3. run `cd blocklets/core && blocklet dev`
12
12
 
13
13
  ##### when error
14
+
14
15
  1. pre-start error component xxx is not running or unreachable
15
- - create .env.local file in this root
16
- - add BLOCKLET_DEV_APP_DID="did:abt:your payment kit server did"
17
- - add BLOCKLET_DEV_MOUNT_POINT="/example"
18
- - copy .env.local to be under the /core
19
- - edit BLOCKLET_DEV_MOUNT_POINT="/"
16
+
17
+ - create .env.local file in this root
18
+ - add BLOCKLET_DEV_APP_DID="did:abt:your payment kit server did"
19
+ - add BLOCKLET_DEV_MOUNT_POINT="/example"
20
+ - copy .env.local to be under the /core
21
+ - edit BLOCKLET_DEV_MOUNT_POINT="/"
22
+
20
23
  2. Insufficient fund to pay for tx cost from xxx, expected 1.0020909, got 0
21
24
  - copy BLOCKLET_DEV_APP_DID
22
25
  - transfer 2 TBA in your DID Wallet to your copied address
26
+
23
27
  ### Debug Stripe
24
28
 
25
29
  1. Install and login with instructions from: https://stripe.com/docs/stripe-cli
@@ -37,5 +41,6 @@ None public environment variables used in the code, can change the behavior of t
37
41
  - PAYMENT_CHANGE_LOCKED_PRICE: allow change locked price, must be set to "1" to work
38
42
  - PAYMENT_RELOAD_SUBSCRIPTION_JOBS: reload subscription jobs on start, must be set to "1" to work
39
43
  - PAYMENT_BILLING_THRESHOLD: global default billing threshold, must be a number
44
+ - PAYMENT_MIN_STAKE_AMOUNT: global min stake amount limit, must be a number
40
45
  - PAYMENT_DAYS_UNTIL_DUE: global default days until due, must be a number
41
46
  - PAYMENT_DAYS_UNTIL_CANCEL: global default days until cancel, must be a number
@@ -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;
@@ -299,6 +299,22 @@ export function getBillingThreshold(config: Record<string, any> = {}) {
299
299
  return 0;
300
300
  }
301
301
 
302
+ export function getMinStakeAmount(config: Record<string, any> = {}) {
303
+ if (config?.min_stake_amount) {
304
+ const threshold = +(config.min_stake_amount as string);
305
+ if (threshold > 0) {
306
+ return threshold;
307
+ }
308
+ }
309
+
310
+ const threshold = +(process.env.PAYMENT_MIN_STAKE_AMOUNT as string);
311
+ if (threshold > 0) {
312
+ return threshold;
313
+ }
314
+
315
+ return 0;
316
+ }
317
+
302
318
  export function canPayWithDelegation(beneficiaries: PaymentBeneficiary[]) {
303
319
  return beneficiaries.length === 0 || beneficiaries.every((x) => x.address === wallet.address);
304
320
  }
@@ -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
+ }
@@ -30,6 +30,7 @@ import {
30
30
  getCheckoutAmount,
31
31
  getCheckoutMode,
32
32
  getFastCheckoutAmount,
33
+ getMinStakeAmount,
33
34
  getStatementDescriptor,
34
35
  getSupportedPaymentCurrencies,
35
36
  getSupportedPaymentMethods,
@@ -41,7 +42,7 @@ import {
41
42
  getDaysUntilDue,
42
43
  getSubscriptionCreateSetup,
43
44
  } from '../libs/subscription';
44
- import { CHECKOUT_SESSION_TTL, formatMetadata, getDataObjectFromQuery } from '../libs/util';
45
+ import { CHECKOUT_SESSION_TTL, formatAmountPrecisionLimit, formatMetadata, getDataObjectFromQuery } from '../libs/util';
45
46
  import { invoiceQueue } from '../queues/invoice';
46
47
  import { paymentQueue } from '../queues/payment';
47
48
  import type { LineItem, TPriceExpanded, TProductExpanded } from '../store/models';
@@ -146,6 +147,7 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
146
147
  description: '',
147
148
  trial_period_days: 0,
148
149
  billing_threshold_amount: 0,
150
+ min_stake_amount: 0,
149
151
  trial_end: 0,
150
152
  },
151
153
  payment_intent_data: {},
@@ -373,6 +375,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
373
375
  raw.payment_link_id = link.id;
374
376
  raw.subscription_data = merge(link.subscription_data, getDataObjectFromQuery(req.query, 'subscription_data'), {
375
377
  billing_threshold_amount: getBillingThreshold(link.subscription_data),
378
+ min_stake_amount: getMinStakeAmount(link.subscription_data),
376
379
  });
377
380
 
378
381
  if (link.after_completion?.hosted_confirmation?.custom_message) {
@@ -559,6 +562,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
559
562
  const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
560
563
  const trialEnds = Number(checkoutSession.subscription_data?.trial_end || 0);
561
564
  const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
565
+ const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
562
566
  const amount = getCheckoutAmount(lineItems, paymentCurrency.id, trialInDays > 0 || trialEnds > now);
563
567
  await checkoutSession.update({
564
568
  amount_subtotal: amount.subtotal,
@@ -809,10 +813,11 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
809
813
  missing_payment_method: 'create_invoice',
810
814
  },
811
815
  },
812
- // @ts-ignore
813
- billing_thresholds: billingThreshold
814
- ? { amount_gte: billingThreshold, reset_billing_cycle_anchor: false } // prettier-ignore
815
- : null,
816
+ billing_thresholds: {
817
+ amount_gte: billingThreshold,
818
+ stake_gte: minStakeAmount,
819
+ reset_billing_cycle_anchor: false,
820
+ },
816
821
  pending_invoice_item_interval: setup.recurring,
817
822
  pending_setup_intent: setupIntent?.id,
818
823
  default_payment_method_id: paymentMethod.id,
@@ -1184,6 +1189,14 @@ router.put('/:id/amount', ensureCheckoutSessionOpen, async (req, res) => {
1184
1189
  if (input > Number(maximum)) {
1185
1190
  return res.status(400).json({ error: 'Custom amount should not be smaller than maximum' });
1186
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
+ }
1187
1200
  } else if (presets?.some((x) => Number(x) === input) === false) {
1188
1201
  return res.status(400).json({ error: 'Custom amount must be one of the presets' });
1189
1202
  }
@@ -43,6 +43,7 @@ export default {
43
43
  const trialEnds = Number(checkoutSession.subscription_data?.trial_end || 0);
44
44
  const trialing = trialInDays > 0 || trialEnds > now;
45
45
  const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
46
+ const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
46
47
  const fastCheckoutAmount = getFastCheckoutAmount(items, checkoutSession.mode, paymentCurrency.id, trialing);
47
48
 
48
49
  if (paymentMethod.type === 'arcblock') {
@@ -65,7 +66,7 @@ export default {
65
66
  paymentCurrency,
66
67
  paymentMethod,
67
68
  trialing,
68
- billingThreshold,
69
+ billingThreshold: Math.max(minStakeAmount, billingThreshold),
69
70
  items,
70
71
  }),
71
72
  ];
@@ -732,8 +732,10 @@ export async function getStakeTxClaim({
732
732
  paymentMethod: PaymentMethod;
733
733
  }) {
734
734
  // create staking amount
735
- const billingThreshold = fromTokenToUnit(subscription.billing_thresholds?.amount_gte || 0, paymentCurrency.decimal);
736
- const staking = getSubscriptionStakeSetup(items, paymentCurrency.id, billingThreshold.toString());
735
+ const billingThreshold = Number(subscription.billing_thresholds?.amount_gte || 0);
736
+ const minStakeAmount = Number(subscription.billing_thresholds?.stake_gte || 0);
737
+ const threshold = fromTokenToUnit(Math.max(billingThreshold, minStakeAmount), paymentCurrency.decimal);
738
+ const staking = getSubscriptionStakeSetup(items, paymentCurrency.id, threshold.toString());
737
739
  const amount = staking.licensed.add(staking.metered).toString();
738
740
 
739
741
  if (paymentMethod.type === 'arcblock') {
@@ -44,6 +44,7 @@ export default {
44
44
  const trialEnds = Number(checkoutSession.subscription_data?.trial_end || 0);
45
45
  const trialing = trialInDays > 0 || trialEnds > now;
46
46
  const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
47
+ const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
47
48
  const fastCheckoutAmount = getFastCheckoutAmount(items, checkoutSession.mode, paymentCurrency.id, trialing);
48
49
 
49
50
  if (paymentMethod.type === 'arcblock') {
@@ -67,7 +68,7 @@ export default {
67
68
  paymentCurrency,
68
69
  paymentMethod,
69
70
  trialing,
70
- billingThreshold,
71
+ billingThreshold: Math.max(minStakeAmount, billingThreshold),
71
72
  items,
72
73
  }),
73
74
  ];
@@ -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({
@@ -11,7 +11,7 @@ import { checkPassportForPricingTable } from '../integrations/blocklet/passport'
11
11
  import { createListParamSchema } from '../libs/api';
12
12
  import logger from '../libs/logger';
13
13
  import { authenticate } from '../libs/security';
14
- import { getBillingThreshold, isLineItemCurrencyAligned } from '../libs/session';
14
+ import { getBillingThreshold, getMinStakeAmount, isLineItemCurrencyAligned } from '../libs/session';
15
15
  import { getDaysUntilCancel, getDaysUntilDue } from '../libs/subscription';
16
16
  import { getDataObjectFromQuery } from '../libs/util';
17
17
  import { CheckoutSession } from '../store/models/checkout-session';
@@ -224,6 +224,7 @@ router.post('/:id/checkout/:priceId', async (req, res) => {
224
224
  ]),
225
225
  subscription_data: merge(price.subscription_data || {}, getDataObjectFromQuery(req.query, 'subscription_data'), {
226
226
  billing_threshold_amount: getBillingThreshold(price.subscription_data),
227
+ min_stake_amount: getMinStakeAmount(price.subscription_data),
227
228
  }),
228
229
  metadata: {
229
230
  ...doc.metadata,
@@ -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
+ };
@@ -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',
@@ -66,6 +66,7 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
66
66
  declare billing_cycle_anchor: number;
67
67
  declare billing_thresholds?: {
68
68
  amount_gte: number;
69
+ stake_gte: number;
69
70
  reset_billing_cycle_anchor: boolean;
70
71
  };
71
72
 
@@ -363,6 +363,7 @@ export type SubscriptionData = {
363
363
  description: string;
364
364
  trial_period_days: number;
365
365
  billing_threshold_amount?: number;
366
+ min_stake_amount?: number;
366
367
  metadata?: Record<string, any>;
367
368
  };
368
369
 
@@ -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.4
17
+ version: 1.14.6
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.4",
3
+ "version": "1.14.6",
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.4",
55
+ "@blocklet/payment-react": "1.14.6",
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.4",
121
+ "@blocklet/payment-types": "1.14.6",
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": "53bfd2bc1dd91c6369220db1040ab8e060e61104"
163
+ "gitHead": "6c2ea2db76a8bb8413d0b802f35b50b650c7f6d2"
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)}>