feat: smart default scope for attach and create-schedule sheets

Adds useSheetScopeEntityId hook used by both AttachProductSheet and
CreateScheduleSheet, which seeds scope from ?entity_id= and falls back
to the customer's active entity-level plan when no customer-level plan
exists. AttachFormProvider now exposes entityId and onScopeChange on
context, so AttachProductSelection and the scope dropdown pull from
the form context instead of the page-level useEntity() store.

Made-with: Cursor
This commit is contained in:
Ayush Rodrigues
2026-04-24 17:01:54 +01:00
parent 9c0c0658c6
commit d4907e63f2
14 changed files with 259 additions and 26 deletions

View File

@@ -7,9 +7,13 @@ import {
type MultiAttachParamsV0,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupAnchorResetRefund } from "@/internal/billing/v2/setup/setupAnchorResetRefund";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
import { setupImmediateMultiProductBillingContext } from "../../common/immediateMultiProduct/setupImmediateMultiProductBillingContext";
import { normalizeCreateSchedulePhases } from "../errors/normalizeCreateSchedulePhases";
import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedulePhasePlans";
import { billingContextToRecurringAndScheduled } from "../utils/billingContextToRecurringAndScheduled";
import { setupScheduledProductsContext } from "./setupScheduledProductsContext";
type CreateScheduleCheckoutModeContext = Pick<
@@ -115,7 +119,7 @@ export const setupCreateScheduleBillingContext = async ({
),
);
return {
const scheduleBillingContext: CreateScheduleBillingContext = {
...billingContext,
checkoutMode: setupCreateScheduleCheckoutMode({
billingContext,
@@ -133,8 +137,54 @@ export const setupCreateScheduleBillingContext = async ({
billingContext.isCustom ||
scheduledCustomPrices.length > 0 ||
scheduledCustomEntitlements.length > 0,
requestedProrationBehavior: params.billing_behavior,
requestedBillingCycleAnchor: params.billing_cycle_anchor,
immediatePhase,
futurePhases,
scheduledPhaseContexts,
};
const { recurringActive } = billingContextToRecurringAndScheduled({
billingContext: scheduleBillingContext,
});
// setupImmediateMultiProductBillingContext does not forward
// `billing_cycle_anchor`, so billingCycleAnchorMs still reflects the existing
// Stripe anchor. When the caller asks to reset the cycle we must recompute
// the anchor (and the reset-cycle anchor) so downstream proration math runs
// against the new `[now, now + interval]` period. Mirrors the attach /
// updateSubscription setups.
if (params.billing_cycle_anchor !== undefined) {
const firstProduct = billingContext.fullProducts[0];
if (firstProduct) {
let recomputedAnchor = setupBillingCycleAnchor({
stripeSubscription: billingContext.stripeSubscription,
customerProduct: recurringActive[0],
newFullProduct: firstProduct,
trialContext: billingContext.trialContext,
currentEpochMs: billingContext.currentEpochMs,
requestedBillingCycleAnchor: params.billing_cycle_anchor,
});
if (billingContext.trialContext?.trialEndsAt) {
recomputedAnchor = billingContext.trialContext.trialEndsAt;
}
scheduleBillingContext.billingCycleAnchorMs = recomputedAnchor;
scheduleBillingContext.resetCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs: recomputedAnchor,
customerProduct: undefined,
newFullProduct: firstProduct,
});
}
}
// Keep forward-looking charges (e.g. prepaid renewals) when the caller asks
// to reset the cycle with proration off; without this, finalizeLineItems
// drops every line item and total due now collapses to 0.
scheduleBillingContext.anchorResetRefund = setupAnchorResetRefund({
billingCycleAnchor: params.billing_cycle_anchor,
prorationBehavior: params.billing_behavior,
outgoingCustomerProduct: recurringActive[0],
});
return scheduleBillingContext;
};

View File

@@ -4,6 +4,8 @@ import { RedirectModeSchema } from "@api/billing/common/redirectMode";
import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice";
import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1";
import { z } from "zod/v4";
import { BillingBehaviorSchema } from "../common/billingBehavior";
import { BillingCycleAnchorSchema } from "../common/billingCycleAnchor";
const CreateScheduleCustomizePlanSchema = z
.object({
@@ -81,6 +83,14 @@ export const CreateScheduleParamsV0Schema = z
description:
"Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects.",
}),
billing_behavior: BillingBehaviorSchema.optional().meta({
description:
"Whether to prorate the immediate phase. 'none' skips proration charges and credits.",
}),
billing_cycle_anchor: BillingCycleAnchorSchema.optional().meta({
description:
"Pass 'now' to reset the billing cycle anchor of the immediate phase to the current time.",
}),
phases: z
.tuple([CreateSchedulePhaseSchema])
.rest(CreateSchedulePhaseSchema)

View File

@@ -3,17 +3,15 @@ import {
isProductCurrentlyAttached,
} from "@autumn/shared";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { useAttachFormContext } from "../context/AttachFormProvider";
export function AttachProductSelection() {
const { form, hasCustomizations } = useAttachFormContext();
const { form, hasCustomizations, entityId } = useAttachFormContext();
const { products } = useProductsQuery();
const availableProducts = products.filter((p) => !p.archived);
const { customer } = useCusQuery();
const { entityId } = useEntity();
const productId = form.state.values.productId;

View File

@@ -59,6 +59,9 @@ interface AttachFormContextValue {
formValues: AttachForm;
features: Feature[];
entityId: string | undefined;
onScopeChange?: (entityId: string | undefined) => void;
product: ProductV2 | undefined;
prepaidItems: PrepaidItemWithFeature[];
originalItems: ProductItem[] | undefined;
@@ -103,6 +106,7 @@ interface AttachFormProviderProps {
onPlanEditorClose?: () => void;
onCheckoutRedirect?: (checkoutUrl: string) => void;
onSuccess?: () => void;
onScopeChange?: (entityId: string | undefined) => void;
initialSchedulePlan?: SchedulePlan | null;
disablePreview?: boolean;
children: ReactNode;
@@ -135,6 +139,7 @@ export function AttachFormProvider({
onPlanEditorClose,
onCheckoutRedirect,
onSuccess,
onScopeChange,
initialSchedulePlan,
disablePreview,
children,
@@ -472,6 +477,8 @@ export function AttachFormProvider({
form,
formValues,
features,
entityId,
onScopeChange,
product: effectiveProduct,
prepaidItems,
originalItems,
@@ -496,6 +503,8 @@ export function AttachFormProvider({
form,
formValues,
features,
entityId,
onScopeChange,
effectiveProduct,
prepaidItems,
originalItems,

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import {
AdvancedSection,
ConfigRow,
} from "@/components/forms/shared/advanced-section";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { useCreateScheduleFormContext } from "../context/CreateScheduleFormProvider";
export function CreateScheduleAdvancedSection() {
const { form, formValues } = useCreateScheduleFormContext();
const { billingBehavior, resetBillingCycle, phases } = formValues;
const isProrate = billingBehavior !== "none";
const hasMultipleImmediatePlans = (phases[0]?.plans.length ?? 0) > 1;
const disabledReason = hasMultipleImmediatePlans
? "Not yet supported for multi attach"
: null;
const renderToggle = ({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}): ReactNode => (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Switch
checked={checked}
disabled={!!disabledReason}
onCheckedChange={onCheckedChange}
/>
</span>
</TooltipTrigger>
{disabledReason && <TooltipContent>{disabledReason}</TooltipContent>}
</Tooltip>
);
return (
<AdvancedSection>
<ConfigRow
title="Prorate Changes"
description="Prorate price differences when changing plans mid-cycle"
action={renderToggle({
checked: isProrate,
onCheckedChange: (checked) =>
form.setFieldValue("billingBehavior", checked ? null : "none"),
})}
/>
<ConfigRow
title="Reset Billing Cycle"
description="Restart the billing cycle from today"
action={renderToggle({
checked: resetBillingCycle,
onCheckedChange: (checked) =>
form.setFieldValue("resetBillingCycle", !!checked),
})}
/>
</AdvancedSection>
);
}

View File

@@ -18,6 +18,7 @@ import { cn } from "@/lib/utils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { useCreateScheduleFormContext } from "../context/CreateScheduleFormProvider";
import { useHasSchedule } from "../hooks/useHasSchedule";
import { CreateScheduleAdvancedSection } from "./CreateScheduleAdvancedSection";
import { SchedulePhaseCard } from "./SchedulePhaseCard";
import { SchedulePreview } from "./SchedulePreview";
@@ -182,6 +183,7 @@ export function CreateScheduleReviewContent() {
/>
<div className="flex-1 overflow-y-auto">
<CreateScheduleAdvancedSection />
<SchedulePreview />
</div>

View File

@@ -140,6 +140,16 @@ export function CreateScheduleFormProvider({
[form.store],
);
const getBillingBehavior = useCallback(
() => form.store.state.values.billingBehavior ?? null,
[form.store],
);
const getResetBillingCycle = useCallback(
() => form.store.state.values.resetBillingCycle ?? false,
[form.store],
);
const buildRequestBody = useBuildCreateScheduleRequestBody({
customerId,
entityId,
@@ -147,6 +157,8 @@ export function CreateScheduleFormProvider({
features,
nowMs,
getPhases,
getBillingBehavior,
getResetBillingCycle,
});
const previewRequestBody = useCreateScheduleRequestBody({
@@ -156,6 +168,8 @@ export function CreateScheduleFormProvider({
products,
features,
nowMs,
billingBehavior: formValues.billingBehavior,
resetBillingCycle: formValues.resetBillingCycle,
});
const phaseTimingError = useMemo(

View File

@@ -1,4 +1,4 @@
import type { ProductItem } from "@autumn/shared";
import { BillingBehaviorSchema, type ProductItem } from "@autumn/shared";
import { z } from "zod/v4";
export const SchedulePlanSchema = z.object({
@@ -169,6 +169,8 @@ export function getPhaseTimingError({
export const CreateScheduleFormSchema = z
.object({
phases: z.array(SchedulePhaseSchema).min(1),
billingBehavior: BillingBehaviorSchema.nullable(),
resetBillingCycle: z.boolean(),
})
.refine(
(data) =>

View File

@@ -13,6 +13,8 @@ export function useCreateScheduleForm({
} = {}) {
const defaultValues: CreateScheduleForm = initialValues ?? {
phases: [{ startsAt: null, plans: [{ ...EMPTY_SCHEDULE_PLAN }] }],
billingBehavior: null,
resetBillingCycle: false,
};
const initialValuesRef = useRef<CreateScheduleForm>(defaultValues);

View File

@@ -1,5 +1,6 @@
import type {
ApiPlanItemV1,
BillingBehavior,
CreateScheduleParamsV0,
Feature,
ProductItem,
@@ -110,6 +111,8 @@ export function buildCreateScheduleRequestBody({
products,
features,
nowMs,
billingBehavior,
resetBillingCycle,
}: {
customerId: string | undefined;
entityId: string | undefined;
@@ -117,6 +120,8 @@ export function buildCreateScheduleRequestBody({
products: ProductV2[];
features: Feature[];
nowMs?: number;
billingBehavior?: BillingBehavior | null;
resetBillingCycle?: boolean;
}): CreateScheduleParamsV0 | null {
const now = nowMs ?? Date.now();
if (!customerId || phases.length === 0) return null;
@@ -164,6 +169,17 @@ export function buildCreateScheduleRequestBody({
phases: validPhases,
};
if (entityId) body.entity_id = entityId;
// `billing_behavior` / `billing_cycle_anchor` aren't supported when the
// immediate phase is a multi-attach. The review UI disables the toggles in
// that case; mirror the same guard here so stale values don't leak into the
// request if the user flips from single-plan to multi-plan after toggling.
const immediatePlanCount = validPhases[0]?.plans.length ?? 0;
const supportsBillingFlags = immediatePlanCount === 1;
if (supportsBillingFlags) {
if (billingBehavior) body.billing_behavior = billingBehavior;
if (resetBillingCycle) body.billing_cycle_anchor = "now";
}
return body as CreateScheduleParamsV0;
}
@@ -174,6 +190,8 @@ export function useCreateScheduleRequestBody({
products,
features,
nowMs,
billingBehavior,
resetBillingCycle,
}: {
customerId: string | undefined;
entityId: string | undefined;
@@ -181,6 +199,8 @@ export function useCreateScheduleRequestBody({
products: ProductV2[];
features: Feature[];
nowMs?: number;
billingBehavior?: BillingBehavior | null;
resetBillingCycle?: boolean;
}) {
return useMemo(
() =>
@@ -191,8 +211,19 @@ export function useCreateScheduleRequestBody({
products,
features,
nowMs,
billingBehavior,
resetBillingCycle,
}),
[customerId, entityId, phases, products, features, nowMs],
[
customerId,
entityId,
phases,
products,
features,
nowMs,
billingBehavior,
resetBillingCycle,
],
);
}
@@ -203,6 +234,8 @@ export function useBuildCreateScheduleRequestBody({
features,
nowMs,
getPhases,
getBillingBehavior,
getResetBillingCycle,
}: {
customerId: string | undefined;
entityId: string | undefined;
@@ -210,6 +243,8 @@ export function useBuildCreateScheduleRequestBody({
features: Feature[];
nowMs?: number;
getPhases: () => SchedulePhase[];
getBillingBehavior?: () => BillingBehavior | null;
getResetBillingCycle?: () => boolean;
}) {
return useMemo(
() =>
@@ -229,6 +264,8 @@ export function useBuildCreateScheduleRequestBody({
products,
features,
nowMs,
billingBehavior: getBillingBehavior?.() ?? null,
resetBillingCycle: getResetBillingCycle?.() ?? false,
});
if (!requestBody) return null;
@@ -246,6 +283,15 @@ export function useBuildCreateScheduleRequestBody({
return requestBody;
},
[customerId, entityId, products, features, nowMs, getPhases],
[
customerId,
entityId,
products,
features,
nowMs,
getPhases,
getBillingBehavior,
getResetBillingCycle,
],
);
}

View File

@@ -227,16 +227,12 @@ export function useHasItemChanges() {
item1: itemDraft.session.draftItem,
item2: itemDraft.session.initialItem,
features,
logDifferences: true,
});
return !same;
}
if (!item || !initialItem) {
console.log(
"[useHasItemChanges] no item or initialItem, returning false",
);
return false;
}
@@ -244,7 +240,6 @@ export function useHasItemChanges() {
item1: item,
item2: initialItem,
features,
logDifferences: true,
});
return !same;

View File

@@ -0,0 +1,34 @@
import { CusProductStatus, type FullCustomer } from "@autumn/shared";
import { useMemo, useState } from "react";
// If the customer has no active customer-level plan but has at least one
// active entity-level plan, default scope to that entity. Otherwise stay
// customer-scoped.
function pickDefaultScopeEntityId({
customer,
}: {
customer: FullCustomer | undefined;
}): string | undefined {
const activePlans =
customer?.customer_products?.filter(
(cp) => cp.status === CusProductStatus.Active && !cp.canceled_at,
) ?? [];
if (activePlans.some((cp) => !cp.entity_id)) return undefined;
return activePlans.find((cp) => !!cp.entity_id)?.entity_id ?? undefined;
}
// Sheet-local scope state for Attach / Create Schedule flows. Seeds from the
// `entity_id` URL param if present, otherwise applies the smart default based
// on the customer's existing plans. Changes stay local to the sheet.
export function useSheetScopeEntityId(customer: FullCustomer | undefined) {
const initialFromUrl = useMemo(
() =>
new URLSearchParams(window.location.search).get("entity_id") ?? undefined,
[],
);
return useState<string | undefined>(
initialFromUrl ?? pickDefaultScopeEntityId({ customer }),
);
}

View File

@@ -35,7 +35,7 @@ import {
} from "@/components/v2/sheets/SharedSheetComponents";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useSheetScopeEntityId } from "@/hooks/useSheetScopeEntityId";
import { useEnv } from "@/utils/envUtils";
import { getBackendErr } from "@/utils/genUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
@@ -205,12 +205,11 @@ function PlanDiffSkeleton() {
}
function SelectContent() {
const { formValues } = useAttachFormContext();
const { formValues, entityId, onScopeChange } = useAttachFormContext();
const { closeSheet, setSheet } = useSheetStore();
const itemId = useSheetStore((s) => s.itemId);
const hasProductSelected = !!formValues.productId;
const { entityId, setEntityId } = useEntity();
const { customer } = useCusQuery();
const fullCustomer = customer as FullCustomer | null;
const entities = fullCustomer?.entities || [];
@@ -247,7 +246,9 @@ function SelectContent() {
<SearchableSelect<EntityOption>
value={entityId ?? CUSTOMER_LEVEL_VALUE}
onValueChange={(value) =>
setEntityId(value === CUSTOMER_LEVEL_VALUE ? null : value)
onScopeChange?.(
value === CUSTOMER_LEVEL_VALUE ? undefined : value,
)
}
options={entityOptions}
getOptionValue={getEntityOptionValue}
@@ -460,12 +461,14 @@ export function AttachProductSheet() {
const { closeSheet } = useSheetStore();
const { customer } = useCusQuery();
const { setIsInlineEditorOpen } = useCustomerContext();
const { entityId } = useEntity();
const [scopeEntityId, setScopeEntityId] = useSheetScopeEntityId(
customer as FullCustomer | undefined,
);
return (
<AttachFormProvider
customerId={customer?.id ?? customer?.internal_id ?? ""}
entityId={entityId ?? undefined}
entityId={scopeEntityId}
initialProductId={itemId ?? undefined}
onPlanEditorOpen={() => setIsInlineEditorOpen(true)}
onPlanEditorClose={() => setIsInlineEditorOpen(false)}
@@ -474,6 +477,7 @@ export function AttachProductSheet() {
toast.success("Checkout URL copied to clipboard");
}}
onSuccess={closeSheet}
onScopeChange={setScopeEntityId}
>
<SheetContent />
</AttachFormProvider>

View File

@@ -6,7 +6,7 @@ import type {
} from "@autumn/shared";
import { CusProductStatus, mapToProductItems } from "@autumn/shared";
import { motion } from "motion/react";
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef } from "react";
import { toast } from "sonner";
import {
AttachFormProvider,
@@ -43,6 +43,7 @@ import {
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useSheetScopeEntityId } from "@/hooks/useSheetScopeEntityId";
import { backendToDisplayQuantity } from "@/utils/billing/prepaidQuantityUtils";
import { useEnv } from "@/utils/envUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
@@ -142,6 +143,8 @@ export function buildInitialValues({
: { ...EMPTY_SCHEDULE_PLAN };
}),
})),
billingBehavior: null,
resetBillingCycle: false,
};
}
@@ -164,6 +167,8 @@ export function buildInitialValues({
activePlans.length > 0 ? activePlans : [{ ...EMPTY_SCHEDULE_PLAN }],
},
],
billingBehavior: null,
resetBillingCycle: false,
};
}
@@ -360,15 +365,10 @@ export function getScheduleForScope({
export function CreateScheduleSheet() {
const { closeSheet } = useSheetStore();
const { customer, testClockFrozenTimeMs } = useCusQuery({ schedule: true });
const initialEntityId =
new URLSearchParams(window.location.search).get("entity_id") ?? undefined;
const [scopeEntityId, setScopeEntityId] = useState<string | undefined>(
initialEntityId,
);
const fullCustomer = customer as FullCustomer | undefined;
const [scopeEntityId, setScopeEntityId] = useSheetScopeEntityId(fullCustomer);
const { products } = useProductsQuery();
const fullCustomer = customer as FullCustomer | undefined;
const schedule = getScheduleForScope({
customer: fullCustomer,
entityId: scopeEntityId,