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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
102
vite/src/components/forms/shared/discount-row/DiscountRow.tsx
Normal file
102
vite/src/components/forms/shared/discount-row/DiscountRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,6 +56,7 @@ export function useUpdateSubscriptionForm({
|
||||
refundBehavior: null,
|
||||
refundAmount: null,
|
||||
noBillingChanges: false,
|
||||
discounts: [],
|
||||
...defaultOverrides,
|
||||
} as UpdateSubscriptionForm,
|
||||
validators: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<
|
||||
|
||||
57
vite/src/hooks/queries/useCusRewardsQuery.tsx
Normal file
57
vite/src/hooks/queries/useCusRewardsQuery.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user