Skip to content

Discount codes are never cleared: usePromoCode().clear is dead code, so the code applies to every future checkout forever #1546

Description

@rajarshidattapy

Severity: Medium (revenue leak + permanent UI artifact)
File: apps/web/hooks/use-promo-code.ts

Description

usePromoCode() returns { getDiscounts, clear }. Across the whole app, only
getDiscounts is ever called:

$ grep -rn "promoCode\." apps/web
apps/web/components/add-document/connections.tsx:335:      discounts: promoCode.getDiscounts(),
apps/web/components/add-document/index.tsx:347:           discounts: promoCode.getDiscounts(),
apps/web/components/add-document/index.tsx:449:           discounts: promoCode.getDiscounts(),
apps/web/components/integrations/plugins-detail.tsx:575:   discounts: promoCode.getDiscounts(),
apps/web/components/integrations-view.tsx:2850:           discounts: promoCode.getDiscounts(),
apps/web/components/onboarding-brain/step-sources.tsx:637: discounts: promoCode.getDiscounts(),
apps/web/components/settings/billing.tsx:703:              discounts: promoCode.getDiscounts(),
apps/web/components/settings/connections-mcp.tsx:557:      discounts: promoCode.getDiscounts(),

clear has zero call sites. Nothing ever removes sm.promoCode.org_<id> from
localStorage, and the stored code carries no expiry or use count.

Impact

Once a user lands on any page with ?discountCode=…:

  1. Every subsequent checkout for that org gets the discount, indefinitely —
    including plan upgrades months later and any of the 8 unrelated checkout
    entry points above. A one-time promo becomes a permanent price cut.
  2. The toast never goes away. PromoCodeHost re-fires it on every mount
    with duration: Number.POSITIVE_INFINITY and id: PROMO_TOAST_ID. Since
    clear() (the only toast.dismiss(PROMO_TOAST_ID) caller) is never
    invoked, "Discount code active — Code X will apply at checkout" sticks to the
    app forever.

Clearing localStorage by hand is the only escape.

Suggested fix

Call promoCode.clear() from the checkout onSuccess path (or wherever
attach/checkout resolves successfully) in each of the 8 call sites — or,
less repetitively, clear it centrally once the org's subscription state flips.
An expiry timestamp stored alongside the code would also bound the blast radius
if a clear is ever missed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions