chore: improving checkout
This commit is contained in:
50
apps/checkout/biome.json
Normal file
50
apps/checkout/biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
73
apps/checkout/src/components/checkout/OrderSummary.tsx
Normal file
73
apps/checkout/src/components/checkout/OrderSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
apps/checkout/src/components/checkout/PlanSelectionCard.tsx
Normal file
158
apps/checkout/src/components/checkout/PlanSelectionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
apps/checkout/src/components/checkout/QuantityInput.tsx
Normal file
88
apps/checkout/src/components/checkout/QuantityInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
147
server/src/internal/billing/v2/utils/billingPlanToChanges.ts
Normal file
147
server/src/internal/billing/v2/utils/billingPlanToChanges.ts
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
109
server/src/internal/checkouts/handlers/handlePreviewCheckout.ts
Normal file
109
server/src/internal/checkouts/handlers/handlePreviewCheckout.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user