payment-kit 1.13.136 → 1.13.138

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 (39) hide show
  1. package/api/src/crons/base.ts +7 -33
  2. package/api/src/crons/index.ts +7 -0
  3. package/api/src/crons/interface/base.ts +17 -0
  4. package/api/src/crons/subscription-trail-will-end.ts +29 -1
  5. package/api/src/crons/subscription-will-canceled.ts +48 -0
  6. package/api/src/crons/subscription-will-renew.ts +29 -2
  7. package/api/src/libs/invoice.ts +20 -6
  8. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +245 -0
  9. package/api/src/libs/notification/template/subscription-cacceled.ts +241 -0
  10. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +286 -0
  11. package/api/src/libs/notification/template/subscription-renew-failed.ts +17 -6
  12. package/api/src/libs/notification/template/subscription-renewed.ts +27 -6
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +14 -5
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +13 -4
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +7 -3
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +261 -0
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +225 -0
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +30 -3
  19. package/api/src/libs/product.ts +24 -0
  20. package/api/src/libs/queue/index.ts +2 -0
  21. package/api/src/libs/queue/store.ts +1 -1
  22. package/api/src/libs/security.ts +1 -1
  23. package/api/src/libs/subscription.ts +19 -3
  24. package/api/src/libs/util.ts +33 -0
  25. package/api/src/locales/en.ts +38 -4
  26. package/api/src/locales/zh.ts +36 -2
  27. package/api/src/queues/notification.ts +91 -2
  28. package/api/src/routes/connect/setup.ts +2 -2
  29. package/api/src/routes/connect/shared.ts +2 -2
  30. package/api/src/store/models/subscription.ts +5 -0
  31. package/api/src/store/models/types.ts +3 -0
  32. package/blocklet.yml +1 -1
  33. package/package.json +44 -44
  34. package/src/contexts/session.ts +2 -2
  35. package/src/pages/admin/payments/links/create.tsx +1 -1
  36. package/src/pages/admin/products/pricing-tables/create.tsx +1 -1
  37. package/src/pages/customer/invoice.tsx +12 -3
  38. package/src/pages/customer/subscription/update.tsx +1 -1
  39. package/api/src/crons/interface/diff.ts +0 -9
@@ -84,7 +84,7 @@ export class SubscriptionSucceededEmailTemplate
84
84
 
85
85
  return Boolean(
86
86
  ['disabled', 'minted', 'sent', 'error'].includes(checkoutSession?.nft_mint_status as string) &&
87
- invoice?.payment_intent_id
87
+ (invoice?.payment_intent_id || (invoice && +invoice.total === 0))
88
88
  );
89
89
  },
90
90
  { timeout: 1000 * 10, interval: 1000 }
@@ -108,7 +108,8 @@ export class SubscriptionSucceededEmailTemplate
108
108
  },
109
109
  });
110
110
 
111
- const locale = await getUserLocale(customer.did);
111
+ const userDid: string = customer.did;
112
+ const locale = await getUserLocale(userDid);
112
113
  const productName = await getMainProductName(subscription.id);
113
114
  const at: string = formatTime(subscription.created_at);
114
115
 
@@ -130,8 +131,16 @@ export class SubscriptionSucceededEmailTemplate
130
131
  // @FIXME: 获取 chainHost 困难的一批?
131
132
  const chainHost: string | undefined =
132
133
  paymentMethod?.settings?.[checkoutSession?.nft_mint_details?.type as 'arcblock' | 'ethereum']?.api_host;
133
- const viewSubscriptionLink = getCustomerSubscriptionPageUrl(subscription.id, locale);
134
- const viewInvoiceLink = getCustomerInvoicePageUrl(invoice.id, locale);
134
+ const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
135
+ subscriptionId: subscription.id,
136
+ locale,
137
+ userDid,
138
+ });
139
+ const viewInvoiceLink = getCustomerInvoicePageUrl({
140
+ invoiceId: invoice.id,
141
+ userDid,
142
+ locale,
143
+ });
135
144
 
136
145
  const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
137
146
  const txHash: string | undefined =
@@ -143,7 +152,7 @@ export class SubscriptionSucceededEmailTemplate
143
152
  productName,
144
153
  at,
145
154
 
146
- userDid: customer.did,
155
+ userDid,
147
156
  nftMintItem,
148
157
  chainHost,
149
158
  paymentInfo,
@@ -90,7 +90,8 @@ export class SubscriptionTrailStartEmailTemplate
90
90
  },
91
91
  });
92
92
 
93
- const locale = await getUserLocale(customer.did);
93
+ const userDid: string = customer.did;
94
+ const locale = await getUserLocale(userDid);
94
95
  const productName = await getMainProductName(subscription.id);
95
96
  const subscriptionTrialEnd: string = formatTime((subscription.trail_end as number) * 1000);
96
97
 
@@ -111,8 +112,16 @@ export class SubscriptionTrailStartEmailTemplate
111
112
  }
112
113
  );
113
114
 
114
- const viewSubscriptionLink = getCustomerSubscriptionPageUrl(subscription.id, locale);
115
- const viewInvoiceLink = getCustomerInvoicePageUrl(invoice.id, locale);
115
+ const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
116
+ subscriptionId: subscription.id,
117
+ locale,
118
+ userDid,
119
+ });
120
+ const viewInvoiceLink = getCustomerInvoicePageUrl({
121
+ invoiceId: invoice.id,
122
+ userDid,
123
+ locale,
124
+ });
116
125
 
117
126
  return {
118
127
  locale,
@@ -121,7 +130,7 @@ export class SubscriptionTrailStartEmailTemplate
121
130
 
122
131
  nftMintItem,
123
132
  chainHost,
124
- userDid: customer.did,
133
+ userDid,
125
134
  paymentInfo,
126
135
  currentPeriodStart,
127
136
  currentPeriodEnd,
@@ -70,13 +70,13 @@ export class SubscriptionTrailWilEndEmailTemplate
70
70
  },
71
71
  })) as PaymentCurrency;
72
72
 
73
- const locale = await getUserLocale(customer.did);
73
+ const userDid = customer.did;
74
+ const locale = await getUserLocale(userDid);
74
75
  const productName = await getMainProductName(subscription.id);
75
76
  const at: string = formatTime((subscription.trail_end as number) * 1000);
76
77
  const willRenewDuration: string =
77
78
  locale === 'en' ? this.getWillRenewDuration(locale) : this.getWillRenewDuration(locale).split(' ').join('');
78
79
 
79
- const userDid = customer.did;
80
80
  const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice);
81
81
  const paymentInfo: string = `${fromUnitToToken(+invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
82
82
  const currentPeriodStart: string = formatTime((subscription.trail_start as number) * 1000);
@@ -88,7 +88,11 @@ export class SubscriptionTrailWilEndEmailTemplate
88
88
  }
89
89
  );
90
90
 
91
- const viewSubscriptionLink = getCustomerSubscriptionPageUrl(subscription.id, locale);
91
+ const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
92
+ subscriptionId: subscription.id,
93
+ locale,
94
+ userDid,
95
+ });
92
96
 
93
97
  return {
94
98
  locale,
@@ -0,0 +1,261 @@
1
+ /* eslint-disable @typescript-eslint/brace-style */
2
+ /* eslint-disable @typescript-eslint/indent */
3
+ import { fromUnitToToken } from '@ocap/util';
4
+ import prettyMsI18n from 'pretty-ms-i18n';
5
+
6
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
7
+ import { translate } from '../../../locales';
8
+ import {
9
+ CheckoutSession,
10
+ Customer,
11
+ NftMintItem,
12
+ PaymentIntent,
13
+ PaymentMethod,
14
+ Subscription,
15
+ } from '../../../store/models';
16
+ import { Invoice } from '../../../store/models/invoice';
17
+ import { PaymentCurrency } from '../../../store/models/payment-currency';
18
+ import { getCustomerInvoicePageUrl } from '../../invoice';
19
+ import { getMainProductName } from '../../product';
20
+ import { getCustomerSubscriptionPageUrl } from '../../subscription';
21
+ import { formatTime, getPrettyMsI18nLocale } from '../../time';
22
+ import { getExplorerLink } from '../../util';
23
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
24
+
25
+ export interface SubscriptionUpgradedEmailTemplateOptions {
26
+ subscriptionId: string;
27
+ }
28
+
29
+ interface SubscriptionUpgradedEmailTemplateContext {
30
+ locale: string;
31
+ productName: string;
32
+ at: string;
33
+ nftMintItem: NftMintItem | undefined;
34
+ chainHost: string | undefined;
35
+ userDid: string;
36
+ paymentInfo: string;
37
+ currentPeriodStart: string;
38
+ currentPeriodEnd: string;
39
+ duration: string;
40
+ viewSubscriptionLink: string;
41
+ viewInvoiceLink: string;
42
+ viewTxHashLink: string | undefined;
43
+ }
44
+
45
+ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<SubscriptionUpgradedEmailTemplateContext> {
46
+ options: SubscriptionUpgradedEmailTemplateOptions;
47
+
48
+ constructor(options: SubscriptionUpgradedEmailTemplateOptions) {
49
+ this.options = options;
50
+ }
51
+
52
+ async getContext(): Promise<SubscriptionUpgradedEmailTemplateContext> {
53
+ const subscription: Subscription | null = await Subscription.findByPk(this.options.subscriptionId);
54
+ if (!subscription) {
55
+ throw new Error(`Subscription not found: ${this.options.subscriptionId}`);
56
+ }
57
+ if (subscription.status !== 'active') {
58
+ throw new Error(`Subscription not active: ${this.options.subscriptionId}`);
59
+ }
60
+
61
+ const customer = await Customer.findByPk(subscription.customer_id);
62
+ if (!customer) {
63
+ throw new Error(`Customer not found: ${subscription.customer_id}`);
64
+ }
65
+
66
+ const invoice = (await Invoice.findByPk(subscription.latest_invoice_id)) as Invoice;
67
+ const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
68
+ const paymentCurrency = (await PaymentCurrency.findOne({
69
+ where: {
70
+ id: subscription.currency_id,
71
+ },
72
+ })) as PaymentCurrency;
73
+
74
+ const checkoutSession = await CheckoutSession.findOne({
75
+ where: {
76
+ subscription_id: subscription.id,
77
+ },
78
+ });
79
+
80
+ const userDid: string = customer.did;
81
+ const locale = await getUserLocale(userDid);
82
+ const productName = await getMainProductName(subscription.id);
83
+ const at: string = formatTime(subscription.created_at);
84
+
85
+ const paymentInfo: string = `${fromUnitToToken(paymentIntent?.amount, paymentCurrency.decimal)} ${
86
+ paymentCurrency.symbol
87
+ }`;
88
+ const hasNft: boolean = checkoutSession?.nft_mint_status === 'minted';
89
+ const nftMintItem: NftMintItem | undefined = hasNft
90
+ ? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as 'arcblock' | 'ethereum']
91
+ : undefined;
92
+ const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
93
+ const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
94
+ const duration: string = prettyMsI18n(
95
+ new Date(currentPeriodEnd).getTime() - new Date(currentPeriodStart).getTime(),
96
+ {
97
+ locale: getPrettyMsI18nLocale(locale),
98
+ }
99
+ );
100
+
101
+ const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
102
+ // @ts-expect-error
103
+ const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
104
+ const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
105
+ subscriptionId: subscription.id,
106
+ locale,
107
+ userDid,
108
+ });
109
+ const viewInvoiceLink = getCustomerInvoicePageUrl({
110
+ invoiceId: invoice.id,
111
+ userDid,
112
+ locale,
113
+ });
114
+
115
+ // @ts-expect-error
116
+ const txHash: string | undefined = paymentIntent?.payment_details?.[paymentMethod.type]?.tx_hash;
117
+ const viewTxHashLink: string | undefined = txHash && getExplorerLink(chainHost, txHash, 'tx');
118
+
119
+ return {
120
+ locale,
121
+ productName,
122
+ at,
123
+
124
+ userDid,
125
+ nftMintItem,
126
+ chainHost,
127
+ paymentInfo,
128
+ currentPeriodStart,
129
+ currentPeriodEnd,
130
+ duration,
131
+
132
+ viewSubscriptionLink,
133
+ viewInvoiceLink,
134
+ viewTxHashLink,
135
+ };
136
+ }
137
+
138
+ async getTemplate(): Promise<BaseEmailTemplateType> {
139
+ const {
140
+ locale,
141
+ productName,
142
+ at,
143
+ nftMintItem,
144
+ chainHost,
145
+ userDid,
146
+ paymentInfo,
147
+ currentPeriodStart,
148
+ currentPeriodEnd,
149
+ duration,
150
+ viewSubscriptionLink,
151
+ viewInvoiceLink,
152
+ viewTxHashLink,
153
+ } = await this.getContext();
154
+
155
+ const template: BaseEmailTemplateType = {
156
+ title: `${translate('notification.subscriptionUpgraded.title', locale, {
157
+ productName: `(${productName})`,
158
+ })}`,
159
+ body: `${translate('notification.subscriptionUpgraded.body', locale, {
160
+ at,
161
+ productName: `(${productName})`,
162
+ })}`,
163
+ // @ts-expect-error
164
+ attachments: [
165
+ nftMintItem &&
166
+ chainHost && {
167
+ type: 'asset',
168
+ data: {
169
+ chainHost,
170
+ did: nftMintItem.address,
171
+ },
172
+ },
173
+ {
174
+ type: 'section',
175
+ fields: [
176
+ {
177
+ type: 'text',
178
+ data: {
179
+ type: 'plain',
180
+ color: '#9397A1',
181
+ text: translate('notification.common.account', locale),
182
+ },
183
+ },
184
+ {
185
+ type: 'text',
186
+ data: {
187
+ type: 'plain',
188
+ text: userDid,
189
+ },
190
+ },
191
+ {
192
+ type: 'text',
193
+ data: {
194
+ type: 'plain',
195
+ color: '#9397A1',
196
+ text: translate('notification.common.product', locale),
197
+ },
198
+ },
199
+ {
200
+ type: 'text',
201
+ data: {
202
+ type: 'plain',
203
+ text: productName,
204
+ },
205
+ },
206
+ {
207
+ type: 'text',
208
+ data: {
209
+ type: 'plain',
210
+ color: '#9397A1',
211
+ text: translate('notification.common.paymentInfo', locale),
212
+ },
213
+ },
214
+ {
215
+ type: 'text',
216
+ data: {
217
+ type: 'plain',
218
+ text: paymentInfo,
219
+ },
220
+ },
221
+ {
222
+ type: 'text',
223
+ data: {
224
+ type: 'plain',
225
+ color: '#9397A1',
226
+ text: translate('notification.common.validityPeriod', locale),
227
+ },
228
+ },
229
+ {
230
+ type: 'text',
231
+ data: {
232
+ type: 'plain',
233
+ text: `${currentPeriodStart} ~ ${currentPeriodEnd}(${duration})`,
234
+ },
235
+ },
236
+ ].filter(Boolean),
237
+ },
238
+ ].filter(Boolean),
239
+ // @ts-ignore
240
+ actions: [
241
+ {
242
+ name: 'viewSubscription',
243
+ title: translate('notification.common.viewSubscription', locale),
244
+ link: viewSubscriptionLink,
245
+ },
246
+ {
247
+ name: 'viewSubscription',
248
+ title: translate('notification.common.viewInvoice', locale),
249
+ link: viewInvoiceLink,
250
+ },
251
+ viewTxHashLink && {
252
+ name: 'viewTxHash',
253
+ title: translate('notification.common.viewTxHash', locale),
254
+ link: viewTxHashLink,
255
+ },
256
+ ].filter(Boolean),
257
+ };
258
+
259
+ return template;
260
+ }
261
+ }
@@ -0,0 +1,225 @@
1
+ /* eslint-disable @typescript-eslint/brace-style */
2
+ /* eslint-disable @typescript-eslint/indent */
3
+ import { fromUnitToToken } from '@ocap/util';
4
+ import type { ManipulateType } from 'dayjs';
5
+
6
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
7
+ import { translate } from '../../../locales';
8
+ import { Customer, Invoice, Subscription } from '../../../store/models';
9
+ import { PaymentCurrency } from '../../../store/models/payment-currency';
10
+ import { getCustomerInvoicePageUrl } from '../../invoice';
11
+ import logger from '../../logger';
12
+ import { getMainProductName } from '../../product';
13
+ import { getCustomerSubscriptionPageUrl } from '../../subscription';
14
+ import { formatTime } from '../../time';
15
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
16
+
17
+ export interface SubscriptionWillCanceledEmailTemplateOptions {
18
+ subscriptionId: string;
19
+ willCancelValue: number;
20
+ willCancelUnit: ManipulateType;
21
+ required?: false | true;
22
+ }
23
+
24
+ interface SubscriptionWillCanceledEmailTemplateContext {
25
+ locale: string;
26
+ productName: string;
27
+ at: string;
28
+ willCancelDuration: string;
29
+
30
+ userDid: string;
31
+ paymentInfo: string;
32
+
33
+ viewSubscriptionLink: string;
34
+ viewInvoiceLink: string;
35
+ }
36
+
37
+ export class SubscriptionWillCanceledEmailTemplate
38
+ implements BaseEmailTemplate<SubscriptionWillCanceledEmailTemplateContext>
39
+ {
40
+ options: SubscriptionWillCanceledEmailTemplateOptions;
41
+
42
+ constructor(options: SubscriptionWillCanceledEmailTemplateOptions) {
43
+ this.options = options;
44
+ }
45
+
46
+ async getContext(): Promise<SubscriptionWillCanceledEmailTemplateContext> {
47
+ if (!this.options.required) {
48
+ logger.error('SubscriptionWillCanceledEmailTemplate: This execution will be skipped', this.options);
49
+ throw new Error('SubscriptionWillCanceledEmailTemplate: This execution will be skipped');
50
+ }
51
+
52
+ const subscription: Subscription | null = await Subscription.findByPk(this.options.subscriptionId);
53
+ if (!subscription) {
54
+ throw new Error(`Subscription(${this.options.subscriptionId}) not found`);
55
+ }
56
+ if (subscription.status !== 'past_due') {
57
+ throw new Error(`Subscription(${this.options.subscriptionId}) status(${subscription.status}) must be past_due`);
58
+ }
59
+
60
+ const customer = await Customer.findByPk(subscription.customer_id);
61
+ if (!customer) {
62
+ throw new Error(`Customer not found: ${subscription.customer_id}`);
63
+ }
64
+
65
+ const invoice = (await Invoice.findByPk(subscription.latest_invoice_id)) as Invoice;
66
+ const paymentCurrency = (await PaymentCurrency.findOne({
67
+ where: {
68
+ id: subscription.currency_id,
69
+ },
70
+ })) as PaymentCurrency;
71
+
72
+ const userDid = customer.did;
73
+ const locale = await getUserLocale(userDid);
74
+ const productName = await getMainProductName(subscription.id);
75
+ const at: string = formatTime(invoice.period_end * 1000);
76
+ const willCancelDuration: string =
77
+ locale === 'en' ? this.getWillCancelDuration(locale) : this.getWillCancelDuration(locale).split(' ').join('');
78
+
79
+ const paymentInfo: string = `${fromUnitToToken(+invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
80
+
81
+ const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
82
+ subscriptionId: subscription.id,
83
+ locale,
84
+ userDid,
85
+ });
86
+ const viewInvoiceLink = getCustomerInvoicePageUrl({
87
+ invoiceId: invoice.id,
88
+ userDid,
89
+ locale,
90
+ action: 'pay',
91
+ });
92
+
93
+ return {
94
+ locale,
95
+ productName,
96
+ at,
97
+ willCancelDuration,
98
+
99
+ userDid,
100
+ paymentInfo,
101
+
102
+ viewSubscriptionLink,
103
+ viewInvoiceLink,
104
+ };
105
+ }
106
+
107
+ getWillCancelDuration(locale: string): string {
108
+ if (this.options.willCancelUnit === 'M') {
109
+ if (this.options.willCancelValue > 1) {
110
+ return `${this.options.willCancelValue} ${translate('notification.common.months', locale)}`;
111
+ }
112
+
113
+ return `${this.options.willCancelValue} ${translate('notification.common.month', locale)}`;
114
+ }
115
+ if (this.options.willCancelUnit === 'd') {
116
+ if (this.options.willCancelValue > 1) {
117
+ return `${this.options.willCancelValue} ${translate('notification.common.days', locale)}`;
118
+ }
119
+ return `${this.options.willCancelValue} ${translate('notification.common.day', locale)}`;
120
+ }
121
+
122
+ if (this.options.willCancelUnit === 'm') {
123
+ if (this.options.willCancelValue > 1) {
124
+ return `${this.options.willCancelValue} ${translate('notification.common.minutes', locale)}`;
125
+ }
126
+ return `${this.options.willCancelValue} ${translate('notification.common.minute', locale)}`;
127
+ }
128
+
129
+ return `${this.options.willCancelValue} ${this.options.willCancelUnit}`;
130
+ }
131
+
132
+ async getTemplate(): Promise<BaseEmailTemplateType | null> {
133
+ const {
134
+ locale,
135
+ productName,
136
+ at,
137
+ willCancelDuration,
138
+
139
+ userDid,
140
+ paymentInfo,
141
+
142
+ viewSubscriptionLink,
143
+ viewInvoiceLink,
144
+ } = await this.getContext();
145
+
146
+ const template: BaseEmailTemplateType = {
147
+ title: `${translate('notification.subscriptWillCanceled.title', locale, {
148
+ productName: `(${productName})`,
149
+ })}`,
150
+ body: translate('notification.subscriptWillCanceled.body', locale, {
151
+ productName,
152
+ willCancelDuration,
153
+ at,
154
+ }),
155
+ // @ts-expect-error
156
+ attachments: [
157
+ {
158
+ type: 'section',
159
+ fields: [
160
+ {
161
+ type: 'text',
162
+ data: {
163
+ type: 'plain',
164
+ color: '#9397A1',
165
+ text: translate('notification.common.account', locale),
166
+ },
167
+ },
168
+ {
169
+ type: 'text',
170
+ data: {
171
+ type: 'plain',
172
+ text: userDid,
173
+ },
174
+ },
175
+ {
176
+ type: 'text',
177
+ data: {
178
+ type: 'plain',
179
+ color: '#9397A1',
180
+ text: translate('notification.common.product', locale),
181
+ },
182
+ },
183
+ {
184
+ type: 'text',
185
+ data: {
186
+ type: 'plain',
187
+ text: productName,
188
+ },
189
+ },
190
+ {
191
+ type: 'text',
192
+ data: {
193
+ type: 'plain',
194
+ color: '#9397A1',
195
+ text: translate('notification.subscriptWillCanceled.renewAmount', locale),
196
+ },
197
+ },
198
+ {
199
+ type: 'text',
200
+ data: {
201
+ type: 'plain',
202
+ text: paymentInfo,
203
+ },
204
+ },
205
+ ].filter(Boolean),
206
+ },
207
+ ].filter(Boolean),
208
+ // @ts-ignore
209
+ actions: [
210
+ viewSubscriptionLink && {
211
+ name: 'viewSubscription',
212
+ title: translate('notification.common.viewSubscription', locale),
213
+ link: viewSubscriptionLink,
214
+ },
215
+ viewInvoiceLink && {
216
+ name: 'viewInvoice',
217
+ title: translate('notification.common.renewNow', locale),
218
+ link: viewInvoiceLink,
219
+ },
220
+ ].filter(Boolean),
221
+ };
222
+
223
+ return template;
224
+ }
225
+ }
@@ -2,6 +2,7 @@
2
2
  /* eslint-disable @typescript-eslint/indent */
3
3
  import { fromUnitToToken } from '@ocap/util';
4
4
  import type { ManipulateType } from 'dayjs';
5
+ import dayjs from 'dayjs';
5
6
  import prettyMsI18n from 'pretty-ms-i18n';
6
7
 
7
8
  import { getUserLocale } from '../../../integrations/blocklet/notification';
@@ -57,6 +58,9 @@ export class SubscriptionWillRenewEmailTemplate
57
58
  if (subscription.isScheduledToCancel()) {
58
59
  throw new Error(`Subscription is scheduled to cancel: ${this.options.subscriptionId}`);
59
60
  }
61
+ if (await this.subscriptionIsRenewed(subscription)) {
62
+ throw new Error(`The subscription(${this.options.subscriptionId}) has been renewed`);
63
+ }
60
64
 
61
65
  const customer = await Customer.findByPk(subscription.customer_id);
62
66
  if (!customer) {
@@ -70,13 +74,13 @@ export class SubscriptionWillRenewEmailTemplate
70
74
  },
71
75
  })) as PaymentCurrency;
72
76
 
73
- const locale = await getUserLocale(customer.did);
77
+ const userDid = customer.did;
78
+ const locale = await getUserLocale(userDid);
74
79
  const productName = await getMainProductName(subscription.id);
75
80
  const at: string = formatTime(invoice.period_end * 1000);
76
81
  const willRenewDuration: string =
77
82
  locale === 'en' ? this.getWillRenewDuration(locale) : this.getWillRenewDuration(locale).split(' ').join('');
78
83
 
79
- const userDid = customer.did;
80
84
  const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice);
81
85
  const paymentInfo: string = `${fromUnitToToken(+invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
82
86
  const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
@@ -88,7 +92,11 @@ export class SubscriptionWillRenewEmailTemplate
88
92
  }
89
93
  );
90
94
 
91
- const viewSubscriptionLink = getCustomerSubscriptionPageUrl(subscription.id, locale);
95
+ const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
96
+ subscriptionId: subscription.id,
97
+ locale,
98
+ userDid,
99
+ });
92
100
 
93
101
  return {
94
102
  locale,
@@ -107,6 +115,25 @@ export class SubscriptionWillRenewEmailTemplate
107
115
  };
108
116
  }
109
117
 
118
+ /**
119
+ * @see https://github.com/blocklet/payment-kit/issues/307
120
+ * @description 该订阅已经在指定期限内续费过了吗?
121
+ * @param {Subscription} subscription
122
+ * @return {*} {Promise<void>}
123
+ * @memberof SubscriptionWillRenewEmailTemplate
124
+ */
125
+ // eslint-disable-next-line require-await
126
+ async subscriptionIsRenewed(subscription: Subscription): Promise<boolean> {
127
+ // @note: 为了最精确的验证,这里的期限 this.options.willRenewValue 还要 + 1,如果没有订阅没有续费,那么 expectedCurrentPeriodEnd 一定是大于 currentPeriodEnd
128
+ const expectedCurrentPeriodEnd: number = dayjs()
129
+ .add(this.options.willRenewValue + 1, this.options.willRenewUnit)
130
+ .toDate()
131
+ .getTime();
132
+ const currentPeriodEnd: number = subscription.current_period_end * 1000;
133
+
134
+ return currentPeriodEnd > expectedCurrentPeriodEnd;
135
+ }
136
+
110
137
  getWillRenewDuration(locale: string): string {
111
138
  if (this.options.willRenewUnit === 'M') {
112
139
  if (this.options.willRenewValue > 1) {
@@ -38,3 +38,27 @@ export async function getMainProductName(subscriptionId: string): Promise<string
38
38
 
39
39
  return product.name;
40
40
  }
41
+
42
+ export async function getMainProductNameByCheckoutSession(checkoutSession: CheckoutSession): Promise<string> {
43
+ const priceId: string = checkoutSession?.line_items.find((x) => !x.cross_sell)?.price_id as string;
44
+
45
+ if (!priceId) {
46
+ throw new Error(`PriceId can't be found by checkoutSession(${checkoutSession.id})`);
47
+ }
48
+
49
+ const price = await Price.findByPk(priceId, {
50
+ attributes: ['product_id'],
51
+ });
52
+
53
+ if (!price) {
54
+ throw new Error(`Price can't be found by priceId(${priceId})`);
55
+ }
56
+
57
+ const product = await Product.findByPk(price?.product_id);
58
+
59
+ if (!product) {
60
+ throw new Error(`Product can't be found by productId(${price?.product_id})`);
61
+ }
62
+
63
+ return product.name;
64
+ }