From 781e85b6c056809980c95d4a06b5a2d3df62e3cd Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 3 Feb 2026 13:14:18 +0000 Subject: [PATCH] chore: merge with attach --- .../checkout/CheckoutBackground.tsx | 8 +- .../components/checkout/CheckoutHeader.tsx | 34 +++-- .../components/checkout/FreeTrialSection.tsx | 25 ++++ .../src/components/checkout/OrderSummary.tsx | 78 ++++++---- .../checkout/OrderSummarySkeleton.tsx | 43 ++++-- .../src/components/checkout/PlanGroupCard.tsx | 41 ++++-- .../components/checkout/PlanGroupSection.tsx | 109 ++++++++++++++ .../components/checkout/PlanSelectionCard.tsx | 38 ++--- .../src/components/checkout/SectionHeader.tsx | 10 +- .../components/motion/CrossfadeContainer.tsx | 2 +- apps/checkout/src/pages/CheckoutPage.tsx | 125 ++++++---------- .../src/utils/buildHeaderDescription.ts | 139 ++++++++++++++++++ .../attach/compute/finalizeAttachPlan.ts | 25 ++-- .../attach/setup/setupAttachBillingContext.ts | 24 ++- .../setup/setupAttachTransitionContext.ts | 2 +- .../attach/setup/setupAttachTrialContext.ts | 96 ++++++++++++ .../filterLineItemsForTrialTransition.ts | 52 ++++--- .../v2/utils/billingPlanToPreviewResponse.ts | 7 +- .../lineItems/lineItemToPreviewLineItem.ts | 1 + .../confirm-free-product-scenario.test.ts | 100 +++++++++++++ .../confirm-paid-no-pm-scenario.test.ts | 131 +++++++++++++++++ .../confirm-paid-with-pm-scenario.test.ts | 119 +++++++++++++++ shared/api/billing/attachV2/attachParamsV0.ts | 7 + .../billing/common/billingPreviewResponse.ts | 1 + .../models/billingModels/lineItem/lineItem.ts | 3 + 25 files changed, 1010 insertions(+), 210 deletions(-) create mode 100644 apps/checkout/src/components/checkout/FreeTrialSection.tsx create mode 100644 apps/checkout/src/components/checkout/PlanGroupSection.tsx create mode 100644 apps/checkout/src/utils/buildHeaderDescription.ts create mode 100644 server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts create mode 100644 server/tests/scenarios/checkout/confirm-free-product-scenario.test.ts create mode 100644 server/tests/scenarios/checkout/confirm-paid-no-pm-scenario.test.ts create mode 100644 server/tests/scenarios/checkout/confirm-paid-with-pm-scenario.test.ts diff --git a/apps/checkout/src/components/checkout/CheckoutBackground.tsx b/apps/checkout/src/components/checkout/CheckoutBackground.tsx index a0e1bb77f..1c9a0eeb7 100644 --- a/apps/checkout/src/components/checkout/CheckoutBackground.tsx +++ b/apps/checkout/src/components/checkout/CheckoutBackground.tsx @@ -12,7 +12,7 @@ export function CheckoutBackground({ children }: { children: ReactNode }) {
{/* Top-right diagonal gradient */}
); diff --git a/apps/checkout/src/components/checkout/FreeTrialSection.tsx b/apps/checkout/src/components/checkout/FreeTrialSection.tsx new file mode 100644 index 000000000..251d98617 --- /dev/null +++ b/apps/checkout/src/components/checkout/FreeTrialSection.tsx @@ -0,0 +1,25 @@ +import type { ApiFreeTrialV2 } from "@autumn/shared"; +import { formatTrialDuration } from "@/utils/trialUtils"; + +interface FreeTrialSectionProps { + freeTrial: ApiFreeTrialV2; + trialAvailable: boolean; +} + +export function FreeTrialSection({ freeTrial, trialAvailable }: FreeTrialSectionProps) { + const duration = formatTrialDuration({ + duration_type: freeTrial.duration_type, + duration_length: freeTrial.duration_length, + }); + + return ( +
+ + Free Trial + + + {duration} + +
+ ); +} diff --git a/apps/checkout/src/components/checkout/OrderSummary.tsx b/apps/checkout/src/components/checkout/OrderSummary.tsx index 77ac73d83..f44c2a4e8 100644 --- a/apps/checkout/src/components/checkout/OrderSummary.tsx +++ b/apps/checkout/src/components/checkout/OrderSummary.tsx @@ -1,13 +1,16 @@ import type { + ApiFreeTrialV2, BillingPreviewResponse, CheckoutChange, PreviewLineItem, } from "@autumn/shared"; -import { format } from "date-fns"; -import { AnimatePresence, motion } from "motion/react"; +import { motion } from "motion/react"; import { useMemo } from "react"; -import { PlanGroupCard } from "@/components/checkout/PlanGroupCard"; -import { LAYOUT_TRANSITION, STANDARD_TRANSITION } from "@/lib/animations"; +import { CardBackground } from "@/components/checkout/CardBackground"; +import { FreeTrialSection } from "@/components/checkout/FreeTrialSection"; +import { PlanGroupSection } from "@/components/checkout/PlanGroupSection"; +import { Separator } from "@/components/ui/separator"; +import { LAYOUT_TRANSITION } from "@/lib/animations"; interface PlanGroup { planId: string; @@ -21,6 +24,8 @@ interface OrderSummaryProps { preview: BillingPreviewResponse; incoming?: CheckoutChange[]; outgoing?: CheckoutChange[]; + freeTrial?: ApiFreeTrialV2; + trialAvailable?: boolean; } export function OrderSummary({ @@ -28,6 +33,8 @@ export function OrderSummary({ preview, incoming = [], outgoing = [], + freeTrial, + trialAvailable = false, }: OrderSummaryProps) { const { line_items, total, currency, next_cycle } = preview; @@ -104,41 +111,50 @@ export function OrderSummary({ return groups; }, [displayLineItems, outgoing, incoming, planNameMap]); + const showFreeTrial = freeTrial && trialAvailable; + return ( - {/* Plan groups as cards */} -
- + {/* Unified card containing all sections */} + + {planGroups.map((group, groupIndex) => ( - +
+ {/* Separator between sections */} + {groupIndex > 0 && } + +
))} -
-
- {/* Message explaining changes take effect next cycle */} - {showNextCycleBreakdown && ( - - Changes take effect{" "} - {format(new Date(next_cycle.starts_at), "d MMM yyyy")} - - )} + {/* Free trial section */} + {showFreeTrial && ( + <> + + + + )} + +
+ + ); } diff --git a/apps/checkout/src/components/checkout/OrderSummarySkeleton.tsx b/apps/checkout/src/components/checkout/OrderSummarySkeleton.tsx index 2d04d9a72..6c8ae29aa 100644 --- a/apps/checkout/src/components/checkout/OrderSummarySkeleton.tsx +++ b/apps/checkout/src/components/checkout/OrderSummarySkeleton.tsx @@ -1,13 +1,38 @@ -import { CyclingSkeletonList } from "@/components/motion/CyclingSkeletonList"; -import { PlanGroupCardSkeleton } from "@/components/checkout/PlanGroupCardSkeleton"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; -/** Skeleton that matches OrderSummary layout with cycling item count */ -export function OrderSummarySkeleton() { +/** Skeleton section that matches PlanGroupSection structure */ +function PlanGroupSectionSkeleton() { return ( - } - minItems={1} - maxItems={2} - /> +
+ {/* Header */} +
+
+ + +
+ +
+ {/* Line item */} +
+
+ + +
+
+
+ ); +} + +/** Skeleton that matches OrderSummary unified card layout */ +export function OrderSummarySkeleton() { + return ( +
+ {/* First plan section */} + + + {/* Second plan section */} + +
); } diff --git a/apps/checkout/src/components/checkout/PlanGroupCard.tsx b/apps/checkout/src/components/checkout/PlanGroupCard.tsx index 2649eff70..0ee5eb591 100644 --- a/apps/checkout/src/components/checkout/PlanGroupCard.tsx +++ b/apps/checkout/src/components/checkout/PlanGroupCard.tsx @@ -1,5 +1,5 @@ import type { PreviewLineItem } from "@autumn/shared"; -import { Minus, Plus } from "@phosphor-icons/react"; +import { Clock, Minus, Plus } from "@phosphor-icons/react"; import { motion } from "motion/react"; import { Separator } from "@/components/ui/separator"; import { FAST_TRANSITION, LAYOUT_TRANSITION, listItemVariants } from "@/lib/animations"; @@ -29,6 +29,10 @@ export function PlanGroupCard({ const Icon = type === "outgoing" ? Minus : Plus; const groupTotal = items.reduce((sum, item) => sum + item.amount, 0); + // Check if all items in this group are deferred for trial + const allDeferred = + items.length > 0 && items.every((item) => item.deferred_for_trial); + // Sort items so base price appears first const sortedItems = [...items].sort((a, b) => { if (a.is_base && !b.is_base) return -1; @@ -52,6 +56,14 @@ export function PlanGroupCard({ > + {/* After trial banner */} + {allDeferred && ( +
+ + Charged after trial ends +
+ )} + {/* Plan header */}
@@ -104,15 +116,24 @@ export function PlanGroupCard({ )}
- - {formatAmount(item.amount, currency)} - +
+ {/* Show "After trial" for individual deferred items (only if not all are deferred) */} + {item.deferred_for_trial && !allDeferred && ( + + + After trial + + )} + + {formatAmount(item.amount, currency)} + +
{itemIndex < sortedItems.length - 1 && ( diff --git a/apps/checkout/src/components/checkout/PlanGroupSection.tsx b/apps/checkout/src/components/checkout/PlanGroupSection.tsx new file mode 100644 index 000000000..dc4831abc --- /dev/null +++ b/apps/checkout/src/components/checkout/PlanGroupSection.tsx @@ -0,0 +1,109 @@ +import type { PreviewLineItem } from "@autumn/shared"; +import { Minus, Plus } from "@phosphor-icons/react"; +import { motion } from "motion/react"; +import { Separator } from "@/components/ui/separator"; +import { FAST_TRANSITION } from "@/lib/animations"; +import { cn } from "@/lib/utils"; +import { formatAmount } from "@/utils/formatUtils"; + +type PlanChangeType = "incoming" | "outgoing"; + +interface PlanGroupSectionProps { + planId: string; + planName: string; + items: PreviewLineItem[]; + currency: string; + type: PlanChangeType; +} + +export function PlanGroupSection({ + planId, + planName, + items, + currency, + type, +}: PlanGroupSectionProps) { + const Icon = type === "outgoing" ? Minus : Plus; + const groupTotal = items.reduce((sum, item) => sum + item.amount, 0); + + // Sort items so base price appears first + const sortedItems = [...items].sort((a, b) => { + if (a.is_base && !b.is_base) return -1; + if (!a.is_base && b.is_base) return 1; + return 0; + }); + + return ( +
+ {/* Plan header */} +
+
+ + + {planName} + +
+ + {formatAmount(groupTotal, currency)} + +
+ + {/* Line items for this plan */} +
+ {sortedItems.length === 0 ? ( +
+ + {type === "outgoing" ? "No charges" : "Free"} + + + {formatAmount(0, currency)} + +
+ ) : ( + sortedItems.map((item, itemIndex) => ( +
+
+
+ + {item.is_base ? "Base Price" : item.title} + + {!item.is_base && item.total_quantity > 1 && ( + + x{item.total_quantity} + + )} +
+ + {formatAmount(item.amount, currency)} + +
+ {itemIndex < sortedItems.length - 1 && ( + + )} +
+ )) + )} +
+
+ ); +} diff --git a/apps/checkout/src/components/checkout/PlanSelectionCard.tsx b/apps/checkout/src/components/checkout/PlanSelectionCard.tsx index 2a2ef2ba9..b5bdc4945 100644 --- a/apps/checkout/src/components/checkout/PlanSelectionCard.tsx +++ b/apps/checkout/src/components/checkout/PlanSelectionCard.tsx @@ -110,9 +110,9 @@ export function PlanSelectionCard({ animate={{ opacity: 1 }} transition={STANDARD_TRANSITION} > -
- - {plan.name} +
+ + {plan.name}
@@ -153,19 +153,19 @@ export function PlanSelectionCard({
)} -
-
- +
+
+ {getFeatureName(feature)} - + {formatAmount(unitPrice, currency)} per{" "} {billingUnits === 1 ? getFeatureUnitDisplay(feature, false) : `${billingUnits} ${getFeatureUnitDisplay(feature, true)}`}
-
+
)} -
-
+
+
- + - + {getFeatureName(feature)}
- + {priceDisplay} per{" "} {billingUnits === 1 ? getFeatureUnitDisplay(feature, false) @@ -285,9 +286,10 @@ export function PlanSelectionCard({
)} -
-
+
+
- + - + {getFeatureName(feature)}
{feature.included_usage !== undefined && feature.included_usage !== null && ( - + {feature.included_usage === -1 ? "Unlimited" : `${feature.included_usage} included`} diff --git a/apps/checkout/src/components/checkout/SectionHeader.tsx b/apps/checkout/src/components/checkout/SectionHeader.tsx index 05fb04b6a..48b35e3d1 100644 --- a/apps/checkout/src/components/checkout/SectionHeader.tsx +++ b/apps/checkout/src/components/checkout/SectionHeader.tsx @@ -12,13 +12,13 @@ export function SectionHeader({ rightContent, }: SectionHeaderProps) { return ( -
-
- {title} - {rightContent} +
+
+ {title} + {rightContent &&
{rightContent}
}
{subheading && ( - {subheading} + {subheading} )}
); diff --git a/apps/checkout/src/components/motion/CrossfadeContainer.tsx b/apps/checkout/src/components/motion/CrossfadeContainer.tsx index be1798cd4..636ea622a 100644 --- a/apps/checkout/src/components/motion/CrossfadeContainer.tsx +++ b/apps/checkout/src/components/motion/CrossfadeContainer.tsx @@ -26,7 +26,7 @@ export function CrossfadeContainer({ transition={{ layout: LAYOUT_TRANSITION }} className={cn("relative", className)} > - + {isLoading ? ( 0 ? "Confirm and Subscribe" : "Confirm"; } @@ -125,58 +127,20 @@ export function CheckoutPage() { return checkoutData.incoming.some((change) => change.plan.price?.interval); }, [checkoutData]); - // Build context subheading for Plan Details section - const planDetailsSubheading = useMemo(() => { - if (!checkoutData?.incoming?.length) return undefined; + const headerDescription = useMemo(() => { + const incomingPlan = checkoutData?.incoming?.[0]?.plan; + const freeTrial = incomingPlan?.free_trial; + const trialAvailable = + incomingPlan?.customer_eligibility?.trial_available ?? false; - const change = checkoutData.incoming[0]; - const scenario = change.plan.customer_eligibility?.scenario; - const outgoingPlanName = checkoutData.outgoing?.[0]?.plan.name; - const entityName = checkoutData.entity?.name || checkoutData.entity?.id; - - // Check for proration (period_end exists when prorated) - const periodEnd = checkoutData.preview?.period_end; - const isProrated = outgoingPlanName && periodEnd; - - let context = ""; - if (outgoingPlanName) { - if (scenario === "upgrade") { - context = `Upgrading from ${outgoingPlanName}`; - } else if (scenario === "downgrade") { - context = `Downgrading from ${outgoingPlanName}`; - } else { - context = `Changing from ${outgoingPlanName}`; - } - } else { - context = "New subscription"; - } - - if (entityName) { - context += ` for ${entityName}`; - } - - // Add proration info when upgrading/downgrading mid-cycle - if (isProrated) { - context += `. Prorated until ${format(periodEnd, "MMM d")}`; - } - - return context; - }, [checkoutData]); - - // Build subheading for Order Summary section - const orderSummarySubheading = useMemo(() => { - if (!checkoutData?.preview) return undefined; - - const { total, currency } = checkoutData.preview; - const entityName = checkoutData.entity?.name || checkoutData.entity?.id; - - let context = `${formatAmount(total, currency)} due today`; - - if (entityName) { - context += ` for ${entityName}`; - } - - return context; + return buildHeaderDescription({ + preview: checkoutData?.preview, + incoming: checkoutData?.incoming, + outgoing: checkoutData?.outgoing, + entity: checkoutData?.entity, + freeTrial, + trialAvailable, + }); }, [checkoutData]); if (!checkoutId) { @@ -221,7 +185,10 @@ export function CheckoutPage() { // Extract free trial info from first incoming plan const incomingPlan = incoming?.[0]?.plan; const freeTrial = incomingPlan?.free_trial; - const trialAvailable = incomingPlan?.customer_eligibility?.trial_available ?? false; + const trialAvailable = + incomingPlan?.customer_eligibility?.trial_available ?? false; + const hasActiveTrial = freeTrial && trialAvailable; + const trialEndDate = incoming?.[0]?.period_end; return ( @@ -233,23 +200,24 @@ export function CheckoutPage() { > {/* Header */} - + {/* Main content - two columns */} -
+
{/* Left column - Plan selection */} - + - + {/* Order summary */} )} - {/* Free trial card */} - {freeTrial && ( - } - > - - - )} +
- {/* Bottom section - full width */} -
- -
- {/* Amount next cycle */} + {/* Amount next cycle / Amount due on trial end */} {preview?.next_cycle && (
- Total due next cycle + + {hasActiveTrial && preview.next_cycle.starts_at + ? `Amount due on ${format(preview.next_cycle.starts_at, "do MMMM yyyy")}` + : "Total due next cycle"} + {formatAmount(preview.next_cycle.total, currency)} @@ -375,6 +331,7 @@ export function CheckoutPage() { total, nextCycleTotal: preview?.next_cycle?.total ?? 0, isSubscription, + hasActiveTrial: hasActiveTrial ?? false, })} diff --git a/apps/checkout/src/utils/buildHeaderDescription.ts b/apps/checkout/src/utils/buildHeaderDescription.ts new file mode 100644 index 000000000..6928d7a8e --- /dev/null +++ b/apps/checkout/src/utils/buildHeaderDescription.ts @@ -0,0 +1,139 @@ +import type { + ApiFreeTrialV2, + BillingPreviewResponse, + CheckoutChange, + CheckoutEntity, +} from "@autumn/shared"; +import { format } from "date-fns"; +import { formatAmount } from "./formatUtils"; + +/** + * Builds the action phrase based on the checkout scenario. + * Examples: "Upgrading from Pro to Enterprise", "Subscribing to Enterprise", "Purchasing Starter" + */ +function buildActionPhrase({ + scenario, + outgoingPlanName, + incomingPlanName, + isRecurring, +}: { + scenario?: string; + outgoingPlanName?: string; + incomingPlanName?: string; + isRecurring: boolean; +}): string { + if (outgoingPlanName) { + const toClause = incomingPlanName ? ` to ${incomingPlanName}` : ""; + if (scenario === "upgrade") { + return `Upgrading from ${outgoingPlanName}${toClause}`; + } + if (scenario === "downgrade") { + return `Downgrading from ${outgoingPlanName}${toClause}`; + } + return `Changing from ${outgoingPlanName}${toClause}`; + } + + if (incomingPlanName) { + return isRecurring + ? `Subscribing to ${incomingPlanName}` + : `Purchasing ${incomingPlanName}`; + } + + return isRecurring ? "New subscription" : "New purchase"; +} + +/** + * Formats the free trial duration into a human-readable string. + * Examples: "14-day", "1-month", "7-day" + */ +function formatTrialDuration(freeTrial: ApiFreeTrialV2): string { + const { duration_length, duration_type } = freeTrial; + return `${duration_length}-${duration_type}`; +} + +/** + * Builds the header description for the checkout page. + * Returns a natural sentence describing the checkout action, amount, and timing. + */ +export function buildHeaderDescription({ + preview, + incoming, + outgoing, + entity, + freeTrial, + trialAvailable, +}: { + preview?: BillingPreviewResponse; + incoming?: CheckoutChange[]; + outgoing?: CheckoutChange[]; + entity?: CheckoutEntity; + freeTrial?: ApiFreeTrialV2 | null; + trialAvailable?: boolean; +}): string | undefined { + if (!preview) return undefined; + + const { total, currency, line_items, next_cycle } = preview; + const change = incoming?.[0]; + const scenario = change?.plan.customer_eligibility?.scenario; + const outgoingPlanName = outgoing?.[0]?.plan.name; + const incomingPlanName = change?.plan.name; + const isRecurring = !!change?.plan.price?.interval; + const entityName = entity?.name || entity?.id; + const hasActiveTrial = freeTrial && trialAvailable; + + // Determine if this is a scheduled change (no immediate charges, changes next cycle) + const isScheduledChange = + line_items.length === 0 && total === 0 && next_cycle; + + // Build the action phrase + let action = buildActionPhrase({ + scenario, + outgoingPlanName, + incomingPlanName, + isRecurring, + }); + + // Add entity if present + if (entityName) { + action += ` for ${entityName}`; + } + + // Build trial phrase if applicable + const trialDuration = hasActiveTrial + ? formatTrialDuration(freeTrial) + : null; + + // Handle negative amounts (refund/credit from previous plan) + if (total < 0) { + const creditAmount = formatAmount(Math.abs(total), currency); + let sentence = `${action}. You'll receive a ${creditAmount} credit for unused time on your previous plan.`; + + if (hasActiveTrial && next_cycle) { + const nextDate = format(new Date(next_cycle.starts_at), "d MMM yyyy"); + const nextAmount = formatAmount(next_cycle.total, currency); + sentence += ` Includes a ${trialDuration} free trial, then you'll be charged ${nextAmount} on ${nextDate}.`; + } else if (next_cycle) { + const nextDate = format(new Date(next_cycle.starts_at), "d MMM yyyy"); + const nextAmount = formatAmount(next_cycle.total, currency); + sentence += ` Your next charge of ${nextAmount} is on ${nextDate}.`; + } + + return sentence; + } + + // Handle free trial (no immediate payment, trial starts) + if (hasActiveTrial && next_cycle) { + const nextDate = format(new Date(next_cycle.starts_at), "d MMM yyyy"); + const nextAmount = formatAmount(next_cycle.total, currency); + return `${action}. Includes a ${trialDuration} free trial, then you'll be charged ${nextAmount} on ${nextDate}.`; + } + + // Handle scheduled changes (no immediate charges) + if (isScheduledChange) { + const effectiveDate = format(new Date(next_cycle.starts_at), "d MMM yyyy"); + return `${action} with ${formatAmount(total, currency)} due today. Changes take effect ${effectiveDate}.`; + } + + // Standard format + return `${action}. ${formatAmount(total, currency)} due today.`; +} diff --git a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts index 3d4c37150..ae407920a 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts @@ -1,18 +1,17 @@ +import type { AttachBillingContext, AutumnBillingPlan } from "@autumn/shared"; import { filterUnchangedPricesFromLineItems } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { filterLineItemsForTrialTransition } from "@/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition"; import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems"; -import type { - AttachBillingContext, - AutumnBillingPlan, -} from "@autumn/shared"; /** * Finalizes the attach billing plan by: - * 1. Filtering out unchanged prices (refund + charge pairs that cancel out) - * 2. Applying Stripe discounts to line items + * 1. Filtering line items based on trial state transitions + * 2. Filtering out unchanged prices (refund + charge pairs that cancel out) + * 3. Applying Stripe discounts to line items */ export const finalizeAttachPlan = ({ - ctx: _ctx, + ctx, plan, attachBillingContext, }: { @@ -20,12 +19,20 @@ export const finalizeAttachPlan = ({ plan: AutumnBillingPlan; attachBillingContext: AttachBillingContext; }): AutumnBillingPlan => { - // 1. Filter out unchanged prices (refund + charge pairs that cancel out) + // 1. Filter line items based on trial state transitions + // (removes charges when starting a trial) + plan.lineItems = filterLineItemsForTrialTransition({ + ctx, + lineItems: plan.lineItems ?? [], + billingContext: attachBillingContext, + }); + + // 2. Filter out unchanged prices (refund + charge pairs that cancel out) plan.lineItems = filterUnchangedPricesFromLineItems({ lineItems: plan.lineItems ?? [], }); - // 2. Apply Stripe discounts if present + // 3. Apply Stripe discounts if present if (attachBillingContext.stripeDiscounts?.length) { plan.lineItems = applyStripeDiscountsToLineItems({ lineItems: plan.lineItems ?? [], diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 7990d7059..9bf909090 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -15,6 +15,7 @@ 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. @@ -70,18 +71,28 @@ export const setupAttachBillingContext = async ({ // Timestamp context const currentEpochMs = testClockFrozenTime ?? Date.now(); - const billingCycleAnchorMs = setupBillingCycleAnchor({ + + // Setup trial context + const trialContext = await setupAttachTrialContext({ + ctx, + attachProduct, + fullCustomer, + currentEpochMs, + params, + }); + + let billingCycleAnchorMs = setupBillingCycleAnchor({ stripeSubscription, customerProduct: currentCustomerProduct, newFullProduct: attachProduct, - trialContext: undefined, + trialContext, currentEpochMs, }); - // if (trialContext?.trialEndsAt) { - // // 4. Trial ends at overrides reset cycle anchor - // billingCycleAnchorMs = trialContext.trialEndsAt; - // } + // Trial ends at overrides billing cycle anchor + if (trialContext?.trialEndsAt) { + billingCycleAnchorMs = trialContext.trialEndsAt; + } const resetCycleAnchorMs = setupResetCycleAnchor({ billingCycleAnchorMs, @@ -131,6 +142,7 @@ export const setupAttachBillingContext = async ({ customPrices, customEnts, isCustom, + trialContext, billingVersion: BillingVersion.V2, }; diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts index d09e9760e..e774c4740 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts @@ -1,3 +1,4 @@ +import type { PlanTiming } from "@autumn/shared"; import { cusProductToPrices, type FullCustomer, @@ -7,7 +8,6 @@ import { isOneOffProduct, isProductUpgrade, } from "@autumn/shared"; -import type { PlanTiming } from "@autumn/shared"; /** * Sets up the transition context for attaching a product. diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts new file mode 100644 index 000000000..8aa918671 --- /dev/null +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts @@ -0,0 +1,96 @@ +import type { + AttachParamsV0, + FreeTrial, + FullCustomer, + FullProduct, + TrialContext, +} from "@autumn/shared"; +import { addDuration, isProductPaidAndRecurring } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getFreeTrialAfterFingerprint } from "@/internal/products/free-trials/freeTrialUtils"; +import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial"; + +/** + * Sets up trial context for the V2 attach flow. + * + * Handles three cases: + * - `params.free_trial === null`: Explicitly disabled, returns undefined + * - `params.free_trial` defined: Custom override (DB insert deferred to execute phase) + * - `params.free_trial === undefined`: Uses product's default trial + * + * Always checks customer eligibility via fingerprint before returning trial context. + */ +export const setupAttachTrialContext = async ({ + ctx, + attachProduct, + fullCustomer, + currentEpochMs, + params, +}: { + ctx: AutumnContext; + attachProduct: FullProduct; + fullCustomer: FullCustomer; + currentEpochMs: number; + params: AttachParamsV0; +}): Promise => { + const { db, org } = ctx; + const paramsFreeTrial = params.free_trial as FreeTrial | null | undefined; + + // Case 1: Explicitly disabled (null) + if (paramsFreeTrial === null) { + return undefined; + } + + let freeTrial: FreeTrial | null = null; + + // Case 2: Custom trial override (defined) + if (paramsFreeTrial !== undefined) { + // Create in-memory FreeTrial object (DB insert happens in execute phase) + freeTrial = initFreeTrial({ + freeTrialParams: paramsFreeTrial, + internalProductId: attachProduct.internal_id, + isCustom: true, + }); + } + // Case 3: Use product default (undefined) + else { + freeTrial = attachProduct.free_trial ?? null; + } + + // No trial to apply + if (!freeTrial) { + return undefined; + } + + // Check customer eligibility via fingerprint + const eligibleFreeTrial = await getFreeTrialAfterFingerprint({ + db, + freeTrial, + productId: attachProduct.id, + fingerprint: fullCustomer.fingerprint, + internalCustomerId: fullCustomer.internal_id, + multipleAllowed: org.config.multiple_trials, + }); + + // Customer not eligible (already used trial) + if (!eligibleFreeTrial) { + return undefined; + } + + // Calculate trial end date + const trialEndsAt = addDuration({ + now: currentEpochMs, + durationType: eligibleFreeTrial.duration, + durationLength: eligibleFreeTrial.length, + }); + + return { + freeTrial: eligibleFreeTrial, + trialEndsAt, + appliesToBilling: isProductPaidAndRecurring(attachProduct), + cardRequired: eligibleFreeTrial.card_required, + // Mark as custom if params override was used + customFreeTrial: + paramsFreeTrial !== undefined ? eligibleFreeTrial : undefined, + }; +}; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts index af77f9e18..427c28ea6 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts @@ -1,17 +1,17 @@ +import type { BillingContext } from "@autumn/shared"; import { isOneOffPrice, type LineItem } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { BillingContext } from "@autumn/shared"; import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; /** - * Filters line items based on trial state transitions. + * Processes line items based on trial state transitions. * * Ending trial (isTrialing → !willBeTrialing): * - Excludes refund items (no refund for trial period) * - Excludes in_arrear positive items (no arrear charges for trial usage) * * Starting trial (!isTrialing → willBeTrialing): - * - Excludes in_advance positive items (no charge for upcoming trial period) + * - Marks in_advance positive items as deferred (charged after trial ends) */ export const filterLineItemsForTrialTransition = ({ // biome-ignore lint/correctness/noUnusedFunctionParameters: might be used in the future @@ -27,30 +27,38 @@ export const filterLineItemsForTrialTransition = ({ billingContext, }); - // No filtering needed if no trial transition + // No processing needed if no trial transition if (!isTrialing && !willBeTrialing) { return lineItems; } - return lineItems.filter((lineItem) => { - const { billingTiming, direction, price } = lineItem.context; - const isPositive = lineItem.amount > 0; - const isRecurringPrice = !isOneOffPrice(price); + return lineItems + .map((lineItem) => { + const { billingTiming, direction, price } = lineItem.context; + const isPositive = lineItem.amount > 0; + const isRecurringPrice = !isOneOffPrice(price); - // Ending trial (isTrialing → !willBeTrialing): - // Filter out refunds and in_arrear positive items (no refund for trial period, no arrear charges) - if (isTrialing) { - if (direction === "refund") return false; - if (billingTiming === "in_arrear" && isPositive) return false; - } + // Starting trial (!isTrialing → willBeTrialing): + // Mark in_advance positive recurring items as deferred (will be charged after trial) + if (willBeTrialing) { + if (billingTiming === "in_advance" && isPositive && isRecurringPrice) { + return { ...lineItem, deferredForTrial: true }; + } + } - // Starting trial (!isTrialing → willBeTrialing): - // Filter out in_advance positive items (no charge for upcoming trial period) - if (willBeTrialing) { - if (billingTiming === "in_advance" && isPositive && isRecurringPrice) - return false; - } + return lineItem; + }) + .filter((lineItem) => { + const { billingTiming, direction } = lineItem.context; + const isPositive = lineItem.amount > 0; - return true; - }); + // Ending trial (isTrialing → !willBeTrialing): + // Filter out refunds and in_arrear positive items (no refund for trial period, no arrear charges) + if (isTrialing) { + if (direction === "refund") return false; + if (billingTiming === "in_arrear" && isPositive) return false; + } + + return true; + }); }; diff --git a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts index 848c8d0bf..de2d89a9e 100644 --- a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts +++ b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts @@ -31,8 +31,13 @@ export const billingPlanToPreviewResponse = ({ lineItemToPreviewLineItem, ); + // Exclude deferred items from total (they'll be charged after trial ends) + const chargeableItems = previewImmediateLineItems.filter( + (line) => !line.deferred_for_trial, + ); + const total = new Decimal( - sumValues(previewImmediateLineItems.map((line) => line.amount)), + sumValues(chargeableItems.map((line) => line.amount)), ) .toDP(2) .toNumber(); diff --git a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts index 6615d561c..565cdd7cf 100644 --- a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts +++ b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts @@ -17,5 +17,6 @@ export const lineItemToPreviewLineItem = (line: LineItem): PreviewLineItem => { total_quantity: line.total_quantity ?? 1, paid_quantity: line.paid_quantity ?? 1, plan_id: line.context.product.id, + deferred_for_trial: line.deferredForTrial, }; }; diff --git a/server/tests/scenarios/checkout/confirm-free-product-scenario.test.ts b/server/tests/scenarios/checkout/confirm-free-product-scenario.test.ts new file mode 100644 index 000000000..77b3e4cd9 --- /dev/null +++ b/server/tests/scenarios/checkout/confirm-free-product-scenario.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test"; +import type { ConfirmCheckoutResponse } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import axios from "axios"; +import chalk from "chalk"; + +/** + * Confirm Free Product Scenario + * + * Tests confirming an Autumn checkout for a free product. + * When a customer confirms a free product checkout, no payment is needed. + * The product should be attached immediately with success. + */ + +test( + `${chalk.yellowBright("autumn-checkout: confirm free product - No payment needed")}`, + async () => { + const customerId = "checkout-confirm-free"; + + // Free plan - dashboard access only, no price + const free = products.base({ + id: "free", + items: [items.dashboard(), items.monthlyMessages({ includedUsage: 100 })], + }); + + // Setup: customer without payment method + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({}), // No payment method + s.products({ list: [free] }), + ], + actions: [], + }); + + // 1. Create checkout with redirect_mode: "always" (Autumn checkout) + const attachResult = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `free_${customerId}`, + redirect_mode: "always", + }); + console.log("attach result:", attachResult); + + // Should return autumn checkout URL (contains /c/) + const checkoutUrl = attachResult.checkout_url; + expect(checkoutUrl).toBeDefined(); + expect(checkoutUrl).toContain("/c/"); + + // Extract checkout ID from URL + const checkoutId = checkoutUrl!.split("/c/")[1]; + console.log("checkout ID:", checkoutId); + + // 2. Verify customer doesn't have product yet + const customerBefore = await autumnV1.customers.get(customerId); + console.log("customer before confirm:", { + products: customerBefore.products?.map( + (p: { id: string; name: string | null }) => ({ + id: p.id, + name: p.name, + }), + ), + }); + + // 3. Confirm the checkout - should succeed without payment + const confirmResponse = await axios.post( + `http://localhost:8080/checkouts/${checkoutId}/confirm`, + {}, + { timeout: 10000 }, + ); + const confirmData = confirmResponse.data; + console.log("confirm response:", confirmData); + + // Assertions for free product confirmation + expect(confirmData.success).toBe(true); + expect(confirmData.checkout_id).toBe(checkoutId); + expect(confirmData.customer_id).toBe(customerId); + expect(confirmData.product_id).toContain("free"); + // Free product should NOT create an invoice + expect(confirmData.invoice_id).toBeNull(); + + // 4. Verify product is now attached + const customerAfter = await autumnV1.customers.get(customerId); + console.log("customer after confirm:", { + products: customerAfter.products?.map( + (p: { id: string; name: string | null }) => ({ + id: p.id, + name: p.name, + }), + ), + }); + + const attachedProduct = customerAfter.products?.find((p: { id: string }) => + p.id.includes("free"), + ); + expect(attachedProduct).toBeDefined(); + }, + { timeout: 30000 }, +); diff --git a/server/tests/scenarios/checkout/confirm-paid-no-pm-scenario.test.ts b/server/tests/scenarios/checkout/confirm-paid-no-pm-scenario.test.ts new file mode 100644 index 000000000..acf7b503e --- /dev/null +++ b/server/tests/scenarios/checkout/confirm-paid-no-pm-scenario.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from "bun:test"; +import type { ConfirmCheckoutResponse } from "@autumn/shared"; +import { removeAllPaymentMethods } from "@/external/stripe/customers/paymentMethods/operations/removeAllPaymentMethods"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import axios from "axios"; +import chalk from "chalk"; + +/** + * Confirm Paid Product Without Payment Method Scenario + * + * Tests confirming an Autumn checkout for a paid product when the customer + * has no payment method on file. This can happen when: + * 1. Customer had PM when checkout was created, then removed it + * 2. Or the checkout requires payment collection + * + * Expected behavior: Should return a payment_url for the customer to complete payment. + */ + +test( + `${chalk.yellowBright("autumn-checkout: confirm paid (no PM) - Returns payment_url")}`, + async () => { + const customerId = "checkout-confirm-paid-no-pm"; + + // Pro plan ($20/mo) + const pro = products.pro({ + id: "pro", + items: [items.dashboard(), items.monthlyMessages({ includedUsage: 500 })], + }); + + // Setup: customer WITH payment method initially (to get autumn checkout) + const { autumnV1, customer } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), // Start with PM to get autumn checkout + s.products({ list: [pro] }), + ], + actions: [], + }); + + // 1. Create checkout with redirect_mode: "always" (Autumn checkout) + // This works because customer has PM + const attachResult = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + redirect_mode: "always", + }); + console.log("attach result:", attachResult); + + // Should return autumn checkout URL + const checkoutUrl = attachResult.checkout_url; + expect(checkoutUrl).toBeDefined(); + expect(checkoutUrl).toContain("/c/"); + + // Extract checkout ID + const checkoutId = checkoutUrl!.split("/c/")[1]; + console.log("checkout ID:", checkoutId); + + // 2. Remove payment method AFTER checkout was created + // This simulates the scenario where customer no longer has a PM when confirming + const stripeCustomerId = customer?.processor?.id; + if (stripeCustomerId) { + await removeAllPaymentMethods({ + stripeClient: ctx.stripeCli, + stripeCustomerId, + }); + console.log("Removed all payment methods from customer"); + } + + // 3. Verify customer doesn't have product yet + const customerBefore = await autumnV1.customers.get(customerId); + console.log("customer before confirm (no PM):", { + products: customerBefore.products?.map( + (p: { id: string; name: string | null }) => ({ + id: p.id, + name: p.name, + }), + ), + }); + + // 4. Attempt to confirm the checkout without PM + // Should return payment_url since payment is required but no PM exists + try { + const confirmResponse = await axios.post< + ConfirmCheckoutResponse & { payment_url?: string; checkout_url?: string } + >( + `http://localhost:8080/checkouts/${checkoutId}/confirm`, + {}, + { timeout: 10000 }, + ); + const confirmData = confirmResponse.data; + console.log("confirm response:", confirmData); + + // If confirm succeeds, it should include a payment_url or checkout_url + // for the customer to complete payment + const paymentUrl = confirmData.payment_url || confirmData.checkout_url; + if (paymentUrl) { + expect(paymentUrl).toBeDefined(); + console.log("payment/checkout url returned:", paymentUrl); + } else { + // If no payment_url, the confirm might still succeed + // and create a subscription that requires payment + console.log("confirm succeeded without payment_url"); + console.log("invoice_id:", confirmData.invoice_id); + } + } catch (error: unknown) { + // It's also acceptable for the confirm to fail with an error + // requiring payment method + if (axios.isAxiosError(error)) { + console.log("confirm error status:", error.response?.status); + console.log("confirm error data:", error.response?.data); + const errorData = error.response?.data; + + // Check if error includes payment_url for payment collection + if (errorData?.payment_url) { + expect(errorData.payment_url).toBeDefined(); + console.log("payment_url in error response:", errorData.payment_url); + } else { + // Should indicate payment method is required or similar + console.log("Error code:", errorData?.code); + console.log("Error message:", errorData?.message); + } + } else { + throw error; + } + } + }, + { timeout: 30000 }, +); diff --git a/server/tests/scenarios/checkout/confirm-paid-with-pm-scenario.test.ts b/server/tests/scenarios/checkout/confirm-paid-with-pm-scenario.test.ts new file mode 100644 index 000000000..a36598124 --- /dev/null +++ b/server/tests/scenarios/checkout/confirm-paid-with-pm-scenario.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test"; +import type { ConfirmCheckoutResponse } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import axios from "axios"; +import chalk from "chalk"; + +/** + * Confirm Paid Product With Payment Method Scenario + * + * Tests confirming an Autumn checkout for a paid product when the customer + * has a valid payment method on file. + * + * Expected behavior: Invoice is created and payment is processed immediately. + */ + +test( + `${chalk.yellowBright("autumn-checkout: confirm paid (with PM) - Invoice created")}`, + async () => { + const customerId = "checkout-confirm-paid-with-pm"; + + // Pro plan ($20/mo) + const pro = products.pro({ + id: "pro", + items: [items.dashboard(), items.monthlyMessages({ includedUsage: 500 })], + }); + + // Setup: customer WITH payment method + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), // Has valid PM + s.products({ list: [pro] }), + ], + actions: [], + }); + + // 1. Create checkout with redirect_mode: "always" (Autumn checkout) + const attachResult = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + redirect_mode: "always", + }); + console.log("attach result:", attachResult); + + // Should return autumn checkout URL (not stripe checkout) + const checkoutUrl = attachResult.checkout_url; + expect(checkoutUrl).toBeDefined(); + expect(checkoutUrl).toContain("/c/"); + + // Extract checkout ID + const checkoutId = checkoutUrl!.split("/c/")[1]; + console.log("checkout ID:", checkoutId); + + // 2. Verify customer doesn't have product yet + const customerBefore = await autumnV1.customers.get(customerId); + console.log("customer before confirm:", { + products: customerBefore.products?.map( + (p: { id: string; name: string | null }) => ({ + id: p.id, + name: p.name, + }), + ), + }); + + // 3. Confirm the checkout - should create invoice and charge PM + const confirmResponse = await axios.post( + `http://localhost:8080/checkouts/${checkoutId}/confirm`, + {}, + { timeout: 15000 }, + ); + const confirmData = confirmResponse.data; + console.log("confirm response:", confirmData); + + // Assertions for paid product with PM + expect(confirmData.success).toBe(true); + expect(confirmData.checkout_id).toBe(checkoutId); + expect(confirmData.customer_id).toBe(customerId); + expect(confirmData.product_id).toContain("pro"); + // Paid product WITH PM should create an invoice + expect(confirmData.invoice_id).toBeDefined(); + expect(confirmData.invoice_id).not.toBeNull(); + console.log("invoice created:", confirmData.invoice_id); + + // 4. Verify product is now attached + const customerAfter = await autumnV1.customers.get(customerId); + console.log("customer after confirm:", { + products: customerAfter.products?.map( + (p: { id: string; name: string | null }) => ({ + id: p.id, + name: p.name, + }), + ), + }); + + const attachedProduct = customerAfter.products?.find((p: { id: string }) => + p.id.includes("pro"), + ); + expect(attachedProduct).toBeDefined(); + + // 5. Verify invoice exists in customer's invoices + const invoices = customerAfter.invoices; + console.log( + "customer invoices:", + invoices?.map((i: { id: string; status: string; total: number }) => ({ + id: i.id, + status: i.status, + total: i.total, + })), + ); + + const matchingInvoice = invoices?.find( + (i: { id: string }) => i.id === confirmData.invoice_id, + ); + expect(matchingInvoice).toBeDefined(); + }, + { timeout: 30000 }, +); diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index 5198fd49e..0d85320a9 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels.js"; +import { CreateFreeTrialSchema } from "../../../models/productModels/freeTrialModels/freeTrialModels.js"; import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js"; import { BillingParamsBaseSchema } from "../common/billingParamsBase.js"; @@ -19,6 +20,12 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({ options: z.array(FeatureOptionsSchema).nullish(), version: z.number().optional(), + // Free trial override + // - undefined: use product's default trial (check eligibility) + // - null: explicitly disable trial + // - object: custom trial override + free_trial: CreateFreeTrialSchema.nullable().optional(), + // Checkout behavior redirect_mode: RedirectModeSchema.default("always"), success_url: z.string().optional(), diff --git a/shared/api/billing/common/billingPreviewResponse.ts b/shared/api/billing/common/billingPreviewResponse.ts index ab11515e2..050af0fb9 100644 --- a/shared/api/billing/common/billingPreviewResponse.ts +++ b/shared/api/billing/common/billingPreviewResponse.ts @@ -8,6 +8,7 @@ export const PreviewLineItemSchema = z.object({ total_quantity: z.number(), paid_quantity: z.number(), plan_id: z.string(), + deferred_for_trial: z.boolean().optional(), }); export type PreviewLineItem = z.infer; diff --git a/shared/models/billingModels/lineItem/lineItem.ts b/shared/models/billingModels/lineItem/lineItem.ts index b822c1d08..677f41083 100644 --- a/shared/models/billingModels/lineItem/lineItem.ts +++ b/shared/models/billingModels/lineItem/lineItem.ts @@ -27,6 +27,9 @@ export const LineItemSchema = z // Optional - for testing chargeImmediately: z.boolean().default(true), + + // Trial deferral - item will be charged after trial ends + deferredForTrial: z.boolean().optional(), }) .transform((data) => { return {