payment-kit 1.13.73 → 1.13.74
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/{schedule → crons}/base.ts +1 -1
- package/api/src/index.ts +7 -7
- package/api/src/integrations/stripe/handlers/customer.ts +24 -0
- package/api/src/integrations/stripe/handlers/index.ts +4 -0
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -1
- package/api/src/integrations/stripe/resource.ts +1 -1
- package/api/src/libs/audit.ts +34 -28
- package/api/src/libs/payment.ts +26 -0
- package/api/src/libs/queue/index.ts +18 -1
- package/api/src/libs/queue/store.ts +6 -5
- package/api/src/libs/session.ts +13 -12
- package/api/src/libs/subscription.ts +26 -0
- package/api/src/libs/util.ts +5 -1
- package/api/src/{jobs → queues}/checkout-session.ts +11 -0
- package/api/src/{jobs → queues}/invoice.ts +15 -6
- package/api/src/{jobs → queues}/payment.ts +182 -30
- package/api/src/{jobs → queues}/subscription.ts +36 -104
- package/api/src/{jobs → queues}/webhook.ts +2 -0
- package/api/src/routes/checkout-sessions.ts +68 -19
- package/api/src/routes/connect/collect.ts +2 -2
- package/api/src/routes/connect/pay.ts +1 -1
- package/api/src/routes/connect/setup.ts +2 -2
- package/api/src/routes/connect/shared.ts +94 -45
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/pricing-table.ts +2 -0
- package/api/src/routes/subscription-items.ts +1 -1
- package/api/src/routes/subscriptions.ts +434 -13
- package/api/src/store/migrate.ts +0 -1
- package/api/src/store/migrations/20231204-subupdate.ts +50 -0
- package/api/src/store/models/checkout-session.ts +4 -0
- package/api/src/store/models/customer.ts +52 -15
- package/api/src/store/models/invoice-item.ts +6 -1
- package/api/src/store/models/invoice.ts +41 -22
- package/api/src/store/models/payment-intent.ts +4 -0
- package/api/src/store/models/setup-intent.ts +4 -0
- package/api/src/store/models/subscription-item.ts +0 -4
- package/api/src/store/models/subscription.ts +77 -44
- package/api/src/store/models/types.ts +1 -0
- package/api/src/store/sequelize.ts +6 -0
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/jest.config.js +14 -0
- package/package.json +24 -19
- package/src/components/blockchain/tx.tsx +20 -11
- package/src/components/checkout/form/index.tsx +1 -1
- package/src/components/invoice/table.tsx +58 -19
- package/src/components/layout/admin.tsx +17 -5
- package/src/components/portal/invoice/list.tsx +12 -8
- package/src/components/portal/subscription/list.tsx +114 -77
- package/src/components/subscription/status.tsx +21 -19
- package/src/global.css +4 -0
- package/src/locales/en.tsx +14 -1
- package/src/locales/zh.tsx +14 -0
- package/src/pages/admin/customers/customers/detail.tsx +47 -3
- package/src/pages/admin/overview.tsx +21 -1
- package/src/pages/admin/payments/intents/detail.tsx +12 -3
- package/src/pages/customer/invoice.tsx +15 -1
- package/src/pages/customer/subscription/index.tsx +9 -2
- package/tests/api/libs/subscription.spec.ts +45 -0
- /package/api/src/{schedule → crons}/index.ts +0 -0
- /package/api/src/{schedule → crons}/interface/diff.ts +0 -0
- /package/api/src/{schedule → crons}/subscription-trail-will-end.ts +0 -0
- /package/api/src/{schedule → crons}/subscription-will-renew.ts +0 -0
- /package/api/src/{jobs → queues}/event.ts +0 -0
- /package/api/src/{jobs → queues}/notification.ts +0 -0
|
@@ -2,10 +2,10 @@ import dayjs from 'dayjs';
|
|
|
2
2
|
import { clone } from 'lodash';
|
|
3
3
|
import pAll from 'p-all';
|
|
4
4
|
|
|
5
|
-
import { NotificationQueueJob, notificationQueue } from '../jobs/notification';
|
|
6
5
|
import { notificationCronConcurrency } from '../libs/env';
|
|
7
6
|
import logger from '../libs/logger';
|
|
8
7
|
import type { SubscriptionTrialWillEndEmailTemplateOptions } from '../libs/notification/template/subscription-trial-will-end';
|
|
8
|
+
import { NotificationQueueJob, notificationQueue } from '../queues/notification';
|
|
9
9
|
import type { Subscription } from '../store/models';
|
|
10
10
|
import type { Diff } from './interface/diff';
|
|
11
11
|
|
package/api/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import 'express-async-errors';
|
|
2
2
|
|
|
3
|
-
import './
|
|
3
|
+
import './crons';
|
|
4
4
|
|
|
5
5
|
import path from 'path';
|
|
6
6
|
|
|
@@ -13,15 +13,15 @@ import morgan from 'morgan';
|
|
|
13
13
|
|
|
14
14
|
import { ensureStakedForGas } from './integrations/blockchain/stake';
|
|
15
15
|
import { ensureWebhookRegistered } from './integrations/stripe/setup';
|
|
16
|
-
import { startCheckoutSessionQueue } from './jobs/checkout-session';
|
|
17
|
-
import { startEventQueue } from './jobs/event';
|
|
18
|
-
import { startInvoiceQueue } from './jobs/invoice';
|
|
19
|
-
import { startNotificationQueue } from './jobs/notification';
|
|
20
|
-
import { startPaymentQueue } from './jobs/payment';
|
|
21
|
-
import { startSubscriptionQueue } from './jobs/subscription';
|
|
22
16
|
import { handlers } from './libs/auth';
|
|
23
17
|
import logger, { accessLogStream } from './libs/logger';
|
|
24
18
|
import { ensureI18n } from './libs/middleware';
|
|
19
|
+
import { startCheckoutSessionQueue } from './queues/checkout-session';
|
|
20
|
+
import { startEventQueue } from './queues/event';
|
|
21
|
+
import { startInvoiceQueue } from './queues/invoice';
|
|
22
|
+
import { startNotificationQueue } from './queues/notification';
|
|
23
|
+
import { startPaymentQueue } from './queues/payment';
|
|
24
|
+
import { startSubscriptionQueue } from './queues/subscription';
|
|
25
25
|
import routes from './routes';
|
|
26
26
|
import collectHandlers from './routes/connect/collect';
|
|
27
27
|
import payHandlers from './routes/connect/pay';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import logger from '../../../libs/logger';
|
|
2
|
+
import { Customer, TEventExpanded } from '../../../store/models';
|
|
3
|
+
|
|
4
|
+
export async function handleCustomerEvent(event: TEventExpanded) {
|
|
5
|
+
const localId = event.data.object.metadata?.id;
|
|
6
|
+
if (!localId) {
|
|
7
|
+
logger.warn('local customer id not found in strip event', { id: event.id, type: event.type });
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const customer = await Customer.findByPk(localId);
|
|
12
|
+
if (!customer) {
|
|
13
|
+
logger.warn('local customer not found', { localId });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
logger.info('received customer event', { id: event.id, type: event.type, localId });
|
|
18
|
+
|
|
19
|
+
if (event.type === 'customer.updated') {
|
|
20
|
+
if (event.data.object.balance !== customer.balance) {
|
|
21
|
+
await customer.update({ balance: event.data.object.balance });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type Stripe from 'stripe';
|
|
2
2
|
|
|
3
|
+
import { handleCustomerEvent } from './customer';
|
|
3
4
|
import { handleInvoiceEvent } from './invoice';
|
|
4
5
|
import { handlePaymentIntentEvent } from './payment-intent';
|
|
5
6
|
import { handleSetupIntentEvent } from './setup-intent';
|
|
@@ -32,6 +33,9 @@ export default function handleStripeEvent(event: any, client: Stripe) {
|
|
|
32
33
|
case 'customer.subscription.updated':
|
|
33
34
|
return handleSubscriptionEvent(event, client);
|
|
34
35
|
|
|
36
|
+
case 'customer.updated':
|
|
37
|
+
return handleCustomerEvent(event);
|
|
38
|
+
|
|
35
39
|
case 'invoice.created':
|
|
36
40
|
case 'invoice.deleted':
|
|
37
41
|
case 'invoice.finalization_failed':
|
|
@@ -4,9 +4,9 @@ import pick from 'lodash/pick';
|
|
|
4
4
|
import pWaitFor from 'p-wait-for';
|
|
5
5
|
import type Stripe from 'stripe';
|
|
6
6
|
|
|
7
|
-
import { handlePaymentSucceed } from '../../../jobs/payment';
|
|
8
7
|
import dayjs from '../../../libs/dayjs';
|
|
9
8
|
import logger from '../../../libs/logger';
|
|
9
|
+
import { handlePaymentSucceed } from '../../../queues/payment';
|
|
10
10
|
import { Invoice, PaymentIntent, PaymentMethod, TEventExpanded } from '../../../store/models';
|
|
11
11
|
import { handleStripeInvoiceCreated } from './invoice';
|
|
12
12
|
|
|
@@ -90,7 +90,7 @@ export async function ensureStripePrice(internal: Price, method: PaymentMethod,
|
|
|
90
90
|
if (internal.custom_unit_amount) {
|
|
91
91
|
attrs.custom_unit_amount = internal.custom_unit_amount;
|
|
92
92
|
} else {
|
|
93
|
-
attrs.unit_amount = Number(getPriceUintAmountByCurrency(internal, currency));
|
|
93
|
+
attrs.unit_amount = Number(getPriceUintAmountByCurrency(internal, currency.id));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// create stripe price
|
package/api/src/libs/audit.ts
CHANGED
|
@@ -16,21 +16,24 @@ export async function createEvent(scope: string, type: LiteralUnion<EventType, s
|
|
|
16
16
|
data.previous_attributes = pick(model._previousDataValues, options.fields);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const event = await Event.create(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
const event = await Event.create(
|
|
20
|
+
{
|
|
21
|
+
type,
|
|
22
|
+
api_version: API_VERSION,
|
|
23
|
+
livemode: !!model.livemode,
|
|
24
|
+
object_id: model.id,
|
|
25
|
+
object_type: scope,
|
|
26
|
+
data,
|
|
27
|
+
request: {
|
|
28
|
+
// FIXME:
|
|
29
|
+
id: '',
|
|
30
|
+
idempotency_key: '',
|
|
31
|
+
},
|
|
32
|
+
metadata: {},
|
|
33
|
+
pending_webhooks: 99, // force all events goto the event queue
|
|
30
34
|
},
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
});
|
|
35
|
+
{ transaction: null }
|
|
36
|
+
);
|
|
34
37
|
|
|
35
38
|
events.emit('event.created', { id: event.id });
|
|
36
39
|
events.emit(event.type, data.object);
|
|
@@ -58,21 +61,24 @@ export async function createStatusEvent(
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
const suffix = config[data.object.status];
|
|
61
|
-
const event = await Event.create(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
const event = await Event.create(
|
|
65
|
+
{
|
|
66
|
+
type: [prefix, suffix].join('.'),
|
|
67
|
+
api_version: API_VERSION,
|
|
68
|
+
livemode: !!model.livemode,
|
|
69
|
+
object_id: model.id,
|
|
70
|
+
object_type: scope,
|
|
71
|
+
data,
|
|
72
|
+
request: {
|
|
73
|
+
// FIXME:
|
|
74
|
+
id: '',
|
|
75
|
+
idempotency_key: '',
|
|
76
|
+
},
|
|
77
|
+
metadata: {},
|
|
78
|
+
pending_webhooks: 99, // force all events goto the event queue
|
|
72
79
|
},
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
80
|
+
{ transaction: null }
|
|
81
|
+
);
|
|
76
82
|
|
|
77
83
|
events.emit('event.created', { id: event.id });
|
|
78
84
|
events.emit(event.type, data.object);
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
PaymentCurrency,
|
|
15
15
|
PaymentIntent,
|
|
16
16
|
PaymentMethod,
|
|
17
|
+
TCustomer,
|
|
17
18
|
TLineItemExpanded,
|
|
18
19
|
} from '../store/models';
|
|
19
20
|
import type { TPaymentCurrency } from '../store/models/payment-currency';
|
|
@@ -112,6 +113,31 @@ export async function isDelegationSufficientForPayment(args: {
|
|
|
112
113
|
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
export function isBalanceSufficientForPayment(args: {
|
|
117
|
+
paymentMethod: PaymentMethod;
|
|
118
|
+
paymentCurrency: TPaymentCurrency;
|
|
119
|
+
customer: TCustomer;
|
|
120
|
+
amount: string;
|
|
121
|
+
}): { sufficient: boolean; balance: string; reason?: string } {
|
|
122
|
+
const { paymentMethod, paymentCurrency, customer, amount } = args;
|
|
123
|
+
if (paymentMethod.type === 'stripe') {
|
|
124
|
+
return { sufficient: false, balance: customer.balance || '0', reason: 'NOT_SUPPORTED' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const tokens = customer.token_balance || {};
|
|
128
|
+
const balance = tokens[paymentCurrency.id] || '0';
|
|
129
|
+
|
|
130
|
+
if (amount === '0') {
|
|
131
|
+
return { sufficient: false, balance, reason: 'NO_REQUIREMENT' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (new BN(balance).lt(new BN(amount))) {
|
|
135
|
+
return { sufficient: false, balance, reason: 'NO_ENOUGH_TOKEN' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { sufficient: true, balance };
|
|
139
|
+
}
|
|
140
|
+
|
|
115
141
|
export function getGasPayerExtra(txBuffer: Buffer) {
|
|
116
142
|
const txHash = toTxHash(txBuffer);
|
|
117
143
|
return {
|
|
@@ -125,7 +125,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
125
125
|
|
|
126
126
|
const onJobComplete = async (err: any, result: any) => {
|
|
127
127
|
if (result === CANCELLED) {
|
|
128
|
-
emit('cancelled', { id: jobId, job
|
|
128
|
+
emit('cancelled', { id: jobId, job });
|
|
129
129
|
clearJob(jobId);
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
@@ -188,6 +188,22 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
188
188
|
return jobEvents;
|
|
189
189
|
};
|
|
190
190
|
|
|
191
|
+
const pushAndWait = (params: PushParams<T>) =>
|
|
192
|
+
new Promise((resolve, reject) => {
|
|
193
|
+
try {
|
|
194
|
+
if (params.runAt || params.delay) {
|
|
195
|
+
console.warn('You may have to wait for a long time to get the result of the delayed job');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const job = push(params);
|
|
199
|
+
job.on('finished', (data: { id: string; job: T; result: any }) => resolve(data));
|
|
200
|
+
job.on('canceled', (data: { id: string; job: T }) => resolve(data));
|
|
201
|
+
job.on('failed', (data: { id: string; job: T; error: Error }) => reject(data));
|
|
202
|
+
} catch (err) {
|
|
203
|
+
reject(err);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
191
207
|
const cancel = async (id: string) => {
|
|
192
208
|
const doc = await store.updateJob(id, { cancelled: true });
|
|
193
209
|
return doc ? doc.job : null;
|
|
@@ -258,6 +274,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
258
274
|
return Object.assign(queueEvents, {
|
|
259
275
|
store,
|
|
260
276
|
push,
|
|
277
|
+
pushAndWait,
|
|
261
278
|
drain: (cb: any) => (queue.drain = cb),
|
|
262
279
|
empty: (cb: any) => (queue.empty = cb),
|
|
263
280
|
saturated: (cb: any) => (queue.saturated = cb),
|
|
@@ -6,23 +6,24 @@ import CustomError from '../error';
|
|
|
6
6
|
export default function createQueueStore(queue: string) {
|
|
7
7
|
return {
|
|
8
8
|
async isCancelled(id: string): Promise<boolean> {
|
|
9
|
-
const job = await Job.findOne({ where: { queue, id } });
|
|
9
|
+
const job = await Job.findOne({ where: { queue, id }, transaction: null });
|
|
10
10
|
return !!job && !!job.cancelled;
|
|
11
11
|
},
|
|
12
12
|
getJob(id: string): Promise<TJob | null> {
|
|
13
|
-
return Job.findOne({ where: { queue, id } });
|
|
13
|
+
return Job.findOne({ where: { queue, id }, transaction: null });
|
|
14
14
|
},
|
|
15
15
|
getJobs(): Promise<TJob[]> {
|
|
16
|
-
return Job.findAll({ where: { queue, delay: -1 }, order: [['created_at', 'ASC']] });
|
|
16
|
+
return Job.findAll({ where: { queue, delay: -1 }, order: [['created_at', 'ASC']], transaction: null });
|
|
17
17
|
},
|
|
18
18
|
getScheduledJobs(): Promise<TJob[]> {
|
|
19
19
|
return Job.findAll({
|
|
20
20
|
where: { queue, delay: { [Op.not]: -1 }, will_run_at: { [Op.lte]: Date.now() } },
|
|
21
21
|
order: [['created_at', 'ASC']],
|
|
22
|
+
transaction: null,
|
|
22
23
|
});
|
|
23
24
|
},
|
|
24
25
|
async updateJob(id: string, updates: Partial<TJob>): Promise<TJob> {
|
|
25
|
-
const job = await Job.findOne({ where: { queue, id } });
|
|
26
|
+
const job = await Job.findOne({ where: { queue, id }, transaction: null });
|
|
26
27
|
if (!job) {
|
|
27
28
|
throw new CustomError('JOB_NOT_FOUND', `Job ${id} does not exist`);
|
|
28
29
|
}
|
|
@@ -30,7 +31,7 @@ export default function createQueueStore(queue: string) {
|
|
|
30
31
|
return job.update(updates);
|
|
31
32
|
},
|
|
32
33
|
async addJob(id: string, job: any, attrs: Partial<TJob> = {}): Promise<TJob> {
|
|
33
|
-
const exist = await Job.findOne({ where: { queue, id } });
|
|
34
|
+
const exist = await Job.findOne({ where: { queue, id }, transaction: null });
|
|
34
35
|
if (exist) {
|
|
35
36
|
throw new CustomError('JOB_DUPLICATE', `Job ${queue}#${id} already exist`);
|
|
36
37
|
}
|
package/api/src/libs/session.ts
CHANGED
|
@@ -36,9 +36,9 @@ export function getCheckoutMode(items: TLineItemExpanded[] = []) {
|
|
|
36
36
|
return 'payment';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function getPriceUintAmountByCurrency(price: TPrice,
|
|
39
|
+
export function getPriceUintAmountByCurrency(price: TPrice, currencyId: string) {
|
|
40
40
|
const options = getPriceCurrencyOptions(price);
|
|
41
|
-
const option = options.find((x) => x.currency_id ===
|
|
41
|
+
const option = options.find((x) => x.currency_id === currencyId);
|
|
42
42
|
if (option) {
|
|
43
43
|
return option.unit_amount;
|
|
44
44
|
}
|
|
@@ -55,14 +55,14 @@ export function getPriceCurrencyOptions(price: TPrice): PriceCurrency[] {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// FIXME: apply coupon for discounts
|
|
58
|
-
export function getCheckoutAmount(items: TLineItemExpanded[],
|
|
58
|
+
export function getCheckoutAmount(items: TLineItemExpanded[], currencyId: string, includeFreeTrial = false) {
|
|
59
59
|
let renew = new BN(0);
|
|
60
60
|
|
|
61
61
|
const total = items
|
|
62
62
|
.reduce((acc, x) => {
|
|
63
63
|
const price = x.upsell_price || x.price;
|
|
64
64
|
if (price.type === 'recurring') {
|
|
65
|
-
renew = renew.add(new BN(getPriceUintAmountByCurrency(price,
|
|
65
|
+
renew = renew.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
|
|
66
66
|
|
|
67
67
|
if (includeFreeTrial) {
|
|
68
68
|
return acc;
|
|
@@ -71,7 +71,7 @@ export function getCheckoutAmount(items: TLineItemExpanded[], currency: TPayment
|
|
|
71
71
|
return acc;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
-
return acc.add(new BN(getPriceUintAmountByCurrency(price,
|
|
74
|
+
return acc.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
|
|
75
75
|
}, new BN(0))
|
|
76
76
|
.toString();
|
|
77
77
|
|
|
@@ -99,16 +99,17 @@ export function getRecurringPeriod(recurring: PriceRecurring) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export function getSubscriptionCreateSetup(items: TLineItemExpanded[],
|
|
102
|
+
export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currencyId: string, trialInDays = 0) {
|
|
103
103
|
let setup = new BN(0);
|
|
104
104
|
let subscription = new BN(0);
|
|
105
105
|
|
|
106
106
|
items.forEach((x) => {
|
|
107
107
|
const price = x.upsell_price || x.price;
|
|
108
|
-
|
|
108
|
+
const unit = getPriceUintAmountByCurrency(price, currencyId);
|
|
109
|
+
setup = setup.add(new BN(unit).mul(new BN(x.quantity)));
|
|
109
110
|
if (price.type === 'recurring') {
|
|
110
111
|
if (trialInDays === 0) {
|
|
111
|
-
subscription = setup.add(new BN(
|
|
112
|
+
subscription = setup.add(new BN(unit).mul(new BN(x.quantity)));
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
});
|
|
@@ -154,11 +155,11 @@ export function getSubscriptionCycleSetup(recurring: PriceRecurring, previousPer
|
|
|
154
155
|
};
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
export function getSubscriptionCycleAmount(items: TLineItemExpanded[],
|
|
158
|
+
export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyId: string) {
|
|
158
159
|
let amount = new BN(0);
|
|
159
160
|
|
|
160
161
|
items.forEach((x) => {
|
|
161
|
-
amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price,
|
|
162
|
+
amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price, currencyId)).mul(new BN(x.quantity)));
|
|
162
163
|
});
|
|
163
164
|
|
|
164
165
|
return {
|
|
@@ -287,7 +288,7 @@ export function canUpsell(from: TPrice, to: TPrice) {
|
|
|
287
288
|
export function getFastCheckoutAmount(
|
|
288
289
|
items: TLineItemExpanded[],
|
|
289
290
|
mode: string,
|
|
290
|
-
|
|
291
|
+
currencyId: string,
|
|
291
292
|
includeFreeTrial = false,
|
|
292
293
|
minimumCycle = 1
|
|
293
294
|
) {
|
|
@@ -296,7 +297,7 @@ export function getFastCheckoutAmount(
|
|
|
296
297
|
minimumCycle = 1;
|
|
297
298
|
}
|
|
298
299
|
|
|
299
|
-
const { total, renew } = getCheckoutAmount(items,
|
|
300
|
+
const { total, renew } = getCheckoutAmount(items, currencyId, includeFreeTrial);
|
|
300
301
|
if (mode === 'payment') {
|
|
301
302
|
return total;
|
|
302
303
|
}
|
|
@@ -3,3 +3,29 @@ import { component } from '@blocklet/sdk';
|
|
|
3
3
|
export function getCustomerSubscriptionPageUrl(subscriptionId: string, locale: string = 'en') {
|
|
4
4
|
return component.getUrl(`customer/subscription/${subscriptionId}?locale=${locale}`);
|
|
5
5
|
}
|
|
6
|
+
|
|
7
|
+
// FIXME: make this configurable from preferences
|
|
8
|
+
export function getDaysUntilDue(query: Record<string, any> = {}): number | null {
|
|
9
|
+
const raw = query.days_until_due || process.env.PAYMENT_DAYS_UNTIL_DUE;
|
|
10
|
+
if (raw) {
|
|
11
|
+
const days = parseInt(raw, 10);
|
|
12
|
+
// eslint-disable-next-line no-restricted-globals
|
|
13
|
+
if (isNaN(days) === false) {
|
|
14
|
+
return days;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const getDueUnit = (interval: string) => {
|
|
22
|
+
if (interval === 'hour') {
|
|
23
|
+
return 60;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (interval === 'day') {
|
|
27
|
+
return 60 * 60;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return 60 * 60 * 24;
|
|
31
|
+
};
|
package/api/src/libs/util.ts
CHANGED
|
@@ -9,8 +9,11 @@ import dayjs from './dayjs';
|
|
|
9
9
|
|
|
10
10
|
export const OCAP_PAYMENT_TX_TYPE = 'fg:t:transfer_v2';
|
|
11
11
|
|
|
12
|
-
export const
|
|
12
|
+
export const MAX_SUBSCRIPTION_ITEM_COUNT = 20;
|
|
13
|
+
|
|
14
|
+
export const MAX_RETRY_COUNT = 20; // 2^20 seconds ~~ 12 days, total retry time: 24 days
|
|
13
15
|
export const MIN_RETRY_MAIL = 13; // total retry time before sending first mail: 6 hours
|
|
16
|
+
|
|
14
17
|
export const STRIPE_API_VERSION = '2023-08-16';
|
|
15
18
|
export const STRIPE_ENDPOINT: string = getUrl('/api/integrations/stripe/webhook');
|
|
16
19
|
export const STRIPE_EVENTS: any[] = [
|
|
@@ -27,6 +30,7 @@ export const STRIPE_EVENTS: any[] = [
|
|
|
27
30
|
'customer.subscription.resumed',
|
|
28
31
|
'customer.subscription.trial_will_end',
|
|
29
32
|
'customer.subscription.updated',
|
|
33
|
+
'customer.updated',
|
|
30
34
|
|
|
31
35
|
'invoice.created',
|
|
32
36
|
'invoice.deleted',
|
|
@@ -3,6 +3,7 @@ import { ensurePassportIssued, ensurePassportRevoked } from '../integrations/blo
|
|
|
3
3
|
import { events } from '../libs/event';
|
|
4
4
|
import logger from '../libs/logger';
|
|
5
5
|
import { CheckoutSession, Price, Subscription } from '../store/models';
|
|
6
|
+
import { subscriptionQueue } from './subscription';
|
|
6
7
|
|
|
7
8
|
// eslint-disable-next-line require-await
|
|
8
9
|
export async function startCheckoutSessionQueue() {
|
|
@@ -30,5 +31,15 @@ export async function startCheckoutSessionQueue() {
|
|
|
30
31
|
ensurePassportRevoked(subscription).catch((err) => {
|
|
31
32
|
logger.error('ensurePassportRevoked failed', { error: err, subscription: subscription.id });
|
|
32
33
|
});
|
|
34
|
+
|
|
35
|
+
// FIXME: ensure invoices that are open or uncollectible are voided
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
events.on('customer.subscription.past_due', (subscription: Subscription) => {
|
|
39
|
+
subscriptionQueue.push({
|
|
40
|
+
id: `cancel-${subscription.id}`,
|
|
41
|
+
job: { subscriptionId: subscription.id, action: 'cancel' },
|
|
42
|
+
runAt: subscription.current_period_end,
|
|
43
|
+
});
|
|
33
44
|
});
|
|
34
45
|
}
|
|
@@ -13,10 +13,10 @@ import { paymentQueue } from './payment';
|
|
|
13
13
|
type InvoiceJob = {
|
|
14
14
|
invoiceId: string;
|
|
15
15
|
retryOnError?: boolean;
|
|
16
|
+
waitForPayment?: boolean;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
// handle invoice payment
|
|
19
|
-
// TODO: send invoice to user with email
|
|
20
20
|
export const handleInvoice = async (job: InvoiceJob) => {
|
|
21
21
|
logger.info('handle invoice', job);
|
|
22
22
|
|
|
@@ -79,14 +79,16 @@ export const handleInvoice = async (job: InvoiceJob) => {
|
|
|
79
79
|
if (invoice.payment_intent_id) {
|
|
80
80
|
logger.warn(`PaymentIntent exist: ${invoice.payment_intent_id} for invoice ${job.invoiceId}`);
|
|
81
81
|
paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
82
|
-
if (paymentIntent &&
|
|
82
|
+
if (paymentIntent && paymentIntent.isImmutable() === false) {
|
|
83
83
|
await paymentIntent.update({ status: 'requires_capture' });
|
|
84
84
|
}
|
|
85
85
|
} else {
|
|
86
86
|
const descriptionMap: any = {
|
|
87
87
|
subscription_create: 'Subscription creation',
|
|
88
88
|
subscription_cycle: 'Subscription cycle',
|
|
89
|
+
subscription_update: 'Subscription update',
|
|
89
90
|
};
|
|
91
|
+
// TODO: support partial payment from user balance
|
|
90
92
|
paymentIntent = await PaymentIntent.create({
|
|
91
93
|
livemode: !!invoice.livemode,
|
|
92
94
|
amount: invoice.total,
|
|
@@ -120,10 +122,17 @@ export const handleInvoice = async (job: InvoiceJob) => {
|
|
|
120
122
|
}
|
|
121
123
|
if (paymentIntent) {
|
|
122
124
|
logger.info(`Payment job created: ${paymentIntent.id}`);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
if (job.waitForPayment) {
|
|
126
|
+
await paymentQueue.pushAndWait({
|
|
127
|
+
id: paymentIntent.id,
|
|
128
|
+
job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
paymentQueue.push({
|
|
132
|
+
id: paymentIntent.id,
|
|
133
|
+
job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
127
136
|
}
|
|
128
137
|
};
|
|
129
138
|
|