fix(web): keep the discount code until the upgrade actually completes

Every checkout call site cleared the stored promo code as soon as
`autumn.attach()` resolved, but attach resolving only means Stripe handed back
a payment URL. A user who closed or abandoned that checkout page came back with
the code already gone from localStorage and the `?discountCode=` param long
since stripped, with no way to get it back.

Clear centrally instead: `PromoCodeHost` spends the code once the org moves up
a plan, which is the point at which the checkout it was captured for actually
went through. Downgrades and trial expiries leave an unused code alone. Codes
also carry a 30-day expiry now, so one that is never redeemed stops applying to
future checkouts instead of discounting them forever.
This commit is contained in:
rajashidattapy 2026-08-20 00:02:43 +05:30
parent 18a2dfbe39
commit e8eaac71fb
9 changed files with 159 additions and 25 deletions

View file

@ -335,7 +335,6 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -347,7 +347,6 @@ export function AddDocument({
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#account`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
@ -449,7 +448,6 @@ export function AddDocument({
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#account`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -2850,7 +2850,6 @@ export function IntegrationsView({
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -575,7 +575,6 @@ export function PluginsDetail() {
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -637,7 +637,6 @@ function OnboardingPlansModal({
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return

View file

@ -703,7 +703,6 @@ export default function Billing() {
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#billing`,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return

View file

@ -557,7 +557,6 @@ export default function ConnectionsMCP() {
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#connections`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "bun:test"
import { isPromoCodeSpent, parseStoredPromoCode } from "./use-promo-code"
const NOW = 1_700_000_000_000
describe("parseStoredPromoCode", () => {
it("reads a stored code with its plan and expiry", () => {
const raw = JSON.stringify({
code: "LAUNCH20",
plan: "free",
expiresAt: NOW + 1000,
})
expect(parseStoredPromoCode(raw, NOW)).toEqual({
code: "LAUNCH20",
plan: "free",
expiresAt: NOW + 1000,
})
})
it("drops a code once it has expired", () => {
const raw = JSON.stringify({ code: "LAUNCH20", expiresAt: NOW - 1 })
expect(parseStoredPromoCode(raw, NOW)).toBeNull()
})
it("still reads bare codes written before plan/expiry were stored", () => {
expect(parseStoredPromoCode("LAUNCH20", NOW)).toEqual({
code: "LAUNCH20",
})
})
it("returns null for missing or unusable values", () => {
expect(parseStoredPromoCode(null, NOW)).toBeNull()
expect(parseStoredPromoCode("{oops", NOW)).toBeNull()
expect(
parseStoredPromoCode(JSON.stringify({ plan: "pro" }), NOW),
).toBeNull()
})
})
describe("isPromoCodeSpent", () => {
it("is spent once the org moves up a plan", () => {
expect(isPromoCodeSpent({ code: "X", plan: "free" }, "pro")).toBe(true)
expect(isPromoCodeSpent({ code: "X", plan: "pro" }, "max")).toBe(true)
})
it("survives an unfinished checkout", () => {
// `attach()` resolving only means Stripe handed back a payment URL; the
// user can still abandon it, and the code has to be there when they retry.
expect(isPromoCodeSpent({ code: "X", plan: "free" }, "free")).toBe(false)
})
it("survives a downgrade or trial expiry", () => {
expect(isPromoCodeSpent({ code: "X", plan: "max" }, "pro")).toBe(false)
expect(isPromoCodeSpent({ code: "X", plan: "pro" }, "free")).toBe(false)
})
it("is never spent while no plan has been recorded yet", () => {
expect(isPromoCodeSpent({ code: "X" }, "max")).toBe(false)
})
})

View file

@ -1,20 +1,86 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { useCustomer } from "autumn-js/react"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo } from "react"
import { toast } from "sonner"
import {
normalizePlanType,
PLAN_RANK,
type PlanType,
useTokenUsage,
} from "@/hooks/use-token-usage"
const PENDING_PROMO_CODE_KEY = "sm.promoCode.pending"
const PROMO_TOAST_ID = "promo-code"
/** A code that is never redeemed stops applying after this long. */
const PROMO_TTL_MS = 30 * 24 * 60 * 60 * 1000
export interface StoredPromoCode {
code: string
/** Plan the org was on when the code was stored. */
plan?: PlanType
expiresAt?: number
}
function promoCodeKey(orgId: string): string {
return `sm.promoCode.org_${orgId}`
}
function readOrgPromoCode(orgId?: string): string | null {
/**
* Codes are stored as JSON. Values written before the plan/expiry bookkeeping
* existed are the bare code, and are kept working until `PromoCodeHost` stamps
* them on the next mount.
*/
export function parseStoredPromoCode(
raw: string | null,
now: number = Date.now(),
): StoredPromoCode | null {
if (!raw) return null
let stored: StoredPromoCode
if (raw.startsWith("{")) {
try {
const parsed = JSON.parse(raw) as StoredPromoCode
if (typeof parsed?.code !== "string" || !parsed.code) return null
stored = parsed
} catch {
return null
}
} else {
stored = { code: raw }
}
if (stored.expiresAt !== undefined && stored.expiresAt <= now) return null
return stored
}
/**
* A discount is spent once the org actually moves up a plan that, not
* `attach()` resolving, is the point at which the checkout it was captured for
* went through. Downgrades and trial expiries leave an unused code alone.
*/
export function isPromoCodeSpent(
stored: StoredPromoCode,
currentPlan: PlanType,
): boolean {
if (!stored.plan) return false
return PLAN_RANK[currentPlan] > PLAN_RANK[stored.plan]
}
function readOrgPromoCode(orgId?: string): StoredPromoCode | null {
if (!orgId || typeof window === "undefined") return null
return window.localStorage.getItem(promoCodeKey(orgId))
return parseStoredPromoCode(window.localStorage.getItem(promoCodeKey(orgId)))
}
function writeOrgPromoCode(orgId: string, code: string, plan: PlanType): void {
const stored: StoredPromoCode = {
code,
plan,
expiresAt: Date.now() + PROMO_TTL_MS,
}
window.localStorage.setItem(promoCodeKey(orgId), JSON.stringify(stored))
}
export function usePromoCode() {
@ -22,17 +88,11 @@ export function usePromoCode() {
const orgId = org?.id
const getDiscounts = useCallback(() => {
const promotionCode = readOrgPromoCode(orgId)
return promotionCode ? [{ promotionCode }] : undefined
const stored = readOrgPromoCode(orgId)
return stored ? [{ promotionCode: stored.code }] : undefined
}, [orgId])
const clear = useCallback(() => {
if (!orgId) return
window.localStorage.removeItem(promoCodeKey(orgId))
toast.dismiss(PROMO_TOAST_ID)
}, [orgId])
return useMemo(() => ({ getDiscounts, clear }), [getDiscounts, clear])
return useMemo(() => ({ getDiscounts }), [getDiscounts])
}
export function PromoCodeCapture() {
@ -52,31 +112,51 @@ export function PromoCodeCapture() {
export function PromoCodeHost() {
const { org } = useAuth()
const router = useRouter()
const autumn = useCustomer()
const { currentPlan, isLoading } = useTokenUsage(autumn)
const orgId = org?.id
useEffect(() => {
if (!org?.id) return
if (!orgId) return
// The stored plan is the yardstick for "has this code been redeemed",
// so nothing is stamped until autumn has loaded — recording a
// placeholder "free" would spend the code on the next render.
if (isLoading) return
const plan = normalizePlanType(currentPlan)
const pending = window.localStorage.getItem(PENDING_PROMO_CODE_KEY)
if (pending) {
window.localStorage.setItem(promoCodeKey(org.id), pending)
writeOrgPromoCode(orgId, pending, plan)
window.localStorage.removeItem(PENDING_PROMO_CODE_KEY)
}
const code = readOrgPromoCode(org.id)
if (!code) {
const stored = readOrgPromoCode(orgId)
if (!stored) {
toast.dismiss(PROMO_TOAST_ID)
return
}
if (isPromoCodeSpent(stored, plan)) {
window.localStorage.removeItem(promoCodeKey(orgId))
toast.dismiss(PROMO_TOAST_ID)
return
}
// Codes stored before this bookkeeping existed carry no plan or expiry.
if (!stored.plan || stored.expiresAt === undefined) {
writeOrgPromoCode(orgId, stored.code, plan)
}
toast.success("Discount code active", {
id: PROMO_TOAST_ID,
description: `Code ${code} will apply at checkout.`,
description: `Code ${stored.code} will apply at checkout.`,
duration: Number.POSITIVE_INFINITY,
action: {
label: "Upgrade",
onClick: () => router.push("/settings#billing"),
},
})
}, [org?.id, router])
}, [orgId, router, currentPlan, isLoading])
return null
}