-
-
+
+
+
+
+
- {i < 1 &&
}
))}
+
- {/* Total row */}
-
-
-
-
-
+ {/* Total row */}
+
+
+
);
diff --git a/apps/checkout/src/components/checkout/PlanGroupCard.tsx b/apps/checkout/src/components/checkout/PlanGroupCard.tsx
new file mode 100644
index 000000000..118f9b03b
--- /dev/null
+++ b/apps/checkout/src/components/checkout/PlanGroupCard.tsx
@@ -0,0 +1,124 @@
+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, STANDARD_TRANSITION, listItemVariants } from "@/lib/animations";
+import { cn } from "@/lib/utils";
+import { formatAmount } from "@/utils/formatUtils";
+import { CardBackground } from "@/components/checkout/CardBackground";
+
+type PlanChangeType = "incoming" | "outgoing";
+
+interface PlanGroupCardProps {
+ planName: string;
+ items: PreviewLineItem[];
+ currency: string;
+ index: number;
+ type: PlanChangeType;
+}
+
+export function PlanGroupCard({
+ planName,
+ items,
+ currency,
+ index,
+ type,
+}: PlanGroupCardProps) {
+ 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 009c8e4fb..4f1a11339 100644
--- a/apps/checkout/src/components/checkout/PlanSelectionCard.tsx
+++ b/apps/checkout/src/components/checkout/PlanSelectionCard.tsx
@@ -1,5 +1,5 @@
import type { ApiPlanFeature, CheckoutChange } from "@autumn/shared";
-import { Check } from "@phosphor-icons/react";
+import { Check, Package } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { AnimatedCard } from "@/components/motion/animated-layout";
import { Card } from "@/components/ui/card";
@@ -12,17 +12,22 @@ import {
} from "@/lib/animations";
import { formatAmount } from "@/utils/formatUtils";
import { QuantityInput } from "./QuantityInput";
-import { PlanSelectionBackground } from "@/components/checkout/PlanSelectionBackground";
+import { CardBackground } from "@/components/checkout/CardBackground";
-function getPricedFeatures(features: ApiPlanFeature[]): {
+function categorizeFeatures(features: ApiPlanFeature[]): {
prepaid: ApiPlanFeature[];
payPerUse: ApiPlanFeature[];
+ included: ApiPlanFeature[];
} {
const prepaid: ApiPlanFeature[] = [];
const payPerUse: ApiPlanFeature[] = [];
+ const included: ApiPlanFeature[] = [];
for (const feature of features) {
- if (!feature.price) continue;
+ if (!feature.price) {
+ included.push(feature);
+ continue;
+ }
if (feature.price.usage_model === "prepaid") {
prepaid.push(feature);
@@ -31,7 +36,7 @@ function getPricedFeatures(features: ApiPlanFeature[]): {
}
}
- return { prepaid, payPerUse };
+ return { prepaid, payPerUse, included };
}
function formatInterval(interval: string): string {
@@ -73,7 +78,6 @@ interface PlanSelectionCardProps {
quantity: number,
billingUnits: number,
) => void;
- outgoingPlanName?: string;
}
export function PlanSelectionCard({
@@ -81,57 +85,30 @@ export function PlanSelectionCard({
currency,
quantities,
onQuantityChange,
- outgoingPlanName,
}: PlanSelectionCardProps) {
const { plan, feature_quantities } = change;
- const { prepaid, payPerUse } = getPricedFeatures(plan.features);
+ const { prepaid, payPerUse, included } = categorizeFeatures(plan.features);
const basePrice = plan.price;
const hasPricedFeatures = prepaid.length > 0 || payPerUse.length > 0;
+ const hasIncludedFeatures = included.length > 0;
+
+ // Show included features only when there are no priced features
+ const showIncludedFeatures = !hasPricedFeatures && hasIncludedFeatures;
return (
-
-
- {/* Plan change label */}
- {outgoingPlanName && (
-
-
- {plan.customer_eligibility?.scenario === "upgrade"
- ? "Upgrading"
- : plan.customer_eligibility?.scenario === "downgrade"
- ? "Downgrading"
- : "Changing"}{" "}
- from {outgoingPlanName}
-
-
- )}
-
+
{/* Plan header */}
-
-
{plan.name}
- {basePrice && (
-
- {formatAmount(basePrice.amount, currency)} per{" "}
- {basePrice.interval}
-
- )}
+
@@ -144,7 +121,7 @@ export function PlanSelectionCard({
>
{/* Prepaid features - show quantity selector */}
- {prepaid.map((feature) => {
+ {prepaid.map((feature, index) => {
const price = feature.price;
if (!price) return null;
@@ -167,15 +144,17 @@ export function PlanSelectionCard({
layout
transition={STANDARD_TRANSITION}
>
-
-
-
-
+ {index > 0 && (
+
+
+
+ )}
+
-
+
{getFeatureName(feature)}
-
+
{formatAmount(unitPrice, currency)} per{" "}
{billingUnits === 1
? getFeatureUnitDisplay(feature, false)
@@ -185,7 +164,7 @@ export function PlanSelectionCard({
0 || prepaid.length > 0;
+
return (
-
-
-
-
-
+ {showSeparator && (
+
+
+
+ )}
+
+
-
+
{getFeatureName(feature)}
@@ -276,7 +260,58 @@ export function PlanSelectionCard({
)}
-
+
+ {/* Included features - shown only when there are no priced features */}
+ {showIncludedFeatures && (
+
+ {included.map((feature, index) => (
+
+ {index > 0 && (
+
+
+
+ )}
+
+
+
+
+
+
+ {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/PlanSelectionCardSkeleton.tsx b/apps/checkout/src/components/checkout/PlanSelectionCardSkeleton.tsx
index 86262cb8b..e02cd86cf 100644
--- a/apps/checkout/src/components/checkout/PlanSelectionCardSkeleton.tsx
+++ b/apps/checkout/src/components/checkout/PlanSelectionCardSkeleton.tsx
@@ -1,4 +1,4 @@
-import { PlanSelectionBackground } from "@/components/checkout/PlanSelectionBackground";
+import { CardBackground } from "@/components/checkout/CardBackground";
import { AnimatedCard } from "@/components/motion/animated-layout";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
@@ -9,7 +9,7 @@ export function PlanSelectionCardSkeleton() {
return (
-
+
{/* Plan header - matches real component */}
@@ -46,7 +46,7 @@ export function PlanSelectionCardSkeleton() {
-
+
);
diff --git a/apps/checkout/src/components/checkout/QuantityInput.tsx b/apps/checkout/src/components/checkout/QuantityInput.tsx
index caa6c3f5c..df77d9d64 100644
--- a/apps/checkout/src/components/checkout/QuantityInput.tsx
+++ b/apps/checkout/src/components/checkout/QuantityInput.tsx
@@ -75,22 +75,22 @@ export function QuantityInput({
{/* Decrement button */}
-
+
{/* Number display */}
-
+
-
+
);
diff --git a/apps/checkout/src/components/checkout/SectionHeader.tsx b/apps/checkout/src/components/checkout/SectionHeader.tsx
new file mode 100644
index 000000000..05fb04b6a
--- /dev/null
+++ b/apps/checkout/src/components/checkout/SectionHeader.tsx
@@ -0,0 +1,25 @@
+import type { ReactNode } from "react";
+
+interface SectionHeaderProps {
+ title: string;
+ subheading?: string;
+ rightContent?: ReactNode;
+}
+
+export function SectionHeader({
+ title,
+ subheading,
+ rightContent,
+}: SectionHeaderProps) {
+ return (
+
+
+ {title}
+ {rightContent}
+
+ {subheading && (
+
{subheading}
+ )}
+
+ );
+}
diff --git a/apps/checkout/src/pages/CheckoutPage.tsx b/apps/checkout/src/pages/CheckoutPage.tsx
index 7cf51fb72..b6711d3a7 100644
--- a/apps/checkout/src/pages/CheckoutPage.tsx
+++ b/apps/checkout/src/pages/CheckoutPage.tsx
@@ -1,4 +1,5 @@
import type { CheckoutChange, ConfirmCheckoutResponse } from "@autumn/shared";
+import { format } from "date-fns";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
@@ -12,6 +13,7 @@ import { OrderSummary } from "@/components/checkout/OrderSummary";
import { OrderSummarySkeleton } from "@/components/checkout/OrderSummarySkeleton";
import { PlanSelectionCard } from "@/components/checkout/PlanSelectionCard";
import { PlanSelectionCardSkeleton } from "@/components/checkout/PlanSelectionCardSkeleton";
+import { SectionHeader } from "@/components/checkout/SectionHeader";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
@@ -99,6 +101,60 @@ 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 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;
+ }, [checkoutData]);
+
if (!checkoutId) {
return
;
}
@@ -151,6 +207,7 @@ export function CheckoutPage() {
+
{/* Main content - two columns */}
@@ -160,6 +217,11 @@ export function CheckoutPage() {
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.05 }}
>
+
+
{isLoading ? (
) : incoming ? (
@@ -170,18 +232,26 @@ export function CheckoutPage() {
currency={currency}
quantities={quantities}
onQuantityChange={handleQuantityChange}
- outgoingPlanName={outgoing?.[0]?.plan.name}
/>
))
) : null}
+ {/* Vertical separator - visible only on desktop */}
+
+
+
{/* Right column - Order summary */}
+
+
{/* Order summary */}
) : preview ? (
-
+
) : null}
-
- {/* Spacer */}
-
-
- {/* Bottom section */}
-
-
-
- {/* Amount summary */}
-
- {/* Amount due today */}
-
- {isLoading ? (
- <>
-
-
- >
- ) : (
- <>
-
- Amount due today
-
-
- {formatAmount(total, currency)}
-
- >
- )}
-
-
- {/* Amount next cycle */}
- {!isLoading && preview?.next_cycle && (
-
- Total due next cycle
-
- {formatAmount(preview.next_cycle.total, currency)}
-
-
- )}
-
-
- {/* Confirm button */}
- {isLoading ? (
-
- ) : (
-
-
-
- )}
-
- {/* Error message */}
-
- {confirmMutation.error && (
-
- {confirmMutation.error instanceof Error
- ? confirmMutation.error.message
- : "Failed to confirm checkout"}
-
- )}
-
-
+
+
+ {/* Bottom section - full width */}
+
+
+ {/* Amount summary */}
+
+ {/* Amount due today */}
+
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+ Amount due today
+
+
+ {formatAmount(total, currency)}
+
+ >
+ )}
+
+
+ {/* Amount next cycle */}
+ {!isLoading && preview?.next_cycle && (
+
+ Total due next cycle
+
+ {formatAmount(preview.next_cycle.total, currency)}
+
+
+ )}
+
+
+ {/* Confirm button */}
+ {isLoading ? (
+
+ ) : (
+
+
+
+ )}
+
+ {/* Error message */}
+
+ {confirmMutation.error && (
+
+ {confirmMutation.error instanceof Error
+ ? confirmMutation.error.message
+ : "Failed to confirm checkout"}
+
+ )}
+
+
+
{
- // 1. Return undefined if billing cycle anchor is now
const { billingCycleAnchorMs } = billingContext;
- if (billingCycleAnchorMs === "now") return undefined;
-
const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({
autumnBillingPlan: billingPlan.autumn,
});
const { insertCustomerProducts } = billingPlan.autumn;
- // 2. Get cycle end and if none, return undefined
+ // Get all customer products
const allCustomerProducts = [
...insertCustomerProducts,
...(updatedCustomerProduct ? [updatedCustomerProduct] : []),
@@ -50,14 +47,22 @@ export const billingPlanToNextCyclePreview = ({
const smallestInterval = getSmallestInterval({ prices });
+ // Return undefined if there's no recurring interval (not a subscription)
if (!smallestInterval) return undefined;
+ // Calculate next cycle start
+ // If billing cycle anchor is "now" (new subscription), calculate from current time
+ const anchorMs =
+ billingCycleAnchorMs === "now"
+ ? billingContext.currentEpochMs
+ : billingCycleAnchorMs;
+
const nextCycleStart = getCycleEnd({
- anchor: billingCycleAnchorMs,
+ anchor: anchorMs,
interval: smallestInterval.interval,
intervalCount: smallestInterval.intervalCount,
now: billingContext.currentEpochMs,
- floor: billingCycleAnchorMs,
+ floor: anchorMs,
});
customerProducts = customerProducts.filter((customerProduct) => {
diff --git a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts
index 60267002b..6615d561c 100644
--- a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts
+++ b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts
@@ -16,5 +16,6 @@ export const lineItemToPreviewLineItem = (line: LineItem): PreviewLineItem => {
is_base: isBase,
total_quantity: line.total_quantity ?? 1,
paid_quantity: line.paid_quantity ?? 1,
+ plan_id: line.context.product.id,
};
};
diff --git a/server/tests/scenarios/attach/allocated-feature-scenario.test.ts b/server/tests/scenarios/attach/allocated-feature-scenario.test.ts
new file mode 100644
index 000000000..3db3cee9a
--- /dev/null
+++ b/server/tests/scenarios/attach/allocated-feature-scenario.test.ts
@@ -0,0 +1,63 @@
+import { test } from "bun:test";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Allocated Feature Scenario
+ *
+ * Tests attaching a product with allocated (per-seat) features.
+ * Customer is billed with proration when seat count changes mid-cycle.
+ */
+
+test(`${chalk.yellowBright("attach: allocated - per-seat feature")}`, async () => {
+ const customerId = "allocated-feature";
+
+ // Pro plan with allocated seats ($20/mo base + $10/seat prorated)
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.allocatedUsers({ includedUsage: 2 }), // 2 free seats, then $10/seat prorated
+ items.allocatedWorkflows({ includedUsage: 1 }), // 1 free workflow, then $10/workflow prorated
+ ],
+ });
+
+ // Setup: customer with payment method
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [],
+ });
+
+ // 1. Preview attach with allocated features
+ const attachPreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ redirect_mode: "always",
+ });
+ console.log("allocated attach preview:", attachPreview);
+
+ // 2. Attach product with allocated features (Autumn checkout URL)
+ const attachResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ redirect_mode: "always",
+ });
+ console.log("allocated attach result:", attachResult);
+
+ // Get customer state after attach
+ const customerAfter = await autumnV1.customers.get(customerId);
+ console.log("customer after allocated attach:", {
+ products: customerAfter.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+});
diff --git a/server/tests/scenarios/attach/complex-attach-scenario.test.ts b/server/tests/scenarios/attach/complex-attach-scenario.test.ts
index 960f8c8ff..5939619a9 100644
--- a/server/tests/scenarios/attach/complex-attach-scenario.test.ts
+++ b/server/tests/scenarios/attach/complex-attach-scenario.test.ts
@@ -60,6 +60,7 @@ test(`${chalk.yellowBright("attach: complex - product with many line items")}`,
customer_id: customerId,
product_id: enterprise.id,
options,
+ redirect_mode: "always",
});
console.log("preview:", preview);
diff --git a/server/tests/scenarios/attach/consumable-feature-scenario.test.ts b/server/tests/scenarios/attach/consumable-feature-scenario.test.ts
new file mode 100644
index 000000000..5af4dc117
--- /dev/null
+++ b/server/tests/scenarios/attach/consumable-feature-scenario.test.ts
@@ -0,0 +1,64 @@
+import { test } from "bun:test";
+import { TestFeature } from "@tests/setup/v2Features";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Consumable Feature Scenario
+ *
+ * Tests attaching a product with pay-per-use (consumable) features.
+ * Customer is billed in arrears for usage beyond included allowance.
+ */
+
+test(`${chalk.yellowBright("attach: consumable - pay-per-use feature")}`, async () => {
+ const customerId = "consumable-feature";
+
+ // Pro plan with consumable features ($20/mo base + usage overage)
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.consumableMessages({ includedUsage: 100 }), // 100 free, then $0.10/message
+ items.consumableWords({ includedUsage: 200 }), // 200 free, then $0.05/word
+ ],
+ });
+
+ // Setup: customer with payment method
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [],
+ });
+
+ // 1. Preview attach with consumable features
+ const attachPreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ redirect_mode: "always",
+ });
+ console.log("consumable attach preview:", attachPreview);
+
+ // 2. Attach product with consumable features (Autumn checkout URL)
+ const attachResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ redirect_mode: "always",
+ });
+ console.log("consumable attach result:", attachResult);
+
+ // Get customer state after attach
+ const customerAfter = await autumnV1.customers.get(customerId);
+ console.log("customer after consumable attach:", {
+ products: customerAfter.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+});
diff --git a/server/tests/scenarios/attach/credits-feature-scenario.test.ts b/server/tests/scenarios/attach/credits-feature-scenario.test.ts
new file mode 100644
index 000000000..623100f2b
--- /dev/null
+++ b/server/tests/scenarios/attach/credits-feature-scenario.test.ts
@@ -0,0 +1,71 @@
+import { test } from "bun:test";
+import { TestFeature } from "@tests/setup/v2Features";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Credits Feature Scenario
+ *
+ * Tests attaching a product with credit-based (prepaid) features.
+ * Customer purchases credits upfront that can be used for various actions.
+ */
+
+test(`${chalk.yellowBright("attach: credits - credit-based feature")}`, async () => {
+ const customerId = "credits-feature";
+
+ // Pro plan with credit system ($20/mo base + credits for actions)
+ // Credits feature maps to action1 (0.2 credits) and action2 (0.6 credits)
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.monthlyCredits({ includedUsage: 100 }), // 100 free credits per month
+ ],
+ });
+
+ // Setup: customer with payment method
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [],
+ });
+
+ // Options for additional prepaid credits
+ const creditOptions = [
+ { feature_id: TestFeature.Credits, quantity: 500 }, // Purchase 500 additional credits
+ ];
+
+ // 1. Preview attach with credit options
+ const attachPreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ options: creditOptions,
+ redirect_mode: "always",
+ });
+ console.log("credits attach preview:", attachPreview);
+
+ // 2. Attach product with credits (Autumn checkout URL)
+ const attachResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ options: creditOptions,
+ redirect_mode: "always",
+ });
+ console.log("credits attach result:", attachResult);
+
+ // Get customer state after attach
+ const customerAfter = await autumnV1.customers.get(customerId);
+ console.log("customer after credits attach:", {
+ products: customerAfter.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+});
diff --git a/server/tests/scenarios/attach/downgrade-plan-scenario.test.ts b/server/tests/scenarios/attach/downgrade-plan-scenario.test.ts
index 4ac958b82..3fd061575 100644
--- a/server/tests/scenarios/attach/downgrade-plan-scenario.test.ts
+++ b/server/tests/scenarios/attach/downgrade-plan-scenario.test.ts
@@ -58,7 +58,7 @@ test(`${chalk.yellowBright("attach: downgrade - from pro to starter plan")}`, as
const customerBefore = await autumnV1.customers.get(customerId);
console.log("customer before downgrade:", {
products: customerBefore.products?.map(
- (p: { id: string; name: string }) => ({
+ (p: { id: string; name: string | null }) => ({
id: p.id,
name: p.name,
}),
@@ -69,6 +69,7 @@ test(`${chalk.yellowBright("attach: downgrade - from pro to starter plan")}`, as
const downgradePreview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: `starter_${customerId}`,
+ redirect_mode: "always",
});
console.log("downgrade preview:", downgradePreview);
diff --git a/server/tests/scenarios/attach/downgrade-prepaid-scenario.test.ts b/server/tests/scenarios/attach/downgrade-prepaid-scenario.test.ts
index 7c21f730a..463008c51 100644
--- a/server/tests/scenarios/attach/downgrade-prepaid-scenario.test.ts
+++ b/server/tests/scenarios/attach/downgrade-prepaid-scenario.test.ts
@@ -56,6 +56,7 @@ test(`${chalk.yellowBright("attach: downgrade - no prepaid to plan with prepaid
customer_id: customerId,
product_id: `pro_${customerId}`,
options: proOptions,
+ redirect_mode: "always",
});
console.log("downgrade preview:", downgradePreview);
diff --git a/server/tests/scenarios/attach/entity-downgrade-scenario.test.ts b/server/tests/scenarios/attach/entity-downgrade-scenario.test.ts
new file mode 100644
index 000000000..0b845bcb4
--- /dev/null
+++ b/server/tests/scenarios/attach/entity-downgrade-scenario.test.ts
@@ -0,0 +1,106 @@
+import { test } from "bun:test";
+import { TestFeature } from "@tests/setup/v2Features";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Entity Downgrade Scenario
+ *
+ * Tests downgrading a specific entity's plan while another entity keeps its current plan.
+ * Customer has premium on both entities, then downgrades entity-2 to pro (scheduled).
+ */
+
+test(`${chalk.yellowBright("attach: entity downgrade - premium on both, downgrade entity-2 to pro")}`, async () => {
+ const customerId = "entity-downgrade";
+
+ // Premium plan ($50/mo) - top tier features
+ const premium = products.premium({
+ id: "premium",
+ items: [
+ items.dashboard(),
+ items.adminRights(),
+ items.monthlyMessages({
+ includedUsage: 1000,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.consumableWords({
+ includedUsage: 500,
+ entityFeatureId: TestFeature.Users,
+ }),
+ ],
+ });
+
+ // Pro plan ($20/mo) - mid tier features
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.monthlyMessages({
+ includedUsage: 500,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.consumableWords({
+ includedUsage: 200,
+ entityFeatureId: TestFeature.Users,
+ }),
+ ],
+ });
+
+ // Setup: customer with payment method, 2 entities, premium attached to both
+ const { autumnV1, entities } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [premium, pro] }),
+ s.entities({ count: 2, featureId: TestFeature.Users }),
+ ],
+ actions: [
+ // Attach premium plan to both entities
+ s.attach({ productId: "premium", entityIndex: 0 }),
+ s.attach({ productId: "premium", entityIndex: 1 }),
+ ],
+ });
+
+ // Get customer state after initial attaches
+ const customerBefore = await autumnV1.customers.get(customerId);
+ console.log("customer before entity downgrade:", {
+ products: customerBefore.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ entities: entities.map((e) => ({ id: e.id, name: e.name })),
+ });
+
+ // 1. Preview downgrading entity-2 to pro (will be scheduled for end of cycle)
+ const downgradePreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ entity_id: entities[1].id, // ent-2
+ redirect_mode: "always",
+ });
+ console.log("entity downgrade preview:", downgradePreview);
+
+ // 2. Downgrade entity-2 to pro (automatically scheduled for end of cycle)
+ const downgradeResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ entity_id: entities[1].id, // ent-2
+ redirect_mode: "always",
+ });
+ console.log("entity downgrade result:", downgradeResult);
+
+ // Get customer state after scheduled downgrade
+ const customerAfter = await autumnV1.customers.get(customerId);
+ console.log("customer after entity downgrade:", {
+ products: customerAfter.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+});
diff --git a/server/tests/scenarios/attach/entity-new-attach-scenario.test.ts b/server/tests/scenarios/attach/entity-new-attach-scenario.test.ts
new file mode 100644
index 000000000..812397af0
--- /dev/null
+++ b/server/tests/scenarios/attach/entity-new-attach-scenario.test.ts
@@ -0,0 +1,77 @@
+import { test } from "bun:test";
+import { TestFeature } from "@tests/setup/v2Features";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Entity New Attach Scenario
+ *
+ * Tests attaching a product to a new entity when another entity already has a plan.
+ * Customer has pro on entity-1, then attaches pro to entity-2 (new entity).
+ */
+
+test(`${chalk.yellowBright("attach: entity - pro on entity-1, attach pro to entity-2")}`, async () => {
+ const customerId = "entity-new-attach";
+
+ // Pro plan ($20/mo) - standard features
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.monthlyMessages({
+ includedUsage: 200,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.consumableWords({
+ includedUsage: 50,
+ entityFeatureId: TestFeature.Users,
+ }),
+ ],
+ });
+
+ // Setup: customer with payment method and 2 entities, pro attached to entity-1
+ const { autumnV1, entities } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ s.entities({ count: 2, featureId: TestFeature.Users }),
+ ],
+ actions: [
+ // Attach pro plan to entity-1 first
+ s.attach({ productId: "pro", entityIndex: 0 }),
+ ],
+ });
+
+ // Get customer state after initial attach
+ const customerBefore = await autumnV1.customers.get(customerId);
+ console.log("customer before new entity attach:", {
+ products: customerBefore.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ entities: entities.map((e) => ({ id: e.id, name: e.name })),
+ });
+
+ // 1. Preview attaching pro to entity-2
+ const attachPreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ entity_id: entities[1].id, // ent-2
+ redirect_mode: "always",
+ });
+ console.log("new entity attach preview:", attachPreview);
+
+ // 2. Attach pro to entity-2 with redirect_mode: "always" (Autumn checkout URL)
+ const attachResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ entity_id: entities[1].id, // ent-2
+ redirect_mode: "always",
+ });
+ console.log("new entity attach result:", attachResult);
+});
diff --git a/server/tests/scenarios/attach/entity-upgrade-proration-scenario.test.ts b/server/tests/scenarios/attach/entity-upgrade-proration-scenario.test.ts
new file mode 100644
index 000000000..1057cb062
--- /dev/null
+++ b/server/tests/scenarios/attach/entity-upgrade-proration-scenario.test.ts
@@ -0,0 +1,107 @@
+import { test } from "bun:test";
+import { TestFeature } from "@tests/setup/v2Features";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Entity Upgrade Proration Scenario
+ *
+ * Tests upgrading a specific entity's plan mid-billing cycle for proration.
+ * Customer has pro on both entities, advances 15 days, then upgrades entity-2 to premium.
+ * This tests that proration is calculated correctly when upgrading halfway through the cycle.
+ */
+
+test(`${chalk.yellowBright("attach: entity - upgrade entity-2 to premium mid-cycle (proration)")}`, async () => {
+ const customerId = "entity-upgrade-proration";
+
+ // Pro plan ($20/mo) - standard features
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.monthlyMessages({
+ includedUsage: 200,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.consumableWords({
+ includedUsage: 50,
+ entityFeatureId: TestFeature.Users,
+ }),
+ ],
+ });
+
+ // Premium plan ($50/mo) - more features, higher limits
+ const premium = products.premium({
+ id: "premium",
+ items: [
+ items.dashboard(),
+ items.adminRights(),
+ items.monthlyMessages({
+ includedUsage: 500,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.consumableWords({
+ includedUsage: 200,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.prepaidUsers({ includedUsage: 5, billingUnits: 1 }),
+ ],
+ });
+
+ // Setup: customer with test clock, payment method, 2 entities, pro attached to both
+ // Then advance 15 days (halfway through billing cycle)
+ const { autumnV1, entities, advancedTo } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ s.entities({ count: 2, featureId: TestFeature.Users }),
+ ],
+ actions: [
+ // Attach pro plan to both entities
+ s.attach({ productId: "pro", entityIndex: 0 }),
+ s.attach({ productId: "pro", entityIndex: 1 }),
+ // Advance 15 days - halfway through the billing cycle
+ s.advanceTestClock({ days: 15 }),
+ ],
+ });
+
+ console.log("advanced to:", new Date(advancedTo).toISOString());
+
+ // Get customer state after initial attaches and clock advance
+ const customerBefore = await autumnV1.customers.get(customerId);
+ console.log("customer before mid-cycle upgrade:", {
+ products: customerBefore.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ entities: entities.map((e) => ({ id: e.id, name: e.name })),
+ });
+
+ // Options for prepaid features in premium plan
+ const premiumOptions = [{ feature_id: TestFeature.Users, quantity: 10 }];
+
+ // 1. Preview upgrading entity-2 to premium (should show prorated amounts)
+ const upgradePreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `premium_${customerId}`,
+ entity_id: entities[1].id, // ent-2
+ options: premiumOptions,
+ redirect_mode: "always",
+ });
+ console.log("mid-cycle entity upgrade preview (prorated):", upgradePreview);
+
+ // 2. Upgrade entity-2 to premium mid-cycle (Autumn checkout URL)
+ const upgradeResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `premium_${customerId}`,
+ entity_id: entities[1].id, // ent-2
+ redirect_mode: "always",
+ options: premiumOptions,
+ });
+ console.log("mid-cycle entity upgrade result:", upgradeResult);
+});
diff --git a/server/tests/scenarios/attach/entity-upgrade-scenario.test.ts b/server/tests/scenarios/attach/entity-upgrade-scenario.test.ts
new file mode 100644
index 000000000..36fe06c20
--- /dev/null
+++ b/server/tests/scenarios/attach/entity-upgrade-scenario.test.ts
@@ -0,0 +1,101 @@
+import { test } from "bun:test";
+import { TestFeature } from "@tests/setup/v2Features";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Entity Upgrade Scenario
+ *
+ * Tests upgrading a specific entity's plan while another entity keeps its current plan.
+ * Customer has pro on both entities, then upgrades entity-2 to premium.
+ */
+
+test(`${chalk.yellowBright("attach: entity - pro on both entities, upgrade entity-2 to premium")}`, async () => {
+ const customerId = "entity-upgrade";
+
+ // Pro plan ($20/mo) - standard features
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.monthlyMessages({
+ includedUsage: 200,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.consumableWords({
+ includedUsage: 50,
+ entityFeatureId: TestFeature.Users,
+ }),
+ ],
+ });
+
+ // Premium plan ($50/mo) - more features, higher limits
+ const premium = products.premium({
+ id: "premium",
+ items: [
+ items.dashboard(),
+ items.adminRights(),
+ items.monthlyMessages({
+ includedUsage: 500,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.consumableWords({
+ includedUsage: 200,
+ entityFeatureId: TestFeature.Users,
+ }),
+ items.prepaidUsers({ includedUsage: 5, billingUnits: 1 }),
+ ],
+ });
+
+ // Setup: customer with payment method and 2 entities, pro attached to both
+ const { autumnV1, entities } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ s.entities({ count: 2, featureId: TestFeature.Users }),
+ ],
+ actions: [
+ // Attach pro plan to both entities
+ s.attach({ productId: "pro", entityIndex: 0 }),
+ s.attach({ productId: "pro", entityIndex: 1 }),
+ ],
+ });
+
+ // Get customer state after initial attaches
+ const customerBefore = await autumnV1.customers.get(customerId);
+ console.log("customer before entity upgrade:", {
+ products: customerBefore.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ entities: entities.map((e) => ({ id: e.id, name: e.name })),
+ });
+
+ // Options for prepaid features in premium plan
+ const premiumOptions = [{ feature_id: TestFeature.Users, quantity: 10 }];
+
+ // 1. Preview upgrading entity-2 to premium
+ const upgradePreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `premium_${customerId}`,
+ entity_id: entities[1].id, // ent-2
+ options: premiumOptions,
+ redirect_mode: "always",
+ });
+ console.log("entity upgrade preview:", upgradePreview);
+
+ // 2. Upgrade entity-2 to premium with redirect_mode: "always" (Autumn checkout URL)
+ const upgradeResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `premium_${customerId}`,
+ entity_id: entities[1].id, // ent-2
+ redirect_mode: "always",
+ options: premiumOptions,
+ });
+ console.log("entity upgrade result:", upgradeResult);
+});
diff --git a/server/tests/scenarios/attach/prepaid-quantities-scenario.test.ts b/server/tests/scenarios/attach/prepaid-quantities-scenario.test.ts
new file mode 100644
index 000000000..d02ed04d5
--- /dev/null
+++ b/server/tests/scenarios/attach/prepaid-quantities-scenario.test.ts
@@ -0,0 +1,72 @@
+import { test } from "bun:test";
+import { TestFeature } from "@tests/setup/v2Features";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Prepaid Quantities Scenario
+ *
+ * Tests attaching a product with prepaid quantity options.
+ * Customer purchases upfront units for prepaid features.
+ */
+
+test(`${chalk.yellowBright("attach: prepaid quantities - with prepaid options")}`, async () => {
+ const customerId = "prepaid-quantities";
+
+ // Pro plan with prepaid features ($20/mo base + prepaid units)
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.prepaidMessages({ includedUsage: 50, billingUnits: 100 }), // $10 per 100 messages
+ items.prepaidUsers({ includedUsage: 2, billingUnits: 1 }), // $10 per user seat
+ ],
+ });
+
+ // Setup: customer with payment method
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [],
+ });
+
+ // Prepaid options - purchase additional units upfront
+ const prepaidOptions = [
+ { feature_id: TestFeature.Messages, quantity: 500 }, // 5 packs of 100
+ { feature_id: TestFeature.Users, quantity: 5 }, // 5 user seats
+ ];
+
+ // 1. Preview attach with prepaid quantities
+ const attachPreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ options: prepaidOptions,
+ redirect_mode: "always",
+ });
+ console.log("prepaid attach preview:", attachPreview);
+
+ // 2. Attach with prepaid quantities (Autumn checkout URL)
+ const attachResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ options: prepaidOptions,
+ redirect_mode: "always",
+ });
+ console.log("prepaid attach result:", attachResult);
+
+ // Get customer state after attach
+ const customerAfter = await autumnV1.customers.get(customerId);
+ console.log("customer after prepaid attach:", {
+ products: customerAfter.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+});
diff --git a/server/tests/scenarios/attach/scheduled-downgrade-free-scenario.test.ts b/server/tests/scenarios/attach/scheduled-downgrade-free-scenario.test.ts
new file mode 100644
index 000000000..2dff79c8b
--- /dev/null
+++ b/server/tests/scenarios/attach/scheduled-downgrade-free-scenario.test.ts
@@ -0,0 +1,85 @@
+import { test } from "bun:test";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Scheduled Downgrade to Free Scenario
+ *
+ * Tests downgrading from a paid plan to a free plan.
+ * Downgrade is automatically scheduled for end of billing cycle.
+ * Customer has pro, then downgrades to free (scheduled).
+ */
+
+test(`${chalk.yellowBright("attach: scheduled downgrade - pro to free")}`, async () => {
+ const customerId = "scheduled-downgrade-free";
+
+ // Pro plan ($20/mo) - paid features
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.adminRights(),
+ items.monthlyMessages({ includedUsage: 500 }),
+ items.consumableWords({ includedUsage: 200 }),
+ ],
+ });
+
+ // Free plan ($0/mo) - basic features only
+ const free = products.base({
+ id: "free",
+ items: [items.dashboard(), items.monthlyMessages({ includedUsage: 50 })],
+ });
+
+ // Setup: customer with payment method and pro plan attached
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro, free] }),
+ ],
+ actions: [
+ // Attach pro plan first
+ s.attach({ productId: "pro" }),
+ ],
+ });
+
+ // Get customer state after initial attach
+ const customerBefore = await autumnV1.customers.get(customerId);
+ console.log("customer before scheduled downgrade to free:", {
+ products: customerBefore.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+
+ // 1. Preview the downgrade to free (will be scheduled for end of cycle)
+ const downgradePreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `free_${customerId}`,
+ redirect_mode: "always",
+ });
+ console.log("scheduled downgrade to free preview:", downgradePreview);
+
+ // 2. Perform the downgrade (automatically scheduled for end of cycle)
+ const downgradeResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `free_${customerId}`,
+ redirect_mode: "always",
+ });
+ console.log("scheduled downgrade to free result:", downgradeResult);
+
+ // Get customer state after scheduled downgrade
+ const customerAfter = await autumnV1.customers.get(customerId);
+ console.log("customer after scheduled downgrade:", {
+ products: customerAfter.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+});
diff --git a/server/tests/scenarios/attach/scheduled-downgrade-paid-scenario.test.ts b/server/tests/scenarios/attach/scheduled-downgrade-paid-scenario.test.ts
new file mode 100644
index 000000000..ebc74092c
--- /dev/null
+++ b/server/tests/scenarios/attach/scheduled-downgrade-paid-scenario.test.ts
@@ -0,0 +1,90 @@
+import { test } from "bun:test";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+/**
+ * Scheduled Downgrade Between Paid Plans Scenario
+ *
+ * Tests downgrading from a premium plan to a cheaper paid plan.
+ * Downgrade is automatically scheduled for end of billing cycle.
+ * Customer has premium ($50/mo), then downgrades to pro ($20/mo) (scheduled).
+ */
+
+test(`${chalk.yellowBright("attach: scheduled downgrade - premium to pro")}`, async () => {
+ const customerId = "scheduled-downgrade-paid";
+
+ // Premium plan ($50/mo) - top tier features
+ const premium = products.premium({
+ id: "premium",
+ items: [
+ items.dashboard(),
+ items.adminRights(),
+ items.monthlyMessages({ includedUsage: 1000 }),
+ items.consumableWords({ includedUsage: 500 }),
+ items.allocatedUsers({ includedUsage: 10 }),
+ ],
+ });
+
+ // Pro plan ($20/mo) - mid tier features
+ const pro = products.pro({
+ id: "pro",
+ items: [
+ items.dashboard(),
+ items.monthlyMessages({ includedUsage: 500 }),
+ items.consumableWords({ includedUsage: 200 }),
+ ],
+ });
+
+ // Setup: customer with payment method and premium plan attached
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [premium, pro] }),
+ ],
+ actions: [
+ // Attach premium plan first
+ s.attach({ productId: "premium" }),
+ ],
+ });
+
+ // Get customer state after initial attach
+ const customerBefore = await autumnV1.customers.get(customerId);
+ console.log("customer before scheduled downgrade to pro:", {
+ products: customerBefore.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+
+ // 1. Preview the downgrade to pro (will be scheduled for end of cycle)
+ const downgradePreview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ redirect_mode: "always",
+ });
+ console.log("scheduled downgrade to pro preview:", downgradePreview);
+
+ // 2. Perform the downgrade (automatically scheduled for end of cycle)
+ const downgradeResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ redirect_mode: "always",
+ });
+ console.log("scheduled downgrade to pro result:", downgradeResult);
+
+ // Get customer state after scheduled downgrade
+ const customerAfter = await autumnV1.customers.get(customerId);
+ console.log("customer after scheduled downgrade:", {
+ products: customerAfter.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
+ });
+});
diff --git a/server/tests/scenarios/attach/upgrade-plan-scenario.test.ts b/server/tests/scenarios/attach/upgrade-plan-scenario.test.ts
index 27e7f0f67..514c8d064 100644
--- a/server/tests/scenarios/attach/upgrade-plan-scenario.test.ts
+++ b/server/tests/scenarios/attach/upgrade-plan-scenario.test.ts
@@ -54,10 +54,12 @@ test(`${chalk.yellowBright("attach: upgrade - from starter to pro plan")}`, asyn
// Get customer state after initial attach
const customerBefore = await autumnV1.customers.get(customerId);
console.log("customer before upgrade:", {
- products: customerBefore.products?.map((p: { id: string; name: string }) => ({
- id: p.id,
- name: p.name,
- })),
+ products: customerBefore.products?.map(
+ (p: { id: string; name: string | null }) => ({
+ id: p.id,
+ name: p.name,
+ }),
+ ),
});
// Options for prepaid features in pro plan
@@ -68,6 +70,7 @@ test(`${chalk.yellowBright("attach: upgrade - from starter to pro plan")}`, asyn
customer_id: customerId,
product_id: `pro_${customerId}`,
options: proOptions,
+ redirect_mode: "always",
});
console.log("upgrade preview:", upgradePreview);
diff --git a/shared/api/billing/common/billingPreviewResponse.ts b/shared/api/billing/common/billingPreviewResponse.ts
index 7c37f1ff8..ab11515e2 100644
--- a/shared/api/billing/common/billingPreviewResponse.ts
+++ b/shared/api/billing/common/billingPreviewResponse.ts
@@ -7,6 +7,7 @@ export const PreviewLineItemSchema = z.object({
is_base: z.boolean().optional(),
total_quantity: z.number(),
paid_quantity: z.number(),
+ plan_id: z.string(),
});
export type PreviewLineItem = z.infer;