payment-kit 1.19.23 → 1.20.0

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.
@@ -732,6 +732,16 @@ export async function createStripeInvoiceForAutoRecharge(params: {
732
732
  // Ensure stripe customer exists
733
733
  const stripeCustomer = await ensureStripeCustomer(customer, paymentMethod);
734
734
 
735
+ if (invoice.metadata?.stripe_id) {
736
+ const existInvoice = await client.invoices.retrieve(invoice.metadata?.stripe_id);
737
+ if (existInvoice) {
738
+ logger.info('Stripe invoice already exists, skipping', {
739
+ localInvoiceId: invoice.id,
740
+ });
741
+ return existInvoice;
742
+ }
743
+ }
744
+
735
745
  const stripeInvoice = await client.invoices.create({
736
746
  customer: stripeCustomer.id,
737
747
  currency: currency.symbol.toLowerCase(),
@@ -751,6 +761,18 @@ export async function createStripeInvoiceForAutoRecharge(params: {
751
761
  customerId: customer.id,
752
762
  });
753
763
 
764
+ await invoice.update({
765
+ metadata: {
766
+ ...invoice.metadata,
767
+ stripe_id: stripeInvoice.id,
768
+ },
769
+ });
770
+
771
+ logger.info('Stripe invoice updated', {
772
+ localInvoiceId: invoice.id,
773
+ stripeInvoiceId: stripeInvoice.id,
774
+ });
775
+
754
776
  // Create invoice items from local invoice items
755
777
  const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: invoice.id } });
756
778
  for (const item of invoiceItems) {
@@ -759,7 +781,7 @@ export async function createStripeInvoiceForAutoRecharge(params: {
759
781
  continue;
760
782
  }
761
783
  const stripePrice = await ensureStripePrice(price as Price, paymentMethod, currency);
762
- await client.invoiceItems.create({
784
+ const stripeInvoiceItem = await client.invoiceItems.create({
763
785
  customer: stripeCustomer.id,
764
786
  invoice: stripeInvoice.id,
765
787
  price: stripePrice.id,
@@ -777,6 +799,12 @@ export async function createStripeInvoiceForAutoRecharge(params: {
777
799
  priceId: price.id,
778
800
  quantity: item.quantity,
779
801
  });
802
+ await item.update({
803
+ metadata: {
804
+ ...item.metadata,
805
+ stripe_id: stripeInvoiceItem.id,
806
+ },
807
+ });
780
808
  }
781
809
 
782
810
  // Finalize and pay the invoice automatically
@@ -0,0 +1,202 @@
1
+ /* eslint-disable @typescript-eslint/brace-style */
2
+ /* eslint-disable @typescript-eslint/indent */
3
+ import camelCase from 'lodash/camelCase';
4
+
5
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
6
+ import { translate } from '../../../locales';
7
+ import { AutoRechargeConfig, Customer, Invoice, PaymentMethod, Price, Product } from '../../../store/models';
8
+ import { PaymentCurrency } from '../../../store/models/payment-currency';
9
+ import { getCustomerInvoicePageUrl } from '../../invoice';
10
+ import { SufficientForPaymentResult, getPaymentDetail } from '../../payment';
11
+ import { formatTime } from '../../time';
12
+ import { formatCurrencyInfo } from '../../util';
13
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
14
+
15
+ export interface CustomerAutoRechargeFailedEmailTemplateOptions {
16
+ customerId: string;
17
+ autoRechargeConfigId: string;
18
+ invoiceId: string;
19
+ paymentIntentId: string;
20
+ result: SufficientForPaymentResult;
21
+ }
22
+
23
+ interface CustomerAutoRechargeFailedEmailTemplateContext {
24
+ locale: string;
25
+ userDid: string;
26
+ at: string;
27
+ reason: string;
28
+ paymentInfo: string;
29
+ autoRechargeAmount: string;
30
+ creditCurrencyName: string;
31
+ viewInvoiceLink: string;
32
+ }
33
+
34
+ export class CustomerAutoRechargeFailedEmailTemplate
35
+ implements BaseEmailTemplate<CustomerAutoRechargeFailedEmailTemplateContext>
36
+ {
37
+ options: CustomerAutoRechargeFailedEmailTemplateOptions;
38
+
39
+ constructor(options: CustomerAutoRechargeFailedEmailTemplateOptions) {
40
+ this.options = options;
41
+ }
42
+
43
+ private async getReason(userDid: string, invoice: Invoice, locale: string): Promise<string> {
44
+ if (this.options.result?.sufficient) {
45
+ throw new Error(`SufficientForPaymentResult.sufficient should be false: ${JSON.stringify(this.options.result)}`);
46
+ }
47
+
48
+ const i18nText = `notification.autoRechargeFailed.reason.${camelCase(this.options.result.reason as string)}`;
49
+
50
+ const paymentDetail = await getPaymentDetail(userDid, invoice);
51
+ const reason = translate(i18nText, locale, {
52
+ balance: `${paymentDetail.balance} ${paymentDetail.symbol}`,
53
+ price: `${paymentDetail.price} ${paymentDetail.symbol}`,
54
+ });
55
+
56
+ return reason;
57
+ }
58
+
59
+ async getContext(): Promise<CustomerAutoRechargeFailedEmailTemplateContext> {
60
+ const customer = await Customer.findByPk(this.options.customerId);
61
+ if (!customer) {
62
+ throw new Error(`Customer not found: ${this.options.customerId}`);
63
+ }
64
+
65
+ const autoRechargeConfig = await AutoRechargeConfig.findByPk(this.options.autoRechargeConfigId, {
66
+ include: [
67
+ {
68
+ model: Price,
69
+ as: 'price',
70
+ include: [{ model: Product, as: 'product' }],
71
+ },
72
+ ],
73
+ });
74
+ if (!autoRechargeConfig) {
75
+ throw new Error(`AutoRechargeConfig not found: ${this.options.autoRechargeConfigId}`);
76
+ }
77
+
78
+ const invoice = await Invoice.findByPk(this.options.invoiceId);
79
+ if (!invoice) {
80
+ throw new Error(`Invoice not found: ${this.options.invoiceId}`);
81
+ }
82
+
83
+ const paymentCurrency = (await PaymentCurrency.findOne({
84
+ where: {
85
+ id: invoice.currency_id,
86
+ },
87
+ })) as PaymentCurrency;
88
+
89
+ const userDid: string = customer.did;
90
+ const locale = await getUserLocale(userDid);
91
+ const at: string = formatTime(Date.now());
92
+ const reason: string = await this.getReason(userDid, invoice, locale);
93
+
94
+ const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
95
+ const paymentInfo: string = formatCurrencyInfo(invoice.amount_remaining, paymentCurrency, paymentMethod);
96
+
97
+ // 获取 credit currency
98
+ const creditCurrency = await PaymentCurrency.findByPk(autoRechargeConfig.currency_id);
99
+ if (!creditCurrency) {
100
+ throw new Error(`Credit currency not found: ${autoRechargeConfig.currency_id}`);
101
+ }
102
+
103
+ // 格式化自动充值金额(根据 invoice 的金额)
104
+ const autoRechargeAmount = formatCurrencyInfo(invoice.amount_remaining, paymentCurrency, paymentMethod);
105
+
106
+ const creditCurrencyName = creditCurrency.name || 'Credits';
107
+
108
+ const viewInvoiceLink = getCustomerInvoicePageUrl({
109
+ invoiceId: invoice.id,
110
+ userDid,
111
+ locale,
112
+ action: 'pay',
113
+ });
114
+
115
+ return {
116
+ locale,
117
+ userDid,
118
+ at,
119
+ reason,
120
+ paymentInfo,
121
+ autoRechargeAmount,
122
+ creditCurrencyName,
123
+ viewInvoiceLink,
124
+ };
125
+ }
126
+
127
+ async getTemplate(): Promise<BaseEmailTemplateType> {
128
+ const { locale, userDid, at, reason, paymentInfo, creditCurrencyName, viewInvoiceLink } = await this.getContext();
129
+
130
+ const template: BaseEmailTemplateType = {
131
+ title: translate('notification.autoRechargeFailed.title', locale),
132
+ body: translate('notification.autoRechargeFailed.body', locale, {
133
+ at,
134
+ creditCurrencyName,
135
+ }),
136
+ // @ts-expect-error
137
+ attachments: [
138
+ {
139
+ type: 'section',
140
+ fields: [
141
+ {
142
+ type: 'text',
143
+ data: {
144
+ type: 'plain',
145
+ color: '#9397A1',
146
+ text: translate('notification.common.account', locale),
147
+ },
148
+ },
149
+ {
150
+ type: 'text',
151
+ data: {
152
+ type: 'plain',
153
+ text: userDid,
154
+ },
155
+ },
156
+ {
157
+ type: 'text',
158
+ data: {
159
+ type: 'plain',
160
+ color: '#9397A1',
161
+ text: translate('notification.common.paymentAmount', locale),
162
+ },
163
+ },
164
+ {
165
+ type: 'text',
166
+ data: {
167
+ type: 'plain',
168
+ text: paymentInfo,
169
+ },
170
+ },
171
+ {
172
+ type: 'text',
173
+ data: {
174
+ type: 'plain',
175
+ color: '#9397A1',
176
+ text: translate('notification.common.failReason', locale),
177
+ },
178
+ },
179
+ {
180
+ type: 'text',
181
+ data: {
182
+ type: 'plain',
183
+ color: '#FF0000',
184
+ text: reason,
185
+ },
186
+ },
187
+ ].filter(Boolean),
188
+ },
189
+ ].filter(Boolean),
190
+ // @ts-ignore
191
+ actions: [
192
+ {
193
+ name: translate('notification.common.renewNow', locale),
194
+ title: translate('notification.common.renewNow', locale),
195
+ link: viewInvoiceLink,
196
+ },
197
+ ].filter(Boolean),
198
+ };
199
+
200
+ return template;
201
+ }
202
+ }
@@ -21,6 +21,7 @@ type PermissionSpec<T extends Model> = {
21
21
  };
22
22
  mine?: boolean;
23
23
  embed?: boolean;
24
+ ensureLogin?: boolean;
24
25
  };
25
26
 
26
27
  /**
@@ -29,7 +30,14 @@ type PermissionSpec<T extends Model> = {
29
30
  * If a request is authenticated by component call, it will set `req.user` to the component user.
30
31
  * If a request is authenticated by record owner, it will set `req.user` to the session user and set `req.doc` to the record.
31
32
  */
32
- export function authenticate<T extends Model>({ component, roles, record, mine, embed }: PermissionSpec<T>) {
33
+ export function authenticate<T extends Model>({
34
+ component,
35
+ roles,
36
+ record,
37
+ mine,
38
+ embed,
39
+ ensureLogin,
40
+ }: PermissionSpec<T>) {
33
41
  return async (req: Request, res: Response, next: NextFunction) => {
34
42
  // authenticate by component call
35
43
  const sig = req.get('x-component-sig');
@@ -78,7 +86,7 @@ export function authenticate<T extends Model>({ component, roles, record, mine,
78
86
  }
79
87
 
80
88
  if (req.headers['x-user-did']) {
81
- const role = (<string>req.headers['x-user-role'] || '').replace('blocklet-', '');
89
+ const role = (<string>req.headers['x-user-role'] || '').replace('blocklet-', '') || 'guest';
82
90
  req.user = {
83
91
  did: <string>req.headers['x-user-did'],
84
92
  role,
@@ -95,6 +103,11 @@ export function authenticate<T extends Model>({ component, roles, record, mine,
95
103
  }
96
104
  }
97
105
 
106
+ if (ensureLogin) {
107
+ req.user.via = 'api';
108
+ return next();
109
+ }
110
+
98
111
  if (mine) {
99
112
  const customer = await Customer.findOne({ where: { did: req.user.did } });
100
113
  if (customer) {
@@ -158,11 +158,23 @@ export function getRecurringPeriod(recurring: PriceRecurring) {
158
158
  }
159
159
  }
160
160
 
161
- export function expandLineItems(items: any[], products: Product[], prices: Price[]) {
161
+ export function expandLineItems(
162
+ items: any[],
163
+ products: Product[],
164
+ prices: Price[],
165
+ creditCurrencies?: TPaymentCurrency[]
166
+ ) {
162
167
  items.forEach((item) => {
163
168
  item.price = prices.find((x) => x.id === item.price_id);
164
169
  if (item.price) {
165
170
  item.price.product = products.find((x) => x.id === item.price.product_id);
171
+ if (item.price.product?.type === 'credit' && item.price.metadata?.credit_config) {
172
+ item.price.credit = {
173
+ amount: Number(item.price.metadata.credit_config.credit_amount || 0) * Number(item.quantity),
174
+ currency_id: item.price.metadata.credit_config.currency_id,
175
+ currency: creditCurrencies?.find((x) => x.id === item.price.metadata.credit_config.currency_id),
176
+ };
177
+ }
166
178
  }
167
179
  });
168
180
 
@@ -142,6 +142,26 @@ export default flat({
142
142
  },
143
143
  },
144
144
 
145
+ autoRechargeFailed: {
146
+ title: 'Auto Top-Up payment failed',
147
+ body: 'We are sorry to inform you that your {creditCurrencyName} auto top-up failed to go through the automatic payment on {at}. If you have any questions, please contact us in time. Thank you!',
148
+ reason: {
149
+ noDidWallet: 'You have not bound DID Wallet, please bind DID Wallet to ensure sufficient balance',
150
+ noDelegation: 'Your DID Wallet has not been authorized, please update authorization',
151
+ noTransferPermission:
152
+ 'Your DID Wallet has not granted transfer permission to the application, please update authorization',
153
+ noTokenPermission:
154
+ 'Your DID Wallet has not granted token transfer permission to the application, please update authorization',
155
+ noTransferTo:
156
+ 'Your DID Wallet has not granted the application automatic payment permission, please update authorization',
157
+ noEnoughAllowance: 'The deduction amount exceeds the single transfer limit, please update authorization',
158
+ noToken: 'Your account has no tokens, please add funds',
159
+ noEnoughToken: 'Your account token balance is {balance}, insufficient for {price}, please add funds',
160
+ noSupported: 'It is not supported to automatically pay with tokens, please check your package',
161
+ txSendFailed: 'Failed to send automatic payment transaction',
162
+ },
163
+ },
164
+
145
165
  subscriptionRefundSucceeded: {
146
166
  title: '{productName} refund successful',
147
167
  body: 'Your subscription to {productName} has been successfully refunded on {at}, with a refund amount of {refundInfo}. If you have any questions, please feel free to contact us.',
@@ -137,6 +137,22 @@ export default flat({
137
137
  txSendFailed: '扣费交易发送失败。',
138
138
  },
139
139
  },
140
+ autoRechargeFailed: {
141
+ title: '自动充值扣费失败',
142
+ body: '很抱歉地通知您,您的 {creditCurrencyName} 自动充值于 {at} 扣费失败。如有任何疑问,请及时联系我们。谢谢!',
143
+ reason: {
144
+ noDidWallet: '您尚未绑定 DID Wallet,请绑定 DID Wallet,确保余额充足。',
145
+ noDelegation: '您的 DID Wallet 尚未授权,请更新授权。',
146
+ noTransferPermission: '您的 DID Wallet 未授予应用转账权限,请更新授权。',
147
+ noTokenPermission: '您的 DID Wallet 未授予应用对应通证的转账权限,请更新授权。',
148
+ noTransferTo: '您的 DID Wallet 未授予应用扣费权限,请更新授权。',
149
+ noEnoughAllowance: '扣款金额超出单笔转账限额,请更新授权。',
150
+ noToken: '您的账户没有任何代币,请充值代币。',
151
+ noEnoughToken: '您的账户代币余额为 {balance},不足 {price},请充值代币。',
152
+ noSupported: '不支持使用代币扣费,请检查您的套餐。',
153
+ txSendFailed: '扣费交易发送失败。',
154
+ },
155
+ },
140
156
 
141
157
  subscriptionRefundSucceeded: {
142
158
  title: '{productName} 退款成功',
@@ -1,6 +1,5 @@
1
1
  import { BN } from '@ocap/util';
2
2
 
3
- import { CustomError } from '@blocklet/error';
4
3
  import { Op } from 'sequelize';
5
4
  import createQueue from '../libs/queue';
6
5
  import {
@@ -16,7 +15,6 @@ import {
16
15
  } from '../store/models';
17
16
  import logger from '../libs/logger';
18
17
  import { getPriceUintAmountByCurrency } from '../libs/session';
19
- import { isDelegationSufficientForPayment } from '../libs/payment';
20
18
  import { createStripeInvoiceForAutoRecharge } from '../integrations/stripe/resource';
21
19
  import { ensureInvoiceAndItems } from '../libs/invoice';
22
20
  import dayjs from '../libs/dayjs';
@@ -146,8 +144,9 @@ async function createInvoiceForAutoRecharge({
146
144
  where: {
147
145
  customer_id: customer.id,
148
146
  currency_id: rechargeCurrency.id,
147
+ billing_reason: 'auto_recharge',
149
148
  status: {
150
- [Op.in]: ['open', 'draft'],
149
+ [Op.in]: ['open', 'draft', 'uncollectible'],
151
150
  },
152
151
  },
153
152
  });
@@ -228,17 +227,7 @@ async function executeAutoRecharge(
228
227
  if (!payer) {
229
228
  throw new Error('No payer found for auto recharge');
230
229
  }
231
- if (paymentMethod.type !== 'stripe') {
232
- const delegationCheck = await isDelegationSufficientForPayment({
233
- paymentMethod,
234
- paymentCurrency: rechargeCurrency,
235
- userDid: payer,
236
- amount: totalAmount.toString(),
237
- });
238
- if (!delegationCheck.sufficient) {
239
- throw new CustomError(delegationCheck.reason, 'insufficient delegation or balance');
240
- }
241
- }
230
+
242
231
  const invoice = await createInvoiceForAutoRecharge({
243
232
  customer,
244
233
  config,
@@ -1,5 +1,5 @@
1
- /* eslint-disable @typescript-eslint/indent */
2
1
  import { Op } from 'sequelize';
2
+ /* eslint-disable @typescript-eslint/indent */
3
3
  import { events } from '../libs/event';
4
4
  import logger from '../libs/logger';
5
5
  import dayjs from '../libs/dayjs';
@@ -98,6 +98,10 @@ import {
98
98
  CustomerCreditLowBalanceEmailTemplate,
99
99
  CustomerCreditLowBalanceEmailTemplateOptions,
100
100
  } from '../libs/notification/template/customer-credit-low-balance';
101
+ import {
102
+ CustomerAutoRechargeFailedEmailTemplate,
103
+ CustomerAutoRechargeFailedEmailTemplateOptions,
104
+ } from '../libs/notification/template/customer-auto-recharge-failed';
101
105
  import {
102
106
  CustomerRevenueSucceededEmailTemplate,
103
107
  CustomerRevenueSucceededEmailTemplateOptions,
@@ -129,7 +133,8 @@ export type NotificationQueueJobType =
129
133
  | 'subscription.overdraftProtection.exhausted'
130
134
  | 'customer.credit.insufficient'
131
135
  | 'customer.credit_grant.granted'
132
- | 'customer.credit.low_balance';
136
+ | 'customer.credit.low_balance'
137
+ | 'customer.auto_recharge.failed';
133
138
 
134
139
  export type NotificationQueueJob = {
135
140
  type: NotificationQueueJobType;
@@ -271,6 +276,10 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
271
276
  return new CustomerCreditLowBalanceEmailTemplate(job.options as CustomerCreditLowBalanceEmailTemplateOptions);
272
277
  }
273
278
 
279
+ if (job.type === 'customer.auto_recharge.failed') {
280
+ return new CustomerAutoRechargeFailedEmailTemplate(job.options as CustomerAutoRechargeFailedEmailTemplateOptions);
281
+ }
282
+
274
283
  throw new Error(`Unknown job type: ${job.type}`);
275
284
  }
276
285
 
@@ -633,6 +642,27 @@ export async function startNotificationQueue() {
633
642
  24 * 3600 // 1天
634
643
  );
635
644
  });
645
+
646
+ events.on(
647
+ 'customer.auto_recharge.failed',
648
+ async (customer: Customer, { autoRechargeConfigId, invoiceId, paymentIntentId }) => {
649
+ const invoice = await Invoice.findByPk(invoiceId);
650
+ logger.info('addNotificationJob:customer.auto_recharge.failed', autoRechargeConfigId);
651
+ if (invoice && autoRechargeConfigId && invoice.metadata?.auto_recharge_failed_reason) {
652
+ addNotificationJob(
653
+ 'customer.auto_recharge.failed',
654
+ {
655
+ customerId: customer.id,
656
+ autoRechargeConfigId,
657
+ invoiceId,
658
+ paymentIntentId,
659
+ result: invoice.metadata?.auto_recharge_failed_reason,
660
+ },
661
+ [customer.id, autoRechargeConfigId, invoiceId]
662
+ );
663
+ }
664
+ }
665
+ );
636
666
  }
637
667
 
638
668
  export async function handleNotificationPreferenceChange(
@@ -1065,10 +1065,11 @@ export const handlePayment = async (job: PaymentJob) => {
1065
1065
 
1066
1066
  // 只有在 第一次重试 或者 重试次数超过阈值 的时候才发送邮件,不然邮件频率太高了
1067
1067
  const minRetryMail = updates.minRetryMail || MIN_RETRY_MAIL;
1068
+ const needSendMail = attemptCount === 1 || attemptCount >= minRetryMail;
1068
1069
 
1069
1070
  logger.warn('catch:PaymentIntent capture failed', { id: paymentIntent.id, attemptCount, minRetryMail, invoice });
1070
1071
 
1071
- if ((attemptCount === 1 || attemptCount >= minRetryMail) && invoice.billing_reason === 'subscription_cycle') {
1072
+ if (needSendMail && invoice.billing_reason === 'subscription_cycle') {
1072
1073
  const subscription = await Subscription.findByPk(invoice.subscription_id);
1073
1074
  if (subscription) {
1074
1075
  await subscription.update({
@@ -1087,6 +1088,20 @@ export const handlePayment = async (job: PaymentJob) => {
1087
1088
  }
1088
1089
  }
1089
1090
  }
1091
+
1092
+ if (needSendMail && invoice.billing_reason === 'auto_recharge') {
1093
+ await invoice.update({
1094
+ metadata: {
1095
+ ...invoice.metadata,
1096
+ auto_recharge_failed_reason: result || { sufficient: false, reason: 'TX_SEND_FAILED' },
1097
+ },
1098
+ });
1099
+ createEvent('Customer', 'customer.auto_recharge.failed', customer, {
1100
+ autoRechargeConfigId: invoice.metadata?.auto_recharge?.config_id,
1101
+ invoiceId: invoice.id,
1102
+ paymentIntentId: paymentIntent.id,
1103
+ });
1104
+ }
1090
1105
  // reschedule next attempt
1091
1106
  const retryAt = updates.invoice.next_payment_attempt;
1092
1107
  if (retryAt) {
@@ -284,6 +284,15 @@ async function checkSufficientBalance({
284
284
  payer,
285
285
  };
286
286
  }
287
+ if (forceReauthorize) {
288
+ return {
289
+ sufficient: false,
290
+ delegation: {
291
+ sufficient: false,
292
+ reason: 'NEED_REAUTHORIZE',
293
+ },
294
+ };
295
+ }
287
296
  const delegation = await isDelegationSufficientForPayment({
288
297
  paymentMethod,
289
298
  paymentCurrency: rechargeCurrency,
@@ -15,6 +15,7 @@ import type { WhereOptions } from 'sequelize';
15
15
 
16
16
  import { CustomError, formatError, getStatusFromError } from '@blocklet/error';
17
17
  import pAll from 'p-all';
18
+ import { withQuery } from 'ufo';
18
19
  import { MetadataSchema } from '../libs/api';
19
20
  import { checkPassportForPaymentLink } from '../integrations/blocklet/passport';
20
21
  import dayjs from '../libs/dayjs';
@@ -48,6 +49,7 @@ import {
48
49
  CHECKOUT_SESSION_TTL,
49
50
  formatAmountPrecisionLimit,
50
51
  formatMetadata,
52
+ getConnectQueryParam,
51
53
  getDataObjectFromQuery,
52
54
  getUserOrAppInfo,
53
55
  isUserInBlocklist,
@@ -98,6 +100,8 @@ const router = Router();
98
100
  const user = sessionMiddleware({ accessKey: true });
99
101
  const auth = authenticate<CheckoutSession>({ component: true, roles: ['owner', 'admin'] });
100
102
 
103
+ const authLogin = authenticate<CheckoutSession>({ component: true, roles: ['owner', 'admin'], ensureLogin: true });
104
+
101
105
  const getPaymentMethods = async (doc: CheckoutSession) => {
102
106
  const paymentMethods = await PaymentMethod.expand(doc.livemode, { type: doc.payment_method_types });
103
107
  const supportedCurrencies = getSupportedPaymentCurrencies(doc.line_items as any[]);
@@ -769,22 +773,54 @@ async function processSubscriptionFastCheckout({
769
773
  }
770
774
 
771
775
  // create checkout session
772
- router.post('/', auth, async (req, res) => {
773
- const raw: Partial<CheckoutSession> = await formatCheckoutSession(req.body);
774
- raw.livemode = !!req.livemode;
775
- raw.created_via = req.user?.via as string;
776
- if (raw.line_items) {
777
- try {
778
- await validateInventory(raw.line_items, true);
779
- } catch (err) {
780
- logger.error('validateInventory failed', { error: err, line_items: raw.line_items });
781
- return res.status(400).json({ error: err.message });
776
+ router.post('/', authLogin, async (req, res) => {
777
+ try {
778
+ const raw: Partial<CheckoutSession> = await formatCheckoutSession(req.body);
779
+ raw.livemode = !!req.livemode;
780
+ raw.created_via = req.user?.via as string;
781
+
782
+ // Customer permission validation and createMine handling
783
+ const { create_mine: createMine } = req.body;
784
+ const currentUserDid = req.user?.did;
785
+ // Handle createMine parameter
786
+ if (createMine === true) {
787
+ if (!currentUserDid) {
788
+ return res.status(400).json({ error: 'User not authenticated, cannot create checkout session for self' });
789
+ }
790
+
791
+ const currentCustomer = await Customer.findOne({ where: { did: currentUserDid } });
792
+
793
+ // Set customer info and record who created it
794
+ raw.customer_id = currentCustomer?.id;
795
+ raw.customer_did = currentUserDid;
796
+ raw.metadata = {
797
+ ...(raw.metadata || {}),
798
+ createdBy: currentUserDid,
799
+ };
800
+ } else if (!['owner', 'admin'].includes(req.user?.role as string)) {
801
+ return res.status(403).json({ error: 'Not authorized to perform this action' });
802
+ }
803
+
804
+ if (raw.line_items) {
805
+ try {
806
+ await validateInventory(raw.line_items, true);
807
+ } catch (err) {
808
+ logger.error('validateInventory failed', { error: err, line_items: raw.line_items });
809
+ return res.status(400).json({ error: err.message });
810
+ }
782
811
  }
783
- }
784
812
 
785
- const doc = await CheckoutSession.create(raw as any);
813
+ const doc = await CheckoutSession.create(raw as any);
814
+ let url = getUrl(`/checkout/${doc.submit_type}/${doc.id}`);
815
+ if (createMine && currentUserDid) {
816
+ url = withQuery(url, getConnectQueryParam({ userDid: currentUserDid }));
817
+ }
786
818
 
787
- res.json({ ...doc.toJSON(), url: getUrl(`/checkout/${doc.submit_type}/${doc.id}`) });
819
+ res.json({ ...doc.toJSON(), url });
820
+ } catch (error) {
821
+ logger.error('Create checkout session failed', { error: error.message, body: req.body });
822
+ res.status(500).json({ error: error.message });
823
+ }
788
824
  });
789
825
 
790
826
  export async function startCheckoutSessionFromPaymentLink(id: string, req: Request, res: Response) {
@@ -1083,6 +1119,17 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1083
1119
  }
1084
1120
  }
1085
1121
 
1122
+ // Validate checkout session ownership if it was created for a specific customer
1123
+ if (checkoutSession.customer_did && checkoutSession.metadata?.createdBy) {
1124
+ const createdByDid = checkoutSession.metadata.createdBy;
1125
+ if (createdByDid !== req.user.did) {
1126
+ return res.status(403).json({
1127
+ error:
1128
+ "It's not allowed to submit checkout sessions created by other users, please create your own checkout session",
1129
+ });
1130
+ }
1131
+ }
1132
+
1086
1133
  let customer = await Customer.findOne({ where: { did: req.user.did } });
1087
1134
  if (!customer) {
1088
1135
  const { user: userInfo } = await blocklet.getUser(req.user.did);
@@ -1631,6 +1678,17 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
1631
1678
  return res.status(400).json({ error: 'Payment method not supported for fast checkout' });
1632
1679
  }
1633
1680
 
1681
+ // Validate checkout session ownership if it was created for a specific customer
1682
+ if (checkoutSession.customer_id && checkoutSession.metadata?.createdBy) {
1683
+ const createdByDid = checkoutSession.metadata.createdBy;
1684
+ if (createdByDid !== req.user.did) {
1685
+ return res.status(403).json({
1686
+ error:
1687
+ "It's not allowed to submit checkout sessions created by other users, please create your own checkout session",
1688
+ });
1689
+ }
1690
+ }
1691
+
1634
1692
  const customer = await Customer.findByPkOrDid(req.user.did);
1635
1693
  if (!customer) {
1636
1694
  return res.status(400).json({ error: '' });
@@ -635,8 +635,15 @@ router.get('/:id', authPortal, async (req, res) => {
635
635
  }
636
636
  const products = (await Product.findAll()).map((x) => x.toJSON());
637
637
  const prices = (await Price.findAll()).map((x) => x.toJSON());
638
+ const paymentCurrencies = (
639
+ await PaymentCurrency.findAll({
640
+ where: {
641
+ type: 'credit',
642
+ },
643
+ })
644
+ ).map((x) => x.toJSON());
638
645
  // @ts-ignore
639
- expandLineItems(json.lines, products, prices);
646
+ expandLineItems(json.lines, products, prices, paymentCurrencies);
640
647
  if (doc.metadata?.invoice_id || doc.metadata?.prev_invoice_id) {
641
648
  const relatedInvoice = await Invoice.findByPk(doc.metadata.invoice_id || doc.metadata.prev_invoice_id, {
642
649
  attributes: ['id', 'number', 'status', 'billing_reason'],
@@ -178,6 +178,7 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
178
178
  };
179
179
  days_until_due?: number;
180
180
  days_until_cancel?: number;
181
+ no_stake?: boolean;
181
182
  };
182
183
 
183
184
  // 3rd party payment tx hash
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.19.23
17
+ version: 1.20.0
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.19.23",
3
+ "version": "1.20.0",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
@@ -44,18 +44,18 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@abtnode/cron": "^1.16.48",
47
- "@arcblock/did": "^1.23.1",
47
+ "@arcblock/did": "^1.24.3",
48
48
  "@arcblock/did-connect-react": "^3.1.32",
49
49
  "@arcblock/did-connect-storage-nedb": "^1.8.0",
50
- "@arcblock/did-util": "^1.23.1",
51
- "@arcblock/jwt": "^1.23.1",
50
+ "@arcblock/did-util": "^1.24.3",
51
+ "@arcblock/jwt": "^1.24.3",
52
52
  "@arcblock/ux": "^3.1.32",
53
- "@arcblock/validator": "^1.23.1",
53
+ "@arcblock/validator": "^1.24.3",
54
54
  "@blocklet/did-space-js": "^1.1.19",
55
55
  "@blocklet/error": "^0.2.5",
56
56
  "@blocklet/js-sdk": "^1.16.48",
57
57
  "@blocklet/logger": "^1.16.48",
58
- "@blocklet/payment-react": "1.19.23",
58
+ "@blocklet/payment-react": "1.20.0",
59
59
  "@blocklet/sdk": "^1.16.48",
60
60
  "@blocklet/ui-react": "^3.1.32",
61
61
  "@blocklet/uploader": "^0.2.7",
@@ -64,11 +64,11 @@
64
64
  "@mui/lab": "7.0.0-beta.14",
65
65
  "@mui/material": "^7.1.2",
66
66
  "@mui/system": "^7.1.1",
67
- "@ocap/asset": "^1.23.1",
68
- "@ocap/client": "^1.23.1",
69
- "@ocap/mcrypto": "^1.23.1",
70
- "@ocap/util": "^1.23.1",
71
- "@ocap/wallet": "^1.23.1",
67
+ "@ocap/asset": "^1.24.3",
68
+ "@ocap/client": "^1.24.3",
69
+ "@ocap/mcrypto": "^1.24.3",
70
+ "@ocap/util": "^1.24.3",
71
+ "@ocap/wallet": "^1.24.3",
72
72
  "@stripe/react-stripe-js": "^2.9.0",
73
73
  "@stripe/stripe-js": "^2.4.0",
74
74
  "ahooks": "^3.8.5",
@@ -124,7 +124,7 @@
124
124
  "devDependencies": {
125
125
  "@abtnode/types": "^1.16.48",
126
126
  "@arcblock/eslint-config-ts": "^0.3.3",
127
- "@blocklet/payment-types": "1.19.23",
127
+ "@blocklet/payment-types": "1.20.0",
128
128
  "@types/cookie-parser": "^1.4.9",
129
129
  "@types/cors": "^2.8.19",
130
130
  "@types/debug": "^4.1.12",
@@ -171,5 +171,5 @@
171
171
  "parser": "typescript"
172
172
  }
173
173
  },
174
- "gitHead": "1bb6bff218ad207b078af97a1700bfe1dc85817a"
174
+ "gitHead": "ca71d3996c6c5be18827a9f3d516bab117b327dd"
175
175
  }
@@ -98,7 +98,9 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
98
98
  try {
99
99
  const response = await api.get(`/api/payment-currencies/${currency.id}/recharge-config`).then((res) => res.data);
100
100
  if (response.recharge_config && response.recharge_config.payment_url) {
101
- window.open(response.recharge_config.payment_url, '_blank');
101
+ const url = new URL(response.recharge_config.payment_url);
102
+ url.searchParams.set('redirect', window.location.href);
103
+ window.open(url.toString(), '_self');
102
104
  } else {
103
105
  Toast.error(t('customer.recharge.unsupported'));
104
106
  }
@@ -138,7 +140,7 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
138
140
  return null;
139
141
  }
140
142
 
141
- const showRecharge = grantData.paymentCurrency.recharge_config?.base_price_id;
143
+ const showRecharge = grantData.paymentCurrency.recharge_config?.base_price_id && mode === 'portal';
142
144
 
143
145
  const totalAmount = grantData.totalAmount || '0';
144
146
  const remainingAmount = grantData.remainingAmount || '0';
@@ -39,6 +39,7 @@ export default function CustomerLink({
39
39
  }}
40
40
  popupInfoType={InfoType.Minimal}
41
41
  showDid={size !== 'small'}
42
+ popupShowDid
42
43
  {...(customer.metadata.anonymous === true
43
44
  ? {
44
45
  user: {
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable react/no-unstable-nested-components */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
- import { formatAmount, Table, getPriceUintAmountByCurrency } from '@blocklet/payment-react';
3
+ import { formatAmount, Table, getPriceUintAmountByCurrency, formatNumber } from '@blocklet/payment-react';
4
4
  import type { TInvoiceExpanded, TInvoiceItem } from '@blocklet/payment-types';
5
5
  import { InfoOutlined } from '@mui/icons-material';
6
6
  import { Box, Stack, Tooltip, Typography } from '@mui/material';
@@ -22,6 +22,10 @@ type Props = {
22
22
  type InvoiceDetailItem = {
23
23
  id: string;
24
24
  product: string;
25
+ credits?: {
26
+ total: number;
27
+ currency: string;
28
+ };
25
29
  quantity: number;
26
30
  rawQuantity: number;
27
31
  price: string;
@@ -64,11 +68,22 @@ export function getInvoiceRows(invoice: TInvoiceExpanded) {
64
68
  ? toBN(line.amount).div(toBN(line.quantity)).toString()
65
69
  : getPriceUintAmountByCurrency(line.price, invoice.paymentCurrency) || line.amount;
66
70
 
71
+ const creditInfo = (line.price as any).credit;
72
+ let credits: { total: number; currency: string } | undefined;
73
+
74
+ if (creditInfo?.amount) {
75
+ credits = {
76
+ total: creditInfo.amount,
77
+ currency: creditInfo.currency?.name || 'Credits',
78
+ };
79
+ }
80
+
67
81
  return {
68
82
  id: line.id,
69
83
  product: `${line.description} ${
70
84
  line.price.product.unit_label ? ` (per ${line.price.product.unit_label})` : ''
71
85
  }`.trim(),
86
+ credits,
72
87
  quantity: line.quantity,
73
88
  rawQuantity: line.metadata?.quantity || 0,
74
89
  price: !line.proration ? formatAmount(price, invoice.paymentCurrency.decimal) : '',
@@ -149,6 +164,44 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
149
164
  {
150
165
  label: t('common.description'),
151
166
  name: 'product',
167
+ options: {
168
+ customBodyRenderLite: (_: string, index: number) => {
169
+ const item = detail[index] as InvoiceDetailItem;
170
+ return (
171
+ <Box>
172
+ <Typography
173
+ component="div"
174
+ sx={{
175
+ textAlign: {
176
+ xs: 'right',
177
+ md: 'left',
178
+ },
179
+ }}>
180
+ {item.product}
181
+ </Typography>
182
+ {item.credits && (
183
+ <Typography
184
+ component="div"
185
+ variant="caption"
186
+ sx={{
187
+ color: 'text.secondary',
188
+ mt: 0.5,
189
+ wordBreak: 'break-word',
190
+ textAlign: {
191
+ xs: 'right',
192
+ md: 'left',
193
+ },
194
+ }}>
195
+ {t('customer.invoice.creditsInfo', {
196
+ amount: formatNumber(item.credits.total),
197
+ currency: item.credits.currency,
198
+ })}
199
+ </Typography>
200
+ )}
201
+ </Box>
202
+ );
203
+ },
204
+ },
152
205
  },
153
206
  {
154
207
  label: t('common.quantity'),
@@ -1,4 +1,4 @@
1
- import { formatTime } from '@blocklet/payment-react';
1
+ import { formatNumber, formatTime } from '@blocklet/payment-react';
2
2
  import { useEffect } from 'react';
3
3
  import type { InvoicePDFProps } from './types';
4
4
  import { composeStyles } from './utils';
@@ -88,7 +88,14 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
88
88
  {detail.map((line) => (
89
89
  <div key={line.id} style={composeStyles('row flex')}>
90
90
  <div style={composeStyles('w-48 p-4-8 pb-15')}>
91
- <span style={composeStyles('dark')}>{line.product}</span>
91
+ <span style={composeStyles('dark')}>
92
+ {line.product}
93
+ {line.credits && (
94
+ <span style={composeStyles('block gray fs-10 mt-5')}>
95
+ {formatNumber(line.credits.total)} {line.credits.currency}
96
+ </span>
97
+ )}
98
+ </span>
92
99
  </div>
93
100
  <div style={composeStyles('w-17 p-4-8 pb-15')}>
94
101
  <span style={composeStyles('dark right')}>{line.quantity}</span>
@@ -1305,6 +1305,7 @@ export default flat({
1305
1305
  invoice: {
1306
1306
  relatedInvoice: 'Related Invoice',
1307
1307
  donation: 'Donation',
1308
+ creditsInfo: 'Total {amount} {currency} included',
1308
1309
  },
1309
1310
  payout: {
1310
1311
  empty: 'No Revenues',
@@ -1258,6 +1258,7 @@ export default flat({
1258
1258
  invoice: {
1259
1259
  relatedInvoice: '关联账单',
1260
1260
  donation: '打赏记录',
1261
+ creditsInfo: '总共包含 {amount} {currency}',
1261
1262
  },
1262
1263
  payout: {
1263
1264
  empty: '没有收款记录',
@@ -1,10 +1,13 @@
1
1
  import { CheckoutForm } from '@blocklet/payment-react';
2
+ import { useSearchParams } from 'react-router-dom';
2
3
 
3
4
  type Props = {
4
5
  id: string;
5
6
  };
6
7
 
7
8
  export default function Payment({ id }: Props) {
9
+ const [searchParams] = useSearchParams();
10
+ const redirect = searchParams.get('redirect');
8
11
  const onPaid = (data: any) => {
9
12
  if (data?.checkoutSession?.success_url) {
10
13
  setTimeout(() => {
@@ -12,15 +15,23 @@ export default function Payment({ id }: Props) {
12
15
  tmp.searchParams.set('checkout_session_id', data.checkoutSession.id);
13
16
  window.location.replace(tmp.href);
14
17
  }, 1000);
15
- } else if (data?.paymentLink) {
18
+ return;
19
+ }
20
+ if (data?.paymentLink) {
16
21
  if (data.paymentLink.after_completion?.type === 'redirect' && data.paymentLink.after_completion?.redirect?.url) {
17
22
  setTimeout(() => {
18
23
  const tmp = new URL(data.paymentLink?.after_completion?.redirect?.url as string, window.location.origin);
19
24
  tmp.searchParams.set('checkout_session_id', data.checkoutSession.id);
20
25
  window.location.replace(tmp.href);
21
26
  }, 1000);
27
+ return;
22
28
  }
23
29
  }
30
+ if (redirect) {
31
+ setTimeout(() => {
32
+ window.location.replace(redirect);
33
+ }, 1000);
34
+ }
24
35
  };
25
36
 
26
37
  return <CheckoutForm mode="standalone" id={id} onPaid={onPaid} />;