payment-kit 1.13.163 → 1.13.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import routes from './routes';
27
27
  import changePaymentHandlers from './routes/connect/change-payment';
28
28
  import changePlanHandlers from './routes/connect/change-plan';
29
29
  import collectHandlers from './routes/connect/collect';
30
+ import collectBatchHandlers from './routes/connect/collect-batch';
30
31
  import payHandlers from './routes/connect/pay';
31
32
  import setupHandlers from './routes/connect/setup';
32
33
  import subscribeHandlers from './routes/connect/subscribe';
@@ -54,6 +55,7 @@ app.use(ensureI18n());
54
55
 
55
56
  const router = express.Router();
56
57
  handlers.attach(Object.assign({ app: router }, collectHandlers));
58
+ handlers.attach(Object.assign({ app: router }, collectBatchHandlers));
57
59
  handlers.attach(Object.assign({ app: router }, payHandlers));
58
60
  handlers.attach(Object.assign({ app: router }, setupHandlers));
59
61
  handlers.attach(Object.assign({ app: router }, subscribeHandlers));
@@ -11,7 +11,7 @@ export default flat({
11
11
  validityPeriod: '服务周期',
12
12
  trialPeriod: '试用期',
13
13
  viewSubscription: '查看订阅',
14
- viewInvoice: '查看发票',
14
+ viewInvoice: '查看账单',
15
15
  viewTxHash: '查看交易',
16
16
  trialDuration: '试用期时长',
17
17
  duration: '时长',
@@ -77,7 +77,7 @@ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
77
77
  logger.info(`Subscription ${subscription.id} updated on payment done ${invoice.id}`);
78
78
  } else if (subscription.status === 'past_due' && subscription.cancelation_details?.reason === 'payment_failed') {
79
79
  // ensure no uncollectible amount before recovering from payment failed
80
- const result = await Invoice.getUncollectibleAmountBySubscription(subscription.id);
80
+ const [result] = await Invoice.getUncollectibleAmount({ subscriptionId: subscription.id });
81
81
  if (isEmpty(result)) {
82
82
  // reset billing cycle anchor and cancel_* if we are recovering from payment failed
83
83
  if (subscription.cancel_at && subscription.cancel_at !== subscription.current_period_end) {
@@ -0,0 +1,125 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ import type { Transaction } from '@ocap/client';
3
+ import { fromAddress } from '@ocap/wallet';
4
+
5
+ import type { CallbackArgs } from '../../libs/auth';
6
+ import { wallet } from '../../libs/auth';
7
+ import { getGasPayerExtra } from '../../libs/payment';
8
+ import { getTxMetadata } from '../../libs/util';
9
+ import { invoiceQueue } from '../../queues/invoice';
10
+ import { handlePaymentSucceed, paymentQueue } from '../../queues/payment';
11
+ import { PaymentIntent } from '../../store/models';
12
+ import { ensureSubscriptionForCollectBatch, getAuthPrincipalClaim } from './shared';
13
+
14
+ // Used to collect uncollectible invoices for a subscription and currency
15
+ export default {
16
+ action: 'collect-batch',
17
+ authPrincipal: false,
18
+ claims: {
19
+ authPrincipal: async ({ extraParams }: CallbackArgs) => {
20
+ const { paymentMethod } = await ensureSubscriptionForCollectBatch(
21
+ extraParams.subscriptionId,
22
+ extraParams.currencyId
23
+ );
24
+ return getAuthPrincipalClaim(paymentMethod, 'pay');
25
+ },
26
+ },
27
+ onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
28
+ const { subscriptionId, currencyId } = extraParams;
29
+ const { amount, invoices, paymentCurrency, paymentMethod } = await ensureSubscriptionForCollectBatch(
30
+ subscriptionId,
31
+ currencyId
32
+ );
33
+
34
+ if (paymentMethod.type === 'arcblock') {
35
+ const tokens = [{ address: paymentCurrency.contract as string, value: amount }];
36
+ // @ts-ignore
37
+ const itx: TransferV3Tx = {
38
+ outputs: [{ owner: wallet.address, tokens, assets: [] }],
39
+ data: getTxMetadata({
40
+ subscriptionId,
41
+ currencyId,
42
+ invoices,
43
+ }),
44
+ };
45
+
46
+ const claims: { [key: string]: object } = {
47
+ prepareTx: {
48
+ type: 'TransferV3Tx',
49
+ description: `Pay all past due invoices for ${subscriptionId}`,
50
+ partialTx: { from: userDid, pk: userPk, itx },
51
+ requirement: { tokens },
52
+ chainInfo: {
53
+ host: paymentMethod.settings?.arcblock?.api_host as string,
54
+ id: paymentMethod.settings?.arcblock?.chain_id as string,
55
+ },
56
+ },
57
+ };
58
+
59
+ return claims;
60
+ }
61
+
62
+ throw new Error(`Payment method ${paymentMethod.type} not supported`);
63
+ },
64
+ onAuth: async ({ userDid, claims, extraParams }: CallbackArgs) => {
65
+ const { subscriptionId, currencyId } = extraParams;
66
+ const { invoices, paymentMethod } = await ensureSubscriptionForCollectBatch(subscriptionId, currencyId);
67
+
68
+ if (paymentMethod.type === 'arcblock') {
69
+ const client = paymentMethod.getOcapClient();
70
+ const claim = claims.find((x) => x.type === 'prepareTx');
71
+
72
+ const tx: Partial<Transaction> = client.decodeTx(claim.finalTx);
73
+ if (claim.delegator && claim.from) {
74
+ tx.delegator = claim.delegator;
75
+ tx.from = claim.from;
76
+ }
77
+
78
+ // @ts-ignore
79
+ const { buffer } = await client.encodeTransferV3Tx({ tx });
80
+ const txHash = await client.sendTransferV3Tx(
81
+ // @ts-ignore
82
+ { tx, wallet: fromAddress(userDid) },
83
+ getGasPayerExtra(buffer)
84
+ );
85
+
86
+ const paymentIntents = await PaymentIntent.findAll({
87
+ where: {
88
+ invoice_id: invoices,
89
+ },
90
+ });
91
+ for (const paymentIntent of paymentIntents) {
92
+ await paymentIntent.update({
93
+ status: 'succeeded',
94
+ amount_received: paymentIntent.amount,
95
+ capture_method: 'manual',
96
+ last_payment_error: null,
97
+ payment_details: {
98
+ arcblock: {
99
+ tx_hash: txHash,
100
+ payer: userDid,
101
+ },
102
+ },
103
+ });
104
+
105
+ await handlePaymentSucceed(paymentIntent);
106
+
107
+ // cleanup the queue
108
+ let exist = await paymentQueue.get(paymentIntent.id);
109
+ if (exist) {
110
+ await paymentQueue.delete(paymentIntent.id);
111
+ }
112
+ if (paymentIntent.invoice_id) {
113
+ exist = await invoiceQueue.get(paymentIntent.invoice_id);
114
+ if (exist) {
115
+ await invoiceQueue.delete(paymentIntent.invoice_id);
116
+ }
117
+ }
118
+ }
119
+
120
+ return { hash: txHash };
121
+ }
122
+
123
+ throw new Error(`Payment method ${paymentMethod.type} not supported`);
124
+ },
125
+ };
@@ -4,6 +4,7 @@ import { toTypeInfo } from '@arcblock/did';
4
4
  import { toDelegateAddress } from '@arcblock/did-util';
5
5
  import { BN } from '@ocap/util';
6
6
  import { fromPublicKey } from '@ocap/wallet';
7
+ import isEmpty from 'lodash/isEmpty';
7
8
 
8
9
  import { estimateMaxGasForTx, hasStakedForGas } from '../../integrations/blockchain/stake';
9
10
  import { blocklet, wallet } from '../../libs/auth';
@@ -768,3 +769,34 @@ export async function ensureChangePaymentContext(subscriptionId: string) {
768
769
  paymentCurrency,
769
770
  };
770
771
  }
772
+
773
+ export async function ensureSubscriptionForCollectBatch(subscriptionId: string, currencyId: string) {
774
+ const subscription = await Subscription.findByPk(subscriptionId);
775
+ if (!subscription) {
776
+ throw new Error(`Subscription ${subscriptionId} not found when prepare batch collect`);
777
+ }
778
+
779
+ const paymentCurrency = await PaymentCurrency.findByPk(currencyId);
780
+ if (!paymentCurrency) {
781
+ throw new Error(`PaymentCurrency ${currencyId} not found when prepare batch collect`);
782
+ }
783
+
784
+ const [summary, detail] = await Invoice.getUncollectibleAmount({
785
+ subscriptionId: subscription.id,
786
+ customerId: subscription.customer_id,
787
+ currencyId,
788
+ });
789
+ if (isEmpty(summary) || !summary[currencyId] || summary[currencyId] === '0') {
790
+ throw new Error(`No uncollectible invoice found for subscription ${subscriptionId} to batch collect`);
791
+ }
792
+
793
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
794
+
795
+ return {
796
+ subscription,
797
+ paymentCurrency: paymentCurrency as PaymentCurrency,
798
+ paymentMethod: paymentMethod as PaymentMethod,
799
+ amount: summary[currencyId],
800
+ invoices: detail[currencyId],
801
+ };
802
+ }
@@ -1375,4 +1375,24 @@ router.get('/:id/usage-records', authPortal, (req, res) => {
1375
1375
  createUsageRecordQueryFn(req.doc)(req, res);
1376
1376
  });
1377
1377
 
1378
+ // Get invoice summary
1379
+ router.get('/:id/summary', authPortal, async (req, res) => {
1380
+ try {
1381
+ const subscription = await Subscription.findByPk(req.params.id);
1382
+ if (!subscription) {
1383
+ return res.status(404).json({ error: 'Subscription not found' });
1384
+ }
1385
+
1386
+ const [summary] = await Invoice.getUncollectibleAmount({
1387
+ subscriptionId: subscription.id,
1388
+ currencyId: subscription.currency_id,
1389
+ customerId: subscription.customer_id,
1390
+ });
1391
+ return res.json(summary);
1392
+ } catch (err) {
1393
+ console.error(err);
1394
+ return res.status(400).json({ error: err.message });
1395
+ }
1396
+ });
1397
+
1378
1398
  export default router;
@@ -161,11 +161,11 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
161
161
 
162
162
  public async getSummary() {
163
163
  const { PaymentIntent, Refund, Invoice } = this.sequelize.models;
164
- const [paid, due, refunded] = await Promise.all([
164
+ const [paid, [due], refunded] = await Promise.all([
165
165
  // @ts-ignore
166
166
  PaymentIntent!.getPaidAmountByCustomer(this.id),
167
167
  // @ts-ignore
168
- Invoice!.getUncollectibleAmountByCustomer(this.id),
168
+ Invoice!.getUncollectibleAmount({ customerId: this.id }),
169
169
  // @ts-ignore
170
170
  Refund!.getRefundAmountByCustomer(this.id),
171
171
  ]);
@@ -176,8 +176,8 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
176
176
  public async canMakeNewPurchase(excludedInvoiceId: string = '') {
177
177
  const { Invoice } = this.sequelize.models;
178
178
  // @ts-ignore
179
- const result = await Invoice!.getUncollectibleAmountByCustomer(this.id, excludedInvoiceId);
180
- return Object.entries(result).every(([, amount]) => new BN(amount).lte(new BN(0)));
179
+ const [summary] = await Invoice!.getUncollectibleAmount({ customerId: this.id, excludedInvoiceId });
180
+ return Object.entries(summary).every(([, amount]) => new BN(amount).lte(new BN(0)));
181
181
  }
182
182
 
183
183
  public getBalanceToApply(currencyId: string, amount: string) {
@@ -19,6 +19,7 @@ import type {
19
19
  CustomerShipping,
20
20
  DiscountAmount,
21
21
  GroupedBN,
22
+ GroupedStrList,
22
23
  PaymentError,
23
24
  PaymentSettings,
24
25
  SimpleCustomField,
@@ -523,44 +524,59 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
523
524
  return ['paid', 'void'].includes(this.status);
524
525
  }
525
526
 
526
- public static async getUncollectibleAmount(where: WhereOptions<Invoice>): Promise<GroupedBN> {
527
+ private static async _getUncollectibleAmount(where: WhereOptions<Invoice>): Promise<[GroupedBN, GroupedStrList]> {
527
528
  const invoices = await Invoice.findAll({ where });
528
- return invoices.reduce((acc: GroupedBN, invoice) => {
529
+ const summary: GroupedBN = {};
530
+ const detail: GroupedStrList = {};
531
+
532
+ invoices.forEach((invoice) => {
529
533
  const key = invoice.currency_id;
530
- if (!acc[key]) {
531
- acc[key] = '0';
534
+ if (!summary[key]) {
535
+ summary[key] = '0';
536
+ }
537
+ if (!detail[key]) {
538
+ detail[key] = [];
532
539
  }
533
540
 
534
- acc[key] = new BN(acc[key]).add(new BN(invoice.amount_remaining)).toString();
541
+ summary[key] = new BN(summary[key]).add(new BN(invoice.amount_remaining)).toString();
542
+ detail[key]!.push(invoice.id);
543
+ });
535
544
 
536
- return acc;
537
- }, {});
545
+ return [summary, detail];
538
546
  }
539
547
 
540
- public static getUncollectibleAmountByCustomer(customerId: string, excludedInvoiceId?: string) {
541
- const where: WhereOptions<Invoice> = {
542
- status: ['uncollectible'],
543
- customer_id: customerId,
544
- amount_remaining: { [Op.gt]: '0' },
545
- };
546
- if (excludedInvoiceId) {
547
- where.id = { [Op.not]: excludedInvoiceId };
548
+ public static getUncollectibleAmount({
549
+ customerId,
550
+ subscriptionId,
551
+ currencyId,
552
+ excludedInvoiceId,
553
+ }: {
554
+ customerId?: string;
555
+ subscriptionId?: string;
556
+ currencyId?: string;
557
+ excludedInvoiceId?: string;
558
+ }) {
559
+ if (!customerId && !subscriptionId) {
560
+ throw new Error('customerId or subscriptionId is required for getUncollectibleAmount');
548
561
  }
549
-
550
- return Invoice.getUncollectibleAmount(where);
551
- }
552
-
553
- public static getUncollectibleAmountBySubscription(subscriptionId: string, excludedInvoiceId?: string) {
554
562
  const where: WhereOptions<Invoice> = {
555
563
  status: ['uncollectible'],
556
- subscription_id: subscriptionId,
557
564
  amount_remaining: { [Op.gt]: '0' },
558
565
  };
566
+ if (currencyId) {
567
+ where.currency_id = currencyId;
568
+ }
569
+ if (customerId) {
570
+ where.customer_id = customerId;
571
+ }
572
+ if (subscriptionId) {
573
+ where.subscription_id = subscriptionId;
574
+ }
559
575
  if (excludedInvoiceId) {
560
576
  where.id = { [Op.not]: excludedInvoiceId };
561
577
  }
562
578
 
563
- return Invoice.getUncollectibleAmount(where);
579
+ return this._getUncollectibleAmount(where);
564
580
  }
565
581
  }
566
582
 
@@ -322,7 +322,7 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
322
322
  },
323
323
  });
324
324
  if (count === 2) {
325
- // 当且仅当 有试用期的订阅更新了 && 恰好有 2 次发票,此时订阅的试用期刚好结束
325
+ // 当且仅当 有试用期的订阅更新了 && 恰好有 2 次账单,此时订阅的试用期刚好结束
326
326
  createEvent('Subscription', 'customer.subscription.trial_end', model, options).catch(console.error);
327
327
  }
328
328
  }
@@ -2,6 +2,7 @@
2
2
  import type { LiteralUnion } from 'type-fest';
3
3
 
4
4
  export type GroupedBN = { [currencyId: string]: string };
5
+ export type GroupedStrList = { [currencyId: string]: string[] };
5
6
 
6
7
  export type Pagination<T = any> = T & {
7
8
  // offset based
@@ -1,17 +1,16 @@
1
1
  import { Op } from 'sequelize';
2
+
3
+ import { getWhereFromKvQuery } from '../../src/libs/api';
2
4
  import dayjs from '../../src/libs/dayjs';
3
5
  import {
4
6
  createCodeGenerator,
5
7
  createIdGenerator,
6
8
  formatMetadata,
7
9
  getDataObjectFromQuery,
8
-
9
10
  getNextRetry,
10
11
  tryWithTimeout,
11
12
  } from '../../src/libs/util';
12
13
 
13
- import { getWhereFromKvQuery } from '../../src/libs/api'
14
-
15
14
  describe('createIdGenerator', () => {
16
15
  it('should return a function that generates an ID with the specified prefix and size', () => {
17
16
  const generateId = createIdGenerator('test', 10);
@@ -199,11 +198,7 @@ describe('getWhereFromKvQuery', () => {
199
198
  const q = 'like-status:succ like-number:1533';
200
199
  const result = getWhereFromKvQuery(q);
201
200
  expect(result).toEqual({
202
- [Op.or]: [
203
- { status: { [Op.like]: `%succ%` } },
204
- { number: { [Op.like]: `%1533%` } }
205
- ],
201
+ [Op.or]: [{ status: { [Op.like]: '%succ%' } }, { number: { [Op.like]: '%1533%' } }],
206
202
  });
207
203
  });
208
-
209
- });
204
+ });
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.163
17
+ version: 1.13.165
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -93,7 +93,7 @@ navigation:
93
93
  - id: billing
94
94
  title:
95
95
  en: Billing
96
- zh: 订阅管理
96
+ zh: 我的账单
97
97
  icon: ion:receipt-outline
98
98
  link: /customer
99
99
  section:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.13.163",
3
+ "version": "1.13.165",
4
4
  "scripts": {
5
5
  "dev": "cross-env COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev --open",
6
6
  "eject": "vite eject",
@@ -50,7 +50,7 @@
50
50
  "@arcblock/jwt": "^1.18.110",
51
51
  "@arcblock/ux": "^2.9.39",
52
52
  "@blocklet/logger": "1.16.23",
53
- "@blocklet/payment-react": "1.13.163",
53
+ "@blocklet/payment-react": "1.13.165",
54
54
  "@blocklet/sdk": "1.16.23",
55
55
  "@blocklet/ui-react": "^2.9.39",
56
56
  "@blocklet/uploader": "^0.0.74",
@@ -110,7 +110,7 @@
110
110
  "devDependencies": {
111
111
  "@abtnode/types": "1.16.23",
112
112
  "@arcblock/eslint-config-ts": "^0.2.4",
113
- "@blocklet/payment-types": "1.13.163",
113
+ "@blocklet/payment-types": "1.13.165",
114
114
  "@types/cookie-parser": "^1.4.6",
115
115
  "@types/cors": "^2.8.17",
116
116
  "@types/dotenv-flow": "^3.3.3",
@@ -149,5 +149,5 @@
149
149
  "parser": "typescript"
150
150
  }
151
151
  },
152
- "gitHead": "2f485afbabe92bb6e63b09c4e69cb2ae06d3d2be"
152
+ "gitHead": "24fadab6620ca13856393399efd6a1dffc431d66"
153
153
  }
@@ -40,7 +40,7 @@ type Props = {
40
40
  setSearch: (x: any) => void;
41
41
  };
42
42
 
43
- export default function FilterTooolbar(props: Props) {
43
+ export default function FilterToolbar(props: Props) {
44
44
  const { setSearch, search, status, currency } = props;
45
45
  const isProduct = window.location.pathname.includes('product');
46
46
  const handleSearch = (obj: any) => {
@@ -71,7 +71,7 @@ const defaultProps = {
71
71
  currency: false,
72
72
  };
73
73
 
74
- FilterTooolbar.defaultProps = defaultProps;
74
+ FilterToolbar.defaultProps = defaultProps;
75
75
  SearchStatus.defaultProps = defaultProps;
76
76
  SearchCurrency.defaultProps = defaultProps;
77
77
  SearchProducts.defaultProps = defaultProps;
@@ -218,7 +218,7 @@ function SearchCustomers({ setSearch }: Pick<Props, 'setSearch'>) {
218
218
  'like-phone': text,
219
219
  },
220
220
  page: 1,
221
- pageSize: 100,
221
+ pageSize: 10,
222
222
  }).then((data: any) => {
223
223
  setCustomers(data.list);
224
224
  });
@@ -315,7 +315,7 @@ function SearchProducts({ setSearch }: Pick<Props, 'setSearch'>) {
315
315
  [key]: str,
316
316
  },
317
317
  page: 1,
318
- pageSize: 100,
318
+ pageSize: 10,
319
319
  });
320
320
  });
321
321
  }, [price]);
@@ -371,7 +371,7 @@ function SearchProducts({ setSearch }: Pick<Props, 'setSearch'>) {
371
371
  const Root = styled(Box)`
372
372
  .table-toolbar-left {
373
373
  display: flex;
374
- align-items: cneter;
374
+ align-items: center;
375
375
  }
376
376
 
377
377
  .table-toolbar-left section {
@@ -396,10 +396,6 @@ const Root = styled(Box)`
396
396
  padding: 0 3px;
397
397
  }
398
398
 
399
- .custom-toobar-title-inner {
400
- line-height: normal;
401
- }
402
-
403
399
  .status-options {
404
400
  position: absolute;
405
401
  box-shadow: 0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14),
@@ -419,8 +415,4 @@ const Root = styled(Box)`
419
415
  list-style: none;
420
416
  line-height: normal;
421
417
  }
422
-
423
- .custom-toobar-title-inner span {
424
- overflow: auto;
425
- }
426
418
  `;
@@ -9,7 +9,7 @@ import { useEffect, useState } from 'react';
9
9
  import { useNavigate } from 'react-router-dom';
10
10
 
11
11
  import CustomerLink from '../customer/link';
12
- import FilterTooolbar from '../filter-toolbar';
12
+ import FilterToolbar from '../filter-toolbar';
13
13
  import Table from '../table';
14
14
  import InvoiceActions from './action';
15
15
 
@@ -41,6 +41,7 @@ type ListProps = {
41
41
  features?: {
42
42
  customer?: boolean;
43
43
  toolbar?: boolean;
44
+ filter?: boolean;
44
45
  footer?: boolean;
45
46
  };
46
47
  customer_id?: string;
@@ -68,6 +69,7 @@ const getListKey = (props: ListProps) => {
68
69
  InvoiceList.defaultProps = {
69
70
  features: {
70
71
  customer: true,
72
+ filter: true,
71
73
  },
72
74
  customer_id: '',
73
75
  subscription_id: '',
@@ -150,20 +152,21 @@ export default function InvoiceList({ customer_id, subscription_id, features, st
150
152
  },
151
153
  },
152
154
  {
153
- label: t('admin.invoice.dueDate'),
154
- name: 'due_date',
155
+ label: t('common.createdAt'),
156
+ name: 'created_at',
155
157
  options: {
156
- customBodyRender: (e: number) => {
157
- return e ? formatTime(e * 1000) : '-';
158
+ sort: true,
159
+ responsive: 'vertical',
160
+ customBodyRender: (e: string) => {
161
+ return formatTime(e);
158
162
  },
159
163
  },
160
164
  },
161
165
  {
162
- label: t('common.createdAt'),
163
- name: 'created_at',
166
+ label: t('common.updatedAt'),
167
+ name: 'updated_at',
164
168
  options: {
165
169
  sort: true,
166
- responsive: 'vertical',
167
170
  customBodyRender: (e: string) => {
168
171
  return formatTime(e);
169
172
  },
@@ -260,7 +263,14 @@ export default function InvoiceList({ customer_id, subscription_id, features, st
260
263
  toolbar={features?.toolbar}
261
264
  footer={features?.footer}
262
265
  title={
263
- <FilterTooolbar setSearch={setSearch} search={search} status={['paid', 'open', 'uncollectible']} currency />
266
+ features?.filter && (
267
+ <FilterToolbar
268
+ setSearch={setSearch}
269
+ search={search}
270
+ status={['draft', 'open', 'void', 'paid', 'uncollectible']}
271
+ currency
272
+ />
273
+ )
264
274
  }
265
275
  />
266
276
  );
@@ -10,7 +10,7 @@ import { useNavigate } from 'react-router-dom';
10
10
 
11
11
  import { debounce } from '../../libs/util';
12
12
  import CustomerLink from '../customer/link';
13
- import FilterTooolbar from '../filter-toolbar';
13
+ import FilterToolbar from '../filter-toolbar';
14
14
  import Table from '../table';
15
15
  import PaymentIntentActions from './actions';
16
16
 
@@ -42,6 +42,7 @@ type ListProps = {
42
42
  features?: {
43
43
  customer?: boolean;
44
44
  toolbar?: boolean;
45
+ filter?: boolean;
45
46
  footer?: boolean;
46
47
  };
47
48
  customer_id?: string;
@@ -107,8 +108,15 @@ export default function PaymentList({ customer_id, invoice_id, features }: ListP
107
108
  customBodyRenderLite: (_: string, index: number) => {
108
109
  const item = data.list[index] as TPaymentIntentExpanded;
109
110
  return (
110
- <Typography component="strong" fontWeight={600}>
111
- {fromUnitToToken(item?.amount_received, item?.paymentCurrency.decimal)}&nbsp;
111
+ <Typography
112
+ component="strong"
113
+ sx={{ color: item.amount_received === '0' ? 'warning.main' : 'inherit' }}
114
+ fontWeight={600}>
115
+ {fromUnitToToken(
116
+ item.amount_received === '0' ? item.amount : item.amount_received,
117
+ item?.paymentCurrency.decimal
118
+ )}
119
+ &nbsp;
112
120
  {item?.paymentCurrency.symbol}
113
121
  </Typography>
114
122
  );
@@ -120,7 +128,6 @@ export default function PaymentList({ customer_id, invoice_id, features }: ListP
120
128
  name: 'status',
121
129
  width: 60,
122
130
  options: {
123
- filter: true,
124
131
  customBodyRenderLite: (_: string, index: number) => {
125
132
  const item = data.list[index] as TPaymentIntentExpanded;
126
133
  return <Status label={item.status} color={getPaymentIntentStatusColor(item.status)} />;
@@ -131,7 +138,6 @@ export default function PaymentList({ customer_id, invoice_id, features }: ListP
131
138
  label: t('common.description'),
132
139
  name: 'description',
133
140
  options: {
134
- filter: true,
135
141
  customBodyRenderLite: (_: string, index: number) => {
136
142
  const item = data.list[index] as TPaymentIntentExpanded;
137
143
  return item.description || item.id;
@@ -143,7 +149,16 @@ export default function PaymentList({ customer_id, invoice_id, features }: ListP
143
149
  name: 'created_at',
144
150
  options: {
145
151
  sort: true,
146
- filterType: 'dropdown',
152
+ customBodyRender: (e: string) => {
153
+ return formatTime(e);
154
+ },
155
+ },
156
+ },
157
+ {
158
+ label: t('common.updatedAt'),
159
+ name: 'updated_at',
160
+ options: {
161
+ sort: true,
147
162
  customBodyRender: (e: string) => {
148
163
  return formatTime(e);
149
164
  },
@@ -230,7 +245,22 @@ export default function PaymentList({ customer_id, invoice_id, features }: ListP
230
245
  toolbar={features?.toolbar}
231
246
  footer={features?.footer}
232
247
  title={
233
- <FilterTooolbar setSearch={setSearch} search={search} status={['succeeded', 'requires_action']} currency />
248
+ features?.filter && (
249
+ <FilterToolbar
250
+ setSearch={setSearch}
251
+ search={search}
252
+ status={[
253
+ 'requires_payment_method',
254
+ 'requires_confirmation',
255
+ 'requires_action',
256
+ 'processing',
257
+ 'requires_capture',
258
+ 'canceled',
259
+ 'succeeded',
260
+ ]}
261
+ currency
262
+ />
263
+ )
234
264
  }
235
265
  />
236
266
  );
@@ -9,7 +9,7 @@ import { useEffect, useState } from 'react';
9
9
  import { useNavigate } from 'react-router-dom';
10
10
 
11
11
  import CustomerLink from '../customer/link';
12
- import FilterTooolbar from '../filter-toolbar';
12
+ import FilterToolbar from '../filter-toolbar';
13
13
  import Table from '../table';
14
14
  import RefundActions from './actions';
15
15
 
@@ -42,6 +42,7 @@ type ListProps = {
42
42
  features?: {
43
43
  customer?: boolean;
44
44
  toolbar?: boolean;
45
+ filter?: boolean;
45
46
  footer?: boolean;
46
47
  };
47
48
  customer_id?: string;
@@ -231,7 +232,16 @@ export default function RefundList({ customer_id, invoice_id, subscription_id, f
231
232
  }}
232
233
  toolbar={features?.toolbar}
233
234
  footer={features?.footer}
234
- title={<FilterTooolbar setSearch={setSearch} search={search} status={['successed']} currency />}
235
+ title={
236
+ features?.filter && (
237
+ <FilterToolbar
238
+ setSearch={setSearch}
239
+ search={search}
240
+ status={['requires_action', 'pending', 'failed', 'canceled', 'succeeded']}
241
+ currency
242
+ />
243
+ )
244
+ }
235
245
  />
236
246
  );
237
247
  }
@@ -8,7 +8,7 @@ import { useEffect, useState } from 'react';
8
8
  import { useNavigate } from 'react-router-dom';
9
9
 
10
10
  import CustomerLink from '../customer/link';
11
- import FilterTooolbar from '../filter-toolbar';
11
+ import FilterToolbar from '../filter-toolbar';
12
12
  import Table from '../table';
13
13
  import SubscriptionActions from './actions';
14
14
  import SubscriptionDescription from './description';
@@ -41,6 +41,7 @@ type ListProps = {
41
41
  features?: {
42
42
  customer?: boolean;
43
43
  toolbar?: boolean;
44
+ filter?: boolean;
44
45
  footer?: boolean;
45
46
  };
46
47
  customer_id?: string;
@@ -222,7 +223,15 @@ export default function SubscriptionList({ customer_id, features, status }: List
222
223
  }}
223
224
  toolbar={features?.toolbar}
224
225
  footer={features?.footer}
225
- title={<FilterTooolbar setSearch={setSearch} search={search} status={['active', 'canceled']} />}
226
+ title={
227
+ features?.filter && (
228
+ <FilterToolbar
229
+ setSearch={setSearch}
230
+ search={search}
231
+ status={['active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'trialing', 'paused']}
232
+ />
233
+ )
234
+ }
226
235
  />
227
236
  );
228
237
  }
@@ -5,6 +5,7 @@ import { ConfirmDialog, api, formatError, formatToDate, getSubscriptionAction }
5
5
  import type { TSubscriptionExpanded } from '@blocklet/payment-types';
6
6
  import { Button, Link, Stack } from '@mui/material';
7
7
  import { useRequest, useSetState } from 'ahooks';
8
+ import isEmpty from 'lodash/isEmpty';
8
9
  import { FormProvider, useForm, useFormContext } from 'react-hook-form';
9
10
  import { useNavigate } from 'react-router-dom';
10
11
 
@@ -12,29 +13,52 @@ import CustomerCancelForm from './cancel';
12
13
 
13
14
  type Props = {
14
15
  subscription: TSubscriptionExpanded;
15
- showUpdate?: boolean;
16
+ showExtra?: boolean;
16
17
  onChange: (action?: string) => any | Promise<any>;
17
18
  };
18
19
 
19
20
  SubscriptionActions.defaultProps = {
20
- showUpdate: false,
21
+ showExtra: false,
21
22
  };
22
23
 
23
- const fetchUpdateOptions = ({ id, showUpdate }: { id: string; showUpdate: boolean }): Promise<boolean> => {
24
- if (!showUpdate) {
25
- return Promise.resolve(false);
24
+ const fetchExtraActions = async ({
25
+ id,
26
+ showExtra,
27
+ }: {
28
+ id: string;
29
+ showExtra: boolean;
30
+ }): Promise<{ changePlan: boolean; batchPay: string }> => {
31
+ if (!showExtra) {
32
+ return Promise.resolve({ changePlan: false, batchPay: '' });
26
33
  }
27
34
 
28
- return api.get(`/api/subscriptions/${id}/change-plan`).then((res) => !!res.data);
35
+ const [changePlan, batchPay] = await Promise.all([
36
+ api
37
+ .get(`/api/subscriptions/${id}/change-plan`)
38
+ .then((res) => !!res.data)
39
+ .catch(() => false),
40
+ api
41
+ .get(`/api/subscriptions/${id}/summary`)
42
+ .then((res) => {
43
+ if (!isEmpty(res.data) && Object.keys(res.data).length === 1) {
44
+ return Object.keys(res.data)[0] as string;
45
+ }
46
+
47
+ return '';
48
+ })
49
+ .catch(() => ''),
50
+ ]);
51
+
52
+ return { changePlan, batchPay };
29
53
  };
30
54
 
31
- export function SubscriptionActionsInner({ subscription, showUpdate, onChange }: Props) {
55
+ export function SubscriptionActionsInner({ subscription, showExtra, onChange }: Props) {
32
56
  const { t, locale } = useLocaleContext();
33
57
  const { reset, getValues } = useFormContext();
34
58
  const navigate = useNavigate();
35
59
  const action = getSubscriptionAction(subscription);
36
60
 
37
- const { data } = useRequest(() => fetchUpdateOptions({ id: subscription.id, showUpdate: !!showUpdate }));
61
+ const { data: extraActions } = useRequest(() => fetchExtraActions({ id: subscription.id, showExtra: !!showExtra }));
38
62
 
39
63
  const [state, setState] = useSetState({
40
64
  action: '',
@@ -75,7 +99,7 @@ export function SubscriptionActionsInner({ subscription, showUpdate, onChange }:
75
99
 
76
100
  return (
77
101
  <Stack direction="row" alignItems="center" spacing={1}>
78
- {action && (
102
+ {!extraActions?.batchPay && action && (
79
103
  <Button
80
104
  variant={action.variant as any}
81
105
  color={action.color as any}
@@ -93,7 +117,7 @@ export function SubscriptionActionsInner({ subscription, showUpdate, onChange }:
93
117
  {t(`payment.customer.${action.action}.button`)}
94
118
  </Button>
95
119
  )}
96
- {data && (
120
+ {extraActions?.changePlan && (
97
121
  <Button
98
122
  variant="contained"
99
123
  color="primary"
@@ -104,6 +128,17 @@ export function SubscriptionActionsInner({ subscription, showUpdate, onChange }:
104
128
  {t('payment.customer.changePlan.button')}
105
129
  </Button>
106
130
  )}
131
+ {!!extraActions?.batchPay && (
132
+ <Button
133
+ variant="contained"
134
+ color="info"
135
+ size="small"
136
+ onClick={() => {
137
+ navigate(`/customer/invoice/past-due?subscription=${subscription.id}&currency=${extraActions.batchPay}`);
138
+ }}>
139
+ {t('admin.subscription.batchPay.button')}
140
+ </Button>
141
+ )}
107
142
  {subscription.service_actions?.map((x) => (
108
143
  // @ts-ignore
109
144
  <Button
@@ -160,5 +195,5 @@ export default function SubscriptionActions(props: Props) {
160
195
  }
161
196
 
162
197
  SubscriptionActionsInner.defaultProps = {
163
- showUpdate: false,
198
+ showExtra: false,
164
199
  };
@@ -231,6 +231,7 @@ export default flat({
231
231
  customer: 'Customer portal',
232
232
  },
233
233
  paymentIntent: {
234
+ list: 'Payments',
234
235
  name: 'Payment',
235
236
  view: 'View payment detail',
236
237
  empty: 'No payment intent',
@@ -385,6 +386,9 @@ export default flat({
385
386
  vary: 'Varies with usage',
386
387
  used: 'Unit used',
387
388
  },
389
+ batchPay: {
390
+ button: 'Pay due invoices',
391
+ },
388
392
  },
389
393
  customer: {
390
394
  view: 'View customer',
@@ -21,8 +21,8 @@ export default flat({
21
21
  coupons: '优惠券',
22
22
  pricing: '定价',
23
23
  pricingTables: '价格表',
24
- billing: '订阅和发票',
25
- invoices: '发票',
24
+ billing: '订阅和账单',
25
+ invoices: '账单',
26
26
  subscriptions: '订阅',
27
27
  developers: '开发者工具箱',
28
28
  webhooks: '钩子',
@@ -53,7 +53,7 @@ export default flat({
53
53
  removeTip: '删除将隐藏此产品不再允许新购买。确定要删除此产品吗?',
54
54
  archived: '此产品已存档',
55
55
  archivedTip:
56
- '此产品无法添加到新的发票、订阅、支付链接或定价表。具有此产品的任何现有订阅将保持活动状态,直到取消,任何现有的支付链接或定价表将被停用。',
56
+ '此产品无法添加到新的账单、订阅、支付链接或定价表。具有此产品的任何现有订阅将保持活动状态,直到取消,任何现有的支付链接或定价表将被停用。',
57
57
  locked: '此产品已锁定,因为至少有一个价格用于订阅或支付。',
58
58
  image: {
59
59
  label: '图片',
@@ -71,7 +71,7 @@ export default flat({
71
71
  description: {
72
72
  label: '描述',
73
73
  required: '产品描述是必需的',
74
- placeholder: '在结账、发票页面显示的产品描述',
74
+ placeholder: '在结账、账单页面显示的产品描述',
75
75
  },
76
76
  statement_descriptor: {
77
77
  label: '声明描述',
@@ -183,7 +183,7 @@ export default flat({
183
183
  mintNft: '购买完成时铸造 NFT',
184
184
  mintNftFrom: '从下面的 NFT 集合铸造',
185
185
  noConfirmPage: '不显示确认页面',
186
- createInvoice: '为相关付款创建发票。',
186
+ createInvoice: '为相关付款创建账单。',
187
187
  adjustable: '可调整数量',
188
188
  adjustableQuantity: '允许客户调整数量',
189
189
  noProducts: '支付链接必须至少有一个产品',
@@ -223,10 +223,11 @@ export default flat({
223
223
  customer: '客户门户',
224
224
  },
225
225
  paymentIntent: {
226
- name: '支付',
227
- view: '查看支付详情',
228
- empty: '没有支付意向',
229
- refund: '退款支付',
226
+ list: '付款记录',
227
+ name: '付款记录',
228
+ view: '查看付款详情',
229
+ empty: '没有付款记录',
230
+ refund: '退款',
230
231
  received: '实收金额',
231
232
  },
232
233
  paymentMethod: {
@@ -297,11 +298,11 @@ export default flat({
297
298
  pendingWebhooks: '待处理的钩子',
298
299
  },
299
300
  invoice: {
300
- view: '查看发票',
301
- name: '发票',
302
- from: '发票来自',
303
- empty: '没有发票',
304
- number: '发票编号',
301
+ view: '查看账单',
302
+ name: '账单',
303
+ from: '账单来自',
304
+ empty: '没有账单',
305
+ number: '账单编号',
305
306
  dueDate: '截止日期',
306
307
  finalizedAt: '已完成时间',
307
308
  paidAt: '支付日期',
@@ -309,8 +310,8 @@ export default flat({
309
310
  customer: '开具给',
310
311
  billing: '计费方式',
311
312
  download: '下载PDF',
312
- edit: '编辑发票',
313
- duplicate: '复制发票',
313
+ edit: '编辑账单',
314
+ duplicate: '复制账单',
314
315
  },
315
316
  subscription: {
316
317
  view: '查看订阅',
@@ -323,11 +324,11 @@ export default flat({
323
324
  trailEnd: '试用期结束于{date}',
324
325
  discount: '折扣',
325
326
  startedAt: '开始于',
326
- nextInvoice: '下一张发票',
327
+ nextInvoice: '下一张账单',
327
328
  itemId: '订阅项目ID',
328
329
  update: '更新订阅',
329
330
  resume: '恢复付款',
330
- resumeTip: '您确定要继续收款吗?此订阅的未来发票将继续付款。',
331
+ resumeTip: '您确定要继续收款吗?此订阅的未来账单将继续付款。',
331
332
  cancel: {
332
333
  schedule: '计划取消',
333
334
  title: '取消订阅',
@@ -356,12 +357,12 @@ export default flat({
356
357
  custom: '暂停到自定义日期',
357
358
  },
358
359
  behavior: {
359
- title: '发票行为',
360
- keep_as_draft: '保留发票为草稿',
360
+ title: '账单行为',
361
+ keep_as_draft: '保留账单为草稿',
361
362
  keep_as_draft_tip: '对于目前提供服务但等待收款的企业。',
362
- mark_uncollectible: '将发票标记为不可收款',
363
+ mark_uncollectible: '将账单标记为不可收款',
363
364
  mark_uncollectible_tip: '对于目前提供免费服务的企业。',
364
- void: '作废发票',
365
+ void: '作废账单',
365
366
  voidTip: '对于目前不提供服务的企业。',
366
367
  },
367
368
  until: {
@@ -376,6 +377,9 @@ export default flat({
376
377
  vary: '随使用情况变化',
377
378
  used: '已使用单位',
378
379
  },
380
+ batchPay: {
381
+ button: '批量付款',
382
+ },
379
383
  },
380
384
  customer: {
381
385
  view: '查看客户',
@@ -387,7 +391,7 @@ export default flat({
387
391
  name: '名称',
388
392
  email: '电子邮件',
389
393
  phone: '电话',
390
- invoicePrefix: '发票前缀',
394
+ invoicePrefix: '账单前缀',
391
395
  balance: '余额 ({currency})',
392
396
  address: {
393
397
  label: '地址',
@@ -132,6 +132,16 @@ export default function InvoiceDetail(props: { id: string }) {
132
132
  label={t('admin.paymentCurrency.name')}
133
133
  value={<Currency logo={data.paymentCurrency.logo} name={data.paymentCurrency.symbol} />}
134
134
  />
135
+ {data.subscription && (
136
+ <InfoRow
137
+ label={t('admin.subscription.name')}
138
+ value={
139
+ <Link to={`/admin/billing/${data.subscription.id}`}>
140
+ {data.subscription.description || data.subscription.id}
141
+ </Link>
142
+ }
143
+ />
144
+ )}
135
145
  </Stack>
136
146
  </Box>
137
147
  <Box className="section">
@@ -1,5 +1,5 @@
1
1
  import InvoiceList from '../../../../components/invoice/list';
2
2
 
3
3
  export default function InvoicesList() {
4
- return <InvoiceList features={{ customer: true, toolbar: true }} />;
4
+ return <InvoiceList features={{ customer: true, toolbar: true, filter: true }} />;
5
5
  }
@@ -41,7 +41,7 @@ export default function PaymentIndex() {
41
41
  // @ts-ignore
42
42
  const TabComponent = pages[page] || pages.intents;
43
43
  const tabs = [
44
- { label: t('admin.payments'), value: 'intents' },
44
+ { label: t('admin.paymentIntent.list'), value: 'intents' },
45
45
  { label: t('admin.refunds'), value: 'refunds' },
46
46
  { label: t('admin.paymentLinks'), value: 'links' },
47
47
  ];
@@ -1,6 +1,12 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import Toast from '@arcblock/ux/lib/Toast';
3
- import { CustomerInvoiceList, formatError, getPrefix, usePaymentContext } from '@blocklet/payment-react';
3
+ import {
4
+ CustomerInvoiceList,
5
+ PaymentProvider,
6
+ formatError,
7
+ getPrefix,
8
+ usePaymentContext,
9
+ } from '@blocklet/payment-react';
4
10
  import type { TCustomerExpanded } from '@blocklet/payment-types';
5
11
  import { ArrowBackOutlined } from '@mui/icons-material';
6
12
  import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
@@ -18,7 +24,7 @@ const fetchData = (): Promise<TCustomerExpanded> => {
18
24
  return api.get('/api/customers/me').then((res) => res.data);
19
25
  };
20
26
 
21
- export default function CustomerInvoicePastDue() {
27
+ export function CustomerInvoicePastDue() {
22
28
  const { t } = useLocaleContext();
23
29
  const { events } = useSessionContext();
24
30
  const { connect } = usePaymentContext();
@@ -49,12 +55,13 @@ export default function CustomerInvoicePastDue() {
49
55
  }
50
56
 
51
57
  const subscriptionId = params.get('subscription') || '';
58
+ const currencyId = params.get('currency') || '';
52
59
  const handleBatchPay = () => {
53
60
  connect.open({
54
61
  containerEl: undefined as unknown as Element,
55
62
  action: 'collect-batch',
56
63
  prefix: joinURL(window.location.origin, getPrefix(), '/api/did'),
57
- extraParams: { subscriptionId },
64
+ extraParams: { subscriptionId, currencyId },
58
65
  onSuccess: () => {
59
66
  connect.close();
60
67
  },
@@ -83,9 +90,9 @@ export default function CustomerInvoicePastDue() {
83
90
  <Alert severity="info">{t('payment.customer.pastDue.warning')}</Alert>
84
91
  <Box className="section">
85
92
  <SectionHeader title={t('payment.customer.pastDue.invoices')} mb={0}>
86
- {subscriptionId && false && (
87
- <Button size="small" variant="contained" color="primary" onClick={handleBatchPay}>
88
- {t('payment.customer.pastDue.batchPay')}
93
+ {subscriptionId && currencyId && (
94
+ <Button size="small" variant="contained" color="info" onClick={handleBatchPay}>
95
+ {t('admin.subscription.batchPay.button')}
89
96
  </Button>
90
97
  )}
91
98
  </SectionHeader>
@@ -93,9 +100,11 @@ export default function CustomerInvoicePastDue() {
93
100
  <CustomerInvoiceList
94
101
  customer_id={data.id}
95
102
  subscription_id={subscriptionId}
103
+ currency_id={currencyId}
96
104
  pageSize={100}
97
105
  status="uncollectible"
98
106
  target="_blank"
107
+ action="pay"
99
108
  />
100
109
  </Box>
101
110
  </Box>
@@ -109,3 +118,13 @@ const Root = styled(Stack)`
109
118
  text-decoration: underline;
110
119
  }
111
120
  `;
121
+
122
+ export default function PastDueWrapper() {
123
+ const { session, connectApi } = useSessionContext();
124
+
125
+ return (
126
+ <PaymentProvider session={session} connect={connectApi}>
127
+ <CustomerInvoicePastDue />
128
+ </PaymentProvider>
129
+ );
130
+ }
@@ -60,7 +60,7 @@ export default function CustomerSubscriptionDetail() {
60
60
  <SubscriptionDescription subscription={data} variant="h5" />
61
61
  <SubscriptionStatus subscription={data} sx={{ ml: 1 }} />
62
62
  </Stack>
63
- <SubscriptionActions subscription={data} onChange={() => refresh()} showUpdate />
63
+ <SubscriptionActions subscription={data} onChange={() => refresh()} showExtra />
64
64
  </Stack>
65
65
  <Stack
66
66
  className="section-body"