payment-kit 1.19.0 → 1.19.2
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/crons/index.ts +8 -0
- package/api/src/index.ts +4 -0
- package/api/src/libs/credit-grant.ts +146 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +4 -3
- package/api/src/libs/notification/template/base.ts +388 -2
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
- package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
- package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
- package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
- package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
- package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
- package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
- package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
- package/api/src/libs/payment.ts +69 -0
- package/api/src/libs/queue/index.ts +3 -2
- package/api/src/libs/session.ts +8 -0
- package/api/src/libs/subscription.ts +74 -3
- package/api/src/libs/util.ts +3 -1
- package/api/src/libs/ws.ts +23 -1
- package/api/src/locales/en.ts +33 -0
- package/api/src/locales/zh.ts +31 -0
- package/api/src/queues/credit-consume.ts +728 -0
- package/api/src/queues/credit-grant.ts +572 -0
- package/api/src/queues/notification.ts +173 -128
- package/api/src/queues/payment.ts +210 -122
- package/api/src/queues/subscription.ts +179 -0
- package/api/src/routes/checkout-sessions.ts +157 -9
- package/api/src/routes/connect/shared.ts +3 -2
- package/api/src/routes/credit-grants.ts +241 -0
- package/api/src/routes/credit-transactions.ts +208 -0
- package/api/src/routes/customers.ts +34 -5
- package/api/src/routes/index.ts +8 -0
- package/api/src/routes/meter-events.ts +347 -0
- package/api/src/routes/meters.ts +219 -0
- package/api/src/routes/payment-currencies.ts +20 -2
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +14 -2
- package/api/src/routes/prices.ts +43 -0
- package/api/src/routes/pricing-table.ts +13 -7
- package/api/src/routes/products.ts +63 -4
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/subscriptions.ts +4 -0
- package/api/src/routes/webhook-endpoints.ts +0 -3
- package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
- package/api/src/store/models/credit-grant.ts +486 -0
- package/api/src/store/models/credit-transaction.ts +268 -0
- package/api/src/store/models/customer.ts +8 -0
- package/api/src/store/models/index.ts +52 -1
- package/api/src/store/models/meter-event.ts +423 -0
- package/api/src/store/models/meter.ts +176 -0
- package/api/src/store/models/payment-currency.ts +66 -14
- package/api/src/store/models/price.ts +6 -0
- package/api/src/store/models/product.ts +2 -2
- package/api/src/store/models/subscription.ts +24 -0
- package/api/src/store/models/types.ts +28 -2
- package/api/tests/libs/subscription.spec.ts +53 -0
- package/blocklet.yml +9 -1
- package/package.json +4 -4
- package/scripts/sdk.js +233 -1
- package/src/app.tsx +10 -0
- package/src/components/collapse.tsx +11 -1
- package/src/components/conditional-section.tsx +87 -0
- package/src/components/customer/credit-grant-item-list.tsx +99 -0
- package/src/components/customer/credit-overview.tsx +246 -0
- package/src/components/customer/form.tsx +7 -3
- package/src/components/invoice/list.tsx +19 -1
- package/src/components/metadata/form.tsx +287 -91
- package/src/components/meter/actions.tsx +101 -0
- package/src/components/meter/add-usage-dialog.tsx +239 -0
- package/src/components/meter/events-list.tsx +657 -0
- package/src/components/meter/form.tsx +245 -0
- package/src/components/meter/products.tsx +264 -0
- package/src/components/meter/usage-guide.tsx +174 -0
- package/src/components/payment-currency/form.tsx +2 -0
- package/src/components/payment-intent/list.tsx +19 -1
- package/src/components/payment-link/item.tsx +2 -2
- package/src/components/payment-link/preview.tsx +1 -1
- package/src/components/payment-link/product-select.tsx +52 -12
- package/src/components/payment-method/arcblock.tsx +2 -0
- package/src/components/payment-method/base.tsx +2 -0
- package/src/components/payment-method/bitcoin.tsx +2 -0
- package/src/components/payment-method/ethereum.tsx +2 -0
- package/src/components/payment-method/stripe.tsx +2 -0
- package/src/components/payouts/list.tsx +19 -1
- package/src/components/payouts/portal/list.tsx +6 -11
- package/src/components/price/currency-select.tsx +56 -32
- package/src/components/price/form.tsx +912 -407
- package/src/components/pricing-table/preview.tsx +1 -1
- package/src/components/product/add-price.tsx +9 -7
- package/src/components/product/create.tsx +7 -4
- package/src/components/product/edit-price.tsx +21 -12
- package/src/components/product/features.tsx +17 -7
- package/src/components/product/form.tsx +100 -90
- package/src/components/refund/list.tsx +19 -1
- package/src/components/section/header.tsx +5 -18
- package/src/components/subscription/items/index.tsx +1 -1
- package/src/components/subscription/metrics.tsx +37 -5
- package/src/components/subscription/portal/actions.tsx +2 -1
- package/src/contexts/products.tsx +26 -9
- package/src/hooks/subscription.ts +34 -0
- package/src/libs/meter-utils.ts +196 -0
- package/src/libs/util.ts +4 -0
- package/src/locales/en.tsx +389 -5
- package/src/locales/zh.tsx +368 -1
- package/src/pages/admin/billing/index.tsx +61 -33
- package/src/pages/admin/billing/invoices/detail.tsx +1 -1
- package/src/pages/admin/billing/meters/create.tsx +60 -0
- package/src/pages/admin/billing/meters/detail.tsx +435 -0
- package/src/pages/admin/billing/meters/index.tsx +210 -0
- package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
- package/src/pages/admin/customers/customers/detail.tsx +14 -10
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/admin/developers/events/detail.tsx +1 -1
- package/src/pages/admin/developers/index.tsx +1 -1
- package/src/pages/admin/payments/intents/detail.tsx +1 -1
- package/src/pages/admin/payments/payouts/detail.tsx +1 -1
- package/src/pages/admin/payments/refunds/detail.tsx +1 -1
- package/src/pages/admin/products/index.tsx +3 -2
- package/src/pages/admin/products/links/detail.tsx +1 -1
- package/src/pages/admin/products/prices/actions.tsx +16 -4
- package/src/pages/admin/products/prices/detail.tsx +30 -3
- package/src/pages/admin/products/prices/list.tsx +8 -1
- package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
- package/src/pages/admin/products/products/create.tsx +233 -57
- package/src/pages/admin/products/products/detail.tsx +2 -1
- package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
- package/src/pages/customer/credit-grant/detail.tsx +308 -0
- package/src/pages/customer/index.tsx +44 -9
- package/src/pages/customer/recharge/account.tsx +5 -5
- package/src/pages/customer/subscription/change-payment.tsx +4 -2
- package/src/pages/customer/subscription/detail.tsx +48 -14
- package/src/pages/customer/subscription/embed.tsx +1 -1
package/api/src/crons/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
batchHandleStripeSubscriptions,
|
|
8
8
|
} from '../integrations/stripe/resource';
|
|
9
9
|
import {
|
|
10
|
+
creditConsumptionCronTime,
|
|
10
11
|
depositVaultCronTime,
|
|
11
12
|
expiredSessionCleanupCronTime,
|
|
12
13
|
meteringSubscriptionDetectionCronTime,
|
|
@@ -27,6 +28,7 @@ import { SubscriptionWillCanceledSchedule } from './subscription-will-canceled';
|
|
|
27
28
|
import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
|
|
28
29
|
import { createMeteringSubscriptionDetection } from './metering-subscription-detection';
|
|
29
30
|
import { startDepositVaultQueue } from '../queues/payment';
|
|
31
|
+
import { startCreditConsumeQueue } from '../queues/credit-consume';
|
|
30
32
|
|
|
31
33
|
function init() {
|
|
32
34
|
Cron.init({
|
|
@@ -107,6 +109,12 @@ function init() {
|
|
|
107
109
|
fn: () => startDepositVaultQueue(),
|
|
108
110
|
options: { runOnInit: true },
|
|
109
111
|
},
|
|
112
|
+
{
|
|
113
|
+
name: 'credit.consumption',
|
|
114
|
+
time: creditConsumptionCronTime,
|
|
115
|
+
fn: () => startCreditConsumeQueue(),
|
|
116
|
+
options: { runOnInit: true },
|
|
117
|
+
},
|
|
110
118
|
],
|
|
111
119
|
onError: (error: Error, name: string) => {
|
|
112
120
|
logger.error('run job failed', { name, error });
|
package/api/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { startPaymentQueue } from './queues/payment';
|
|
|
28
28
|
import { startPayoutQueue } from './queues/payout';
|
|
29
29
|
import { startRefundQueue } from './queues/refund';
|
|
30
30
|
import { startSubscriptionQueue } from './queues/subscription';
|
|
31
|
+
import { startCreditConsumeQueue } from './queues/credit-consume';
|
|
32
|
+
import { startCreditGrantQueue } from './queues/credit-grant';
|
|
31
33
|
import routes from './routes';
|
|
32
34
|
import changePaymentHandlers from './routes/connect/change-payment';
|
|
33
35
|
import changePlanHandlers from './routes/connect/change-plan';
|
|
@@ -118,6 +120,8 @@ export const server = app.listen(port, (err?: any) => {
|
|
|
118
120
|
startCheckoutSessionQueue().then(() => logger.info('checkoutSession queue started'));
|
|
119
121
|
startNotificationQueue().then(() => logger.info('notification queue started'));
|
|
120
122
|
startRefundQueue().then(() => logger.info('refund queue started'));
|
|
123
|
+
startCreditConsumeQueue().then(() => logger.info('credit queue started'));
|
|
124
|
+
startCreditGrantQueue().then(() => logger.info('credit grant queue started'));
|
|
121
125
|
startUploadBillingInfoListener();
|
|
122
126
|
|
|
123
127
|
if (process.env.BLOCKLET_MODE === 'production') {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
2
|
+
|
|
3
|
+
import { CreditGrant, Customer, PaymentCurrency, Subscription } from '../store/models';
|
|
4
|
+
import { formatMetadata } from './util';
|
|
5
|
+
import logger from './logger';
|
|
6
|
+
import dayjs from './dayjs';
|
|
7
|
+
import { getMeterPriceIdsFromSubscription } from './subscription';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 创建 CreditGrant - 抽象重复的创建逻辑
|
|
11
|
+
* 注意:amount应该已经是Unit格式(在路由层处理fromTokenToUnit转换)
|
|
12
|
+
*/
|
|
13
|
+
export async function createCreditGrant(params: {
|
|
14
|
+
amount: string; // Unit格式的金额
|
|
15
|
+
currency_id?: string;
|
|
16
|
+
customer_id: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
category: 'paid' | 'promotional';
|
|
19
|
+
priority?: number;
|
|
20
|
+
effective_at?: number;
|
|
21
|
+
expires_at?: number;
|
|
22
|
+
applicability_config?: {
|
|
23
|
+
scope: {
|
|
24
|
+
prices?: string[];
|
|
25
|
+
price_type?: 'metered';
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
metadata?: Record<string, any>;
|
|
29
|
+
livemode?: boolean;
|
|
30
|
+
created_via?: string;
|
|
31
|
+
created_by?: string;
|
|
32
|
+
}): Promise<CreditGrant> {
|
|
33
|
+
const customer = await Customer.findByPk(params.customer_id);
|
|
34
|
+
if (!customer) {
|
|
35
|
+
throw new Error(`Customer ${params.customer_id} not found`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const amount = new BN(params.amount);
|
|
39
|
+
if (amount.lte(new BN(0))) {
|
|
40
|
+
throw new Error('Amount must be greater than 0');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const currencyId = params.currency_id;
|
|
44
|
+
if (!currencyId) {
|
|
45
|
+
throw new Error('currency_id is required');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const now = dayjs().unix();
|
|
49
|
+
if (params.expires_at && params.expires_at < now) {
|
|
50
|
+
throw new Error('expires_at must be in the future');
|
|
51
|
+
}
|
|
52
|
+
const isEffectiveNow = !params.effective_at || params.effective_at <= now;
|
|
53
|
+
const initialStatus = isEffectiveNow ? 'granted' : 'pending';
|
|
54
|
+
const applicabilityConfig = params.applicability_config || {
|
|
55
|
+
scope: {
|
|
56
|
+
type: 'metered',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const grantData: any = {
|
|
61
|
+
amount: params.amount,
|
|
62
|
+
currency_id: currencyId,
|
|
63
|
+
customer_id: params.customer_id,
|
|
64
|
+
name: params.name,
|
|
65
|
+
category: params.category,
|
|
66
|
+
priority: params.priority || 50,
|
|
67
|
+
effective_at: params.effective_at || now,
|
|
68
|
+
expires_at: params.expires_at,
|
|
69
|
+
applicability_config: applicabilityConfig,
|
|
70
|
+
livemode: !!params.livemode,
|
|
71
|
+
created_via: params.created_via || 'api',
|
|
72
|
+
created_by: params.created_by,
|
|
73
|
+
remaining_amount: params.amount,
|
|
74
|
+
status: initialStatus,
|
|
75
|
+
metadata: formatMetadata(params.metadata),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const creditGrant = await CreditGrant.create(grantData);
|
|
79
|
+
|
|
80
|
+
logger.info('Credit grant created', {
|
|
81
|
+
grantId: creditGrant.id,
|
|
82
|
+
customerId: params.customer_id,
|
|
83
|
+
amount: params.amount,
|
|
84
|
+
currencyId,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return creditGrant;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function getCustomerCreditBalance(customerId: string, currencyId: string, subscriptionId?: string) {
|
|
91
|
+
let priceIds: string[] = [];
|
|
92
|
+
if (subscriptionId) {
|
|
93
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
94
|
+
if (!subscription) {
|
|
95
|
+
throw new Error(`Subscription ${subscriptionId} not found`);
|
|
96
|
+
}
|
|
97
|
+
priceIds = await getMeterPriceIdsFromSubscription(subscription);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId, priceIds);
|
|
101
|
+
|
|
102
|
+
const totalBalance = availableGrants
|
|
103
|
+
.reduce((sum, grant) => sum.add(new BN(grant.remaining_amount)), new BN(0))
|
|
104
|
+
.toString();
|
|
105
|
+
|
|
106
|
+
const paymentCurrency = await PaymentCurrency.findByPk(currencyId);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
customer_id: customerId,
|
|
110
|
+
currency_id: currencyId,
|
|
111
|
+
paymentCurrency,
|
|
112
|
+
totalBalance,
|
|
113
|
+
availableGrants,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function calculateExpiresAt(validDurationValue: number, validDurationUnit: string): number | undefined {
|
|
118
|
+
if (!validDurationValue || validDurationValue <= 0) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const now = dayjs();
|
|
123
|
+
let expiresAt: dayjs.Dayjs;
|
|
124
|
+
|
|
125
|
+
switch (validDurationUnit) {
|
|
126
|
+
case 'hours':
|
|
127
|
+
expiresAt = now.add(validDurationValue, 'hour');
|
|
128
|
+
break;
|
|
129
|
+
case 'days':
|
|
130
|
+
expiresAt = now.add(validDurationValue, 'day');
|
|
131
|
+
break;
|
|
132
|
+
case 'weeks':
|
|
133
|
+
expiresAt = now.add(validDurationValue, 'week');
|
|
134
|
+
break;
|
|
135
|
+
case 'months':
|
|
136
|
+
expiresAt = now.add(validDurationValue, 'month');
|
|
137
|
+
break;
|
|
138
|
+
case 'years':
|
|
139
|
+
expiresAt = now.add(validDurationValue, 'year');
|
|
140
|
+
break;
|
|
141
|
+
default:
|
|
142
|
+
expiresAt = now.add(validDurationValue, 'day');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return expiresAt.unix();
|
|
146
|
+
}
|
package/api/src/libs/env.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const daysUntilCancel: string | undefined = process.env.DAYS_UNTIL_CANCEL
|
|
|
13
13
|
export const meteringSubscriptionDetectionCronTime: string =
|
|
14
14
|
process.env.METERING_SUBSCRIPTION_DETECTION_CRON_TIME || '0 0 10 * * *'; // 默认每天 10:00 执行
|
|
15
15
|
export const depositVaultCronTime: string = process.env.DEPOSIT_VAULT_CRON_TIME || '0 */5 * * * *'; // 默认每 5 min 执行一次
|
|
16
|
+
export const creditConsumptionCronTime: string = process.env.CREDIT_CONSUMPTION_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
16
17
|
// sequelize 配置相关
|
|
17
18
|
export const sequelizeOptionsPoolMin: number = process.env.SEQUELIZE_OPTIONS_POOL_MIN
|
|
18
19
|
? +process.env.SEQUELIZE_OPTIONS_POOL_MIN
|
package/api/src/libs/invoice.ts
CHANGED
|
@@ -543,7 +543,7 @@ export async function ensureInvoiceAndItems({
|
|
|
543
543
|
// apply possible balance to invoice
|
|
544
544
|
let remaining = props.total;
|
|
545
545
|
let result = { starting: {}, ending: {} };
|
|
546
|
-
if (applyCredit && props.total > '0') {
|
|
546
|
+
if (applyCredit && props.total > '0' && !props.amount_remaining) {
|
|
547
547
|
const balance = customer.getBalanceToApply(currency.id, props.total);
|
|
548
548
|
result = await customer.decreaseTokenBalance(currency.id, balance);
|
|
549
549
|
remaining = new BN(props.total).sub(new BN(balance)).toString();
|
|
@@ -1053,8 +1053,9 @@ export async function retryUncollectibleInvoices(options: {
|
|
|
1053
1053
|
};
|
|
1054
1054
|
|
|
1055
1055
|
const settledResults = await Promise.allSettled(
|
|
1056
|
-
overdueInvoices.map(async (invoice) => {
|
|
1056
|
+
overdueInvoices.map(async (invoice, index) => {
|
|
1057
1057
|
const { paymentIntent } = invoice;
|
|
1058
|
+
const delay = index * 2;
|
|
1058
1059
|
if (!paymentIntent) {
|
|
1059
1060
|
throw new Error('No payment intent found');
|
|
1060
1061
|
}
|
|
@@ -1064,7 +1065,7 @@ export async function retryUncollectibleInvoices(options: {
|
|
|
1064
1065
|
'payment.queued',
|
|
1065
1066
|
paymentIntent.id,
|
|
1066
1067
|
{ paymentIntentId: paymentIntent.id, retryOnError: true, ignoreMaxRetryCheck: true },
|
|
1067
|
-
{ sync: false }
|
|
1068
|
+
{ sync: false, delay }
|
|
1068
1069
|
);
|
|
1069
1070
|
|
|
1070
1071
|
return invoice;
|
|
@@ -1,14 +1,400 @@
|
|
|
1
1
|
import type { TNotification, TNotificationInput } from '@blocklet/sdk/lib/types/notification';
|
|
2
|
+
import prettyMsI18n from 'pretty-ms-i18n';
|
|
3
|
+
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
4
|
+
import { translate } from '../../../locales';
|
|
5
|
+
import {
|
|
6
|
+
Customer,
|
|
7
|
+
PaymentMethod,
|
|
8
|
+
Subscription,
|
|
9
|
+
PaymentCurrency,
|
|
10
|
+
Invoice,
|
|
11
|
+
CheckoutSession,
|
|
12
|
+
NFTMintChainType,
|
|
13
|
+
NftMintItem,
|
|
14
|
+
} from '../../../store/models';
|
|
15
|
+
import { getMainProductName } from '../../product';
|
|
16
|
+
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
17
|
+
import { getCustomerInvoicePageUrl } from '../../invoice';
|
|
18
|
+
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
19
|
+
import { formatCurrencyInfo, getSubscriptionNotificationCustomActions } from '../../util';
|
|
2
20
|
|
|
3
21
|
export type BaseEmailTemplateType = TNotificationInput | TNotification;
|
|
22
|
+
|
|
4
23
|
export type BaseEmailTemplateContext = {
|
|
5
24
|
userDid: string;
|
|
6
25
|
};
|
|
7
26
|
|
|
8
27
|
export interface BaseEmailTemplate<C = BaseEmailTemplateContext> {
|
|
9
28
|
getTemplate(): Promise<BaseEmailTemplateType | null>;
|
|
10
|
-
|
|
11
29
|
getContext(): Promise<C>;
|
|
12
|
-
|
|
13
30
|
options: Record<string, any>;
|
|
14
31
|
}
|
|
32
|
+
|
|
33
|
+
// 基础订阅数据接口
|
|
34
|
+
export interface BaseSubscriptionData {
|
|
35
|
+
subscription: Subscription;
|
|
36
|
+
customer: Customer;
|
|
37
|
+
userDid: string;
|
|
38
|
+
locale: string;
|
|
39
|
+
productName: string;
|
|
40
|
+
paymentCurrency: PaymentCurrency;
|
|
41
|
+
isCreditSubscription: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 链接生成结果接口
|
|
45
|
+
export interface SubscriptionLinks {
|
|
46
|
+
viewSubscriptionLink: string;
|
|
47
|
+
viewInvoiceLink?: string;
|
|
48
|
+
viewTxHashLink?: string;
|
|
49
|
+
customActions: any[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// NFT 信息接口
|
|
53
|
+
export interface NftInfo {
|
|
54
|
+
hasNft: boolean;
|
|
55
|
+
nftMintItem?: NftMintItem;
|
|
56
|
+
chainHost?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 支付信息接口
|
|
60
|
+
export interface PaymentInfo {
|
|
61
|
+
paymentMethod?: PaymentMethod;
|
|
62
|
+
paymentInfo: string;
|
|
63
|
+
invoice?: Invoice;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 抽象基类
|
|
67
|
+
export abstract class BaseSubscriptionEmailTemplate<C extends BaseEmailTemplateContext = BaseEmailTemplateContext>
|
|
68
|
+
// eslint-disable-next-line prettier/prettier
|
|
69
|
+
implements BaseEmailTemplate<C> {
|
|
70
|
+
abstract options: Record<string, any>;
|
|
71
|
+
|
|
72
|
+
abstract getContext(): Promise<C>;
|
|
73
|
+
abstract getTemplate(): Promise<BaseEmailTemplateType | null>;
|
|
74
|
+
|
|
75
|
+
// 1. 基础数据获取逻辑
|
|
76
|
+
protected async getSubscriptionBasicData(subscriptionId: string): Promise<BaseSubscriptionData> {
|
|
77
|
+
const subscription = await this.validateSubscriptionExists(subscriptionId);
|
|
78
|
+
const customer = await this.validateCustomerExists(subscription.customer_id);
|
|
79
|
+
|
|
80
|
+
const userDid = customer.did;
|
|
81
|
+
const locale = await getUserLocale(userDid);
|
|
82
|
+
const productName = await getMainProductName(subscription.id);
|
|
83
|
+
const paymentCurrency = (await PaymentCurrency.findOne({
|
|
84
|
+
where: { id: subscription.currency_id },
|
|
85
|
+
})) as PaymentCurrency;
|
|
86
|
+
|
|
87
|
+
const isCreditSubscription = await this.isCreditSubscription(subscription, paymentCurrency);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
subscription,
|
|
91
|
+
customer,
|
|
92
|
+
userDid,
|
|
93
|
+
locale,
|
|
94
|
+
productName,
|
|
95
|
+
paymentCurrency,
|
|
96
|
+
isCreditSubscription,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. Credit 订阅检测
|
|
101
|
+
protected async isCreditSubscription(
|
|
102
|
+
subscription: Subscription,
|
|
103
|
+
paymentCurrency?: PaymentCurrency
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
let currency = paymentCurrency;
|
|
106
|
+
if (!currency) {
|
|
107
|
+
currency = (await PaymentCurrency.findOne({
|
|
108
|
+
where: { id: subscription.currency_id },
|
|
109
|
+
})) as PaymentCurrency;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isConsumesCredit = await subscription.isConsumesCredit();
|
|
113
|
+
return isConsumesCredit && currency.type === 'credit';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3. 支付信息处理(根据 Credit 订阅条件处理)
|
|
117
|
+
protected async getPaymentInfo(
|
|
118
|
+
subscription: Subscription,
|
|
119
|
+
paymentCurrency: PaymentCurrency,
|
|
120
|
+
isCreditSubscription: boolean,
|
|
121
|
+
invoiceId?: string
|
|
122
|
+
): Promise<PaymentInfo> {
|
|
123
|
+
const paymentMethod = (await PaymentMethod.findByPk(paymentCurrency.payment_method_id)) as PaymentMethod;
|
|
124
|
+
if (isCreditSubscription) {
|
|
125
|
+
// Credit 订阅不需要显示支付信息
|
|
126
|
+
return {
|
|
127
|
+
paymentInfo: '',
|
|
128
|
+
paymentMethod,
|
|
129
|
+
invoice: undefined,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 非 Credit 订阅需要获取发票和支付信息
|
|
134
|
+
const invoice = invoiceId
|
|
135
|
+
? await Invoice.findByPk(invoiceId)
|
|
136
|
+
: await Invoice.findByPk(subscription.latest_invoice_id);
|
|
137
|
+
|
|
138
|
+
if (!invoice) {
|
|
139
|
+
throw new Error(`Invoice not found for subscription: ${subscription.id}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const paymentInfo = formatCurrencyInfo(invoice.total, paymentCurrency, paymentMethod);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
paymentMethod,
|
|
146
|
+
paymentInfo,
|
|
147
|
+
invoice,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 4. 链接生成逻辑
|
|
152
|
+
protected generateSubscriptionLinks(
|
|
153
|
+
subscription: Subscription,
|
|
154
|
+
locale: string,
|
|
155
|
+
userDid: string,
|
|
156
|
+
actionType: string,
|
|
157
|
+
invoiceId?: string,
|
|
158
|
+
isCreditSubscription: boolean = false
|
|
159
|
+
): SubscriptionLinks {
|
|
160
|
+
const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
|
|
161
|
+
subscriptionId: subscription.id,
|
|
162
|
+
locale,
|
|
163
|
+
userDid,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Credit 订阅不显示发票链接
|
|
167
|
+
const viewInvoiceLink =
|
|
168
|
+
!isCreditSubscription && invoiceId
|
|
169
|
+
? getCustomerInvoicePageUrl({
|
|
170
|
+
invoiceId,
|
|
171
|
+
userDid,
|
|
172
|
+
locale,
|
|
173
|
+
})
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
const customActions = getSubscriptionNotificationCustomActions(subscription, actionType, locale);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
viewSubscriptionLink,
|
|
180
|
+
viewInvoiceLink,
|
|
181
|
+
customActions,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 5. NFT 信息获取
|
|
186
|
+
protected async getNftMintInfo(subscriptionId: string): Promise<NftInfo> {
|
|
187
|
+
const checkoutSession = await CheckoutSession.findBySubscriptionId(subscriptionId);
|
|
188
|
+
const hasNft = checkoutSession?.nft_mint_status === 'minted';
|
|
189
|
+
|
|
190
|
+
const nftMintItem = hasNft
|
|
191
|
+
? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as NFTMintChainType]
|
|
192
|
+
: undefined;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
hasNft,
|
|
196
|
+
nftMintItem,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 6. 时间和持续时间处理
|
|
201
|
+
protected formatSubscriptionPeriod(
|
|
202
|
+
startTime: number,
|
|
203
|
+
endTime: number,
|
|
204
|
+
locale: string
|
|
205
|
+
): {
|
|
206
|
+
currentPeriodStart: string;
|
|
207
|
+
currentPeriodEnd: string;
|
|
208
|
+
duration: string;
|
|
209
|
+
} {
|
|
210
|
+
const currentPeriodStart = formatTime(startTime * 1000);
|
|
211
|
+
const currentPeriodEnd = formatTime(endTime * 1000);
|
|
212
|
+
const duration = prettyMsI18n(new Date(currentPeriodEnd).getTime() - new Date(currentPeriodStart).getTime(), {
|
|
213
|
+
locale: getPrettyMsI18nLocale(locale),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
currentPeriodStart,
|
|
218
|
+
currentPeriodEnd,
|
|
219
|
+
duration,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 7. 模板字段构建 - 基础字段
|
|
224
|
+
protected buildCommonFields(userDid: string, productName: string, locale: string): any[] {
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
type: 'text',
|
|
228
|
+
data: {
|
|
229
|
+
type: 'plain',
|
|
230
|
+
color: '#9397A1',
|
|
231
|
+
text: translate('notification.common.account', locale),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
type: 'text',
|
|
236
|
+
data: {
|
|
237
|
+
type: 'plain',
|
|
238
|
+
text: userDid,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
type: 'text',
|
|
243
|
+
data: {
|
|
244
|
+
type: 'plain',
|
|
245
|
+
color: '#9397A1',
|
|
246
|
+
text: translate('notification.common.product', locale),
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
type: 'text',
|
|
251
|
+
data: {
|
|
252
|
+
type: 'plain',
|
|
253
|
+
text: productName,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 支付金额字段(Credit 订阅时不显示)
|
|
260
|
+
protected buildPaymentField(paymentInfo: string, locale: string, isCreditSubscription: boolean): any[] {
|
|
261
|
+
if (isCreditSubscription) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return [
|
|
266
|
+
{
|
|
267
|
+
type: 'text',
|
|
268
|
+
data: {
|
|
269
|
+
type: 'plain',
|
|
270
|
+
color: '#9397A1',
|
|
271
|
+
text: translate('notification.common.paymentAmount', locale),
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
type: 'text',
|
|
276
|
+
data: {
|
|
277
|
+
type: 'plain',
|
|
278
|
+
text: paymentInfo,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 有效期字段(Credit 订阅时不显示)
|
|
285
|
+
protected buildPeriodField(
|
|
286
|
+
currentPeriodStart: string,
|
|
287
|
+
currentPeriodEnd: string,
|
|
288
|
+
duration: string,
|
|
289
|
+
locale: string,
|
|
290
|
+
isCreditSubscription: boolean
|
|
291
|
+
): any[] {
|
|
292
|
+
if (isCreditSubscription) {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return [
|
|
297
|
+
{
|
|
298
|
+
type: 'text',
|
|
299
|
+
data: {
|
|
300
|
+
type: 'plain',
|
|
301
|
+
color: '#9397A1',
|
|
302
|
+
text: translate('notification.common.validityPeriod', locale),
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
type: 'text',
|
|
307
|
+
data: {
|
|
308
|
+
type: 'plain',
|
|
309
|
+
text: `${currentPeriodStart} ~ ${currentPeriodEnd}(${duration})`,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 8. 模板操作按钮构建
|
|
316
|
+
protected buildCommonActions(
|
|
317
|
+
viewSubscriptionLink: string,
|
|
318
|
+
viewInvoiceLink: string | undefined,
|
|
319
|
+
locale: string,
|
|
320
|
+
isCreditSubscription: boolean,
|
|
321
|
+
customActions: any[] = []
|
|
322
|
+
): any[] {
|
|
323
|
+
const actions = [
|
|
324
|
+
{
|
|
325
|
+
name: translate('notification.common.viewSubscription', locale),
|
|
326
|
+
title: translate('notification.common.viewSubscription', locale),
|
|
327
|
+
link: viewSubscriptionLink,
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
// Credit 订阅不显示发票链接
|
|
332
|
+
if (!isCreditSubscription && viewInvoiceLink) {
|
|
333
|
+
actions.push({
|
|
334
|
+
name: translate('notification.common.viewInvoice', locale),
|
|
335
|
+
title: translate('notification.common.viewInvoice', locale),
|
|
336
|
+
link: viewInvoiceLink,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return [...actions, ...customActions].filter(Boolean);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 9. 错误处理方法
|
|
344
|
+
protected async validateSubscriptionExists(subscriptionId: string): Promise<Subscription> {
|
|
345
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
346
|
+
if (!subscription) {
|
|
347
|
+
throw new Error(`Subscription not found: ${subscriptionId}`);
|
|
348
|
+
}
|
|
349
|
+
return subscription;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
protected async validateCustomerExists(customerId: string): Promise<Customer> {
|
|
353
|
+
const customer = await Customer.findByPk(customerId);
|
|
354
|
+
if (!customer) {
|
|
355
|
+
throw new Error(`Customer not found: ${customerId}`);
|
|
356
|
+
}
|
|
357
|
+
return customer;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 10. Credit 订阅条件判断辅助方法
|
|
361
|
+
protected shouldShowPaymentAmount(isCreditSubscription: boolean): boolean {
|
|
362
|
+
return !isCreditSubscription;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
protected shouldShowPeriod(isCreditSubscription: boolean): boolean {
|
|
366
|
+
return !isCreditSubscription;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
protected shouldShowInvoiceLink(isCreditSubscription: boolean): boolean {
|
|
370
|
+
return !isCreditSubscription;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 11. 字段和操作过滤方法
|
|
374
|
+
protected filterFieldsForCredit(fields: any[], isCreditSubscription: boolean): any[] {
|
|
375
|
+
if (!isCreditSubscription) {
|
|
376
|
+
return fields;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 移除支付相关字段
|
|
380
|
+
return fields.filter((field) => {
|
|
381
|
+
const text = field?.data?.text;
|
|
382
|
+
return (
|
|
383
|
+
!text ||
|
|
384
|
+
(!text.includes('notification.common.paymentAmount') && !text.includes('notification.common.validityPeriod'))
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
protected filterActionsForCredit(actions: any[], isCreditSubscription: boolean): any[] {
|
|
390
|
+
if (!isCreditSubscription) {
|
|
391
|
+
return actions;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 移除发票相关操作
|
|
395
|
+
return actions.filter((action) => {
|
|
396
|
+
const name = action?.name;
|
|
397
|
+
return !name || !name.includes('notification.common.viewInvoice');
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|