+
{/* 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 {