cleaned up custom checkout
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}.`;
|
||||
}
|
||||
|
||||
16
apps/checkout/src/utils/getCheckoutPreviewIntent.ts
Normal file
16
apps/checkout/src/utils/getCheckoutPreviewIntent.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -76,6 +76,10 @@ export async function attach({
|
||||
params,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[attach.ts] BILLING CONTEXT CHECKOUT MODE:",
|
||||
billingContext.checkoutMode,
|
||||
);
|
||||
if (preview) {
|
||||
return {
|
||||
billingContext,
|
||||
|
||||
@@ -78,6 +78,7 @@ export const renew = async ({
|
||||
|
||||
// feature_quantities: attachParams.optionsList,
|
||||
cancel_action: "uncancel",
|
||||
redirect_mode: "if_required",
|
||||
};
|
||||
|
||||
return await billingActions.updateSubscription({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,7 +37,6 @@ export const handlePreviewUpdateSubscription = createRoute({
|
||||
ctx,
|
||||
billingContext,
|
||||
billingPlan,
|
||||
params: body,
|
||||
});
|
||||
|
||||
return c.json(previewResponse, 200);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -130,6 +130,7 @@ export const billingPlanToNextCyclePreview = ({
|
||||
billingPlanToNextCycleLineItems({
|
||||
ctx,
|
||||
customerProducts: filteredCustomerProducts,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
billingContext,
|
||||
nextCycleStart,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -101,7 +101,6 @@ export async function previewCheckoutAction({
|
||||
}),
|
||||
billingContext: updateSubscriptionResult.billingContext,
|
||||
billingPlan,
|
||||
params: params as UpdateSubscriptionV1Params,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 }],
|
||||
});
|
||||
});
|
||||
129
server/tests/integration/billing/utils/expectPreviewChanges.ts
Normal file
129
server/tests/integration/billing/utils/expectPreviewChanges.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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,
|
||||
|
||||
14
shared/internal/checkout/confirmCheckoutResponse.ts
Normal file
14
shared/internal/checkout/confirmCheckoutResponse.ts
Normal 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
|
||||
>;
|
||||
8
shared/internal/checkout/getCheckoutResponse.ts
Normal file
8
shared/internal/checkout/getCheckoutResponse.ts
Normal 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>;
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from "./checkoutResponses";
|
||||
export * from "./checkoutResponseCommon";
|
||||
export * from "./confirmCheckoutParams";
|
||||
export * from "./confirmCheckoutResponse";
|
||||
export * from "./getCheckoutResponse";
|
||||
export * from "./previewCheckoutResponse";
|
||||
|
||||
10
shared/internal/checkout/previewCheckoutResponse.ts
Normal file
10
shared/internal/checkout/previewCheckoutResponse.ts
Normal 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
|
||||
>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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[]>(),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ export function useAttachForm({
|
||||
trialCardRequired: true,
|
||||
planSchedule: null,
|
||||
billingBehavior: null,
|
||||
redirectMode: "if_required",
|
||||
newBillingSubscription: false,
|
||||
discounts: [],
|
||||
} as AttachForm,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user