payment-kit 1.13.162 → 1.13.164

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
+ }
@@ -2,7 +2,6 @@ import { fromTokenToUnit } from '@ocap/util';
2
2
  import { Router } from 'express';
3
3
  import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
- import type { WhereOptions } from 'sequelize';
6
5
 
7
6
  import { getWhereFromKvQuery, getWhereFromQuery } from '../libs/api';
8
7
  import logger from '../libs/logger';
@@ -112,22 +111,33 @@ const paginationSchema = Joi.object<{
112
111
  pageSize: number;
113
112
  livemode?: boolean;
114
113
  active?: boolean;
114
+ status?: string;
115
115
  name?: string;
116
116
  description?: string;
117
+ q?: string;
118
+ o?: string;
117
119
  }>({
118
120
  page: Joi.number().integer().min(1).default(1),
119
121
  pageSize: Joi.number().integer().min(1).max(100).default(20),
120
122
  livemode: Joi.boolean().empty(''),
121
123
  active: Joi.boolean().empty(''),
124
+ status: Joi.string().empty(''),
122
125
  name: Joi.string().empty(''),
123
126
  description: Joi.string().empty(''),
127
+ q: Joi.string().empty(''), // query
128
+ o: Joi.string().empty(''), // order
124
129
  });
125
130
  router.get('/', auth, async (req, res) => {
126
131
  const { page, pageSize, active, livemode, name, description, ...query } = await paginationSchema.validateAsync(
127
132
  req.query,
128
133
  { stripUnknown: false, allowUnknown: true }
129
134
  );
130
- const where: WhereOptions<Product> = {};
135
+ const where = getWhereFromKvQuery(query.q);
136
+
137
+ if (query.status) {
138
+ // 兼容处理,支持 status
139
+ where.active = query.status === 'active';
140
+ }
131
141
 
132
142
  if (typeof active === 'boolean') {
133
143
  where.active = active;
@@ -151,7 +161,7 @@ router.get('/', auth, async (req, res) => {
151
161
 
152
162
  const { rows: list, count } = await Product.findAndCountAll({
153
163
  where,
154
- order: [['created_at', 'DESC']],
164
+ order: [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']],
155
165
  offset: (page - 1) * pageSize,
156
166
  limit: pageSize,
157
167
  include: [{ model: Price, as: 'prices' }],
@@ -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.162
17
+ version: 1.13.164
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.162",
3
+ "version": "1.13.164",
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.162",
53
+ "@blocklet/payment-react": "1.13.164",
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.162",
113
+ "@blocklet/payment-types": "1.13.164",
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": "e11cae8972d35cde567d4784511aacd33a29c1bc"
152
+ "gitHead": "3172c382ed27451d90ea78ebd99087658fe7362c"
153
153
  }