payment-kit 1.13.92 → 1.13.94
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/index.ts +2 -0
- package/api/src/libs/audit.ts +28 -34
- package/api/src/libs/payment.ts +2 -11
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/util.ts +8 -5
- package/api/src/routes/checkout-sessions.ts +41 -39
- package/api/src/routes/connect/collect.ts +12 -12
- package/api/src/routes/connect/setup.ts +8 -11
- package/api/src/routes/connect/shared.ts +81 -20
- package/api/src/routes/connect/subscribe.ts +8 -11
- package/api/src/routes/connect/update.ts +134 -0
- package/api/src/routes/pricing-table.ts +9 -121
- package/api/src/routes/subscriptions.ts +417 -142
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/pricing-table.ts +125 -1
- package/api/src/store/models/subscription.ts +4 -0
- package/api/src/store/models/types.ts +8 -0
- package/api/tests/libs/util.spec.ts +6 -6
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/app.tsx +12 -4
- package/src/components/checkout/form/address.tsx +41 -34
- package/src/components/checkout/form/index.tsx +1 -1
- package/src/components/checkout/pricing-table.tsx +205 -0
- package/src/components/payment-link/product-select.tsx +13 -3
- package/src/components/portal/invoice/list.tsx +1 -1
- package/src/components/portal/subscription/actions.tsx +153 -0
- package/src/components/portal/subscription/list.tsx +21 -150
- package/src/components/subscription/metrics.tsx +46 -0
- package/src/contexts/products.tsx +2 -1
- package/src/libs/util.ts +43 -0
- package/src/locales/en.tsx +15 -1
- package/src/locales/zh.tsx +16 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -34
- package/src/pages/checkout/pricing-table.tsx +9 -158
- package/src/pages/customer/subscription/{index.tsx → detail.tsx} +6 -36
- package/src/pages/customer/subscription/update.tsx +281 -0
|
@@ -10,6 +10,7 @@ import { Transaction, WhereOptions } from 'sequelize';
|
|
|
10
10
|
import { getWhereFromQuery } from '../libs/api';
|
|
11
11
|
import dayjs from '../libs/dayjs';
|
|
12
12
|
import logger from '../libs/logger';
|
|
13
|
+
import { isDelegationSufficientForPayment } from '../libs/payment';
|
|
13
14
|
import { authenticate } from '../libs/security';
|
|
14
15
|
import {
|
|
15
16
|
expandLineItems,
|
|
@@ -17,9 +18,10 @@ import {
|
|
|
17
18
|
getSubscriptionCreateSetup,
|
|
18
19
|
isLineItemAligned,
|
|
19
20
|
} from '../libs/session';
|
|
20
|
-
import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata } from '../libs/util';
|
|
21
|
+
import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata, sleep } from '../libs/util';
|
|
21
22
|
import { invoiceQueue } from '../queues/invoice';
|
|
22
23
|
import { subscriptionQueue } from '../queues/subscription';
|
|
24
|
+
import type { TLineItemExpanded } from '../store/models';
|
|
23
25
|
import { Customer } from '../store/models/customer';
|
|
24
26
|
import { Invoice } from '../store/models/invoice';
|
|
25
27
|
import { InvoiceItem } from '../store/models/invoice-item';
|
|
@@ -27,9 +29,11 @@ import { PaymentCurrency } from '../store/models/payment-currency';
|
|
|
27
29
|
import { PaymentIntent } from '../store/models/payment-intent';
|
|
28
30
|
import { PaymentMethod } from '../store/models/payment-method';
|
|
29
31
|
import { Price } from '../store/models/price';
|
|
32
|
+
import { PricingTable } from '../store/models/pricing-table';
|
|
30
33
|
import { Product } from '../store/models/product';
|
|
31
34
|
import { Subscription, TSubscription } from '../store/models/subscription';
|
|
32
|
-
import { SubscriptionItem
|
|
35
|
+
import { SubscriptionItem } from '../store/models/subscription-item';
|
|
36
|
+
import type { LineItem, SubscriptionUpdateItem } from '../store/models/types';
|
|
33
37
|
import { UsageRecord } from '../store/models/usage-record';
|
|
34
38
|
import { sequelize } from '../store/sequelize';
|
|
35
39
|
import { ensureInvoiceAndItems } from './connect/shared';
|
|
@@ -277,8 +281,10 @@ router.put('/:id/recover', authPortal, async (req, res) => {
|
|
|
277
281
|
return res.status(400).json({ error: 'Subscription not recoverable from cancellation' });
|
|
278
282
|
}
|
|
279
283
|
|
|
280
|
-
await updateStripeSubscription(doc, { cancel_at_period_end: false });
|
|
281
|
-
|
|
284
|
+
await updateStripeSubscription(doc, { cancel_at_period_end: false, cancel_at: null, canceled_at: null });
|
|
285
|
+
|
|
286
|
+
// @ts-ignore
|
|
287
|
+
await doc.update({ cancel_at_period_end: false, cancel_at: null, canceled_at: null });
|
|
282
288
|
|
|
283
289
|
// reschedule jobs
|
|
284
290
|
subscriptionQueue
|
|
@@ -358,7 +364,7 @@ router.put('/:id/resume', auth, async (req, res) => {
|
|
|
358
364
|
return res.json(doc);
|
|
359
365
|
});
|
|
360
366
|
|
|
361
|
-
const isValidSubscriptionItemChange = (item:
|
|
367
|
+
const isValidSubscriptionItemChange = (item: SubscriptionUpdateItem) => {
|
|
362
368
|
if (item.deleted) {
|
|
363
369
|
if (!item.id) {
|
|
364
370
|
throw new Error('You must delete subscription item with id');
|
|
@@ -382,20 +388,208 @@ const isValidSubscriptionItemChange = (item: TSubscriptionItem & { [key: string]
|
|
|
382
388
|
return true;
|
|
383
389
|
};
|
|
384
390
|
|
|
385
|
-
|
|
391
|
+
const validateSubscriptionUpdateRequest = async (subscription: Subscription, items: SubscriptionUpdateItem[]) => {
|
|
392
|
+
// validate
|
|
393
|
+
items.every(isValidSubscriptionItemChange);
|
|
394
|
+
|
|
395
|
+
// ensure no duplicate id
|
|
396
|
+
const ids = items.filter((x: any) => x.id).map((x: any) => x.id);
|
|
397
|
+
if (uniq(ids).length !== ids.length) {
|
|
398
|
+
throw new Error('Subscription item should not have duplicate id');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ensure no duplicate price_id
|
|
402
|
+
const priceIds = items.filter((x: any) => x.price_id).map((x: any) => x.price_id);
|
|
403
|
+
if (uniq(priceIds).length !== priceIds.length) {
|
|
404
|
+
throw new Error('Subscription item should not have duplicate price_id');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const addedItems = await Price.expand(items.filter((x: any) => x.price_id && !x.id) as LineItem[]);
|
|
408
|
+
if (addedItems.some((x) => !x.price)) {
|
|
409
|
+
throw new Error('Subscription item should not use non-exist price');
|
|
410
|
+
}
|
|
411
|
+
if (addedItems.some((x) => !x.price.active)) {
|
|
412
|
+
throw new Error('Subscription item should not use archived price');
|
|
413
|
+
}
|
|
414
|
+
for (let i = 0; i < addedItems.length; i++) {
|
|
415
|
+
const result = isLineItemAligned(addedItems, i);
|
|
416
|
+
if (result.currency === false) {
|
|
417
|
+
throw new Error('New subscription items should have same currency');
|
|
418
|
+
}
|
|
419
|
+
if (result.recurring === false) {
|
|
420
|
+
throw new Error('New subscription items should have same recurring');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const existingItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
425
|
+
const deletedItems = items.filter((x: any) => x.deleted && x.id).map((x: any) => x.id);
|
|
426
|
+
if (existingItems.length - deletedItems.length + addedItems.length === 0) {
|
|
427
|
+
throw new Error('Subscription should have at least one subscription item');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const existingExpanded = await Price.expand(
|
|
431
|
+
existingItems.filter((x) => deletedItems.includes(x.id) === false).map((x) => x.toJSON())
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// try handle cross-sell with different interval, just replace with new price that have same interval
|
|
435
|
+
const newRecurring = addedItems.find((x) => x.price.type === 'recurring')?.price.recurring;
|
|
436
|
+
await Promise.all(
|
|
437
|
+
existingExpanded.map(async (x: TLineItemExpanded) => {
|
|
438
|
+
const oldRecurring = x.price.recurring;
|
|
439
|
+
if (
|
|
440
|
+
newRecurring?.interval !== oldRecurring?.interval ||
|
|
441
|
+
newRecurring?.interval_count !== oldRecurring?.interval_count
|
|
442
|
+
) {
|
|
443
|
+
const prices = await Price.findAll({
|
|
444
|
+
where: { product_id: x.price.product_id, type: 'recurring', active: true },
|
|
445
|
+
include: [{ model: Product, as: 'product' }],
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const newPrice = prices.find(
|
|
449
|
+
(y) =>
|
|
450
|
+
y.recurring?.interval === newRecurring?.interval &&
|
|
451
|
+
y.recurring?.interval_count === newRecurring?.interval_count
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
if (newPrice) {
|
|
455
|
+
logger.info('Replace subscription item on update', {
|
|
456
|
+
subscription: subscription.id,
|
|
457
|
+
item: x.id,
|
|
458
|
+
oldPrice: x.price.id,
|
|
459
|
+
newPrice: newPrice.id,
|
|
460
|
+
});
|
|
461
|
+
x.price_id = newPrice.id;
|
|
462
|
+
x.price = newPrice.toJSON();
|
|
463
|
+
return x;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
throw new Error(`Replacement price missing for ${x.id} when interval changed`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return x;
|
|
470
|
+
})
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// FIXME: following logic should be extracted
|
|
474
|
+
const newItems: any[] = [...existingExpanded, ...addedItems];
|
|
475
|
+
if (newItems.length > MAX_SUBSCRIPTION_ITEM_COUNT) {
|
|
476
|
+
throw new Error(`Subscription should not have more than ${MAX_SUBSCRIPTION_ITEM_COUNT} items`);
|
|
477
|
+
}
|
|
478
|
+
for (let i = 0; i < newItems.length; i++) {
|
|
479
|
+
const result = isLineItemAligned(newItems, i);
|
|
480
|
+
if (result.currency === false) {
|
|
481
|
+
throw new Error('Subscription item should have same currency');
|
|
482
|
+
}
|
|
483
|
+
if (result.recurring === false) {
|
|
484
|
+
throw new Error('Subscription item should have same recurring');
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
existingItems,
|
|
490
|
+
addedItems,
|
|
491
|
+
newItems,
|
|
492
|
+
};
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const createProration = async (subscription: TSubscription, setup: ReturnType<typeof getSubscriptionCreateSetup>) => {
|
|
496
|
+
const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
|
|
497
|
+
if (!lastInvoice) {
|
|
498
|
+
throw new Error('Subscription should have latest invoice when create proration');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 1. get last invoice, and invoice items, filter invoice items that are in licensed recurring mode
|
|
502
|
+
const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: lastInvoice.id, proration: false } });
|
|
503
|
+
const invoiceItemsExpanded = await Price.expand(invoiceItems.map((x) => x.toJSON()));
|
|
504
|
+
const prorationItems = invoiceItemsExpanded.filter(
|
|
505
|
+
(x) => x.price.type === 'recurring' && x.price.recurring?.usage_type === 'licensed'
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
// 2. calculate proration args based on the filtered invoice items
|
|
509
|
+
const now = dayjs().unix();
|
|
510
|
+
const prorationStart = lastInvoice.period_start;
|
|
511
|
+
const prorationEnd = lastInvoice.period_end;
|
|
512
|
+
const prorationRate = Math.ceil(((prorationEnd - now) / (prorationEnd - prorationStart)) * 1000000);
|
|
513
|
+
let totalProrationAmount = new BN(0);
|
|
514
|
+
const prorations = await Promise.all(
|
|
515
|
+
prorationItems.map((x: TLineItemExpanded & { [key: string]: any }) => {
|
|
516
|
+
const unitAmount = getPriceUintAmountByCurrency(x.price, subscription.currency_id);
|
|
517
|
+
const prorationAmount = new BN(unitAmount)
|
|
518
|
+
.mul(new BN(x.quantity))
|
|
519
|
+
.mul(new BN(prorationRate))
|
|
520
|
+
.div(new BN(1000000))
|
|
521
|
+
.toString();
|
|
522
|
+
logger.info('subscription proration item', {
|
|
523
|
+
subscription: subscription.id,
|
|
524
|
+
invoice: x.invoice_id,
|
|
525
|
+
invoiceItem: x.id,
|
|
526
|
+
prorationAmount,
|
|
527
|
+
});
|
|
528
|
+
totalProrationAmount = totalProrationAmount.add(new BN(prorationAmount));
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
price_id: x.price_id,
|
|
532
|
+
amount: `-${prorationAmount}`,
|
|
533
|
+
quantity: x.quantity,
|
|
534
|
+
// @ts-ignore
|
|
535
|
+
description: `Unused time on ${x.price.product.name} after ${dayjs().format('lll')}`,
|
|
536
|
+
period: {
|
|
537
|
+
start: lastInvoice.period_start,
|
|
538
|
+
end: lastInvoice.period_end,
|
|
539
|
+
},
|
|
540
|
+
proration: true,
|
|
541
|
+
proration_details: {
|
|
542
|
+
credited_items: {
|
|
543
|
+
invoice_id: lastInvoice.id,
|
|
544
|
+
// @ts-ignore
|
|
545
|
+
invoice_line_items: [x.id],
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
})
|
|
550
|
+
);
|
|
551
|
+
logger.info('subscription prorations created', {
|
|
552
|
+
subscription: subscription.id,
|
|
553
|
+
prorations,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// 5. adjust invoice total && update customer token balance
|
|
557
|
+
let total = new BN(setup.amount.setup);
|
|
558
|
+
let credit = new BN(setup.amount.setup);
|
|
559
|
+
if (total.gte(totalProrationAmount)) {
|
|
560
|
+
total = total.sub(totalProrationAmount).toString();
|
|
561
|
+
credit = '0';
|
|
562
|
+
} else {
|
|
563
|
+
credit = totalProrationAmount.sub(total).toString();
|
|
564
|
+
total = '0';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
logger.info('subscription proration result', {
|
|
568
|
+
subscription: subscription.id,
|
|
569
|
+
prorationStart,
|
|
570
|
+
prorationEnd,
|
|
571
|
+
prorationRate,
|
|
572
|
+
totalProrationAmount,
|
|
573
|
+
total,
|
|
574
|
+
credit,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
lastInvoice,
|
|
579
|
+
total,
|
|
580
|
+
credit,
|
|
581
|
+
prorations,
|
|
582
|
+
};
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// TODO: @wangshijun forward changes to stripe
|
|
386
586
|
const updateSchema = Joi.object<{
|
|
387
587
|
description?: string;
|
|
388
588
|
metadata?: Record<string, any>;
|
|
389
589
|
payment_behavior?: string;
|
|
390
590
|
proration_behavior?: string;
|
|
391
591
|
billing_cycle_anchor?: string;
|
|
392
|
-
items:
|
|
393
|
-
id?: string;
|
|
394
|
-
deleted?: boolean;
|
|
395
|
-
clear_usage?: boolean;
|
|
396
|
-
price_id?: string;
|
|
397
|
-
quantity?: number;
|
|
398
|
-
}[];
|
|
592
|
+
items: SubscriptionUpdateItem[];
|
|
399
593
|
}>({
|
|
400
594
|
description: Joi.string().min(1).optional(),
|
|
401
595
|
metadata: Joi.any().optional(),
|
|
@@ -416,7 +610,7 @@ const updateSchema = Joi.object<{
|
|
|
416
610
|
.default([]),
|
|
417
611
|
});
|
|
418
612
|
// eslint-disable-next-line consistent-return
|
|
419
|
-
router.put('/:id',
|
|
613
|
+
router.put('/:id', authPortal, async (req, res) => {
|
|
420
614
|
logger.debug('subscription update request', { subscription: req.params.id, body: req.body });
|
|
421
615
|
try {
|
|
422
616
|
const { error } = updateSchema.validate(req.body);
|
|
@@ -458,6 +652,14 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
458
652
|
updates.proration_behavior = req.body.proration_behavior;
|
|
459
653
|
}
|
|
460
654
|
|
|
655
|
+
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
656
|
+
if (!paymentMethod) {
|
|
657
|
+
throw new Error('Subscription should have payment method');
|
|
658
|
+
}
|
|
659
|
+
if (paymentMethod.type === 'stripe') {
|
|
660
|
+
throw new Error('Update is not supported for subscriptions paid with stripe');
|
|
661
|
+
}
|
|
662
|
+
|
|
461
663
|
const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
462
664
|
if (!paymentCurrency) {
|
|
463
665
|
throw new Error('Subscription should have payment currency');
|
|
@@ -468,56 +670,23 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
468
670
|
}
|
|
469
671
|
|
|
470
672
|
let invoice: Invoice;
|
|
673
|
+
let connectAction = '';
|
|
471
674
|
|
|
472
675
|
await sequelize.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED }, async () => {
|
|
676
|
+
if (subscription.isActive() === false) {
|
|
677
|
+
return res.status(400).json({ error: 'Subscription is not active' });
|
|
678
|
+
}
|
|
679
|
+
if (subscription.isScheduledToCancel()) {
|
|
680
|
+
throw new Error('Subscription is scheduled to cancel');
|
|
681
|
+
}
|
|
682
|
+
|
|
473
683
|
// handle subscription item changes
|
|
474
684
|
if (Array.isArray(req.body.items) && req.body.items.length > 0) {
|
|
475
|
-
// validate
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const ids = req.body.items.filter((x: any) => x.id).map((x: any) => x.id);
|
|
480
|
-
if (uniq(ids).length !== ids.length) {
|
|
481
|
-
throw new Error('Subscription item should not have duplicate id');
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// ensure no duplicate price_id
|
|
485
|
-
const priceIds = req.body.items.filter((x: any) => x.price_id).map((x: any) => x.price_id);
|
|
486
|
-
if (uniq(priceIds).length !== priceIds.length) {
|
|
487
|
-
throw new Error('Subscription item should not have duplicate price_id');
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const addedItems = await Price.expand(req.body.items.filter((x: any) => x.price_id && !x.id));
|
|
491
|
-
if (addedItems.some((x) => !x.price)) {
|
|
492
|
-
throw new Error('Subscription item should not use non-exist price');
|
|
493
|
-
}
|
|
494
|
-
if (addedItems.some((x) => !x.price.active)) {
|
|
495
|
-
throw new Error('Subscription item should not use archived price');
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
const existingItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
499
|
-
const deletedItems = req.body.items.filter((x: any) => x.deleted && x.id).map((x: any) => x.id);
|
|
500
|
-
if (existingItems.length - deletedItems.length + addedItems.length === 0) {
|
|
501
|
-
throw new Error('Subscription should have at least one subscription item');
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// FIXME: following logic should be extracted
|
|
505
|
-
const existingExpanded = await Price.expand(
|
|
506
|
-
existingItems.filter((x) => deletedItems.includes(x.id) === false).map((x) => x.toJSON())
|
|
685
|
+
// validate the request
|
|
686
|
+
const { existingItems, addedItems, newItems } = await validateSubscriptionUpdateRequest(
|
|
687
|
+
subscription,
|
|
688
|
+
req.body.items
|
|
507
689
|
);
|
|
508
|
-
const newItems: any[] = [...existingExpanded, ...addedItems];
|
|
509
|
-
if (newItems.length > MAX_SUBSCRIPTION_ITEM_COUNT) {
|
|
510
|
-
throw new Error(`Subscription should not have more than ${MAX_SUBSCRIPTION_ITEM_COUNT} items`);
|
|
511
|
-
}
|
|
512
|
-
for (let i = 0; i < newItems.length; i++) {
|
|
513
|
-
const result = isLineItemAligned(newItems, i);
|
|
514
|
-
if (result.currency === false) {
|
|
515
|
-
throw new Error('Subscription item should have same currency');
|
|
516
|
-
}
|
|
517
|
-
if (result.recurring === false) {
|
|
518
|
-
throw new Error('Subscription item should have same recurring');
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
690
|
|
|
522
691
|
// update subscription items
|
|
523
692
|
for (const item of req.body.items) {
|
|
@@ -558,24 +727,8 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
558
727
|
// handle proration
|
|
559
728
|
const prorationBehavior = updates.proration_behavior || subscription.proration_behavior || 'none';
|
|
560
729
|
if (prorationBehavior === 'create_prorations') {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
throw new Error('Subscription should have latest invoice');
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// 0. get last invoice, and invoice items, filter invoice items that are in licensed recurring mode
|
|
567
|
-
const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: lastInvoice.id } });
|
|
568
|
-
const invoiceItemsExpanded = await Price.expand(invoiceItems.map((x) => x.toJSON()));
|
|
569
|
-
const prorationItems = invoiceItemsExpanded.filter(
|
|
570
|
-
(x) => x.price.type === 'recurring' && x.price.recurring?.usage_type === 'licensed'
|
|
571
|
-
);
|
|
572
|
-
|
|
573
|
-
// 1. calculate proration amount based on the filtered invoice items
|
|
574
|
-
const now = dayjs().unix();
|
|
575
|
-
const prorationStart = lastInvoice.period_start;
|
|
576
|
-
const prorationEnd = lastInvoice.period_end;
|
|
577
|
-
const prorationRate = Math.ceil(((prorationEnd - now) / (prorationEnd - prorationStart)) * 1000000);
|
|
578
|
-
let totalProrationAmount = new BN(0);
|
|
730
|
+
// 1. create proration
|
|
731
|
+
const { lastInvoice, total, credit, prorations } = await createProration(subscription, setup);
|
|
579
732
|
|
|
580
733
|
// 2. create new invoice: amount according to new subscription items
|
|
581
734
|
// 3. create new invoice items: amount according to new subscription items
|
|
@@ -607,76 +760,42 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
607
760
|
|
|
608
761
|
// 4. create proration invoice items: amount according to proration amount
|
|
609
762
|
const prorationInvoiceItems = await Promise.all(
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
.mul(new BN(x.quantity))
|
|
614
|
-
.mul(new BN(prorationRate))
|
|
615
|
-
.div(new BN(1000000))
|
|
616
|
-
.toString();
|
|
617
|
-
logger.info('subscription proration invoice item', { subscription: req.params.id, prorationAmount });
|
|
618
|
-
totalProrationAmount = totalProrationAmount.add(new BN(prorationAmount));
|
|
619
|
-
|
|
620
|
-
return InvoiceItem.create({
|
|
763
|
+
prorations.map((x: any) =>
|
|
764
|
+
InvoiceItem.create({
|
|
765
|
+
...x,
|
|
621
766
|
livemode: subscription.livemode,
|
|
622
|
-
amount: `-${prorationAmount}`,
|
|
623
|
-
quantity: x.quantity,
|
|
624
|
-
// @ts-ignore
|
|
625
|
-
description: `Unused time on ${x.price.product.name} after ${dayjs().format('lll')}`,
|
|
626
|
-
period: {
|
|
627
|
-
start: lastInvoice.period_start,
|
|
628
|
-
end: lastInvoice.period_end,
|
|
629
|
-
},
|
|
630
767
|
currency_id: subscription.currency_id,
|
|
631
768
|
customer_id: customer.id,
|
|
632
769
|
price_id: x.price_id,
|
|
633
770
|
invoice_id: invoice.id,
|
|
634
771
|
subscription_id: subscription.id,
|
|
635
|
-
// @ts-ignore
|
|
636
772
|
subscription_item_id: x.subscription_item_id,
|
|
637
773
|
discountable: false,
|
|
638
774
|
discounts: [],
|
|
639
775
|
discount_amounts: [],
|
|
640
|
-
proration: true,
|
|
641
|
-
proration_details: {
|
|
642
|
-
credited_items: {
|
|
643
|
-
invoice_id: lastInvoice.id,
|
|
644
|
-
// @ts-ignore
|
|
645
|
-
invoice_line_items: [x.id],
|
|
646
|
-
},
|
|
647
|
-
},
|
|
648
776
|
metadata: {},
|
|
649
|
-
})
|
|
650
|
-
|
|
777
|
+
})
|
|
778
|
+
)
|
|
651
779
|
);
|
|
652
780
|
logger.info('subscription proration invoice items created', {
|
|
653
781
|
subscription: req.params.id,
|
|
654
782
|
items: prorationInvoiceItems.map((x) => x.id),
|
|
655
783
|
});
|
|
656
784
|
|
|
657
|
-
// 5. adjust invoice total
|
|
658
|
-
|
|
659
|
-
if (invoiceTotal.gt(totalProrationAmount)) {
|
|
660
|
-
const invoiceAmount = invoiceTotal.sub(totalProrationAmount).toString();
|
|
785
|
+
// 5. adjust invoice total or update customer credit balance
|
|
786
|
+
if (total !== '0') {
|
|
661
787
|
await invoice.update({
|
|
662
788
|
status: 'open',
|
|
663
|
-
subtotal:
|
|
664
|
-
subtotal_excluding_tax:
|
|
665
|
-
total
|
|
666
|
-
amount_due:
|
|
667
|
-
amount_remaining:
|
|
668
|
-
});
|
|
669
|
-
logger.info('subscription proration used on invoice', {
|
|
670
|
-
subscription: req.params.id,
|
|
671
|
-
prorationStart,
|
|
672
|
-
prorationEnd,
|
|
673
|
-
prorationRate,
|
|
674
|
-
totalProrationAmount,
|
|
675
|
-
invoiceAmount,
|
|
789
|
+
subtotal: total,
|
|
790
|
+
subtotal_excluding_tax: total,
|
|
791
|
+
total,
|
|
792
|
+
amount_due: total,
|
|
793
|
+
amount_remaining: total,
|
|
676
794
|
});
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
795
|
+
logger.info('subscription proration used on invoice', { subscription: req.params.id, total });
|
|
796
|
+
}
|
|
797
|
+
if (credit !== '0') {
|
|
798
|
+
const balance = await customer.increaseTokenBalance(paymentCurrency.id, credit);
|
|
680
799
|
await invoice.update({
|
|
681
800
|
status: 'open',
|
|
682
801
|
subtotal: '0',
|
|
@@ -687,35 +806,64 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
687
806
|
starting_token_balance: balance.starting,
|
|
688
807
|
ending_token_balance: balance.ending,
|
|
689
808
|
});
|
|
690
|
-
logger.info('subscription proration credit to customer', {
|
|
691
|
-
subscription: req.params.id,
|
|
692
|
-
prorationStart,
|
|
693
|
-
prorationEnd,
|
|
694
|
-
prorationRate,
|
|
695
|
-
totalProrationAmount,
|
|
696
|
-
customerCredit,
|
|
697
|
-
});
|
|
809
|
+
logger.info('subscription proration credit to customer', { subscription: req.params.id, credit });
|
|
698
810
|
}
|
|
699
811
|
|
|
812
|
+
await subscription.update(updates);
|
|
813
|
+
|
|
700
814
|
// 6. process the invoice as usual: push into queue
|
|
701
|
-
logger.info('subscription update invoice processing', { subscription: subscription.id, invoice: invoice.id });
|
|
702
815
|
await invoiceQueue.pushAndWait({
|
|
703
816
|
id: invoice.id,
|
|
704
817
|
job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
|
|
705
818
|
});
|
|
706
819
|
logger.info('subscription update invoice processed', { subscription: subscription.id, invoice: invoice.id });
|
|
707
820
|
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
//
|
|
821
|
+
// wait for async events flushed
|
|
822
|
+
await sleep(3000);
|
|
823
|
+
|
|
824
|
+
// check if we have succeeded
|
|
825
|
+
await Promise.all([invoice.reload(), subscription.reload()]);
|
|
826
|
+
|
|
827
|
+
if (invoice.status === 'paid') {
|
|
828
|
+
await subscriptionQueue.delete(subscription.id);
|
|
829
|
+
subscriptionQueue.push({
|
|
830
|
+
id: subscription.id,
|
|
831
|
+
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
832
|
+
// our next invoice should be generated at the end of current period, either trailing or normal
|
|
833
|
+
runAt: subscription.trail_end || subscription.current_period_end,
|
|
834
|
+
});
|
|
835
|
+
} else {
|
|
836
|
+
await subscription.update({ status: 'past_due' });
|
|
837
|
+
logger.info('subscription past_due on invoice paid', {
|
|
838
|
+
subscription: subscription.id,
|
|
839
|
+
invoice: invoice.id,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const delegation = await isDelegationSufficientForPayment({
|
|
843
|
+
paymentMethod,
|
|
844
|
+
paymentCurrency,
|
|
845
|
+
userDid: customer.did,
|
|
846
|
+
amount: setup.amount.setup,
|
|
847
|
+
});
|
|
848
|
+
if (delegation.sufficient === false) {
|
|
849
|
+
if (['NO_DID_WALLET'].includes(delegation.reason as string)) {
|
|
850
|
+
connectAction = 'bind';
|
|
851
|
+
} else if (['NO_TOKEN', 'NO_ENOUGH_TOKEN'].includes(delegation.reason as string)) {
|
|
852
|
+
connectAction = 'collect';
|
|
853
|
+
} else {
|
|
854
|
+
connectAction = 'update';
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
712
858
|
}
|
|
713
859
|
} else if (req.body.billing_cycle_anchor === 'now') {
|
|
714
860
|
// FIXME: handle billing cycle anchor change without any item change
|
|
861
|
+
await subscription.update(updates);
|
|
862
|
+
} else {
|
|
863
|
+
await subscription.update(updates);
|
|
715
864
|
}
|
|
716
865
|
|
|
717
|
-
|
|
718
|
-
return res.json(subscription);
|
|
866
|
+
return res.json({ ...subscription.toJSON(), connectAction });
|
|
719
867
|
});
|
|
720
868
|
} catch (err) {
|
|
721
869
|
console.error(err);
|
|
@@ -723,6 +871,133 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
723
871
|
}
|
|
724
872
|
});
|
|
725
873
|
|
|
874
|
+
const getUpdateTable = async (subscription: Subscription) => {
|
|
875
|
+
// If we are from a pricing table
|
|
876
|
+
if (subscription.metadata.pricing_table_id) {
|
|
877
|
+
const table = await PricingTable.findByPk(subscription.metadata.pricing_table_id);
|
|
878
|
+
if (table) {
|
|
879
|
+
// @ts-ignore
|
|
880
|
+
return table.expand();
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// if we are from upsell
|
|
885
|
+
const items = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
886
|
+
if (items.length === 1) {
|
|
887
|
+
const expanded = await Price!.expand(
|
|
888
|
+
items.map((x) => x.toJSON()),
|
|
889
|
+
{ upsell: true, product: true }
|
|
890
|
+
);
|
|
891
|
+
const exist = expanded.find((x) => x.price.type === 'recurring' && x.price.upsell?.upsells_to_id);
|
|
892
|
+
if (exist && exist.price.upsell?.upsells_to) {
|
|
893
|
+
// Fake a pricing table here
|
|
894
|
+
return {
|
|
895
|
+
active: true,
|
|
896
|
+
livemode: subscription.livemode,
|
|
897
|
+
name: 'Fake Pricing Table',
|
|
898
|
+
items: [
|
|
899
|
+
{
|
|
900
|
+
// Current item should always be the first
|
|
901
|
+
...PricingTable.formatItem({
|
|
902
|
+
price_id: exist.price.id,
|
|
903
|
+
product_id: exist.price.product_id,
|
|
904
|
+
}),
|
|
905
|
+
price: exist.price,
|
|
906
|
+
product: exist.price.product,
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
// Upsell item comes next
|
|
910
|
+
...PricingTable.formatItem({
|
|
911
|
+
price_id: exist.price.upsell.upsells_to_id,
|
|
912
|
+
product_id: exist.price.product_id,
|
|
913
|
+
}),
|
|
914
|
+
price: exist.price.upsell.upsells_to,
|
|
915
|
+
product: exist.price.product,
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return null;
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
// Check that the subscription is upgradable
|
|
926
|
+
router.get('/:id/update', authPortal, async (req, res) => {
|
|
927
|
+
try {
|
|
928
|
+
const subscription = await Subscription.findByPk(req.params.id);
|
|
929
|
+
if (!subscription) {
|
|
930
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
931
|
+
}
|
|
932
|
+
if (subscription.isActive() === false) {
|
|
933
|
+
return res.status(400).json({ error: 'Subscription is not active' });
|
|
934
|
+
}
|
|
935
|
+
if (subscription.isScheduledToCancel()) {
|
|
936
|
+
return res.status(400).json({ error: 'Subscription is scheduled to cancel' });
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
940
|
+
if (paymentMethod?.type === 'stripe') {
|
|
941
|
+
return res.status(400).json({ error: 'Update is not supported for subscriptions paid with stripe' });
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const table = await getUpdateTable(subscription);
|
|
945
|
+
return res.json(table);
|
|
946
|
+
} catch (err) {
|
|
947
|
+
console.error(err);
|
|
948
|
+
return res.json(null);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// Simulate subscription update
|
|
953
|
+
router.post('/:id/update', authPortal, async (req, res) => {
|
|
954
|
+
try {
|
|
955
|
+
const subscription = await Subscription.findByPk(req.params.id);
|
|
956
|
+
if (!subscription) {
|
|
957
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
958
|
+
}
|
|
959
|
+
if (subscription.isActive() === false) {
|
|
960
|
+
return res.status(400).json({ error: 'Subscription is not active' });
|
|
961
|
+
}
|
|
962
|
+
if (subscription.isScheduledToCancel()) {
|
|
963
|
+
return res.status(400).json({ error: 'Subscription is scheduled to cancel' });
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
967
|
+
if (paymentMethod?.type === 'stripe') {
|
|
968
|
+
return res.status(400).json({ error: 'Update is not supported for subscriptions paid with stripe' });
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const { error } = updateSchema.validate(req.body);
|
|
972
|
+
if (error) {
|
|
973
|
+
return res.status(400).json({ error: `Subscription update request invalid: ${error.message}` });
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// handle subscription item changes
|
|
977
|
+
if (!Array.isArray(req.body.items) || !req.body.items.length) {
|
|
978
|
+
return res.status(400).json({ error: 'Subscription update request invalid: items are empty' });
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// validate the request
|
|
982
|
+
const { newItems } = await validateSubscriptionUpdateRequest(subscription, req.body.items);
|
|
983
|
+
|
|
984
|
+
// do the simulation
|
|
985
|
+
const setup = getSubscriptionCreateSetup(newItems, subscription.currency_id, 0);
|
|
986
|
+
const result = await createProration(subscription, setup);
|
|
987
|
+
|
|
988
|
+
return res.json({
|
|
989
|
+
setup,
|
|
990
|
+
total: result.total,
|
|
991
|
+
credit: result.credit,
|
|
992
|
+
prorations: result.prorations,
|
|
993
|
+
items: newItems,
|
|
994
|
+
});
|
|
995
|
+
} catch (err) {
|
|
996
|
+
console.error(err);
|
|
997
|
+
return res.status(400).json({ error: err.message });
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
726
1001
|
// FIXME: this should be removed in future
|
|
727
1002
|
// Clean up subscriptions that have invalid invoices and payments
|
|
728
1003
|
router.delete('/cleanup', auth, async (req, res) => {
|