chore: increase edge case handling
This commit is contained in:
@@ -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 */}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
124
apps/checkout/src/components/checkout/PlanGroupCard.tsx
Normal file
124
apps/checkout/src/components/checkout/PlanGroupCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
25
apps/checkout/src/components/checkout/SectionHeader.tsx
Normal file
25
apps/checkout/src/components/checkout/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
106
server/tests/scenarios/attach/entity-downgrade-scenario.test.ts
Normal file
106
server/tests/scenarios/attach/entity-downgrade-scenario.test.ts
Normal 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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
101
server/tests/scenarios/attach/entity-upgrade-scenario.test.ts
Normal file
101
server/tests/scenarios/attach/entity-upgrade-scenario.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user