payment-kit 1.19.0 → 1.19.1

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.
Files changed (133) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +4 -0
  3. package/api/src/libs/credit-grant.ts +146 -0
  4. package/api/src/libs/env.ts +1 -0
  5. package/api/src/libs/invoice.ts +4 -3
  6. package/api/src/libs/notification/template/base.ts +388 -2
  7. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  8. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  9. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  10. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  11. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  12. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  19. package/api/src/libs/payment.ts +69 -0
  20. package/api/src/libs/queue/index.ts +3 -2
  21. package/api/src/libs/session.ts +8 -0
  22. package/api/src/libs/subscription.ts +74 -3
  23. package/api/src/libs/ws.ts +23 -1
  24. package/api/src/locales/en.ts +33 -0
  25. package/api/src/locales/zh.ts +31 -0
  26. package/api/src/queues/credit-consume.ts +715 -0
  27. package/api/src/queues/credit-grant.ts +572 -0
  28. package/api/src/queues/notification.ts +173 -128
  29. package/api/src/queues/payment.ts +210 -122
  30. package/api/src/queues/subscription.ts +179 -0
  31. package/api/src/routes/checkout-sessions.ts +157 -9
  32. package/api/src/routes/connect/shared.ts +3 -2
  33. package/api/src/routes/credit-grants.ts +241 -0
  34. package/api/src/routes/credit-transactions.ts +208 -0
  35. package/api/src/routes/index.ts +8 -0
  36. package/api/src/routes/meter-events.ts +347 -0
  37. package/api/src/routes/meters.ts +219 -0
  38. package/api/src/routes/payment-currencies.ts +14 -2
  39. package/api/src/routes/payment-links.ts +1 -1
  40. package/api/src/routes/payment-methods.ts +14 -2
  41. package/api/src/routes/prices.ts +43 -0
  42. package/api/src/routes/pricing-table.ts +13 -7
  43. package/api/src/routes/products.ts +63 -4
  44. package/api/src/routes/settings.ts +1 -1
  45. package/api/src/routes/subscriptions.ts +4 -0
  46. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  47. package/api/src/store/models/credit-grant.ts +486 -0
  48. package/api/src/store/models/credit-transaction.ts +268 -0
  49. package/api/src/store/models/customer.ts +8 -0
  50. package/api/src/store/models/index.ts +52 -1
  51. package/api/src/store/models/meter-event.ts +423 -0
  52. package/api/src/store/models/meter.ts +176 -0
  53. package/api/src/store/models/payment-currency.ts +66 -14
  54. package/api/src/store/models/price.ts +6 -0
  55. package/api/src/store/models/product.ts +2 -2
  56. package/api/src/store/models/subscription.ts +24 -0
  57. package/api/src/store/models/types.ts +28 -2
  58. package/api/tests/libs/subscription.spec.ts +53 -0
  59. package/blocklet.yml +9 -1
  60. package/package.json +4 -4
  61. package/scripts/sdk.js +233 -1
  62. package/src/app.tsx +10 -0
  63. package/src/components/collapse.tsx +11 -1
  64. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  65. package/src/components/customer/credit-overview.tsx +233 -0
  66. package/src/components/customer/form.tsx +5 -2
  67. package/src/components/invoice/list.tsx +19 -1
  68. package/src/components/metadata/form.tsx +286 -90
  69. package/src/components/meter/actions.tsx +101 -0
  70. package/src/components/meter/add-usage-dialog.tsx +239 -0
  71. package/src/components/meter/events-list.tsx +657 -0
  72. package/src/components/meter/form.tsx +245 -0
  73. package/src/components/meter/products.tsx +264 -0
  74. package/src/components/meter/usage-guide.tsx +174 -0
  75. package/src/components/payment-currency/form.tsx +2 -0
  76. package/src/components/payment-intent/list.tsx +19 -1
  77. package/src/components/payment-link/preview.tsx +1 -1
  78. package/src/components/payment-link/product-select.tsx +52 -12
  79. package/src/components/payment-method/arcblock.tsx +2 -0
  80. package/src/components/payment-method/base.tsx +2 -0
  81. package/src/components/payment-method/bitcoin.tsx +2 -0
  82. package/src/components/payment-method/ethereum.tsx +2 -0
  83. package/src/components/payment-method/stripe.tsx +2 -0
  84. package/src/components/payouts/list.tsx +19 -1
  85. package/src/components/price/currency-select.tsx +51 -31
  86. package/src/components/price/form.tsx +881 -407
  87. package/src/components/pricing-table/preview.tsx +1 -1
  88. package/src/components/product/add-price.tsx +9 -7
  89. package/src/components/product/create.tsx +7 -4
  90. package/src/components/product/edit-price.tsx +21 -12
  91. package/src/components/product/features.tsx +17 -7
  92. package/src/components/product/form.tsx +104 -89
  93. package/src/components/refund/list.tsx +19 -1
  94. package/src/components/section/header.tsx +5 -18
  95. package/src/components/subscription/items/index.tsx +1 -1
  96. package/src/components/subscription/metrics.tsx +37 -5
  97. package/src/components/subscription/portal/actions.tsx +2 -1
  98. package/src/contexts/products.tsx +26 -9
  99. package/src/hooks/subscription.ts +34 -0
  100. package/src/libs/meter-utils.ts +196 -0
  101. package/src/libs/util.ts +4 -0
  102. package/src/locales/en.tsx +385 -4
  103. package/src/locales/zh.tsx +364 -0
  104. package/src/pages/admin/billing/index.tsx +61 -33
  105. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  106. package/src/pages/admin/billing/meters/create.tsx +60 -0
  107. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  108. package/src/pages/admin/billing/meters/index.tsx +210 -0
  109. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  110. package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
  111. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  112. package/src/pages/admin/customers/customers/detail.tsx +22 -10
  113. package/src/pages/admin/customers/index.tsx +5 -0
  114. package/src/pages/admin/developers/events/detail.tsx +1 -1
  115. package/src/pages/admin/developers/index.tsx +1 -1
  116. package/src/pages/admin/payments/intents/detail.tsx +1 -1
  117. package/src/pages/admin/payments/payouts/detail.tsx +1 -1
  118. package/src/pages/admin/payments/refunds/detail.tsx +1 -1
  119. package/src/pages/admin/products/index.tsx +3 -2
  120. package/src/pages/admin/products/links/detail.tsx +1 -1
  121. package/src/pages/admin/products/prices/actions.tsx +16 -4
  122. package/src/pages/admin/products/prices/detail.tsx +30 -3
  123. package/src/pages/admin/products/prices/list.tsx +8 -1
  124. package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
  125. package/src/pages/admin/products/products/create.tsx +233 -57
  126. package/src/pages/admin/products/products/detail.tsx +2 -1
  127. package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
  128. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  129. package/src/pages/customer/index.tsx +35 -2
  130. package/src/pages/customer/recharge/account.tsx +5 -5
  131. package/src/pages/customer/subscription/change-payment.tsx +4 -2
  132. package/src/pages/customer/subscription/detail.tsx +48 -14
  133. package/src/pages/customer/subscription/embed.tsx +1 -1
@@ -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
+ }
@@ -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
@@ -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
+ }