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.
Files changed (37) hide show
  1. package/api/src/index.ts +2 -0
  2. package/api/src/libs/audit.ts +28 -34
  3. package/api/src/libs/payment.ts +2 -11
  4. package/api/src/libs/session.ts +1 -1
  5. package/api/src/libs/util.ts +8 -5
  6. package/api/src/routes/checkout-sessions.ts +41 -39
  7. package/api/src/routes/connect/collect.ts +12 -12
  8. package/api/src/routes/connect/setup.ts +8 -11
  9. package/api/src/routes/connect/shared.ts +81 -20
  10. package/api/src/routes/connect/subscribe.ts +8 -11
  11. package/api/src/routes/connect/update.ts +134 -0
  12. package/api/src/routes/pricing-table.ts +9 -121
  13. package/api/src/routes/subscriptions.ts +417 -142
  14. package/api/src/store/models/index.ts +3 -0
  15. package/api/src/store/models/pricing-table.ts +125 -1
  16. package/api/src/store/models/subscription.ts +4 -0
  17. package/api/src/store/models/types.ts +8 -0
  18. package/api/tests/libs/util.spec.ts +6 -6
  19. package/blocklet.yml +1 -1
  20. package/package.json +6 -6
  21. package/src/app.tsx +12 -4
  22. package/src/components/checkout/form/address.tsx +41 -34
  23. package/src/components/checkout/form/index.tsx +1 -1
  24. package/src/components/checkout/pricing-table.tsx +205 -0
  25. package/src/components/payment-link/product-select.tsx +13 -3
  26. package/src/components/portal/invoice/list.tsx +1 -1
  27. package/src/components/portal/subscription/actions.tsx +153 -0
  28. package/src/components/portal/subscription/list.tsx +21 -150
  29. package/src/components/subscription/metrics.tsx +46 -0
  30. package/src/contexts/products.tsx +2 -1
  31. package/src/libs/util.ts +43 -0
  32. package/src/locales/en.tsx +15 -1
  33. package/src/locales/zh.tsx +16 -2
  34. package/src/pages/admin/billing/subscriptions/detail.tsx +2 -34
  35. package/src/pages/checkout/pricing-table.tsx +9 -158
  36. package/src/pages/customer/subscription/{index.tsx → detail.tsx} +6 -36
  37. 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, TSubscriptionItem } from '../store/models/subscription-item';
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
- await doc.update({ cancel_at_period_end: false });
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: TSubscriptionItem & { [key: string]: any }) => {
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
- // TODO: forward changes to stripe
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', auth, async (req, res) => {
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
- req.body.items.every(isValidSubscriptionItemChange);
477
-
478
- // ensure no duplicate id
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
- const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
562
- if (!lastInvoice) {
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
- prorationItems.map((x) => {
611
- const unitAmount = getPriceUintAmountByCurrency(x.price, subscription.currency_id);
612
- const prorationAmount = new BN(unitAmount)
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 && update customer token balance
658
- const invoiceTotal = new BN(setup.amount.setup);
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: invoiceAmount,
664
- subtotal_excluding_tax: invoiceAmount,
665
- total: invoiceAmount,
666
- amount_due: invoiceAmount,
667
- amount_remaining: invoiceAmount,
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
- } else {
678
- const customerCredit = totalProrationAmount.sub(invoiceTotal).toString();
679
- const balance = await customer.increaseTokenBalance(paymentCurrency.id, customerCredit);
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
- // client side
709
- // 0. get invoice payment mode: credit/manual
710
- // 1. wait for invoice.payment_succeeded event if payment mode is credit
711
- // 2. popup to pay invoice if payment mode is manual
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
- await subscription.update(updates);
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) => {