payment-kit 1.15.17 → 1.15.18

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 (43) hide show
  1. package/api/src/integrations/stripe/handlers/invoice.ts +20 -0
  2. package/api/src/libs/audit.ts +1 -1
  3. package/api/src/libs/invoice.ts +81 -1
  4. package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
  5. package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
  6. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
  7. package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
  8. package/api/src/libs/notification/template/subscription-renewed.ts +10 -2
  9. package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
  10. package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
  11. package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
  12. package/api/src/libs/notification/template/subscription-trial-will-end.ts +11 -0
  13. package/api/src/libs/notification/template/subscription-upgraded.ts +11 -1
  14. package/api/src/libs/notification/template/subscription-will-canceled.ts +10 -0
  15. package/api/src/libs/notification/template/subscription-will-renew.ts +10 -1
  16. package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
  17. package/api/src/libs/subscription.ts +67 -0
  18. package/api/src/libs/util.ts +30 -0
  19. package/api/src/locales/en.ts +13 -0
  20. package/api/src/locales/zh.ts +13 -0
  21. package/api/src/queues/invoice.ts +18 -0
  22. package/api/src/queues/notification.ts +43 -1
  23. package/api/src/queues/subscription.ts +21 -2
  24. package/api/src/routes/checkout-sessions.ts +26 -0
  25. package/api/src/routes/subscriptions.ts +5 -3
  26. package/api/src/store/models/checkout-session.ts +2 -0
  27. package/api/src/store/models/types.ts +22 -4
  28. package/api/src/store/models/usage-record.ts +5 -1
  29. package/api/tests/libs/subscription.spec.ts +58 -1
  30. package/api/tests/libs/util.spec.ts +135 -0
  31. package/blocklet.yml +1 -1
  32. package/package.json +4 -4
  33. package/scripts/sdk.js +37 -3
  34. package/src/components/invoice/list.tsx +0 -1
  35. package/src/components/invoice/table.tsx +7 -2
  36. package/src/components/subscription/items/index.tsx +26 -7
  37. package/src/components/subscription/items/usage-records.tsx +21 -10
  38. package/src/components/subscription/portal/actions.tsx +16 -14
  39. package/src/libs/util.ts +51 -0
  40. package/src/locales/en.tsx +2 -0
  41. package/src/locales/zh.tsx +2 -0
  42. package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
  43. 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 { getOwnerDid } from '@api/libs/util';
2
+ import prettyMsI18n from 'pretty-ms-i18n';
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
+ }
@@ -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
+ }
@@ -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}!',
@@ -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 '@api/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
  }