payment-kit 1.18.30 → 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.
- package/api/src/crons/metering-subscription-detection.ts +9 -0
- package/api/src/integrations/arcblock/nft.ts +1 -0
- package/api/src/integrations/blocklet/passport.ts +1 -1
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/setup-intent.ts +29 -1
- package/api/src/integrations/stripe/handlers/subscription.ts +19 -15
- package/api/src/integrations/stripe/resource.ts +81 -1
- package/api/src/libs/audit.ts +42 -0
- package/api/src/libs/invoice.ts +54 -7
- package/api/src/libs/notification/index.ts +72 -4
- package/api/src/libs/notification/template/base.ts +2 -0
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -5
- package/api/src/libs/notification/template/subscription-renewed.ts +1 -5
- package/api/src/libs/notification/template/subscription-succeeded.ts +8 -18
- package/api/src/libs/notification/template/subscription-trial-start.ts +2 -10
- package/api/src/libs/notification/template/subscription-upgraded.ts +1 -5
- package/api/src/libs/payment.ts +47 -14
- package/api/src/libs/product.ts +1 -4
- package/api/src/libs/session.ts +600 -8
- package/api/src/libs/setting.ts +172 -0
- package/api/src/libs/subscription.ts +7 -69
- package/api/src/libs/ws.ts +5 -0
- package/api/src/queues/checkout-session.ts +42 -36
- package/api/src/queues/notification.ts +3 -2
- package/api/src/queues/payment.ts +33 -6
- package/api/src/queues/usage-record.ts +2 -10
- package/api/src/routes/checkout-sessions.ts +324 -187
- package/api/src/routes/connect/shared.ts +160 -38
- package/api/src/routes/connect/subscribe.ts +123 -64
- package/api/src/routes/payment-currencies.ts +3 -6
- package/api/src/routes/payment-links.ts +11 -1
- package/api/src/routes/payment-stats.ts +2 -2
- package/api/src/routes/payouts.ts +2 -1
- package/api/src/routes/settings.ts +45 -0
- package/api/src/routes/subscriptions.ts +1 -2
- package/api/src/store/migrations/20250408-subscription-grouping.ts +39 -0
- package/api/src/store/migrations/20250419-subscription-grouping.ts +69 -0
- package/api/src/store/models/checkout-session.ts +52 -0
- package/api/src/store/models/index.ts +1 -0
- package/api/src/store/models/payment-link.ts +6 -0
- package/api/src/store/models/subscription.ts +8 -6
- package/api/src/store/models/types.ts +31 -1
- package/api/tests/libs/session.spec.ts +423 -0
- package/api/tests/libs/subscription.spec.ts +0 -110
- package/blocklet.yml +3 -1
- package/package.json +20 -19
- package/scripts/sdk.js +486 -155
- package/src/locales/en.tsx +1 -1
- package/src/locales/zh.tsx +1 -1
- package/src/pages/admin/settings/vault-config/edit-form.tsx +1 -1
- package/src/pages/customer/subscription/change-payment.tsx +8 -3
package/api/src/libs/session.ts
CHANGED
|
@@ -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
|
|
8
|
-
import
|
|
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 {
|
|
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
|
|
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
|
|
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
|
+
}
|