feat: in-app subscription cancellation for paid plans (#934)

## Summary
- Add a "Cancel subscription" button to the billing card for Pro and Scale users, alongside the existing "Manage billing" portal link.
- Cancellation is scheduled end-of-cycle via `autumn.updateSubscription({ planId, cancelAction: "cancel_end_of_cycle" })`, so users keep paid features until period end.
- Confirmation dialog surfaces remaining days from `useTokenUsage` and the correct plan name (Pro/Scale).
- Enterprise users do not see the cancel button — contract-based, portal/sales only.

Previously, the only path off a paid plan was Autumn's external customer portal, which contradicted the in-app "cancel anytime from the Billing tab" copy in the support FAQ.
This commit is contained in:
MaheshtheDev 2026-05-12 05:36:27 +00:00
parent 085c3dd8b3
commit 5a0dffbc4c
2 changed files with 164 additions and 19 deletions

View file

@ -197,6 +197,8 @@ export default function Account() {
} = useAuth()
const autumn = useCustomer()
const [isUpgrading, setIsUpgrading] = useState(false)
const [isCancelling, setIsCancelling] = useState(false)
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
const [emailConfirm, setEmailConfirm] = useState("")
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isClosingAccount, setIsClosingAccount] = useState(false)
@ -287,6 +289,33 @@ export default function Account() {
}
}
// Enterprise is contract-based — direct those users to the portal/sales.
const cancellablePlanId =
currentPlan === "pro" || currentPlan === "scale"
? (`api_${currentPlan}` as const)
: null
const handleCancelSubscription = async () => {
if (!cancellablePlanId) return
setIsCancelling(true)
try {
await autumn.updateSubscription({
planId: cancellablePlanId,
cancelAction: "cancel_end_of_cycle",
})
autumn.refetch?.()
setIsCancelDialogOpen(false)
toast.success(
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
)
} catch (error) {
console.error(error)
toast.error("Failed to cancel subscription. Please try again.")
} finally {
setIsCancelling(false)
}
}
const handleDeleteAccount = async () => {
if (!user?.email || !emailMatches || membershipsPending) return
setIsClosingAccount(true)
@ -546,25 +575,141 @@ export default function Account() {
</p>
</div>
<button
type="button"
onClick={() => {
autumn.openCustomerPortal?.({
returnUrl: "https://app.supermemory.ai/settings#account",
})
}}
className={cn(
"relative w-full h-11 rounded-full flex items-center justify-center gap-2",
"bg-[#0D121A] border border-[rgba(115,115,115,0.2)]",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
<div className="flex flex-col sm:flex-row gap-3">
<button
type="button"
onClick={() => {
autumn.openCustomerPortal?.({
returnUrl:
"https://app.supermemory.ai/settings#account",
})
}}
className={cn(
"relative flex-1 h-11 rounded-full flex items-center justify-center gap-2",
"bg-[#0D121A] border border-[rgba(115,115,115,0.2)]",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
>
<Settings className="size-4" />
Manage billing
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]" />
</button>
{cancellablePlanId && (
<Dialog
open={isCancelDialogOpen}
onOpenChange={setIsCancelDialogOpen}
>
<DialogTrigger asChild>
<button
type="button"
className={cn(
"relative flex-1 h-11 rounded-full flex items-center justify-center gap-2",
"bg-[#290F0A] text-[#C73B1B]",
"font-medium text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
>
Cancel subscription
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
</button>
</DialogTrigger>
<DialogContent
showCloseButton={false}
className={cn(
"bg-[#1B1F24] rounded-[22px] p-4",
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
"min-w-xl",
)}
>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-4">
<div className="flex flex-1 flex-col gap-3 pl-1">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Cancel {planDisplayNames[currentPlan]}{" "}
subscription?
</p>
<p
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#A3A3A3] leading-snug",
)}
>
You&apos;ll keep Pro features until the end of
your current billing period
{daysRemaining !== null
? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)`
: ""}
. After that, your account will switch to the
Free plan.
</p>
</div>
<DialogClose asChild>
<button
type="button"
className={cn(
"relative size-7 rounded-full bg-[#0D121A] border border-[#73737333]",
"flex items-center justify-center shrink-0",
"cursor-pointer transition-opacity hover:opacity-80",
)}
>
<X className="size-4 text-[#737373]" />
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]" />
</button>
</DialogClose>
</div>
<div className="flex items-center justify-end gap-5">
<DialogClose asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
"cursor-pointer transition-opacity hover:opacity-80",
)}
>
Keep plan
</button>
</DialogClose>
<button
type="button"
onClick={() => void handleCancelSubscription()}
disabled={isCancelling}
className={cn(
"relative flex items-center gap-1.5 px-4 py-2 rounded-full",
"bg-[#290F0A] text-[#C73B1B]",
"font-normal text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity",
"disabled:opacity-40 disabled:cursor-not-allowed",
!isCancelling && "hover:opacity-90",
dmSans125ClassName(),
)}
>
{isCancelling && (
<LoaderIcon className="size-[18px] animate-spin" />
)}
<span>
{isCancelling
? "Cancelling…"
: "Cancel subscription"}
</span>
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
</button>
</div>
</div>
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
</DialogContent>
</Dialog>
)}
>
<Settings className="size-4" />
Manage billing
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]" />
</button>
</div>
</>
) : (
<>

View file

@ -295,7 +295,7 @@
},
"packages/memory-graph": {
"name": "@supermemory/memory-graph",
"version": "0.2.0",
"version": "0.2.1",
"dependencies": {
"d3-force": "^3.0.0",
},