cleaned up custom checkout

This commit is contained in:
John Yeo
2026-03-12 20:52:17 +00:00
parent a28510a00e
commit 71642d4d94
64 changed files with 8679 additions and 194 deletions

View File

@@ -179,6 +179,7 @@ Each client version (`autumnV1`, `autumnV2`, `autumnV2_1`) expects different inp
|--------|------------|--------------|---------------|--------------------------|
| `autumnV1` | V1 (`/attach`, `/billing.attach`) | `AttachParamsV0Input` | `ApiCustomerV3` | `UpdateSubscriptionV0Params` |
| `autumnV2` | V2 (`/billing.attach`) | `AttachParamsV1Input` | `ApiCustomer` | `UpdateSubscriptionV1Params` |
| `autumnV2_1` | V2.1 (`/attach`, `/update_subscription`) | `AttachParamsV1Input` | `ApiCustomerV5` | `UpdateSubscriptionV1ParamsInput` |
Key differences between `AttachParamsV0Input` and `AttachParamsV1Input`:
- V0 (`autumnV1`): uses `product_id` + `options: [{ feature_id, quantity }]`
@@ -201,6 +202,21 @@ const params: AttachParamsV1Input = {
};
await autumnV2.billing.attach<AttachParamsV1Input>(params);
// ✅ CORRECT — autumnV2_1 uses V1-style attach/update-subscription params
await autumnV2_1.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
feature_quantities: [{ feature_id: "messages", quantity: 200 }],
});
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "always",
});
// ❌ WRONG — mixing V1 param names with autumnV1 client
await autumnV1.billing.attach({ customer_id, plan_id: pro.id, feature_quantities: [...] });
```
Gotcha: if a test uses V1-style `attach` / `update_subscription` params like `plan_id`, `feature_quantities`, or `redirect_mode`, use `autumnV2_1`, not `autumnV2`.

View File

@@ -41,6 +41,7 @@ export function ConfirmSection() {
preview,
isSubscription,
hasActiveTrial,
isUnchangedQuantityUpdate,
handleConfirm,
} = useCheckoutContext();
const hasNextCycleUsage =
@@ -99,7 +100,8 @@ export function ConfirmSection() {
disabled={
hasActionRequiredState ||
status.isConfirming ||
status.isUpdating
status.isUpdating ||
isUnchangedQuantityUpdate
}
>
{getButtonText({

View File

@@ -76,12 +76,15 @@ export function OrderSummary() {
}
// Convert to array, with outgoing plans first (credits), then incoming plans
const outgoingIds = new Set<string>(outgoing.map((c) => c.plan_id));
const incomingIds = new Set<string>(incoming.map((c) => c.plan_id));
const visibleOutgoing = outgoing.filter(
(change) => !incomingIds.has(change.plan_id),
);
const outgoingIds = new Set<string>(visibleOutgoing.map((c) => c.plan_id));
const groups: PlanGroup[] = [];
// Add outgoing plan groups first (including those with no line items like free plans)
for (const change of outgoing) {
for (const change of visibleOutgoing) {
const planId = change.plan_id;
const items = groupMap.get(planId) || [];
groups.push({
@@ -89,7 +92,7 @@ export function OrderSummary() {
planName: planNameMap.get(planId) || planId,
items,
type: "outgoing",
cancelledAt: change.expires_at ?? undefined,
cancelledAt: change.effective_at ?? undefined,
});
}

View File

@@ -47,7 +47,12 @@ interface PlanSelectionCardProps {
}
export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
const { currency, quantities, handleQuantityChange } = useCheckoutContext();
const {
adjustableFeatureIds,
currency,
quantities,
handleQuantityChange,
} = useCheckoutContext();
const { plan, feature_quantities } = change;
if (!plan) return null;
@@ -87,6 +92,9 @@ export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
);
const currentQuantity =
quantities[planItem.feature_id] ?? quantityInfo?.quantity ?? 0;
const isAdjustable = adjustableFeatureIds.includes(
planItem.feature_id,
);
const billingUnits = price.billing_units || 1;
return (
@@ -139,6 +147,7 @@ export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
: undefined
}
step={billingUnits}
disabled={!isAdjustable}
/>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import {
type BillingPreviewChange,
type BillingResponse,
CheckoutAction,
type ConfirmCheckoutResponse,
@@ -16,23 +17,48 @@ import { buildHeaderDescription } from "@/utils/buildHeaderDescription";
const SUCCESS_REDIRECT_DELAY_MS = 2000;
function buildOptionsArray(
incoming: { feature_quantities: { feature_id: string; quantity: number }[] }[],
function haveMatchingQuantities({
incoming,
outgoing,
}: {
incoming: BillingPreviewChange;
outgoing: BillingPreviewChange;
}) {
if (incoming.feature_quantities.length !== outgoing.feature_quantities.length) {
return false;
}
const outgoingQuantities = new Map(
outgoing.feature_quantities.map((featureQuantity) => [
featureQuantity.feature_id,
featureQuantity.quantity,
]),
);
return incoming.feature_quantities.every(
(featureQuantity) =>
outgoingQuantities.get(featureQuantity.feature_id) ===
featureQuantity.quantity,
);
}
function buildFeatureQuantities(
incoming: BillingPreviewChange[],
quantities: Record<string, number>,
): { feature_id: string; quantity: number }[] {
const options: { feature_id: string; quantity: number }[] = [];
const featureQuantities: { 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({
featureQuantities.push({
feature_id: fq.feature_id,
quantity,
});
}
}
return options;
return featureQuantities;
}
export function useCheckoutState({
@@ -81,8 +107,8 @@ export function useCheckoutState({
// === Debounced preview ===
const debouncedPreview = useDebouncedCallback(
(options: { feature_id: string; quantity: number }[]) => {
previewMutation.mutate({ options });
(feature_quantities: { feature_id: string; quantity: number }[]) => {
previewMutation.mutate({ feature_quantities });
},
600,
);
@@ -91,8 +117,23 @@ export function useCheckoutState({
const derivedState = useMemo(() => {
const { action, env, preview, org, entity, status: checkoutStatus } =
checkoutData ?? {};
const adjustableFeatureIds = checkoutData?.adjustable_feature_ids ?? [];
const incoming = preview?.incoming;
const outgoing = preview?.outgoing;
const isUpdateQuantityIntent =
preview?.object === "update_subscription_preview" &&
preview.intent === "update_quantity";
const matchingOutgoingChange = incoming?.[0]
? outgoing?.find((change) => change.plan_id === incoming[0].plan_id)
: undefined;
const isUnchangedQuantityUpdate =
isUpdateQuantityIntent &&
Boolean(incoming?.[0]) &&
Boolean(matchingOutgoingChange) &&
haveMatchingQuantities({
incoming: incoming[0],
outgoing: matchingOutgoingChange,
});
const incomingPlan = incoming?.[0]?.plan;
const freeTrial = incomingPlan?.free_trial;
const hasActiveTrial = !!freeTrial;
@@ -124,6 +165,8 @@ export function useCheckoutState({
hasActiveTrial,
isSandbox: env === "sandbox",
headerDescription,
adjustableFeatureIds,
isUnchangedQuantityUpdate,
};
}, [checkoutData, routeMode]);
@@ -134,22 +177,22 @@ export function useCheckoutState({
if (checkoutData?.preview?.incoming) {
const newQuantities = { ...quantities, [featureId]: quantity };
const options = buildOptionsArray(
const featureQuantities = buildFeatureQuantities(
checkoutData.preview.incoming,
newQuantities,
);
debouncedPreview(options);
debouncedPreview(featureQuantities);
}
},
[checkoutData, quantities, debouncedPreview],
);
const handleConfirm = useCallback(() => {
const options = checkoutData?.preview?.incoming
? buildOptionsArray(checkoutData.preview.incoming, quantities)
const featureQuantities = checkoutData?.preview?.incoming
? buildFeatureQuantities(checkoutData.preview.incoming, quantities)
: [];
confirmMutation.mutate({ options }, {
confirmMutation.mutate({ feature_quantities: featureQuantities }, {
onSuccess: (result) => {
if (!result.success) {
setActionRequiredResponse(result);

View File

@@ -8,6 +8,7 @@ import type {
} from "@autumn/shared";
import { format } from "date-fns";
import { formatAmount } from "./formatUtils";
import { getCheckoutPreviewIntent } from "./getCheckoutPreviewIntent";
/**
* Builds a phrase describing applied discounts.
@@ -138,6 +139,10 @@ export function buildHeaderDescription({
}): string | undefined {
if (!preview) return undefined;
const isUpdateSubscriptionPreview =
preview.object === "update_subscription_preview";
const previewIntent = getCheckoutPreviewIntent({ preview });
const { total, currency, line_items, next_cycle } = preview;
const change = incoming?.[0];
const scenario = change?.plan?.customer_eligibility?.scenario;
@@ -158,15 +163,19 @@ export function buildHeaderDescription({
});
// Build the action phrase
let action = buildActionPhrase({
scenario,
outgoingPlanName,
incomingPlanName,
isRecurring,
});
let action = isUpdateSubscriptionPreview
? incomingPlanName
? `Update plan ${incomingPlanName}`
: "Update plan"
: buildActionPhrase({
scenario,
outgoingPlanName,
incomingPlanName,
isRecurring,
});
// Add entity if present
if (entityName) {
if (entityName && !isUpdateSubscriptionPreview) {
action += ` for ${entityName}`;
}
@@ -211,6 +220,10 @@ export function buildHeaderDescription({
// Handle scheduled changes (no immediate charges)
if (isScheduledChange) {
if (previewIntent === "update_quantity") {
return `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} ${formatAmount(total, currency)} due today.`;
}
const effectiveDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
return `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} ${formatAmount(total, currency)} due today. Changes take effect ${effectiveDate}.`;
}

View File

@@ -0,0 +1,16 @@
import type {
AttachPreviewResponse,
PreviewUpdateSubscriptionResponse,
} from "@autumn/shared";
export const getCheckoutPreviewIntent = ({
preview,
}: {
preview?: AttachPreviewResponse | PreviewUpdateSubscriptionResponse;
}) => {
if (!preview || preview.object !== "update_subscription_preview") {
return undefined;
}
return preview.intent;
};

7177
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,33 @@ function getEnvVariable(filePath: string, key: string): string | null {
return null;
}
function killPorts({ ports }: { ports: number[] }) {
if (process.platform === "win32") {
return;
}
try {
const portArgs = ports.map((port) => `-ti:${port}`);
const result = Bun.spawnSync(["lsof", ...portArgs], {
stdout: "pipe",
stderr: "pipe",
});
const output = new TextDecoder().decode(result.stdout).trim();
if (!output) {
return;
}
const pids = [...new Set(output.split("\n").filter(Boolean))];
for (const pid of pids) {
process.kill(Number.parseInt(pid, 10), "SIGKILL");
}
console.log(`Killed processes on ports ${ports.join(", ")}.\n`);
} catch (error) {
console.warn("Port cleanup failed, continuing without cleanup.", error);
}
}
async function startDev() {
const rootDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(rootDir, "..");
@@ -55,8 +82,10 @@ async function startDev() {
console.log("\n Using remote backend (*.useautumn.com)");
console.log("Skipping port cleanup...\n");
} else {
// Port cleanup disabled (detection is unreliable)
console.log("Skipping port cleanup...\n");
console.log("Cleaning up local dev ports...\n");
killPorts({
ports: [VITE_PORT, SERVER_PORT, CHECKOUT_PORT],
});
}
// Clear Vite cache to prevent dep optimization issues

View File

@@ -5,6 +5,8 @@
"lib": ["ES2022"],
"moduleResolution": "bundler",
"resolveJsonModule": true,
"typeRoots": ["./node_modules/@types", "../node_modules/@types"],
"types": ["bun", "node"],
"allowJs": true,
"checkJs": false,
"outDir": "./dist",
@@ -13,7 +15,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"@server/*": ["../server/src/*"],

View File

@@ -76,6 +76,10 @@ export async function attach({
params,
});
console.log(
"[attach.ts] BILLING CONTEXT CHECKOUT MODE:",
billingContext.checkoutMode,
);
if (preview) {
return {
billingContext,

View File

@@ -78,6 +78,7 @@ export const renew = async ({
// feature_quantities: attachParams.optionsList,
cancel_action: "uncancel",
redirect_mode: "if_required",
};
return await billingActions.updateSubscription({

View File

@@ -8,6 +8,7 @@ import {
findActiveCustomerProductById,
InternalError,
} from "@autumn/shared";
import { te } from "date-fns/locale";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { billingActions } from "@/internal/billing/v2/actions";
import { attachParamsToStripeBillingContext } from "@/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext";
@@ -81,6 +82,7 @@ export const updateQuantity = async ({
feature_quantities: optionsListToFeatureQuantities({
optionsList: attachParams.optionsList,
}),
redirect_mode: "if_required",
};
const res = await billingActions.updateSubscription({

View File

@@ -53,6 +53,7 @@ export async function migrate({
version: newProduct.version, // to trigger update custom plan intent
transition_rules: transitionRules,
redirect_mode: "if_required",
};
ctx.logger.info(

View File

@@ -12,6 +12,45 @@ export const handleUpdateCheckoutErrors = ({
}) => {
if (billingContext.checkoutMode !== "autumn_checkout") return;
if (billingContext.intent === UpdateSubscriptionIntent.UpdateQuantity) {
const currentQuantities = new Map(
billingContext.customerProduct.options.map((option) => [
option.feature_id,
option.quantity,
]),
);
const quantitiesUnchanged = billingContext.featureQuantities.every(
(featureQuantity) =>
currentQuantities.get(featureQuantity.feature_id) ===
featureQuantity.quantity,
);
const hasAdjustableFeature = billingContext.featureQuantities.some(
(featureQuantity) =>
billingContext.adjustableFeatureQuantities?.includes(
featureQuantity.feature_id,
) === true,
);
if (quantitiesUnchanged && !hasAdjustableFeature) {
throw new RecaseError({
message:
"Cannot create checkout when quantities are not updated or adjustable",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
}
if (billingContext.intent === UpdateSubscriptionIntent.CancelAction) {
throw new RecaseError({
message: "Autumn checkout does not support cancel or uncancel updates",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (billingContext.intent !== UpdateSubscriptionIntent.None) return;
throw new RecaseError({

View File

@@ -10,6 +10,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupDefaultProductContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext";
import { setupUpdateSubscriptionProductContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
import { setupAdjustableQuantities } from "@/internal/billing/v2/setup/setupAdjustableQuantities";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
import { setupCancelAction } from "@/internal/billing/v2/setup/setupCancelMode";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
@@ -127,7 +128,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
let checkoutMode = setupAttachCheckoutMode({
paymentMethod,
redirectMode: params.redirect_mode,
redirectMode: params.redirect_mode ?? "if_required",
attachProduct: fullProduct,
stripeSubscription,
trialContext,
@@ -164,6 +165,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
invoiceMode,
featureQuantities,
adjustableFeatureQuantities: setupAdjustableQuantities({ params }),
customPrices,
customEnts,

View File

@@ -37,7 +37,6 @@ export const handlePreviewUpdateSubscription = createRoute({
ctx,
billingContext,
billingPlan,
params: body,
});
return c.json(previewResponse, 200);

View File

@@ -1,9 +1,11 @@
import type { BillingParamsBaseV1 } from "@autumn/shared";
import type { FeatureQuantityParamsV0 } from "@autumn/shared";
export const setupAdjustableQuantities = ({
params,
}: {
params: BillingParamsBaseV1;
params: {
feature_quantities?: FeatureQuantityParamsV0[];
};
}) => {
return (
params.feature_quantities

View File

@@ -1,4 +1,5 @@
import {
type AutumnBillingPlan,
type BillingContext,
type FullCusProduct,
ms,
@@ -16,11 +17,13 @@ import { lineItemToPreviewUsageLineItem } from "../../lineItems/lineItemToPrevie
export const billingPlanToNextCycleLineItems = ({
ctx,
customerProducts,
autumnBillingPlan,
billingContext,
nextCycleStart,
}: {
ctx: AutumnContext;
customerProducts: FullCusProduct[];
autumnBillingPlan: AutumnBillingPlan;
billingContext: BillingContext;
nextCycleStart: number;
}) => {
@@ -60,6 +63,10 @@ export const billingPlanToNextCycleLineItems = ({
timestampsMatch(lineItem.context.billingPeriod.start, nextCycleStart),
);
const deferredLineItems = (autumnBillingPlan.lineItems ?? []).filter(
(lineItem) => lineItem.chargeImmediately === false,
);
if (billingContext.stripeDiscounts?.length) {
const nextCycleDiscounts = filterStripeDiscountsForNextCycle({
stripeDiscounts: billingContext.stripeDiscounts,
@@ -73,9 +80,10 @@ export const billingPlanToNextCycleLineItems = ({
});
}
const previewLineItems = nextCycleAutumnLineItems.map(
lineItemToPreviewLineItem,
);
const previewLineItems = [
...nextCycleAutumnLineItems,
...deferredLineItems,
].map(lineItemToPreviewLineItem);
const subtotal = sumValues(previewLineItems.map((line) => line.subtotal));
const total = sumValues(previewLineItems.map((line) => line.total));

View File

@@ -130,6 +130,7 @@ export const billingPlanToNextCyclePreview = ({
billingPlanToNextCycleLineItems({
ctx,
customerProducts: filteredCustomerProducts,
autumnBillingPlan: billingPlan.autumn,
billingContext,
nextCycleStart,
});

View File

@@ -10,17 +10,15 @@ import {
isPrepaidPrice,
notNullish,
scopeExpandForCtx,
UpdateSubscriptionIntent,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct";
import { cusProductToBalances } from "@/internal/customers/cusUtils/apiCusUtils/getApiBalance/cusProductToBalances.js";
import { getApiSubscription } from "@/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.js";
import { billingPlanToOutgoingEffectiveAt } from "./billingPlanToEffectiveAt";
/**
* Convert cusProduct.options to feature_quantities with actual quantities
* (multiplied by billingUnits for prepaid features)
*/
function cusProductToFeatureQuantities({
ctx,
cusProduct,
@@ -35,7 +33,7 @@ function cusProductToFeatureQuantities({
features: ctx.features,
errorOnNotFound: true,
});
// Find the price for this feature to get billing units
const cusPrice = findCusPriceByFeature({
internalFeatureId: feature.internal_id,
cusPrices: cusProduct.customer_prices,
@@ -67,10 +65,54 @@ function cusProductToFeatureQuantities({
.filter(notNullish);
}
/**
* Convert a BillingPlan into incoming and outgoing CheckoutChange arrays.
* Incoming = products being added, Outgoing = products being canceled/expired/deleted.
*/
const getIsOutgoing = ({
billingContext,
updates,
}: {
billingContext: BillingContext;
updates: {
canceled?: boolean | null;
ended_at?: number | null;
status?: CusProductStatus | null;
};
}) => {
if (
"intent" in billingContext &&
billingContext.intent === UpdateSubscriptionIntent.UpdateQuantity
) {
return true;
}
if (billingContext.cancelAction === "uncancel") {
return true;
}
return Boolean(
updates.canceled ||
updates.ended_at ||
updates.status === CusProductStatus.Expired,
);
};
const getShouldIncludeIncoming = ({
billingContext,
isOutgoing,
incoming,
}: {
billingContext: BillingContext;
isOutgoing: boolean;
incoming: BillingPreviewChange[];
}) => {
if ("intent" in billingContext) {
return (
billingContext.intent === UpdateSubscriptionIntent.UpdateQuantity ||
billingContext.cancelAction === "uncancel"
);
}
return !isOutgoing && incoming.length === 0;
};
export const billingPlanToChanges = async ({
ctx,
billingContext,
@@ -93,7 +135,6 @@ export const billingPlanToChanges = async ({
prefix: "incoming",
});
// 1. Products being added (incoming)
for (const cusProduct of autumn.insertCustomerProducts) {
const { data: subscription } = await getApiSubscription({
ctx: incomingCtx,
@@ -101,12 +142,12 @@ export const billingPlanToChanges = async ({
fullCus: fullCustomer,
});
// biome-ignore lint/correctness/noUnusedVariables: Might use this in the future
const balances = cusProductToBalances({
ctx: incomingCtx,
cusProduct,
fullCustomer,
});
void balances;
incoming.push({
plan_id: subscription.plan_id,
@@ -115,11 +156,10 @@ export const billingPlanToChanges = async ({
ctx: incomingCtx,
cusProduct,
}),
expires_at: subscription.expires_at,
effective_at: null,
});
}
// 2. Products being canceled/expired (outgoing)
const outgoingCtx = scopeExpandForCtx({
ctx,
prefix: "outgoing",
@@ -131,12 +171,17 @@ export const billingPlanToChanges = async ({
});
const { updates } = autumn.updateCustomerProduct;
const isOutgoing =
updates.canceled ||
updates.ended_at ||
updates.status === CusProductStatus.Expired;
const isOutgoing = getIsOutgoing({
billingContext,
updates,
});
const shouldIncludeIncoming = getShouldIncludeIncoming({
billingContext,
isOutgoing,
incoming,
});
if (!isOutgoing && updatedCustomerProduct && incoming.length === 0) {
if (shouldIncludeIncoming && updatedCustomerProduct) {
const { data: subscription } = await getApiSubscription({
ctx: incomingCtx,
cusProduct: updatedCustomerProduct,
@@ -150,26 +195,31 @@ export const billingPlanToChanges = async ({
ctx: incomingCtx,
cusProduct: updatedCustomerProduct,
}),
expires_at: subscription.expires_at,
effective_at: null,
});
}
// Include in outgoing if: canceled, has an end date, or being expired (immediate upgrade)
if (isOutgoing && updatedCustomerProduct) {
const outgoingCustomerProduct =
autumn.updateCustomerProduct.customerProduct;
if (isOutgoing && outgoingCustomerProduct) {
const { data: subscription } = await getApiSubscription({
ctx: outgoingCtx,
cusProduct: updatedCustomerProduct,
cusProduct: outgoingCustomerProduct,
fullCus: fullCustomer,
});
outgoing.push({
plan_id: updatedCustomerProduct.product.id,
plan_id: outgoingCustomerProduct.product.id,
plan: subscription.plan,
feature_quantities: cusProductToFeatureQuantities({
ctx: outgoingCtx,
cusProduct: updatedCustomerProduct,
cusProduct: outgoingCustomerProduct,
}),
effective_at: billingPlanToOutgoingEffectiveAt({
billingContext,
autumnBillingPlan: billingPlan.autumn,
}),
expires_at: updatedCustomerProduct.ended_at ?? null,
});
}
}

View File

@@ -0,0 +1,42 @@
import type {
AttachBillingContext,
AutumnBillingPlan,
BillingContext,
UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import { billingPlanToUpdatedCustomerProduct } from "../billingPlanToUpdatedCustomerProduct";
export const billingPlanToOutgoingEffectiveAt = ({
billingContext,
autumnBillingPlan,
}: {
billingContext:
| BillingContext
| UpdateSubscriptionBillingContext
| AttachBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({
autumnBillingPlan,
});
// A. for update action
if ("intent" in billingContext) {
// 1. Cancel end of cycle
if (billingContext.cancelAction === "cancel_end_of_cycle") {
return updatedCustomerProduct?.ended_at ?? null;
}
return billingContext.currentEpochMs;
}
// If plan timing is "scheduled":
if (
"planTiming" in billingContext &&
billingContext.planTiming === "end_of_cycle"
) {
return updatedCustomerProduct?.ended_at ?? null;
}
return billingContext.currentEpochMs;
};

View File

@@ -2,7 +2,6 @@ import type {
BillingPlan,
PreviewUpdateSubscriptionResponse,
UpdateSubscriptionBillingContext,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { billingPlanToPreviewResponse } from "../../billingPlanToPreviewResponse";
@@ -12,12 +11,10 @@ export const billingPlanToUpdateSubscriptionPreview = async ({
ctx,
billingContext,
billingPlan,
params,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
billingPlan: BillingPlan;
params: UpdateSubscriptionV1Params;
}): Promise<PreviewUpdateSubscriptionResponse> => {
const basePreview = await billingPlanToPreviewResponse({
ctx,
@@ -29,7 +26,6 @@ export const billingPlanToUpdateSubscriptionPreview = async ({
...basePreview,
object: "update_subscription_preview",
intent: billingPlanToUpdateSubscriptionPreviewIntent({
params,
billingContext,
}),
} satisfies PreviewUpdateSubscriptionResponse;

View File

@@ -2,14 +2,11 @@ import {
type UpdateSubscriptionBillingContext,
UpdateSubscriptionIntent,
UpdateSubscriptionPreviewIntent,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
export const billingPlanToUpdateSubscriptionPreviewIntent = ({
params,
billingContext,
}: {
params: UpdateSubscriptionV1Params;
billingContext: UpdateSubscriptionBillingContext;
}) => {
switch (billingContext.intent) {
@@ -18,16 +15,16 @@ export const billingPlanToUpdateSubscriptionPreviewIntent = ({
case UpdateSubscriptionIntent.UpdatePlan:
return UpdateSubscriptionPreviewIntent.UpdatePlan;
case UpdateSubscriptionIntent.CancelAction: {
if (params.cancel_action === "cancel_immediately") {
return UpdateSubscriptionPreviewIntent.CancelImmediately;
switch (billingContext.cancelAction) {
case "cancel_immediately":
return UpdateSubscriptionPreviewIntent.CancelImmediately;
case "cancel_end_of_cycle":
return UpdateSubscriptionPreviewIntent.CancelEndOfCycle;
case "uncancel":
return UpdateSubscriptionPreviewIntent.Uncancel;
default:
return UpdateSubscriptionPreviewIntent.None;
}
if (params.cancel_action === "cancel_end_of_cycle") {
return UpdateSubscriptionPreviewIntent.CancelEndOfCycle;
}
if (params.cancel_action === "uncancel") {
return UpdateSubscriptionPreviewIntent.Uncancel;
}
return UpdateSubscriptionPreviewIntent.None;
}
case UpdateSubscriptionIntent.None:

View File

@@ -1,9 +1,9 @@
import type { BillingContext, BillingPlan } from "@autumn/shared";
import { type BillingPreviewResponse, orgToCurrency } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { billingPlanToChanges } from "./billingPlan/billingPlanToChanges";
import { billingPlanToImmediatePreview } from "./billingPlan/toImmediatePreview/billingPlanToImmediatePreview";
import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
import { billingPlanToChanges } from "./billingPlan/toPreviewChanges/billingPlanToChanges";
import { logBillingPreview } from "./logs/logBillingPreview";
export const billingPlanToPreviewResponse = async ({

View File

@@ -4,14 +4,17 @@ import type { HonoEnv } from "@/honoUtils/HonoEnv";
import { handleConfirmCheckout } from "./handlers/handleConfirmCheckout";
import { handleGetCheckout } from "./handlers/handleGetCheckout";
import { handlePreviewCheckout } from "./handlers/handlePreviewCheckout";
import { checkoutMiddleware } from "./middleware/checkoutMiddleware";
import {
checkoutMiddleware,
checkoutRateLimiter,
} from "./middleware/checkoutMiddleware";
export const publicCheckoutRouter = new Hono<HonoEnv>();
// publicCheckoutRouter.use(analyticsMiddleware);
// Apply rate limiter to all checkout routes
// publicCheckoutRouter.use("/:checkout_id", checkoutRateLimiter);
// publicCheckoutRouter.use("/:checkout_id/*", checkoutRateLimiter);
publicCheckoutRouter.use("/:checkout_id", checkoutRateLimiter);
publicCheckoutRouter.use("/:checkout_id/*", checkoutRateLimiter);
// Apply checkout middleware to fetch from cache
publicCheckoutRouter.use("/:checkout_id", checkoutMiddleware);

View File

@@ -2,6 +2,14 @@ import type { Checkout, GetCheckoutResponse } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { previewCheckoutAction } from "../utils/previewCheckoutAction/previewCheckoutAction";
const getAdjustableFeatureIds = ({ checkout }: { checkout: Checkout }) => {
return (
checkout.params.feature_quantities
?.filter((featureQuantity) => featureQuantity.adjustable === true)
.map((featureQuantity) => featureQuantity.feature_id) ?? []
);
};
/**
* GET /checkouts/:checkout_id
*
@@ -34,6 +42,7 @@ export const handleGetCheckout = createRoute({
name: fullCustomer.name || null,
email: fullCustomer.email || null,
},
adjustable_feature_ids: getAdjustableFeatureIds({ checkout }),
entity: fullCustomer.entity
? {
id: fullCustomer.entity.id ?? fullCustomer.entity.internal_id,

View File

@@ -2,12 +2,20 @@ import {
type Checkout,
type ConfirmCheckoutParams,
ConfirmCheckoutParamsSchema,
type GetCheckoutResponse,
type PreviewCheckoutResponse,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { augmentCheckoutParams } from "../utils/augmentCheckoutParams";
import { previewCheckoutAction } from "../utils/previewCheckoutAction/previewCheckoutAction";
const getAdjustableFeatureIds = ({ checkout }: { checkout: Checkout }) => {
return (
checkout.params.feature_quantities
?.filter((featureQuantity) => featureQuantity.adjustable === true)
.map((featureQuantity) => featureQuantity.feature_id) ?? []
);
};
/**
* POST /checkouts/:checkout_id/preview
*
@@ -31,7 +39,7 @@ export const handlePreviewCheckout = createRoute({
});
const { fullCustomer } = billingContext;
const response: GetCheckoutResponse = {
const response: PreviewCheckoutResponse = {
env: checkout.env,
action: checkout.action,
status: checkout.status,
@@ -46,6 +54,7 @@ export const handlePreviewCheckout = createRoute({
name: fullCustomer.name || null,
email: fullCustomer.email || null,
},
adjustable_feature_ids: getAdjustableFeatureIds({ checkout }),
entity: fullCustomer.entity
? {
id: fullCustomer.entity.id ?? fullCustomer.entity.internal_id,

View File

@@ -44,13 +44,34 @@ export function augmentCheckoutParams({
checkout: Checkout;
body: ConfirmCheckoutParams;
}): AttachParamsV1 | UpdateSubscriptionV1Params {
const mergeFeatureQuantities = ({
originalFeatureQuantities,
}: {
originalFeatureQuantities:
| AttachParamsV1["feature_quantities"]
| UpdateSubscriptionV1Params["feature_quantities"];
}) => {
return body.feature_quantities.map((featureQuantity) => {
const originalFeatureQuantity = originalFeatureQuantities?.find(
(original) => original.feature_id === featureQuantity.feature_id,
);
return {
...featureQuantity,
adjustable: originalFeatureQuantity?.adjustable,
};
});
};
switch (checkout.action) {
case CheckoutAction.Attach: {
const originalParams = checkout.params as AttachParamsV1;
return {
...originalParams,
feature_quantities: body.options,
feature_quantities: mergeFeatureQuantities({
originalFeatureQuantities: originalParams.feature_quantities,
}),
};
}
case CheckoutAction.UpdateSubscription: {
@@ -58,7 +79,9 @@ export function augmentCheckoutParams({
return {
...originalParams,
feature_quantities: body.options,
feature_quantities: mergeFeatureQuantities({
originalFeatureQuantities: originalParams.feature_quantities,
}),
};
}
default:

View File

@@ -101,7 +101,6 @@ export async function previewCheckoutAction({
}),
billingContext: updateSubscriptionResult.billingContext,
billingPlan,
params: params as UpdateSubscriptionV1Params,
});
return {

View File

@@ -31,6 +31,7 @@ export const handleCancelV2 = createRoute({
? "cancel_immediately"
: "cancel_end_of_cycle",
proration_behavior: bodyProrate ? "prorate_immediately" : "none",
redirect_mode: "if_required",
};
ctx.logger.info(

View File

@@ -0,0 +1,113 @@
import { expect, test } from "bun:test";
import { expectPreviewChanges } from "@tests/integration/billing/utils/expectPreviewChanges";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("attach preview: new plan")}`, async () => {
const customerId = "attach-preview-new-plan";
const pro = products.pro({
id: "pro",
items: [
items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 10 }),
],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
});
expect(preview.total).toBe(30);
expectPreviewChanges({
preview,
incoming: [
{
planId: pro.id,
featureQuantities: [
{ feature_id: TestFeature.Messages, quantity: 100 },
],
effectiveAt: null,
},
],
outgoing: [],
});
});
test.concurrent(`${chalk.yellowBright("attach preview: immediate switch")}`, async () => {
const customerId = "attach-preview-immediate-switch";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(30);
expectPreviewChanges({
preview,
incoming: [{ planId: premium.id, effectiveAt: null }],
outgoing: [{ planId: pro.id }],
});
});
test.concurrent(`${chalk.yellowBright("attach preview: scheduled switch")}`, async () => {
const customerId = "attach-preview-scheduled-switch";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: free.id,
});
expect(preview.total).toBe(0);
expectPreviewChanges({
preview,
incoming: [{ planId: free.id, effectiveAt: null }],
outgoing: [{ planId: pro.id }],
});
});

View File

@@ -0,0 +1,106 @@
import { test } from "bun:test";
import { ErrCode, type UpdateSubscriptionV1ParamsInput } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("update-checkout error: no changes with redirect mode")}`, async () => {
const pro = products.pro({
id: "pro",
items: [items.dashboard()],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "err-update-checkout-no-changes",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"Cannot create checkout when no billing changes will happen in this update",
func: async () => {
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "always",
});
},
});
});
test.concurrent(`${chalk.yellowBright("update-checkout error: prepaid no changes with redirect mode")}`, async () => {
const pro = products.pro({
id: "pro",
items: [
items.dashboard(),
items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
}),
],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "err-update-checkout-prepaid-no-changes",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 300 }],
}),
],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"Cannot create checkout when quantities are not updated or adjustable",
func: async () => {
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "always",
});
},
});
});
test.concurrent(`${chalk.yellowBright("update-checkout error: cancel action with redirect mode")}`, async () => {
const pro = products.pro({
id: "pro",
items: [items.dashboard()],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "err-update-checkout-cancel-action",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Autumn checkout does not support cancel or uncancel updates",
func: async () => {
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
cancel_action: "cancel_end_of_cycle",
redirect_mode: "always",
});
},
});
});

View File

@@ -0,0 +1,251 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
UpdateSubscriptionPreviewIntent,
} from "@autumn/shared";
import {
expectProductActive,
expectProductCanceling,
expectProductNotPresent,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectPreviewChanges } from "@tests/integration/billing/utils/expectPreviewChanges";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const getProducts = () => {
const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 });
const proMessagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [freeMessagesItem],
isDefault: true,
});
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
return {
free,
pro,
};
};
test.concurrent(`${chalk.yellowBright("update-subscription preview: update quantity")}`, async () => {
const customerId = "update-sub-preview-quantity";
const pro = products.pro({
id: "pro",
items: [
items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
}),
],
});
const { advancedTo, autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 300 }],
});
expect(preview.intent).toBe(UpdateSubscriptionPreviewIntent.UpdateQuantity);
expectPreviewChanges({
preview,
incoming: [
{
planId: pro.id,
featureQuantities: [
{ feature_id: TestFeature.Messages, quantity: 300 },
],
effectiveAt: null,
},
],
outgoing: [
{
planId: pro.id,
featureQuantities: [
{ feature_id: TestFeature.Messages, quantity: 200 },
],
effectiveAt: advancedTo,
},
],
});
});
test.concurrent(`${chalk.yellowBright("update-subscription preview: update custom plan")}`, async () => {
const customerId = "update-sub-preview-custom-plan";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const preview = await autumnV2_1.subscriptions.previewUpdate({
customer_id: customerId,
plan_id: pro.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 30 }),
items: [itemsV2.monthlyWords({ included: 200 })],
},
});
expect(preview.intent).toBe(UpdateSubscriptionPreviewIntent.UpdatePlan);
expectPreviewChanges({
preview,
incoming: [{ planId: pro.id, effectiveAt: null }],
outgoing: [{ planId: pro.id }],
});
});
test.concurrent(`${chalk.yellowBright("update-subscription preview: cancel immediately with default free")}`, async () => {
const customerId = "update-sub-preview-cancel-immediately";
const { free, pro } = getProducts();
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
const customerBeforePreview =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerBeforePreview,
productId: pro.id,
});
await expectProductNotPresent({
customer: customerBeforePreview,
productId: free.id,
});
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
});
expect(preview.total).toBe(-20);
expectPreviewChanges({
preview,
incoming: [{ planId: free.id, effectiveAt: null }],
outgoing: [{ planId: pro.id }],
});
});
test.concurrent(`${chalk.yellowBright("update-subscription preview: cancel end of cycle with default free")}`, async () => {
const customerId = "update-sub-preview-cancel-eoc";
const { free, pro } = getProducts();
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
const customerBeforePreview =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerBeforePreview,
productId: pro.id,
});
await expectProductNotPresent({
customer: customerBeforePreview,
productId: free.id,
});
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
expectPreviewChanges({
preview,
incoming: [{ planId: free.id, effectiveAt: null }],
outgoing: [{ planId: pro.id }],
});
});
test.concurrent(`${chalk.yellowBright("update-subscription preview: uncancel with scheduled default free")}`, async () => {
const customerId = "update-sub-preview-uncancel";
const { free, pro } = getProducts();
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.updateSubscription({
productId: pro.id,
cancelAction: "cancel_end_of_cycle",
}),
],
});
const customerBeforePreview =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerBeforePreview,
productId: pro.id,
});
await expectProductScheduled({
customer: customerBeforePreview,
productId: free.id,
});
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
cancel_action: "uncancel",
});
expectPreviewChanges({
preview,
incoming: [{ planId: pro.id, effectiveAt: null }],
outgoing: [{ planId: pro.id }],
});
});

View File

@@ -0,0 +1,129 @@
import { expect } from "bun:test";
import {
type BillingPreviewChange,
type BillingPreviewResponse,
formatMs,
type PreviewUpdateSubscriptionResponse,
} from "@autumn/shared";
type PreviewChangeExpectation = {
planId: string;
featureQuantities?: Array<{ feature_id: string; quantity: number }>;
effectiveAt?: number | null;
effectiveAtToleranceMs?: number;
};
type ExpectPreviewChangesParams = {
preview: BillingPreviewResponse | PreviewUpdateSubscriptionResponse;
incoming?: PreviewChangeExpectation[];
outgoing?: PreviewChangeExpectation[];
debug?: boolean;
};
const TEN_MINUTES_MS = 10 * 60 * 1000;
const logPreviewChanges = ({
changes,
label,
}: {
changes: BillingPreviewChange[];
label: string;
}) => {
console.log(`\n${label} (${changes.length})`);
for (const change of changes) {
const effectiveAt =
change.effective_at === null
? "null"
: new Date(change.effective_at).toISOString();
console.log(` ├─ ${change.plan_id} effective_at=${effectiveAt}`);
if (change.feature_quantities.length === 0) {
console.log(" │ └─ no feature quantities");
continue;
}
for (let index = 0; index < change.feature_quantities.length; index++) {
const featureQuantity = change.feature_quantities[index];
const branch =
index === change.feature_quantities.length - 1 ? "└─" : "├─";
console.log(
`${branch} ${featureQuantity.feature_id}: ${featureQuantity.quantity}`,
);
}
}
};
const expectPreviewChange = ({
change,
expected,
}: {
change: BillingPreviewChange | undefined;
expected: PreviewChangeExpectation;
}) => {
expect(change).toBeDefined();
if (!change) {
return;
}
if (expected.featureQuantities) {
expect(change.feature_quantities).toEqual(
expect.arrayContaining(expected.featureQuantities),
);
}
if (expected.effectiveAt !== undefined) {
if (expected.effectiveAt === null) {
expect(change.effective_at).toBeNull();
return;
}
const actualEffectiveAt = change.effective_at;
const toleranceMs = expected.effectiveAtToleranceMs ?? TEN_MINUTES_MS;
expect(actualEffectiveAt).toBeDefined();
expect(actualEffectiveAt).not.toBeNull();
const diff = Math.abs((actualEffectiveAt ?? 0) - expected.effectiveAt);
expect(
diff,
`effectiveAt mismatch for ${expected.planId}: expected ${formatMs(expected.effectiveAt)}, got ${formatMs(actualEffectiveAt ?? 0)}`,
).toBeLessThanOrEqual(toleranceMs);
}
};
export const expectPreviewChanges = ({
preview,
incoming = [],
outgoing = [],
debug = true,
}: ExpectPreviewChangesParams) => {
if (debug) {
logPreviewChanges({ changes: preview.incoming, label: "PREVIEW INCOMING" });
logPreviewChanges({ changes: preview.outgoing, label: "PREVIEW OUTGOING" });
}
expect(preview.incoming).toHaveLength(incoming.length);
expect(preview.outgoing).toHaveLength(outgoing.length);
for (const expected of incoming) {
expectPreviewChange({
change: preview.incoming.find(
(change) => change.plan_id === expected.planId,
),
expected,
});
}
for (const expected of outgoing) {
expectPreviewChange({
change: preview.outgoing.find(
(change) => change.plan_id === expected.planId,
),
expected,
});
}
};

View File

@@ -42,7 +42,7 @@ test(`${chalk.yellowBright("checkout: complex - product with many line items")}`
// Options for prepaid features
const options = [
{ feature_id: TestFeature.Messages, quantity: 500 },
{ feature_id: TestFeature.Messages, quantity: 500, adjustable: true },
{ feature_id: TestFeature.Users, quantity: 10 },
];

View File

@@ -1,4 +1,5 @@
import { test } from "bun:test";
import type { AttachParamsV1 } from "@autumn/shared";
import {
applySubscriptionDiscount,
createPercentCoupon,
@@ -42,7 +43,7 @@ test(`${chalk.yellowBright("checkout: discount applied to upgrade - 20% off")}`,
});
// Setup: customer with payment method and starter plan attached
const { autumnV1 } = await initScenario({
const { autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
@@ -68,29 +69,20 @@ test(`${chalk.yellowBright("checkout: discount applied to upgrade - 20% off")}`,
couponIds: [coupon.id],
});
// Get customer state before upgrade
const customerBefore = await autumnV1.customers.get(customerId);
console.log("customer before upgrade with discount:", {
products: customerBefore.products?.map(
(p: { id: string; name: string | null }) => ({
id: p.id,
name: p.name,
}),
),
});
// 1. Preview upgrade to pro (should show discounted charge)
const upgradePreview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: `pro_${customerId}`,
redirect_mode: "always",
});
const upgradePreview = await autumnV2_1.billing.previewAttach<AttachParamsV1>(
{
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "always",
},
);
console.log("upgrade with 20% discount preview:", upgradePreview);
// 2. Perform the upgrade with redirect_mode: "always" (Autumn checkout URL)
const upgradeResult = await autumnV1.billing.attach({
const upgradeResult = await autumnV2_1.billing.attach<AttachParamsV1>({
customer_id: customerId,
product_id: `pro_${customerId}`,
plan_id: `pro_${customerId}`,
redirect_mode: "always",
});
console.log("upgrade with 20% discount result:", upgradeResult);

View File

@@ -32,21 +32,6 @@ const GRADUATED_TIERS = [
test(`${chalk.yellowBright("tiers: tiered prepaid (graduated) upgrade - starter → pro with 800 messages")}`, async () => {
const customerId = "tiers-tiered-prepaid-upgrade";
const tieredWords = items.tieredConsumableWords({
includedUsage: 100,
billingUnits: BILLING_UNITS,
tiers: GRADUATED_TIERS,
});
const volumePrepaidCredits = items.volumePrepaidCredits({
includedUsage: 100,
billingUnits: 0,
tiers: [
{ to: 500, flat_amount: 20, amount: 0 },
{ to: "inf" as const, flat_amount: 50, amount: 0.5 },
],
});
// Pro: graduated tiered prepaid messages ($49/mo, 100 included, 2-tier pricing)
const pro = products.base({
id: "pro",
@@ -58,8 +43,6 @@ test(`${chalk.yellowBright("tiers: tiered prepaid (graduated) upgrade - starter
billingUnits: BILLING_UNITS,
tiers: GRADUATED_TIERS,
}),
tieredWords,
volumePrepaidCredits,
items.monthlyPrice({ price: 49 }),
],
});

View File

@@ -0,0 +1,75 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
/**
* Update Subscription Checkout - Custom plan decrease scenario
*
* Starts from a paid base plan, then previews an update-subscription checkout
* that lowers the base price while keeping the same included features.
*/
test(`${chalk.yellowBright("update-subscription-checkout: custom plan reduce base price")}`, async () => {
const customerId = "update-sub-checkout-custom-dec";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 30 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
console.log("customer before custom plan decrease:", {
products: customerBefore.products?.map((product) => ({
id: product.id,
name: product.name,
status: product.status,
})),
features: {
[TestFeature.Messages]: customerBefore.features?.[TestFeature.Messages],
},
});
const updateParams: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "always",
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
},
};
const updatePreview =
await autumnV2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
updateParams,
);
console.log("custom plan decrease preview:", updatePreview);
expect(updatePreview.total).toBe(-10);
const updateResult =
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(
updateParams,
);
console.log("custom plan decrease result:", updateResult);
expect(updateResult.payment_url).toBeTruthy();
});

View File

@@ -0,0 +1,70 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
/**
* Update Subscription Checkout - Custom plan increase scenario
*
* Starts from a paid base plan, then previews an update-subscription checkout
* that raises the base price and adds a second included feature.
*/
test(`${chalk.yellowBright("update-subscription-checkout: custom plan increase base price + add feature")}`, async () => {
const customerId = "update-sub-checkout-custom-inc";
const pro = products.base({
id: "pro",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
console.log("customer before custom plan increase:", {
products: customerBefore.products?.map((product) => ({
id: product.id,
name: product.name,
status: product.status,
})),
features: {
[TestFeature.Messages]: customerBefore.features?.[TestFeature.Messages],
[TestFeature.Words]: customerBefore.features?.[TestFeature.Words],
},
});
const updateParams: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "always",
customize: {
price: itemsV2.monthlyPrice({ amount: 30 }),
items: [itemsV2.monthlyWords({ included: 200 })],
},
};
const updateResult =
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(
updateParams,
);
console.log("custom plan increase result:", updateResult);
expect(updateResult.payment_url).toBeTruthy();
});

View File

@@ -0,0 +1,98 @@
import { test } from "bun:test";
import {
type ApiCustomerV3,
OnIncrease,
type UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
/**
* Update Subscription Checkout - Quantity Next Cycle Scenario
*
* Baseline scenario for frontend card exploration.
* Starts with an attached prepaid plan, previews a quantity update on the same
* product with `ProrateNextCycle`, then applies it so we can inspect the
* before/preview/after shapes.
*/
test(`${chalk.yellowBright("update-subscription-checkout: quantity update on same plan - prorate next cycle")}`, async () => {
const customerId = "update-sub-checkout-qty";
const pro = products.pro({
id: "pro",
items: [
items.dashboard(),
items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
prorationConfig: {
onIncrease: OnIncrease.ProrateNextCycle,
},
}),
],
});
const initialOptions = [{ feature_id: TestFeature.Messages, quantity: 300 }];
const updatedOptions = [
{ feature_id: TestFeature.Messages, quantity: 700, adjustable: true },
];
const { autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
options: initialOptions,
}),
],
});
const customerBefore =
await autumnV2.customers.get<ApiCustomerV3>(customerId);
console.log("customer before quantity update:", {
products: customerBefore.products?.map((product) => ({
id: product.id,
name: product.name,
status: product.status,
})),
features: customerBefore.features?.[TestFeature.Messages],
});
const updateParams = {
customer_id: customerId,
plan_id: pro.id,
feature_quantities: updatedOptions,
redirect_mode: "always",
} satisfies UpdateSubscriptionV1ParamsInput;
const updatePreview =
await autumnV2.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
updateParams,
);
console.log("update subscription preview:", updatePreview);
const updateResult =
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(
updateParams,
);
console.log("update subscription result:", updateResult);
const customerAfter = await autumnV2.customers.get<ApiCustomerV3>(customerId);
console.log("customer after quantity update:", {
products: customerAfter.products?.map((product) => ({
id: product.id,
name: product.name,
status: product.status,
})),
features: customerAfter.features?.[TestFeature.Messages],
});
});

View File

@@ -1,5 +1,8 @@
import { test } from "bun:test";
import type { ApiCustomerV3, UpdateSubscriptionV1Params } from "@autumn/shared";
import type {
ApiCustomerV3,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
@@ -29,9 +32,8 @@ test(`${chalk.yellowBright("update-subscription-checkout: quantity update on sam
});
const initialOptions = [{ feature_id: TestFeature.Messages, quantity: 300 }];
const updatedOptions = [{ feature_id: TestFeature.Messages, quantity: 700 }];
const { autumnV2 } = await initScenario({
const { autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
@@ -46,7 +48,7 @@ test(`${chalk.yellowBright("update-subscription-checkout: quantity update on sam
});
const customerBefore =
await autumnV2.customers.get<ApiCustomerV3>(customerId);
await autumnV2_1.customers.get<ApiCustomerV3>(customerId);
console.log("customer before quantity update:", {
products: customerBefore.products?.map((product) => ({
id: product.id,
@@ -56,13 +58,20 @@ test(`${chalk.yellowBright("update-subscription-checkout: quantity update on sam
features: customerBefore.features?.[TestFeature.Messages],
});
const updatedOptions = [
{ feature_id: TestFeature.Messages, quantity: 400, adjustable: true },
];
const updateParams = {
customer_id: customerId,
plan_id: pro.id,
feature_quantities: updatedOptions,
redirect_mode: "always",
} satisfies UpdateSubscriptionV1ParamsInput;
const updateResult =
await autumnV2.subscriptions.update<UpdateSubscriptionV1Params>({
customer_id: customerId,
plan_id: pro.id,
// feature_quantities: [],
redirect_mode: "always",
});
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>(
updateParams,
);
console.log("update subscription result:", updateResult);
});

View File

@@ -8,12 +8,17 @@
*/
import { describe, expect, test } from "bun:test";
import type { UpdateSubscriptionV1Params } from "@autumn/shared";
import chalk from "chalk";
import {
computeUpdateSubscriptionIntent,
type FullCusProduct,
UpdateSubscriptionIntent,
} from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionIntent";
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import chalk from "chalk";
import { setupUpdateSubscriptionIntent } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionIntent";
const customerProduct = {
prices: [],
} as unknown as FullCusProduct;
const baseParams: UpdateSubscriptionV1Params = {
customer_id: "cus_test",
@@ -28,7 +33,11 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
version: 2,
};
const result = computeUpdateSubscriptionIntent(params);
const result = setupUpdateSubscriptionIntent({
params,
checkoutMode: null,
customerProduct,
});
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
});
@@ -40,7 +49,11 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
feature_quantities: [{ feature_id: "seats", quantity: 10 }],
};
const result = computeUpdateSubscriptionIntent(params);
const result = setupUpdateSubscriptionIntent({
params,
checkoutMode: null,
customerProduct,
});
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
});
@@ -51,7 +64,11 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
version: 0,
};
const result = computeUpdateSubscriptionIntent(params);
const result = setupUpdateSubscriptionIntent({
params,
checkoutMode: null,
customerProduct,
});
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
});
@@ -64,7 +81,11 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
feature_quantities: [{ feature_id: "seats", quantity: 5 }],
};
const result = computeUpdateSubscriptionIntent(params);
const result = setupUpdateSubscriptionIntent({
params,
checkoutMode: null,
customerProduct,
});
expect(result).toBe(UpdateSubscriptionIntent.UpdateQuantity);
});
@@ -78,7 +99,11 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
],
};
const result = computeUpdateSubscriptionIntent(params);
const result = setupUpdateSubscriptionIntent({
params,
checkoutMode: null,
customerProduct,
});
expect(result).toBe(UpdateSubscriptionIntent.UpdateQuantity);
});
@@ -93,7 +118,11 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
},
};
const result = computeUpdateSubscriptionIntent(params);
const result = setupUpdateSubscriptionIntent({
params,
checkoutMode: null,
customerProduct,
});
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
});
@@ -107,7 +136,11 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
},
};
const result = computeUpdateSubscriptionIntent(params);
const result = setupUpdateSubscriptionIntent({
params,
checkoutMode: null,
customerProduct,
});
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
});

View File

@@ -306,6 +306,7 @@ const prepaid = ({
billingUnits = 100,
includedUsage = 0,
config,
prorationConfig,
entityFeatureId,
}: {
featureId: string;
@@ -313,16 +314,34 @@ const prepaid = ({
billingUnits?: number;
includedUsage?: number;
config?: ProductItemConfig;
prorationConfig?: {
onIncrease?: ProductItemConfig["on_increase"];
onDecrease?: ProductItemConfig["on_decrease"];
};
entityFeatureId?: string;
}): LimitedItem =>
constructPrepaidItem({
}): LimitedItem => {
const mergedConfig =
config || prorationConfig
? {
...config,
...(prorationConfig?.onIncrease
? { on_increase: prorationConfig.onIncrease }
: {}),
...(prorationConfig?.onDecrease
? { on_decrease: prorationConfig.onDecrease }
: {}),
}
: undefined;
return constructPrepaidItem({
featureId,
price,
billingUnits,
includedUsage,
config,
config: mergedConfig,
entityFeatureId,
}) as LimitedItem;
};
/**
* Prepaid messages - purchase units upfront ($10/unit)
@@ -334,12 +353,17 @@ const prepaidMessages = ({
billingUnits = 100,
price = 10,
config,
prorationConfig,
entityFeatureId,
}: {
includedUsage?: number;
billingUnits?: number;
price?: number;
config?: ProductItemConfig;
prorationConfig?: {
onIncrease?: ProductItemConfig["on_increase"];
onDecrease?: ProductItemConfig["on_decrease"];
};
entityFeatureId?: string;
} = {}): LimitedItem =>
prepaid({
@@ -348,6 +372,7 @@ const prepaidMessages = ({
billingUnits,
includedUsage,
config,
prorationConfig,
entityFeatureId,
});

View File

@@ -13,8 +13,7 @@ export const BillingPreviewChangeSchema = z.object({
quantity: z.number(),
}),
),
expires_at: z.number().nullable(),
// balances: z.record(z.string(), ApiBalanceV1Schema),
effective_at: z.number().nullable(),
});
export type BillingPreviewChange = z.infer<typeof BillingPreviewChangeSchema>;

View File

@@ -15,13 +15,13 @@ export enum UpdateSubscriptionPreviewIntent {
export const ExtPreviewUpdateSubscriptionResponseSchema =
ExtBillingPreviewResponseSchema.extend({
intent: z.nativeEnum(UpdateSubscriptionPreviewIntent),
intent: z.enum(UpdateSubscriptionPreviewIntent),
});
export const PreviewUpdateSubscriptionResponseSchema =
BillingPreviewResponseSchema.extend({
object: z.literal("update_subscription_preview").meta({ internal: true }),
intent: z.nativeEnum(UpdateSubscriptionPreviewIntent),
intent: z.enum(UpdateSubscriptionPreviewIntent),
});
export type ExtPreviewUpdateSubscriptionResponse = z.infer<

View File

@@ -4,6 +4,7 @@ import { z } from "zod/v4";
import { BillingBehaviorSchema } from "../common/billingBehavior";
import { BillingParamsBaseV0Schema } from "../common/billingParamsBase/billingParamsBaseV0";
import { CancelActionSchema } from "../common/cancelAction";
import { RedirectModeSchema } from "../common/redirectMode";
export const ExtUpdateSubscriptionV0ParamsSchema =
BillingParamsBaseV0Schema.extend({
@@ -38,7 +39,7 @@ export const ExtUpdateSubscriptionV0ParamsSchema =
export const UpdateSubscriptionV0ParamsSchema =
ExtUpdateSubscriptionV0ParamsSchema.extend({
customer_product_id: z.string().optional(),
// refund_behavior: RefundBehaviorSchema.optional(),
redirect_mode: RedirectModeSchema.optional(),
})
.check((ctx) => {

View File

@@ -2,6 +2,7 @@ import { CusProductStatus } from "@models/cusProductModels/cusProductEnums";
import { z } from "zod/v4";
import { BillingParamsBaseV1Schema } from "../common/billingParamsBase/billingParamsBaseV1";
import { CancelActionSchema } from "../common/cancelAction";
import { RedirectModeSchema } from "../common/redirectMode";
export const ExtUpdateSubscriptionV1ParamsSchema =
BillingParamsBaseV1Schema.extend({
@@ -54,6 +55,7 @@ export const UpdateSubscriptionV1ParamsSchema =
customer_product_id: z.string().optional().meta({
internal: true,
}),
redirect_mode: RedirectModeSchema.optional(),
}).refine((data) => UPDATE_FIELDS.some((key) => data[key] !== undefined), {
message:
"At least one update parameter must be provided (feature_quantities, version, customize, or cancel_action)",

View File

@@ -7,63 +7,39 @@ import {
CheckoutStatus,
} from "../../models/checkouts/checkoutTable";
/**
* 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(),
});
/**
* GET /checkouts/:checkout_id response
*/
export const GetCheckoutResponseSchema = z.object({
export const CheckoutPreviewSchema = z.union([
AttachPreviewResponseSchema,
PreviewUpdateSubscriptionResponseSchema,
]);
export const CheckoutResponseBaseSchema = z.object({
env: z.string(),
action: z.nativeEnum(CheckoutAction),
status: z.nativeEnum(CheckoutStatus),
response: BillingResponseSchema.nullable(),
preview: z.union([
AttachPreviewResponseSchema,
PreviewUpdateSubscriptionResponseSchema,
]),
preview: CheckoutPreviewSchema,
org: CheckoutOrgSchema,
customer: CheckoutCustomerSchema,
entity: CheckoutEntitySchema.nullable(),
});
/**
* POST /checkouts/:checkout_id/confirm response
*/
export const ConfirmCheckoutResponseSchema = BillingResponseSchema.extend({
success: z.boolean(),
checkout_id: z.string(),
product_id: z.string(),
invoice_id: z.string().nullable(),
success_url: z.string().url(),
adjustable_feature_ids: z.array(z.string()),
});
export type CheckoutOrg = z.infer<typeof CheckoutOrgSchema>;
export type CheckoutCustomer = z.infer<typeof CheckoutCustomerSchema>;
export type CheckoutEntity = z.infer<typeof CheckoutEntitySchema>;
export type GetCheckoutResponse = z.infer<typeof GetCheckoutResponseSchema>;
export type ConfirmCheckoutResponse = z.infer<
typeof ConfirmCheckoutResponseSchema
>;

View File

@@ -2,7 +2,7 @@ import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels"
import { z } from "zod/v4";
export const ConfirmCheckoutParamsSchema = z.object({
options: z.array(
feature_quantities: z.array(
FeatureOptionsSchema.pick({
feature_id: true,
quantity: true,

View File

@@ -0,0 +1,14 @@
import { z } from "zod/v4";
import { BillingResponseSchema } from "../../api/billing/common/billingResponse";
export const ConfirmCheckoutResponseSchema = BillingResponseSchema.extend({
success: z.boolean(),
checkout_id: z.string(),
product_id: z.string(),
invoice_id: z.string().nullable(),
success_url: z.string().url(),
});
export type ConfirmCheckoutResponse = z.infer<
typeof ConfirmCheckoutResponseSchema
>;

View File

@@ -0,0 +1,8 @@
import { z } from "zod/v4";
import { CheckoutResponseBaseSchema } from "./checkoutResponseCommon";
export const GetCheckoutResponseSchema = z.object({
...CheckoutResponseBaseSchema.shape,
});
export type GetCheckoutResponse = z.infer<typeof GetCheckoutResponseSchema>;

View File

@@ -1,2 +1,5 @@
export * from "./checkoutResponses";
export * from "./checkoutResponseCommon";
export * from "./confirmCheckoutParams";
export * from "./confirmCheckoutResponse";
export * from "./getCheckoutResponse";
export * from "./previewCheckoutResponse";

View File

@@ -0,0 +1,10 @@
import { z } from "zod/v4";
import { CheckoutResponseBaseSchema } from "./checkoutResponseCommon";
export const PreviewCheckoutResponseSchema = z.object({
...CheckoutResponseBaseSchema.shape,
});
export type PreviewCheckoutResponse = z.infer<
typeof PreviewCheckoutResponseSchema
>;

View File

@@ -3,7 +3,8 @@ import { z } from "zod/v4";
import {
ConfirmCheckoutResponseSchema,
GetCheckoutResponseSchema,
} from "../checkout/checkoutResponses";
PreviewCheckoutResponseSchema,
} from "../checkout";
import { ConfirmCheckoutParamsSchema } from "../checkout/confirmCheckoutParams";
export const getCheckoutContract = oc
@@ -26,7 +27,7 @@ export const previewCheckoutContract = oc
.object({ checkout_id: z.string() })
.extend(ConfirmCheckoutParamsSchema.shape),
)
.output(GetCheckoutResponseSchema);
.output(PreviewCheckoutResponseSchema);
export const confirmCheckoutContract = oc
.route({

View File

@@ -1,7 +1,12 @@
import { CheckoutAction } from "../../models/checkouts/checkoutTable";
const DEFAULT_CHECKOUT_BASE_URL =
process.env.NODE_ENV === "production"
? "https://checkout.useautumn.com"
: "http://localhost:3001";
export const checkoutToUrl = ({
checkoutBaseUrl = "http://localhost:3001",
checkoutBaseUrl = DEFAULT_CHECKOUT_BASE_URL,
action,
checkoutId,
}: {

View File

@@ -3,6 +3,7 @@ import {
FreeTrialDuration,
type PlanTiming,
type ProductItem,
RedirectModeSchema,
} from "@autumn/shared";
import { z } from "zod/v4";
import type { FormDiscount } from "./utils/discountUtils";
@@ -18,6 +19,7 @@ export const AttachFormSchema = z.object({
trialCardRequired: z.boolean(),
planSchedule: z.custom<PlanTiming>().nullable(),
billingBehavior: z.custom<BillingBehavior>().nullable(),
redirectMode: RedirectModeSchema,
newBillingSubscription: z.boolean(),
discounts: z.custom<FormDiscount[]>(),
});

View File

@@ -26,8 +26,9 @@ import { addDiscount } from "../utils/discountUtils";
import { AttachDiscountRow } from "./AttachDiscountRow";
export function AttachAdvancedSection() {
const { form, formValues } = useAttachFormContext();
const { discounts, newBillingSubscription } = formValues;
const { form, formValues, previewQuery } = useAttachFormContext();
const { discounts, newBillingSubscription, redirectMode } = formValues;
const checkoutType = previewQuery.data?.checkout_type;
const {
hasActiveSubscription,
@@ -54,7 +55,10 @@ export function AttachAdvancedSection() {
const hasCustomSettings =
(hasActiveSubscription && (hasCustomSchedule || hasCustomBilling)) ||
newBillingSubscription ||
redirectMode === "always" ||
hasDiscounts;
const showRedirectModeRow =
checkoutType === "autumn_checkout" || checkoutType === null;
const handleAddDiscount = () => {
form.setFieldValue("discounts", addDiscount(discounts));
@@ -88,6 +92,10 @@ export function AttachAdvancedSection() {
parts.push(`${validCount} discount${validCount > 1 ? "s" : ""}`);
}
if (redirectMode === "always") {
parts.push("Checkout redirect: Always");
}
return parts.join(" \u2022 ");
};
@@ -265,6 +273,46 @@ export function AttachAdvancedSection() {
)}
</div>
</motion.div>
{showRedirectModeRow && (
<motion.div variants={ACCORDION_ITEM}>
<div className="rounded-xl input-base px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-t2">Checkout Redirect</span>
<div className="flex shrink-0">
<IconCheckbox
variant="secondary"
size="sm"
checked={redirectMode === "if_required"}
onCheckedChange={() =>
form.setFieldValue("redirectMode", "if_required")
}
className={cn(
"min-w-[76px] px-2 text-xs rounded-r-none",
redirectMode !== "if_required" && "border-r-0",
)}
>
Auto
</IconCheckbox>
<IconCheckbox
variant="secondary"
size="sm"
checked={redirectMode === "always"}
onCheckedChange={() =>
form.setFieldValue("redirectMode", "always")
}
className={cn(
"min-w-[76px] px-2 text-xs rounded-l-none",
redirectMode !== "always" && "border-l-0",
)}
>
Always
</IconCheckbox>
</div>
</div>
</div>
</motion.div>
)}
</AdvancedSection>
);
}

View File

@@ -40,6 +40,12 @@ export function AttachFooter() {
const isLoading = previewQuery.isLoading;
const hasError = !!previewQuery.error;
const previewData = previewQuery.data;
const confirmLabel =
formValues.redirectMode === "always"
? previewData?.checkout_type === "stripe_checkout"
? "Redirect to Stripe Checkout"
: "Redirect to Autumn Checkout"
: "Attach Product";
const isReady =
hasProductSelected && !isLoading && !hasError && !!previewData;
const showSkeleton = hasProductSelected && isLoading;
@@ -141,7 +147,10 @@ export function AttachFooter() {
</span>
</TooltipTrigger>
{invoiceDisabledReason && (
<TooltipContent side="top" className="max-w-(--radix-tooltip-trigger-width)">
<TooltipContent
side="top"
className="max-w-(--radix-tooltip-trigger-width)"
>
{invoiceDisabledReason}
</TooltipContent>
)}
@@ -152,7 +161,7 @@ export function AttachFooter() {
onClick={handleConfirm}
isLoading={isPending}
>
Attach Product
{confirmLabel}
</Button>
</motion.div>
)}

View File

@@ -128,6 +128,7 @@ export function AttachFormProvider({
trialCardRequired,
planSchedule,
billingBehavior,
redirectMode,
newBillingSubscription,
discounts,
} = formValues;
@@ -223,6 +224,7 @@ export function AttachFormProvider({
trialCardRequired,
planSchedule,
billingBehavior,
redirectMode,
newBillingSubscription,
discounts,
});

View File

@@ -21,6 +21,7 @@ export function useAttachForm({
trialCardRequired: true,
planSchedule: null,
billingBehavior: null,
redirectMode: "if_required",
newBillingSubscription: false,
discounts: [],
} as AttachForm,

View File

@@ -4,6 +4,11 @@ import type { AxiosError } from "axios";
import { useEffect, useMemo, useState } from "react";
import { useAxiosInstance } from "@/services/useAxiosInstance";
const ATTACH_PREVIEW_EXPAND = [
"incoming.plan.items.feature",
"outgoing.plan.items.feature",
] as const;
interface UseAttachPreviewParams {
requestBody: AttachParamsV0 | null;
enabled?: boolean;
@@ -42,7 +47,10 @@ export function useAttachPreview({
const response = await axiosInstance.post<AttachPreviewResponse>(
"/v1/billing.preview_attach",
requestBody,
{
...requestBody,
expand: ATTACH_PREVIEW_EXPAND,
},
);
return response.data;

View File

@@ -7,6 +7,7 @@ import type {
ProductItem,
ProductItemInterval,
ProductV2,
RedirectMode,
} from "@autumn/shared";
import { useMemo } from "react";
import { getFreeTrial } from "@/components/forms/update-subscription-v2/utils/getFreeTrial";
@@ -30,6 +31,7 @@ export interface BuildAttachRequestBodyParams {
trialCardRequired: boolean;
planSchedule: PlanTiming | null;
billingBehavior: BillingBehavior | null;
redirectMode: RedirectMode;
newBillingSubscription: boolean;
discounts: FormDiscount[];
}
@@ -48,6 +50,7 @@ export function buildAttachRequestBody({
trialCardRequired,
planSchedule,
billingBehavior,
redirectMode,
newBillingSubscription,
discounts,
}: BuildAttachRequestBodyParams): AttachParamsV0 | null {
@@ -63,7 +66,7 @@ export function buildAttachRequestBody({
const body: AttachParamsV0Input = {
customer_id: customerId,
product_id: product.id,
redirect_mode: "if_required",
redirect_mode: redirectMode,
};
if (entityId) {
@@ -135,6 +138,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) {
trialCardRequired,
planSchedule,
billingBehavior,
redirectMode,
newBillingSubscription,
discounts,
} = params;
@@ -154,6 +158,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) {
trialCardRequired,
planSchedule,
billingBehavior,
redirectMode,
newBillingSubscription,
discounts,
}),
@@ -170,6 +175,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) {
trialCardRequired,
planSchedule,
billingBehavior,
redirectMode,
newBillingSubscription,
discounts,
],

View File

@@ -8,6 +8,11 @@ import { useEffect, useState } from "react";
import { useAxiosInstance } from "@/services/useAxiosInstance";
const UPDATE_PREVIEW_EXPAND = [
"incoming.plan.items.feature",
"outgoing.plan.items.feature",
] as const;
/** Debounced preview query for update subscription. Accepts a pre-built request body. */
export function useUpdateSubscriptionPreview({
body,
@@ -47,7 +52,10 @@ export function useUpdateSubscriptionPreview({
const response =
await axiosInstance.post<PreviewUpdateSubscriptionResponse>(
"/v1/billing.preview_update",
body,
{
...body,
expand: UPDATE_PREVIEW_EXPAND,
},
);
return response.data;