diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 8b24a6bd2..e338d8dcc 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -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 diff --git a/apps/web/components/add-document/index.tsx b/apps/web/components/add-document/index.tsx index 6ab77d9a4..b9057e314 100644 --- a/apps/web/components/add-document/index.tsx +++ b/apps/web/components/add-document/index.tsx @@ -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 diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 19a3b9b09..ea57e502c 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -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 diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index cfcba4816..ff6726ae4 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -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 diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index 861235e2e..1acb12f03 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -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 diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index d69474b95..bcea709f0 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -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 diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 96cd382dd..13e57912a 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -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 diff --git a/apps/web/hooks/use-promo-code.test.ts b/apps/web/hooks/use-promo-code.test.ts new file mode 100644 index 000000000..8b86773c4 --- /dev/null +++ b/apps/web/hooks/use-promo-code.test.ts @@ -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) + }) +}) diff --git a/apps/web/hooks/use-promo-code.ts b/apps/web/hooks/use-promo-code.ts index 1bc8ca1c0..71fd57103 100644 --- a/apps/web/hooks/use-promo-code.ts +++ b/apps/web/hooks/use-promo-code.ts @@ -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 }