DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Every upgrade offer in our app has a name, and the same name comes back from Stripe on the way home

Nakodo offers the next plan up in nine places: above the launch button at the end of setup, in the note when a Free month's creators are all used up, over the rows the Free plan keeps back, beside the platform tickboxes it cannot use, in a form's own limit message, in a toast, in a monthly email, and twice during setup. One of those offers is now a free week of Pro, which makes the question "which of the nine actually works" worth money.

The answer is not a funnel builder. It is that each offer has a short name, and that name travels through the client, the Server Action, Stripe's redirect and our analytics without being transformed on the way.

The name

export const planHref = (prompt: string) => `/settings/plan?from=${prompt}`;
Enter fullscreen mode Exit fullscreen mode
// Which upgrade offer was on screen, once per page view, so PostHog can show
// how often each one is seen against how often it leads to Checkout
// (checkout.started, sent with the same name as `from`).
export function UpgradeSeen({ prompt }: { prompt: string }) {
  useEffect(() => {
    if (posthog.__loaded) posthog.capture("upgrade.prompt_shown", { prompt });
  }, [prompt]);
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Seven of those places link through that helper, with names like review, month_used, brief_platforms and setup_platforms. The three that are panels rather than links also record that they were on screen: the review page's plan card renders <UpgradeSeen prompt="review" /> and its button passes from="review". Checkout then fires one event with the same string:

track(user.id, "checkout.started", { plan, trial, currency, ...(from && { from }) });
Enter fullscreen mode Exit fullscreen mode

So "how often was this offer seen" and "how often did it lead to a payment page" are two queries over the same property, and a click through to the plan page shows up as a page view with ?from=review on it. Three surfaces, one vocabulary. The analytics side of this app is otherwise deliberately thin, and keeps the visitor ID in a JavaScript variable, so an offer name that survives a redirect is doing a lot of work for one string.

The name is also untrusted input, because it arrives from the browser as an argument to a Server Action:

const offer = typeof from === "string" && /^[\w-]{1,40}$/.test(from) ? from : "plan_page";
Enter fullscreen mode Exit fullscreen mode

Word characters and hyphens, 40 at most, anything else becomes the default. An analytics dimension that a stranger can write arbitrary strings into is a dimension you end up deleting.

Three outcomes, one hook

Pressing an upgrade button can mean three quite different things depending on whether there is already a Stripe subscription, and all three live in one place:

export function usePlanChange(back?: string) {
  const router = useRouter();
  const [pending, start] = useTransition();
  const run = (action: () => Promise<PlanChangeResult>) =>
    start(async () => {
      const r = await action();
      if (r.ok && r.upgraded && back) {
        notify(r);
        router.replace(back, { scroll: false });
        router.refresh();
      } else if (r.ok && r.upgraded) router.push(`/settings/plan?upgraded=${r.upgraded}`, { scroll: true });
      else notify(r);
    });
  return [pending, run] as const;
}
Enter fullscreen mode Exit fullscreen mode

A first paid plan never reaches any of those branches: the action redirects to Stripe and the component is gone. An upgrade on an existing subscription takes effect immediately, and then the question is where the person was. With back, they stay on the page the offer was on, with a toast, and router.refresh() re-renders it at the new plan's limits. Without back, they go to the plan page, which has a proper welcome rather than a toast.

replace rather than push on the first branch, because the person is already on that page: pushing it again would make the back button a no-op.

Coming home from Stripe

Checkout's return URL carries the plan it started:

function withUpgraded(path: string, plan: PaidPlanId): string {
  const url = new URL(path, "https://nakodo.invalid");
  url.searchParams.set("upgraded", plan);
  return `${url.pathname}${url.search}${url.hash}`;
}
Enter fullscreen mode Exit fullscreen mode

A throwaway base host so URL will parse a relative path, then only the relative parts are kept. It is three lines instead of string concatenation because the next path can already have a query string and a hash on it, and this is the one place where getting that wrong sends people to the wrong campaign.

Both Stripe pages come back through a route of our own first:

// Where Checkout and the customer portal send the user back to. Syncs the
// subscription first, so the next page shows the new plan even if the webhook
// hasn't arrived yet.
Enter fullscreen mode Exit fullscreen mode

That is the fix for the oldest race in payments: the customer is back in the app before the webhook has landed, sees their old plan, and concludes the payment failed. The return route pulls the subscription from Stripe, swallows any error (the webhook catches up), and only then redirects.

Then the page the offer was on says so, once:

export function Upgraded({ plan, message }: { plan: string; message: string }) {
  const params = useSearchParams();
  const router = useRouter();
  const path = usePathname();
  const upgraded = params.get("upgraded");

  useEffect(() => {
    if (!upgraded || path === "/settings/plan") return;
    if (upgraded === plan) toast.success(message, { duration: 10_000 });
    const rest = new URLSearchParams(params);
    rest.delete("upgraded");
    router.replace(rest.size ? `${path}?${rest}` : path, { scroll: false });
  }, [upgraded, plan, message, params, path, router]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Four decisions in twelve lines:

The toast only fires when upgraded matches the plan the server has actually rendered. The parameter is a claim made by a URL, and a URL can be bookmarked, shared or reloaded an hour later. If the plan has not changed yet, nothing is said, which is better than congratulating someone on a subscription they do not have.

The parameter is then removed, whatever the answer was, by rebuilding the query from the remaining params rather than by assuming there were none. A reload must not repeat the message.

replace, not push, so the cleanup does not add a history entry, and scroll: false so the page does not jump while a toast is appearing at the edge of it.

And the plan page returns early, because it has its own welcome panel. One component, one job, and the surface that does it better keeps doing it.

The price in the dialog is the price that is charged

The last piece is the upgrade confirmation. An upgrade mid-month charges the difference for the rest of the month, and that figure cannot be computed in the client:

  const ask = (id: PlanId) => {
    setTo(id);
    setQuote(null);
    setOpen(true);
    if (!billedByStripe) return;
    startCheck(async () => {
      const r = await previewUpgradeAction(id);
      if (r.ok) setQuote({ amount: r.amount, prorationDate: r.prorationDate, endsTrial: r.endsTrial });
Enter fullscreen mode Exit fullscreen mode

The quote is fetched every time the dialog opens, and prorationDate goes back to Stripe with the change:

  // Stripe's own timestamp from the preview, so the charge matches the one shown.
  const when = z.number().int().positive().optional().safeParse(prorationDate);
Enter fullscreen mode Exit fullscreen mode

Stripe prorates against a timestamp. Pass back the timestamp the preview used and the invoice is the figure the dialog showed, instead of a figure a few seconds later with a slightly different number of remaining days in it. The confirm button is disabled until the quote arrives, and the quote is its label:

<AlertDialogAction disabled={billedByStripe && (checking || !quote)} onClick={confirm}>
  {!billedByStripe ? `Upgrade to ${plan?.name}` : quote ? `Pay ${quote.amount} and upgrade` : "Checking price..."}
</AlertDialogAction>
Enter fullscreen mode Exit fullscreen mode

And because a free week is now in the mix, the dialog has a third sentence for the case where upgrading ends it early:

: quote?.endsTrial
  ? `Your free week ends now. You'll pay ${quote.amount} today for your first month of ${plan?.name}, then ${price} a month.`
Enter fullscreen mode Exit fullscreen mode

Nobody should discover that by reading a card statement.

Why bother naming offers

Because the alternative is a dashboard full of events called upgrade_click that cannot tell you which of four cards earned it, and the fix for that is always the same: put the name in the code next to the thing it names, validate it where it crosses a trust boundary, and never rewrite it on the way through. The plan cards are on the pricing page; the offers are the same ones, in the places where the limit actually bites.

Top comments (0)