diff --git a/apps/checkout/biome.json b/apps/checkout/biome.json new file mode 100644 index 000000000..85441eef3 --- /dev/null +++ b/apps/checkout/biome.json @@ -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" + } + } + } +} diff --git a/apps/checkout/package.json b/apps/checkout/package.json index c6ea49cb4..e20673cc7 100644 --- a/apps/checkout/package.json +++ b/apps/checkout/package.json @@ -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" - } -} \ No newline at end of file + "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" + } +} diff --git a/apps/checkout/src/components/checkout/CheckoutLoadingState.tsx b/apps/checkout/src/components/checkout/CheckoutLoadingState.tsx index 7317b340a..aa0e52470 100644 --- a/apps/checkout/src/components/checkout/CheckoutLoadingState.tsx +++ b/apps/checkout/src/components/checkout/CheckoutLoadingState.tsx @@ -6,7 +6,10 @@ export function CheckoutLoadingState() {
{/* Header */} - +
+ + +
{/* Line items card */}
diff --git a/apps/checkout/src/components/checkout/OrderSummary.tsx b/apps/checkout/src/components/checkout/OrderSummary.tsx new file mode 100644 index 000000000..f992601f0 --- /dev/null +++ b/apps/checkout/src/components/checkout/OrderSummary.tsx @@ -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 ( +
+ {/* Plan name label */} + {planName} + + {/* Line items card */} + + {/* Line items */} +
+ {line_items.map((item, index) => { + const isBasePrice = index === 0; + return ( +
+ + {item.description} + + + {formatAmount(item.amount, currency)} + +
+ ); + })} +
+ + {/* Total row */} +
+ Total + + {formatAmount(total, currency)} + +
+
+ + {/* Next cycle info */} + {next_cycle && ( +
+
+ + New monthly total starting + + + {formatDate(next_cycle.starts_at)} + +
+ + {formatAmount(next_cycle.total, currency)} + +
+ )} +
+ ); +} diff --git a/apps/checkout/src/components/checkout/PlanSelectionCard.tsx b/apps/checkout/src/components/checkout/PlanSelectionCard.tsx new file mode 100644 index 000000000..0cfa61e28 --- /dev/null +++ b/apps/checkout/src/components/checkout/PlanSelectionCard.tsx @@ -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; + 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 ( + + {/* Plan header */} +
+
+ + {plan.name} + + {basePrice && ( + + {formatAmount(basePrice.amount, currency)} per{" "} + {basePrice.interval} + + )} +
+
+ + Selected +
+
+ + {/* Prepaid features */} + {prepaidFeatures.length > 0 && ( +
+ {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 ( +
+
+ + {feature.name} + + + {formatAmount(feature.unitPrice, currency)} per{" "} + {feature.billingUnits === 1 + ? "unit" + : `${feature.billingUnits} units`} + +
+
+ + {formatAmount(totalPrice, currency)}/{intervalLabel} + + + onQuantityChange( + feature.featureId, + value, + feature.billingUnits, + ) + } + min={0} + max={ + feature.maxPurchase + ? feature.maxPurchase * feature.billingUnits + : 999999 + } + step={feature.billingUnits} + disabled={isUpdating} + /> +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/checkout/src/components/checkout/QuantityInput.tsx b/apps/checkout/src/components/checkout/QuantityInput.tsx new file mode 100644 index 000000000..f838c9c86 --- /dev/null +++ b/apps/checkout/src/components/checkout/QuantityInput.tsx @@ -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) => { + 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 ( +
+ + + +
+ ); +} diff --git a/apps/checkout/src/hooks/useCheckout.ts b/apps/checkout/src/hooks/useCheckout.ts index f2e1acded..0d7342761 100644 --- a/apps/checkout/src/hooks/useCheckout.ts +++ b/apps/checkout/src/hooks/useCheckout.ts @@ -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(); diff --git a/apps/checkout/src/pages/CheckoutPage.tsx b/apps/checkout/src/pages/CheckoutPage.tsx index 678e198d3..c6c6a6e1d 100644 --- a/apps/checkout/src/pages/CheckoutPage.tsx +++ b/apps/checkout/src/pages/CheckoutPage.tsx @@ -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, +): { 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(null); - const { data: checkoutData, isLoading, error } = useCheckout({ checkoutId }); + // Local quantity overrides for optimistic UI + const [quantities, setQuantities] = useState>({}); + 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 ; } @@ -49,69 +108,62 @@ export function CheckoutPage() { return ; } - const { preview } = checkoutData; - - console.log("preview:", JSON.stringify(preview, null, 2)); + const { preview, incoming } = checkoutData; + const { total, currency } = preview; + const isUpdating = previewMutation.isPending; return (
{/* Header */} -

Checkout

+

+ Confirm your order +

- {/* Line items card */} -
- {preview.line_items.map((item) => ( -
-
- - {item.title} - - - {item.description} - -
- - {formatAmount(item.amount, preview.currency)} - -
+ {/* Plan selection section - one card per incoming plan */} +
+ {incoming.map((change) => ( + ))}
+ {/* Order summary section */} + + + + {/* Amount due today */} -
-
- - Amount due today - - - {formatAmount(preview.total, preview.currency)} - -
- {preview.next_cycle && ( -

- Then {formatAmount(preview.next_cycle.total, preview.currency)} - /month starting {formatDate(preview.next_cycle.starts_at)} -

- )} +
+ + Amount due today + + + {formatAmount(total, currency)} +
- {/* Button */} -
- -
+ {/* Confirm button */} + + {/* Error message */} {confirmMutation.error && (

{confirmMutation.error instanceof Error diff --git a/bun.lock b/bun.lock index 79b09fa6d..c2f1de737 100644 --- a/bun.lock +++ b/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=="], diff --git a/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts b/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts new file mode 100644 index 000000000..fafe74e2b --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts @@ -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 => { + 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, + }); +}; diff --git a/server/src/internal/billing/v2/utils/billingPlanToChanges.ts b/server/src/internal/billing/v2/utils/billingPlanToChanges.ts new file mode 100644 index 000000000..4fc15c279 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlanToChanges.ts @@ -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 }; +}; diff --git a/server/src/internal/billing/v2/utils/getApiSubscriptionForCheckout.ts b/server/src/internal/billing/v2/utils/getApiSubscriptionForCheckout.ts new file mode 100644 index 000000000..9ba5a2e94 --- /dev/null +++ b/server/src/internal/billing/v2/utils/getApiSubscriptionForCheckout.ts @@ -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 => { + 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, + }; +}; diff --git a/server/src/internal/checkouts/checkoutRouter.ts b/server/src/internal/checkouts/checkoutRouter.ts index c89fcea7f..b62e568df 100644 --- a/server/src/internal/checkouts/checkoutRouter.ts +++ b/server/src/internal/checkouts/checkoutRouter.ts @@ -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); diff --git a/server/src/internal/checkouts/handlers/handleGetCheckout.ts b/server/src/internal/checkouts/handlers/handleGetCheckout.ts index a0a26f50c..0db111f36 100644 --- a/server/src/internal/checkouts/handlers/handleGetCheckout.ts +++ b/server/src/internal/checkouts/handlers/handleGetCheckout.ts @@ -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); }, }); diff --git a/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts b/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts new file mode 100644 index 000000000..04bb30232 --- /dev/null +++ b/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts @@ -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); + }, +}); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts new file mode 100644 index 000000000..ed176f5f0 --- /dev/null +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.ts @@ -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 => { + const balances: Record = {}; + + // Group customer_entitlements by feature_id + const featureToCusEnts: Record = {}; + + 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; +}; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts index a3b2e7836..71a9843ab 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts @@ -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 { diff --git a/shared/internal/checkout/checkoutResponses.ts b/shared/internal/checkout/checkoutResponses.ts index d04af2db2..71d04d37a 100644 --- a/shared/internal/checkout/checkoutResponses.ts +++ b/shared/internal/checkout/checkoutResponses.ts @@ -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; +export type CheckoutCustomer = z.infer; +export type CheckoutEntity = z.infer; +export type CheckoutSubscription = z.infer; +export type CheckoutChange = z.infer; export type GetCheckoutResponse = z.infer; export type ConfirmCheckoutResponse = z.infer< typeof ConfirmCheckoutResponseSchema diff --git a/shared/internal/contracts/checkout.ts b/shared/internal/contracts/checkout.ts index 3a37dbb1b..6373c1593 100644 --- a/shared/internal/contracts/checkout.ts +++ b/shared/internal/contracts/checkout.ts @@ -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, };