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