payment-kit 1.16.17 → 1.16.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/crons/index.ts +1 -1
- package/api/src/hooks/pre-start.ts +2 -0
- package/api/src/index.ts +2 -0
- package/api/src/integrations/arcblock/stake.ts +7 -1
- package/api/src/integrations/stripe/resource.ts +1 -1
- package/api/src/libs/env.ts +12 -0
- package/api/src/libs/event.ts +8 -0
- package/api/src/libs/invoice.ts +585 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -2
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +6 -2
- package/api/src/libs/notification/template/subscription.overdraft-protection.exhausted.ts +139 -0
- package/api/src/libs/overdraft-protection.ts +85 -0
- package/api/src/libs/payment.ts +1 -65
- package/api/src/libs/queue/index.ts +0 -1
- package/api/src/libs/subscription.ts +532 -2
- package/api/src/libs/util.ts +4 -0
- package/api/src/locales/en.ts +5 -0
- package/api/src/locales/zh.ts +5 -0
- package/api/src/queues/event.ts +3 -2
- package/api/src/queues/invoice.ts +28 -3
- package/api/src/queues/notification.ts +25 -3
- package/api/src/queues/payment.ts +154 -3
- package/api/src/queues/refund.ts +2 -2
- package/api/src/queues/subscription.ts +215 -4
- package/api/src/queues/webhook.ts +1 -0
- package/api/src/routes/connect/change-payment.ts +1 -1
- package/api/src/routes/connect/change-plan.ts +1 -1
- package/api/src/routes/connect/overdraft-protection.ts +120 -0
- package/api/src/routes/connect/recharge.ts +2 -1
- package/api/src/routes/connect/setup.ts +1 -1
- package/api/src/routes/connect/shared.ts +117 -350
- package/api/src/routes/connect/subscribe.ts +1 -1
- package/api/src/routes/customers.ts +2 -2
- package/api/src/routes/invoices.ts +9 -4
- package/api/src/routes/subscriptions.ts +172 -2
- package/api/src/store/migrate.ts +9 -10
- package/api/src/store/migrations/20240905-index.ts +95 -60
- package/api/src/store/migrations/20241203-overdraft-protection.ts +25 -0
- package/api/src/store/migrations/20241216-update-overdraft-protection.ts +30 -0
- package/api/src/store/models/customer.ts +2 -2
- package/api/src/store/models/invoice.ts +7 -0
- package/api/src/store/models/lock.ts +7 -0
- package/api/src/store/models/subscription.ts +15 -0
- package/api/src/store/sequelize.ts +6 -1
- package/blocklet.yml +1 -1
- package/package.json +23 -23
- package/src/components/customer/overdraft-protection.tsx +367 -0
- package/src/components/event/list.tsx +3 -4
- package/src/components/subscription/actions/cancel.tsx +3 -0
- package/src/components/subscription/portal/actions.tsx +324 -77
- package/src/components/uploader.tsx +31 -26
- package/src/env.d.ts +1 -0
- package/src/hooks/subscription.ts +30 -0
- package/src/libs/env.ts +4 -0
- package/src/locales/en.tsx +41 -0
- package/src/locales/zh.tsx +37 -0
- package/src/pages/admin/billing/invoices/detail.tsx +16 -15
- package/src/pages/customer/index.tsx +7 -2
- package/src/pages/customer/invoice/detail.tsx +29 -5
- package/src/pages/customer/invoice/past-due.tsx +18 -4
- package/src/pages/customer/recharge.tsx +2 -4
- package/src/pages/customer/subscription/change-payment.tsx +7 -1
- package/src/pages/customer/subscription/detail.tsx +69 -51
- package/tsconfig.json +0 -5
- package/api/tests/libs/payment.spec.ts +0 -168
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* eslint-disable prettier/prettier */
|
|
2
|
+
import { translate } from '../../../locales';
|
|
3
|
+
import { Customer, Subscription } from '../../../store/models';
|
|
4
|
+
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
5
|
+
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
6
|
+
import { getCustomerSubscriptionPageUrl, isSubscriptionOverdraftProtectionEnabled } from '../../subscription';
|
|
7
|
+
import { getMainProductName } from '../../product';
|
|
8
|
+
|
|
9
|
+
export interface OverdraftProtectionExhaustedEmailTemplateOptions {
|
|
10
|
+
subscriptionId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface OverdraftProtectionExhaustedEmailTemplateContext {
|
|
14
|
+
locale: string;
|
|
15
|
+
userDid: string;
|
|
16
|
+
viewSubscriptionLink: string;
|
|
17
|
+
productName: string;
|
|
18
|
+
subscriptionId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class OverdraftProtectionExhaustedEmailTemplate
|
|
22
|
+
implements BaseEmailTemplate<OverdraftProtectionExhaustedEmailTemplateContext> {
|
|
23
|
+
options: OverdraftProtectionExhaustedEmailTemplateOptions;
|
|
24
|
+
|
|
25
|
+
constructor(options: OverdraftProtectionExhaustedEmailTemplateOptions) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getContext(): Promise<OverdraftProtectionExhaustedEmailTemplateContext> {
|
|
30
|
+
const { subscriptionId } = this.options;
|
|
31
|
+
|
|
32
|
+
const subscription: Subscription | null = await Subscription.findByPk(subscriptionId);
|
|
33
|
+
if (!subscription) {
|
|
34
|
+
throw new Error(`Subscription not found: ${subscriptionId}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const customer: Customer | null = await Customer.findByPk(subscription.customer_id);
|
|
38
|
+
if (!customer) {
|
|
39
|
+
throw new Error(`Customer not found: ${subscription.customer_id}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!subscription.overdraft_protection?.enabled) {
|
|
43
|
+
throw new Error(`Overdraft protection not enabled for subscription: ${subscriptionId}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { enabled } = await isSubscriptionOverdraftProtectionEnabled(subscription);
|
|
47
|
+
if (enabled) {
|
|
48
|
+
throw new Error(`Overdraft protection enabled for subscription: ${subscriptionId}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const productName = await getMainProductName(subscription.id);
|
|
52
|
+
const userDid = customer.did;
|
|
53
|
+
const locale = await getUserLocale(userDid);
|
|
54
|
+
const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
|
|
55
|
+
subscriptionId: subscription.id,
|
|
56
|
+
locale,
|
|
57
|
+
userDid,
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
userDid,
|
|
61
|
+
locale,
|
|
62
|
+
viewSubscriptionLink,
|
|
63
|
+
productName,
|
|
64
|
+
subscriptionId,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getTemplate(): Promise<BaseEmailTemplateType> {
|
|
69
|
+
const { locale, userDid, viewSubscriptionLink, productName, subscriptionId } = await this.getContext();
|
|
70
|
+
|
|
71
|
+
const template: BaseEmailTemplateType = {
|
|
72
|
+
title: translate('notification.overdraftProtectionExhausted.title', locale),
|
|
73
|
+
body: translate('notification.overdraftProtectionExhausted.body', locale, {
|
|
74
|
+
productName
|
|
75
|
+
}),
|
|
76
|
+
attachments: [
|
|
77
|
+
{
|
|
78
|
+
type: 'section',
|
|
79
|
+
fields: [
|
|
80
|
+
{
|
|
81
|
+
type: 'text',
|
|
82
|
+
data: {
|
|
83
|
+
type: 'plain',
|
|
84
|
+
color: '#9397A1',
|
|
85
|
+
text: translate('notification.common.account', locale),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: 'text',
|
|
90
|
+
data: {
|
|
91
|
+
type: 'plain',
|
|
92
|
+
text: userDid,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: 'text',
|
|
97
|
+
data: {
|
|
98
|
+
type: 'plain',
|
|
99
|
+
color: '#9397A1',
|
|
100
|
+
text: translate('notification.common.product', locale),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
type: 'text',
|
|
105
|
+
data: {
|
|
106
|
+
type: 'plain',
|
|
107
|
+
text: productName,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'text',
|
|
112
|
+
data: {
|
|
113
|
+
type: 'plain',
|
|
114
|
+
color: '#9397A1',
|
|
115
|
+
text: translate('notification.common.subscriptionId', locale),
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
type: 'text',
|
|
120
|
+
data: {
|
|
121
|
+
type: 'plain',
|
|
122
|
+
text: subscriptionId,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
].filter(Boolean),
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
actions: [
|
|
129
|
+
{
|
|
130
|
+
name: translate('notification.common.viewSubscription', locale),
|
|
131
|
+
title: translate('notification.common.viewSubscription', locale),
|
|
132
|
+
link: viewSubscriptionLink,
|
|
133
|
+
},
|
|
134
|
+
].filter(Boolean),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return template;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createProductAndPrices } from '../routes/products';
|
|
2
|
+
import { PaymentCurrency, PaymentMethod, Price, Product } from '../store/models';
|
|
3
|
+
import logger from './logger';
|
|
4
|
+
|
|
5
|
+
export const overdraftProtectionKey = 'overdraft-protection';
|
|
6
|
+
|
|
7
|
+
export async function ensureOverdraftProtectionPrice(livemode = true) {
|
|
8
|
+
try {
|
|
9
|
+
const exist = await Price.findOne({ where: { lookup_key: overdraftProtectionKey, livemode } });
|
|
10
|
+
if (exist) {
|
|
11
|
+
const product = await Product.findByPk(exist.product_id);
|
|
12
|
+
return {
|
|
13
|
+
product,
|
|
14
|
+
price: exist,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const baseCurrency = await PaymentCurrency.findOne({ where: { is_base_currency: true, livemode } });
|
|
18
|
+
const currencies = (await PaymentCurrency.findAll({
|
|
19
|
+
where: { livemode },
|
|
20
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
21
|
+
})) as (PaymentCurrency & { payment_method: PaymentMethod })[];
|
|
22
|
+
|
|
23
|
+
const currencyOptions = currencies
|
|
24
|
+
.filter((currency) => currency.payment_method?.type === 'arcblock')
|
|
25
|
+
.map((currency) => ({ currency_id: currency.id, unit_amount: '1' }));
|
|
26
|
+
|
|
27
|
+
const result = await createProductAndPrices({
|
|
28
|
+
type: 'service',
|
|
29
|
+
name: 'Overdraft Protection',
|
|
30
|
+
description:
|
|
31
|
+
'If you purchase this service, the overdue subscription will be protected and not be ended due to overdue',
|
|
32
|
+
images: [],
|
|
33
|
+
statement_descriptor: '',
|
|
34
|
+
unit_label: '',
|
|
35
|
+
features: [],
|
|
36
|
+
livemode,
|
|
37
|
+
prices: [
|
|
38
|
+
{
|
|
39
|
+
livemode,
|
|
40
|
+
locked: true,
|
|
41
|
+
model: 'standard',
|
|
42
|
+
billing_scheme: '',
|
|
43
|
+
currency_id: baseCurrency?.id,
|
|
44
|
+
nickname: '',
|
|
45
|
+
type: 'one_time',
|
|
46
|
+
unit_amount: '1',
|
|
47
|
+
lookup_key: overdraftProtectionKey,
|
|
48
|
+
recurring: {},
|
|
49
|
+
transform_quantity: { divide_by: 1, round: 'up' },
|
|
50
|
+
tiers: [],
|
|
51
|
+
metadata: [],
|
|
52
|
+
custom_unit_amount: null,
|
|
53
|
+
currency_options: currencyOptions,
|
|
54
|
+
tiers_mode: null,
|
|
55
|
+
quantity_available: 0,
|
|
56
|
+
quantity_limit_per_checkout: 0,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
metadata: [],
|
|
60
|
+
});
|
|
61
|
+
logger.info('overdraft protection price created', {
|
|
62
|
+
productId: result.id,
|
|
63
|
+
priceId: result.prices?.[0]?.id,
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
product: result,
|
|
67
|
+
price: result.prices?.[0],
|
|
68
|
+
};
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logger.error('create overdraft protection price error', err);
|
|
71
|
+
return {
|
|
72
|
+
product: null,
|
|
73
|
+
price: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function ensureCreateOverdraftProtectionPrices() {
|
|
79
|
+
try {
|
|
80
|
+
await ensureOverdraftProtectionPrice(true);
|
|
81
|
+
await ensureOverdraftProtectionPrice(false);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logger.error('ensure overdraft protection prices error', err);
|
|
84
|
+
}
|
|
85
|
+
}
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -10,23 +10,11 @@ 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 {
|
|
14
|
-
Invoice,
|
|
15
|
-
PaymentCurrency,
|
|
16
|
-
PaymentIntent,
|
|
17
|
-
PaymentMethod,
|
|
18
|
-
SubscriptionItem,
|
|
19
|
-
TCustomer,
|
|
20
|
-
TLineItemExpanded,
|
|
21
|
-
Subscription,
|
|
22
|
-
Price,
|
|
23
|
-
UsageRecord,
|
|
24
|
-
} from '../store/models';
|
|
13
|
+
import { Invoice, PaymentCurrency, PaymentIntent, PaymentMethod, TCustomer, TLineItemExpanded } from '../store/models';
|
|
25
14
|
import type { TPaymentCurrency } from '../store/models/payment-currency';
|
|
26
15
|
import { blocklet, ethWallet, wallet } from './auth';
|
|
27
16
|
import logger from './logger';
|
|
28
17
|
import { OCAP_PAYMENT_TX_TYPE } from './util';
|
|
29
|
-
import { getSubscriptionCycleAmount, getSubscriptionCycleSetup } from './subscription';
|
|
30
18
|
|
|
31
19
|
export interface SufficientForPaymentResult {
|
|
32
20
|
sufficient: boolean;
|
|
@@ -356,55 +344,3 @@ export async function isBalanceSufficientForRefund(args: {
|
|
|
356
344
|
|
|
357
345
|
throw new Error(`isBalanceSufficientForRefund: Payment method ${paymentMethod.type} not supported`);
|
|
358
346
|
}
|
|
359
|
-
|
|
360
|
-
export async function getPaymentAmountForCycleSubscription(
|
|
361
|
-
subscription: Subscription,
|
|
362
|
-
paymentCurrency: PaymentCurrency
|
|
363
|
-
) {
|
|
364
|
-
const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
365
|
-
if (subscriptionItems.length === 0) {
|
|
366
|
-
logger.info('subscription items not found in getPaymentAmountForCycleSubscription', {
|
|
367
|
-
subscription: subscription.id,
|
|
368
|
-
});
|
|
369
|
-
return 0;
|
|
370
|
-
}
|
|
371
|
-
let expandedItems = await Price.expand(
|
|
372
|
-
subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
|
|
373
|
-
{ product: true }
|
|
374
|
-
);
|
|
375
|
-
if (expandedItems.length === 0) {
|
|
376
|
-
logger.info('expanded items not found in getPaymentAmountForCycleSubscription', {
|
|
377
|
-
subscription: subscription.id,
|
|
378
|
-
});
|
|
379
|
-
return 0;
|
|
380
|
-
}
|
|
381
|
-
const previousPeriodEnd =
|
|
382
|
-
subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
|
|
383
|
-
const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
|
|
384
|
-
// get usage summaries for this billing cycle
|
|
385
|
-
expandedItems = await Promise.all(
|
|
386
|
-
expandedItems.map(async (x: any) => {
|
|
387
|
-
// For metered billing, we need to get usage summary for this billing cycle
|
|
388
|
-
// @link https://stripe.com/docs/products-prices/pricing-models#usage-types
|
|
389
|
-
if (x.price.recurring?.usage_type === 'metered') {
|
|
390
|
-
const rawQuantity = await UsageRecord.getSummary({
|
|
391
|
-
id: x.id,
|
|
392
|
-
start: setup.period.start - setup.cycle / 1000,
|
|
393
|
-
end: setup.period.end - setup.cycle / 1000,
|
|
394
|
-
method: x.price.recurring?.aggregate_usage,
|
|
395
|
-
dryRun: true,
|
|
396
|
-
});
|
|
397
|
-
x.quantity = x.price.transformQuantity(rawQuantity);
|
|
398
|
-
// record raw quantity in metadata
|
|
399
|
-
x.metadata = x.metadata || {};
|
|
400
|
-
x.metadata.quantity = rawQuantity;
|
|
401
|
-
}
|
|
402
|
-
return x;
|
|
403
|
-
})
|
|
404
|
-
);
|
|
405
|
-
if (expandedItems.length > 0) {
|
|
406
|
-
const amount = getSubscriptionCycleAmount(expandedItems, paymentCurrency.id);
|
|
407
|
-
return +fromUnitToToken(amount?.total || '0', paymentCurrency.decimal);
|
|
408
|
-
}
|
|
409
|
-
return 0;
|
|
410
|
-
}
|
|
@@ -147,7 +147,6 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
147
147
|
})
|
|
148
148
|
.catch((err) => {
|
|
149
149
|
console.error(err);
|
|
150
|
-
logger.error('Can not add scheduled job to store', { jobId, job, attrs, error: err });
|
|
151
150
|
});
|
|
152
151
|
|
|
153
152
|
// @ts-ignore
|