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.
- package/api/src/integrations/stripe/resource.ts +29 -1
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +202 -0
- package/api/src/libs/security.ts +15 -2
- package/api/src/libs/session.ts +13 -1
- package/api/src/locales/en.ts +20 -0
- package/api/src/locales/zh.ts +16 -0
- package/api/src/queues/auto-recharge.ts +3 -14
- package/api/src/queues/notification.ts +32 -2
- package/api/src/queues/payment.ts +16 -1
- package/api/src/routes/auto-recharge-configs.ts +9 -0
- package/api/src/routes/checkout-sessions.ts +71 -13
- package/api/src/routes/invoices.ts +8 -1
- package/api/src/store/models/checkout-session.ts +1 -0
- package/blocklet.yml +1 -1
- package/package.json +13 -13
- package/src/components/customer/credit-overview.tsx +4 -2
- package/src/components/customer/link.tsx +1 -0
- package/src/components/invoice/table.tsx +54 -1
- package/src/components/invoice-pdf/template.tsx +9 -2
- package/src/locales/en.tsx +1 -0
- package/src/locales/zh.tsx +1 -0
- package/src/pages/checkout/pay.tsx +12 -1
|
@@ -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
|
+
}
|
package/api/src/libs/security.ts
CHANGED
|
@@ -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>({
|
|
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) {
|
package/api/src/libs/session.ts
CHANGED
|
@@ -158,11 +158,23 @@ export function getRecurringPeriod(recurring: PriceRecurring) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
export function expandLineItems(
|
|
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
|
|
package/api/src/locales/en.ts
CHANGED
|
@@ -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.',
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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('/',
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'],
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
51
|
-
"@arcblock/jwt": "^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.
|
|
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.
|
|
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.
|
|
68
|
-
"@ocap/client": "^1.
|
|
69
|
-
"@ocap/mcrypto": "^1.
|
|
70
|
-
"@ocap/util": "^1.
|
|
71
|
-
"@ocap/wallet": "^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.
|
|
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": "
|
|
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
|
-
|
|
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';
|
|
@@ -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')}>
|
|
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>
|
package/src/locales/en.tsx
CHANGED
package/src/locales/zh.tsx
CHANGED
|
@@ -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
|
-
|
|
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} />;
|