payment-kit 1.15.33 → 1.15.34
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/integrations/stripe/handlers/setup-intent.ts +3 -1
- package/api/src/integrations/stripe/handlers/subscription.ts +2 -8
- package/api/src/integrations/stripe/resource.ts +0 -11
- package/api/src/libs/invoice.ts +202 -1
- package/api/src/libs/notification/template/subscription-canceled.ts +11 -2
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -1
- package/api/src/libs/notification/template/subscription-renewed.ts +1 -1
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +9 -5
- package/api/src/libs/notification/template/subscription-will-canceled.ts +9 -5
- package/api/src/libs/notification/template/subscription-will-renew.ts +10 -12
- package/api/src/libs/payment.ts +3 -2
- package/api/src/libs/subscription.ts +33 -14
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +3 -1
- package/api/src/queues/refund.ts +9 -8
- package/api/src/queues/subscription.ts +109 -38
- package/api/src/routes/checkout-sessions.ts +20 -4
- package/api/src/routes/connect/change-payment.ts +51 -34
- package/api/src/routes/connect/change-plan.ts +25 -3
- package/api/src/routes/connect/setup.ts +27 -6
- package/api/src/routes/connect/shared.ts +135 -1
- package/api/src/routes/connect/subscribe.ts +25 -3
- package/api/src/routes/invoices.ts +23 -105
- package/api/src/routes/subscriptions.ts +66 -17
- package/api/src/store/models/invoice.ts +2 -1
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/src/components/invoice/list.tsx +47 -24
- package/src/components/pricing-table/payment-settings.tsx +1 -1
- package/src/components/subscription/actions/cancel.tsx +10 -7
- package/src/components/subscription/metrics.tsx +1 -1
- package/src/pages/admin/billing/invoices/detail.tsx +15 -0
- package/src/pages/admin/billing/invoices/index.tsx +1 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +12 -4
- package/src/pages/admin/customers/customers/detail.tsx +1 -0
- package/src/pages/customer/index.tsx +1 -1
- package/src/pages/customer/invoice/detail.tsx +28 -14
- package/src/pages/customer/subscription/change-plan.tsx +8 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/src/pages/customer/subscription/embed.tsx +3 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type Stripe from 'stripe';
|
|
2
2
|
|
|
3
3
|
import logger from '../../../libs/logger';
|
|
4
|
-
import { CheckoutSession, SetupIntent, Subscription, TEventExpanded } from '../../../store/models';
|
|
4
|
+
import { CheckoutSession, Lock, SetupIntent, Subscription, TEventExpanded } from '../../../store/models';
|
|
5
5
|
|
|
6
6
|
async function handleSubscriptionOnSetupSucceeded(event: TEventExpanded, stripeIntentId: string) {
|
|
7
7
|
const subscription = await Subscription.findOne({
|
|
@@ -69,6 +69,8 @@ async function handleSetupIntentOnSetupSucceeded(event: TEventExpanded, stripeIn
|
|
|
69
69
|
payment_method_options: {},
|
|
70
70
|
},
|
|
71
71
|
});
|
|
72
|
+
// lock the subscription to prevent concurrent change plan
|
|
73
|
+
await Lock.acquire(`${subscription.id}-change-plan`, subscription.current_period_end);
|
|
72
74
|
logger.info('subscription payment changed to stripe on stripe setup intent succeeded', {
|
|
73
75
|
subscriptionId: subscription.id,
|
|
74
76
|
setupIntentId: setupIntent.id,
|
|
@@ -70,19 +70,13 @@ export async function handleSubscriptionEvent(event: TEventExpanded, _: Stripe)
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const fields = [
|
|
74
|
-
'cancel_at',
|
|
75
|
-
'cancel_at_period_end',
|
|
76
|
-
'canceled_at',
|
|
77
|
-
'current_period_start',
|
|
78
|
-
'current_period_end',
|
|
79
|
-
'trial_end',
|
|
80
|
-
];
|
|
73
|
+
const fields = ['cancel_at', 'cancel_at_period_end', 'canceled_at', 'current_period_start', 'current_period_end'];
|
|
81
74
|
if (subscription.payment_settings?.payment_method_types?.includes('stripe')) {
|
|
82
75
|
fields.push('pause_collection');
|
|
83
76
|
}
|
|
84
77
|
if (subscription.status === 'trialing' && event.data.object.status === 'active') {
|
|
85
78
|
fields.push('status');
|
|
79
|
+
fields.push('trial_end');
|
|
86
80
|
}
|
|
87
81
|
|
|
88
82
|
await finalizeStripeSubscriptionUpdate({
|
|
@@ -289,18 +289,7 @@ export async function ensureStripeSubscription(
|
|
|
289
289
|
}
|
|
290
290
|
})
|
|
291
291
|
);
|
|
292
|
-
|
|
293
|
-
await internal.update({
|
|
294
|
-
payment_details: {
|
|
295
|
-
stripe: {
|
|
296
|
-
customer_id: customer.id,
|
|
297
|
-
subscription_id: stripeSubscription.id,
|
|
298
|
-
setup_intent_id: stripeSubscription.pending_setup_intent?.id,
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
292
|
}
|
|
303
|
-
|
|
304
293
|
return stripeSubscription;
|
|
305
294
|
}
|
|
306
295
|
|
package/api/src/libs/invoice.ts
CHANGED
|
@@ -3,20 +3,25 @@ import type { LiteralUnion } from 'type-fest';
|
|
|
3
3
|
import { withQuery } from 'ufo';
|
|
4
4
|
|
|
5
5
|
import { fromUnitToToken } from '@ocap/util';
|
|
6
|
+
import { Op } from 'sequelize';
|
|
7
|
+
import { cloneDeep, pick } from 'lodash';
|
|
6
8
|
import {
|
|
9
|
+
Customer,
|
|
7
10
|
Invoice,
|
|
8
11
|
InvoiceItem,
|
|
9
12
|
PaymentCurrency,
|
|
10
13
|
PaymentMethod,
|
|
11
14
|
Price,
|
|
12
15
|
Product,
|
|
16
|
+
Refund,
|
|
17
|
+
SetupIntent,
|
|
13
18
|
Subscription,
|
|
14
19
|
SubscriptionItem,
|
|
15
20
|
UsageRecord,
|
|
16
21
|
} from '../store/models';
|
|
17
22
|
import { getConnectQueryParam } from './util';
|
|
18
23
|
import { expandLineItems } from './session';
|
|
19
|
-
import { getSubscriptionCycleAmount, getSubscriptionCycleSetup } from './subscription';
|
|
24
|
+
import { getSubscriptionCycleAmount, getSubscriptionCycleSetup, getSubscriptionStakeAmountSetup } from './subscription';
|
|
20
25
|
|
|
21
26
|
export function getCustomerInvoicePageUrl({
|
|
22
27
|
invoiceId,
|
|
@@ -144,3 +149,199 @@ export async function getInvoiceShouldPayTotal(invoice: Invoice) {
|
|
|
144
149
|
return invoice.total;
|
|
145
150
|
}
|
|
146
151
|
}
|
|
152
|
+
|
|
153
|
+
export async function getReturnStakeInvoices(subscription: Subscription) {
|
|
154
|
+
const method = await PaymentMethod.findOne({ where: { type: 'arcblock', livemode: subscription.livemode } });
|
|
155
|
+
const returnStakeInvoice: Invoice[] = [];
|
|
156
|
+
const refunds = await Refund.findAll({
|
|
157
|
+
where: { subscription_id: subscription.id, status: 'succeeded', type: 'stake_return' },
|
|
158
|
+
include: [
|
|
159
|
+
{ model: Invoice, as: 'invoice' },
|
|
160
|
+
{ model: Customer, as: 'customer' },
|
|
161
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await Promise.all(
|
|
166
|
+
refunds.map(async (r: any) => {
|
|
167
|
+
const invoice =
|
|
168
|
+
r.invoice ||
|
|
169
|
+
(await Invoice.findOne({
|
|
170
|
+
where: {
|
|
171
|
+
subscription_id: subscription.id,
|
|
172
|
+
currency_id: r.currency_id,
|
|
173
|
+
},
|
|
174
|
+
order: [['created_at', 'ASC']],
|
|
175
|
+
}));
|
|
176
|
+
returnStakeInvoice.push({
|
|
177
|
+
id: r.id,
|
|
178
|
+
status: 'paid',
|
|
179
|
+
billing_reason: 'return_stake',
|
|
180
|
+
description: 'Return Subscription staking',
|
|
181
|
+
total: r.amount,
|
|
182
|
+
amount_due: '0',
|
|
183
|
+
amount_paid: r.amount,
|
|
184
|
+
amount_remaining: '0',
|
|
185
|
+
subscription_id: subscription.id,
|
|
186
|
+
paid: true,
|
|
187
|
+
...pick(invoice, ['number', 'auto_advance', 'period_start', 'period_end']),
|
|
188
|
+
...pick(r, ['created_at', 'updated_at', 'currency_id', 'customer_id']),
|
|
189
|
+
// @ts-ignore
|
|
190
|
+
paymentCurrency: r.paymentCurrency,
|
|
191
|
+
paymentMethod: method,
|
|
192
|
+
customer: r.customer,
|
|
193
|
+
metadata: {
|
|
194
|
+
payment_details: {
|
|
195
|
+
arcblock: {
|
|
196
|
+
tx_hash: r?.payment_details?.arcblock?.tx_hash,
|
|
197
|
+
payer: r?.payment_details?.arcblock?.payer,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
return returnStakeInvoice;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function getSetupInvoice(
|
|
208
|
+
subscription: Subscription,
|
|
209
|
+
paymentMethod: PaymentMethod,
|
|
210
|
+
currencyId: string,
|
|
211
|
+
stakingProps: { tx_hash: string; payer: string; address: string }
|
|
212
|
+
) {
|
|
213
|
+
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
214
|
+
if (!currency) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
if (!stakingProps?.tx_hash) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (paymentMethod.type !== 'arcblock') {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const firstInvoice = await Invoice.findOne({
|
|
224
|
+
where: { subscription_id: subscription.id, currency_id: currency.id },
|
|
225
|
+
order: [['created_at', 'ASC']],
|
|
226
|
+
include: [{ model: Customer, as: 'customer' }],
|
|
227
|
+
});
|
|
228
|
+
if (!firstInvoice) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const stakeAmountResult = await getSubscriptionStakeAmountSetup(subscription, paymentMethod, stakingProps.tx_hash);
|
|
232
|
+
// @ts-ignore
|
|
233
|
+
const stakeAmount = stakeAmountResult?.[currency?.contract] || '0';
|
|
234
|
+
return {
|
|
235
|
+
id: stakingProps.address as string,
|
|
236
|
+
status: 'paid',
|
|
237
|
+
description: 'Subscription staking',
|
|
238
|
+
billing_reason: 'stake',
|
|
239
|
+
total: stakeAmount,
|
|
240
|
+
amount_due: '0',
|
|
241
|
+
amount_paid: stakeAmount,
|
|
242
|
+
amount_remaining: '0',
|
|
243
|
+
...pick(firstInvoice, [
|
|
244
|
+
'number',
|
|
245
|
+
'paid',
|
|
246
|
+
'auto_advance',
|
|
247
|
+
'currency_id',
|
|
248
|
+
'customer_id',
|
|
249
|
+
'subscription_id',
|
|
250
|
+
'period_start',
|
|
251
|
+
'period_end',
|
|
252
|
+
'created_at',
|
|
253
|
+
'updated_at',
|
|
254
|
+
]),
|
|
255
|
+
// @ts-ignore
|
|
256
|
+
paymentCurrency: currency,
|
|
257
|
+
paymentMethod,
|
|
258
|
+
// @ts-ignore
|
|
259
|
+
customer: firstInvoice?.customer,
|
|
260
|
+
metadata: {
|
|
261
|
+
payment_details: {
|
|
262
|
+
arcblock: {
|
|
263
|
+
tx_hash: stakingProps?.tx_hash,
|
|
264
|
+
payer: stakingProps?.payer,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function getStakingInvoices(subscription: Subscription): Promise<Invoice[]> {
|
|
272
|
+
const method = await PaymentMethod.findOne({ where: { type: 'arcblock', livemode: subscription.livemode } });
|
|
273
|
+
if (!method) {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
const stakeInvoices = await Invoice.findAll({
|
|
277
|
+
where: { subscription_id: subscription.id, billing_reason: 'stake', status: 'paid' },
|
|
278
|
+
include: [
|
|
279
|
+
{ model: Customer, as: 'customer' },
|
|
280
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
281
|
+
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
const invoices = cloneDeep(stakeInvoices);
|
|
285
|
+
const setups = await SetupIntent.findAll({
|
|
286
|
+
where: {
|
|
287
|
+
customer_id: subscription.customer_id,
|
|
288
|
+
payment_method_id: method?.id,
|
|
289
|
+
status: 'succeeded',
|
|
290
|
+
[Op.and]: [
|
|
291
|
+
{ metadata: { [Op.not]: null } },
|
|
292
|
+
{ 'metadata.subscription_id': subscription.id },
|
|
293
|
+
{ setup_details: { [Op.not]: null } },
|
|
294
|
+
{ 'setup_details.arcblock': { [Op.not]: null } },
|
|
295
|
+
{ 'setup_details.arcblock.staking': { [Op.not]: null } },
|
|
296
|
+
] as any,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
if (setups.length === 0) {
|
|
300
|
+
// No payment change, use the first invoice
|
|
301
|
+
if (stakeInvoices.length > 0) {
|
|
302
|
+
return stakeInvoices;
|
|
303
|
+
}
|
|
304
|
+
const newInvoice = await getSetupInvoice(subscription, method, subscription.currency_id, {
|
|
305
|
+
tx_hash: subscription.payment_details?.arcblock?.staking?.tx_hash!,
|
|
306
|
+
payer: subscription.payment_details?.arcblock?.payer!,
|
|
307
|
+
address: subscription.payment_details?.arcblock?.staking?.address!,
|
|
308
|
+
});
|
|
309
|
+
// @ts-ignore
|
|
310
|
+
return [newInvoice].filter(Boolean);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
await Promise.all(
|
|
314
|
+
setups.map(async (setup: SetupIntent, index: number) => {
|
|
315
|
+
if (
|
|
316
|
+
index === 0 &&
|
|
317
|
+
setup.metadata?.from_currency &&
|
|
318
|
+
!stakeInvoices.find((x) => x.currency_id === setup?.metadata?.from_currency)
|
|
319
|
+
) {
|
|
320
|
+
const paymentDetails =
|
|
321
|
+
setup?.metadata?.from_payment_details?.arcblock || subscription.payment_details?.arcblock;
|
|
322
|
+
const fromMethod = await PaymentMethod.findByPk(setup?.metadata?.from_payment_method_id);
|
|
323
|
+
const newInvoice = await getSetupInvoice(subscription, fromMethod!, setup.metadata?.from_currency, {
|
|
324
|
+
tx_hash: paymentDetails?.staking?.tx_hash!,
|
|
325
|
+
payer: paymentDetails?.payer!,
|
|
326
|
+
address: paymentDetails?.staking?.address!,
|
|
327
|
+
});
|
|
328
|
+
if (newInvoice) {
|
|
329
|
+
// @ts-ignore
|
|
330
|
+
invoices.push(newInvoice);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (setup.metadata?.to_currency && !stakeInvoices.find((x) => x.currency_id === setup?.metadata?.to_currency)) {
|
|
334
|
+
const newInvoice = await getSetupInvoice(subscription, method, setup.metadata?.to_currency, {
|
|
335
|
+
tx_hash: setup.setup_details?.arcblock?.staking?.tx_hash!,
|
|
336
|
+
payer: setup.setup_details?.arcblock?.payer!,
|
|
337
|
+
address: setup.setup_details?.arcblock?.staking?.address!,
|
|
338
|
+
});
|
|
339
|
+
if (newInvoice) {
|
|
340
|
+
// @ts-ignore
|
|
341
|
+
invoices.push(newInvoice);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
return invoices;
|
|
347
|
+
}
|
|
@@ -56,7 +56,15 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
56
56
|
throw new Error(`Customer(${subscription.customer_id}) not found`);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const invoice =
|
|
59
|
+
const invoice = await Invoice.findOne({
|
|
60
|
+
where: {
|
|
61
|
+
id: subscription.latest_invoice_id,
|
|
62
|
+
},
|
|
63
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
64
|
+
});
|
|
65
|
+
if (!invoice) {
|
|
66
|
+
throw new Error(`Invoice(${subscription.latest_invoice_id}) not found`);
|
|
67
|
+
}
|
|
60
68
|
const paymentCurrency = (await PaymentCurrency.findOne({
|
|
61
69
|
where: {
|
|
62
70
|
id: subscription.currency_id,
|
|
@@ -68,7 +76,8 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
68
76
|
const productName = await getMainProductName(subscription.id);
|
|
69
77
|
const at: string = formatTime(subscription.canceled_at * 1000);
|
|
70
78
|
|
|
71
|
-
|
|
79
|
+
// @ts-ignore
|
|
80
|
+
const paymentInfo: string = `${fromUnitToToken(invoice.total, invoice?.paymentCurrency?.decimal)} ${invoice?.paymentCurrency?.symbol}`;
|
|
72
81
|
const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
|
|
73
82
|
const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
|
|
74
83
|
const duration: string = prettyMsI18n(
|
|
@@ -91,7 +91,7 @@ export class SubscriptionRenewFailedEmailTemplate
|
|
|
91
91
|
const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
92
92
|
const paymentCurrency = (await PaymentCurrency.findOne({
|
|
93
93
|
where: {
|
|
94
|
-
id:
|
|
94
|
+
id: invoice.currency_id,
|
|
95
95
|
},
|
|
96
96
|
})) as PaymentCurrency;
|
|
97
97
|
|
|
@@ -82,7 +82,7 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
|
|
|
82
82
|
const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
83
83
|
const paymentCurrency = (await PaymentCurrency.findOne({
|
|
84
84
|
where: {
|
|
85
|
-
id:
|
|
85
|
+
id: invoice.currency_id,
|
|
86
86
|
},
|
|
87
87
|
})) as PaymentCurrency;
|
|
88
88
|
|
|
@@ -4,7 +4,7 @@ import { fromUnitToToken } from '@ocap/util';
|
|
|
4
4
|
import type { ManipulateType } from 'dayjs';
|
|
5
5
|
import prettyMsI18n from 'pretty-ms-i18n';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { getTokenByAddress } from '../../../integrations/arcblock/stake';
|
|
8
8
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
9
9
|
import { translate } from '../../../locales';
|
|
10
10
|
import { Customer, PaymentMethod, Subscription, PaymentCurrency } from '../../../store/models';
|
|
@@ -76,6 +76,10 @@ export class SubscriptionTrialWilEndEmailTemplate
|
|
|
76
76
|
|
|
77
77
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
78
78
|
|
|
79
|
+
if (!paymentMethod) {
|
|
80
|
+
throw new Error(`Payment method not found: ${paymentCurrency.payment_method_id}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
79
83
|
const userDid = customer.did;
|
|
80
84
|
const locale = await getUserLocale(userDid);
|
|
81
85
|
const productName = await getMainProductName(subscription.id);
|
|
@@ -86,14 +90,14 @@ export class SubscriptionTrialWilEndEmailTemplate
|
|
|
86
90
|
const at: string = formatTime((subscription.trial_end as number) * 1000);
|
|
87
91
|
|
|
88
92
|
const willEndDuration: string = getSimplifyDuration((subscription.trial_end! - dayjs().unix()) * 1000, locale);
|
|
89
|
-
// const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice);
|
|
90
93
|
|
|
91
94
|
const paymentAmount = await getPaymentAmountForCycleSubscription(subscription, paymentCurrency);
|
|
92
95
|
const paymentDetail = { price: paymentAmount, balance: 0, symbol: paymentCurrency.symbol };
|
|
96
|
+
// @ts-ignore
|
|
97
|
+
const paymentAddress = subscription.payment_details?.[paymentMethod.type]?.payer ?? undefined;
|
|
98
|
+
const balance = await getTokenByAddress(paymentAddress, paymentMethod, paymentCurrency);
|
|
99
|
+
paymentDetail.balance = +fromUnitToToken(balance, paymentCurrency.decimal);
|
|
93
100
|
|
|
94
|
-
const token = await getTokenSummaryByDid(userDid, customer.livemode);
|
|
95
|
-
|
|
96
|
-
paymentDetail.balance = +fromUnitToToken(token?.[paymentCurrency.id] || '0', paymentCurrency.decimal);
|
|
97
101
|
const paymentInfo: string = `${paymentAmount} ${paymentCurrency.symbol}`;
|
|
98
102
|
const currentPeriodStart: string = formatTime((subscription.trial_start as number) * 1000);
|
|
99
103
|
const currentPeriodEnd: string = formatTime((subscription.trial_end as number) * 1000);
|
|
@@ -78,19 +78,23 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
78
78
|
throw new Error(`Customer not found: ${subscription.customer_id}`);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const invoice =
|
|
82
|
-
const paymentCurrency = (await PaymentCurrency.findOne({
|
|
81
|
+
const invoice = await Invoice.findOne({
|
|
83
82
|
where: {
|
|
84
|
-
id: subscription.
|
|
83
|
+
id: subscription.latest_invoice_id,
|
|
85
84
|
},
|
|
86
|
-
|
|
85
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
86
|
+
});
|
|
87
|
+
if (!invoice) {
|
|
88
|
+
throw new Error(`Invoice(${subscription.latest_invoice_id}) not found`);
|
|
89
|
+
}
|
|
87
90
|
|
|
88
91
|
const userDid = customer.did;
|
|
89
92
|
const locale = await getUserLocale(userDid);
|
|
90
93
|
const productName = await getMainProductName(subscription.id);
|
|
91
94
|
const at: string = formatTime(cancelAt * 1000);
|
|
92
95
|
const willCancelDuration: string = getSimplifyDuration((cancelAt - now) * 1000, locale);
|
|
93
|
-
|
|
96
|
+
// @ts-ignore
|
|
97
|
+
const paymentInfo: string = `${fromUnitToToken(+invoice.total, invoice?.paymentCurrency?.decimal)} ${invoice?.paymentCurrency?.symbol}`;
|
|
94
98
|
|
|
95
99
|
let body: string = translate('notification.subscriptWillCanceled.body', locale, {
|
|
96
100
|
productName,
|
|
@@ -6,7 +6,7 @@ import type { LiteralUnion } from 'type-fest';
|
|
|
6
6
|
|
|
7
7
|
import { fromUnitToToken } from '@ocap/util';
|
|
8
8
|
import dayjs from '../../dayjs';
|
|
9
|
-
import {
|
|
9
|
+
import { getTokenByAddress } from '../../../integrations/arcblock/stake';
|
|
10
10
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
11
11
|
import { translate } from '../../../locales';
|
|
12
12
|
import {
|
|
@@ -93,19 +93,19 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
93
93
|
const productName = await getMainProductName(subscription.id);
|
|
94
94
|
const at: string = formatTime(invoice.period_end * 1000);
|
|
95
95
|
const willRenewDuration = getSimplifyDuration((invoice.period_end - dayjs().unix()) * 1000, locale);
|
|
96
|
-
// const upcomingInvoiceAmount = await getUpcomingInvoiceAmount(subscription.id);
|
|
97
|
-
// const amount: string = fromUnitToToken(+upcomingInvoiceAmount.amount, upcomingInvoiceAmount.currency?.decimal);
|
|
98
|
-
// const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice, amount);
|
|
99
|
-
// paymentDetail.price = +amount;
|
|
100
96
|
|
|
101
97
|
const paymentAmount = await getPaymentAmountForCycleSubscription(subscription, paymentCurrency);
|
|
102
98
|
const paymentDetail = { price: paymentAmount, balance: 0, symbol: paymentCurrency.symbol, balanceFormatted: '0' };
|
|
103
99
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
101
|
+
if (!paymentMethod) {
|
|
102
|
+
throw new Error(`Payment method not found: ${paymentCurrency.payment_method_id}`);
|
|
103
|
+
}
|
|
104
|
+
// @ts-ignore
|
|
105
|
+
const paymentAddress = subscription.payment_details?.[paymentMethod.type]?.payer ?? undefined;
|
|
106
|
+
const balance = await getTokenByAddress(paymentAddress, paymentMethod, paymentCurrency);
|
|
107
|
+
paymentDetail.balanceFormatted = fromUnitToToken(balance, paymentCurrency.decimal);
|
|
108
|
+
paymentDetail.balance = +paymentDetail.balanceFormatted;
|
|
109
109
|
|
|
110
110
|
const { isPrePaid, interval } = await this.getPaymentCategory({
|
|
111
111
|
subscriptionId: subscription.id,
|
|
@@ -132,8 +132,6 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
132
132
|
locale,
|
|
133
133
|
userDid,
|
|
134
134
|
});
|
|
135
|
-
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
136
|
-
|
|
137
135
|
const addFundsLink: string = getCustomerRechargeLink({
|
|
138
136
|
locale,
|
|
139
137
|
userDid,
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -211,13 +211,14 @@ export async function getPaymentDetail(
|
|
|
211
211
|
if (!paymentMethod) {
|
|
212
212
|
return defaultResult;
|
|
213
213
|
}
|
|
214
|
-
|
|
214
|
+
const paymentSettings = invoice?.payment_settings;
|
|
215
215
|
if (['arcblock', 'ethereum'].includes(paymentMethod.type)) {
|
|
216
216
|
// balance enough token for payment?
|
|
217
|
+
const payer = paymentSettings?.payment_method_options.arcblock?.payer as string;
|
|
217
218
|
const result = await isDelegationSufficientForPayment({
|
|
218
219
|
paymentMethod,
|
|
219
220
|
paymentCurrency,
|
|
220
|
-
userDid,
|
|
221
|
+
userDid: payer || userDid,
|
|
221
222
|
amount: inputAmount,
|
|
222
223
|
});
|
|
223
224
|
|
|
@@ -321,7 +321,7 @@ export async function createProration(
|
|
|
321
321
|
const prorations = await Promise.all(
|
|
322
322
|
prorationItems.map((x: TLineItemExpanded & { [key: string]: any }) => {
|
|
323
323
|
const price = getSubscriptionItemPrice(x);
|
|
324
|
-
const unitAmount = getPriceUintAmountByCurrency(price,
|
|
324
|
+
const unitAmount = getPriceUintAmountByCurrency(price, lastInvoice.currency_id);
|
|
325
325
|
const amount = new BN(unitAmount)
|
|
326
326
|
.mul(new BN(x.quantity))
|
|
327
327
|
.mul(new BN(prorationRate))
|
|
@@ -402,10 +402,10 @@ export async function createProration(
|
|
|
402
402
|
};
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
-
export async function getSubscriptionRefundSetup(subscription: Subscription, anchor: number) {
|
|
405
|
+
export async function getSubscriptionRefundSetup(subscription: Subscription, anchor: number, currencyId?: string) {
|
|
406
406
|
const items = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
407
407
|
const expanded = await Price.expand(items.map((x) => x.toJSON()));
|
|
408
|
-
const setup = getSubscriptionCreateSetup(expanded, subscription.currency_id, 0);
|
|
408
|
+
const setup = getSubscriptionCreateSetup(expanded, currencyId || subscription.currency_id, 0);
|
|
409
409
|
return createProration(subscription, setup, anchor);
|
|
410
410
|
}
|
|
411
411
|
|
|
@@ -452,7 +452,7 @@ export async function getUpcomingInvoiceAmount(subscriptionId: string) {
|
|
|
452
452
|
throw new Error('Subscription not found');
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
-
if (subscription.isActive() === false) {
|
|
455
|
+
if (subscription.isActive() === false && subscription.status !== 'past_due') {
|
|
456
456
|
throw new Error(`Subscription not active for ${subscriptionId}, so usage check is skipped`);
|
|
457
457
|
}
|
|
458
458
|
|
|
@@ -726,10 +726,17 @@ export async function getRemainingStakes(subscriptionIds: string[], subscription
|
|
|
726
726
|
export async function getSubscriptionStakeSlashSetup(
|
|
727
727
|
subscription: Subscription,
|
|
728
728
|
address: string,
|
|
729
|
-
paymentMethod: PaymentMethod
|
|
729
|
+
paymentMethod: PaymentMethod,
|
|
730
|
+
paymentCurrencyId?: string
|
|
730
731
|
) {
|
|
731
732
|
const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
|
|
732
|
-
const result = await getSubscriptionRemainingStakeSetup(
|
|
733
|
+
const result = await getSubscriptionRemainingStakeSetup(
|
|
734
|
+
subscription,
|
|
735
|
+
address,
|
|
736
|
+
paymentMethod,
|
|
737
|
+
'slash',
|
|
738
|
+
paymentCurrencyId
|
|
739
|
+
);
|
|
733
740
|
return {
|
|
734
741
|
...result,
|
|
735
742
|
lastInvoice,
|
|
@@ -739,10 +746,17 @@ export async function getSubscriptionStakeSlashSetup(
|
|
|
739
746
|
export async function getSubscriptionStakeReturnSetup(
|
|
740
747
|
subscription: Subscription,
|
|
741
748
|
address: string,
|
|
742
|
-
paymentMethod: PaymentMethod
|
|
749
|
+
paymentMethod: PaymentMethod,
|
|
750
|
+
paymentCurrencyId?: string
|
|
743
751
|
) {
|
|
744
752
|
const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
|
|
745
|
-
const result = await getSubscriptionRemainingStakeSetup(
|
|
753
|
+
const result = await getSubscriptionRemainingStakeSetup(
|
|
754
|
+
subscription,
|
|
755
|
+
address,
|
|
756
|
+
paymentMethod,
|
|
757
|
+
'return',
|
|
758
|
+
paymentCurrencyId
|
|
759
|
+
);
|
|
746
760
|
return {
|
|
747
761
|
...result,
|
|
748
762
|
lastInvoice,
|
|
@@ -753,9 +767,10 @@ export async function getSubscriptionRemainingStakeSetup(
|
|
|
753
767
|
subscription: Subscription,
|
|
754
768
|
address: string,
|
|
755
769
|
paymentMethod: PaymentMethod,
|
|
756
|
-
action: 'return' | 'slash' = 'return'
|
|
770
|
+
action: 'return' | 'slash' = 'return',
|
|
771
|
+
paymentCurrencyId?: string
|
|
757
772
|
) {
|
|
758
|
-
const currency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
773
|
+
const currency = await PaymentCurrency.findByPk(paymentCurrencyId || subscription.currency_id);
|
|
759
774
|
if (!currency) {
|
|
760
775
|
return {
|
|
761
776
|
total: '0',
|
|
@@ -782,12 +797,12 @@ export async function getSubscriptionRemainingStakeSetup(
|
|
|
782
797
|
}
|
|
783
798
|
const [summary] = await Invoice.getUncollectibleAmount({
|
|
784
799
|
subscriptionId: subscription.id,
|
|
785
|
-
currencyId:
|
|
800
|
+
currencyId: currency.id,
|
|
786
801
|
customerId: subscription.customer_id,
|
|
787
802
|
});
|
|
788
803
|
const subscriptionInitStakes = JSON.parse(state.data?.value || '{}');
|
|
789
804
|
const initStake = subscriptionInitStakes[subscription.id];
|
|
790
|
-
const uncollectibleAmountBN = new BN(summary?.[
|
|
805
|
+
const uncollectibleAmountBN = new BN(summary?.[currency.id] || '0');
|
|
791
806
|
if (state.nonce) {
|
|
792
807
|
const returnStake = total.sub(uncollectibleAmountBN);
|
|
793
808
|
return {
|
|
@@ -909,11 +924,15 @@ export async function getSubscriptionStakeCancellation(
|
|
|
909
924
|
return cancellation;
|
|
910
925
|
}
|
|
911
926
|
|
|
912
|
-
export async function getSubscriptionStakeAmountSetup(
|
|
927
|
+
export async function getSubscriptionStakeAmountSetup(
|
|
928
|
+
subscription: Subscription,
|
|
929
|
+
paymentMethod: PaymentMethod,
|
|
930
|
+
stakingTxHash?: string
|
|
931
|
+
) {
|
|
913
932
|
if (paymentMethod.type !== 'arcblock') {
|
|
914
933
|
return null;
|
|
915
934
|
}
|
|
916
|
-
const txHash = subscription?.payment_details?.arcblock?.staking?.tx_hash;
|
|
935
|
+
const txHash = stakingTxHash || subscription?.payment_details?.arcblock?.staking?.tx_hash;
|
|
917
936
|
if (!txHash) {
|
|
918
937
|
return null;
|
|
919
938
|
}
|
|
@@ -587,11 +587,13 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
587
587
|
}
|
|
588
588
|
const client = paymentMethod.getOcapClient();
|
|
589
589
|
|
|
590
|
+
const payer = paymentSettings?.payment_method_options.arcblock?.payer as string;
|
|
591
|
+
|
|
590
592
|
// check balance before capture with transaction
|
|
591
593
|
result = await isDelegationSufficientForPayment({
|
|
592
594
|
paymentMethod,
|
|
593
595
|
paymentCurrency,
|
|
594
|
-
userDid: customer.did,
|
|
596
|
+
userDid: payer || customer.did,
|
|
595
597
|
amount: paymentIntent.amount,
|
|
596
598
|
});
|
|
597
599
|
if (result.sufficient === false) {
|
package/api/src/queues/refund.ts
CHANGED
|
@@ -126,10 +126,14 @@ const handleRefundJob = async (
|
|
|
126
126
|
// try refund transfer and reschedule on error
|
|
127
127
|
logger.info('Refund transfer attempt', { id: refund.id, attempt: refund.attempt_count });
|
|
128
128
|
let result;
|
|
129
|
+
const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
|
|
130
|
+
if (!paymentIntent) {
|
|
131
|
+
throw new Error('PaymentIntent not found');
|
|
132
|
+
}
|
|
133
|
+
|
|
129
134
|
try {
|
|
130
135
|
if (paymentMethod.type === 'arcblock') {
|
|
131
136
|
const client = paymentMethod.getOcapClient();
|
|
132
|
-
|
|
133
137
|
// check balance before transfer with transaction
|
|
134
138
|
result = await isBalanceSufficientForRefund({ paymentMethod, paymentCurrency, amount: refund.amount });
|
|
135
139
|
if (result.sufficient === false) {
|
|
@@ -137,11 +141,12 @@ const handleRefundJob = async (
|
|
|
137
141
|
throw new CustomError(result.reason, 'app balance not sufficient for this refund');
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
const payer = paymentIntent?.payment_details?.arcblock?.payer;
|
|
140
145
|
// do the transfer
|
|
141
146
|
const signed = await client.signTransferV2Tx({
|
|
142
147
|
tx: {
|
|
143
148
|
itx: {
|
|
144
|
-
to: customer.did,
|
|
149
|
+
to: payer || customer.did,
|
|
145
150
|
value: '0',
|
|
146
151
|
assets: [],
|
|
147
152
|
tokens: [{ address: paymentCurrency.contract, value: refund.amount }],
|
|
@@ -192,7 +197,6 @@ const handleRefundJob = async (
|
|
|
192
197
|
|
|
193
198
|
// do the capture
|
|
194
199
|
const client = paymentMethod.getEvmClient();
|
|
195
|
-
const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
|
|
196
200
|
const payer = paymentIntent!.payment_details?.ethereum?.payer as string;
|
|
197
201
|
const receipt = await sendErc20ToUser(client, paymentCurrency.contract, payer, refund.amount);
|
|
198
202
|
logger.info('refund transfer done', { id: refund.id, txHash: receipt.hash });
|
|
@@ -216,10 +220,6 @@ const handleRefundJob = async (
|
|
|
216
220
|
if (!refund.payment_intent_id) {
|
|
217
221
|
throw new Error('payment intent id not found');
|
|
218
222
|
}
|
|
219
|
-
const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
|
|
220
|
-
if (!paymentIntent) {
|
|
221
|
-
throw new Error('PaymentIntent not found');
|
|
222
|
-
}
|
|
223
223
|
const stripePaymentIntentId = paymentIntent?.payment_details?.stripe?.payment_intent_id;
|
|
224
224
|
if (!stripePaymentIntentId) {
|
|
225
225
|
throw new Error('paymentIntent should have stripe payment intent id');
|
|
@@ -315,7 +315,8 @@ const handleStakeReturnJob = async (
|
|
|
315
315
|
}
|
|
316
316
|
const client = paymentMethod.getOcapClient();
|
|
317
317
|
const subscription = await Subscription.findByPk(refund.subscription_id);
|
|
318
|
-
const address =
|
|
318
|
+
const address =
|
|
319
|
+
arcblockDetail?.staking?.address || (await getSubscriptionStakeAddress(subscription!, customer.did));
|
|
319
320
|
const stakeEnough = await checkRemainingStake(paymentMethod, paymentCurrency, address, refund.amount);
|
|
320
321
|
if (!stakeEnough.enough) {
|
|
321
322
|
logger.warn('Stake return aborted because stake is not enough ', {
|