347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
import {
|
|
ACTIVE_STATUSES,
|
|
type AttachBillingContext,
|
|
type AttachParamsV1,
|
|
type BillingContextOverride,
|
|
BillingVersion,
|
|
CusProductStatus,
|
|
cusProductToPrices,
|
|
hasCustomItems,
|
|
isFreeProduct,
|
|
isFutureStartDate,
|
|
isOneOffProduct,
|
|
isPastStartDate,
|
|
notNullish,
|
|
orgDisableStripeWrites,
|
|
orgToReturnUrl,
|
|
} from "@autumn/shared";
|
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
|
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
|
|
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
|
|
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
|
|
import { setupFinalizeFirstInvoice } from "@/internal/billing/v2/setup/setupFinalizeFirstInvoice";
|
|
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
|
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
|
|
import { setupPaymentBehaviorIntent } from "@/internal/billing/v2/setup/setupPaymentBehaviorIntent";
|
|
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
|
|
import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs";
|
|
import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling";
|
|
import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities";
|
|
import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund";
|
|
import { setupIgnoreProrationBehavior } from "../../../setup/setupIgnoreProrationBehavior";
|
|
import { getAttachAccessStartsAt } from "./getAttachAccessStartsAt";
|
|
import { setupAttachCheckoutMode } from "./setupAttachCheckoutMode";
|
|
import { setupAttachEndOfCycleMs } from "./setupAttachEndOfCycleMs";
|
|
import { setupAttachProductContext } from "./setupAttachProductContext";
|
|
import { setupAttachTransitionContext } from "./setupAttachTransitionContext";
|
|
import { setupAttachTrialContext } from "./setupAttachTrialContext";
|
|
|
|
/**
|
|
* Assembles the full billing context for attaching a product.
|
|
*/
|
|
export const setupAttachBillingContext = async ({
|
|
ctx,
|
|
params,
|
|
preview = false,
|
|
contextOverride = {},
|
|
}: {
|
|
ctx: AutumnContext;
|
|
params: AttachParamsV1;
|
|
preview?: boolean;
|
|
contextOverride?: BillingContextOverride;
|
|
}): Promise<AttachBillingContext> => {
|
|
const { fullCustomer: fullCustomerOverride } = contextOverride;
|
|
|
|
// fullCustomer must resolve before the product context so patch-style customize
|
|
// (add_items/remove_items) routes through setupAttachPatchProductContext, matching
|
|
// multiAttach (setupImmediateMultiProductBillingContext) and createSchedule.
|
|
const fullCustomer =
|
|
fullCustomerOverride ??
|
|
(await setupFullCustomerContext({
|
|
ctx,
|
|
params,
|
|
}));
|
|
|
|
const {
|
|
fullProduct: attachProduct,
|
|
customPrices,
|
|
customEnts,
|
|
} = await setupAttachProductContext({
|
|
ctx,
|
|
params,
|
|
contextOverride,
|
|
fullCustomer,
|
|
});
|
|
|
|
const { currentCustomerProduct, scheduledCustomerProduct, planTiming } =
|
|
setupAttachTransitionContext({
|
|
fullCustomer,
|
|
attachProduct,
|
|
planScheduleOverride: params.plan_schedule,
|
|
});
|
|
|
|
const isAttachPaidRecurring =
|
|
!isOneOffProduct({ prices: attachProduct.prices }) &&
|
|
!isFreeProduct({ prices: attachProduct.prices });
|
|
|
|
const hasPaidRecurringSubscription = fullCustomer.customer_products.some(
|
|
(customerProduct) => {
|
|
const hasActiveOrTrialingStatus =
|
|
ACTIVE_STATUSES.includes(customerProduct.status) ||
|
|
customerProduct.status === CusProductStatus.Trialing;
|
|
|
|
if (!hasActiveOrTrialingStatus) return false;
|
|
if (!customerProduct.subscription_ids?.length) return false;
|
|
|
|
const prices = cusProductToPrices({
|
|
cusProduct: customerProduct,
|
|
});
|
|
|
|
return !isOneOffProduct({ prices }) && !isFreeProduct({ prices });
|
|
},
|
|
);
|
|
|
|
const isTransitionFromFree =
|
|
notNullish(currentCustomerProduct) &&
|
|
isFreeProduct({
|
|
prices: cusProductToPrices({
|
|
cusProduct: currentCustomerProduct,
|
|
}),
|
|
});
|
|
|
|
// Only respect new_billing_subscription for non-transition scenarios
|
|
// (add-ons, entity products). Upgrades/downgrades ignore the flag.
|
|
const shouldForceNewSubscription =
|
|
(!currentCustomerProduct && params.new_billing_subscription) ||
|
|
(Boolean(params.new_billing_subscription) &&
|
|
isAttachPaidRecurring &&
|
|
isTransitionFromFree &&
|
|
hasPaidRecurringSubscription);
|
|
|
|
// no_billing_changes blocks WRITES but should still allow reading the
|
|
// existing Stripe sub when one is linked — needed so the new cusProduct
|
|
// inherits subscription_ids and the paid-product guard doesn't misfire.
|
|
// External-PSP origin callers (e.g. RevenueCat) opt out of fetching
|
|
// entirely via `contextOverride.skipBillingFetching`.
|
|
const skipBillingFetching =
|
|
orgDisableStripeWrites({ ctx }) ||
|
|
contextOverride.skipBillingFetching === true;
|
|
|
|
const skipBillingChangesBase =
|
|
skipBillingFetching ||
|
|
params.no_billing_changes === true ||
|
|
params.processor_subscription_id !== undefined;
|
|
|
|
const {
|
|
stripeSubscription,
|
|
stripeSubscriptionSchedule,
|
|
stripeCustomer,
|
|
stripeDiscounts,
|
|
stripeTaxRate,
|
|
paymentMethod,
|
|
testClockFrozenTime,
|
|
} = await setupStripeBillingContext({
|
|
ctx,
|
|
fullCustomer,
|
|
product: attachProduct,
|
|
targetCustomerProduct: currentCustomerProduct,
|
|
contextOverride,
|
|
params,
|
|
newBillingSubscription: shouldForceNewSubscription,
|
|
skipBillingFetching,
|
|
createStripeCustomerIfMissing:
|
|
!preview && params.no_billing_changes !== true,
|
|
fetchTaxRate: preview,
|
|
});
|
|
|
|
const featureQuantities = setupFeatureQuantitiesContext({
|
|
ctx,
|
|
featureQuantitiesParams: params,
|
|
fullProduct: attachProduct,
|
|
currentCustomerProduct: currentCustomerProduct,
|
|
initializeUndefinedQuantities: true,
|
|
contextOverride,
|
|
});
|
|
|
|
const invoiceMode = await setupInvoiceModeContext({ ctx, params });
|
|
const paymentBehaviorIntent = setupPaymentBehaviorIntent({
|
|
contextOverride,
|
|
paymentMethod,
|
|
});
|
|
const shouldFinalizeFirstInvoice = setupFinalizeFirstInvoice({
|
|
contextOverride,
|
|
invoiceMode,
|
|
});
|
|
const isCustom = hasCustomItems(params.customize);
|
|
|
|
// Timestamp context
|
|
const currentEpochMs = testClockFrozenTime ?? Date.now();
|
|
|
|
// Setup trial context
|
|
const trialContext = await setupAttachTrialContext({
|
|
ctx,
|
|
params,
|
|
currentContext: {
|
|
fullCustomer,
|
|
attachProduct,
|
|
stripeSubscription,
|
|
currentEpochMs,
|
|
currentCustomerProduct,
|
|
},
|
|
});
|
|
|
|
const skipBillingChanges =
|
|
skipBillingChangesBase || trialContext?.onEnd === "revert";
|
|
|
|
let billingCycleAnchorMs = setupBillingCycleAnchor({
|
|
stripeSubscription,
|
|
customerProduct: currentCustomerProduct,
|
|
newFullProduct: attachProduct,
|
|
trialContext,
|
|
currentEpochMs,
|
|
requestedBillingCycleAnchor: params.billing_cycle_anchor,
|
|
billingStartsAt: params.starts_at,
|
|
});
|
|
|
|
// Trial ends at overrides billing cycle anchor
|
|
if (trialContext?.trialEndsAt) {
|
|
billingCycleAnchorMs = trialContext.trialEndsAt;
|
|
}
|
|
|
|
const endOfCycleMs =
|
|
contextOverride.endOfCycleMsOverride ??
|
|
setupAttachEndOfCycleMs({
|
|
planTiming,
|
|
currentCustomerProduct,
|
|
stripeSubscription,
|
|
billingCycleAnchorMs,
|
|
currentEpochMs,
|
|
});
|
|
|
|
const billingStartsAt =
|
|
params.starts_at ??
|
|
(planTiming === "end_of_cycle" ? endOfCycleMs : undefined);
|
|
|
|
const hasFutureStartDate = isFutureStartDate(
|
|
params.starts_at,
|
|
currentEpochMs,
|
|
);
|
|
|
|
const subscriptionBackdateStartMs =
|
|
params.starts_at !== undefined &&
|
|
isPastStartDate(params.starts_at, currentEpochMs)
|
|
? params.starts_at
|
|
: undefined;
|
|
|
|
const accessStartsAt = getAttachAccessStartsAt({
|
|
params,
|
|
currentEpochMs,
|
|
});
|
|
|
|
const resetCycleAnchorMs = setupResetCycleAnchor({
|
|
billingCycleAnchorMs,
|
|
customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...)
|
|
newFullProduct: attachProduct,
|
|
billingStartsAt,
|
|
});
|
|
|
|
const checkoutMode = setupAttachCheckoutMode({
|
|
paymentMethod,
|
|
redirectMode: params.redirect_mode,
|
|
attachProduct,
|
|
stripeSubscription,
|
|
trialContext,
|
|
invoiceMode,
|
|
hasFutureStartDate,
|
|
});
|
|
|
|
const transitionConfig = setupTransitionConfigs({
|
|
params,
|
|
contextOverride,
|
|
});
|
|
|
|
const outgoingCusProductIds = currentCustomerProduct
|
|
? [currentCustomerProduct.id]
|
|
: [];
|
|
const { storedChargeLineItems, storedRefundLineItems } =
|
|
await fetchStoredLineItemsForSubscriptionBilling({
|
|
db: ctx.db,
|
|
fullCustomer,
|
|
stripeSubscription,
|
|
outgoingCusProductIds,
|
|
});
|
|
|
|
return {
|
|
fullCustomer,
|
|
fullProducts: [attachProduct],
|
|
attachProduct,
|
|
featureQuantities,
|
|
transitionConfig,
|
|
|
|
currentCustomerProduct,
|
|
scheduledCustomerProduct,
|
|
|
|
planTiming,
|
|
endOfCycleMs,
|
|
checkoutMode,
|
|
|
|
stripeCustomer,
|
|
stripeSubscription,
|
|
stripeSubscriptionSchedule,
|
|
stripeDiscounts,
|
|
stripeTaxRate,
|
|
paymentMethod,
|
|
|
|
currentEpochMs,
|
|
billingCycleAnchorMs,
|
|
resetCycleAnchorMs,
|
|
billingStartsAt,
|
|
subscriptionBackdateStartMs,
|
|
requestedBillingCycleAnchor: params.billing_cycle_anchor,
|
|
requestedProrationBehavior: setupIgnoreProrationBehavior({
|
|
isOneOffAttach: isOneOffProduct({ prices: attachProduct.prices }),
|
|
})
|
|
? undefined
|
|
: params.proration_behavior,
|
|
|
|
invoiceMode,
|
|
paymentBehaviorIntent,
|
|
shouldFinalizeFirstInvoice,
|
|
skipCustomPaymentMethodGuard: contextOverride.skipCustomPaymentMethodGuard,
|
|
skipExternalPSPGuard: contextOverride.skipExternalPSPGuard,
|
|
processorTypeOverride: contextOverride.processorTypeOverride,
|
|
enablePlanImmediately: params.enable_plan_immediately ?? false,
|
|
accessStartsAt,
|
|
|
|
customPrices,
|
|
customEnts,
|
|
isCustom,
|
|
trialContext,
|
|
|
|
adjustableFeatureQuantities: setupAdjustableQuantities({ params }),
|
|
|
|
billingVersion: contextOverride.billingVersion ?? BillingVersion.V2,
|
|
actionSource: "attach",
|
|
successUrl:
|
|
params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }),
|
|
checkoutSessionParams: params.checkout_session_params,
|
|
userMetadata: params.metadata,
|
|
taxRateId: params.tax_rate_id,
|
|
|
|
externalId: params.subscription_id,
|
|
|
|
skipBillingChanges,
|
|
dryRunStripe: preview,
|
|
|
|
storedChargeLineItems,
|
|
storedRefundLineItems,
|
|
|
|
anchorResetRefund: setupAnchorResetRefund({
|
|
billingCycleAnchor: params.billing_cycle_anchor,
|
|
prorationBehavior: params.proration_behavior,
|
|
outgoingCustomerProduct: currentCustomerProduct,
|
|
carryOverBalances: params.carry_over_balances,
|
|
}),
|
|
};
|
|
};
|