payment-kit 1.15.19 → 1.15.21

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 (32) hide show
  1. package/api/src/crons/base.ts +69 -7
  2. package/api/src/crons/subscription-trial-will-end.ts +20 -5
  3. package/api/src/crons/subscription-will-canceled.ts +22 -6
  4. package/api/src/crons/subscription-will-renew.ts +13 -4
  5. package/api/src/index.ts +2 -0
  6. package/api/src/integrations/arcblock/stake.ts +27 -0
  7. package/api/src/libs/notification/template/subscription-canceled.ts +4 -0
  8. package/api/src/libs/notification/template/subscription-trial-will-end.ts +12 -34
  9. package/api/src/libs/notification/template/subscription-will-canceled.ts +82 -48
  10. package/api/src/libs/notification/template/subscription-will-renew.ts +16 -45
  11. package/api/src/libs/time.ts +13 -0
  12. package/api/src/libs/util.ts +17 -0
  13. package/api/src/locales/en.ts +12 -2
  14. package/api/src/locales/zh.ts +11 -2
  15. package/api/src/queues/subscription.ts +108 -4
  16. package/api/src/routes/connect/recharge.ts +143 -0
  17. package/api/src/routes/connect/shared.ts +25 -0
  18. package/api/src/routes/customers.ts +2 -2
  19. package/api/src/routes/subscription-items.ts +49 -11
  20. package/api/src/routes/subscriptions.ts +41 -36
  21. package/api/src/store/models/subscription.ts +2 -0
  22. package/api/tests/libs/time.spec.ts +54 -0
  23. package/blocklet.yml +1 -1
  24. package/package.json +19 -19
  25. package/src/app.tsx +10 -0
  26. package/src/components/subscription/actions/cancel.tsx +30 -9
  27. package/src/components/subscription/actions/index.tsx +11 -3
  28. package/src/locales/en.tsx +13 -0
  29. package/src/locales/zh.tsx +13 -0
  30. package/src/pages/customer/recharge.tsx +417 -0
  31. package/src/pages/customer/subscription/detail.tsx +38 -20
  32. package/tsconfig.json +2 -2
@@ -34,13 +34,19 @@ export abstract class BaseSubscriptionScheduleNotification<Options extends any>
34
34
  const subscriptions = await this.getSubscriptions();
35
35
  logger.info(`${name}.run.${subscriptions.length}`, subscriptions.length);
36
36
 
37
- const subscriptionForWillRenew = this.getSubscriptionsForWillRenew(subscriptions);
37
+ const scheduleSubscriptions = this.getScheduleSubscriptions(subscriptions);
38
38
 
39
- await this.addTaskToQueue(subscriptionForWillRenew);
39
+ await this.addTaskToQueue(scheduleSubscriptions);
40
40
 
41
41
  logger.info(`${name}.run end`);
42
42
  }
43
43
 
44
+ async reScheduleSubscriptionTasks(subscriptions: Subscription[]): Promise<void> {
45
+ await this.deleteScheduleSubscriptionJobs(subscriptions);
46
+ const scheduleSubscriptions = this.getScheduleSubscriptions(subscriptions);
47
+ await this.addTaskToQueue(scheduleSubscriptions);
48
+ }
49
+
44
50
  static readonly DIFFS: Diff[] = [
45
51
  {
46
52
  value: 1,
@@ -67,6 +73,8 @@ export abstract class BaseSubscriptionScheduleNotification<Options extends any>
67
73
  unit: 'm',
68
74
  },
69
75
  ];
76
+
77
+ abstract getEndTime(subscription: Subscription): number;
70
78
  /**
71
79
  * @see https://github.com/blocklet/payment-kit/issues/236#issuecomment-1824129965
72
80
  * @description
@@ -74,13 +82,13 @@ export abstract class BaseSubscriptionScheduleNotification<Options extends any>
74
82
  * @return {*} {SubscriptionForWillRenew[]}
75
83
  * @memberof SubscriptionWillRenewSchedule
76
84
  */
77
- getSubscriptionsForWillRenew(subscriptions: Subscription[]): BaseSubscriptionScheduleNotificationSubscription[] {
85
+ getScheduleSubscriptions(subscriptions: Subscription[]): BaseSubscriptionScheduleNotificationSubscription[] {
78
86
  return subscriptions.map((subscription: Subscription): BaseSubscriptionScheduleNotificationSubscription => {
79
87
  const s: BaseSubscriptionScheduleNotificationSubscription = clone(
80
88
  subscription
81
89
  ) as BaseSubscriptionScheduleNotificationSubscription;
82
90
  const currentPeriodStart: number = s.current_period_start * 1000;
83
- const currentPeriodEnd: number = s.current_period_end * 1000;
91
+ const currentPeriodEnd: number = this.getEndTime(s);
84
92
 
85
93
  if (
86
94
  dayjs(currentPeriodEnd).diff(this.start, 'M') >= 1 &&
@@ -151,13 +159,26 @@ export abstract class BaseSubscriptionScheduleNotification<Options extends any>
151
159
 
152
160
  async addTaskToQueue(subscriptions: BaseSubscriptionScheduleNotificationSubscription[]): Promise<void> {
153
161
  const tasks: Task<Options>[] = [];
162
+ const deleteTasks = [];
154
163
  for (const subscription of subscriptions) {
155
164
  for (const diff of subscription.diffs) {
156
- const task: Task<Options> = this.getTask({ subscription, diff });
157
- tasks.push(task);
165
+ try {
166
+ const task: Task<Options> = this.getTask({ subscription, diff });
167
+ tasks.push(task);
168
+ } catch (error) {
169
+ logger.error('getTask error', { error, subscriptionId: subscription.id, diff });
170
+ deleteTasks.push(() => this.deleteScheduleSubscriptionJobs([subscription]));
171
+ }
158
172
  }
159
173
  }
160
174
 
175
+ await pAll(
176
+ deleteTasks.map((task) => () => task()),
177
+ {
178
+ concurrency: notificationCronConcurrency,
179
+ stopOnError: false,
180
+ }
181
+ );
161
182
  await pAll(
162
183
  tasks.map((x) => {
163
184
  return async () => {
@@ -170,7 +191,48 @@ export abstract class BaseSubscriptionScheduleNotification<Options extends any>
170
191
  notificationQueue.push(x);
171
192
  };
172
193
  }),
173
- { concurrency: notificationCronConcurrency }
194
+ {
195
+ concurrency: notificationCronConcurrency,
196
+ stopOnError: false,
197
+ }
174
198
  );
175
199
  }
200
+
201
+ async deleteScheduleSubscriptionJobs(subscriptions: Subscription[]): Promise<void> {
202
+ const jobsToDelete = await Promise.all(
203
+ subscriptions.flatMap((subscription) =>
204
+ BaseSubscriptionScheduleNotification.DIFFS.map(async (diff) => {
205
+ const jobId = `${subscription.id}.${this.eventType}.${diff.value}.${diff.unit}`;
206
+ const job = await notificationQueue.get(jobId);
207
+ return job ? { jobId, subscriptionId: subscription.id } : null;
208
+ })
209
+ )
210
+ );
211
+
212
+ const existingJobs = jobsToDelete.filter((x): x is { jobId: string; subscriptionId: string } => x !== null);
213
+
214
+ const deletePromises = existingJobs.map(
215
+ ({ jobId, subscriptionId }: { jobId: string; subscriptionId: string }) =>
216
+ async () => {
217
+ try {
218
+ const deleted = await notificationQueue.delete(jobId);
219
+ if (deleted) {
220
+ logger.info('Deleted subscription job', { jobId, subscriptionId, eventType: this.eventType });
221
+ }
222
+ } catch (error) {
223
+ logger.error('Failed to delete subscription job', {
224
+ jobId,
225
+ subscriptionId,
226
+ eventType: this.eventType,
227
+ error: error instanceof Error ? error.message : String(error),
228
+ });
229
+ }
230
+ }
231
+ );
232
+
233
+ await pAll(deletePromises, {
234
+ concurrency: notificationCronConcurrency,
235
+ stopOnError: false,
236
+ });
237
+ }
176
238
  }
@@ -17,7 +17,7 @@ export class SubscriptionTrialWillEndSchedule extends BaseSubscriptionScheduleNo
17
17
  current_period_start: {
18
18
  [Op.lte]: this.start / 1000,
19
19
  },
20
- current_period_end: {
20
+ trial_end: {
21
21
  [Op.lt]: this.end / 1000,
22
22
  },
23
23
  trial_start: {
@@ -25,7 +25,7 @@ export class SubscriptionTrialWillEndSchedule extends BaseSubscriptionScheduleNo
25
25
  },
26
26
  status: 'trialing',
27
27
  },
28
- attributes: ['id', 'current_period_start', 'current_period_end'],
28
+ attributes: ['id', 'current_period_start', 'current_period_end', 'trial_end'],
29
29
  raw: true,
30
30
  });
31
31
 
@@ -40,6 +40,19 @@ export class SubscriptionTrialWillEndSchedule extends BaseSubscriptionScheduleNo
40
40
  diff: Diff;
41
41
  }): Task<SubscriptionTrialWillEndEmailTemplateOptions> {
42
42
  const type = this.eventType;
43
+ if (subscription.status !== 'trialing') {
44
+ throw new Error(`Subscription(${subscription.id}) is not trialing, no need to send notification`);
45
+ }
46
+
47
+ const trialEnd = this.getEndTime(subscription);
48
+ if (!subscription.trial_end) {
49
+ throw new Error(`Subscription(${subscription.id}) has no trial_end, no need to send notification`);
50
+ }
51
+
52
+ if (trialEnd <= this.start) {
53
+ throw new Error(`Subscription(${subscription.id}) is already expired, no need to send notification`);
54
+ }
55
+
43
56
  return {
44
57
  id: `${subscription.id}.${type}.${diff.value}.${diff.unit}`,
45
58
  job: {
@@ -51,9 +64,11 @@ export class SubscriptionTrialWillEndSchedule extends BaseSubscriptionScheduleNo
51
64
  required: !!diff.required,
52
65
  },
53
66
  },
54
- delay: dayjs(subscription.current_period_end * 1000)
55
- .subtract(diff.value, diff.unit)
56
- .diff(this.start, 's'),
67
+ delay: dayjs(trialEnd).subtract(diff.value, diff.unit).diff(this.start, 's'),
57
68
  };
58
69
  }
70
+
71
+ override getEndTime(subscription: Subscription): number {
72
+ return subscription.trial_end! * 1000;
73
+ }
59
74
  }
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable no-console */
2
2
  import dayjs from 'dayjs';
3
3
 
4
+ import { Op } from 'sequelize';
4
5
  import type { SubscriptionWillCanceledEmailTemplateOptions } from '../libs/notification/template/subscription-will-canceled';
5
6
  import { Subscription } from '../store/models';
6
7
  import { BaseSubscriptionScheduleNotification, BaseSubscriptionScheduleNotificationEventType } from './base';
@@ -12,14 +13,20 @@ export class SubscriptionWillCanceledSchedule extends BaseSubscriptionScheduleNo
12
13
  async getSubscriptions(): Promise<Subscription[]> {
13
14
  const subscriptions = await Subscription.findAll({
14
15
  where: {
15
- status: 'past_due',
16
+ status: ['active', 'past_due'],
17
+ [Op.or]: [
18
+ { cancel_at: { [Op.gt]: this.start } },
19
+ { cancel_at_period_end: true, current_period_end: { [Op.gt]: this.start } },
20
+ ],
16
21
  },
17
- attributes: ['id', 'current_period_start', 'current_period_end'],
22
+ attributes: ['id', 'current_period_start', 'current_period_end', 'cancel_at', 'status'],
18
23
  raw: true,
19
24
  });
20
25
 
21
26
  subscriptions.forEach((subscription) => {
22
- subscription.current_period_end = subscription.cancel_at!;
27
+ if (subscription.cancel_at) {
28
+ subscription.current_period_end = subscription.cancel_at;
29
+ }
23
30
  });
24
31
 
25
32
  return subscriptions;
@@ -33,6 +40,13 @@ export class SubscriptionWillCanceledSchedule extends BaseSubscriptionScheduleNo
33
40
  diff: Diff;
34
41
  }): Task<SubscriptionWillCanceledEmailTemplateOptions> {
35
42
  const type = this.eventType;
43
+ const cancelAt = this.getEndTime(subscription);
44
+ if (!cancelAt) {
45
+ throw new Error(`Subscription(${subscription.id}) has no cancel_at, no need to send notification`);
46
+ }
47
+ if (cancelAt <= this.start) {
48
+ throw new Error(`Subscription(${subscription.id}) is already canceled, no need to send notification`);
49
+ }
36
50
  return {
37
51
  id: `${subscription.id}.${type}.${diff.value}.${diff.unit}`,
38
52
  job: {
@@ -44,9 +58,11 @@ export class SubscriptionWillCanceledSchedule extends BaseSubscriptionScheduleNo
44
58
  required: true,
45
59
  },
46
60
  },
47
- delay: dayjs(subscription.current_period_end * 1000)
48
- .subtract(diff.value, diff.unit)
49
- .diff(this.start, 's'),
61
+ delay: dayjs(cancelAt).subtract(diff.value, diff.unit).diff(this.start, 's'),
50
62
  };
51
63
  }
64
+
65
+ override getEndTime(subscription: Subscription): number {
66
+ return (subscription.cancel_at || subscription.current_period_end) * 1000;
67
+ }
52
68
  }
@@ -21,7 +21,7 @@ export class SubscriptionWillRenewSchedule extends BaseSubscriptionScheduleNotif
21
21
  },
22
22
  status: 'active',
23
23
  },
24
- attributes: ['id', 'current_period_start', 'current_period_end'],
24
+ attributes: ['id', 'current_period_start', 'current_period_end', 'status'],
25
25
  raw: true,
26
26
  });
27
27
 
@@ -36,6 +36,13 @@ export class SubscriptionWillRenewSchedule extends BaseSubscriptionScheduleNotif
36
36
  diff: Diff;
37
37
  }): Task<SubscriptionWillRenewEmailTemplateOptions> {
38
38
  const type = this.eventType;
39
+ const periodEnd = this.getEndTime(subscription);
40
+ if (periodEnd <= this.start) {
41
+ throw new Error(`Subscription(${subscription.id}) is already expired, no need to send notification`);
42
+ }
43
+ if (subscription.status !== 'active') {
44
+ throw new Error(`Subscription(${subscription.id}) is not active, no need to send notification`);
45
+ }
39
46
  return {
40
47
  id: `${subscription.id}.${type}.${diff.value}.${diff.unit}`,
41
48
  job: {
@@ -47,9 +54,11 @@ export class SubscriptionWillRenewSchedule extends BaseSubscriptionScheduleNotif
47
54
  required: !!diff.required,
48
55
  },
49
56
  },
50
- delay: dayjs(subscription.current_period_end * 1000)
51
- .subtract(diff.value, diff.unit)
52
- .diff(this.start, 's'),
57
+ delay: dayjs(periodEnd).subtract(diff.value, diff.unit).diff(this.start, 's'),
53
58
  };
54
59
  }
60
+
61
+ override getEndTime(subscription: Subscription): number {
62
+ return subscription.current_period_end * 1000;
63
+ }
55
64
  }
package/api/src/index.ts CHANGED
@@ -33,6 +33,7 @@ import changePaymentHandlers from './routes/connect/change-payment';
33
33
  import changePlanHandlers from './routes/connect/change-plan';
34
34
  import collectHandlers from './routes/connect/collect';
35
35
  import collectBatchHandlers from './routes/connect/collect-batch';
36
+ import rechargeHandlers from './routes/connect/recharge';
36
37
  import payHandlers from './routes/connect/pay';
37
38
  import setupHandlers from './routes/connect/setup';
38
39
  import subscribeHandlers from './routes/connect/subscribe';
@@ -69,6 +70,7 @@ handlers.attach(Object.assign({ app: router }, setupHandlers));
69
70
  handlers.attach(Object.assign({ app: router }, subscribeHandlers));
70
71
  handlers.attach(Object.assign({ app: router }, changePaymentHandlers));
71
72
  handlers.attach(Object.assign({ app: router }, changePlanHandlers));
73
+ handlers.attach(Object.assign({ app: router }, rechargeHandlers));
72
74
 
73
75
  router.use('/api', routes);
74
76
 
@@ -261,3 +261,30 @@ export async function getTokenSummaryByDid(
261
261
 
262
262
  return results;
263
263
  }
264
+
265
+ export async function getTokenByAddress(
266
+ address: string,
267
+ paymentMethod: PaymentMethod,
268
+ paymentCurrency: PaymentCurrency
269
+ ): Promise<string | undefined> {
270
+ if (paymentMethod.type === 'arcblock') {
271
+ const client = paymentMethod.getOcapClient();
272
+ const { tokens } = await client.getAccountTokens({ address });
273
+ return tokens.find((t: any) => t.address === paymentCurrency.contract)?.balance;
274
+ }
275
+ if (paymentMethod.type === 'ethereum') {
276
+ const client = paymentMethod.getEvmClient();
277
+ if (paymentCurrency.contract) {
278
+ const token = await fetchErc20Balance(client, paymentCurrency.contract, address);
279
+ return token;
280
+ }
281
+ const token = await fetchEtherBalance(client, address);
282
+ return token;
283
+ }
284
+ logger.info(`unsupported payment method type: ${paymentMethod.type}`, {
285
+ paymentCurrencyId: paymentCurrency.id,
286
+ paymentMethodId: paymentMethod.id,
287
+ address,
288
+ });
289
+ return '0';
290
+ }
@@ -122,6 +122,10 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
122
122
  cancellationReason = translate('notification.subscriptionCanceled.paymentFailed', locale);
123
123
  }
124
124
 
125
+ if (subscription.cancelation_details?.reason === 'stake_revoked') {
126
+ cancellationReason = translate('notification.subscriptionCanceled.stakeRevoked', locale);
127
+ }
128
+
125
129
  // @ts-expect-error
126
130
  const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
127
131
  const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
@@ -11,7 +11,7 @@ import { Customer, PaymentMethod, Subscription, PaymentCurrency } from '../../..
11
11
  import { PaymentDetail, getPaymentAmountForCycleSubscription } from '../../payment';
12
12
  import { getMainProductName } from '../../product';
13
13
  import { getCustomerSubscriptionPageUrl } from '../../subscription';
14
- import { formatTime, getPrettyMsI18nLocale } from '../../time';
14
+ import { formatTime, getPrettyMsI18nLocale, getSimplifyDuration } from '../../time';
15
15
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
16
16
  import dayjs from '../../dayjs';
17
17
  import { getSubscriptionNotificationCustomActions } from '../../util';
@@ -27,7 +27,7 @@ interface SubscriptionTrialWilEndEmailTemplateContext {
27
27
  locale: string;
28
28
  productName: string;
29
29
  at: string;
30
- willRenewDuration: string;
30
+ willEndDuration: string;
31
31
  paymentDetail: PaymentDetail;
32
32
 
33
33
  userDid: string;
@@ -79,10 +79,13 @@ export class SubscriptionTrialWilEndEmailTemplate
79
79
  const userDid = customer.did;
80
80
  const locale = await getUserLocale(userDid);
81
81
  const productName = await getMainProductName(subscription.id);
82
+ if (!subscription.trial_end) {
83
+ throw new Error(`Subscription has no trial end: ${subscription.id}`);
84
+ }
85
+
82
86
  const at: string = formatTime((subscription.trial_end as number) * 1000);
83
- const willRenewDuration: string =
84
- locale === 'en' ? this.getWillRenewDuration(locale) : this.getWillRenewDuration(locale).split(' ').join('');
85
87
 
88
+ const willEndDuration: string = getSimplifyDuration((subscription.trial_end! - dayjs().unix()) * 1000, locale);
86
89
  // const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice);
87
90
 
88
91
  const paymentAmount = await getPaymentAmountForCycleSubscription(subscription, paymentCurrency);
@@ -117,7 +120,7 @@ export class SubscriptionTrialWilEndEmailTemplate
117
120
  locale,
118
121
  productName,
119
122
  at,
120
- willRenewDuration,
123
+ willEndDuration,
121
124
  paymentDetail,
122
125
 
123
126
  userDid,
@@ -132,37 +135,12 @@ export class SubscriptionTrialWilEndEmailTemplate
132
135
  };
133
136
  }
134
137
 
135
- getWillRenewDuration(locale: string): string {
136
- if (this.options.willRenewUnit === 'M') {
137
- if (this.options.willRenewValue > 1) {
138
- return `${this.options.willRenewValue} ${translate('notification.common.months', locale)}`;
139
- }
140
-
141
- return `${this.options.willRenewValue} ${translate('notification.common.month', locale)}`;
142
- }
143
- if (this.options.willRenewUnit === 'd') {
144
- if (this.options.willRenewValue > 1) {
145
- return `${this.options.willRenewValue} ${translate('notification.common.days', locale)}`;
146
- }
147
- return `${this.options.willRenewValue} ${translate('notification.common.day', locale)}`;
148
- }
149
-
150
- if (this.options.willRenewUnit === 'm') {
151
- if (this.options.willRenewValue > 1) {
152
- return `${this.options.willRenewValue} ${translate('notification.common.minutes', locale)}`;
153
- }
154
- return `${this.options.willRenewValue} ${translate('notification.common.minute', locale)}`;
155
- }
156
-
157
- return `${this.options.willRenewValue} ${this.options.willRenewUnit}`;
158
- }
159
-
160
138
  async getTemplate(): Promise<BaseEmailTemplateType | null> {
161
139
  const {
162
140
  locale,
163
141
  productName,
164
142
  at,
165
- willRenewDuration,
143
+ willEndDuration,
166
144
  paymentDetail,
167
145
 
168
146
  userDid,
@@ -191,19 +169,19 @@ export class SubscriptionTrialWilEndEmailTemplate
191
169
  const template: BaseEmailTemplateType = {
192
170
  title: `${translate('notification.subscriptionTrialWillEnd.title', locale, {
193
171
  productName,
194
- willRenewDuration,
172
+ willEndDuration,
195
173
  })}`,
196
174
  body:
197
175
  canPay || isStripe
198
176
  ? `${translate('notification.subscriptionTrialWillEnd.body', locale, {
199
177
  at,
200
178
  productName,
201
- willRenewDuration,
179
+ willEndDuration,
202
180
  })}`
203
181
  : `${translate('notification.subscriptionTrialWillEnd.unableToPayBody', locale, {
204
182
  at,
205
183
  productName,
206
- willRenewDuration,
184
+ willEndDuration,
207
185
  balance: `${paymentDetail.balance} ${paymentDetail.symbol}`,
208
186
  price: `${paymentDetail.price} ${paymentDetail.symbol}`,
209
187
  })}`,
@@ -12,7 +12,7 @@ import { getCustomerInvoicePageUrl } from '../../invoice';
12
12
  import logger from '../../logger';
13
13
  import { getMainProductName } from '../../product';
14
14
  import { getCustomerSubscriptionPageUrl } from '../../subscription';
15
- import { formatTime } from '../../time';
15
+ import { formatTime, getSimplifyDuration } from '../../time';
16
16
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
17
17
  import { getSubscriptionNotificationCustomActions } from '../../util';
18
18
 
@@ -27,7 +27,8 @@ interface SubscriptionWillCanceledEmailTemplateContext {
27
27
  locale: string;
28
28
  productName: string;
29
29
  at: string;
30
- willCancelDuration: string;
30
+ cancelReason: string;
31
+ body: string;
31
32
 
32
33
  userDid: string;
33
34
  paymentInfo: string;
@@ -35,6 +36,7 @@ interface SubscriptionWillCanceledEmailTemplateContext {
35
36
  viewSubscriptionLink: string;
36
37
  viewInvoiceLink: string;
37
38
  customActions: any[];
39
+ needRenew: boolean;
38
40
  }
39
41
 
40
42
  export class SubscriptionWillCanceledEmailTemplate
@@ -56,8 +58,19 @@ export class SubscriptionWillCanceledEmailTemplate
56
58
  if (!subscription) {
57
59
  throw new Error(`Subscription(${this.options.subscriptionId}) not found`);
58
60
  }
59
- if (subscription.status !== 'past_due') {
60
- throw new Error(`Subscription(${this.options.subscriptionId}) status(${subscription.status}) must be past_due`);
61
+ if (subscription.isImmutable()) {
62
+ throw new Error(`Subscription(${this.options.subscriptionId}) is immutable, no need to send notification`);
63
+ }
64
+
65
+ const now = dayjs().unix();
66
+ const cancelAt = subscription.cancel_at || subscription.current_period_end;
67
+ if (!subscription.cancel_at && !subscription.cancel_at_period_end) {
68
+ throw new Error(
69
+ `Subscription(${this.options.subscriptionId}) is not scheduled to cancel, no need to send notification`
70
+ );
71
+ }
72
+ if (cancelAt <= now) {
73
+ throw new Error(`Subscription(${this.options.subscriptionId}) is already canceled, no need to send notification`);
61
74
  }
62
75
 
63
76
  const customer = await Customer.findByPk(subscription.customer_id);
@@ -75,12 +88,39 @@ export class SubscriptionWillCanceledEmailTemplate
75
88
  const userDid = customer.did;
76
89
  const locale = await getUserLocale(userDid);
77
90
  const productName = await getMainProductName(subscription.id);
78
- const at: string = formatTime(invoice.period_end * 1000);
79
- const willCancelDuration: string =
80
- locale === 'en' ? this.getWillCancelDuration(locale) : this.getWillCancelDuration(locale).split(' ').join('');
81
-
91
+ const at: string = formatTime(cancelAt * 1000);
92
+ const willCancelDuration: string = getSimplifyDuration((cancelAt - now) * 1000, locale);
82
93
  const paymentInfo: string = `${fromUnitToToken(+invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
83
94
 
95
+ let body: string = translate('notification.subscriptWillCanceled.body', locale, {
96
+ productName,
97
+ willCancelDuration,
98
+ at,
99
+ });
100
+
101
+ let needRenew = false;
102
+ const reasonMap = {
103
+ cancellation_requested: 'customerCanceled',
104
+ payment_failed: 'paymentFailed',
105
+ stake_revoked: 'stakeRevoked',
106
+ } as const;
107
+
108
+ const cancelReason = translate(
109
+ `notification.subscriptWillCanceled.${reasonMap[subscription.cancelation_details?.reason as keyof typeof reasonMap] || 'adminCanceled'}`,
110
+ locale,
111
+ {
112
+ canceled_at: formatTime(subscription.canceled_at ? subscription.canceled_at * 1000 : dayjs().unix()),
113
+ }
114
+ );
115
+ if (subscription.status === 'past_due' || subscription.cancelation_details?.reason === 'payment_failed') {
116
+ body = translate('notification.subscriptWillCanceled.pastDue', locale, {
117
+ productName,
118
+ willCancelDuration,
119
+ at,
120
+ });
121
+ needRenew = true;
122
+ }
123
+
84
124
  const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
85
125
  subscriptionId: subscription.id,
86
126
  locale,
@@ -102,7 +142,8 @@ export class SubscriptionWillCanceledEmailTemplate
102
142
  locale,
103
143
  productName,
104
144
  at,
105
- willCancelDuration,
145
+ body,
146
+ cancelReason,
106
147
 
107
148
  userDid,
108
149
  paymentInfo,
@@ -110,41 +151,18 @@ export class SubscriptionWillCanceledEmailTemplate
110
151
  viewSubscriptionLink,
111
152
  viewInvoiceLink,
112
153
  customActions,
154
+ needRenew,
113
155
  };
114
156
  }
115
157
 
116
- getWillCancelDuration(locale: string): string {
117
- if (this.options.willCancelUnit === 'M') {
118
- if (this.options.willCancelValue > 1) {
119
- return `${this.options.willCancelValue} ${translate('notification.common.months', locale)}`;
120
- }
121
-
122
- return `${this.options.willCancelValue} ${translate('notification.common.month', locale)}`;
123
- }
124
- if (this.options.willCancelUnit === 'd') {
125
- if (this.options.willCancelValue > 1) {
126
- return `${this.options.willCancelValue} ${translate('notification.common.days', locale)}`;
127
- }
128
- return `${this.options.willCancelValue} ${translate('notification.common.day', locale)}`;
129
- }
130
-
131
- if (this.options.willCancelUnit === 'm') {
132
- if (this.options.willCancelValue > 1) {
133
- return `${this.options.willCancelValue} ${translate('notification.common.minutes', locale)}`;
134
- }
135
- return `${this.options.willCancelValue} ${translate('notification.common.minute', locale)}`;
136
- }
137
-
138
- return `${this.options.willCancelValue} ${this.options.willCancelUnit}`;
139
- }
140
-
141
158
  async getTemplate(): Promise<BaseEmailTemplateType | null> {
142
159
  const {
143
160
  locale,
144
161
  productName,
145
162
  at,
146
- willCancelDuration,
147
-
163
+ body,
164
+ cancelReason,
165
+ needRenew,
148
166
  userDid,
149
167
  paymentInfo,
150
168
 
@@ -162,11 +180,7 @@ export class SubscriptionWillCanceledEmailTemplate
162
180
  title: `${translate('notification.subscriptWillCanceled.title', locale, {
163
181
  productName,
164
182
  })}`,
165
- body: translate('notification.subscriptWillCanceled.body', locale, {
166
- productName,
167
- willCancelDuration,
168
- at,
169
- }),
183
+ body,
170
184
  // @ts-expect-error
171
185
  attachments: [
172
186
  {
@@ -202,19 +216,38 @@ export class SubscriptionWillCanceledEmailTemplate
202
216
  text: productName,
203
217
  },
204
218
  },
219
+ ...(needRenew
220
+ ? [
221
+ {
222
+ type: 'text',
223
+ data: {
224
+ type: 'plain',
225
+ color: '#9397A1',
226
+ text: translate('notification.common.paymentAmount', locale),
227
+ },
228
+ },
229
+ {
230
+ type: 'text',
231
+ data: {
232
+ type: 'plain',
233
+ text: paymentInfo,
234
+ },
235
+ },
236
+ ]
237
+ : []),
205
238
  {
206
239
  type: 'text',
207
240
  data: {
208
241
  type: 'plain',
209
242
  color: '#9397A1',
210
- text: translate('notification.common.paymentAmount', locale),
243
+ text: translate('notification.subscriptWillCanceled.cancelReason', locale),
211
244
  },
212
245
  },
213
246
  {
214
247
  type: 'text',
215
248
  data: {
216
249
  type: 'plain',
217
- text: paymentInfo,
250
+ text: cancelReason,
218
251
  },
219
252
  },
220
253
  ].filter(Boolean),
@@ -227,11 +260,12 @@ export class SubscriptionWillCanceledEmailTemplate
227
260
  title: translate('notification.common.viewSubscription', locale),
228
261
  link: viewSubscriptionLink,
229
262
  },
230
- viewInvoiceLink && {
231
- name: translate('notification.common.renewNow', locale),
232
- title: translate('notification.common.renewNow', locale),
233
- link: viewInvoiceLink,
234
- },
263
+ viewInvoiceLink &&
264
+ needRenew && {
265
+ name: translate('notification.common.renewNow', locale),
266
+ title: translate('notification.common.renewNow', locale),
267
+ link: viewInvoiceLink,
268
+ },
235
269
  ...customActions,
236
270
  ].filter(Boolean),
237
271
  };