-
-
- 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,
};