payment-kit 1.18.29 → 1.18.31

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 (56) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/crons/metering-subscription-detection.ts +9 -0
  3. package/api/src/integrations/arcblock/nft.ts +1 -0
  4. package/api/src/integrations/blocklet/passport.ts +1 -1
  5. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  6. package/api/src/integrations/stripe/handlers/setup-intent.ts +29 -1
  7. package/api/src/integrations/stripe/handlers/subscription.ts +19 -15
  8. package/api/src/integrations/stripe/resource.ts +81 -1
  9. package/api/src/libs/audit.ts +42 -0
  10. package/api/src/libs/constants.ts +2 -0
  11. package/api/src/libs/env.ts +2 -2
  12. package/api/src/libs/invoice.ts +54 -7
  13. package/api/src/libs/notification/index.ts +72 -4
  14. package/api/src/libs/notification/template/base.ts +2 -0
  15. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -5
  16. package/api/src/libs/notification/template/subscription-renewed.ts +1 -5
  17. package/api/src/libs/notification/template/subscription-succeeded.ts +8 -18
  18. package/api/src/libs/notification/template/subscription-trial-start.ts +2 -10
  19. package/api/src/libs/notification/template/subscription-upgraded.ts +1 -5
  20. package/api/src/libs/payment.ts +48 -8
  21. package/api/src/libs/product.ts +1 -4
  22. package/api/src/libs/session.ts +600 -8
  23. package/api/src/libs/setting.ts +172 -0
  24. package/api/src/libs/subscription.ts +7 -69
  25. package/api/src/libs/ws.ts +5 -0
  26. package/api/src/queues/checkout-session.ts +42 -36
  27. package/api/src/queues/notification.ts +3 -2
  28. package/api/src/queues/payment.ts +56 -8
  29. package/api/src/queues/usage-record.ts +2 -10
  30. package/api/src/routes/checkout-sessions.ts +324 -187
  31. package/api/src/routes/connect/shared.ts +160 -38
  32. package/api/src/routes/connect/subscribe.ts +123 -64
  33. package/api/src/routes/payment-currencies.ts +11 -0
  34. package/api/src/routes/payment-links.ts +11 -1
  35. package/api/src/routes/payment-stats.ts +2 -2
  36. package/api/src/routes/payouts.ts +2 -1
  37. package/api/src/routes/settings.ts +45 -0
  38. package/api/src/routes/subscriptions.ts +1 -2
  39. package/api/src/store/migrations/20250408-subscription-grouping.ts +39 -0
  40. package/api/src/store/migrations/20250419-subscription-grouping.ts +69 -0
  41. package/api/src/store/models/checkout-session.ts +52 -0
  42. package/api/src/store/models/index.ts +1 -0
  43. package/api/src/store/models/payment-link.ts +6 -0
  44. package/api/src/store/models/subscription.ts +8 -6
  45. package/api/src/store/models/types.ts +32 -1
  46. package/api/tests/libs/session.spec.ts +423 -0
  47. package/api/tests/libs/subscription.spec.ts +0 -110
  48. package/blocklet.yml +3 -1
  49. package/package.json +25 -24
  50. package/scripts/sdk.js +486 -155
  51. package/src/locales/en.tsx +4 -0
  52. package/src/locales/zh.tsx +3 -0
  53. package/src/pages/admin/settings/vault-config/edit-form.tsx +58 -3
  54. package/src/pages/admin/settings/vault-config/index.tsx +35 -1
  55. package/src/pages/customer/subscription/change-payment.tsx +8 -3
  56. package/src/pages/integrations/overview.tsx +1 -1
@@ -3,12 +3,34 @@ import type { TransactionInput } from '@ocap/client';
3
3
  import { BN } from '@ocap/util';
4
4
  import cloneDeep from 'lodash/cloneDeep';
5
5
  import isEqual from 'lodash/isEqual';
6
-
7
- import type { CheckoutSession, TLineItemExpanded, TPaymentCurrency, TPaymentMethodExpanded } from '../store/models';
8
- import type { Price, TPrice } from '../store/models/price';
6
+ import pAll from 'p-all';
7
+ import { omit } from 'lodash';
8
+ import dayjs from './dayjs';
9
+
10
+ import {
11
+ Customer,
12
+ Invoice,
13
+ PaymentCurrency,
14
+ PaymentMethod,
15
+ SetupIntent,
16
+ Subscription,
17
+ SubscriptionItem,
18
+ TPriceExpanded,
19
+ type CheckoutSession,
20
+ type TLineItemExpanded,
21
+ type TPaymentCurrency,
22
+ type TPaymentMethodExpanded,
23
+ } from '../store/models';
24
+ import type { Price, Price as TPrice } from '../store/models/price';
9
25
  import type { Product } from '../store/models/product';
10
- import type { PaymentBeneficiary, PriceCurrency, PriceRecurring } from '../store/models/types';
26
+ import type {
27
+ PaymentBeneficiary,
28
+ PriceCurrency,
29
+ PriceRecurring,
30
+ SubscriptionBillingThresholds,
31
+ } from '../store/models/types';
11
32
  import { wallet } from './auth';
33
+ import logger from './logger';
12
34
 
13
35
  export function getStatementDescriptor(items: any[]) {
14
36
  for (const item of items) {
@@ -37,7 +59,7 @@ export function getCheckoutMode(items: TLineItemExpanded[] = []) {
37
59
  return 'payment';
38
60
  }
39
61
 
40
- export function getPriceUintAmountByCurrency(price: TPrice, currencyId: string) {
62
+ export function getPriceUintAmountByCurrency(price: TPrice | TPriceExpanded, currencyId: string) {
41
63
  const options = getPriceCurrencyOptions(price);
42
64
  const option = options.find((x) => x.currency_id === currencyId);
43
65
  if (option) {
@@ -57,7 +79,7 @@ export function getPriceUintAmountByCurrency(price: TPrice, currencyId: string)
57
79
  throw new Error(`Currency option ${currencyId} not configured for price ${price.id}`);
58
80
  }
59
81
 
60
- export function getPriceCurrencyOptions(price: TPrice): PriceCurrency[] {
82
+ export function getPriceCurrencyOptions(price: TPrice | TPriceExpanded): PriceCurrency[] {
61
83
  if (Array.isArray(price.currency_options)) {
62
84
  return price.currency_options;
63
85
  }
@@ -170,12 +192,12 @@ export function getSupportedPaymentCurrencies(items: TLineItemExpanded[]) {
170
192
  export function isLineItemCurrencyAligned(list: TLineItemExpanded[], index: number) {
171
193
  const prices = list.map((x) => x.upsell_price || x.price);
172
194
 
173
- const current = getPriceCurrencyOptions(prices[index] as TPrice)
195
+ const current = getPriceCurrencyOptions(prices[index] as any)
174
196
  .map((x) => x.currency_id)
175
197
  .sort();
176
198
 
177
199
  for (let i = 0; i < index; i++) {
178
- const previous = getPriceCurrencyOptions(prices[i] as TPrice)
200
+ const previous = getPriceCurrencyOptions(prices[i] as any)
179
201
  .map((x) => x.currency_id)
180
202
  .sort();
181
203
  if (isEqual(current, previous) === false) {
@@ -383,3 +405,573 @@ export function isDonationCheckoutSession(checkoutSession: CheckoutSession): boo
383
405
  !!checkoutSession.metadata?.is_donation
384
406
  );
385
407
  }
408
+
409
+ /**
410
+ * Format subscription product name based on line items
411
+ */
412
+ export function formatSubscriptionProduct(items: TLineItemExpanded[], maxLength = 3) {
413
+ const names = items.map((x) => x.price?.product?.name || '').filter(Boolean);
414
+ return names.length > maxLength ? `${names.slice(0, maxLength).join(', ')} ...` : names.join(', ');
415
+ }
416
+
417
+ /**
418
+ * Setup subscription creation parameters
419
+ */
420
+ export function getSubscriptionCreateSetup(
421
+ items: TLineItemExpanded[],
422
+ currencyId: string,
423
+ trialInDays = 0,
424
+ trialEnd = 0
425
+ ) {
426
+ let setup = new BN(0);
427
+
428
+ items.forEach((x) => {
429
+ const price = x.upsell_price || x.price;
430
+ const unit = getPriceUintAmountByCurrency(price, currencyId);
431
+ const amount = new BN(unit).mul(new BN(x.quantity));
432
+ if (price.type === 'recurring') {
433
+ if (price.recurring?.usage_type === 'licensed') {
434
+ setup = setup.add(amount);
435
+ }
436
+ }
437
+ if (price.type === 'one_time') {
438
+ setup = setup.add(amount);
439
+ }
440
+ });
441
+
442
+ const now = dayjs().unix();
443
+ const item = items.find((x) => (x.upsell_price || x.price).type === 'recurring');
444
+ const recurring = (item?.upsell_price || item?.price)?.recurring as PriceRecurring;
445
+ const cycle = getRecurringPeriod(recurring);
446
+
447
+ let trialStartAt = 0;
448
+ let trialEndAt = 0;
449
+ if (+trialEnd && trialEnd > now) {
450
+ trialStartAt = now;
451
+ trialEndAt = trialEnd;
452
+ } else if (trialInDays) {
453
+ trialStartAt = now;
454
+ trialEndAt = dayjs().add(trialInDays, 'day').unix();
455
+ }
456
+
457
+ const periodStart = trialStartAt || now;
458
+ const periodEnd = trialEndAt || dayjs().add(cycle, 'millisecond').unix();
459
+
460
+ return {
461
+ recurring,
462
+ cycle: {
463
+ duration: cycle,
464
+ anchor: periodEnd,
465
+ },
466
+ trial: {
467
+ start: trialStartAt,
468
+ end: trialEndAt,
469
+ },
470
+ period: {
471
+ start: periodStart,
472
+ end: periodEnd,
473
+ },
474
+ amount: {
475
+ setup: setup.toString(),
476
+ },
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Creates subscription groupings based on checkout session configuration
482
+ * @param {Object} params - Parameters for subscription creation
483
+ * @returns {Object} Created subscription information
484
+ */
485
+ export async function createGroupSubscriptions({
486
+ checkoutSession,
487
+ lineItems,
488
+ customer,
489
+ paymentSettings,
490
+ setupIntent,
491
+ trialConfig = {},
492
+ }: {
493
+ checkoutSession: CheckoutSession;
494
+ lineItems: TLineItemExpanded[];
495
+ customer: Customer;
496
+ paymentSettings: { method: PaymentMethod; currency: PaymentCurrency };
497
+ setupIntent: SetupIntent | null;
498
+ trialConfig?: { trialInDays?: number; trialEnd?: number };
499
+ }) {
500
+ const enableGrouping = checkoutSession.enable_subscription_grouping;
501
+
502
+ // Filter recurring items only
503
+ const recurringItems = getRecurringLineItems(lineItems);
504
+ if (recurringItems.length === 0) {
505
+ logger.info('No recurring items found for subscription', {
506
+ session: checkoutSession.id,
507
+ });
508
+ return { subscriptions: [], subscriptionGroups: {}, primarySubscription: null };
509
+ }
510
+
511
+ try {
512
+ // Process based on grouping mode
513
+ if (!enableGrouping) {
514
+ // Single subscription mode
515
+ const result = await createSingleSubscription({
516
+ checkoutSession,
517
+ lineItems: recurringItems,
518
+ customer,
519
+ paymentSettings,
520
+ setupIntent,
521
+ trialConfig,
522
+ });
523
+ return result;
524
+ }
525
+ // Multiple subscriptions mode
526
+ const result = await createMultipleSubscriptions({
527
+ checkoutSession,
528
+ lineItems,
529
+ customer,
530
+ paymentSettings,
531
+ setupIntent,
532
+ trialConfig,
533
+ });
534
+ return result;
535
+ } catch (error) {
536
+ logger.error('Failed to create subscriptions', {
537
+ error: error.message,
538
+ session: checkoutSession.id,
539
+ stack: error.stack,
540
+ });
541
+ throw error;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Creates a single subscription for all items
547
+ */
548
+ async function createSingleSubscription(params: {
549
+ checkoutSession: CheckoutSession;
550
+ lineItems: TLineItemExpanded[];
551
+ customer: Customer;
552
+ paymentSettings: { method: PaymentMethod; currency: PaymentCurrency };
553
+ setupIntent: SetupIntent | null;
554
+ trialConfig?: { trialInDays?: number; trialEnd?: number };
555
+ }) {
556
+ const { checkoutSession, lineItems, customer, paymentSettings, setupIntent, trialConfig } = params;
557
+ const existingSubscriptionId = checkoutSession.subscription_id;
558
+
559
+ const subscription = await createOrUpdateSubscription({
560
+ existingSubscriptionId,
561
+ checkoutSession,
562
+ lineItems,
563
+ customer,
564
+ paymentSettings,
565
+ setupIntent,
566
+ trialConfig,
567
+ });
568
+
569
+ logger.info('Single subscription processed', {
570
+ session: checkoutSession.id,
571
+ subscription: subscription?.id,
572
+ isNew: !existingSubscriptionId,
573
+ });
574
+
575
+ return {
576
+ subscriptions: subscription ? [subscription] : [],
577
+ subscriptionGroups: {},
578
+ primarySubscription: subscription,
579
+ };
580
+ }
581
+
582
+ /**
583
+ * Creates multiple subscriptions grouped by price ID
584
+ */
585
+ async function createMultipleSubscriptions(params: {
586
+ checkoutSession: CheckoutSession;
587
+ lineItems: TLineItemExpanded[];
588
+ customer: Customer;
589
+ paymentSettings: { method: PaymentMethod; currency: PaymentCurrency };
590
+ setupIntent: SetupIntent | null;
591
+ trialConfig?: { trialInDays?: number; trialEnd?: number };
592
+ }) {
593
+ const { checkoutSession, lineItems, customer, paymentSettings, setupIntent, trialConfig } = params;
594
+
595
+ // Group items by price ID
596
+ const priceGroups = groupLineItemsByPrice(lineItems);
597
+ const existingGroups = checkoutSession.subscription_groups || {};
598
+
599
+ const subscriptions: any[] = [];
600
+ const subscriptionGroups: Record<string, string> = {};
601
+ let primarySubscription: any = null;
602
+
603
+ const noStake = !!checkoutSession.subscription_data?.no_stake;
604
+ const billingThreshold = getBillingThreshold(checkoutSession.subscription_data);
605
+ const minStakeAmount = getMinStakeAmount(checkoutSession.subscription_data);
606
+
607
+ // Create a task for each price group
608
+ const tasks = Object.entries(priceGroups).map(([priceId, items], index) => async () => {
609
+ try {
610
+ const existingSubscriptionId = existingGroups[priceId];
611
+ const isPrimary = index === 0;
612
+ const subscriptionMetadata = {
613
+ price_id: priceId,
614
+ checkout_session_id: checkoutSession.id,
615
+ ...(isPrimary ? { is_primary_subscription: true } : {}),
616
+ };
617
+
618
+ const subscriptionItems = items;
619
+
620
+ // Set different stake configurations for different subscriptions
621
+ let billingThresholds = {};
622
+ if (isPrimary) {
623
+ billingThresholds = {
624
+ amount_gte: billingThreshold,
625
+ stake_gte: minStakeAmount,
626
+ reset_billing_cycle_anchor: false,
627
+ no_stake: noStake,
628
+ };
629
+ } else {
630
+ // if not primary subscription, set stake to 0
631
+ billingThresholds = {
632
+ amount_gte: 0,
633
+ stake_gte: 0,
634
+ reset_billing_cycle_anchor: false,
635
+ no_stake: true,
636
+ references_primary_stake: true,
637
+ };
638
+ }
639
+
640
+ const subscription = await createOrUpdateSubscription({
641
+ existingSubscriptionId,
642
+ checkoutSession,
643
+ lineItems: subscriptionItems,
644
+ customer,
645
+ paymentSettings,
646
+ setupIntent,
647
+ trialConfig,
648
+ metadata: subscriptionMetadata,
649
+ billing_thresholds: billingThresholds as SubscriptionBillingThresholds,
650
+ });
651
+
652
+ if (!subscription) {
653
+ return { success: false, priceId, error: 'Failed to create subscription' };
654
+ }
655
+
656
+ return { success: true, priceId, subscription, isPrimary };
657
+ } catch (error) {
658
+ logger.error('Failed to create subscription for price group', {
659
+ priceId,
660
+ error: error.message,
661
+ session: checkoutSession.id,
662
+ stack: error.stack,
663
+ });
664
+ return { success: false, priceId, error: error.message };
665
+ }
666
+ });
667
+
668
+ const results = await pAll(tasks, { concurrency: 5 });
669
+
670
+ results.forEach((result) => {
671
+ if (result.success && result.subscription) {
672
+ subscriptionGroups[result.priceId] = result.subscription.id;
673
+ subscriptions.push(result.subscription);
674
+ if (result.isPrimary) {
675
+ primarySubscription = result.subscription;
676
+ }
677
+ }
678
+ });
679
+
680
+ return {
681
+ subscriptions,
682
+ subscriptionGroups,
683
+ primarySubscription,
684
+ };
685
+ }
686
+
687
+ /**
688
+ * Core logic for creating or updating a single subscription
689
+ */
690
+ async function createOrUpdateSubscription(params: {
691
+ existingSubscriptionId?: string | null;
692
+ checkoutSession: CheckoutSession;
693
+ lineItems: TLineItemExpanded[];
694
+ customer: Customer;
695
+ paymentSettings: { method: PaymentMethod; currency: PaymentCurrency };
696
+ setupIntent: SetupIntent | null;
697
+ trialConfig?: { trialInDays?: number; trialEnd?: number };
698
+ metadata?: Record<string, any>;
699
+ billing_thresholds?: SubscriptionBillingThresholds;
700
+ }): Promise<any> {
701
+ const {
702
+ existingSubscriptionId,
703
+ checkoutSession,
704
+ lineItems,
705
+ customer,
706
+ paymentSettings,
707
+ setupIntent,
708
+ trialConfig = {},
709
+ metadata = {},
710
+ billing_thresholds: billingThresholds,
711
+ } = params;
712
+
713
+ const { trialInDays = 0, trialEnd = 0 } = trialConfig;
714
+ const { method: paymentMethod, currency: paymentCurrency } = paymentSettings;
715
+
716
+ try {
717
+ const itemsSubscriptionData = mergeSubscriptionDataFromLineItems(lineItems);
718
+ let subscription = null;
719
+ const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays, trialEnd);
720
+
721
+ if (existingSubscriptionId) {
722
+ subscription = await Subscription.findByPk(existingSubscriptionId);
723
+ }
724
+
725
+ if (subscription) {
726
+ // Validate subscription status
727
+ if (subscription.status !== 'incomplete') {
728
+ throw new Error(`Subscription ${subscription.id} status not incomplete: ${subscription.status}`);
729
+ }
730
+
731
+ const billingCycleAnchor =
732
+ itemsSubscriptionData?.billing_cycle_anchor ||
733
+ checkoutSession.subscription_data?.billing_cycle_anchor ||
734
+ setup.cycle.anchor;
735
+
736
+ // Update existing subscription
737
+ subscription = await subscription.update({
738
+ currency_id: paymentCurrency.id,
739
+ customer_id: customer.id,
740
+ default_payment_method_id: paymentMethod.id,
741
+ current_period_start: setup.period.start,
742
+ current_period_end: setup.period.end,
743
+ billing_cycle_anchor: billingCycleAnchor,
744
+ trial_end: setup.trial.end,
745
+ trial_start: setup.trial.start,
746
+ pending_invoice_item_interval: setup.recurring,
747
+ pending_setup_intent: setupIntent?.id,
748
+ });
749
+
750
+ logger.info('Subscription updated', {
751
+ session: checkoutSession.id,
752
+ subscription: subscription.id,
753
+ });
754
+
755
+ // Rebuild subscription items
756
+ await SubscriptionItem.destroy({ where: { subscription_id: subscription.id } });
757
+ await createSubscriptionItems(subscription.id, lineItems, checkoutSession, itemsSubscriptionData);
758
+
759
+ // Update invoice customer if needed
760
+ const invoice = await Invoice.findByPk(subscription.latest_invoice_id);
761
+ if (invoice && invoice.customer_id !== customer.id) {
762
+ await invoice.update({ customer_id: customer.id });
763
+ }
764
+ } else {
765
+ // Create new subscription
766
+ const recoveredFromId =
767
+ itemsSubscriptionData.recovered_from || checkoutSession.subscription_data?.recovered_from || '';
768
+ const recoveredFrom = recoveredFromId ? await Subscription.findByPk(recoveredFromId) : null;
769
+
770
+ const safeMetadata = {
771
+ ...(checkoutSession.subscription_data?.notification_settings
772
+ ? {
773
+ notification_settings: checkoutSession.subscription_data.notification_settings,
774
+ }
775
+ : {}),
776
+ ...omit(checkoutSession.metadata || {}, ['days_until_due', 'days_until_cancel']),
777
+ ...(itemsSubscriptionData.metadata || {}),
778
+ ...metadata,
779
+ };
780
+
781
+ const subscriptionData = {
782
+ ...checkoutSession.subscription_data,
783
+ ...itemsSubscriptionData,
784
+ };
785
+
786
+ const subscriptionBillingThresholds = billingThresholds || {
787
+ amount_gte: getBillingThreshold(subscriptionData),
788
+ stake_gte: getMinStakeAmount(subscriptionData),
789
+ reset_billing_cycle_anchor: false,
790
+ no_stake: subscriptionData.no_stake,
791
+ };
792
+
793
+ subscription = await Subscription.create({
794
+ livemode: !!checkoutSession.livemode,
795
+ currency_id: paymentCurrency.id,
796
+ customer_id: customer.id,
797
+ status: 'incomplete',
798
+ current_period_start: setup.period.start,
799
+ current_period_end: setup.period.end,
800
+ // FIXME: billing_cycle_anchor implementation is not aligned with stripe
801
+ billing_cycle_anchor: subscriptionData.billing_cycle_anchor || setup.cycle.anchor,
802
+ start_date: dayjs().unix(),
803
+ trial_end: setup.trial.end,
804
+ trial_start: setup.trial.start,
805
+ trial_settings: {
806
+ end_behavior: {
807
+ missing_payment_method: 'create_invoice',
808
+ },
809
+ },
810
+ billing_thresholds: subscriptionBillingThresholds,
811
+ pending_invoice_item_interval: setup.recurring,
812
+ pending_setup_intent: setupIntent?.id,
813
+ default_payment_method_id: paymentMethod.id,
814
+ cancel_at_period_end: false,
815
+ collection_method: 'charge_automatically',
816
+ description: subscriptionData.description || formatSubscriptionProduct(lineItems),
817
+ proration_behavior: subscriptionData.proration_behavior || 'none',
818
+ payment_behavior: 'default_incomplete',
819
+ days_until_due: subscriptionData.days_until_due,
820
+ days_until_cancel: subscriptionData.days_until_cancel,
821
+ recovered_from: recoveredFrom?.id,
822
+ metadata: safeMetadata,
823
+ service_actions: subscriptionData.service_actions || [],
824
+ });
825
+
826
+ logger.info('Subscription created', {
827
+ session: checkoutSession.id,
828
+ subscription: subscription.id,
829
+ });
830
+
831
+ // Create subscription items with item-specific metadata
832
+ await createSubscriptionItems(subscription.id, lineItems, checkoutSession, itemsSubscriptionData);
833
+ }
834
+
835
+ return subscription;
836
+ } catch (error) {
837
+ logger.error('Failed in createOrUpdateSubscription', {
838
+ existingSubscriptionId,
839
+ error: error.message,
840
+ stack: error.stack,
841
+ session: checkoutSession.id,
842
+ });
843
+ throw error;
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Creates subscription items for a subscription
849
+ */
850
+ async function createSubscriptionItems(
851
+ subscriptionId: string,
852
+ lineItems: TLineItemExpanded[],
853
+ checkoutSession: CheckoutSession,
854
+ itemsSubscriptionData: Record<string, any>
855
+ ) {
856
+ try {
857
+ const metadata = {
858
+ ...(itemsSubscriptionData?.metadata || {}),
859
+ ...(checkoutSession.metadata || {}),
860
+ };
861
+
862
+ const subItems = await Promise.all(
863
+ lineItems.map((item) =>
864
+ SubscriptionItem.create({
865
+ livemode: !!checkoutSession.livemode,
866
+ subscription_id: subscriptionId,
867
+ price_id: item.upsell_price_id || item.price_id,
868
+ quantity: item.quantity,
869
+ metadata,
870
+ })
871
+ )
872
+ );
873
+
874
+ logger.info('Subscription items created', {
875
+ session: checkoutSession.id,
876
+ subscription: subscriptionId,
877
+ items: subItems.map((x) => x.id),
878
+ });
879
+
880
+ return subItems;
881
+ } catch (error) {
882
+ logger.error('Failed to create subscription items', {
883
+ subscriptionId,
884
+ error: error.message,
885
+ stack: error.stack,
886
+ session: checkoutSession.id,
887
+ });
888
+ throw error;
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Groups line items by price ID
894
+ */
895
+ function groupLineItemsByPrice(lineItems: TLineItemExpanded[]): Record<string, TLineItemExpanded[]> {
896
+ const groups: Record<string, TLineItemExpanded[]> = {};
897
+ const recurringLineItems = getRecurringLineItems(lineItems);
898
+
899
+ recurringLineItems.forEach((item) => {
900
+ const priceId = item.upsell_price_id || item.price_id;
901
+ if (!groups[priceId]) {
902
+ groups[priceId] = [];
903
+ }
904
+ groups[priceId].push(item);
905
+ });
906
+
907
+ return groups;
908
+ }
909
+
910
+ export function getCheckoutSessionSubscriptionIds(checkoutSession: CheckoutSession) {
911
+ const enableGrouping = checkoutSession.enable_subscription_grouping;
912
+ if (!enableGrouping && checkoutSession.subscription_id) {
913
+ return [checkoutSession.subscription_id];
914
+ }
915
+ return Object.values(checkoutSession.subscription_groups || {}).filter(Boolean);
916
+ }
917
+
918
+ export function getOneTimeLineItems(lineItems: TLineItemExpanded[]) {
919
+ return lineItems.filter((item) => item.price?.type === 'one_time');
920
+ }
921
+
922
+ export function getRecurringLineItems(lineItems: TLineItemExpanded[]) {
923
+ return lineItems.filter((item) => item.price?.type === 'recurring');
924
+ }
925
+
926
+ export function mergeSubscriptionDataFromLineItems(lineItems: TLineItemExpanded[]): Record<string, any> {
927
+ if (lineItems.length === 0) {
928
+ return {};
929
+ }
930
+ const mergedData: Record<string, any> = {};
931
+ const mergedMetadata: Record<string, any> = {};
932
+ lineItems.forEach((item: TLineItemExpanded) => {
933
+ const subData = (item.subscription_data || {}) as any;
934
+ if (subData.metadata) {
935
+ Object.assign(mergedMetadata, subData.metadata);
936
+ }
937
+ Object.entries(subData).forEach(([key, value]) => {
938
+ if (key !== 'metadata') {
939
+ mergedData[key] = value;
940
+ }
941
+ });
942
+ });
943
+
944
+ if (Object.keys(mergedMetadata).length > 0) {
945
+ mergedData.metadata = mergedMetadata;
946
+ }
947
+
948
+ return mergedData;
949
+ }
950
+
951
+ /**
952
+ * Get line items related to a specific subscription
953
+ * @param subscription The subscription object
954
+ * @param lineItems All line items from checkout session
955
+ * @param primarySubscription Optional primary subscription to check one-time items
956
+ * @returns Line items associated with the subscription
957
+ */
958
+ export async function getSubscriptionLineItems(
959
+ subscription: Subscription,
960
+ lineItems: TLineItemExpanded[],
961
+ primarySubscription?: Subscription
962
+ ): Promise<TLineItemExpanded[]> {
963
+ const subscriptionItems = await SubscriptionItem.findAll({
964
+ where: { subscription_id: subscription.id },
965
+ });
966
+
967
+ let subItems = lineItems.filter((x) =>
968
+ subscriptionItems.some((y) => y.price_id === x.price_id || y.price_id === x.upsell_price_id)
969
+ );
970
+
971
+ // Add one-time items if this is the primary subscription
972
+ if (primarySubscription && subscription.id === primarySubscription.id) {
973
+ subItems = [...subItems, ...getOneTimeLineItems(lineItems)];
974
+ }
975
+
976
+ return subItems;
977
+ }