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.
- package/api/src/crons/payment-stat.ts +1 -0
- package/api/src/index.ts +2 -2
- package/api/src/integrations/arcblock/stake.ts +17 -10
- package/api/src/libs/auth.ts +3 -2
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +15 -8
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -30
- package/api/src/libs/notification/template/subscription-canceled.ts +45 -23
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +130 -47
- package/api/src/libs/notification/template/subscription-renewed.ts +10 -2
- package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +228 -0
- package/api/src/libs/notification/template/subscription-succeeded.ts +2 -2
- package/api/src/libs/notification/template/subscription-trial-start.ts +7 -10
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +13 -5
- package/api/src/libs/notification/template/subscription-will-renew.ts +41 -29
- package/api/src/libs/payment.ts +53 -1
- package/api/src/libs/subscription.ts +43 -0
- package/api/src/locales/en.ts +24 -0
- package/api/src/locales/zh.ts +22 -0
- package/api/src/queues/invoice.ts +1 -1
- package/api/src/queues/notification.ts +9 -0
- package/api/src/queues/payment.ts +17 -0
- package/api/src/routes/checkout-sessions.ts +13 -1
- package/api/src/routes/payment-stats.ts +3 -3
- package/api/src/routes/subscriptions.ts +26 -6
- package/api/src/store/migrations/20240905-index.ts +100 -0
- package/api/src/store/models/subscription.ts +1 -0
- package/api/tests/libs/payment.spec.ts +168 -0
- package/blocklet.yml +1 -1
- package/package.json +10 -10
- package/src/components/balance-list.tsx +2 -2
- package/src/components/invoice/list.tsx +2 -2
- package/src/components/invoice/table.tsx +1 -1
- package/src/components/payment-intent/list.tsx +1 -1
- package/src/components/payouts/list.tsx +1 -1
- package/src/components/refund/list.tsx +2 -2
- package/src/components/subscription/actions/cancel.tsx +41 -13
- package/src/components/subscription/actions/index.tsx +11 -8
- package/src/components/subscription/actions/slash-stake.tsx +52 -0
- package/src/locales/en.tsx +1 -0
- package/src/locales/zh.tsx +1 -0
- package/src/pages/admin/billing/invoices/detail.tsx +2 -2
- package/src/pages/customer/refund/list.tsx +1 -1
- 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.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 =
|
|
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,
|
|
10
|
+
import { Customer, Subscription } from '../../../store/models';
|
|
10
11
|
import { PaymentCurrency } from '../../../store/models/payment-currency';
|
|
11
|
-
import { PaymentDetail,
|
|
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
|
-
|
|
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 {
|
|
14
|
+
import { getPaymentAmountForCycleSubscription, type PaymentDetail } from '../../payment';
|
|
14
15
|
import { getMainProductName } from '../../product';
|
|
15
|
-
import { getCustomerSubscriptionPageUrl
|
|
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
|
|
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
|
|
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:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
{
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|
package/api/src/locales/en.ts
CHANGED
|
@@ -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
|
});
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -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
|
});
|