payment-kit 1.15.1 → 1.15.3

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/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/auth.ts +3 -2
  5. package/api/src/libs/notification/template/customer-reward-succeeded.ts +15 -8
  6. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -30
  7. package/api/src/libs/notification/template/subscription-canceled.ts +45 -23
  8. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +130 -47
  9. package/api/src/libs/notification/template/subscription-renewed.ts +10 -2
  10. package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +228 -0
  11. package/api/src/libs/notification/template/subscription-succeeded.ts +2 -2
  12. package/api/src/libs/notification/template/subscription-trial-start.ts +7 -10
  13. package/api/src/libs/notification/template/subscription-trial-will-end.ts +13 -5
  14. package/api/src/libs/notification/template/subscription-will-renew.ts +41 -29
  15. package/api/src/libs/payment.ts +53 -1
  16. package/api/src/libs/subscription.ts +43 -0
  17. package/api/src/locales/en.ts +24 -0
  18. package/api/src/locales/zh.ts +22 -0
  19. package/api/src/queues/invoice.ts +1 -1
  20. package/api/src/queues/notification.ts +9 -0
  21. package/api/src/queues/payment.ts +17 -0
  22. package/api/src/routes/checkout-sessions.ts +13 -1
  23. package/api/src/routes/payment-stats.ts +3 -3
  24. package/api/src/routes/subscriptions.ts +26 -6
  25. package/api/src/store/migrations/20240905-index.ts +100 -0
  26. package/api/src/store/models/subscription.ts +1 -0
  27. package/api/tests/libs/payment.spec.ts +168 -0
  28. package/blocklet.yml +1 -1
  29. package/package.json +10 -10
  30. package/src/components/balance-list.tsx +2 -2
  31. package/src/components/invoice/list.tsx +2 -2
  32. package/src/components/invoice/table.tsx +1 -1
  33. package/src/components/payment-intent/list.tsx +1 -1
  34. package/src/components/payouts/list.tsx +1 -1
  35. package/src/components/refund/list.tsx +2 -2
  36. package/src/components/subscription/actions/cancel.tsx +41 -13
  37. package/src/components/subscription/actions/index.tsx +11 -8
  38. package/src/components/subscription/actions/slash-stake.tsx +52 -0
  39. package/src/locales/en.tsx +1 -0
  40. package/src/locales/zh.tsx +1 -0
  41. package/src/pages/admin/billing/invoices/detail.tsx +2 -2
  42. package/src/pages/customer/refund/list.tsx +1 -1
  43. package/src/pages/customer/subscription/detail.tsx +1 -1
@@ -0,0 +1,228 @@
1
+ /* eslint-disable prettier/prettier */
2
+ import { fromUnitToToken } from '@ocap/util';
3
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
4
+ import { translate } from '../../../locales';
5
+ import { Customer, PaymentIntent, PaymentMethod, Subscription } from '../../../store/models';
6
+ import { Invoice } from '../../../store/models/invoice';
7
+ import { PaymentCurrency } from '../../../store/models/payment-currency';
8
+ import { getMainProductName } from '../../product';
9
+ import { getCustomerSubscriptionPageUrl } from '../../subscription';
10
+ import { formatTime } from '../../time';
11
+ import { getExplorerLink } from '../../util';
12
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
13
+ import logger from '../../logger';
14
+
15
+ export interface SubscriptionStakeSlashSucceededEmailTemplateOptions {
16
+ paymentIntentId: string;
17
+ subscriptionId: string;
18
+ invoiceId: string;
19
+ }
20
+
21
+ interface SubscriptionStakeSlashSucceededEmailTemplateContext {
22
+ locale: string;
23
+ productName: string;
24
+ at: string;
25
+
26
+ userDid: string;
27
+ slashInfo: string;
28
+ slashReason: string;
29
+
30
+ viewSubscriptionLink: string;
31
+ viewTxHashLink: string | undefined;
32
+ }
33
+
34
+ export class SubscriptionStakeSlashSucceededEmailTemplate
35
+ implements BaseEmailTemplate<SubscriptionStakeSlashSucceededEmailTemplateContext> {
36
+ options: SubscriptionStakeSlashSucceededEmailTemplateOptions;
37
+
38
+ constructor(options: SubscriptionStakeSlashSucceededEmailTemplateOptions) {
39
+ this.options = options;
40
+ }
41
+
42
+ async getContext(): Promise<SubscriptionStakeSlashSucceededEmailTemplateContext> {
43
+ const invoice = await Invoice.findByPk(this.options.invoiceId);
44
+ if (!invoice) {
45
+ throw new Error(`Invoice not found: ${this.options.invoiceId}`);
46
+ }
47
+ if (invoice?.billing_reason !== 'slash_stake') {
48
+ throw new Error(`Invoice billing_reason not slash_stake: ${this.options.invoiceId}`);
49
+ }
50
+ const paymentIntent = await PaymentIntent.findByPk(this.options.paymentIntentId);
51
+
52
+ if (!paymentIntent) {
53
+ throw new Error(`PaymentIntent not found: ${this.options.paymentIntentId}`);
54
+ }
55
+ if (paymentIntent.status !== 'succeeded') {
56
+ throw new Error(`SlashStake not succeeded: ${this.options.paymentIntentId}`);
57
+ }
58
+
59
+ const subscription = await Subscription.findByPk(this.options.subscriptionId);
60
+
61
+ if (!subscription) {
62
+ throw new Error(`Subscription not found: ${this.options.subscriptionId}`);
63
+ }
64
+
65
+ if (subscription.status !== 'canceled') {
66
+ throw new Error(`Subscription status not cancelled: ${this.options.subscriptionId}`);
67
+ }
68
+
69
+ const customer = await Customer.findByPk(subscription.customer_id);
70
+ if (!customer) {
71
+ throw new Error(`Customer not found: ${subscription.customer_id}`);
72
+ }
73
+
74
+ const paymentCurrency = (await PaymentCurrency.findOne({
75
+ where: {
76
+ id: subscription.currency_id,
77
+ },
78
+ })) as PaymentCurrency;
79
+
80
+ const userDid: string = customer.did;
81
+ const locale = await getUserLocale(userDid);
82
+ const productName = await getMainProductName(this.options.subscriptionId);
83
+ const at: string = formatTime(paymentIntent.created_at);
84
+
85
+ const slashInfo: string = `${fromUnitToToken(paymentIntent?.amount_received || '0', paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
86
+
87
+ const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentIntent.payment_method_id);
88
+ // @ts-expect-error
89
+ const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
90
+ const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
91
+ subscriptionId: this.options.subscriptionId,
92
+ locale,
93
+ userDid,
94
+ });
95
+
96
+ // @ts-expect-error
97
+ const txHash: string | undefined = paymentIntent?.payment_details?.[paymentMethod.type]?.tx_hash;
98
+ const viewTxHashLink: string | undefined =
99
+ txHash &&
100
+ getExplorerLink({
101
+ type: 'tx',
102
+ did: txHash,
103
+ chainHost,
104
+ });
105
+
106
+ const slashReason = subscription?.cancelation_details?.slash_reason || 'admin slash';
107
+
108
+ return {
109
+ locale,
110
+ productName,
111
+ at,
112
+
113
+ userDid,
114
+ slashInfo,
115
+ slashReason,
116
+ viewSubscriptionLink,
117
+ viewTxHashLink,
118
+ };
119
+ }
120
+
121
+ async getTemplate(): Promise<BaseEmailTemplateType> {
122
+ const {
123
+ locale,
124
+ productName,
125
+ at,
126
+
127
+ userDid,
128
+ slashInfo,
129
+ slashReason,
130
+ viewSubscriptionLink,
131
+ viewTxHashLink,
132
+ } = await this.getContext();
133
+
134
+ logger.info('SubscriptionStakeSlashSucceededEmailTemplate getTemplate', { productName, at, userDid, slashInfo, viewSubscriptionLink, viewTxHashLink });
135
+ const template: BaseEmailTemplateType = {
136
+ title: `${translate('notification.subscriptionStakeSlashSucceeded.title', locale, {
137
+ productName,
138
+ })}`,
139
+ body: `${translate('notification.subscriptionStakeSlashSucceeded.body', locale, {
140
+ at,
141
+ productName,
142
+ slashInfo,
143
+ })}`,
144
+ attachments: [
145
+ {
146
+ type: 'section',
147
+ fields: [
148
+ {
149
+ type: 'text',
150
+ data: {
151
+ type: 'plain',
152
+ color: '#9397A1',
153
+ text: translate('notification.common.account', locale),
154
+ },
155
+ },
156
+ {
157
+ type: 'text',
158
+ data: {
159
+ type: 'plain',
160
+ text: userDid,
161
+ },
162
+ },
163
+ {
164
+ type: 'text',
165
+ data: {
166
+ type: 'plain',
167
+ color: '#9397A1',
168
+ text: translate('notification.common.product', locale),
169
+ },
170
+ },
171
+ {
172
+ type: 'text',
173
+ data: {
174
+ type: 'plain',
175
+ text: productName,
176
+ },
177
+ },
178
+ {
179
+ type: 'text',
180
+ data: {
181
+ type: 'plain',
182
+ color: '#9397A1',
183
+ text: translate('notification.common.slashAmount', locale),
184
+ },
185
+ },
186
+ {
187
+ type: 'text',
188
+ data: {
189
+ type: 'plain',
190
+ text: slashInfo,
191
+ },
192
+ },
193
+ {
194
+ type: 'text',
195
+ data: {
196
+ type: 'plain',
197
+ color: '#9397A1',
198
+ text: translate('notification.common.slashReason', locale),
199
+ },
200
+ },
201
+ {
202
+ type: 'text',
203
+ data: {
204
+ type: 'plain',
205
+ text: slashReason,
206
+ },
207
+ },
208
+ ],
209
+ },
210
+ ],
211
+ // @ts-ignore
212
+ actions: [
213
+ {
214
+ name: translate('notification.common.viewSubscription', locale),
215
+ title: translate('notification.common.viewSubscription', locale),
216
+ link: viewSubscriptionLink,
217
+ },
218
+ viewTxHashLink && {
219
+ name: translate('notification.common.viewTxHash', locale),
220
+ title: translate('notification.common.viewTxHash', locale),
221
+ link: viewTxHashLink as string,
222
+ },
223
+ ].filter(Boolean),
224
+ };
225
+
226
+ return template;
227
+ }
228
+ }
@@ -118,8 +118,8 @@ export class SubscriptionSucceededEmailTemplate
118
118
  const nftMintItem: NftMintItem | undefined = hasNft
119
119
  ? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as 'arcblock' | 'ethereum']
120
120
  : undefined;
121
- const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
122
- const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
121
+ const currentPeriodStart: string = formatTime((subscription.current_period_start || invoice.period_start) * 1000);
122
+ const currentPeriodEnd: string = formatTime((subscription.current_period_end || invoice.period_end) * 1000);
123
123
  const duration: string = prettyMsI18n(
124
124
  new Date(currentPeriodEnd).getTime() - new Date(currentPeriodStart).getTime(),
125
125
  {
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/brace-style */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
- import { toDid } from '@ocap/util';
3
+ import { fromUnitToToken, toDid } from '@ocap/util';
4
4
  import pWaitFor from 'p-wait-for';
5
5
  import prettyMsI18n from 'pretty-ms-i18n';
6
6
 
@@ -65,18 +65,15 @@ export class SubscriptionTrialStartEmailTemplate
65
65
  subscription_id: subscription.id,
66
66
  },
67
67
  });
68
-
69
- return ['minted', 'sent', 'error'].includes(checkoutSession?.nft_mint_status as string);
68
+ return ['minted', 'sent', 'error', 'disabled'].includes(checkoutSession?.nft_mint_status as string);
70
69
  },
71
70
  { timeout: 1000 * 10, interval: 1000 }
72
71
  );
73
72
 
74
- const invoice: Invoice = (await Invoice.findOne({
75
- where: {
76
- subscription_id: subscription.id,
77
- total: 0,
78
- },
79
- })) as Invoice;
73
+ const invoice: Invoice = (await Invoice.findByPk(subscription.latest_invoice_id)) as Invoice;
74
+ if (!invoice) {
75
+ throw new Error(`Invoice not found in subscription: ${subscription.id}`);
76
+ }
80
77
 
81
78
  const paymentCurrency = (await PaymentCurrency.findOne({
82
79
  where: {
@@ -102,7 +99,7 @@ export class SubscriptionTrialStartEmailTemplate
102
99
  const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
103
100
  const chainHost: string | undefined =
104
101
  paymentMethod?.settings?.[checkoutSession?.nft_mint_details?.type as 'arcblock' | 'ethereum']?.api_host;
105
- const paymentInfo: string = `0 ${paymentCurrency.symbol}`;
102
+ const paymentInfo: string = `${fromUnitToToken(invoice?.total || '0', paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
106
103
  const currentPeriodStart: string = formatTime((subscription.trial_start as number) * 1000);
107
104
  const currentPeriodEnd: string = formatTime((subscription.trial_end as number) * 1000);
108
105
  const duration: string = prettyMsI18n(
@@ -4,11 +4,12 @@ import { fromUnitToToken } from '@ocap/util';
4
4
  import type { ManipulateType } from 'dayjs';
5
5
  import prettyMsI18n from 'pretty-ms-i18n';
6
6
 
7
+ import { getTokenSummaryByDid } from '@api/integrations/arcblock/stake';
7
8
  import { getUserLocale } from '../../../integrations/blocklet/notification';
8
9
  import { translate } from '../../../locales';
9
- import { Customer, Invoice, Subscription } from '../../../store/models';
10
+ import { Customer, Subscription } from '../../../store/models';
10
11
  import { PaymentCurrency } from '../../../store/models/payment-currency';
11
- import { PaymentDetail, getPaymentDetail } from '../../payment';
12
+ import { PaymentDetail, getPaymentAmountForCycleSubscription } from '../../payment';
12
13
  import { getMainProductName } from '../../product';
13
14
  import { getCustomerSubscriptionPageUrl } from '../../subscription';
14
15
  import { formatTime, getPrettyMsI18nLocale } from '../../time';
@@ -63,7 +64,7 @@ export class SubscriptionTrialWilEndEmailTemplate
63
64
  throw new Error(`Customer not found: ${subscription.customer_id}`);
64
65
  }
65
66
 
66
- const invoice = (await Invoice.findByPk(subscription.latest_invoice_id)) as Invoice;
67
+ // const invoice = (await Invoice.findByPk(subscription.latest_invoice_id)) as Invoice;
67
68
  const paymentCurrency = (await PaymentCurrency.findOne({
68
69
  where: {
69
70
  id: subscription.currency_id,
@@ -77,8 +78,15 @@ export class SubscriptionTrialWilEndEmailTemplate
77
78
  const willRenewDuration: string =
78
79
  locale === 'en' ? this.getWillRenewDuration(locale) : this.getWillRenewDuration(locale).split(' ').join('');
79
80
 
80
- const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice);
81
- const paymentInfo: string = `${fromUnitToToken(+invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
81
+ // const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice);
82
+
83
+ const paymentAmount = await getPaymentAmountForCycleSubscription(subscription, paymentCurrency);
84
+ const paymentDetail = { price: paymentAmount, balance: 0, symbol: paymentCurrency.symbol };
85
+
86
+ const token = await getTokenSummaryByDid(userDid, customer.livemode);
87
+
88
+ paymentDetail.balance = +fromUnitToToken(token?.[paymentCurrency.id] || '0', paymentCurrency.decimal);
89
+ const paymentInfo: string = `${paymentAmount} ${paymentCurrency.symbol}`;
82
90
  const currentPeriodStart: string = formatTime((subscription.trial_start as number) * 1000);
83
91
  const currentPeriodEnd: string = formatTime((subscription.trial_end as number) * 1000);
84
92
  const duration: string = prettyMsI18n(
@@ -1,18 +1,19 @@
1
1
  /* eslint-disable @typescript-eslint/brace-style */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
- import { fromUnitToToken } from '@ocap/util';
4
3
  import type { ManipulateType } from 'dayjs';
5
4
  import dayjs from 'dayjs';
6
5
  import prettyMsI18n from 'pretty-ms-i18n';
7
6
  import type { LiteralUnion } from 'type-fest';
8
7
 
8
+ import { getTokenSummaryByDid } from '@api/integrations/arcblock/stake';
9
+ import { fromUnitToToken } from '@ocap/util';
9
10
  import { getUserLocale } from '../../../integrations/blocklet/notification';
10
11
  import { translate } from '../../../locales';
11
12
  import { Customer, Invoice, PaymentMethod, Price, Subscription, SubscriptionItem } from '../../../store/models';
12
13
  import { PaymentCurrency } from '../../../store/models/payment-currency';
13
- import { PaymentDetail, getPaymentDetail } from '../../payment';
14
+ import { getPaymentAmountForCycleSubscription, type PaymentDetail } from '../../payment';
14
15
  import { getMainProductName } from '../../product';
15
- import { getCustomerSubscriptionPageUrl, getUpcomingInvoiceAmount } from '../../subscription';
16
+ import { getCustomerSubscriptionPageUrl } from '../../subscription';
16
17
  import { formatTime, getPrettyMsI18nLocale } from '../../time';
17
18
  import { getExplorerLink } from '../../util';
18
19
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
@@ -40,6 +41,7 @@ interface SubscriptionWillRenewEmailTemplateContext {
40
41
 
41
42
  viewSubscriptionLink: string;
42
43
  addFundsLink: string;
44
+ paymentMethod: PaymentMethod | null;
43
45
  }
44
46
 
45
47
  export class SubscriptionWillRenewEmailTemplate
@@ -85,18 +87,24 @@ export class SubscriptionWillRenewEmailTemplate
85
87
  const willRenewDuration: string =
86
88
  locale === 'en' ? this.getWillRenewDuration(locale) : this.getWillRenewDuration(locale).split(' ').join('');
87
89
 
88
- const upcomingInvoiceAmount = await getUpcomingInvoiceAmount(subscription.id);
89
- const amount: string = fromUnitToToken(+upcomingInvoiceAmount.amount, upcomingInvoiceAmount.currency?.decimal);
90
- const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice, amount);
91
- paymentDetail.price = +amount;
90
+ // const upcomingInvoiceAmount = await getUpcomingInvoiceAmount(subscription.id);
91
+ // const amount: string = fromUnitToToken(+upcomingInvoiceAmount.amount, upcomingInvoiceAmount.currency?.decimal);
92
+ // const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice, amount);
93
+ // paymentDetail.price = +amount;
94
+
95
+ const paymentAmount = await getPaymentAmountForCycleSubscription(subscription, paymentCurrency);
96
+ const paymentDetail = { price: paymentAmount, balance: 0, symbol: paymentCurrency.symbol };
97
+
98
+ const token = await getTokenSummaryByDid(userDid, customer.livemode);
92
99
 
100
+ paymentDetail.balance = +fromUnitToToken(token?.[paymentCurrency.id] || '0', paymentCurrency.decimal);
93
101
  const { isPrePaid, interval } = await this.getPaymentCategory({
94
102
  subscriptionId: subscription.id,
95
103
  });
96
104
  const paidType: string = isPrePaid
97
105
  ? translate('notification.common.prepaid', locale)
98
106
  : translate('notification.common.postpaid', locale);
99
- const paymentInfo: string = `${paymentDetail.price} ${paymentCurrency.symbol}`;
107
+ const paymentInfo: string = `${paymentDetail?.price || '0'} ${paymentCurrency.symbol}`;
100
108
  const currentPeriodStart: string = isPrePaid
101
109
  ? formatTime(invoice.period_end * 1000)
102
110
  : formatTime(invoice.period_start * 1000);
@@ -143,6 +151,7 @@ export class SubscriptionWillRenewEmailTemplate
143
151
 
144
152
  viewSubscriptionLink,
145
153
  addFundsLink,
154
+ paymentMethod,
146
155
  };
147
156
  }
148
157
  async getPaymentCategory({ subscriptionId }: { subscriptionId: string }): Promise<{
@@ -228,10 +237,11 @@ export class SubscriptionWillRenewEmailTemplate
228
237
 
229
238
  viewSubscriptionLink,
230
239
  addFundsLink,
240
+ paymentMethod,
231
241
  } = await this.getContext();
232
242
 
233
243
  const canPay: boolean = paymentDetail.balance >= paymentDetail.price;
234
- if (canPay && !this.options.required) {
244
+ if (canPay) {
235
245
  // 当余额足够支付并且本封邮件不是必须发送时,可以不发送邮件
236
246
  return null;
237
247
  }
@@ -240,31 +250,33 @@ export class SubscriptionWillRenewEmailTemplate
240
250
  return null;
241
251
  }
242
252
 
253
+ const isStripe = paymentMethod?.type === 'stripe';
243
254
  const template: BaseEmailTemplateType = {
244
255
  title: `${translate('notification.subscriptionWillRenew.title', locale, {
245
256
  productName,
246
257
  willRenewDuration,
247
258
  })}`,
248
- body: canPay
249
- ? `${translate('notification.subscriptionWillRenew.body', locale, {
250
- at,
251
- productName,
252
- willRenewDuration,
253
- balance: `${paymentDetail.balance} ${paymentDetail.symbol}`,
254
- })}`
255
- : `${translate('notification.subscriptionWillRenew.unableToPayBody', locale, {
256
- at,
257
- productName,
258
- willRenewDuration,
259
- reason: `<span style="color: red;">${translate(
260
- 'notification.subscriptionWillRenew.unableToPayReason',
261
- locale,
262
- {
263
- balance: `${paymentDetail.balance} ${paymentDetail.symbol}`,
264
- price: `${paymentDetail.price} ${paymentDetail.symbol}`,
265
- }
266
- )}</span>`,
267
- })}`,
259
+ body:
260
+ canPay || isStripe
261
+ ? `${translate('notification.subscriptionWillRenew.body', locale, {
262
+ at,
263
+ productName,
264
+ willRenewDuration,
265
+ balance: `${paymentDetail.balance} ${paymentDetail.symbol}`,
266
+ })}`
267
+ : `${translate('notification.subscriptionWillRenew.unableToPayBody', locale, {
268
+ at,
269
+ productName,
270
+ willRenewDuration,
271
+ reason: `<span style="color: red;">${translate(
272
+ 'notification.subscriptionWillRenew.unableToPayReason',
273
+ locale,
274
+ {
275
+ balance: `${paymentDetail.balance} ${paymentDetail.symbol}`,
276
+ price: `${paymentDetail.price} ${paymentDetail.symbol}`,
277
+ }
278
+ )}</span>`,
279
+ })}`,
268
280
  // @ts-expect-error
269
281
  attachments: [
270
282
  {
@@ -10,11 +10,23 @@ import cloneDeep from 'lodash/cloneDeep';
10
10
  import type { LiteralUnion } from 'type-fest';
11
11
 
12
12
  import { fetchErc20Allowance, fetchErc20Balance, fetchEtherBalance } from '../integrations/ethereum/token';
13
- import { Invoice, PaymentCurrency, PaymentIntent, PaymentMethod, TCustomer, TLineItemExpanded } from '../store/models';
13
+ import {
14
+ Invoice,
15
+ PaymentCurrency,
16
+ PaymentIntent,
17
+ PaymentMethod,
18
+ SubscriptionItem,
19
+ TCustomer,
20
+ TLineItemExpanded,
21
+ Subscription,
22
+ Price,
23
+ UsageRecord,
24
+ } from '../store/models';
14
25
  import type { TPaymentCurrency } from '../store/models/payment-currency';
15
26
  import { blocklet, ethWallet, wallet } from './auth';
16
27
  import logger from './logger';
17
28
  import { OCAP_PAYMENT_TX_TYPE } from './util';
29
+ import { getSubscriptionCycleAmount, getSubscriptionCycleSetup } from './subscription';
18
30
 
19
31
  export interface SufficientForPaymentResult {
20
32
  sufficient: boolean;
@@ -339,3 +351,43 @@ export async function isBalanceSufficientForRefund(args: {
339
351
 
340
352
  throw new Error(`isBalanceSufficientForRefund: Payment method ${paymentMethod.type} not supported`);
341
353
  }
354
+
355
+ export async function getPaymentAmountForCycleSubscription(
356
+ subscription: Subscription,
357
+ paymentCurrency: PaymentCurrency
358
+ ) {
359
+ const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
360
+ let expandedItems = await Price.expand(
361
+ subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
362
+ { product: true }
363
+ );
364
+ const previousPeriodEnd =
365
+ subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
366
+ const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
367
+ // get usage summaries for this billing cycle
368
+ expandedItems = await Promise.all(
369
+ expandedItems.map(async (x: any) => {
370
+ // For metered billing, we need to get usage summary for this billing cycle
371
+ // @link https://stripe.com/docs/products-prices/pricing-models#usage-types
372
+ if (x.price.recurring?.usage_type === 'metered') {
373
+ const rawQuantity = await UsageRecord.getSummary({
374
+ id: x.id,
375
+ start: setup.period.start - setup.cycle / 1000,
376
+ end: setup.period.end - setup.cycle / 1000,
377
+ method: x.price.recurring?.aggregate_usage,
378
+ dryRun: false,
379
+ });
380
+ x.quantity = x.price.transformQuantity(rawQuantity);
381
+ // record raw quantity in metadata
382
+ x.metadata = x.metadata || {};
383
+ x.metadata.quantity = rawQuantity;
384
+ }
385
+ return x;
386
+ })
387
+ );
388
+ if (expandedItems.length > 0) {
389
+ const amount = getSubscriptionCycleAmount(expandedItems, paymentCurrency.id);
390
+ return +fromUnitToToken(amount?.total || '0', paymentCurrency.decimal);
391
+ }
392
+ return 0;
393
+ }
@@ -268,6 +268,19 @@ export async function createProration(
268
268
  logger.warn('try to create proration with invalid arguments', { anchor, prorationStart, prorationEnd });
269
269
  throw new Error('Subscription proration anchor should not be larger than prorationEnd');
270
270
  }
271
+ const trialing = subscription.status === 'trialing';
272
+ if (trialing) {
273
+ return {
274
+ lastInvoice,
275
+ total: '0',
276
+ due: '0',
277
+ used: '0',
278
+ unused: '0',
279
+ prorations: [],
280
+ newCredit: '0',
281
+ appliedCredit: '0',
282
+ };
283
+ }
271
284
 
272
285
  const prorationRate = Math.ceil(((prorationEnd - anchor) / (prorationEnd - prorationStart)) * precision);
273
286
  let unused = new BN(0);
@@ -823,3 +836,33 @@ export async function getSubscriptionStakeAddress(subscription: Subscription, cu
823
836
  (await getCustomerStakeAddress(customerDid, subscription.id))
824
837
  );
825
838
  }
839
+
840
+ export async function getSubscriptionStakeCancellation(
841
+ subscription: Subscription,
842
+ paymentMethod: PaymentMethod,
843
+ paymentCurrency: PaymentCurrency
844
+ ): Promise<{
845
+ stakeReturn: boolean;
846
+ stakeSlash: boolean;
847
+ stakeEnough: boolean;
848
+ hasStake: boolean;
849
+ }> {
850
+ const cancellation = {
851
+ stakeReturn: subscription.cancelation_details?.return_stake === true,
852
+ stakeSlash: subscription.cancelation_details?.slash_stake === true,
853
+ stakeEnough: false,
854
+ hasStake: !!subscription?.payment_details?.arcblock?.staking?.tx_hash,
855
+ };
856
+ const customerCancelRequest = subscription.cancelation_details?.reason === 'cancellation_requested';
857
+ if (customerCancelRequest) {
858
+ // cancel request from customer
859
+ const address = subscription?.payment_details?.arcblock?.staking?.address;
860
+ let stakeEnough;
861
+ if (address && paymentMethod) {
862
+ const stakeReturnResult = await getSubscriptionStakeReturnSetup(subscription, address, paymentMethod);
863
+ stakeEnough = await checkRemainingStake(paymentMethod, paymentCurrency, address, stakeReturnResult.return_amount);
864
+ cancellation.stakeEnough = stakeEnough?.enough;
865
+ }
866
+ }
867
+ return cancellation;
868
+ }
@@ -7,6 +7,10 @@ export default flat({
7
7
  product: 'Product',
8
8
  paymentAmount: 'Payment amount',
9
9
  refundAmount: 'Refund amount',
10
+ stakeAmount: 'Stake amount',
11
+ returnAmount: 'Return amount',
12
+ slashAmount: 'Slash amount',
13
+ slashReason: 'Slash reason',
10
14
  refundPeriod: 'Refund period',
11
15
  validityPeriod: 'Service period',
12
16
  paymentPeriod: 'Payment period',
@@ -110,6 +114,16 @@ export default flat({
110
114
  body: 'Your subscription to {productName} has been successfully refunded on {at}, with a refund amount of {refundInfo}. If you have any questions, please feel free to contact us.',
111
115
  },
112
116
 
117
+ subscriptionStakeReturnSucceeded: {
118
+ title: '{productName} stake return successful',
119
+ body: 'The stake for your subscription to {productName} has been returned on {at}, with a return amount of {refundInfo}. If you have any questions, please feel free to contact us.',
120
+ },
121
+
122
+ subscriptionStakeSlashSucceeded: {
123
+ title: '{productName} stake slashed',
124
+ body: 'The stake for your subscription to {productName} has been slashed on {at}, with a slash amount of {slashInfo}. If you have any questions, please feel free to contact us.',
125
+ },
126
+
113
127
  customerRewardSucceeded: {
114
128
  title: 'Thanks for your reward of {amount}',
115
129
  body: 'Thanks for your reward on {at} for {subject}, the amount of reward is {amount}. Your support is our driving force, thanks for your generous support!',
@@ -127,6 +141,16 @@ export default flat({
127
141
  body: 'Your subscription to {productName} has been canceled on {at}. If you have any questions, please feel free to contact us.',
128
142
  adminCanceled: 'Admin canceled',
129
143
  adminCanceledAndRefunded: 'Admin canceled and refunded, please check for the refund email',
144
+ adminCanceledAndStakeReturned: 'Admin canceled and returned the stake, please check for the stake return email',
145
+ adminCanceledAndSlashed: 'Admin canceled and slashed stake, please check for the stake slash email',
146
+ adminCanceledAndRefundedAndStakeReturned:
147
+ 'The administrator has canceled the subscription and refunded, and the stake has been returned, please check for the refund and stake return email.',
148
+ adminCanceledAndRefundedAndStakeSlashed:
149
+ 'The administrator has canceled the subscription and refunded, and the stake has been slashed, please check for the refund and stake slash email.',
150
+ customerCanceled: 'User-initiated cancellation',
151
+ customerCanceledAndStakeReturned:
152
+ 'User-initiated cancellation, the stake will be returned later, please check for the stake return email',
153
+ paymentFailed: 'Payment failed',
130
154
  },
131
155
  },
132
156
  });
@@ -7,6 +7,10 @@ export default flat({
7
7
  product: '商品',
8
8
  paymentAmount: '扣费金额',
9
9
  refundAmount: '退款金额',
10
+ stakeAmount: '质押金额',
11
+ returnAmount: '退还金额',
12
+ slashAmount: '罚没金额',
13
+ slashReason: '罚没原因',
10
14
  refundPeriod: '退款周期',
11
15
  validityPeriod: '服务周期',
12
16
  paymentPeriod: '扣款周期',
@@ -107,6 +111,16 @@ export default flat({
107
111
  body: '您订阅的 {productName} 在 {at} 已退款成功,退款金额为 {refundInfo}。如有任何疑问,请随时与我们联系。',
108
112
  },
109
113
 
114
+ subscriptionStakeReturnSucceeded: {
115
+ title: '{productName} 质押退还成功',
116
+ body: '您订阅的 {productName} 在 {at} 已退还押金,退还金额为 {refundInfo}。如有任何疑问,请随时与我们联系。',
117
+ },
118
+
119
+ subscriptionStakeSlashSucceeded: {
120
+ title: '{productName} 质押已被罚没',
121
+ body: '您订阅的 {productName} 在 {at} 已罚没押金,罚没金额为 {slashInfo}。如有任何疑问,请随时与我们联系。',
122
+ },
123
+
110
124
  customerRewardSucceeded: {
111
125
  title: '感谢您打赏的 {amount}',
112
126
  body: '感谢您于 {at} 在 {subject} 下的打赏,打赏金额为 {amount}。您的支持是我们前行的动力,谢谢您的大力支持!',
@@ -124,6 +138,14 @@ export default flat({
124
138
  body: '您订阅的 {productName} 在 {at} 已取消。如有任何疑问,请随时与我们联系。',
125
139
  adminCanceled: '管理员取消',
126
140
  adminCanceledAndRefunded: '管理员取消并已退款,请留意后续的退款邮件',
141
+ adminCanceledAndStakeReturned: '管理员取消并已退还押金,请留意后续的质押退还邮件',
142
+ adminCanceledAndSlashed: '管理员取消并已罚没,请留意后续的罚没邮件',
143
+ adminCanceledAndRefundedAndStakeReturned:
144
+ '管理员已取消订阅并退款,押金也已退还,请留意后续的退款和质押退还邮件。',
145
+ adminCanceledAndRefundedAndStakeSlashed: '管理员已取消订阅并退款,押金已被罚没,请留意后续的退款和质押罚没邮件。',
146
+ customerCanceled: '用户主动取消',
147
+ customerCanceledAndStakeReturned: '用户主动取消, 押金会在稍后退还, 请留意后续的质押退还邮件',
148
+ paymentFailed: '扣费失败',
127
149
  },
128
150
  },
129
151
  });