payment-kit 1.15.17 → 1.15.19
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/integrations/stripe/handlers/invoice.ts +20 -0
- package/api/src/libs/audit.ts +1 -1
- package/api/src/libs/invoice.ts +81 -1
- package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
- package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
- package/api/src/libs/notification/template/subscription-renewed.ts +10 -2
- package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
- package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
- package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +11 -0
- package/api/src/libs/notification/template/subscription-upgraded.ts +11 -1
- package/api/src/libs/notification/template/subscription-will-canceled.ts +10 -0
- package/api/src/libs/notification/template/subscription-will-renew.ts +10 -1
- package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
- package/api/src/libs/subscription.ts +67 -0
- package/api/src/libs/util.ts +30 -0
- package/api/src/locales/en.ts +13 -0
- package/api/src/locales/zh.ts +13 -0
- package/api/src/queues/invoice.ts +18 -0
- package/api/src/queues/notification.ts +43 -1
- package/api/src/queues/subscription.ts +21 -2
- package/api/src/routes/checkout-sessions.ts +26 -0
- package/api/src/routes/subscriptions.ts +5 -3
- package/api/src/store/models/checkout-session.ts +2 -0
- package/api/src/store/models/types.ts +22 -4
- package/api/src/store/models/usage-record.ts +5 -1
- package/api/tests/libs/subscription.spec.ts +58 -1
- package/api/tests/libs/util.spec.ts +135 -0
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/scripts/sdk.js +37 -3
- package/src/components/invoice/list.tsx +0 -1
- package/src/components/invoice/table.tsx +7 -2
- package/src/components/subscription/items/index.tsx +26 -7
- package/src/components/subscription/items/usage-records.tsx +21 -10
- package/src/components/subscription/portal/actions.tsx +16 -14
- package/src/libs/util.ts +51 -0
- package/src/locales/en.tsx +2 -0
- package/src/locales/zh.tsx +2 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
- package/src/pages/customer/subscription/embed.tsx +16 -14
|
@@ -14,6 +14,7 @@ import { getMainProductName } from '../../product';
|
|
|
14
14
|
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
15
15
|
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
16
16
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
17
|
+
import { getSubscriptionNotificationCustomActions } from '../../util';
|
|
17
18
|
|
|
18
19
|
export interface SubscriptionTrialStartEmailTemplateOptions {
|
|
19
20
|
subscriptionId: string;
|
|
@@ -41,6 +42,7 @@ interface SubscriptionTrialStartEmailTemplateContext {
|
|
|
41
42
|
viewSubscriptionLink: string;
|
|
42
43
|
viewInvoiceLink: string;
|
|
43
44
|
oneTimeProductInfo?: Array<OneTimeProductInfo>;
|
|
45
|
+
customActions: any[];
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export class SubscriptionTrialStartEmailTemplate
|
|
@@ -130,6 +132,12 @@ export class SubscriptionTrialStartEmailTemplate
|
|
|
130
132
|
locale,
|
|
131
133
|
});
|
|
132
134
|
|
|
135
|
+
const customActions = getSubscriptionNotificationCustomActions(
|
|
136
|
+
subscription,
|
|
137
|
+
'customer.subscription.trial_start',
|
|
138
|
+
locale
|
|
139
|
+
);
|
|
140
|
+
|
|
133
141
|
return {
|
|
134
142
|
locale,
|
|
135
143
|
productName,
|
|
@@ -146,6 +154,7 @@ export class SubscriptionTrialStartEmailTemplate
|
|
|
146
154
|
viewSubscriptionLink,
|
|
147
155
|
viewInvoiceLink,
|
|
148
156
|
oneTimeProductInfo,
|
|
157
|
+
customActions,
|
|
149
158
|
};
|
|
150
159
|
}
|
|
151
160
|
|
|
@@ -232,6 +241,7 @@ export class SubscriptionTrialStartEmailTemplate
|
|
|
232
241
|
viewSubscriptionLink,
|
|
233
242
|
viewInvoiceLink,
|
|
234
243
|
oneTimeProductInfo,
|
|
244
|
+
customActions,
|
|
235
245
|
} = await this.getContext();
|
|
236
246
|
|
|
237
247
|
const hasOneTimeProduct = !isEmpty(oneTimeProductInfo);
|
|
@@ -342,6 +352,7 @@ export class SubscriptionTrialStartEmailTemplate
|
|
|
342
352
|
title: translate('notification.common.viewInvoice', locale),
|
|
343
353
|
link: viewInvoiceLink,
|
|
344
354
|
},
|
|
355
|
+
...customActions,
|
|
345
356
|
].filter(Boolean),
|
|
346
357
|
};
|
|
347
358
|
|
|
@@ -14,6 +14,7 @@ import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
|
14
14
|
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
15
15
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
16
16
|
import dayjs from '../../dayjs';
|
|
17
|
+
import { getSubscriptionNotificationCustomActions } from '../../util';
|
|
17
18
|
|
|
18
19
|
export interface SubscriptionTrialWillEndEmailTemplateOptions {
|
|
19
20
|
subscriptionId: string;
|
|
@@ -37,6 +38,7 @@ interface SubscriptionTrialWilEndEmailTemplateContext {
|
|
|
37
38
|
|
|
38
39
|
viewSubscriptionLink: string;
|
|
39
40
|
paymentMethod: PaymentMethod | null;
|
|
41
|
+
customActions: any[];
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export class SubscriptionTrialWilEndEmailTemplate
|
|
@@ -105,6 +107,12 @@ export class SubscriptionTrialWilEndEmailTemplate
|
|
|
105
107
|
userDid,
|
|
106
108
|
});
|
|
107
109
|
|
|
110
|
+
const customActions = getSubscriptionNotificationCustomActions(
|
|
111
|
+
subscription,
|
|
112
|
+
'customer.subscription.trial_will_end',
|
|
113
|
+
locale
|
|
114
|
+
);
|
|
115
|
+
|
|
108
116
|
return {
|
|
109
117
|
locale,
|
|
110
118
|
productName,
|
|
@@ -120,6 +128,7 @@ export class SubscriptionTrialWilEndEmailTemplate
|
|
|
120
128
|
|
|
121
129
|
viewSubscriptionLink,
|
|
122
130
|
paymentMethod,
|
|
131
|
+
customActions,
|
|
123
132
|
};
|
|
124
133
|
}
|
|
125
134
|
|
|
@@ -163,6 +172,7 @@ export class SubscriptionTrialWilEndEmailTemplate
|
|
|
163
172
|
duration,
|
|
164
173
|
paymentMethod,
|
|
165
174
|
viewSubscriptionLink,
|
|
175
|
+
customActions,
|
|
166
176
|
} = await this.getContext();
|
|
167
177
|
|
|
168
178
|
// 如果当前时间大于试用结束时间,那么不发送通知
|
|
@@ -295,6 +305,7 @@ export class SubscriptionTrialWilEndEmailTemplate
|
|
|
295
305
|
title: translate('notification.common.viewSubscription', locale),
|
|
296
306
|
link: viewSubscriptionLink,
|
|
297
307
|
},
|
|
308
|
+
...customActions,
|
|
298
309
|
].filter(Boolean),
|
|
299
310
|
};
|
|
300
311
|
|
|
@@ -19,7 +19,7 @@ import { getCustomerInvoicePageUrl } from '../../invoice';
|
|
|
19
19
|
import { getMainProductName } from '../../product';
|
|
20
20
|
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
21
21
|
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
22
|
-
import { getExplorerLink } from '../../util';
|
|
22
|
+
import { getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
|
|
23
23
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
24
24
|
|
|
25
25
|
export interface SubscriptionUpgradedEmailTemplateOptions {
|
|
@@ -41,6 +41,7 @@ interface SubscriptionUpgradedEmailTemplateContext {
|
|
|
41
41
|
viewInvoiceLink: string;
|
|
42
42
|
viewTxHashLink: string | undefined;
|
|
43
43
|
skipInvoice: boolean;
|
|
44
|
+
customActions: any[];
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<SubscriptionUpgradedEmailTemplateContext> {
|
|
@@ -131,6 +132,12 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
131
132
|
chainHost,
|
|
132
133
|
});
|
|
133
134
|
|
|
135
|
+
const customActions = getSubscriptionNotificationCustomActions(
|
|
136
|
+
subscription,
|
|
137
|
+
'customer.subscription.upgraded',
|
|
138
|
+
locale
|
|
139
|
+
);
|
|
140
|
+
|
|
134
141
|
return {
|
|
135
142
|
locale,
|
|
136
143
|
productName,
|
|
@@ -148,6 +155,7 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
148
155
|
viewInvoiceLink,
|
|
149
156
|
viewTxHashLink,
|
|
150
157
|
skipInvoice,
|
|
158
|
+
customActions,
|
|
151
159
|
};
|
|
152
160
|
}
|
|
153
161
|
|
|
@@ -167,6 +175,7 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
167
175
|
viewInvoiceLink,
|
|
168
176
|
viewTxHashLink,
|
|
169
177
|
skipInvoice,
|
|
178
|
+
customActions,
|
|
170
179
|
} = await this.getContext();
|
|
171
180
|
|
|
172
181
|
const template: BaseEmailTemplateType = {
|
|
@@ -275,6 +284,7 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
275
284
|
title: translate('notification.common.viewTxHash', locale),
|
|
276
285
|
link: viewTxHashLink,
|
|
277
286
|
},
|
|
287
|
+
...customActions,
|
|
278
288
|
].filter(Boolean),
|
|
279
289
|
};
|
|
280
290
|
|
|
@@ -14,6 +14,7 @@ import { getMainProductName } from '../../product';
|
|
|
14
14
|
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
15
15
|
import { formatTime } from '../../time';
|
|
16
16
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
17
|
+
import { getSubscriptionNotificationCustomActions } from '../../util';
|
|
17
18
|
|
|
18
19
|
export interface SubscriptionWillCanceledEmailTemplateOptions {
|
|
19
20
|
subscriptionId: string;
|
|
@@ -33,6 +34,7 @@ interface SubscriptionWillCanceledEmailTemplateContext {
|
|
|
33
34
|
|
|
34
35
|
viewSubscriptionLink: string;
|
|
35
36
|
viewInvoiceLink: string;
|
|
37
|
+
customActions: any[];
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export class SubscriptionWillCanceledEmailTemplate
|
|
@@ -91,6 +93,11 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
91
93
|
action: 'pay',
|
|
92
94
|
});
|
|
93
95
|
|
|
96
|
+
const customActions = getSubscriptionNotificationCustomActions(
|
|
97
|
+
subscription,
|
|
98
|
+
'customer.subscription.will_canceled',
|
|
99
|
+
locale
|
|
100
|
+
);
|
|
94
101
|
return {
|
|
95
102
|
locale,
|
|
96
103
|
productName,
|
|
@@ -102,6 +109,7 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
102
109
|
|
|
103
110
|
viewSubscriptionLink,
|
|
104
111
|
viewInvoiceLink,
|
|
112
|
+
customActions,
|
|
105
113
|
};
|
|
106
114
|
}
|
|
107
115
|
|
|
@@ -142,6 +150,7 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
142
150
|
|
|
143
151
|
viewSubscriptionLink,
|
|
144
152
|
viewInvoiceLink,
|
|
153
|
+
customActions,
|
|
145
154
|
} = await this.getContext();
|
|
146
155
|
|
|
147
156
|
// 如果当前时间大于订阅终止时间,那么不发送通知
|
|
@@ -223,6 +232,7 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
223
232
|
title: translate('notification.common.renewNow', locale),
|
|
224
233
|
link: viewInvoiceLink,
|
|
225
234
|
},
|
|
235
|
+
...customActions,
|
|
226
236
|
].filter(Boolean),
|
|
227
237
|
};
|
|
228
238
|
|
|
@@ -22,7 +22,7 @@ import { getPaymentAmountForCycleSubscription, type PaymentDetail } from '../../
|
|
|
22
22
|
import { getMainProductName } from '../../product';
|
|
23
23
|
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
24
24
|
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
25
|
-
import { getExplorerLink } from '../../util';
|
|
25
|
+
import { getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
|
|
26
26
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
27
27
|
|
|
28
28
|
export interface SubscriptionWillRenewEmailTemplateOptions {
|
|
@@ -49,6 +49,7 @@ interface SubscriptionWillRenewEmailTemplateContext {
|
|
|
49
49
|
viewSubscriptionLink: string;
|
|
50
50
|
addFundsLink: string;
|
|
51
51
|
paymentMethod: PaymentMethod | null;
|
|
52
|
+
customActions: any[];
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export class SubscriptionWillRenewEmailTemplate
|
|
@@ -145,6 +146,11 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
145
146
|
},
|
|
146
147
|
})!;
|
|
147
148
|
|
|
149
|
+
const customActions = getSubscriptionNotificationCustomActions(
|
|
150
|
+
subscription,
|
|
151
|
+
'customer.subscription.will_renew',
|
|
152
|
+
locale
|
|
153
|
+
);
|
|
148
154
|
return {
|
|
149
155
|
locale,
|
|
150
156
|
productName,
|
|
@@ -162,6 +168,7 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
162
168
|
viewSubscriptionLink,
|
|
163
169
|
addFundsLink,
|
|
164
170
|
paymentMethod,
|
|
171
|
+
customActions,
|
|
165
172
|
};
|
|
166
173
|
}
|
|
167
174
|
async getPaymentCategory({ subscriptionId }: { subscriptionId: string }): Promise<{
|
|
@@ -249,6 +256,7 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
249
256
|
viewSubscriptionLink,
|
|
250
257
|
addFundsLink,
|
|
251
258
|
paymentMethod,
|
|
259
|
+
customActions,
|
|
252
260
|
} = await this.getContext();
|
|
253
261
|
|
|
254
262
|
// 如果当前时间大于预计扣费时间,那么不发送通知
|
|
@@ -423,6 +431,7 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
423
431
|
title: translate('notification.common.viewSubscription', locale),
|
|
424
432
|
link: viewSubscriptionLink,
|
|
425
433
|
},
|
|
434
|
+
...customActions,
|
|
426
435
|
].filter(Boolean),
|
|
427
436
|
};
|
|
428
437
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import prettyMsI18n from 'pretty-ms-i18n';
|
|
2
|
+
import { getOwnerDid } from '../../util';
|
|
3
|
+
import { translate } from '../../../locales';
|
|
4
|
+
import { Subscription } from '../../../store/models';
|
|
5
|
+
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
6
|
+
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
7
|
+
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
8
|
+
import { getMainProductName } from '../../product';
|
|
9
|
+
import { checkUsageReportEmpty, getAdminSubscriptionPageUrl } from '../../subscription';
|
|
10
|
+
|
|
11
|
+
export interface UsageReportEmptyEmailTemplateOptions {
|
|
12
|
+
subscriptionId: string;
|
|
13
|
+
usageReportStart: number;
|
|
14
|
+
usageReportEnd: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UsageReportEmptyEmailTemplateContext {
|
|
18
|
+
locale: string;
|
|
19
|
+
userDid: string;
|
|
20
|
+
productName: string;
|
|
21
|
+
subscriptionId: string;
|
|
22
|
+
currentPeriodStart: string;
|
|
23
|
+
currentPeriodEnd: string;
|
|
24
|
+
viewSubscriptionLink: string;
|
|
25
|
+
duration: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class UsageReportEmptyEmailTemplate implements BaseEmailTemplate<UsageReportEmptyEmailTemplateContext> {
|
|
29
|
+
options: UsageReportEmptyEmailTemplateOptions;
|
|
30
|
+
|
|
31
|
+
constructor(options: UsageReportEmptyEmailTemplateOptions) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async getContext(): Promise<UsageReportEmptyEmailTemplateContext> {
|
|
36
|
+
const { usageReportStart, usageReportEnd, subscriptionId } = this.options;
|
|
37
|
+
const subscription: Subscription | null = await Subscription.findByPk(subscriptionId);
|
|
38
|
+
if (!subscription) {
|
|
39
|
+
throw new Error(`Subscription not found: ${subscriptionId}`);
|
|
40
|
+
}
|
|
41
|
+
const userDid = await getOwnerDid();
|
|
42
|
+
if (!userDid) {
|
|
43
|
+
throw new Error('get owner did failed');
|
|
44
|
+
}
|
|
45
|
+
const usageReportEmpty = await checkUsageReportEmpty(subscription, usageReportStart, usageReportEnd);
|
|
46
|
+
if (!usageReportEmpty) {
|
|
47
|
+
throw new Error('Usage report is not empty, no need to send email');
|
|
48
|
+
}
|
|
49
|
+
const locale = await getUserLocale(userDid);
|
|
50
|
+
const productName = await getMainProductName(subscription.id);
|
|
51
|
+
const currentPeriodStart = formatTime(subscription.current_period_start * 1000);
|
|
52
|
+
const currentPeriodEnd = formatTime(subscription.current_period_end * 1000);
|
|
53
|
+
const duration: string = prettyMsI18n(
|
|
54
|
+
new Date(currentPeriodEnd).getTime() - new Date(currentPeriodStart).getTime(),
|
|
55
|
+
{
|
|
56
|
+
locale: getPrettyMsI18nLocale(locale),
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
const viewSubscriptionLink = getAdminSubscriptionPageUrl({
|
|
60
|
+
subscriptionId: subscription.id,
|
|
61
|
+
locale,
|
|
62
|
+
userDid,
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
userDid,
|
|
66
|
+
locale,
|
|
67
|
+
productName,
|
|
68
|
+
subscriptionId: subscription.id,
|
|
69
|
+
currentPeriodStart,
|
|
70
|
+
currentPeriodEnd,
|
|
71
|
+
viewSubscriptionLink,
|
|
72
|
+
duration,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async getTemplate(): Promise<BaseEmailTemplateType> {
|
|
77
|
+
const {
|
|
78
|
+
locale,
|
|
79
|
+
productName,
|
|
80
|
+
subscriptionId,
|
|
81
|
+
currentPeriodStart,
|
|
82
|
+
currentPeriodEnd,
|
|
83
|
+
viewSubscriptionLink,
|
|
84
|
+
duration,
|
|
85
|
+
} = await this.getContext();
|
|
86
|
+
|
|
87
|
+
const template: BaseEmailTemplateType = {
|
|
88
|
+
title: translate('notification.usageReportEmpty.title', locale, {
|
|
89
|
+
productName,
|
|
90
|
+
}),
|
|
91
|
+
body: translate('notification.usageReportEmpty.body', locale, {
|
|
92
|
+
productName,
|
|
93
|
+
}),
|
|
94
|
+
attachments: [
|
|
95
|
+
{
|
|
96
|
+
type: 'section',
|
|
97
|
+
fields: [
|
|
98
|
+
{
|
|
99
|
+
type: 'text',
|
|
100
|
+
data: {
|
|
101
|
+
type: 'plain',
|
|
102
|
+
color: '#9397A1',
|
|
103
|
+
text: translate('notification.common.product', locale),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: 'text',
|
|
108
|
+
data: {
|
|
109
|
+
type: 'plain',
|
|
110
|
+
text: productName,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
type: 'text',
|
|
115
|
+
data: {
|
|
116
|
+
type: 'plain',
|
|
117
|
+
color: '#9397A1',
|
|
118
|
+
text: translate('notification.common.subscriptionId', locale),
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: 'text',
|
|
123
|
+
data: {
|
|
124
|
+
type: 'plain',
|
|
125
|
+
text: subscriptionId,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: 'text',
|
|
130
|
+
data: {
|
|
131
|
+
type: 'plain',
|
|
132
|
+
color: '#9397A1',
|
|
133
|
+
text: translate('notification.common.validityPeriod', locale),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
type: 'text',
|
|
138
|
+
data: {
|
|
139
|
+
type: 'plain',
|
|
140
|
+
text: `${currentPeriodStart} ~ ${currentPeriodEnd}(${duration})`,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
].filter(Boolean),
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
// @ts-ignore
|
|
147
|
+
actions: [
|
|
148
|
+
{
|
|
149
|
+
name: translate('notification.common.viewSubscription', locale),
|
|
150
|
+
title: translate('notification.common.viewSubscription', locale),
|
|
151
|
+
link: viewSubscriptionLink,
|
|
152
|
+
},
|
|
153
|
+
].filter(Boolean),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return template;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -49,6 +49,40 @@ export function getCustomerSubscriptionPageUrl({
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export function getAdminSubscriptionPageUrl({
|
|
53
|
+
subscriptionId,
|
|
54
|
+
locale = 'en',
|
|
55
|
+
userDid,
|
|
56
|
+
}: {
|
|
57
|
+
subscriptionId: string;
|
|
58
|
+
locale: LiteralUnion<'en' | 'zh', string>;
|
|
59
|
+
userDid: string;
|
|
60
|
+
}) {
|
|
61
|
+
return component.getUrl(
|
|
62
|
+
withQuery(`admin/billing/${subscriptionId}`, {
|
|
63
|
+
locale,
|
|
64
|
+
...getConnectQueryParam({ userDid }),
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getAdminInvoicePageUrl({
|
|
70
|
+
invoiceId,
|
|
71
|
+
locale = 'en',
|
|
72
|
+
userDid,
|
|
73
|
+
}: {
|
|
74
|
+
invoiceId: string;
|
|
75
|
+
locale: LiteralUnion<'en' | 'zh', string>;
|
|
76
|
+
userDid: string;
|
|
77
|
+
}) {
|
|
78
|
+
return component.getUrl(
|
|
79
|
+
withQuery(`admin/billing/${invoiceId}`, {
|
|
80
|
+
locale,
|
|
81
|
+
...getConnectQueryParam({ userDid }),
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
export function parseIntegerConfig(alternatives: any[], defaultValue: number) {
|
|
53
87
|
for (const raw of alternatives) {
|
|
54
88
|
const days = parseInt(raw, 10);
|
|
@@ -909,3 +943,36 @@ export async function getSubscriptionStakeAmountSetup(subscription: Subscription
|
|
|
909
943
|
logger.info('get subscription stake amount setup success', { txHash, amountRes });
|
|
910
944
|
return amountRes;
|
|
911
945
|
}
|
|
946
|
+
|
|
947
|
+
// check if usage report is empty
|
|
948
|
+
export async function checkUsageReportEmpty(
|
|
949
|
+
subscription: Subscription,
|
|
950
|
+
usageReportStart: number,
|
|
951
|
+
usageReportEnd: number
|
|
952
|
+
) {
|
|
953
|
+
const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
954
|
+
const expandedItems = await Price.expand(
|
|
955
|
+
subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
|
|
956
|
+
{ product: true }
|
|
957
|
+
);
|
|
958
|
+
const meteredItems = expandedItems.filter((x: any) => x?.price?.recurring?.usage_type === 'metered');
|
|
959
|
+
if (meteredItems.length === 0) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
const usageReportEmpty = await Promise.all(
|
|
963
|
+
meteredItems.map(async (x: any) => {
|
|
964
|
+
const usageRecords = await UsageRecord.findAll({
|
|
965
|
+
where: {
|
|
966
|
+
subscription_item_id: x.id,
|
|
967
|
+
billed: false,
|
|
968
|
+
timestamp: {
|
|
969
|
+
[Op.gt]: usageReportStart,
|
|
970
|
+
[Op.lte]: usageReportEnd,
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
return usageRecords.length === 0;
|
|
975
|
+
})
|
|
976
|
+
);
|
|
977
|
+
return usageReportEmpty.every(Boolean);
|
|
978
|
+
}
|
package/api/src/libs/util.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { joinURL, withQuery } from 'ufo';
|
|
|
10
10
|
|
|
11
11
|
import dayjs from './dayjs';
|
|
12
12
|
import { blocklet, wallet } from './auth';
|
|
13
|
+
import type { Subscription } from '../store/models';
|
|
14
|
+
import logger from './logger';
|
|
13
15
|
|
|
14
16
|
export const OCAP_PAYMENT_TX_TYPE = 'fg:t:transfer_v2';
|
|
15
17
|
|
|
@@ -289,3 +291,31 @@ export function getCustomerProfileUrl({
|
|
|
289
291
|
})
|
|
290
292
|
);
|
|
291
293
|
}
|
|
294
|
+
|
|
295
|
+
export async function getOwnerDid() {
|
|
296
|
+
try {
|
|
297
|
+
const { user } = await blocklet.getOwner();
|
|
298
|
+
return user?.did;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
logger.error('getOwnerDid error', error);
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function getSubscriptionNotificationCustomActions(
|
|
306
|
+
subscription: Subscription,
|
|
307
|
+
eventType: string,
|
|
308
|
+
locale: string
|
|
309
|
+
) {
|
|
310
|
+
if (!subscription || !subscription?.service_actions || !subscription?.service_actions?.length) {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
const actions = subscription.service_actions?.filter(
|
|
314
|
+
(x: any) => x?.type === 'notification' && x?.triggerEvents?.includes(eventType)
|
|
315
|
+
);
|
|
316
|
+
return actions?.map((x: any) => ({
|
|
317
|
+
name: x?.name || x?.text?.[locale],
|
|
318
|
+
title: x?.text?.[locale],
|
|
319
|
+
link: x?.link,
|
|
320
|
+
}));
|
|
321
|
+
}
|
package/api/src/locales/en.ts
CHANGED
|
@@ -45,6 +45,14 @@ export default flat({
|
|
|
45
45
|
qty: '{count} unit',
|
|
46
46
|
failReason: 'Failure reason',
|
|
47
47
|
balanceReminder: 'Balance reminder',
|
|
48
|
+
subscriptionId: 'Subscription ID',
|
|
49
|
+
shouldPayAmount: 'Should pay amount',
|
|
50
|
+
billedAmount: 'Billed amount',
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
billingDiscrepancy: {
|
|
54
|
+
title: '{productName} billing discrepancy',
|
|
55
|
+
body: 'Detected billing discrepancy for {productName}, please check.',
|
|
48
56
|
},
|
|
49
57
|
|
|
50
58
|
sendTo: 'Sent to',
|
|
@@ -53,6 +61,11 @@ export default flat({
|
|
|
53
61
|
message: 'A new {collection} NFT is minted and sent to your wallet, please check it out.',
|
|
54
62
|
},
|
|
55
63
|
|
|
64
|
+
usageReportEmpty: {
|
|
65
|
+
title: 'No usage report for {productName}',
|
|
66
|
+
body: 'No usage report for {productName} detected, please check.',
|
|
67
|
+
},
|
|
68
|
+
|
|
56
69
|
subscriptionTrialStart: {
|
|
57
70
|
title: 'Welcome to the start of your {productName} trial',
|
|
58
71
|
body: 'Congratulations on your {productName} trial! The length of the trial is {trialDuration} and will end at {subscriptionTrialEnd}. Have fun with {productName}!',
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -45,6 +45,9 @@ export default flat({
|
|
|
45
45
|
qty: '{count} 件',
|
|
46
46
|
failReason: '失败原因',
|
|
47
47
|
balanceReminder: '余额提醒',
|
|
48
|
+
subscriptionId: '订阅 ID',
|
|
49
|
+
shouldPayAmount: '应收金额',
|
|
50
|
+
billedAmount: '实缴金额',
|
|
48
51
|
},
|
|
49
52
|
|
|
50
53
|
sendTo: '发送给',
|
|
@@ -53,6 +56,16 @@ export default flat({
|
|
|
53
56
|
message: '{collection} NFT 已经铸造完成并发送到你的钱包,请查收',
|
|
54
57
|
},
|
|
55
58
|
|
|
59
|
+
usageReportEmpty: {
|
|
60
|
+
title: '{productName} 未上报用量',
|
|
61
|
+
body: '检测到 {productName} 未上报用量,请留意。',
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
billingDiscrepancy: {
|
|
65
|
+
title: '{productName} 账单金额核算不一致',
|
|
66
|
+
body: '检测到 {productName} 账单金额核算不一致,请留意。',
|
|
67
|
+
},
|
|
68
|
+
|
|
56
69
|
subscriptionTrialStart: {
|
|
57
70
|
title: '欢迎开始您的 {productName} 试用之旅',
|
|
58
71
|
body: '恭喜您获得了 {productName} 的试用资格!试用期时长为 {trialDuration},将于 {subscriptionTrialEnd} 结束。祝您使用愉快!',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Op } from 'sequelize';
|
|
2
2
|
|
|
3
|
+
import { getInvoiceShouldPayTotal } from '../libs/invoice';
|
|
3
4
|
import { batchHandleStripeInvoices } from '../integrations/stripe/resource';
|
|
4
5
|
import { createEvent } from '../libs/audit';
|
|
5
6
|
import dayjs from '../libs/dayjs';
|
|
@@ -12,6 +13,7 @@ import { Subscription } from '../store/models/subscription';
|
|
|
12
13
|
import { paymentQueue } from './payment';
|
|
13
14
|
|
|
14
15
|
import { getLock } from '../libs/lock';
|
|
16
|
+
import { events } from '../libs/event';
|
|
15
17
|
|
|
16
18
|
type InvoiceJob = {
|
|
17
19
|
invoiceId: string;
|
|
@@ -240,3 +242,19 @@ export const startInvoiceQueue = async () => {
|
|
|
240
242
|
invoiceQueue.on('failed', ({ id, job, error }) => {
|
|
241
243
|
logger.error('Invoice job failed', { id, job, error });
|
|
242
244
|
});
|
|
245
|
+
|
|
246
|
+
events.on('invoice.paid', async ({ id: invoiceId }) => {
|
|
247
|
+
const invoice = await Invoice.findByPk(invoiceId);
|
|
248
|
+
if (!invoice) {
|
|
249
|
+
logger.error('Invoice not found', { invoiceId });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const checkBillingReason = ['subscription_cycle', 'subscription_cancel'];
|
|
253
|
+
if (checkBillingReason.includes(invoice.billing_reason)) {
|
|
254
|
+
const shouldPayTotal = await getInvoiceShouldPayTotal(invoice);
|
|
255
|
+
if (shouldPayTotal !== invoice.total) {
|
|
256
|
+
createEvent('Invoice', 'billing.discrepancy', invoice);
|
|
257
|
+
logger.info('create billing discrepancy event', { invoiceId, shouldPayTotal, invoiceTotal: invoice.total });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
@@ -57,6 +57,14 @@ import {
|
|
|
57
57
|
} from '../libs/notification/template/subscription-stake-slash-succeeded';
|
|
58
58
|
import createQueue from '../libs/queue';
|
|
59
59
|
import { CheckoutSession, EventType, Invoice, PaymentLink, Refund, Subscription } from '../store/models';
|
|
60
|
+
import {
|
|
61
|
+
UsageReportEmptyEmailTemplate,
|
|
62
|
+
UsageReportEmptyEmailTemplateOptions,
|
|
63
|
+
} from '../libs/notification/template/usage-report-empty';
|
|
64
|
+
import {
|
|
65
|
+
BillingDiscrepancyEmailTemplate,
|
|
66
|
+
BillingDiscrepancyEmailTemplateOptions,
|
|
67
|
+
} from '../libs/notification/template/billing-discrepancy';
|
|
60
68
|
|
|
61
69
|
export type NotificationQueueJobOptions = any;
|
|
62
70
|
|
|
@@ -65,7 +73,9 @@ export type NotificationQueueJobType =
|
|
|
65
73
|
| 'customer.subscription.will_renew'
|
|
66
74
|
| 'customer.subscription.trial_will_end'
|
|
67
75
|
| 'customer.subscription.will_canceled'
|
|
68
|
-
| 'customer.reward.succeeded'
|
|
76
|
+
| 'customer.reward.succeeded'
|
|
77
|
+
| 'usage.report.empty'
|
|
78
|
+
| 'billing.discrepancy';
|
|
69
79
|
|
|
70
80
|
export type NotificationQueueJob = {
|
|
71
81
|
type: NotificationQueueJobType;
|
|
@@ -73,6 +83,12 @@ export type NotificationQueueJob = {
|
|
|
73
83
|
};
|
|
74
84
|
|
|
75
85
|
function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
|
|
86
|
+
if (job.type === 'usage.report.empty') {
|
|
87
|
+
return new UsageReportEmptyEmailTemplate(job.options as UsageReportEmptyEmailTemplateOptions);
|
|
88
|
+
}
|
|
89
|
+
if (job.type === 'billing.discrepancy') {
|
|
90
|
+
return new BillingDiscrepancyEmailTemplate(job.options as BillingDiscrepancyEmailTemplateOptions);
|
|
91
|
+
}
|
|
76
92
|
if (job.type === 'customer.subscription.started') {
|
|
77
93
|
return new SubscriptionSucceededEmailTemplate(job.options as SubscriptionSucceededEmailTemplateOptions);
|
|
78
94
|
}
|
|
@@ -275,4 +291,30 @@ export async function startNotificationQueue() {
|
|
|
275
291
|
},
|
|
276
292
|
});
|
|
277
293
|
});
|
|
294
|
+
|
|
295
|
+
events.on('usage.report.empty', (subscription: Subscription, { usageReportStart, usageReportEnd }) => {
|
|
296
|
+
notificationQueue.push({
|
|
297
|
+
id: `usage.report.empty.${subscription.id}`,
|
|
298
|
+
job: {
|
|
299
|
+
type: 'usage.report.empty',
|
|
300
|
+
options: {
|
|
301
|
+
subscriptionId: subscription.id,
|
|
302
|
+
usageReportStart,
|
|
303
|
+
usageReportEnd,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
events.on('billing.discrepancy', (invoice: Invoice) => {
|
|
310
|
+
notificationQueue.push({
|
|
311
|
+
id: `billing.discrepancy.${invoice.id}`,
|
|
312
|
+
job: {
|
|
313
|
+
type: 'billing.discrepancy',
|
|
314
|
+
options: {
|
|
315
|
+
invoiceId: invoice.id,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
});
|
|
278
320
|
}
|