Merge pull request #1463 from useautumn/cursor/add-subscription-discount-support

Add discount support to subscription updates and display coupons in detail sheet
This commit is contained in:
Ayush
2026-05-05 19:09:36 +01:00
committed by GitHub
12 changed files with 358 additions and 150 deletions

View File

@@ -1,8 +1,14 @@
import { CusProductStatus, CustomerExpand, Scopes } from "@autumn/shared";
import {
CusProductStatus,
CustomerExpand,
type FullCusProduct,
Scopes,
} from "@autumn/shared";
import { getTestClockFrozenTimeMs } from "@/external/stripe/testClocks/utils/convertStripeTestClock";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { CusService } from "@/internal/customers/CusService";
import { getCusAutoTopupPurchaseLimits } from "@/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits";
import { getCusRewards } from "@/internal/customers/cusUtils/cusResponseUtils/getCusRewards";
/**
* Internal route for get full customer object.
@@ -10,6 +16,9 @@ import { getCusAutoTopupPurchaseLimits } from "@/internal/customers/cusUtils/cus
* Note: schedules are NOT hydrated here. Dashboard consumers that need the
* customer's persisted schedule must fetch it separately via
* `GET /customers/:customer_id/schedule`.
*
* Supports optional `?expand=rewards` query param to lazily fetch
* per-subscription discount data from Stripe.
*/
export const handleGetCustomer = createRoute({
scopes: [Scopes.Customers.Read],
@@ -17,11 +26,17 @@ export const handleGetCustomer = createRoute({
const ctx = c.get("ctx");
const { customer_id } = c.req.param();
const expandParam = c.req.query("expand");
const extraExpands = expandParam
? (expandParam.split(",").filter(Boolean) as CustomerExpand[])
: [];
const expand = [CustomerExpand.Invoices, ...extraExpands];
const fullCus = await CusService.getFull({
ctx,
idOrInternalId: customer_id,
withEntities: true,
expand: [CustomerExpand.Invoices],
expand,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.PastDue,
@@ -30,23 +45,34 @@ export const handleGetCustomer = createRoute({
],
});
const [testClockFrozenTimeMs, autoTopupsWithLimits] = await Promise.all([
getTestClockFrozenTimeMs({
ctx,
stripeCustomerId: fullCus.processor?.id,
}),
getCusAutoTopupPurchaseLimits({
ctx,
internalCustomerId: fullCus.internal_id,
autoTopupsConfig: fullCus.auto_topups,
expand: [CustomerExpand.AutoTopupsPurchaseLimit],
}),
]);
const [testClockFrozenTimeMs, autoTopupsWithLimits, rewards] =
await Promise.all([
getTestClockFrozenTimeMs({
ctx,
stripeCustomerId: fullCus.processor?.id,
}),
getCusAutoTopupPurchaseLimits({
ctx,
internalCustomerId: fullCus.internal_id,
autoTopupsConfig: fullCus.auto_topups,
expand: [CustomerExpand.AutoTopupsPurchaseLimit],
}),
getCusRewards({
org: ctx.org,
env: ctx.env,
fullCus,
subIds: fullCus.customer_products.flatMap(
(cp: FullCusProduct) => cp.subscription_ids || [],
),
expand,
}),
]);
return c.json({
customer: {
...fullCus,
auto_topups: autoTopupsWithLimits ?? fullCus.auto_topups,
rewards: rewards ?? undefined,
},
test_clock_frozen_time_ms: testClockFrozenTimeMs,
});

View File

@@ -1,110 +1,25 @@
import { XIcon } from "@phosphor-icons/react";
import { CheckIcon } from "lucide-react";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { SearchableSelect } from "@/components/v2/selects/SearchableSelect";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useStripeCouponsQuery } from "@/hooks/queries/useStripeCouponsQuery";
import { DiscountRow } from "@/components/forms/shared/discount-row/DiscountRow";
import { useAttachFormContext } from "../context/AttachFormProvider";
import { buildDiscountOptions } from "../utils/discountOptionUtils";
import { removeDiscount, updateDiscount } from "../utils/discountUtils";
interface AttachDiscountRowProps {
index: number;
}
export function AttachDiscountRow({ index }: AttachDiscountRowProps) {
export function AttachDiscountRow({ index }: { index: number }) {
const { form, formValues, product } = useAttachFormContext();
const { rewards, rewardPrograms } = useRewardsQuery();
const { stripeCoupons } = useStripeCouponsQuery();
const discounts = formValues.discounts;
const discount = discounts[index];
if (!discount) return null;
const allOptions = buildDiscountOptions({
rewards,
rewardPrograms,
stripeCoupons,
productId: product?.id,
});
// Get reward IDs already selected in other rows
const selectedRewardIds = discounts
.filter((d, i) => i !== index && "reward_id" in d)
.map((d) => ("reward_id" in d ? d.reward_id : ""))
.filter(Boolean);
// Filter out already-selected options
const availableOptions = allOptions.filter(
(o) => !selectedRewardIds.includes(o.id),
);
const handleRewardChange = (rewardId: string) => {
form.setFieldValue(
"discounts",
updateDiscount(discounts, index, { reward_id: rewardId }),
);
};
const handleRemove = () => {
form.setFieldValue("discounts", removeDiscount(discounts, index));
};
const currentRewardId =
"reward_id" in discount ? (discount.reward_id ?? "") : "";
return (
<div className="flex items-center gap-2 h-8">
{/* Reward select */}
<div className="flex-1 min-w-0">
<SearchableSelect
value={currentRewardId}
onValueChange={handleRewardChange}
options={availableOptions}
getOptionValue={(o) => o.id}
getOptionLabel={(o) => o.label}
placeholder="Select discount..."
searchable
searchPlaceholder="Search discounts..."
emptyText="No discounts found"
triggerClassName="h-7 px-2 text-xs border-0 shadow-none bg-transparent hover:bg-muted/50"
renderOption={(option, isSelected) => (
<>
<span className="flex-1 truncate min-w-0">{option.label}</span>
{option.sublabel && (
<span className="text-t3 text-xs shrink-0">
{option.sublabel}
</span>
)}
{isSelected && <CheckIcon className="size-4 shrink-0" />}
</>
)}
renderValue={(option) => {
if (!option)
return <span className="text-t3">Select discount...</span>;
return (
<span className="flex items-center gap-2">
<span className="truncate">{option.label}</span>
{option.sublabel && (
<span className="text-t3 text-xs shrink-0">
{option.sublabel}
</span>
)}
</span>
);
}}
/>
</div>
{/* Remove button */}
<IconButton
variant="muted"
size="sm"
onClick={handleRemove}
icon={<XIcon size={12} />}
className="shrink-0 text-t3 hover:text-red-500"
/>
</div>
<DiscountRow
discounts={discounts}
index={index}
productId={product?.id}
onUpdate={({ rewardId }) => {
form.setFieldValue(
"discounts",
updateDiscount(discounts, index, { reward_id: rewardId }),
);
}}
onRemove={() => {
form.setFieldValue("discounts", removeDiscount(discounts, index));
}}
/>
);
}

View File

@@ -0,0 +1,102 @@
import { XIcon } from "@phosphor-icons/react";
import { CheckIcon } from "lucide-react";
import {
buildDiscountOptions,
type DiscountOption,
} from "@/components/forms/attach-v2/utils/discountOptionUtils";
import type { FormDiscount } from "@/components/forms/attach-v2/utils/discountUtils";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { SearchableSelect } from "@/components/v2/selects/SearchableSelect";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { useStripeCouponsQuery } from "@/hooks/queries/useStripeCouponsQuery";
export function DiscountRow({
discounts,
index,
productId,
onUpdate,
onRemove,
}: {
discounts: FormDiscount[];
index: number;
productId: string | undefined;
onUpdate: ({ rewardId }: { rewardId: string }) => void;
onRemove: () => void;
}) {
const { rewards, rewardPrograms } = useRewardsQuery();
const { stripeCoupons } = useStripeCouponsQuery();
const discount = discounts[index];
if (!discount) return null;
const allOptions = buildDiscountOptions({
rewards,
rewardPrograms,
stripeCoupons,
productId,
});
const selectedRewardIds = discounts
.filter((d, i) => i !== index && "reward_id" in d)
.map((d) => ("reward_id" in d ? d.reward_id : ""))
.filter(Boolean);
const availableOptions = allOptions.filter(
(o) => !selectedRewardIds.includes(o.id),
);
const currentRewardId =
"reward_id" in discount ? (discount.reward_id ?? "") : "";
return (
<div className="flex items-center gap-2 h-8">
<div className="flex-1 min-w-0">
<SearchableSelect
value={currentRewardId}
onValueChange={(rewardId) => onUpdate({ rewardId })}
options={availableOptions}
getOptionValue={(o: DiscountOption) => o.id}
getOptionLabel={(o: DiscountOption) => o.label}
placeholder="Select discount..."
searchable
searchPlaceholder="Search discounts..."
emptyText="No discounts found"
triggerClassName="h-7 px-2 text-xs border-0 shadow-none bg-transparent hover:bg-muted/50"
renderOption={(option: DiscountOption, isSelected: boolean) => (
<>
<span className="flex-1 truncate min-w-0">{option.label}</span>
{option.sublabel && (
<span className="text-t3 text-xs shrink-0">
{option.sublabel}
</span>
)}
{isSelected && <CheckIcon className="size-4 shrink-0" />}
</>
)}
renderValue={(option: DiscountOption | undefined) => {
if (!option)
return <span className="text-t3">Select discount...</span>;
return (
<span className="flex items-center gap-2">
<span className="truncate">{option.label}</span>
{option.sublabel && (
<span className="text-t3 text-xs shrink-0">
{option.sublabel}
</span>
)}
</span>
);
}}
/>
</div>
<IconButton
variant="muted"
size="sm"
onClick={onRemove}
icon={<XIcon size={12} />}
className="shrink-0 text-t3 hover:text-red-500"
/>
</div>
);
}

View File

@@ -52,7 +52,9 @@ export function PriceDisplay({ product, currency }: PriceDisplayProps) {
return (
<span className="flex items-center gap-1">
<span className="text-t1 font-semibold">{priceDisplay.formattedPrice}</span>
<span className="text-t1 font-semibold">
{priceDisplay.formattedPrice}
</span>
<span className="text-t3">{priceDisplay.intervalText}</span>
</span>
);

View File

@@ -1,59 +1,127 @@
import { PlusIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import {
addDiscount,
removeDiscount,
updateDiscount,
} from "@/components/forms/attach-v2/utils/discountUtils";
import {
AdvancedSection,
ConfigRow,
} from "@/components/forms/shared/advanced-section";
import { DiscountRow } from "@/components/forms/shared/discount-row/DiscountRow";
import { Switch } from "@/components/ui/switch";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useUpdateSubscriptionFormContext } from "../context/UpdateSubscriptionFormProvider";
export function UpdateSubscriptionAdvancedSection() {
const { form, formValues, formContext } = useUpdateSubscriptionFormContext();
const { billingBehavior, resetBillingCycle, noBillingChanges } = formValues;
const { customerProduct } = formContext;
const { billingBehavior, resetBillingCycle, noBillingChanges, discounts } =
formValues;
const { customerProduct, product } = formContext;
const hasActiveSubscription =
(customerProduct.subscription_ids?.length ?? 0) > 0;
const isProrate = billingBehavior !== "none";
if (!hasActiveSubscription) return null;
const handleAddDiscount = () => {
form.setFieldValue("discounts", addDiscount(discounts));
};
return (
<AdvancedSection>
<ConfigRow
title="Prorate Changes"
description="Prorate price differences when changing plans mid-cycle"
title="Discounts"
description="Apply percentage or fixed-amount discounts to this subscription"
action={
<Switch
checked={isProrate}
onCheckedChange={(checked) =>
form.setFieldValue("billingBehavior", checked ? null : "none")
<IconButton
variant="muted"
size="sm"
onClick={handleAddDiscount}
icon={<PlusIcon size={12} />}
className="text-t3"
>
Add
</IconButton>
}
>
{discounts.length > 0 && (
<div className="space-y-2">
<AnimatePresence initial={false} mode="popLayout">
{discounts.map((discount, index) => (
<motion.div
key={discount._id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
>
<DiscountRow
discounts={discounts}
index={index}
productId={product?.id}
onUpdate={({ rewardId }) => {
form.setFieldValue(
"discounts",
updateDiscount(discounts, index, {
reward_id: rewardId,
}),
);
}}
onRemove={() => {
form.setFieldValue(
"discounts",
removeDiscount(discounts, index),
);
}}
/>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</ConfigRow>
{hasActiveSubscription && (
<>
<ConfigRow
title="Prorate Changes"
description="Prorate price differences when changing plans mid-cycle"
action={
<Switch
checked={isProrate}
onCheckedChange={(checked) =>
form.setFieldValue("billingBehavior", checked ? null : "none")
}
/>
}
/>
}
/>
<ConfigRow
title="No Billing Changes"
description="Update subscription state without applying Stripe billing changes"
action={
<Switch
checked={noBillingChanges}
onCheckedChange={(checked) =>
form.setFieldValue("noBillingChanges", !!checked)
<ConfigRow
title="No Billing Changes"
description="Update subscription state without applying Stripe billing changes"
action={
<Switch
checked={noBillingChanges}
onCheckedChange={(checked) =>
form.setFieldValue("noBillingChanges", !!checked)
}
/>
}
/>
}
/>
<ConfigRow
title="Reset Billing Cycle"
description="Restart the billing cycle from today"
action={
<Switch
checked={resetBillingCycle}
onCheckedChange={(checked) =>
form.setFieldValue("resetBillingCycle", !!checked)
<ConfigRow
title="Reset Billing Cycle"
description="Restart the billing cycle from today"
action={
<Switch
checked={resetBillingCycle}
onCheckedChange={(checked) =>
form.setFieldValue("resetBillingCycle", !!checked)
}
/>
}
/>
}
/>
</>
)}
</AdvancedSection>
);
}

View File

@@ -36,6 +36,8 @@ export function useHasSubscriptionChanges({
if (formValues.resetBillingCycle) return true;
if (formValues.noBillingChanges) return true;
if (formValues.discounts?.length > 0) return true;
const trialChanges = generateTrialChanges({
customerProduct,
removeTrial: formValues.removeTrial,
@@ -83,6 +85,7 @@ export function useHasSubscriptionChanges({
formValues.billingBehavior,
formValues.resetBillingCycle,
formValues.noBillingChanges,
formValues.discounts,
initialBillingBehavior,
formValues.removeTrial,
formValues.trialLength,

View File

@@ -56,6 +56,7 @@ export function useUpdateSubscriptionForm({
refundBehavior: null,
refundAmount: null,
noBillingChanges: false,
discounts: [],
...defaultOverrides,
} as UpdateSubscriptionForm,
validators: {

View File

@@ -120,8 +120,13 @@ export function useUpdateSubscriptionRequestBody({
refundBehavior,
refundAmount,
noBillingChanges,
discounts,
} = formValues;
const validDiscounts = discounts?.length
? discounts.filter((d) => "reward_id" in d && d.reward_id)
: undefined;
const base = {
customer_id: customerId ?? "",
product_id: product?.id,
@@ -172,6 +177,7 @@ export function useUpdateSubscriptionRequestBody({
billing_behavior: billingBehavior || undefined,
billing_cycle_anchor: resetBillingCycle ? "now" : undefined,
no_billing_changes: noBillingChanges || undefined,
discounts: validDiscounts,
};
}, [
form.store,

View File

@@ -6,6 +6,7 @@ import {
} from "@autumn/shared";
import { z } from "zod/v4";
import type { FormDiscount } from "@/components/forms/attach-v2/utils/discountUtils";
import { RefundBehaviorSchema } from "@/components/forms/update-subscription-v2/types/refundBehaviourSchema";
export const UpdateSubscriptionFormSchema = z.object({
@@ -27,6 +28,7 @@ export const UpdateSubscriptionFormSchema = z.object({
refundBehavior: RefundBehaviorSchema.nullable(),
refundAmount: z.enum(["prorated", "full"]).nullable(),
noBillingChanges: z.boolean(),
discounts: z.custom<FormDiscount[]>(),
});
export type UpdateSubscriptionForm = z.infer<

View File

@@ -0,0 +1,57 @@
import type { ApiDiscount } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useParams } from "react-router";
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useCusRewardsQuery = ({
enabled = true,
}: {
enabled?: boolean;
} = {}) => {
const { customer_id } = useParams();
const axiosInstance = useAxiosInstance();
const buildKey = useQueryKeyFactory();
const fetcher = async () => {
if (!customer_id) return { customer: { rewards: { discounts: [] } } };
const { data } = await axiosInstance.get(
`/customers/${customer_id}?expand=rewards`,
);
return data;
};
const { data, isLoading, error, refetch } = useQuery({
queryKey: buildKey(["customer-rewards", customer_id]),
queryFn: fetcher,
enabled: enabled && !!customer_id,
staleTime: 5 * 60 * 1000,
});
const discounts: ApiDiscount[] = useMemo(
() => data?.customer?.rewards?.discounts ?? [],
[data],
);
const getDiscountsForSubscription = useCallback(
({ subscriptionIds }: { subscriptionIds: string[] }) => {
if (subscriptionIds.length === 0) return [];
return discounts.filter(
(discount) =>
discount.subscription_id &&
subscriptionIds.includes(discount.subscription_id),
);
},
[discounts],
);
return {
discounts,
getDiscountsForSubscription,
isLoading,
error,
refetch,
};
};

View File

@@ -1,3 +1,4 @@
import type { ApiDiscount } from "@autumn/shared";
import {
CusProductStatus,
type Entity,
@@ -19,6 +20,7 @@ import {
Info,
SubtractIcon,
TagIcon,
TicketIcon,
TimerIcon,
XCircle,
} from "@phosphor-icons/react";
@@ -30,6 +32,7 @@ import { MiniCopyButton } from "@/components/v2/buttons/CopyButton";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { InfoRow } from "@/components/v2/InfoRow";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useCusRewardsQuery } from "@/hooks/queries/useCusRewardsQuery";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useProductVersionQuery } from "@/hooks/queries/useProductVersionQuery";
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
@@ -44,6 +47,15 @@ import { BasePriceDisplay } from "@/views/products/plan/components/plan-card/Bas
import { PlanFeatureRow } from "@/views/products/plan/components/plan-card/PlanFeatureRow";
import { CustomerProductsStatus } from "../table/customer-products/CustomerProductsStatus";
function formatDiscountLabel({ discount }: { discount: ApiDiscount }): string {
const value =
discount.type === "percentage_discount"
? `${discount.discount_value}% off`
: `${discount.discount_value / 100} ${discount.currency?.toUpperCase() ?? ""} off`;
return discount.name ? `${discount.name} (${value})` : value;
}
function SubscriptionDetailItems({
items,
product,
@@ -53,9 +65,7 @@ function SubscriptionDetailItems({
items: ProductItem[];
product: FrontendProduct;
prepaidDisplayQuantities: Record<string, number>;
adminIds?: import(
"@/components/forms/shared/admin/AdminPlanIdsTooltip"
).AdminPlanIds;
adminIds?: import("@/components/forms/shared/admin/AdminPlanIdsTooltip").AdminPlanIds;
}) {
const sortedItems = useMemo(() => sortPlanItems({ items }), [items]);
const { visibleItems, collapsedBooleanItems } = useMemo(
@@ -114,6 +124,7 @@ export function SubscriptionDetailSheet() {
const setSheet = useSheetStore((s) => s.setSheet);
// Get customer product and productV2 by itemId
const { cusProduct, productV2 } = useSubscriptionById({ itemId });
const { getDiscountsForSubscription } = useCusRewardsQuery();
// Prefetch product version data so the update sheet has it cached immediately
useProductVersionQuery({ productId: productV2?.id });
@@ -142,6 +153,10 @@ export function SubscriptionDetailSheet() {
);
const isScheduled = cusProduct.status === CusProductStatus.Scheduled;
const subscriptionDiscounts = getDiscountsForSubscription({
subscriptionIds: cusProduct.subscription_ids ?? [],
});
const canCancel = !isExpired;
const canUpdate = !isExpired && !isScheduled;
const prepaidDisplayQuantities = backendToDisplayQuantity({
@@ -310,6 +325,15 @@ export function SubscriptionDetailSheet() {
}
/>
{subscriptionDiscounts.map((discount: ApiDiscount) => (
<InfoRow
key={discount.id}
icon={<TicketIcon size={16} weight="duotone" />}
label="Coupon"
value={formatDiscountLabel({ discount })}
/>
))}
<InfoRow
icon={<CalendarBlankIcon size={16} weight="duotone" />}
label="Started"

View File

@@ -13,6 +13,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { useCusRewardsQuery } from "@/hooks/queries/useCusRewardsQuery";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useIsMobile } from "@/hooks/useIsMobile";
@@ -46,6 +47,7 @@ export default function CustomerView2() {
} = useCusQuery();
useCusReferralQuery();
useCusRewardsQuery();
const { entityId, setEntityId } = useEntity();
const sheetType = useSheetStore((s) => s.type);