payment-kit 1.15.16 → 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 (51) hide show
  1. package/api/src/integrations/stripe/handlers/invoice.ts +20 -0
  2. package/api/src/integrations/stripe/resource.ts +2 -2
  3. package/api/src/libs/audit.ts +1 -1
  4. package/api/src/libs/invoice.ts +81 -1
  5. package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
  6. package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
  7. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
  8. package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
  9. package/api/src/libs/notification/template/subscription-renewed.ts +11 -3
  10. package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
  11. package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
  12. package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
  13. package/api/src/libs/notification/template/subscription-trial-will-end.ts +17 -0
  14. package/api/src/libs/notification/template/subscription-upgraded.ts +51 -26
  15. package/api/src/libs/notification/template/subscription-will-canceled.ts +16 -0
  16. package/api/src/libs/notification/template/subscription-will-renew.ts +15 -3
  17. package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
  18. package/api/src/libs/queue/index.ts +69 -19
  19. package/api/src/libs/queue/store.ts +28 -5
  20. package/api/src/libs/subscription.ts +129 -19
  21. package/api/src/libs/util.ts +30 -0
  22. package/api/src/locales/en.ts +13 -0
  23. package/api/src/locales/zh.ts +13 -0
  24. package/api/src/queues/invoice.ts +58 -20
  25. package/api/src/queues/notification.ts +43 -1
  26. package/api/src/queues/payment.ts +5 -1
  27. package/api/src/queues/subscription.ts +64 -15
  28. package/api/src/routes/checkout-sessions.ts +26 -0
  29. package/api/src/routes/invoices.ts +11 -31
  30. package/api/src/routes/subscriptions.ts +43 -7
  31. package/api/src/store/models/checkout-session.ts +2 -0
  32. package/api/src/store/models/job.ts +4 -0
  33. package/api/src/store/models/types.ts +22 -4
  34. package/api/src/store/models/usage-record.ts +5 -1
  35. package/api/tests/libs/subscription.spec.ts +154 -0
  36. package/api/tests/libs/util.spec.ts +135 -0
  37. package/blocklet.yml +1 -1
  38. package/package.json +10 -10
  39. package/scripts/sdk.js +37 -3
  40. package/src/components/invoice/list.tsx +0 -1
  41. package/src/components/invoice/table.tsx +7 -2
  42. package/src/components/subscription/items/index.tsx +26 -7
  43. package/src/components/subscription/items/usage-records.tsx +21 -10
  44. package/src/components/subscription/portal/actions.tsx +16 -14
  45. package/src/libs/util.ts +51 -0
  46. package/src/locales/en.tsx +2 -0
  47. package/src/locales/zh.tsx +2 -0
  48. package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
  49. package/src/pages/customer/subscription/change-plan.tsx +1 -1
  50. package/src/pages/customer/subscription/embed.tsx +16 -14
  51. package/vite-server.config.ts +8 -0
@@ -4,6 +4,8 @@ import pick from 'lodash/pick';
4
4
  import pWaitFor from 'p-wait-for';
5
5
  import type Stripe from 'stripe';
6
6
 
7
+ import { checkUsageReportEmpty } from '@api/libs/subscription';
8
+ import { createEvent } from '@api/libs/audit';
7
9
  import { getLock } from '../../../libs/lock';
8
10
  import logger from '../../../libs/logger';
9
11
  import {
@@ -256,6 +258,24 @@ export async function handleStripeInvoiceCreated(event: TEventExpanded, client:
256
258
  const customer = await Customer.findByPk(subscription.customer_id);
257
259
  const checkoutSession = await CheckoutSession.findOne({ where: { subscription_id: subscription.id } });
258
260
 
261
+ if (stripeInvoice.billing_reason === 'subscription_cycle') {
262
+ // check if usage report is empty
263
+ const usageReportStart = stripeInvoice.period_start;
264
+ const usageReportEnd = stripeInvoice.period_end;
265
+ const usageReportEmpty = await checkUsageReportEmpty(subscription, usageReportStart, usageReportEnd);
266
+ if (usageReportEmpty) {
267
+ createEvent('Subscription', 'usage.report.empty', subscription, {
268
+ usageReportStart,
269
+ usageReportEnd,
270
+ }).catch(console.error);
271
+ logger.info('create usage report empty event', {
272
+ subscriptionId: subscription.id,
273
+ usageReportStart,
274
+ usageReportEnd,
275
+ });
276
+ }
277
+ }
278
+
259
279
  // create stripe invoice
260
280
  const invoice = await ensureStripeInvoice(stripeInvoice, subscription, client);
261
281
 
@@ -44,7 +44,7 @@ export async function ensureStripeProduct(internal: Product, method: PaymentMeth
44
44
  attrs.unit_label = internal.unit_label;
45
45
  }
46
46
  if (internal.statement_descriptor) {
47
- attrs.statement_descriptor_suffix = '';
47
+ attrs.statement_descriptor = '';
48
48
  }
49
49
 
50
50
  const product = await client.products.create(attrs);
@@ -176,7 +176,7 @@ export async function ensureStripePaymentIntent(
176
176
  enabled: true,
177
177
  allow_redirects: 'never',
178
178
  },
179
- statement_descriptor_suffix: '',
179
+ statement_descriptor: '',
180
180
  metadata: {
181
181
  appPid: env.appPid,
182
182
  id: internal.id,
@@ -33,7 +33,7 @@ export async function createEvent(scope: string, type: LiteralUnion<EventType, s
33
33
  });
34
34
 
35
35
  events.emit('event.created', { id: event.id });
36
- events.emit(event.type, data.object);
36
+ events.emit(event.type, data.object, options);
37
37
  }
38
38
 
39
39
  export async function createStatusEvent(
@@ -3,9 +3,20 @@ import type { LiteralUnion } from 'type-fest';
3
3
  import { withQuery } from 'ufo';
4
4
 
5
5
  import { fromUnitToToken } from '@ocap/util';
6
- import { Invoice, InvoiceItem, PaymentCurrency, Price, Product } from '../store/models';
6
+ import {
7
+ Invoice,
8
+ InvoiceItem,
9
+ PaymentCurrency,
10
+ PaymentMethod,
11
+ Price,
12
+ Product,
13
+ Subscription,
14
+ SubscriptionItem,
15
+ UsageRecord,
16
+ } from '../store/models';
7
17
  import { getConnectQueryParam } from './util';
8
18
  import { expandLineItems } from './session';
19
+ import { getSubscriptionCycleAmount, getSubscriptionCycleSetup } from './subscription';
9
20
 
10
21
  export function getCustomerInvoicePageUrl({
11
22
  invoiceId,
@@ -64,3 +75,72 @@ export async function getOneTimeProductInfo(invoiceId: string, paymentCurrency:
64
75
  return [];
65
76
  }
66
77
  }
78
+
79
+ export async function getInvoiceShouldPayTotal(invoice: Invoice) {
80
+ try {
81
+ const subscription = await Subscription.findByPk(invoice.subscription_id);
82
+ if (!subscription) {
83
+ throw new Error(`Subscription not found: ${invoice.subscription_id}`);
84
+ }
85
+
86
+ const paymentCurrency = await PaymentCurrency.findByPk(invoice.currency_id);
87
+ if (!paymentCurrency) {
88
+ throw new Error(`Payment currency not found: ${invoice.currency_id}`);
89
+ }
90
+
91
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
92
+ if (!paymentMethod) {
93
+ throw new Error(`Payment method not found: ${paymentCurrency.payment_method_id}`);
94
+ }
95
+
96
+ const subscriptionItems = await SubscriptionItem.findAll({
97
+ where: { subscription_id: subscription.id },
98
+ });
99
+
100
+ let expandedItems = await Price.expand(
101
+ subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
102
+ { product: true }
103
+ );
104
+
105
+ const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, invoice.period_start);
106
+ let offset = 0;
107
+ let filterFunc = (item: any) => item;
108
+ if (['arcblock', 'ethereum'].includes(paymentMethod.type)) {
109
+ switch (invoice.billing_reason) {
110
+ case 'subscription_cancel':
111
+ filterFunc = (item: any) => item.price?.recurring?.usage_type === 'metered';
112
+ break;
113
+ case 'subscription_cycle':
114
+ filterFunc = (item: any) => item.price?.type === 'recurring';
115
+ offset = setup.cycle / 1000;
116
+ break;
117
+ default:
118
+ filterFunc = () => true;
119
+ }
120
+ }
121
+ const previousPeriodStart = setup.period.start - offset;
122
+ const previousPeriodEnd = setup.period.end - offset;
123
+ expandedItems = await Promise.all(
124
+ expandedItems.filter(filterFunc).map(async (item: any) => {
125
+ const { price } = item;
126
+ if (price?.recurring?.usage_type === 'metered') {
127
+ const rawQuantity = await UsageRecord.getSummary({
128
+ id: item.id,
129
+ start: previousPeriodStart,
130
+ end: previousPeriodEnd,
131
+ method: price.recurring?.aggregate_usage,
132
+ dryRun: true,
133
+ searchBilled: false,
134
+ });
135
+ item.quantity = price.transformQuantity(rawQuantity);
136
+ }
137
+ return item;
138
+ })
139
+ );
140
+ const amount = getSubscriptionCycleAmount(expandedItems, subscription.currency_id);
141
+ return amount?.total || invoice.total;
142
+ } catch (err) {
143
+ console.error(err);
144
+ return invoice.total;
145
+ }
146
+ }
@@ -0,0 +1,223 @@
1
+ import { fromUnitToToken } from '@ocap/util';
2
+ import prettyMsI18n from 'pretty-ms-i18n';
3
+ import { getOwnerDid } from '../../util';
4
+ import { translate } from '../../../locales';
5
+ import { Invoice, PaymentCurrency, Subscription } from '../../../store/models';
6
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
7
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
8
+ import { formatTime, getPrettyMsI18nLocale } from '../../time';
9
+ import { getAdminSubscriptionPageUrl, getAdminInvoicePageUrl } from '../../subscription';
10
+ import { getMainProductName } from '../../product';
11
+ import { getInvoiceShouldPayTotal } from '../../invoice';
12
+
13
+ export interface BillingDiscrepancyEmailTemplateOptions {
14
+ invoiceId: string;
15
+ }
16
+
17
+ interface BillingDiscrepancyEmailTemplateContext {
18
+ locale: string;
19
+ userDid: string;
20
+ productName: string;
21
+ subscriptionId: string;
22
+ billingAmount: string;
23
+ shouldPayAmount: string;
24
+ invoiceId: string;
25
+ currentPeriodStart: string;
26
+ currentPeriodEnd: string;
27
+ duration: string;
28
+ viewSubscriptionLink: string;
29
+ viewInvoiceLink: string;
30
+ }
31
+
32
+ export class BillingDiscrepancyEmailTemplate implements BaseEmailTemplate<BillingDiscrepancyEmailTemplateContext> {
33
+ options: BillingDiscrepancyEmailTemplateOptions;
34
+
35
+ constructor(options: BillingDiscrepancyEmailTemplateOptions) {
36
+ this.options = options;
37
+ }
38
+
39
+ async getContext(): Promise<BillingDiscrepancyEmailTemplateContext> {
40
+ const { invoiceId } = this.options;
41
+ const invoice = await Invoice.findByPk(invoiceId);
42
+ if (!invoice) {
43
+ throw new Error(`Invoice not found: ${invoiceId}`);
44
+ }
45
+ const subscriptionId = invoice.subscription_id;
46
+ const subscription: Subscription | null = await Subscription.findByPk(subscriptionId);
47
+ if (!subscription) {
48
+ throw new Error(`Subscription not found: ${subscriptionId}`);
49
+ }
50
+ const paymentCurrency = (await PaymentCurrency.findOne({
51
+ where: {
52
+ id: subscription.currency_id,
53
+ },
54
+ })) as PaymentCurrency;
55
+
56
+ const userDid = await getOwnerDid();
57
+ if (!userDid) {
58
+ throw new Error('get owner did failed');
59
+ }
60
+ const locale = await getUserLocale(userDid);
61
+ const productName = await getMainProductName(subscription.id);
62
+ const currentPeriodStart = formatTime(invoice.period_start * 1000);
63
+ const currentPeriodEnd = formatTime(invoice.period_end * 1000);
64
+ const duration: string = prettyMsI18n(
65
+ new Date(currentPeriodEnd).getTime() - new Date(currentPeriodStart).getTime(),
66
+ {
67
+ locale: getPrettyMsI18nLocale(locale),
68
+ }
69
+ );
70
+ const viewSubscriptionLink = getAdminSubscriptionPageUrl({
71
+ subscriptionId: subscription.id,
72
+ locale,
73
+ userDid,
74
+ });
75
+ const viewInvoiceLink = getAdminInvoicePageUrl({
76
+ invoiceId,
77
+ userDid,
78
+ locale,
79
+ });
80
+
81
+ const billingAmount = `${fromUnitToToken(invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
82
+
83
+ const shouldPayTotal = await getInvoiceShouldPayTotal(invoice);
84
+ if (shouldPayTotal === invoice.total) {
85
+ throw new Error('should pay total is equal to invoice total, no need to send billing discrepancy notification');
86
+ }
87
+ const shouldPayAmount = `${fromUnitToToken(shouldPayTotal, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
88
+ return {
89
+ userDid,
90
+ locale,
91
+ productName,
92
+ subscriptionId: subscription.id,
93
+ billingAmount,
94
+ shouldPayAmount,
95
+ invoiceId,
96
+ currentPeriodStart,
97
+ currentPeriodEnd,
98
+ duration,
99
+ viewSubscriptionLink,
100
+ viewInvoiceLink,
101
+ };
102
+ }
103
+
104
+ async getTemplate(): Promise<BaseEmailTemplateType> {
105
+ const {
106
+ locale,
107
+ productName,
108
+ subscriptionId,
109
+ billingAmount,
110
+ currentPeriodStart,
111
+ currentPeriodEnd,
112
+ duration,
113
+ viewSubscriptionLink,
114
+ viewInvoiceLink,
115
+ shouldPayAmount,
116
+ } = await this.getContext();
117
+
118
+ const template: BaseEmailTemplateType = {
119
+ title: translate('notification.billingDiscrepancy.title', locale, {
120
+ productName,
121
+ }),
122
+ body: translate('notification.billingDiscrepancy.body', locale, {
123
+ productName,
124
+ }),
125
+ attachments: [
126
+ {
127
+ type: 'section',
128
+ fields: [
129
+ {
130
+ type: 'text',
131
+ data: {
132
+ type: 'plain',
133
+ color: '#9397A1',
134
+ text: translate('notification.common.product', locale),
135
+ },
136
+ },
137
+ {
138
+ type: 'text',
139
+ data: {
140
+ type: 'plain',
141
+ text: productName,
142
+ },
143
+ },
144
+ {
145
+ type: 'text',
146
+ data: {
147
+ type: 'plain',
148
+ color: '#9397A1',
149
+ text: translate('notification.common.subscriptionId', locale),
150
+ },
151
+ },
152
+ {
153
+ type: 'text',
154
+ data: {
155
+ type: 'plain',
156
+ text: subscriptionId,
157
+ },
158
+ },
159
+ {
160
+ type: 'text',
161
+ data: {
162
+ type: 'plain',
163
+ color: '#9397A1',
164
+ text: translate('notification.common.shouldPayAmount', locale),
165
+ },
166
+ },
167
+ {
168
+ type: 'text',
169
+ data: {
170
+ type: 'plain',
171
+ text: shouldPayAmount,
172
+ },
173
+ },
174
+ {
175
+ type: 'text',
176
+ data: {
177
+ type: 'plain',
178
+ color: '#9397A1',
179
+ text: translate('notification.common.billedAmount', locale),
180
+ },
181
+ },
182
+ {
183
+ type: 'text',
184
+ data: {
185
+ type: 'plain',
186
+ text: billingAmount,
187
+ },
188
+ },
189
+ {
190
+ type: 'text',
191
+ data: {
192
+ type: 'plain',
193
+ color: '#9397A1',
194
+ text: translate('notification.common.validityPeriod', locale),
195
+ },
196
+ },
197
+ {
198
+ type: 'text',
199
+ data: {
200
+ type: 'plain',
201
+ text: `${currentPeriodStart} ~ ${currentPeriodEnd}(${duration})`,
202
+ },
203
+ },
204
+ ].filter(Boolean),
205
+ },
206
+ ],
207
+ actions: [
208
+ {
209
+ name: translate('notification.common.viewSubscription', locale),
210
+ title: translate('notification.common.viewSubscription', locale),
211
+ link: viewSubscriptionLink,
212
+ },
213
+ {
214
+ name: translate('notification.common.viewInvoice', locale),
215
+ title: translate('notification.common.viewInvoice', locale),
216
+ link: viewInvoiceLink,
217
+ },
218
+ ].filter(Boolean),
219
+ };
220
+
221
+ return template;
222
+ }
223
+ }
@@ -12,6 +12,7 @@ import { getMainProductName } from '../../product';
12
12
  import { getCustomerSubscriptionPageUrl, getSubscriptionStakeCancellation } from '../../subscription';
13
13
  import { formatTime, getPrettyMsI18nLocale } from '../../time';
14
14
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
15
+ import { getSubscriptionNotificationCustomActions } from '../../util';
15
16
 
16
17
  export interface SubscriptionCanceledEmailTemplateOptions {
17
18
  subscriptionId: string;
@@ -31,6 +32,7 @@ interface SubscriptionCanceledEmailTemplateContext {
31
32
  cancellationReason: string;
32
33
 
33
34
  viewSubscriptionLink: string;
35
+ customActions: any[];
34
36
  }
35
37
 
36
38
  export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<SubscriptionCanceledEmailTemplateContext> {
@@ -128,6 +130,12 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
128
130
  userDid,
129
131
  });
130
132
 
133
+ const customActions = getSubscriptionNotificationCustomActions(
134
+ subscription,
135
+ 'customer.subscription.deleted',
136
+ locale
137
+ );
138
+
131
139
  return {
132
140
  locale,
133
141
  productName,
@@ -142,6 +150,7 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
142
150
  cancellationReason,
143
151
 
144
152
  viewSubscriptionLink,
153
+ customActions,
145
154
  };
146
155
  }
147
156
 
@@ -159,6 +168,7 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
159
168
  cancellationReason,
160
169
 
161
170
  viewSubscriptionLink,
171
+ customActions,
162
172
  } = await this.getContext();
163
173
 
164
174
  const template: BaseEmailTemplateType = {
@@ -259,6 +269,7 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
259
269
  title: translate('notification.common.viewSubscription', locale),
260
270
  link: viewSubscriptionLink,
261
271
  },
272
+ ...customActions,
262
273
  ].filter(Boolean),
263
274
  };
264
275
 
@@ -5,13 +5,13 @@ import prettyMsI18n from 'pretty-ms-i18n';
5
5
 
6
6
  import { getUserLocale } from '../../../integrations/blocklet/notification';
7
7
  import { translate } from '../../../locales';
8
- import { Customer, PaymentIntent, PaymentMethod, Refund } from '../../../store/models';
8
+ import { Customer, PaymentIntent, PaymentMethod, Refund, Subscription } from '../../../store/models';
9
9
  import { Invoice } from '../../../store/models/invoice';
10
10
  import { PaymentCurrency } from '../../../store/models/payment-currency';
11
11
  import { getMainProductName } from '../../product';
12
12
  import { getCustomerSubscriptionPageUrl } from '../../subscription';
13
13
  import { formatTime, getPrettyMsI18nLocale } from '../../time';
14
- import { getExplorerLink } from '../../util';
14
+ import { getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
15
15
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
16
16
 
17
17
  export interface SubscriptionRefundSucceededEmailTemplateOptions {
@@ -37,6 +37,7 @@ interface SubscriptionRefundSucceededEmailTemplateContext {
37
37
  viewSubscriptionLink: string;
38
38
  viewTxHashLink: string | undefined;
39
39
  refund: Refund;
40
+ customActions: any[];
40
41
  }
41
42
 
42
43
  export class SubscriptionRefundSucceededEmailTemplate
@@ -122,6 +123,12 @@ export class SubscriptionRefundSucceededEmailTemplate
122
123
  chainHost,
123
124
  });
124
125
 
126
+ let customActions: any[] = [];
127
+ if (refund?.subscription_id) {
128
+ const subscription = await Subscription.findByPk(refund.subscription_id);
129
+ customActions = getSubscriptionNotificationCustomActions(subscription!, 'refund.succeeded', locale);
130
+ }
131
+
125
132
  return {
126
133
  locale,
127
134
  productName,
@@ -141,6 +148,7 @@ export class SubscriptionRefundSucceededEmailTemplate
141
148
  viewSubscriptionLink,
142
149
  viewTxHashLink,
143
150
  refund,
151
+ customActions,
144
152
  };
145
153
  }
146
154
 
@@ -21,7 +21,7 @@ import { SufficientForPaymentResult, getPaymentDetail } from '../../payment';
21
21
  import { getMainProductName } from '../../product';
22
22
  import { getCustomerSubscriptionPageUrl } from '../../subscription';
23
23
  import { formatTime, getPrettyMsI18nLocale } from '../../time';
24
- import { getExplorerLink } from '../../util';
24
+ import { getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
25
25
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
26
26
 
27
27
  export interface SubscriptionRenewFailedEmailTemplateOptions {
@@ -45,6 +45,7 @@ interface SubscriptionRenewFailedEmailTemplateContext {
45
45
  viewSubscriptionLink: string;
46
46
  viewInvoiceLink: string;
47
47
  viewTxHashLink: string | undefined;
48
+ customActions: any[];
48
49
  }
49
50
 
50
51
  export class SubscriptionRenewFailedEmailTemplate
@@ -147,7 +148,11 @@ export class SubscriptionRenewFailedEmailTemplate
147
148
  chainHost,
148
149
  })
149
150
  : undefined;
150
-
151
+ const customActions = getSubscriptionNotificationCustomActions(
152
+ subscription,
153
+ 'customer.subscription.renew_failed',
154
+ locale
155
+ );
151
156
  return {
152
157
  locale,
153
158
  productName,
@@ -164,6 +169,7 @@ export class SubscriptionRenewFailedEmailTemplate
164
169
  viewSubscriptionLink,
165
170
  viewInvoiceLink,
166
171
  viewTxHashLink,
172
+ customActions,
167
173
  };
168
174
  }
169
175
 
@@ -182,6 +188,7 @@ export class SubscriptionRenewFailedEmailTemplate
182
188
  viewSubscriptionLink,
183
189
  viewInvoiceLink,
184
190
  viewTxHashLink,
191
+ customActions,
185
192
  } = await this.getContext();
186
193
 
187
194
  const template: BaseEmailTemplateType = {
@@ -308,6 +315,7 @@ export class SubscriptionRenewFailedEmailTemplate
308
315
  title: translate('notification.common.viewTxHash', locale),
309
316
  link: viewTxHashLink as string,
310
317
  },
318
+ ...customActions,
311
319
  ].filter(Boolean),
312
320
  };
313
321
 
@@ -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 SubscriptionRenewedEmailTemplateOptions {
@@ -49,6 +49,7 @@ interface SubscriptionRenewedEmailTemplateContext {
49
49
  viewInvoiceLink: string;
50
50
  viewTxHashLink: string | undefined;
51
51
  invoice: Invoice;
52
+ customActions: any[];
52
53
  }
53
54
 
54
55
  export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<SubscriptionRenewedEmailTemplateContext> {
@@ -93,7 +94,7 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
93
94
  const userDid: string = customer.did;
94
95
  const locale = await getUserLocale(userDid);
95
96
  const productName = await getMainProductName(subscription.id);
96
- const at: string = formatTime(Date.now());
97
+ const at: string = formatTime((invoice?.status_transitions?.paid_at ?? Math.floor(Date.now() / 1000)) * 1000);
97
98
 
98
99
  const hasNft: boolean = checkoutSession?.nft_mint_status === 'minted';
99
100
  const nftMintItem: NftMintItem | undefined = hasNft
@@ -133,7 +134,11 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
133
134
  chainHost,
134
135
  })
135
136
  : undefined;
136
-
137
+ const customActions = getSubscriptionNotificationCustomActions(
138
+ subscription,
139
+ 'customer.subscription.renewed',
140
+ locale
141
+ );
137
142
  return {
138
143
  locale,
139
144
  productName,
@@ -151,6 +156,7 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
151
156
  viewInvoiceLink,
152
157
  viewTxHashLink,
153
158
  invoice,
159
+ customActions,
154
160
  };
155
161
  }
156
162
 
@@ -170,6 +176,7 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
170
176
  viewInvoiceLink,
171
177
  viewTxHashLink,
172
178
  invoice,
179
+ customActions,
173
180
  } = await this.getContext();
174
181
 
175
182
  if (invoice.total === '0') {
@@ -288,6 +295,7 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
288
295
  title: translate('notification.common.viewTxHash', locale),
289
296
  link: viewTxHashLink as string,
290
297
  },
298
+ ...customActions,
291
299
  ].filter(Boolean),
292
300
  };
293
301
 
@@ -8,7 +8,7 @@ import { PaymentCurrency } from '../../../store/models/payment-currency';
8
8
  import { getMainProductName } from '../../product';
9
9
  import { getCustomerSubscriptionPageUrl } from '../../subscription';
10
10
  import { formatTime } from '../../time';
11
- import { getExplorerLink } from '../../util';
11
+ import { getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
12
12
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
13
13
  import logger from '../../logger';
14
14
 
@@ -29,6 +29,7 @@ interface SubscriptionStakeSlashSucceededEmailTemplateContext {
29
29
 
30
30
  viewSubscriptionLink: string;
31
31
  viewTxHashLink: string | undefined;
32
+ customActions: any[];
32
33
  }
33
34
 
34
35
  export class SubscriptionStakeSlashSucceededEmailTemplate
@@ -105,6 +106,12 @@ export class SubscriptionStakeSlashSucceededEmailTemplate
105
106
 
106
107
  const slashReason = subscription?.cancelation_details?.slash_reason || 'admin slash';
107
108
 
109
+ const customActions = getSubscriptionNotificationCustomActions(
110
+ subscription,
111
+ 'subscription.stake.slash.succeeded',
112
+ locale
113
+ );
114
+
108
115
  return {
109
116
  locale,
110
117
  productName,
@@ -115,6 +122,7 @@ export class SubscriptionStakeSlashSucceededEmailTemplate
115
122
  slashReason,
116
123
  viewSubscriptionLink,
117
124
  viewTxHashLink,
125
+ customActions,
118
126
  };
119
127
  }
120
128
 
@@ -129,6 +137,7 @@ export class SubscriptionStakeSlashSucceededEmailTemplate
129
137
  slashReason,
130
138
  viewSubscriptionLink,
131
139
  viewTxHashLink,
140
+ customActions,
132
141
  } = await this.getContext();
133
142
 
134
143
  logger.info('SubscriptionStakeSlashSucceededEmailTemplate getTemplate', { productName, at, userDid, slashInfo, viewSubscriptionLink, viewTxHashLink });
@@ -220,6 +229,7 @@ export class SubscriptionStakeSlashSucceededEmailTemplate
220
229
  title: translate('notification.common.viewTxHash', locale),
221
230
  link: viewTxHashLink as string,
222
231
  },
232
+ ...customActions,
223
233
  ].filter(Boolean),
224
234
  };
225
235