payment-kit 1.15.20 → 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.
- package/api/src/crons/base.ts +69 -7
- package/api/src/crons/subscription-trial-will-end.ts +20 -5
- package/api/src/crons/subscription-will-canceled.ts +22 -6
- package/api/src/crons/subscription-will-renew.ts +13 -4
- package/api/src/index.ts +2 -0
- package/api/src/integrations/arcblock/stake.ts +27 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +4 -0
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +12 -34
- package/api/src/libs/notification/template/subscription-will-canceled.ts +82 -48
- package/api/src/libs/notification/template/subscription-will-renew.ts +16 -45
- package/api/src/libs/time.ts +13 -0
- package/api/src/libs/util.ts +17 -0
- package/api/src/locales/en.ts +12 -2
- package/api/src/locales/zh.ts +11 -2
- package/api/src/queues/subscription.ts +107 -2
- package/api/src/routes/connect/recharge.ts +143 -0
- package/api/src/routes/connect/shared.ts +25 -0
- package/api/src/routes/customers.ts +2 -2
- package/api/src/routes/subscriptions.ts +41 -36
- package/api/src/store/models/subscription.ts +2 -0
- package/api/tests/libs/time.spec.ts +54 -0
- package/blocklet.yml +1 -1
- package/package.json +19 -19
- package/src/app.tsx +10 -0
- package/src/components/subscription/actions/cancel.tsx +30 -9
- package/src/components/subscription/actions/index.tsx +11 -3
- package/src/locales/en.tsx +13 -0
- package/src/locales/zh.tsx +13 -0
- package/src/pages/customer/recharge.tsx +417 -0
- package/src/pages/customer/subscription/detail.tsx +38 -20
package/api/src/crons/base.ts
CHANGED
|
@@ -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
|
|
37
|
+
const scheduleSubscriptions = this.getScheduleSubscriptions(subscriptions);
|
|
38
38
|
|
|
39
|
-
await this.addTaskToQueue(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
willEndDuration,
|
|
202
180
|
})}`
|
|
203
181
|
: `${translate('notification.subscriptionTrialWillEnd.unableToPayBody', locale, {
|
|
204
182
|
at,
|
|
205
183
|
productName,
|
|
206
|
-
|
|
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
|
-
|
|
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.
|
|
60
|
-
throw new Error(`Subscription(${this.options.subscriptionId})
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
};
|