payment-kit 1.15.20 → 1.15.21

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.
@@ -38,7 +38,6 @@ import { PaymentMethod } from '../store/models/payment-method';
38
38
  import { Price } from '../store/models/price';
39
39
  import { PricingTable } from '../store/models/pricing-table';
40
40
  import { Product } from '../store/models/product';
41
- import { Refund } from '../store/models/refund';
42
41
  import { SetupIntent } from '../store/models/setup-intent';
43
42
  import { Subscription, TSubscription } from '../store/models/subscription';
44
43
  import { SubscriptionItem } from '../store/models/subscription-item';
@@ -46,6 +45,8 @@ import type { LineItem, ServiceAction, SubscriptionUpdateItem } from '../store/m
46
45
  import { UsageRecord } from '../store/models/usage-record';
47
46
  import { cleanupInvoiceAndItems, ensureInvoiceAndItems } from './connect/shared';
48
47
  import { createUsageRecordQueryFn } from './usage-records';
48
+ import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-canceled';
49
+ import { getTokenByAddress } from '../integrations/arcblock/stake';
49
50
 
50
51
  const router = Router();
51
52
  const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
@@ -377,57 +378,35 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
377
378
  }
378
379
  }
379
380
 
380
- await subscription.update(updates);
381
-
382
381
  // trigger refund
383
382
  if (updates.cancel_at < subscription.current_period_end && refund !== 'none') {
384
383
  if (['owner', 'admin'].includes(req.user?.role as string) === false) {
385
- return res.status(403).json({ error: 'Not authorized to perform this action' });
384
+ return res.status(403).json({ error: 'Not authorized to refund' });
386
385
  }
387
-
388
386
  const result = await getSubscriptionRefundSetup(subscription, updates.cancel_at);
389
387
  if (result.unused !== '0') {
390
- const item = await Refund.create({
391
- type: 'refund',
392
- livemode: subscription.livemode,
393
- amount: refund === 'last' ? result.total : result.unused,
394
- description: 'refund_transfer_on_subscription_cancel',
395
- status: 'pending',
396
- reason: 'requested_by_admin',
397
- currency_id: subscription.currency_id,
398
- customer_id: subscription.customer_id,
399
- payment_method_id: subscription.default_payment_method_id,
400
- payment_intent_id: result.lastInvoice.payment_intent_id as string,
401
- invoice_id: result.lastInvoice.id,
402
- subscription_id: subscription.id,
403
- attempt_count: 0,
404
- attempted: false,
405
- next_attempt: 0,
406
- last_attempt_error: null,
407
- starting_balance: '0',
408
- ending_balance: '0',
409
- starting_token_balance: {},
410
- ending_token_balance: {},
411
- metadata: {
412
- requested_by: req.user?.did,
413
- unused_period_start: refund === 'last' ? subscription.current_period_start : updates.cancel_at,
414
- unused_period_end: subscription.current_period_end,
415
- },
416
- });
417
- logger.info('subscription cancel refund created', {
388
+ // @ts-ignore
389
+ updates.cancelation_details = {
390
+ ...(updates.cancelation_details || {}),
391
+ refund,
392
+ requested_by: req.user?.did,
393
+ };
394
+ logger.info('subscription cancel with refund', {
418
395
  ...req.params,
419
396
  ...req.body,
397
+ refund,
420
398
  ...pick(result, ['total', 'unused']),
421
- item: item.toJSON(),
422
399
  });
423
400
  } else {
424
- logger.info('subscription cancel refund skipped', {
401
+ logger.info('subscription cancel no refund', {
425
402
  ...req.params,
426
403
  ...req.body,
427
404
  ...pick(result, ['total', 'unused']),
428
405
  });
429
406
  }
430
407
  }
408
+ await subscription.update(updates);
409
+ await new SubscriptionWillCanceledSchedule().reScheduleSubscriptionTasks([subscription]);
431
410
 
432
411
  return res.json(subscription);
433
412
  });
@@ -455,7 +434,7 @@ router.put('/:id/recover', authPortal, async (req, res) => {
455
434
  }
456
435
 
457
436
  await doc.update({ cancel_at_period_end: false, cancel_at: 0, canceled_at: 0 });
458
-
437
+ await new SubscriptionWillCanceledSchedule().deleteScheduleSubscriptionJobs([doc]);
459
438
  // reschedule jobs
460
439
  subscriptionQueue
461
440
  .delete(`cancel-${doc.id}`)
@@ -1730,4 +1709,30 @@ router.put('/:id/slash-stake', auth, async (req, res) => {
1730
1709
  return res.status(400).json({ error: err.message });
1731
1710
  }
1732
1711
  });
1712
+
1713
+ // get payer token
1714
+ router.get('/:id/payer-token', authMine, async (req, res) => {
1715
+ const subscription = await Subscription.findByPk(req.params.id);
1716
+ if (!subscription) {
1717
+ return res.status(400).json({ error: `Subscription(${req.params.id}) not found` });
1718
+ }
1719
+ const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
1720
+ if (!paymentMethod) {
1721
+ return res.status(400).json({ error: `Payment method(${subscription.default_payment_method_id}) not found` });
1722
+ }
1723
+
1724
+ const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
1725
+ if (!paymentCurrency) {
1726
+ return res.status(400).json({ error: `Payment currency(${subscription.currency_id}) not found` });
1727
+ }
1728
+
1729
+ // @ts-ignore
1730
+ const paymentAddress = subscription.payment_details?.[paymentMethod.type]?.payer ?? undefined;
1731
+ if (!paymentAddress && ['ethereum', 'arcblock'].includes(paymentMethod.type)) {
1732
+ return res.status(400).json({ error: `Payer not found on subscription payment detail: ${subscription.id}` });
1733
+ }
1734
+
1735
+ const token = await getTokenByAddress(paymentAddress, paymentMethod, paymentCurrency);
1736
+ return res.json({ token, paymentAddress });
1737
+ });
1733
1738
  export default router;
@@ -64,6 +64,8 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
64
64
  return_stake?: boolean;
65
65
  slash_stake?: boolean;
66
66
  slash_reason?: string;
67
+ refund?: LiteralUnion<'last' | 'proration' | 'none', string>;
68
+ requested_by?: string;
67
69
  };
68
70
 
69
71
  declare billing_cycle_anchor: number;
@@ -0,0 +1,54 @@
1
+ import { getSimplifyDuration } from '../../src/libs/time';
2
+
3
+ describe('getSimplifyDuration', () => {
4
+ const testCasesEn = [
5
+ { ms: 30 * 1000, expected: '30 seconds' },
6
+ { ms: 34 * 60 * 1000 + 30 * 1000, expected: '34 minutes' },
7
+ { ms: 59 * 60 * 1000, expected: '59 minutes' },
8
+ { ms: 60 * 60 * 1000, expected: '1 hour' },
9
+ { ms: 23 * 60 * 60 * 1000, expected: '23 hours' },
10
+ { ms: 24 * 60 * 60 * 1000, expected: '1 day' },
11
+ { ms: 24 * 60 * 60 * 1000 + 20 * 60 * 1000 + 30 * 1000, expected: '1 day' },
12
+ { ms: 25 * 24 * 60 * 60 * 1000 + 30 * 60 * 1000 + 10 * 1000, expected: '25 days' },
13
+ { ms: 32 * 24 * 60 * 60 * 1000 + 30 * 60 * 1000 + 12 * 1000, expected: '32 days' },
14
+ { ms: 60 * 24 * 60 * 60 * 1000 + 20 * 60 * 1000, expected: '60 days' },
15
+ { ms: 366 * 24 * 60 * 60 * 1000 + 20 * 60 * 1000, expected: '1 year' },
16
+ ];
17
+
18
+ const testCasesZh = [
19
+ { ms: 30 * 1000, expected: '30秒' },
20
+ { ms: 34 * 60 * 1000 + 30 * 1000, expected: '34分钟' },
21
+ { ms: 59 * 60 * 1000, expected: '59分钟' },
22
+ { ms: 60 * 60 * 1000, expected: '1小时' },
23
+ { ms: 23 * 60 * 60 * 1000, expected: '23小时' },
24
+ { ms: 24 * 60 * 60 * 1000, expected: '1天' },
25
+ { ms: 24 * 60 * 60 * 1000 + 20 * 60 * 1000 + 30 * 1000, expected: '1天' },
26
+ { ms: 25 * 24 * 60 * 60 * 1000 + 30 * 60 * 1000 + 10 * 1000, expected: '25天' },
27
+ { ms: 32 * 24 * 60 * 60 * 1000 + 30 * 60 * 1000 + 12 * 1000, expected: '32天' },
28
+ { ms: 60 * 24 * 60 * 60 * 1000 + 20 * 60 * 1000, expected: '60天' },
29
+ { ms: 366 * 24 * 60 * 60 * 1000 + 20 * 60 * 1000, expected: '1年' },
30
+ ];
31
+
32
+ test.each(testCasesEn)('should return $expected for $ms milliseconds in English', ({ ms, expected }) => {
33
+ expect(getSimplifyDuration(ms, 'en')).toBe(expected);
34
+ });
35
+
36
+ test.each(testCasesZh)('should return $expected for $ms milliseconds in Chinese', ({ ms, expected }) => {
37
+ expect(getSimplifyDuration(ms, 'zh')).toBe(expected);
38
+ });
39
+
40
+ test('should handle zero', () => {
41
+ expect(getSimplifyDuration(0, 'en')).toBe('0ms');
42
+ expect(getSimplifyDuration(0, 'zh')).toBe('0毫秒');
43
+ });
44
+
45
+ test('should handle negative values', () => {
46
+ expect(getSimplifyDuration(-24 * 60 * 60 * 1000, 'en')).toBe('-1 days');
47
+ expect(getSimplifyDuration(-24 * 60 * 60 * 1000, 'zh')).toBe('-1天');
48
+ });
49
+
50
+ test('should handle large number of days', () => {
51
+ expect(getSimplifyDuration(1000 * 24 * 60 * 60 * 1000, 'en')).toBe('2 years');
52
+ expect(getSimplifyDuration(1000 * 24 * 60 * 60 * 1000, 'zh')).toBe('2年');
53
+ });
54
+ });
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.15.20
17
+ version: 1.15.21
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.15.20",
3
+ "version": "1.15.21",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "eject": "vite eject",
@@ -43,29 +43,29 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@abtnode/cron": "^1.16.32",
46
- "@arcblock/did": "^1.18.135",
46
+ "@arcblock/did": "^1.18.136",
47
47
  "@arcblock/did-auth-storage-nedb": "^1.7.1",
48
- "@arcblock/did-connect": "^2.10.45",
49
- "@arcblock/did-util": "^1.18.135",
50
- "@arcblock/jwt": "^1.18.135",
51
- "@arcblock/ux": "^2.10.45",
52
- "@arcblock/validator": "^1.18.135",
48
+ "@arcblock/did-connect": "^2.10.51",
49
+ "@arcblock/did-util": "^1.18.136",
50
+ "@arcblock/jwt": "^1.18.136",
51
+ "@arcblock/ux": "^2.10.51",
52
+ "@arcblock/validator": "^1.18.136",
53
53
  "@blocklet/js-sdk": "^1.16.32",
54
54
  "@blocklet/logger": "^1.16.32",
55
- "@blocklet/payment-react": "1.15.20",
55
+ "@blocklet/payment-react": "1.15.21",
56
56
  "@blocklet/sdk": "^1.16.32",
57
- "@blocklet/ui-react": "^2.10.45",
58
- "@blocklet/uploader": "^0.1.43",
59
- "@blocklet/xss": "^0.1.9",
57
+ "@blocklet/ui-react": "^2.10.51",
58
+ "@blocklet/uploader": "^0.1.46",
59
+ "@blocklet/xss": "^0.1.12",
60
60
  "@mui/icons-material": "^5.16.6",
61
61
  "@mui/lab": "^5.0.0-alpha.173",
62
62
  "@mui/material": "^5.16.6",
63
63
  "@mui/system": "^5.16.6",
64
- "@ocap/asset": "^1.18.135",
65
- "@ocap/client": "^1.18.135",
66
- "@ocap/mcrypto": "^1.18.135",
67
- "@ocap/util": "^1.18.135",
68
- "@ocap/wallet": "^1.18.135",
64
+ "@ocap/asset": "^1.18.136",
65
+ "@ocap/client": "^1.18.136",
66
+ "@ocap/mcrypto": "^1.18.136",
67
+ "@ocap/util": "^1.18.136",
68
+ "@ocap/wallet": "^1.18.136",
69
69
  "@react-pdf/renderer": "^3.4.4",
70
70
  "@stripe/react-stripe-js": "^2.7.3",
71
71
  "@stripe/stripe-js": "^2.4.0",
@@ -117,8 +117,8 @@
117
117
  },
118
118
  "devDependencies": {
119
119
  "@abtnode/types": "^1.16.32",
120
- "@arcblock/eslint-config-ts": "^0.3.2",
121
- "@blocklet/payment-types": "1.15.20",
120
+ "@arcblock/eslint-config-ts": "^0.3.3",
121
+ "@blocklet/payment-types": "1.15.21",
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": "0499ea79c00bb48d7a2479af589c21698c6388a0"
163
+ "gitHead": "b27debca52a2ea7f93a2a237053b8e171f8be3a2"
164
164
  }
package/src/app.tsx CHANGED
@@ -27,6 +27,7 @@ const CustomerSubscriptionDetail = React.lazy(() => import('./pages/customer/sub
27
27
  const CustomerSubscriptionEmbed = React.lazy(() => import('./pages/customer/subscription/embed'));
28
28
  const CustomerSubscriptionChangePlan = React.lazy(() => import('./pages/customer/subscription/change-plan'));
29
29
  const CustomerSubscriptionChangePayment = React.lazy(() => import('./pages/customer/subscription/change-payment'));
30
+ const CustomerRecharge = React.lazy(() => import('./pages/customer/recharge'));
30
31
 
31
32
  // const theme = createTheme({
32
33
  // typography: {
@@ -92,6 +93,15 @@ function App() {
92
93
  </Layout>
93
94
  }
94
95
  />
96
+ <Route
97
+ key="customer-recharge"
98
+ path="/customer/subscription/:id/recharge"
99
+ element={
100
+ <Layout>
101
+ <CustomerRecharge />
102
+ </Layout>
103
+ }
104
+ />
95
105
  <Route
96
106
  key="customer-embed"
97
107
  path="/customer/embed/subscription"
@@ -1,9 +1,9 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
- import { api, formatAmount, formatTime } from '@blocklet/payment-react';
2
+ import { api, dayjs, formatAmount, formatTime } from '@blocklet/payment-react';
3
3
  import type { TSubscriptionExpanded } from '@blocklet/payment-types';
4
4
  import { Box, Divider, FormControlLabel, Radio, RadioGroup, Stack, TextField, Typography, styled } from '@mui/material';
5
5
  import { useRequest } from 'ahooks';
6
- import { useEffect } from 'react';
6
+ import { useEffect, useMemo } from 'react';
7
7
  import { Controller, useFormContext, useWatch } from 'react-hook-form';
8
8
 
9
9
  const fetchData = (id: string, time: string): Promise<{ total: string; unused: string }> => {
@@ -21,13 +21,23 @@ export default function SubscriptionCancelForm({ data }: { data: TSubscriptionEx
21
21
  const cancelTime = useWatch({ control, name: 'cancel.time' });
22
22
  const refundType = useWatch({ control, name: 'cancel.refund' });
23
23
  const stakingType = useWatch({ control, name: 'cancel.staking' });
24
- const {
25
- loading,
26
- data: refund,
27
- refresh,
28
- } = useRequest(() => fetchData(data.id, cancelAt === 'custom' ? cancelTime : ''));
24
+ const actualCancelAt = useMemo(() => {
25
+ if (cancelAt === 'custom') {
26
+ return cancelTime;
27
+ }
28
+ if (cancelAt === 'current_period_end') {
29
+ return new Date(data.current_period_end * 1000);
30
+ }
31
+ return '';
32
+ }, [cancelAt, cancelTime]);
33
+ const { loading, data: refund, refresh } = useRequest(() => fetchData(data.id, actualCancelAt));
29
34
 
30
- const { data: staking } = useRequest(() => fetchStakingData(data.id, cancelAt === 'custom' ? cancelTime : ''));
35
+ const { data: staking } = useRequest(() => {
36
+ if (data.paymentMethod?.type === 'arcblock') {
37
+ return fetchStakingData(data.id, actualCancelAt);
38
+ }
39
+ return Promise.resolve({ return_amount: '0', slash_amount: '0' });
40
+ });
31
41
  useEffect(() => {
32
42
  if (data) {
33
43
  refresh();
@@ -71,7 +81,18 @@ export default function SubscriptionCancelForm({ data }: { data: TSubscriptionEx
71
81
  {isCustom && (
72
82
  <Controller
73
83
  name="cancel.time"
74
- rules={{ required: isCustom }}
84
+ rules={{
85
+ required: isCustom,
86
+ validate: (value) => {
87
+ const now = dayjs();
88
+ const selectedTime = dayjs(value);
89
+ const periodEndTime = dayjs.unix(data.current_period_end);
90
+ if (selectedTime.isBefore(now) || selectedTime.isAfter(periodEndTime)) {
91
+ return t('admin.subscription.cancel.at.timeError');
92
+ }
93
+ return true;
94
+ },
95
+ }}
75
96
  control={control}
76
97
  render={({ field }) => (
77
98
  <TextField
@@ -44,9 +44,17 @@ function SubscriptionActionsInner({ data, variant, onChange }: Props) {
44
44
  slash_amount: '0',
45
45
  },
46
46
  runAsync: fetchStakeResultAsync,
47
- } = useRequest(() => fetchStakingData(data.id, ''), {
48
- manual: true,
49
- });
47
+ } = useRequest(
48
+ () => {
49
+ if (data.paymentMethod?.type === 'arcblock') {
50
+ return fetchStakingData(data.id, '');
51
+ }
52
+ return Promise.resolve({ return_amount: '0', slash_amount: '0', total: '0' });
53
+ },
54
+ {
55
+ manual: true,
56
+ }
57
+ );
50
58
 
51
59
  const stakeValue = useMemo(() => {
52
60
  return formatBNStr(stakeResult?.slash_amount, data?.paymentCurrency?.decimal);
@@ -457,6 +457,7 @@ export default flat({
457
457
  now: 'Immediately ({date})',
458
458
  current_period_end: 'End of current period ({date})',
459
459
  custom: 'On a custom date',
460
+ timeError: 'Cancel time must be within the current period',
460
461
  },
461
462
  refund: {
462
463
  title: 'Refund',
@@ -616,5 +617,17 @@ export default flat({
616
617
  product: {
617
618
  empty: 'No Product',
618
619
  },
620
+ recharge: {
621
+ title: 'Recharge',
622
+ amount: 'Amount',
623
+ submit: 'Submit',
624
+ unsupported: 'Unsupported currency, please select another one',
625
+ receiveAddress: 'Receive Address',
626
+ view: 'View Subscription',
627
+ success: 'Recharge successfully',
628
+ custom: 'Custom',
629
+ estimatedDuration: '{duration} {unit} est.',
630
+ intervals: 'intervals',
631
+ },
619
632
  },
620
633
  });
@@ -447,6 +447,7 @@ export default flat({
447
447
  now: '立即取消({date})',
448
448
  current_period_end: '本周期结束后({date})',
449
449
  custom: '自定义取消日期',
450
+ timeError: '取消时间必须在当前周期内',
450
451
  },
451
452
  refund: {
452
453
  title: '退款',
@@ -604,5 +605,17 @@ export default flat({
604
605
  product: {
605
606
  empty: '没有订阅产品',
606
607
  },
608
+ recharge: {
609
+ title: '充值',
610
+ amount: '金额',
611
+ submit: '提交',
612
+ unsupported: '暂不支持该货币充值,请选择其他货币',
613
+ receiveAddress: '收款地址',
614
+ view: '查看订阅',
615
+ success: '充值成功',
616
+ estimatedDuration: '预计可用 {duration} {unit}',
617
+ custom: '自定义',
618
+ intervals: '个周期',
619
+ },
607
620
  },
608
621
  });