payment-kit 1.15.2 → 1.15.4

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 (42) hide show
  1. package/api/src/crons/payment-stat.ts +1 -0
  2. package/api/src/index.ts +2 -2
  3. package/api/src/integrations/arcblock/stake.ts +17 -10
  4. package/api/src/libs/notification/template/customer-reward-succeeded.ts +15 -8
  5. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -30
  6. package/api/src/libs/notification/template/subscription-canceled.ts +45 -23
  7. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +130 -47
  8. package/api/src/libs/notification/template/subscription-renewed.ts +10 -2
  9. package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +228 -0
  10. package/api/src/libs/notification/template/subscription-succeeded.ts +2 -2
  11. package/api/src/libs/notification/template/subscription-trial-start.ts +7 -10
  12. package/api/src/libs/notification/template/subscription-trial-will-end.ts +13 -5
  13. package/api/src/libs/notification/template/subscription-will-renew.ts +41 -29
  14. package/api/src/libs/payment.ts +53 -1
  15. package/api/src/libs/subscription.ts +43 -0
  16. package/api/src/locales/en.ts +24 -0
  17. package/api/src/locales/zh.ts +22 -0
  18. package/api/src/queues/invoice.ts +1 -1
  19. package/api/src/queues/notification.ts +9 -0
  20. package/api/src/queues/payment.ts +17 -0
  21. package/api/src/routes/checkout-sessions.ts +13 -1
  22. package/api/src/routes/payment-stats.ts +3 -3
  23. package/api/src/routes/subscriptions.ts +26 -6
  24. package/api/src/store/migrations/20240905-index.ts +100 -0
  25. package/api/src/store/models/subscription.ts +1 -0
  26. package/api/tests/libs/payment.spec.ts +168 -0
  27. package/blocklet.yml +1 -1
  28. package/package.json +9 -9
  29. package/src/components/balance-list.tsx +2 -2
  30. package/src/components/invoice/list.tsx +2 -2
  31. package/src/components/invoice/table.tsx +1 -1
  32. package/src/components/payment-intent/list.tsx +1 -1
  33. package/src/components/payouts/list.tsx +1 -1
  34. package/src/components/refund/list.tsx +2 -2
  35. package/src/components/subscription/actions/cancel.tsx +41 -13
  36. package/src/components/subscription/actions/index.tsx +11 -8
  37. package/src/components/subscription/actions/slash-stake.tsx +52 -0
  38. package/src/locales/en.tsx +1 -0
  39. package/src/locales/zh.tsx +1 -0
  40. package/src/pages/admin/billing/invoices/detail.tsx +2 -2
  41. package/src/pages/customer/refund/list.tsx +1 -1
  42. package/src/pages/customer/subscription/detail.tsx +1 -1
@@ -66,6 +66,7 @@ export async function getPaymentStat(
66
66
  await Refund.findAll({
67
67
  where: {
68
68
  updated_at: { [Op.gte]: start.toDate(), [Op.lt]: end.toDate() },
69
+ type: 'refund',
69
70
  status: 'succeeded',
70
71
  },
71
72
  attributes: ['amount', 'currency_id'],
package/api/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import express, { ErrorRequestHandler, Request, Response } from 'express';
10
10
  import morgan from 'morgan';
11
11
 
12
12
  // eslint-disable-next-line import/no-extraneous-dependencies
13
- import { xss } from 'express-xss-sanitizer';
13
+ import { xss } from '@blocklet/xss';
14
14
 
15
15
  import crons from './crons/index';
16
16
  import { ensureStakedForGas } from './integrations/arcblock/stake';
@@ -55,7 +55,7 @@ app.use((req, res, next) => {
55
55
  });
56
56
  app.use(express.urlencoded({ extended: true, limit: '1 mb' }));
57
57
  app.use(cors());
58
- app.use(xss());
58
+ app.use(xss({ allowedKeys: [] }));
59
59
  app.use(ensureI18n());
60
60
 
61
61
  const router = express.Router();
@@ -5,7 +5,7 @@ import assert from 'assert';
5
5
  import { isEthereumDid } from '@arcblock/did';
6
6
  import { toStakeAddress } from '@arcblock/did-util';
7
7
  import env from '@blocklet/sdk/lib/env';
8
- import { fromUnitToToken, toBN } from '@ocap/util';
8
+ import { BN, fromUnitToToken, toBN } from '@ocap/util';
9
9
 
10
10
  import { wallet } from '../../libs/auth';
11
11
  import { events } from '../../libs/event';
@@ -189,20 +189,27 @@ export async function getStakeSummaryByDid(did: string, livemode: boolean = true
189
189
  return {};
190
190
  }
191
191
 
192
- // FIXME: should use listStakes to find all stakes and summarize here
193
- const address = toStakeAddress(did, wallet.address);
194
192
  const results: GroupedBN = {};
195
193
  await Promise.all(
196
194
  methods.map(async (method: PaymentMethod) => {
197
195
  const client = method.getOcapClient();
198
- const { state } = await client.getStakeState({ address });
199
- (state?.tokens || []).forEach((t: any) => {
200
- // @ts-ignore
201
- const currency = method.payment_currencies.find((c: PaymentCurrency) => t.address === c.contract);
202
- if (currency) {
203
- results[currency.id] = t.value;
204
- }
196
+ const { stakes } = await client.listStakes({
197
+ addressFilter: {
198
+ receiver: wallet.address,
199
+ },
205
200
  });
201
+ (stakes || [])
202
+ .filter((x: any) => x.sender === did)
203
+ .forEach((x: any) => {
204
+ const { tokens } = x;
205
+ (tokens || []).forEach((t: any) => {
206
+ // @ts-ignore
207
+ const currency = method.payment_currencies?.find((c: PaymentCurrency) => t.address === c.contract);
208
+ if (currency) {
209
+ results[currency.id] = new BN(results[currency.id] || '0').add(new BN(t.balance || '0')).toString();
210
+ }
211
+ });
212
+ });
206
213
  })
207
214
  );
208
215
 
@@ -23,6 +23,7 @@ import logger from '../../logger';
23
23
  import { formatTime } from '../../time';
24
24
  import { getExplorerLink } from '../../util';
25
25
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
26
+ import { blocklet } from '../../auth';
26
27
 
27
28
  export interface CustomerRewardSucceededEmailTemplateOptions {
28
29
  checkoutSessionId: string;
@@ -109,16 +110,14 @@ export class CustomerRewardSucceededEmailTemplate
109
110
  const locale = await getUserLocale(userDid);
110
111
  const at: string = formatTime(checkoutSession.created_at);
111
112
 
112
- const paymentInfo: string = `${fromUnitToToken(checkoutSession?.amount_total, paymentCurrency.decimal)} ${
113
- paymentCurrency.symbol
114
- }`;
113
+ const paymentInfo: string = `${fromUnitToToken(checkoutSession?.amount_total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
115
114
  const paymentIntent = await PaymentIntent.findByPk(checkoutSession!.payment_intent_id);
116
115
  if (!paymentIntent) {
117
116
  throw new Error(
118
117
  `Payment intent cannot be found for checkoutSession.payment_intent_id${checkoutSession!.payment_intent_id}`
119
118
  );
120
119
  }
121
- const rewardDetail: string = this.getRewardDetail({
120
+ const rewardDetail: string = await this.getRewardDetail({
122
121
  paymentIntent,
123
122
  paymentCurrency,
124
123
  locale,
@@ -166,7 +165,7 @@ export class CustomerRewardSucceededEmailTemplate
166
165
  viewTxHashLink,
167
166
  };
168
167
  }
169
- getRewardDetail({
168
+ async getRewardDetail({
170
169
  paymentIntent,
171
170
  paymentCurrency,
172
171
  locale,
@@ -174,16 +173,24 @@ export class CustomerRewardSucceededEmailTemplate
174
173
  paymentIntent: PaymentIntent;
175
174
  paymentCurrency: PaymentCurrency;
176
175
  locale: LiteralUnion<'zh' | 'en', string>;
177
- }): string {
176
+ }): Promise<string> {
178
177
  if (isEmpty(paymentIntent.beneficiaries)) {
179
178
  logger.warn('Payment intent not available', { paymentIntentId: paymentIntent.id });
180
179
  return '';
181
180
  }
182
181
 
182
+ const promises = paymentIntent.beneficiaries!.map((x: PaymentBeneficiary) => {
183
+ return blocklet.getUser(x.address);
184
+ });
185
+ const users = await Promise.all(promises);
186
+ if (!users.length) {
187
+ logger.warn('No users found for payment intent', { paymentIntentId: paymentIntent.id });
188
+ return '';
189
+ }
183
190
  const rewardDetail: string = paymentIntent
184
- .beneficiaries!.map((x: PaymentBeneficiary) => {
191
+ .beneficiaries!.map((x: PaymentBeneficiary, index: number) => {
185
192
  return translate('notification.customerRewardSucceeded.received', locale, {
186
- address: `${x.address}`,
193
+ address: `${users[index]?.user?.fullName || ''} ( ${x.address} )`,
187
194
  amount: `${fromUnitToToken(x.share, paymentCurrency.decimal)} ${paymentCurrency.symbol}`,
188
195
  });
189
196
  })
@@ -5,14 +5,7 @@ import pWaitFor from 'p-wait-for';
5
5
 
6
6
  import { getUserLocale } from '../../../integrations/blocklet/notification';
7
7
  import { translate } from '../../../locales';
8
- import {
9
- CheckoutSession,
10
- Customer,
11
- NftMintItem,
12
- PaymentIntent,
13
- PaymentLink,
14
- PaymentMethod,
15
- } from '../../../store/models';
8
+ import { CheckoutSession, Customer, NftMintItem, PaymentIntent, PaymentMethod } from '../../../store/models';
16
9
  import { PaymentCurrency } from '../../../store/models/payment-currency';
17
10
  import { getMainProductNameByCheckoutSession } from '../../product';
18
11
  import { formatTime } from '../../time';
@@ -82,13 +75,6 @@ export class OneTimePaymentSucceededEmailTemplate
82
75
  );
83
76
 
84
77
  const checkoutSession = (await CheckoutSession.findByPk(cs.id)) as CheckoutSession;
85
- const paymentLink = await PaymentLink.findByPk(checkoutSession.payment_link_id);
86
- if (!paymentLink) {
87
- throw new Error(`Payment link cannot be found for payment_link_id(${checkoutSession.payment_link_id})`);
88
- }
89
- if (paymentLink.submit_type !== 'auto') {
90
- throw new Error(`Payment link submit_type(${paymentLink.submit_type}) must be auto`);
91
- }
92
78
 
93
79
  const paymentCurrency = (await PaymentCurrency.findOne({
94
80
  where: {
@@ -223,21 +209,6 @@ export class OneTimePaymentSucceededEmailTemplate
223
209
  text: paymentInfo,
224
210
  },
225
211
  },
226
- {
227
- type: 'text',
228
- data: {
229
- type: 'plain',
230
- color: '#9397A1',
231
- text: translate('notification.common.validityPeriod', locale),
232
- },
233
- },
234
- {
235
- type: 'text',
236
- data: {
237
- type: 'plain',
238
- text: translate('notification.common.permanent', locale),
239
- },
240
- },
241
212
  ].filter(Boolean),
242
213
  },
243
214
  ].filter(Boolean),
@@ -8,9 +8,8 @@ import { translate } from '../../../locales';
8
8
  import { Customer, PaymentMethod, Refund, Subscription } from '../../../store/models';
9
9
  import { Invoice } from '../../../store/models/invoice';
10
10
  import { PaymentCurrency } from '../../../store/models/payment-currency';
11
- import logger from '../../logger';
12
11
  import { getMainProductName } from '../../product';
13
- import { getCustomerSubscriptionPageUrl } from '../../subscription';
12
+ import { getCustomerSubscriptionPageUrl, getSubscriptionStakeCancellation } from '../../subscription';
14
13
  import { formatTime, getPrettyMsI18nLocale } from '../../time';
15
14
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
16
15
 
@@ -49,18 +48,6 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
49
48
  if (subscription.status !== 'canceled') {
50
49
  throw new Error(`Subscription(${this.options.subscriptionId}) status(${subscription.status}) must be canceled`);
51
50
  }
52
- if (['payment_failed', 'payment_disputed'].includes(subscription.cancelation_details?.reason as string) === false) {
53
- // 只有没钱导致订阅被取消了,或者管理员取消了,才会发送通知
54
- logger.error(
55
- `Subscription(${this.options.subscriptionId}) cancelation reason must be payment_disputed or payment_failed`,
56
- {
57
- cancelation_details: subscription.cancelation_details,
58
- }
59
- );
60
- throw new Error(
61
- `Subscription(${this.options.subscriptionId}) cancelation reason must be payment_disputed or payment_failed`
62
- );
63
- }
64
51
 
65
52
  const customer = await Customer.findByPk(subscription.customer_id);
66
53
  if (!customer) {
@@ -88,16 +75,51 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
88
75
  locale: getPrettyMsI18nLocale(locale),
89
76
  }
90
77
  );
91
- const refund = await Refund.findOne({
92
- where: {
93
- subscription_id: subscription.id,
94
- },
95
- });
96
- const cancellationReason = refund
97
- ? translate('notification.subscriptionCanceled.adminCanceledAndRefunded', locale)
98
- : translate('notification.subscriptionCanceled.adminCanceled', locale);
99
-
100
78
  const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
79
+
80
+ const customerCancelRequest = subscription.cancelation_details?.reason === 'cancellation_requested';
81
+ let cancellationReason = '';
82
+ const { stakeEnough, stakeReturn, stakeSlash, hasStake } = await getSubscriptionStakeCancellation(
83
+ subscription,
84
+ paymentMethod!,
85
+ paymentCurrency
86
+ );
87
+ if (customerCancelRequest) {
88
+ // cancel request from customer
89
+ cancellationReason = translate('notification.subscriptionCanceled.customerCanceled', locale);
90
+ if (stakeReturn && hasStake && stakeEnough) {
91
+ cancellationReason = translate('notification.subscriptionCanceled.customerCanceledAndStakeReturned', locale);
92
+ }
93
+ } else {
94
+ // default admin cancel
95
+ const refund = await Refund.findOne({
96
+ where: {
97
+ subscription_id: subscription.id,
98
+ type: 'refund',
99
+ },
100
+ });
101
+ const conditions = [
102
+ {
103
+ condition: refund && stakeSlash,
104
+ key: 'notification.subscriptionCanceled.adminCanceledAndRefundedAndStakeSlashed',
105
+ },
106
+ {
107
+ condition: refund && stakeReturn,
108
+ key: 'notification.subscriptionCanceled.adminCanceledAndRefundedAndStakeReturned',
109
+ },
110
+ { condition: refund, key: 'notification.subscriptionCanceled.adminCanceledAndRefunded' },
111
+ { condition: stakeReturn, key: 'notification.subscriptionCanceled.adminCanceledAndStakeReturned' },
112
+ ];
113
+ const matchedCondition = conditions.find((item) => item.condition);
114
+ if (matchedCondition) {
115
+ cancellationReason = translate(matchedCondition.key, locale);
116
+ }
117
+ }
118
+
119
+ if (subscription.cancelation_details?.reason === 'payment_failed') {
120
+ cancellationReason = translate('notification.subscriptionCanceled.paymentFailed', locale);
121
+ }
122
+
101
123
  // @ts-expect-error
102
124
  const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
103
125
  const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
@@ -8,7 +8,6 @@ import { translate } from '../../../locales';
8
8
  import { Customer, PaymentIntent, PaymentMethod, Refund } from '../../../store/models';
9
9
  import { Invoice } from '../../../store/models/invoice';
10
10
  import { PaymentCurrency } from '../../../store/models/payment-currency';
11
- import { getCustomerInvoicePageUrl } from '../../invoice';
12
11
  import { getMainProductName } from '../../product';
13
12
  import { getCustomerSubscriptionPageUrl } from '../../subscription';
14
13
  import { formatTime, getPrettyMsI18nLocale } from '../../time';
@@ -28,16 +27,16 @@ interface SubscriptionRefundSucceededEmailTemplateContext {
28
27
  userDid: string;
29
28
  paymentInfo: string;
30
29
  refundInfo: string;
31
- currentPeriodStart: string;
32
- currentPeriodEnd: string;
30
+ currentPeriodStart?: string;
31
+ currentPeriodEnd?: string;
33
32
  duration: string;
34
- unusedPeriodStart: string;
35
- unusedPeriodEnd: string;
33
+ unusedPeriodStart?: string;
34
+ unusedPeriodEnd?: string;
36
35
  unusedDuration: string;
37
36
 
38
37
  viewSubscriptionLink: string;
39
- viewInvoiceLink: string;
40
38
  viewTxHashLink: string | undefined;
39
+ refund: Refund;
41
40
  }
42
41
 
43
42
  export class SubscriptionRefundSucceededEmailTemplate
@@ -63,7 +62,6 @@ export class SubscriptionRefundSucceededEmailTemplate
63
62
  throw new Error(`Customer not found: ${refund.customer_id}`);
64
63
  }
65
64
 
66
- const invoice = (await Invoice.findByPk(refund.invoice_id)) as Invoice;
67
65
  const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
68
66
  const paymentCurrency = (await PaymentCurrency.findOne({
69
67
  where: {
@@ -76,42 +74,43 @@ export class SubscriptionRefundSucceededEmailTemplate
76
74
  const productName = await getMainProductName(refund.subscription_id!);
77
75
  const at: string = formatTime(refund.created_at);
78
76
 
79
- const paymentInfo: string = `${fromUnitToToken(paymentIntent?.amount_received || '0', paymentCurrency.decimal)} ${
80
- paymentCurrency.symbol
81
- }`;
77
+ const paymentInfo: string = `${fromUnitToToken(paymentIntent?.amount_received || '0', paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
82
78
  const refundInfo: string = `${fromUnitToToken(refund.amount, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
83
79
 
84
- const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
85
- const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
86
- const duration: string = prettyMsI18n(
87
- new Date(currentPeriodEnd).getTime() - new Date(currentPeriodStart).getTime(),
88
- {
80
+ let invoice = null;
81
+ let currentPeriodStart;
82
+ let currentPeriodEnd;
83
+ let duration;
84
+ let unusedPeriodStart;
85
+ let unusedPeriodEnd;
86
+ let unusedDuration;
87
+ if (refund.type === 'refund') {
88
+ invoice = (await Invoice.findByPk(refund.invoice_id)) as Invoice;
89
+ currentPeriodStart = formatTime(invoice.period_start * 1000);
90
+ currentPeriodEnd = formatTime(invoice.period_end * 1000);
91
+ duration = prettyMsI18n(new Date(currentPeriodEnd).getTime() - new Date(currentPeriodStart).getTime(), {
89
92
  locale: getPrettyMsI18nLocale(locale),
90
- }
91
- );
93
+ });
92
94
 
93
- const unusedPeriodStart: string = formatTime(refund!.metadata!.unused_period_start! * 1000);
94
- const unusedPeriodEnd: string = formatTime(refund!.metadata!.unused_period_end! * 1000);
95
- const unusedDuration: string = prettyMsI18n(
96
- new Date(unusedPeriodEnd).getTime() - new Date(unusedPeriodStart).getTime(),
97
- {
98
- locale: getPrettyMsI18nLocale(locale),
95
+ if (refund?.metadata?.unused_period_start && refund?.metadata?.unused_period_end) {
96
+ unusedPeriodStart = formatTime(refund!.metadata!.unused_period_start! * 1000);
97
+ unusedPeriodEnd = formatTime(refund!.metadata!.unused_period_end! * 1000);
98
+ unusedDuration = prettyMsI18n(new Date(unusedPeriodEnd).getTime() - new Date(unusedPeriodStart).getTime(), {
99
+ locale: getPrettyMsI18nLocale(locale),
100
+ });
99
101
  }
100
- );
102
+ }
101
103
 
102
- const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
104
+ const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(
105
+ refund.payment_method_id || invoice?.default_payment_method_id
106
+ );
103
107
  // @ts-expect-error
104
108
  const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
105
109
  const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
106
- subscriptionId: refund.id,
110
+ subscriptionId: refund.subscription_id!,
107
111
  locale,
108
112
  userDid,
109
113
  });
110
- const viewInvoiceLink = getCustomerInvoicePageUrl({
111
- invoiceId: invoice.id,
112
- userDid,
113
- locale,
114
- });
115
114
 
116
115
  // @ts-expect-error
117
116
  const txHash: string | undefined = refund?.payment_details?.[paymentMethod.type]?.tx_hash;
@@ -140,8 +139,8 @@ export class SubscriptionRefundSucceededEmailTemplate
140
139
  unusedDuration,
141
140
 
142
141
  viewSubscriptionLink,
143
- viewInvoiceLink,
144
142
  viewTxHashLink,
143
+ refund,
145
144
  };
146
145
  }
147
146
 
@@ -163,8 +162,87 @@ export class SubscriptionRefundSucceededEmailTemplate
163
162
 
164
163
  viewSubscriptionLink,
165
164
  viewTxHashLink,
165
+ refund,
166
166
  } = await this.getContext();
167
167
 
168
+ if (refund.type === 'stake_return') {
169
+ return {
170
+ title: `${translate('notification.subscriptionStakeReturnSucceeded.title', locale, {
171
+ productName,
172
+ })}`,
173
+ body: `${translate('notification.subscriptionStakeReturnSucceeded.body', locale, {
174
+ at,
175
+ productName,
176
+ refundInfo,
177
+ })}`,
178
+ attachments: [
179
+ {
180
+ type: 'section',
181
+ fields: [
182
+ {
183
+ type: 'text',
184
+ data: {
185
+ type: 'plain',
186
+ color: '#9397A1',
187
+ text: translate('notification.common.account', locale),
188
+ },
189
+ },
190
+ {
191
+ type: 'text',
192
+ data: {
193
+ type: 'plain',
194
+ text: userDid,
195
+ },
196
+ },
197
+ {
198
+ type: 'text',
199
+ data: {
200
+ type: 'plain',
201
+ color: '#9397A1',
202
+ text: translate('notification.common.product', locale),
203
+ },
204
+ },
205
+ {
206
+ type: 'text',
207
+ data: {
208
+ type: 'plain',
209
+ text: productName,
210
+ },
211
+ },
212
+ {
213
+ type: 'text',
214
+ data: {
215
+ type: 'plain',
216
+ color: '#9397A1',
217
+ text: translate('notification.common.returnAmount', locale),
218
+ },
219
+ },
220
+ {
221
+ type: 'text',
222
+ data: {
223
+ type: 'plain',
224
+ text: refundInfo,
225
+ },
226
+ },
227
+ ],
228
+ },
229
+ ],
230
+ // @ts-ignore
231
+ actions: [
232
+ {
233
+ name: translate('notification.common.viewSubscription', locale),
234
+ title: translate('notification.common.viewSubscription', locale),
235
+ link: viewSubscriptionLink,
236
+ },
237
+ viewTxHashLink && {
238
+ name: translate('notification.common.viewTxHash', locale),
239
+ title: translate('notification.common.viewTxHash', locale),
240
+ link: viewTxHashLink as string,
241
+ },
242
+ ].filter(Boolean),
243
+ };
244
+ }
245
+
168
246
  const template: BaseEmailTemplateType = {
169
247
  title: `${translate('notification.subscriptionRefundSucceeded.title', locale, {
170
248
  productName,
@@ -254,21 +332,26 @@ export class SubscriptionRefundSucceededEmailTemplate
254
332
  text: `${currentPeriodStart} ~ ${currentPeriodEnd}(${duration})`,
255
333
  },
256
334
  },
257
- {
258
- type: 'text',
259
- data: {
260
- type: 'plain',
261
- color: '#9397A1',
262
- text: translate('notification.common.refundPeriod', locale),
263
- },
264
- },
265
- {
266
- type: 'text',
267
- data: {
268
- type: 'plain',
269
- text: `${unusedPeriodStart} ~ ${unusedPeriodEnd}(${unusedDuration})`,
270
- },
271
- },
335
+
336
+ ...(unusedPeriodStart && unusedPeriodEnd && unusedDuration
337
+ ? [
338
+ {
339
+ type: 'text',
340
+ data: {
341
+ type: 'plain',
342
+ color: '#9397A1',
343
+ text: translate('notification.common.refundPeriod', locale),
344
+ },
345
+ },
346
+ {
347
+ type: 'text',
348
+ data: {
349
+ type: 'plain',
350
+ text: `${unusedPeriodStart} ~ ${unusedPeriodEnd}(${unusedDuration})`,
351
+ },
352
+ },
353
+ ]
354
+ : []),
272
355
  ].filter(Boolean),
273
356
  },
274
357
  ].filter(Boolean),
@@ -2,7 +2,7 @@
2
2
  /* eslint-disable @typescript-eslint/indent */
3
3
  import { fromUnitToToken, toDid } from '@ocap/util';
4
4
  import prettyMsI18n from 'pretty-ms-i18n';
5
-
5
+ import logger from '@api/libs/logger';
6
6
  import { getUserLocale } from '../../../integrations/blocklet/notification';
7
7
  import { translate } from '../../../locales';
8
8
  import {
@@ -48,6 +48,7 @@ interface SubscriptionRenewedEmailTemplateContext {
48
48
  viewSubscriptionLink: string;
49
49
  viewInvoiceLink: string;
50
50
  viewTxHashLink: string | undefined;
51
+ invoice: Invoice;
51
52
  }
52
53
 
53
54
  export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<SubscriptionRenewedEmailTemplateContext> {
@@ -76,6 +77,7 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
76
77
  ? await Invoice.findByPk(this.options.invoiceId)
77
78
  : await Invoice.findByPk(subscription.latest_invoice_id)
78
79
  ) as Invoice;
80
+
79
81
  const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
80
82
  const paymentCurrency = (await PaymentCurrency.findOne({
81
83
  where: {
@@ -148,10 +150,11 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
148
150
  viewSubscriptionLink,
149
151
  viewInvoiceLink,
150
152
  viewTxHashLink,
153
+ invoice,
151
154
  };
152
155
  }
153
156
 
154
- async getTemplate(): Promise<BaseEmailTemplateType> {
157
+ async getTemplate(): Promise<BaseEmailTemplateType | null> {
155
158
  const {
156
159
  locale,
157
160
  productName,
@@ -166,8 +169,13 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
166
169
  viewSubscriptionLink,
167
170
  viewInvoiceLink,
168
171
  viewTxHashLink,
172
+ invoice,
169
173
  } = await this.getContext();
170
174
 
175
+ if (invoice.total === '0') {
176
+ logger.info('Invoice amount is 0, skipping renewed notification');
177
+ return null;
178
+ }
171
179
  const template: BaseEmailTemplateType = {
172
180
  title: `${translate('notification.subscriptionRenewed.title', locale, {
173
181
  productName,