diff --git a/server/src/internal/customers/internalHandlers/handleGetCustomer.ts b/server/src/internal/customers/internalHandlers/handleGetCustomer.ts index 078297ebe..c62f44126 100644 --- a/server/src/internal/customers/internalHandlers/handleGetCustomer.ts +++ b/server/src/internal/customers/internalHandlers/handleGetCustomer.ts @@ -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, }); diff --git a/vite/src/components/forms/attach-v2/components/AttachDiscountRow.tsx b/vite/src/components/forms/attach-v2/components/AttachDiscountRow.tsx index c525dbefa..a56240e95 100644 --- a/vite/src/components/forms/attach-v2/components/AttachDiscountRow.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachDiscountRow.tsx @@ -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 ( -
- {/* Reward select */} -
- 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) => ( - <> - {option.label} - {option.sublabel && ( - - {option.sublabel} - - )} - {isSelected && } - - )} - renderValue={(option) => { - if (!option) - return Select discount...; - return ( - - {option.label} - {option.sublabel && ( - - {option.sublabel} - - )} - - ); - }} - /> -
- - {/* Remove button */} - } - className="shrink-0 text-t3 hover:text-red-500" - /> -
+ { + form.setFieldValue( + "discounts", + updateDiscount(discounts, index, { reward_id: rewardId }), + ); + }} + onRemove={() => { + form.setFieldValue("discounts", removeDiscount(discounts, index)); + }} + /> ); } diff --git a/vite/src/components/forms/shared/discount-row/DiscountRow.tsx b/vite/src/components/forms/shared/discount-row/DiscountRow.tsx new file mode 100644 index 000000000..0f634e5ca --- /dev/null +++ b/vite/src/components/forms/shared/discount-row/DiscountRow.tsx @@ -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 ( +
+
+ 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) => ( + <> + {option.label} + {option.sublabel && ( + + {option.sublabel} + + )} + {isSelected && } + + )} + renderValue={(option: DiscountOption | undefined) => { + if (!option) + return Select discount...; + return ( + + {option.label} + {option.sublabel && ( + + {option.sublabel} + + )} + + ); + }} + /> +
+ + } + className="shrink-0 text-t3 hover:text-red-500" + /> +
+ ); +} diff --git a/vite/src/components/forms/update-subscription-v2/components/PriceDisplay.tsx b/vite/src/components/forms/update-subscription-v2/components/PriceDisplay.tsx index cb2bc2d08..3d558ba47 100644 --- a/vite/src/components/forms/update-subscription-v2/components/PriceDisplay.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/PriceDisplay.tsx @@ -52,7 +52,9 @@ export function PriceDisplay({ product, currency }: PriceDisplayProps) { return ( - {priceDisplay.formattedPrice} + + {priceDisplay.formattedPrice} + {priceDisplay.intervalText} ); diff --git a/vite/src/components/forms/update-subscription-v2/components/UpdateSubscriptionAdvancedSection.tsx b/vite/src/components/forms/update-subscription-v2/components/UpdateSubscriptionAdvancedSection.tsx index 9387b46de..e49f3895d 100644 --- a/vite/src/components/forms/update-subscription-v2/components/UpdateSubscriptionAdvancedSection.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/UpdateSubscriptionAdvancedSection.tsx @@ -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 ( - form.setFieldValue("billingBehavior", checked ? null : "none") + } + className="text-t3" + > + Add + + } + > + {discounts.length > 0 && ( +
+ + {discounts.map((discount, index) => ( + + { + form.setFieldValue( + "discounts", + updateDiscount(discounts, index, { + reward_id: rewardId, + }), + ); + }} + onRemove={() => { + form.setFieldValue( + "discounts", + removeDiscount(discounts, index), + ); + }} + /> + + ))} + +
+ )} +
+ + {hasActiveSubscription && ( + <> + + form.setFieldValue("billingBehavior", checked ? null : "none") + } + /> } /> - } - /> - - form.setFieldValue("noBillingChanges", !!checked) + + form.setFieldValue("noBillingChanges", !!checked) + } + /> } /> - } - /> - - form.setFieldValue("resetBillingCycle", !!checked) + + form.setFieldValue("resetBillingCycle", !!checked) + } + /> } /> - } - /> + + )}
); } diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts b/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts index 999fb4eb6..8a0a75942 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useHasSubscriptionChanges.ts @@ -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, diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts index a5e74ae2c..3e8376125 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts @@ -56,6 +56,7 @@ export function useUpdateSubscriptionForm({ refundBehavior: null, refundAmount: null, noBillingChanges: false, + discounts: [], ...defaultOverrides, } as UpdateSubscriptionForm, validators: { diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts index 46c99952b..8fa9b7c31 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts @@ -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, diff --git a/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts b/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts index 4d03d4b05..9c20f729d 100644 --- a/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts +++ b/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts @@ -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(), }); export type UpdateSubscriptionForm = z.infer< diff --git a/vite/src/hooks/queries/useCusRewardsQuery.tsx b/vite/src/hooks/queries/useCusRewardsQuery.tsx new file mode 100644 index 000000000..0548dabb5 --- /dev/null +++ b/vite/src/hooks/queries/useCusRewardsQuery.tsx @@ -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, + }; +}; diff --git a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx index 633f7e3da..5dfb539d7 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx @@ -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; - 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) => ( + } + label="Coupon" + value={formatDiscountLabel({ discount })} + /> + ))} + } label="Started" diff --git a/vite/src/views/customers2/customer/CustomerView2.tsx b/vite/src/views/customers2/customer/CustomerView2.tsx index a457d6d06..17231943a 100644 --- a/vite/src/views/customers2/customer/CustomerView2.tsx +++ b/vite/src/views/customers2/customer/CustomerView2.tsx @@ -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);