chore: improving checkout

This commit is contained in:
Charlie Lamb
2026-01-30 10:00:26 +00:00
parent a69dcb91bd
commit c7936a43ee
19 changed files with 1200 additions and 105 deletions

50
apps/checkout/biome.json Normal file
View File

@@ -0,0 +1,50 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"experimentalScannerIgnores": ["dist/**", "public/**"],
"includes": ["!src/components/ui/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"a11y": {
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off"
},
"recommended": true,
"complexity": {
"noStaticOnlyClass": "off"
},
"suspicious": {
"noArrayIndexKey": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

View File

@@ -1,48 +1,49 @@
{
"name": "checkout-2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@autumn/shared": "workspace:*",
"@base-ui/react": "^1.1.0",
"@fontsource-variable/inter": "^5.2.8",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/openapi-client": "catalog:",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"shadcn": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^6.0.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
"name": "checkout",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@autumn/shared": "workspace:*",
"@base-ui/react": "^1.1.0",
"@fontsource-variable/inter": "^5.2.8",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/openapi-client": "catalog:",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"shadcn": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"use-debounce": "^10.1.0",
"vite-tsconfig-paths": "^6.0.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -6,7 +6,10 @@ export function CheckoutLoadingState() {
<div className="min-h-screen bg-background px-6 py-12 flex items-center justify-center">
<div className="w-full max-w-lg flex flex-col gap-8">
{/* Header */}
<Skeleton className="h-8 w-32" />
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-5 w-48" />
</div>
{/* Line items card */}
<div className="bg-card border border-border shadow-sm rounded-lg divide-y divide-border">

View File

@@ -0,0 +1,73 @@
import type { BillingPreviewResponse } from "@autumn/shared";
import { Card } from "@/components/ui/card";
import { formatAmount, formatDate } from "@/utils/formatUtils";
interface OrderSummaryProps {
planName: string;
preview: BillingPreviewResponse;
}
export function OrderSummary({ planName, preview }: OrderSummaryProps) {
const { line_items, total, currency, next_cycle } = preview;
return (
<div className="flex flex-col gap-4">
{/* Plan name label */}
<span className="text-base font-medium text-foreground">{planName}</span>
{/* Line items card */}
<Card className="py-0 gap-0">
{/* Line items */}
<div className="divide-y divide-border">
{line_items.map((item, index) => {
const isBasePrice = index === 0;
return (
<div
key={`${item.title}-${index}`}
className="flex items-center justify-between px-4 py-3"
>
<span
className={
isBasePrice
? "font-medium text-foreground"
: "text-muted-foreground pl-3"
}
>
{item.description}
</span>
<span className="font-medium tabular-nums text-foreground">
{formatAmount(item.amount, currency)}
</span>
</div>
);
})}
</div>
{/* Total row */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border bg-muted/30">
<span className="font-medium text-foreground">Total</span>
<span className="font-semibold tabular-nums text-foreground">
{formatAmount(total, currency)}
</span>
</div>
</Card>
{/* Next cycle info */}
{next_cycle && (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm text-muted-foreground">
New monthly total starting
</span>
<span className="text-sm text-muted-foreground">
{formatDate(next_cycle.starts_at)}
</span>
</div>
<span className="text-lg font-semibold tabular-nums text-foreground">
{formatAmount(next_cycle.total, currency)}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import type { CheckoutChange } from "@autumn/shared";
import { Check } from "@phosphor-icons/react";
import { Card } from "@/components/ui/card";
import { formatAmount } from "@/utils/formatUtils";
import { QuantityInput } from "./QuantityInput";
interface PrepaidFeatureInfo {
featureId: string;
name: string;
quantity: number;
unitPrice: number;
billingUnits: number;
maxPurchase: number | null;
interval: string;
}
function getPrepaidFeatures(change: CheckoutChange): PrepaidFeatureInfo[] {
const { plan, feature_quantities } = change;
const prepaidFeatures: PrepaidFeatureInfo[] = [];
for (const feature of plan.features) {
if (feature.price?.usage_model === "prepaid") {
const quantityInfo = feature_quantities.find(
(fq) => fq.feature_id === feature.feature_id,
);
prepaidFeatures.push({
featureId: feature.feature_id,
name: feature.feature?.name || feature.feature_id,
quantity: quantityInfo?.quantity || 0,
unitPrice: feature.price.amount || 0,
billingUnits: feature.price.billing_units || 1,
maxPurchase: feature.price.max_purchase,
interval: feature.price.interval || "month",
});
}
}
return prepaidFeatures;
}
function formatInterval(interval: string): string {
switch (interval) {
case "month":
return "mo";
case "year":
return "yr";
case "week":
return "wk";
case "day":
return "day";
default:
return interval;
}
}
interface PlanSelectionCardProps {
change: CheckoutChange;
currency: string;
quantities: Record<string, number>;
onQuantityChange: (
featureId: string,
quantity: number,
billingUnits: number,
) => void;
isUpdating?: boolean;
}
export function PlanSelectionCard({
change,
currency,
quantities,
onQuantityChange,
isUpdating = false,
}: PlanSelectionCardProps) {
const { plan } = change;
const prepaidFeatures = getPrepaidFeatures(change);
const basePrice = plan.price;
return (
<Card className="py-0 gap-0">
{/* Plan header */}
<div className="flex items-center justify-between px-4 py-4">
<div className="flex flex-col gap-0.5">
<span className="text-base font-medium text-muted-foreground">
{plan.name}
</span>
{basePrice && (
<span className="text-lg font-semibold text-foreground">
{formatAmount(basePrice.amount, currency)} per{" "}
{basePrice.interval}
</span>
)}
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-primary/20 bg-primary/5 text-primary">
<Check className="h-4 w-4" weight="bold" />
<span className="text-sm font-medium">Selected</span>
</div>
</div>
{/* Prepaid features */}
{prepaidFeatures.length > 0 && (
<div className="divide-y divide-border border-t border-border">
{prepaidFeatures.map((feature) => {
const currentQuantity =
quantities[feature.featureId] ?? feature.quantity;
// Price per billing unit, so total = (quantity / billingUnits) * unitPrice
const units = currentQuantity / feature.billingUnits;
const totalPrice = units * feature.unitPrice;
const intervalLabel = formatInterval(feature.interval);
return (
<div
key={feature.featureId}
className="flex items-center justify-between px-4 py-4"
>
<div className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">
{feature.name}
</span>
<span className="text-sm text-muted-foreground">
{formatAmount(feature.unitPrice, currency)} per{" "}
{feature.billingUnits === 1
? "unit"
: `${feature.billingUnits} units`}
</span>
</div>
<div className="flex items-center gap-4">
<span className="font-medium tabular-nums text-foreground">
{formatAmount(totalPrice, currency)}/{intervalLabel}
</span>
<QuantityInput
value={currentQuantity}
onChange={(value) =>
onQuantityChange(
feature.featureId,
value,
feature.billingUnits,
)
}
min={0}
max={
feature.maxPurchase
? feature.maxPurchase * feature.billingUnits
: 999999
}
step={feature.billingUnits}
disabled={isUpdating}
/>
</div>
</div>
);
})}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,88 @@
import { Minus, Plus } from "@phosphor-icons/react";
import { useState } from "react";
interface QuantityInputProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
}
export function QuantityInput({
value,
onChange,
min = 0,
max = 999999,
step = 1,
disabled = false,
}: QuantityInputProps) {
const [inputValue, setInputValue] = useState(value.toString());
const handleDecrement = () => {
const newValue = Math.max(min, value - step);
onChange(newValue);
setInputValue(newValue.toString());
};
const handleIncrement = () => {
const newValue = Math.min(max, value + step);
onChange(newValue);
setInputValue(newValue.toString());
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
setInputValue(raw);
const parsed = Number.parseInt(raw, 10);
if (!Number.isNaN(parsed) && parsed >= min && parsed <= max) {
onChange(parsed);
}
};
const handleBlur = () => {
// On blur, sync input value with actual value
setInputValue(value.toString());
};
// Sync external value changes
if (
value.toString() !== inputValue &&
document.activeElement?.tagName !== "INPUT"
) {
setInputValue(value.toString());
}
return (
<div className="flex items-center border border-border rounded-lg overflow-hidden bg-background">
<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"
onClick={handleDecrement}
disabled={disabled || value <= min}
>
<Minus className="h-3.5 w-3.5" weight="bold" />
</button>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="w-16 h-8 text-center text-sm 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}
disabled={disabled}
/>
<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"
onClick={handleIncrement}
disabled={disabled || value >= max}
>
<Plus className="h-3.5 w-3.5" weight="bold" />
</button>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import type { GetCheckoutResponse } from "@autumn/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { checkoutApi } from "@/api/checkoutClient";
@@ -14,6 +15,22 @@ export function useCheckout({ checkoutId }: { checkoutId: string }) {
});
}
export function usePreviewCheckout({ checkoutId }: { checkoutId: string }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (options: { feature_id: string; quantity: number }[]) =>
checkoutApi.previewCheckout({ checkout_id: checkoutId, options }),
onSuccess: (data) => {
// Update the checkout query cache with new preview data
queryClient.setQueryData(
checkoutKeys.detail(checkoutId),
data as GetCheckoutResponse,
);
},
});
}
export function useConfirmCheckout({ checkoutId }: { checkoutId: string }) {
const queryClient = useQueryClient();

View File

@@ -1,13 +1,39 @@
import type { ConfirmCheckoutResponse } from "@autumn/shared";
import { useState } from "react";
import type { CheckoutChange, ConfirmCheckoutResponse } from "@autumn/shared";
import { useCallback, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useDebouncedCallback } from "use-debounce";
import { CheckoutErrorState } from "@/components/checkout/CheckoutErrorState";
import { CheckoutLoadingState } from "@/components/checkout/CheckoutLoadingState";
import { CheckoutSuccessState } from "@/components/checkout/CheckoutSuccessState";
import { OrderSummary } from "@/components/checkout/OrderSummary";
import { PlanSelectionCard } from "@/components/checkout/PlanSelectionCard";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useCheckout, useConfirmCheckout } from "@/hooks/useCheckout";
import { formatAmount, formatDate } from "@/utils/formatUtils";
import {
useCheckout,
useConfirmCheckout,
usePreviewCheckout,
} from "@/hooks/useCheckout";
import { formatAmount } from "@/utils/formatUtils";
function buildOptionsArray(
incoming: CheckoutChange[],
quantities: Record<string, number>,
): { feature_id: string; quantity: number }[] {
const options: { feature_id: string; quantity: number }[] = [];
for (const change of incoming) {
for (const fq of change.feature_quantities) {
const quantity = quantities[fq.feature_id] ?? fq.quantity;
options.push({
feature_id: fq.feature_id,
quantity,
});
}
}
return options;
}
export function CheckoutPage() {
const { checkoutId: checkoutIdParam } = useParams<{ checkoutId: string }>();
@@ -15,10 +41,37 @@ export function CheckoutPage() {
const [confirmResult, setConfirmResult] =
useState<ConfirmCheckoutResponse | null>(null);
const { data: checkoutData, isLoading, error } = useCheckout({ checkoutId });
// Local quantity overrides for optimistic UI
const [quantities, setQuantities] = useState<Record<string, number>>({});
const { data: checkoutData, isLoading, error } = useCheckout({ checkoutId });
const previewMutation = usePreviewCheckout({ checkoutId });
const confirmMutation = useConfirmCheckout({ checkoutId });
// Debounced preview update
const debouncedPreview = useDebouncedCallback(
(options: { feature_id: string; quantity: number }[]) => {
previewMutation.mutate(options);
},
300,
);
const handleQuantityChange = useCallback(
(featureId: string, quantity: number, _billingUnits: number) => {
// Update local state immediately for optimistic UI
// Quantity is in actual units (e.g., 500 messages), which is what the API expects
setQuantities((prev) => ({ ...prev, [featureId]: quantity }));
// Build options and trigger debounced preview
if (checkoutData) {
const newQuantities = { ...quantities, [featureId]: quantity };
const options = buildOptionsArray(checkoutData.incoming, newQuantities);
debouncedPreview(options);
}
},
[checkoutData, quantities, debouncedPreview],
);
const handleConfirm = () => {
confirmMutation.mutate(undefined, {
onSuccess: (result) => {
@@ -27,6 +80,12 @@ export function CheckoutPage() {
});
};
// Get first incoming plan name for order summary
const primaryPlanName = useMemo(() => {
if (!checkoutData?.incoming?.length) return "Order";
return checkoutData.incoming[0].plan.name || "Order";
}, [checkoutData]);
if (!checkoutId) {
return <CheckoutErrorState message="Missing checkout ID" />;
}
@@ -49,69 +108,62 @@ export function CheckoutPage() {
return <CheckoutErrorState message="No checkout data available" />;
}
const { preview } = checkoutData;
console.log("preview:", JSON.stringify(preview, null, 2));
const { preview, incoming } = checkoutData;
const { total, currency } = preview;
const isUpdating = previewMutation.isPending;
return (
<div className="min-h-screen bg-background px-6 py-12 flex items-center justify-center">
<div className="w-full max-w-lg flex flex-col gap-8">
{/* Header */}
<h1 className="text-2xl font-semibold text-foreground">Checkout</h1>
<h1 className="text-2xl font-semibold text-foreground">
Confirm your order
</h1>
{/* Line items card */}
<div className="bg-card border border-border shadow-sm rounded-lg divide-y divide-border">
{preview.line_items.map((item) => (
<div
key={item.description}
className="flex justify-between items-start gap-4 px-4 py-3.5"
>
<div className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">
{item.title}
</span>
<span className="text-sm text-muted-foreground">
{item.description}
</span>
</div>
<span className="font-medium tabular-nums shrink-0">
{formatAmount(item.amount, preview.currency)}
</span>
</div>
{/* Plan selection section - one card per incoming plan */}
<div className="flex flex-col gap-4">
{incoming.map((change) => (
<PlanSelectionCard
key={change.plan.id}
change={change}
currency={currency}
quantities={quantities}
onQuantityChange={handleQuantityChange}
/>
))}
</div>
<Separator />
{/* Order summary section */}
<OrderSummary planName={primaryPlanName} preview={preview} />
<Separator />
{/* Amount due today */}
<div className="flex flex-col gap-1">
<div className="flex justify-between items-center">
<span className="text-base font-medium text-muted-foreground">
Amount due today
</span>
<span className="text-2xl font-semibold tabular-nums">
{formatAmount(preview.total, preview.currency)}
</span>
</div>
{preview.next_cycle && (
<p className="text-sm text-muted-foreground">
Then {formatAmount(preview.next_cycle.total, preview.currency)}
/month starting {formatDate(preview.next_cycle.starts_at)}
</p>
)}
<div className="flex items-center justify-between">
<span className="text-base font-medium text-foreground">
Amount due today
</span>
<span className="text-2xl font-semibold tabular-nums text-foreground">
{formatAmount(total, currency)}
</span>
</div>
{/* Button */}
<div className="pt-4">
<Button
className="w-full h-12 text-base rounded-xl"
onClick={handleConfirm}
disabled={confirmMutation.isPending}
>
{confirmMutation.isPending ? "Processing..." : "Confirm Purchase"}
</Button>
</div>
{/* Confirm button */}
<Button
className="w-full h-12 text-base font-medium rounded-lg"
onClick={handleConfirm}
disabled={confirmMutation.isPending || isUpdating}
>
{confirmMutation.isPending
? "Processing..."
: isUpdating
? "Updating..."
: "Confirm purchase"}
</Button>
{/* Error message */}
{confirmMutation.error && (
<p className="text-sm text-destructive text-center">
{confirmMutation.error instanceof Error

View File

@@ -38,6 +38,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"use-debounce": "^10.1.0",
"vite-tsconfig-paths": "^6.0.5",
},
"devDependencies": {
@@ -3406,6 +3407,8 @@
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-debounce": ["use-debounce@10.1.0", "", { "peerDependencies": { "react": "*" } }, "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-stick-to-bottom": ["use-stick-to-bottom@1.1.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ssUfMNvfH8a8hGLoAt5kcOsjbsVORknon2tbkECuf3EsVucFFBbyXl+Xnv3b58P8ZRuZelzO81fgb6M0eRo8cg=="],

View File

@@ -0,0 +1,125 @@
import type { BillingContext, BillingPlan } from "@autumn/shared";
import {
type CheckoutLineV0,
type CheckoutResponseV0,
CheckoutResponseV0Schema,
orgToCurrency,
toProductItem,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils";
import {
getProductItemResponse,
getProductResponse,
} from "@/internal/products/productUtils/productResponseUtils/getProductResponse";
import { notNullish } from "@/utils/genUtils";
import { billingPlanToNextCyclePreview } from "./billingPlan/billingPlanToNextCyclePreview";
export const billingContextToCheckoutResponse = async ({
ctx,
billingContext,
billingPlan,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
billingPlan: BillingPlan;
}): Promise<CheckoutResponseV0> => {
const { fullCustomer, fullProducts, featureQuantities } = billingContext;
const { features, org } = ctx;
const currency = orgToCurrency({ org });
// 1. Get primary product (first non-add-on or first product)
const mainProduct = fullProducts.find((p) => !p.is_add_on) ?? fullProducts[0];
const product = mainProduct
? await getProductResponse({
product: mainProduct,
features,
fullCus: fullCustomer,
currency,
db: ctx.db,
options: featureQuantities,
})
: null;
// 2. Build line items from billing plan
const planLineItems = billingPlan.autumn.lineItems ?? [];
// Collect all prices and entitlements from products for lookup
const allPrices = fullProducts.flatMap((p) => p.prices);
const allEnts = fullProducts.flatMap((p) => p.entitlements);
const lines: CheckoutLineV0[] = planLineItems
.filter((line) => line.chargeImmediately)
.map((line) => {
const { price } = line.context;
// Find entitlement for this price
const ent = getPriceEntitlement(price, allEnts);
// Build product item from price + entitlement
const productItem = toProductItem({ ent, price });
return {
description: line.description,
amount: line.finalAmount,
item: getProductItemResponse({
item: productItem,
features,
currency,
withDisplay: true,
options: featureQuantities,
}),
};
})
.filter(notNullish);
// 3. Calculate total
const total = new Decimal(lines.reduce((acc, line) => acc + line.amount, 0))
.toDecimalPlaces(2)
.toNumber();
// 4. Get next cycle preview
const nextCycle = billingPlanToNextCyclePreview({
ctx,
billingContext,
billingPlan,
});
// 5. Build options from feature quantities
const options = featureQuantities
.map((fq) => {
const price = allPrices.find(
(p) =>
p.config &&
"feature_id" in p.config &&
(p.config.feature_id === fq.feature_id ||
p.config.internal_feature_id === fq.internal_feature_id),
);
if (!price) return undefined;
const billingUnits =
price.config && "billing_units" in price.config
? price.config.billing_units || 1
: 1;
return {
feature_id: fq.feature_id,
quantity: fq.quantity * billingUnits,
};
})
.filter(notNullish);
return CheckoutResponseV0Schema.parse({
customer_id: fullCustomer.id || fullCustomer.internal_id,
product,
current_product: null,
lines,
options,
total,
currency,
next_cycle: nextCycle,
});
};

View File

@@ -0,0 +1,147 @@
import {
addToExpand,
type BillingContext,
type BillingPlan,
type CheckoutChange,
CusExpand,
type FullCusProduct,
isPrepaidPrice,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { cusProductToBalances } from "@/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.js";
import { getApiSubscriptionForCheckout } from "./getApiSubscriptionForCheckout.js";
/**
* Convert cusProduct.options to feature_quantities with actual quantities
* (multiplied by billingUnits for prepaid features)
*/
function cusProductToFeatureQuantities({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) {
return cusProduct.options.map((option) => {
// Find the price for this feature to get billing units
const cusPrice = cusProduct.customer_prices.find((cp) => {
const cusEnt = cusProduct.customer_entitlements.find(
(ce) =>
ce.internal_feature_id === option.internal_feature_id ||
ce.entitlement.feature_id === option.feature_id,
);
return (
cusEnt &&
cp.price.config.internal_feature_id ===
cusEnt.entitlement.internal_feature_id
);
});
let quantity = option.quantity;
// For prepaid prices, multiply by billing units to get actual quantity
if (cusPrice && isPrepaidPrice(cusPrice.price)) {
const billingUnits = cusPrice.price.config.billing_units ?? 1;
quantity = option.quantity * billingUnits;
}
return {
feature_id: option.feature_id,
quantity,
};
});
}
/**
* Convert a BillingPlan into incoming and outgoing CheckoutChange arrays.
* Incoming = products being added, Outgoing = products being canceled/expired/deleted.
*/
export const billingPlanToChanges = async ({
ctx,
billingContext,
billingPlan,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
billingPlan: BillingPlan;
}): Promise<{ incoming: CheckoutChange[]; outgoing: CheckoutChange[] }> => {
const incoming: CheckoutChange[] = [];
const outgoing: CheckoutChange[] = [];
const { autumn } = billingPlan;
const { fullCustomer } = billingContext;
const ctxWithExpand = addToExpand({
ctx,
add: [CusExpand.SubscriptionsPlan],
});
// 1. Products being added (incoming)
for (const cusProduct of autumn.insertCustomerProducts) {
const subscription = await getApiSubscriptionForCheckout({
ctx: ctxWithExpand,
cusProduct,
billingContext,
});
const balances = cusProductToBalances({
ctx,
cusProduct,
fullCustomer,
});
incoming.push({
plan: subscription.plan,
balances,
feature_quantities: cusProductToFeatureQuantities({ cusProduct }),
});
}
// 2. Products being canceled/expired (outgoing)
if (autumn.updateCustomerProduct) {
const { customerProduct, updates } = autumn.updateCustomerProduct;
if (updates.canceled || updates.ended_at) {
const subscription = await getApiSubscriptionForCheckout({
ctx,
cusProduct: customerProduct,
billingContext,
});
const balances = cusProductToBalances({
ctx,
cusProduct: customerProduct,
fullCustomer,
});
outgoing.push({
plan: subscription.plan,
feature_quantities: cusProductToFeatureQuantities({
cusProduct: customerProduct,
}),
balances,
});
}
}
// 3. Scheduled products being deleted (outgoing)
if (autumn.deleteCustomerProduct) {
const cusProduct = autumn.deleteCustomerProduct;
const subscription = await getApiSubscriptionForCheckout({
ctx,
cusProduct,
billingContext,
});
const balances = cusProductToBalances({
ctx,
cusProduct,
fullCustomer,
});
outgoing.push({
plan: subscription.plan,
feature_quantities: cusProductToFeatureQuantities({ cusProduct }),
balances,
});
}
return { incoming, outgoing };
};

View File

@@ -0,0 +1,82 @@
import {
type BillingContext,
type CheckoutSubscription,
CusProductStatus,
cusProductToPlanStatus,
cusProductToProduct,
type FullCusProduct,
isCustomerProductTrialing,
orgToCurrency,
secondsToMs,
} from "@autumn/shared";
import {
getEarliestPeriodStart,
getLatestPeriodEnd,
} from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js";
/**
* Build an ApiSubscription with plan always included (for checkout display).
* Unlike getApiSubscription which uses ctx.expand, this always includes the plan.
*/
export const getApiSubscriptionForCheckout = async ({
ctx,
cusProduct,
billingContext,
}: {
ctx: AutumnContext;
cusProduct: FullCusProduct;
billingContext: BillingContext;
}): Promise<CheckoutSubscription> => {
const fullProduct = cusProductToProduct({ cusProduct });
const { fullCustomer, stripeSubscription } = billingContext;
const currency = orgToCurrency({ org: ctx.org });
// Always get plan for checkout
const plan = await getPlanResponse({
product: fullProduct,
features: ctx.features,
fullCus: fullCustomer,
currency,
});
const status = cusProductToPlanStatus({ status: cusProduct.status });
// Get subscription period from Stripe subscription if available
let periodStart: number | null = null;
let periodEnd: number | null = null;
if (stripeSubscription) {
periodStart =
secondsToMs(getEarliestPeriodStart({ sub: stripeSubscription })) ?? null;
periodEnd =
secondsToMs(getLatestPeriodEnd({ sub: stripeSubscription })) ?? null;
} else if (
cusProduct.trial_ends_at &&
cusProduct.trial_ends_at > Date.now()
) {
periodStart = cusProduct.starts_at;
periodEnd = cusProduct.trial_ends_at;
}
return {
plan,
plan_id: fullProduct.id,
add_on: fullProduct.is_add_on,
default: fullProduct.is_default,
status,
past_due: cusProduct.status === CusProductStatus.PastDue,
canceled_at: cusProduct.canceled_at || null,
expires_at: cusProduct.ended_at || null,
trial_ends_at: isCustomerProductTrialing(cusProduct)
? (cusProduct.trial_ends_at ?? null)
: null,
started_at: cusProduct.starts_at,
quantity: cusProduct.quantity,
current_period_start: periodStart,
current_period_end: periodEnd,
};
};

View File

@@ -2,6 +2,7 @@ import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv";
import { handleConfirmCheckout } from "./handlers/handleConfirmCheckout";
import { handleGetCheckout } from "./handlers/handleGetCheckout";
import { handlePreviewCheckout } from "./handlers/handlePreviewCheckout";
import {
checkoutMiddleware,
checkoutRateLimiter,
@@ -19,4 +20,5 @@ publicCheckoutRouter.use("/:checkout_id/*", checkoutMiddleware);
// Routes
publicCheckoutRouter.get("/:checkout_id", ...handleGetCheckout);
publicCheckoutRouter.post("/:checkout_id/preview", ...handlePreviewCheckout);
publicCheckoutRouter.post("/:checkout_id/confirm", ...handleConfirmCheckout);

View File

@@ -7,9 +7,10 @@ import {
RecaseError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse";
import { billingActions } from "@/internal/billing/v2/actions";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { billingActions } from "@/internal/billing/v2/actions/index.js";
import { billingPlanToChanges } from "@/internal/billing/v2/utils/billingPlanToChanges.js";
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse.js";
/**
* GET /checkouts/:checkout_id
@@ -47,12 +48,43 @@ export const handleGetCheckout = createRoute({
});
}
const { fullCustomer } = billingContext;
// Build preview with line items, total, currency, next_cycle
const preview = billingPlanToPreviewResponse({
ctx,
billingContext,
billingPlan,
});
return c.json({ preview } satisfies GetCheckoutResponse);
// Build changes array
const { incoming, outgoing } = await billingPlanToChanges({
ctx,
billingContext,
billingPlan,
});
const response: GetCheckoutResponse = {
preview,
org: {
name: ctx.org.name,
logo: ctx.org.logo || null,
},
customer: {
id: fullCustomer.id || fullCustomer.internal_id,
name: fullCustomer.name || null,
email: fullCustomer.email || null,
},
entity: fullCustomer.entity
? {
id: fullCustomer.entity.id,
name: fullCustomer.entity.name || null,
}
: null,
incoming,
outgoing,
};
return c.json(response);
},
});

View File

@@ -0,0 +1,109 @@
import {
type AttachParamsV0,
type Checkout,
CheckoutAction,
ErrCode,
FeatureOptionsSchema,
type GetCheckoutResponse,
RecaseError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { billingActions } from "@/internal/billing/v2/actions/index.js";
import { billingPlanToChanges } from "@/internal/billing/v2/utils/billingPlanToChanges.js";
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse.js";
const PreviewCheckoutBodySchema = z.object({
options: z.array(
FeatureOptionsSchema.pick({
feature_id: true,
quantity: true,
}),
),
});
/**
* POST /checkouts/:checkout_id/preview
*
* Returns updated checkout preview with new feature quantities.
* Used for inline quantity editing in the checkout UI.
*/
export const handlePreviewCheckout = createRoute({
body: PreviewCheckoutBodySchema,
handler: async (c) => {
const ctx = c.get("ctx");
const checkout = c.get("checkout") as Checkout;
const body = c.req.valid("json");
if (checkout.action !== CheckoutAction.Attach) {
throw new RecaseError({
message: "Only attach checkouts are supported",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
const originalParams = checkout.params as AttachParamsV0;
// Merge provided options with original params
const params: AttachParamsV0 = {
...originalParams,
options: body.options,
};
// Re-run attach in preview mode with updated options
const { billingContext, billingPlan } = await billingActions.attach({
ctx,
params,
preview: true,
});
if (!billingPlan) {
throw new RecaseError({
message: "Failed to compute billing plan",
code: ErrCode.InternalError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
const { fullCustomer } = billingContext;
// Build preview with line items, total, currency, next_cycle
const preview = billingPlanToPreviewResponse({
ctx,
billingContext,
billingPlan,
});
// Build incoming/outgoing changes
const { incoming, outgoing } = await billingPlanToChanges({
ctx,
billingContext,
billingPlan,
});
const response: GetCheckoutResponse = {
preview,
org: {
name: ctx.org.name,
logo: ctx.org.logo || null,
},
customer: {
id: fullCustomer.id || fullCustomer.internal_id,
name: fullCustomer.name || null,
email: fullCustomer.email || null,
},
entity: fullCustomer.entity
? {
id: fullCustomer.entity.id,
name: fullCustomer.entity.name || null,
}
: null,
incoming,
outgoing,
};
return c.json(response);
},
});

View File

@@ -0,0 +1,67 @@
import type {
ApiBalance,
FullCusEntWithFullCusProduct,
FullCusProduct,
FullCustomer,
} from "@autumn/shared";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
import { getApiBalance } from "./getApiBalance.js";
/**
* Extract balances from a FullCusProduct's customer_entitlements.
* Used for checkout preview to show what balances will be granted.
*/
export const cusProductToBalances = ({
ctx,
cusProduct,
fullCustomer,
}: {
ctx: RequestContext;
cusProduct: FullCusProduct;
fullCustomer: FullCustomer;
}): Record<string, ApiBalance> => {
const balances: Record<string, ApiBalance> = {};
// Group customer_entitlements by feature_id
const featureToCusEnts: Record<string, FullCusEntWithFullCusProduct[]> = {};
for (const cusEnt of cusProduct.customer_entitlements) {
const featureId = cusEnt.entitlement.feature.id;
// Create FullCusEntWithFullCusProduct by attaching cusProduct
const cusEntWithProduct: FullCusEntWithFullCusProduct = {
...cusEnt,
customer_product: cusProduct,
};
featureToCusEnts[featureId] = [
...(featureToCusEnts[featureId] || []),
cusEntWithProduct,
];
}
// Build ApiBalance for each feature
for (const featureId in featureToCusEnts) {
const cusEnts = featureToCusEnts[featureId];
const feature = cusEnts[0].entitlement.feature;
// Create a preview FullCustomer with this product's entitlements
const previewFullCus: FullCustomer = {
...fullCustomer,
customer_products: [cusProduct],
};
const { data } = getApiBalance({
ctx,
fullCus: previewFullCus,
cusEnts,
feature,
includeRollovers: false,
includeBreakdown: false,
});
balances[featureId] = data;
}
return balances;
};

View File

@@ -100,6 +100,11 @@ export const getApiSubscription = async ({
quantity: cusProduct.quantity,
current_period_start: stripeSubData?.current_period_start || null,
current_period_end: stripeSubData?.current_period_end || null,
feature_quantities: cusProduct.options.map((option) => ({
feature_id: option.feature_id,
quantity: option.quantity,
upcoming_quantity: option.upcoming_quantity,
})),
});
return {

View File

@@ -1,11 +1,66 @@
import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels.js";
import { z } from "zod/v4";
import { BillingPreviewResponseSchema } from "../../api/billing/common/billingPreviewResponse.js";
import { ApiBalanceSchema } from "../../api/customers/cusFeatures/apiBalance.js";
import { ApiSubscriptionSchema } from "../../api/customers/cusPlans/apiSubscription.js";
import { ApiPlanSchema } from "../../api/products/apiPlan.js";
/**
* Org branding for checkout display
*/
export const CheckoutOrgSchema = z.object({
name: z.string(),
logo: z.string().nullable(),
});
/**
* Customer info for checkout display
*/
export const CheckoutCustomerSchema = z.object({
id: z.string(),
name: z.string().nullable(),
email: z.string().nullable(),
});
/**
* Entity info for checkout display (optional)
*/
export const CheckoutEntitySchema = z.object({
id: z.string(),
name: z.string().nullable(),
});
/**
* Subscription with required plan (always expanded for checkout)
*/
export const CheckoutSubscriptionSchema = ApiSubscriptionSchema.extend({
plan: ApiPlanSchema,
});
/**
* A change in the checkout (product being added, canceled, or expiring)
*/
export const CheckoutChangeSchema = z.object({
plan: ApiPlanSchema,
feature_quantities: z.array(
FeatureOptionsSchema.pick({
feature_id: true,
quantity: true,
}),
),
balances: z.record(z.string(), ApiBalanceSchema),
});
/**
* GET /checkouts/:checkout_id response
*/
export const GetCheckoutResponseSchema = z.object({
preview: BillingPreviewResponseSchema,
org: CheckoutOrgSchema,
customer: CheckoutCustomerSchema,
entity: CheckoutEntitySchema.nullable(),
incoming: z.array(CheckoutChangeSchema),
outgoing: z.array(CheckoutChangeSchema),
});
/**
@@ -19,6 +74,11 @@ export const ConfirmCheckoutResponseSchema = z.object({
invoice_id: z.string().nullable(),
});
export type CheckoutOrg = z.infer<typeof CheckoutOrgSchema>;
export type CheckoutCustomer = z.infer<typeof CheckoutCustomerSchema>;
export type CheckoutEntity = z.infer<typeof CheckoutEntitySchema>;
export type CheckoutSubscription = z.infer<typeof CheckoutSubscriptionSchema>;
export type CheckoutChange = z.infer<typeof CheckoutChangeSchema>;
export type GetCheckoutResponse = z.infer<typeof GetCheckoutResponseSchema>;
export type ConfirmCheckoutResponse = z.infer<
typeof ConfirmCheckoutResponseSchema

View File

@@ -1,3 +1,4 @@
import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels.js";
import { oc } from "@orpc/contract";
import { z } from "zod/v4";
import {
@@ -14,6 +15,25 @@ export const getCheckoutContract = oc
.input(z.object({ checkout_id: z.string() }))
.output(GetCheckoutResponseSchema);
export const previewCheckoutContract = oc
.route({
method: "POST",
path: "/checkouts/{checkout_id}/preview",
tags: ["internal"],
})
.input(
z.object({
checkout_id: z.string(),
options: z.array(
FeatureOptionsSchema.pick({
feature_id: true,
quantity: true,
}),
),
}),
)
.output(GetCheckoutResponseSchema);
export const confirmCheckoutContract = oc
.route({
method: "POST",
@@ -25,5 +45,6 @@ export const confirmCheckoutContract = oc
export const checkoutContract = {
getCheckout: getCheckoutContract,
previewCheckout: previewCheckoutContract,
confirmCheckout: confirmCheckoutContract,
};