chore: increase edge case handling

This commit is contained in:
Charlie Lamb
2026-02-02 16:52:56 +00:00
parent 80c060e218
commit c4bedbeee2
27 changed files with 1407 additions and 348 deletions

View File

@@ -5,7 +5,7 @@ import { motion } from "motion/react";
* Full-screen background wrapper with subtle diagonal gradients from primary color.
* Includes entrance animation for the content container.
*/
export function PlanSelectionBackground({ children }: { children: ReactNode }) {
export function CardBackground({ children }: { children: ReactNode }) {
return (
<div className="bg-card relative overflow-hidden">
{/* Top-right diagonal gradient */}

View File

@@ -1,26 +1,37 @@
import type { BillingPreviewResponse, PreviewLineItem } from "@autumn/shared";
import type {
BillingPreviewResponse,
CheckoutChange,
PreviewLineItem,
} from "@autumn/shared";
import { format } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import { useMemo } from "react";
import { AnimatedLayout } from "@/components/motion/animated-layout";
import { Separator } from "@/components/ui/separator";
import {
FAST_TRANSITION,
STANDARD_TRANSITION,
listContainerVariants,
listItemVariants,
} from "@/lib/animations";
import { formatAmount } from "@/utils/formatUtils";
import { PlanGroupCard } from "@/components/checkout/PlanGroupCard";
import { STANDARD_TRANSITION, listContainerVariants } from "@/lib/animations";
interface PlanGroup {
planId: string;
planName: string;
items: PreviewLineItem[];
type: "incoming" | "outgoing";
}
interface OrderSummaryProps {
planName: string;
preview: BillingPreviewResponse;
incoming?: CheckoutChange[];
outgoing?: CheckoutChange[];
}
export function OrderSummary({ planName, preview }: OrderSummaryProps) {
const { line_items, total, currency, period_start, period_end, next_cycle } =
preview;
export function OrderSummary({
planName,
preview,
incoming = [],
outgoing = [],
}: OrderSummaryProps) {
const { line_items, total, currency, next_cycle } = preview;
const hasBillingPeriod = period_start && period_end;
const hasNoImmediateCharges = line_items.length === 0 && total === 0;
const showNextCycleBreakdown = hasNoImmediateCharges && next_cycle;
@@ -28,160 +39,108 @@ export function OrderSummary({ planName, preview }: OrderSummaryProps) {
const displayLineItems: PreviewLineItem[] = showNextCycleBreakdown
? next_cycle.line_items
: line_items;
const displayTotal = showNextCycleBreakdown ? next_cycle.total : total;
// Separate base item from sub-items
const baseItem = displayLineItems.find((item) => item.is_base);
const subItems = displayLineItems.filter((item) => !item.is_base);
// Build a map of plan_id -> plan_name from incoming and outgoing
const planNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const change of [...outgoing, ...incoming]) {
map.set(change.plan.id, change.plan.name || change.plan.id);
}
return map;
}, [incoming, outgoing]);
// Group line items by plan_id
const planGroups = useMemo((): PlanGroup[] => {
const groupMap = new Map<string, PreviewLineItem[]>();
for (const item of displayLineItems) {
const planId = item.plan_id;
if (!groupMap.has(planId)) {
groupMap.set(planId, []);
}
groupMap.get(planId)!.push(item);
}
// Convert to array, with outgoing plans first (credits), then incoming plans
const outgoingIds = new Set(outgoing.map((c) => c.plan.id));
const incomingIds = new Set(incoming.map((c) => c.plan.id));
const groups: PlanGroup[] = [];
// Add outgoing plan groups first (including those with no line items like free plans)
for (const change of outgoing) {
const planId = change.plan.id;
const items = groupMap.get(planId) || [];
groups.push({
planId,
planName: planNameMap.get(planId) || planId,
items,
type: "outgoing",
});
}
// Add incoming plan groups (including those with no line items)
for (const change of incoming) {
const planId = change.plan.id;
const items = groupMap.get(planId) || [];
groups.push({
planId,
planName: planNameMap.get(planId) || planId,
items,
type: "incoming",
});
}
// Add any remaining line item groups that weren't in incoming/outgoing
for (const [planId, items] of groupMap) {
if (!outgoingIds.has(planId) && !incomingIds.has(planId)) {
groups.push({
planId,
planName: planNameMap.get(planId) || planId,
items,
type: "incoming",
});
}
}
return groups;
}, [displayLineItems, outgoing, incoming, planNameMap]);
return (
<AnimatedLayout
className="flex flex-col"
className="flex flex-col gap-4"
layoutId="order-summary"
variants={listContainerVariants}
initial="initial"
animate="animate"
>
{/* Plan name and billing period */}
<motion.div
className="flex items-center justify-between pb-3"
variants={listItemVariants}
>
<span className="text-foreground">{planName}</span>
{hasBillingPeriod && (
<motion.span
className="text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ ...STANDARD_TRANSITION, delay: 0.1 }}
>
{format(period_start, "d MMM yyyy")}
</motion.span>
)}
</motion.div>
<motion.div
initial={{ scaleX: 0, originX: 0 }}
animate={{ scaleX: 1 }}
transition={STANDARD_TRANSITION}
>
<Separator />
</motion.div>
{/* Line items */}
<div className="flex flex-col">
{/* Base item */}
{/* Plan groups as cards */}
<div className="flex flex-col gap-3">
<AnimatePresence mode="popLayout">
{baseItem && (
<motion.div
key="base-item"
layout
variants={listItemVariants}
initial="initial"
animate="animate"
exit="exit"
>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Base Price</span>
<motion.span
key={baseItem.amount}
className="text-sm tabular-nums text-muted-foreground"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={FAST_TRANSITION}
>
{formatAmount(baseItem.amount, currency)}
</motion.span>
</div>
<Separator />
</motion.div>
)}
</AnimatePresence>
{/* Sub-items */}
<AnimatePresence mode="popLayout">
{subItems.map((item, index) => (
<motion.div
key={item.title}
layout
variants={listItemVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ ...STANDARD_TRANSITION, delay: index * 0.03 }}
>
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{item.title}
</span>
{item.total_quantity > 1 && (
<motion.span
key={item.total_quantity}
className="text-sm text-muted-foreground"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={FAST_TRANSITION}
>
x{item.total_quantity}
</motion.span>
)}
</div>
<motion.span
key={item.amount}
className="text-sm tabular-nums text-muted-foreground"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={FAST_TRANSITION}
>
{formatAmount(item.amount, currency)}
</motion.span>
</div>
{index < subItems.length - 1 && <Separator />}
</motion.div>
{planGroups.map((group, groupIndex) => (
<PlanGroupCard
key={group.planId}
planName={group.planName}
items={group.items}
currency={currency}
index={groupIndex}
type={group.type}
/>
))}
</AnimatePresence>
{/* Total row */}
<motion.div
initial={{ scaleX: 0, originX: 0 }}
animate={{ scaleX: 1 }}
transition={{ ...STANDARD_TRANSITION, delay: 0.1 }}
>
<Separator />
</motion.div>
<motion.div
className="flex items-center justify-between py-3"
variants={listItemVariants}
initial="initial"
animate="animate"
transition={{ ...STANDARD_TRANSITION, delay: 0.15 }}
>
<span className="text-sm font-medium text-foreground">Total</span>
<motion.span
key={displayTotal}
className="text-sm font-medium tabular-nums text-foreground"
initial={{ opacity: 0.5, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={FAST_TRANSITION}
>
{formatAmount(displayTotal, currency)}
</motion.span>
</motion.div>
{/* Message explaining changes take effect next cycle */}
{showNextCycleBreakdown && (
<motion.p
className="text-xs text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ ...STANDARD_TRANSITION, delay: 0.2 }}
>
Changes take effect{" "}
{format(new Date(next_cycle.starts_at), "d MMM yyyy")}
</motion.p>
)}
</div>
{/* Message explaining changes take effect next cycle */}
{showNextCycleBreakdown && (
<motion.p
className="text-xs text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ ...STANDARD_TRANSITION, delay: 0.2 }}
>
Changes take effect{" "}
{format(new Date(next_cycle.starts_at), "d MMM yyyy")}
</motion.p>
)}
</AnimatedLayout>
);
}

View File

@@ -1,44 +1,38 @@
import { AnimatedLayout } from "@/components/motion/animated-layout";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
/** Skeleton that matches OrderSummary layout exactly */
export function OrderSummarySkeleton() {
return (
<AnimatedLayout className="flex flex-col" layoutId="order-summary">
{/* Plan name and billing period */}
<div className="flex items-center justify-between pb-3">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<Separator />
{/* Line items - simulate 3 items */}
<div className="flex flex-col">
{/* Base item */}
<div className="flex items-center justify-between py-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-12" />
<AnimatedLayout className="flex flex-col gap-4" layoutId="order-summary">
{/* Plan group card skeleton */}
<Card className="py-0 gap-0">
{/* Plan name header */}
<div className="flex items-center justify-between px-4 py-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
<Separator />
{/* Sub-items */}
{/* Line items */}
{[0, 1].map((i) => (
<div key={`line-skeleton-${i}`}>
<div className="flex items-center justify-between py-3">
<Skeleton className="h-4 w-24" />
<div className="px-4">
<Separator />
</div>
<div className="flex items-center justify-between px-4 py-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-12" />
</div>
{i < 1 && <Separator />}
</div>
))}
</Card>
{/* Total row */}
<Separator />
<div className="flex items-center justify-between py-3">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-14" />
</div>
{/* Total row */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-14" />
</div>
</AnimatedLayout>
);

View File

@@ -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 (
<motion.div
layout
variants={listItemVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
...STANDARD_TRANSITION,
delay: index * 0.05,
}}
className="rounded-lg border border-border overflow-hidden"
>
<CardBackground>
{/* Plan header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-background/50">
<div className="flex items-center gap-2">
<Icon
className={cn(
"h-4 w-4",
type === "outgoing"
? "dark:text-red-400/60 text-red-500"
: "dark:text-emerald-400/60 text-emerald-500"
)}
weight="bold"
/>
<span className="text-sm font-medium text-foreground">
{planName}
</span>
</div>
<span className="text-sm font-medium tabular-nums text-foreground">
{formatAmount(groupTotal, currency)}
</span>
</div>
{/* Line items for this plan */}
<div className="px-3">
{sortedItems.length === 0 ? (
<div className="flex items-center justify-between py-2.5">
<span className="text-sm text-muted-foreground">
{type === "outgoing" ? "No charges" : "Free"}
</span>
<span className="text-sm tabular-nums text-muted-foreground">
{formatAmount(0, currency)}
</span>
</div>
) : (
sortedItems.map((item, itemIndex) => (
<div key={`${item.title}-${itemIndex}`}>
<div className="flex items-center justify-between py-2.5">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{item.is_base ? "Base Price" : item.title}
</span>
{!item.is_base && item.total_quantity > 1 && (
<motion.span
key={item.total_quantity}
className="text-xs text-muted-foreground"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={FAST_TRANSITION}
>
x{item.total_quantity}
</motion.span>
)}
</div>
<motion.span
key={item.amount}
className="text-sm tabular-nums text-muted-foreground"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={FAST_TRANSITION}
>
{formatAmount(item.amount, currency)}
</motion.span>
</div>
{itemIndex < sortedItems.length - 1 && (
<Separator className="opacity-50" />
)}
</div>
))
)}
</div>
</CardBackground>
</motion.div>
);
}

View File

@@ -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 (
<AnimatedCard layoutId="plan-selection-card">
<Card className="py-0 gap-0">
<PlanSelectionBackground>
{/* Plan change label */}
{outgoingPlanName && (
<motion.div
className="px-4 pt-3 pb-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={STANDARD_TRANSITION}
>
<span className="text-xs text-muted-foreground">
{plan.customer_eligibility?.scenario === "upgrade"
? "Upgrading"
: plan.customer_eligibility?.scenario === "downgrade"
? "Downgrading"
: "Changing"}{" "}
from {outgoingPlanName}
</span>
</motion.div>
)}
<CardBackground>
{/* Plan header */}
<motion.div
className={`flex items-center justify-between px-4 ${outgoingPlanName ? "pt-1 pb-4" : "py-4"}`}
className="flex items-center px-3 py-2 border-b bg-background/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={STANDARD_TRANSITION}
>
<div className="flex justify-between items-center w-full gap-0.5">
<span className="text-base text-foreground">{plan.name}</span>
{basePrice && (
<motion.div
className="flex items-center gap-1 text-muted-foreground"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ ...STANDARD_TRANSITION, delay: 0.1 }}
>
{formatAmount(basePrice.amount, currency)} per{" "}
{basePrice.interval}
</motion.div>
)}
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" weight="bold" />
<span className="text-sm font-medium text-foreground">{plan.name}</span>
</div>
</motion.div>
@@ -144,7 +121,7 @@ export function PlanSelectionCard({
>
{/* Prepaid features - show quantity selector */}
<AnimatePresence>
{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}
>
<div className="px-4">
<Separator className="w-auto" />
</div>
<div className="flex items-center justify-between px-4 py-4">
{index > 0 && (
<div className="px-3">
<Separator />
</div>
)}
<div className="flex items-center justify-between px-3 py-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-foreground">
<span className="text-sm text-foreground">
{getFeatureName(feature)}
</span>
<span className="text-sm text-muted-foreground">
<span className="text-xs text-muted-foreground">
{formatAmount(unitPrice, currency)} per{" "}
{billingUnits === 1
? getFeatureUnitDisplay(feature, false)
@@ -185,7 +164,7 @@ export function PlanSelectionCard({
<div className="flex items-center gap-4">
<motion.span
key={totalPrice}
className="text-[15px] text-muted-foreground leading-none tracking-tight tabular-nums"
className="text-sm text-muted-foreground tabular-nums"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={FAST_TRANSITION}
@@ -235,6 +214,9 @@ export function PlanSelectionCard({
priceDisplay = formatAmount(price.amount || 0, currency);
}
// Show separator if there are prepaid features before, or if not the first pay-per-use
const showSeparator = index > 0 || prepaid.length > 0;
return (
<motion.div
key={feature.feature_id}
@@ -242,11 +224,13 @@ export function PlanSelectionCard({
layout
transition={STANDARD_TRANSITION}
>
<div className="px-4">
<Separator className="w-auto" />
</div>
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
{showSeparator && (
<div className="px-3">
<Separator />
</div>
)}
<div className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-2">
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
@@ -259,7 +243,7 @@ export function PlanSelectionCard({
>
<Check className="h-4 w-4 text-muted-foreground shrink-0" />
</motion.div>
<span className="text-sm text-foreground">
<span className="text-sm text-muted-foreground">
{getFeatureName(feature)}
</span>
</div>
@@ -276,7 +260,58 @@ export function PlanSelectionCard({
</AnimatePresence>
</motion.div>
)}
</PlanSelectionBackground>
{/* Included features - shown only when there are no priced features */}
{showIncludedFeatures && (
<motion.div
className="flex flex-col"
variants={listContainerVariants}
initial="initial"
animate="animate"
>
{included.map((feature, index) => (
<motion.div
key={feature.feature_id}
variants={listItemVariants}
layout
transition={STANDARD_TRANSITION}
>
{index > 0 && (
<div className="px-3">
<Separator />
</div>
)}
<div className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-2">
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
type: "spring",
stiffness: 400,
damping: 20,
delay: 0.1 + index * 0.05,
}}
>
<Check className="h-4 w-4 text-muted-foreground shrink-0" />
</motion.div>
<span className="text-sm text-muted-foreground">
{getFeatureName(feature)}
</span>
</div>
{feature.included_usage !== undefined && feature.included_usage !== null && (
<span className="text-sm text-muted-foreground">
{feature.included_usage === -1
? "Unlimited"
: `${feature.included_usage} included`}
</span>
)}
</div>
</motion.div>
))}
</motion.div>
)}
</CardBackground>
</Card>
</AnimatedCard>
);

View File

@@ -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 (
<AnimatedCard layoutId="plan-selection-card">
<Card className="py-0 gap-0 flex-1">
<PlanSelectionBackground>
<CardBackground>
{/* Plan header - matches real component */}
<div className="flex items-center justify-between px-4 py-4">
<Skeleton className="h-5 w-32" />
@@ -46,7 +46,7 @@ export function PlanSelectionCardSkeleton() {
<Skeleton className="h-9 w-28 rounded-md" />
</div>
</div>
</PlanSelectionBackground>
</CardBackground>
</Card>
</AnimatedCard>
);

View File

@@ -75,22 +75,22 @@ export function QuantityInput({
{/* Decrement button */}
<motion.button
type="button"
className="h-8 w-8 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-r border-border"
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-r border-border"
onClick={handleDecrement}
disabled={disabled || isAtMin}
whileTap={{ scale: 0.9 }}
transition={FAST_TRANSITION}
>
<Minus className="h-3.5 w-3.5" weight="bold" />
<Minus className="h-2.5 w-2.5" weight="bold" />
</motion.button>
{/* Number display */}
<div className="w-16 h-8 flex items-center justify-center overflow-hidden relative">
<div className="w-10 h-6 flex items-center justify-center overflow-hidden relative">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="w-full h-full text-center text-sm font-medium tabular-nums text-foreground bg-transparent border-none focus:outline-none focus:ring-0 disabled:opacity-50"
className="w-full h-full text-center text-xs font-medium tabular-nums text-foreground bg-transparent border-none focus:outline-none focus:ring-0 disabled:opacity-50"
value={inputValue}
onChange={handleInputChange}
onBlur={handleBlur}
@@ -101,13 +101,13 @@ export function QuantityInput({
{/* Increment button */}
<motion.button
type="button"
className="h-8 w-8 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-l border-border"
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-l border-border"
onClick={handleIncrement}
disabled={disabled || isAtMax}
whileTap={{ scale: 0.9 }}
transition={FAST_TRANSITION}
>
<Plus className="h-3.5 w-3.5" weight="bold" />
<Plus className="h-2.5 w-2.5" weight="bold" />
</motion.button>
</motion.div>
);

View File

@@ -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 (
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between">
<span className="text-foreground">{title}</span>
{rightContent}
</div>
{subheading && (
<span className="text-xs text-muted-foreground">{subheading}</span>
)}
</div>
);
}

View File

@@ -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 <CheckoutErrorState message="Missing checkout ID" />;
}
@@ -151,6 +207,7 @@ export function CheckoutPage() {
<CheckoutHeader org={org} isLoading={isLoading} />
</motion.div>
{/* Main content - two columns */}
<LayoutGroup>
<div className="flex flex-col lg:flex-row gap-8 w-full">
@@ -160,6 +217,11 @@ export function CheckoutPage() {
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.05 }}
>
<SectionHeader
title="Plan Details"
subheading={planDetailsSubheading}
/>
{isLoading ? (
<PlanSelectionCardSkeleton />
) : incoming ? (
@@ -170,18 +232,26 @@ export function CheckoutPage() {
currency={currency}
quantities={quantities}
onQuantityChange={handleQuantityChange}
outgoingPlanName={outgoing?.[0]?.plan.name}
/>
))
) : null}
</motion.div>
{/* Vertical separator - visible only on desktop */}
<Separator orientation="vertical" className="hidden lg:block h-auto self-stretch" />
<Separator orientation="horizontal" className="block lg:hidden h-auto self-stretch" />
{/* Right column - Order summary */}
<motion.div
className="flex flex-col gap-6 w-full lg:w-1/2"
className="flex flex-col gap-4 w-full lg:w-1/2"
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.1 }}
>
<SectionHeader
title="Order Summary"
subheading={orderSummarySubheading}
/>
{/* Order summary */}
<motion.div
animate={{ opacity: isUpdating ? 0.6 : 1 }}
@@ -190,98 +260,101 @@ export function CheckoutPage() {
{isLoading ? (
<OrderSummarySkeleton />
) : preview ? (
<OrderSummary planName={primaryPlanName} preview={preview} />
<OrderSummary
planName={primaryPlanName}
preview={preview}
incoming={incoming}
outgoing={outgoing}
/>
) : null}
</motion.div>
{/* Spacer */}
<div className="flex-1" />
{/* Bottom section */}
<motion.div
className="flex flex-col gap-6"
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.15 }}
>
<Separator />
{/* Amount summary */}
<div className="flex flex-col gap-1">
{/* Amount due today */}
<div className="flex items-center justify-between">
{isLoading ? (
<>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-6 w-16" />
</>
) : (
<>
<span className="text-base font-medium text-foreground">
Amount due today
</span>
<span className="text-lg font-medium text-foreground tabular-nums">
{formatAmount(total, currency)}
</span>
</>
)}
</div>
{/* Amount next cycle */}
{!isLoading && preview?.next_cycle && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>Total due next cycle</span>
<span className="tabular-nums">
{formatAmount(preview.next_cycle.total, currency)}
</span>
</div>
)}
</div>
{/* Confirm button */}
{isLoading ? (
<Skeleton className="h-12 w-full rounded-lg" />
) : (
<motion.div
whileTap={{ scale: 0.98 }}
transition={FAST_TRANSITION}
>
<Button
className="w-full h-12 text-base font-medium rounded-lg"
onClick={handleConfirm}
disabled={confirmMutation.isPending || isUpdating}
>
{confirmMutation.isPending
? "Processing..."
: isUpdating
? "Updating..."
: isSubscription
? "Pay and subscribe"
: "Pay"}
</Button>
</motion.div>
)}
{/* Error message */}
<AnimatePresence>
{confirmMutation.error && (
<motion.p
className="text-sm text-destructive text-center"
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={FAST_TRANSITION}
>
{confirmMutation.error instanceof Error
? confirmMutation.error.message
: "Failed to confirm checkout"}
</motion.p>
)}
</AnimatePresence>
</motion.div>
</motion.div>
</div>
</LayoutGroup>
<Separator />
{/* Bottom section - full width */}
<motion.div
className="flex flex-col gap-6"
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.15 }}
>
{/* Amount summary */}
<div className="flex flex-col gap-1">
{/* Amount due today */}
<div className="flex items-center justify-between">
{isLoading ? (
<>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-6 w-16" />
</>
) : (
<>
<span className="text-base font-medium text-foreground">
Amount due today
</span>
<span className="text-lg font-medium text-foreground tabular-nums">
{formatAmount(total, currency)}
</span>
</>
)}
</div>
{/* Amount next cycle */}
{!isLoading && preview?.next_cycle && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>Total due next cycle</span>
<span className="tabular-nums">
{formatAmount(preview.next_cycle.total, currency)}
</span>
</div>
)}
</div>
{/* Confirm button */}
{isLoading ? (
<Skeleton className="h-12 w-full rounded-lg" />
) : (
<motion.div
whileTap={{ scale: 0.98 }}
transition={FAST_TRANSITION}
>
<Button
className="w-full h-12 text-base font-medium rounded-lg"
onClick={handleConfirm}
disabled={confirmMutation.isPending || isUpdating}
>
{confirmMutation.isPending
? "Processing..."
: isUpdating
? "Updating..."
: isSubscription
? "Pay and subscribe"
: "Pay"}
</Button>
</motion.div>
)}
{/* Error message */}
<AnimatePresence>
{confirmMutation.error && (
<motion.p
className="text-sm text-destructive text-center"
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={FAST_TRANSITION}
>
{confirmMutation.error instanceof Error
? confirmMutation.error.message
: "Failed to confirm checkout"}
</motion.p>
)}
</AnimatePresence>
</motion.div>
<motion.div
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.2 }}

View File

@@ -1,10 +1,10 @@
import { format } from "date-fns";
export function formatAmount(cents: number, currency: string): string {
export function formatAmount(amount: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(cents / 100);
}).format(amount);
}
export function formatDate(timestamp: number): string {

View File

@@ -22,17 +22,14 @@ export const billingPlanToNextCyclePreview = ({
billingContext: BillingContext;
billingPlan: BillingPlan;
}): BillingPreviewResponse["next_cycle"] => {
// 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) => {

View File

@@ -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,
};
};

View File

@@ -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,
}),
),
});
});

View File

@@ -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);

View File

@@ -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,
}),
),
});
});

View File

@@ -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,
}),
),
});
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,
}),
),
});
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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,
}),
),
});
});

View File

@@ -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,
}),
),
});
});

View File

@@ -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,
}),
),
});
});

View File

@@ -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);

View File

@@ -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<typeof PreviewLineItemSchema>;