Nakodo has three plans, and the pricing page makes a promise in its FAQ: "Can I change or cancel my plan? Yes, any time in Settings. If you move to a smaller plan, your extra campaigns are paused rather than deleted."
The fastest way to half-keep that promise is a button that opens the Stripe customer portal. We do not do that, and the decision has earned its keep. This is the shape we ended up with.
Earlier I wrote about writing one price figure per currency instead of letting Stripe convert. That post was about what a plan costs. This one is about what happens when somebody moves between plans, which turned out to be a much larger pile of decisions.
Why not the portal
Three reasons, in increasing order of how much they cost to learn.
The portal does not know your product. When somebody moves from our Business plan to Free, campaigns above the new limit are paused, keyword counts are trimmed, CSV export goes away, and already-unlocked creators stay unlocked. Those sentences belong in the confirmation dialog, next to the button, and the portal has no way to say them.
A credit is an invitation. A plain downgrade applied immediately leaves the customer with a prorated credit. That means anyone can buy the big plan, use a month of it in two days, and swap down for most of their money back. Our rule is that a smaller plan starts when the paid month ends, and nothing is refunded in between, because the month was used.
Some accounts have no Stripe subscription at all. A plan granted by hand looks exactly like a paid plan everywhere in the app, and has no customer, no subscription and no invoice. For those accounts the portal is a dead end, so the flag that matters is not "is this a paid plan" but "is this plan billed by Stripe":
billedByStripe: boolean; // a live Stripe subscription, changed through Stripe
billingAccount: boolean; // a Stripe customer, so there are invoices and a card to manage
Two separate booleans, because a cancelled subscriber still has invoices worth showing while having nothing to change.
Four verbs
Everything a subscriber can do to their plan is one of these:
export async function upgrade(userId: string, plan: PaidPlanId, prorationDate?: number): Promise<void>
export async function scheduleDowngrade(userId: string, plan: PaidPlanId): Promise<void>
export async function cancelSubscription(userId: string): Promise<void>
export async function keepPlan(userId: string): Promise<void>
Upgrades happen now. Downgrades and cancellations happen at the end of the paid month. keepPlan undoes a booked downgrade or cancellation. Every one of them starts by loading the same Current object (subscription, its single item, the plan that item's price maps to, any schedule, the customer id) and refuses to continue if the subscription has quietly ended:
if (!item || !plan || sub.status === "canceled" || sub.status === "incomplete_expired") {
await settle(row.stripeCustomerId);
throw new UserError("Your subscription has already ended. Reload the page to see your plan.");
}
Note the settle() before the throw. If Stripe and our row disagree, the discovery gets written down even though the action failed, so the reload the message asks for shows the truth.
The upgrade quote has to be the amount charged
An upgrade charges the difference for the rest of the paid month. Showing a number in a dialog and then charging a different one is the sort of thing that generates support email forever, so the preview and the charge are tied together with an explicit proration date.
export async function previewUpgrade(userId: string, plan: PaidPlanId): Promise<UpgradePreview> {
const c = await current(userId);
requireUpgrade(c, plan);
const prorationDate = Math.floor(Date.now() / 1000);
const invoice = await stripe().invoices.createPreview({
customer: c.customerId,
subscription: c.sub.id,
subscription_details: {
items: [{ id: c.item.id, price: await priceIdFor(plan) }],
proration_behavior: "always_invoice",
proration_date: prorationDate,
},
});
const amountDue = invoice.lines.data
.filter((line) => line.parent?.subscription_item_details?.proration)
.reduce((sum, line) => sum + line.amount, 0);
return { amountDue: Math.max(0, amountDue), currency: invoice.currency, prorationDate };
}
Two details in there cost me time:
The sum filters for proration lines only. A preview invoice can also carry the next period's normal charge, and including that turns "pay 23.41 now" into a figure the customer will not recognise. The filter is on line.parent?.subscription_item_details?.proration, which is where that flag lives in the current API shape.
Math.max(0, amountDue) because the proration lines can net out negative, and a dialog saying "this will charge you -4.20" is not a thing anyone should ship.
Then the prorationDate comes back to the client and goes into the update call, with guards:
const now = Math.floor(Date.now() / 1000);
const dated =
prorationDate !== undefined &&
prorationDate <= now &&
prorationDate > now - 3600 &&
prorationDate >= c.item.current_period_start;
A quote is honoured for an hour, and only inside the billing period it was computed in. If somebody leaves the dialog open over lunch, we drop their stale date and let Stripe prorate from now, which charges slightly less than the quote they saw, never more. That asymmetry is deliberate: of the two ways to be wrong about money, only one of them is survivable.
The update itself:
await stripe().subscriptions.update(c.sub.id, {
items: [{ id: c.item.id, price }],
proration_behavior: "always_invoice",
payment_behavior: "error_if_incomplete",
...(dated && { proration_date: prorationDate }),
...uncancel(c.sub),
});
payment_behavior: "error_if_incomplete" is the important flag. Without it a declined card can leave you on the new plan with an unpaid invoice, which is to say giving the product away. With it, a decline means nothing changed and the Stripe error explains why.
A downgrade is a two-phase subscription schedule
This is the part the portal cannot do on your terms. Keep the current price until the end of the paid period, then switch.
const schedule = c.schedule ?? (await stripe().subscriptionSchedules.create({ from_subscription: c.sub.id }));
const phase = schedule.phases.find((p) => p.start_date === schedule.current_phase?.start_date) ?? schedule.phases[0];
const discounts = phase.discounts.flatMap((d) => (d.discount ? [{ discount: idOf(d.discount) }] : []));
await stripe().subscriptionSchedules.update(schedule.id, {
end_behavior: "release",
phases: [
{ items: [{ price: c.item.price.id, quantity: c.item.quantity ?? 1 }], start_date: phase.start_date, end_date: phase.end_date, discounts },
{ items: [{ price, quantity: 1 }], duration: { interval: "month" }, discounts },
],
});
The discounts line is the easiest thing here to leave out and the most expensive. When you rewrite a schedule's phases, anything you do not restate is gone, so a customer on a promotion code would silently lose it the moment they moved to a smaller plan. The current phase's discounts are read off and carried into both phases.
end_behavior: "release" means that once the second phase starts, the schedule lets go and leaves a normal subscription behind. Without it you accumulate schedules that outlive their purpose and then fight your next update.
Because a subscription can hold only one of these arrangements at a time, the four verbs have to clean up after each other: an upgrade releases any booked downgrade first, cancelling replaces a booked downgrade, and keepPlan releases the schedule and clears cancel_at_period_end. Skip any one of them and the next update fails against a schedule that is still holding the subscription.
Changes are written down immediately, not when the webhook lands
Stripe's webhook is the source of truth and it also arrives whenever it arrives. A customer who just clicked Upgrade is looking at the page right now, so every verb syncs the row as soon as Stripe accepts the change:
async function settle(customerId: string) {
try {
await syncStripeCustomer(customerId);
} catch (e) {
console.error("Stripe sync failed", customerId, e);
}
}
A failed sync is logged rather than shown. Stripe has already made the change, so telling the customer their upgrade failed would be false; the webhook will bring the row up to date a moment later. The upgrade() call does this in a finally block, so even a decline refreshes what we know.
The rules that decide the row are pure functions
syncStripeCustomer fetches the customer's subscriptions, and then two pure functions decide what they mean. They have no Stripe client and no database, so they can be tested with plain objects.
// The subscription that decides the plan when a customer has more than one,
// say after paying in two tabs: one that grants access, then the bigger plan,
// then the newest.
export function pickSubscription(subs: Stripe.Subscription[]): Stripe.Subscription | null
export function mirrorOf(sub: Stripe.Subscription): Mirror | null
Two subscriptions on one customer sounds like something that cannot happen until you watch somebody check out in two tabs. The ordering is the policy: a subscription that grants access beats one that does not, then the bigger plan wins, then the newest. The customer gets the better of what they accidentally bought, and the duplicate is a refund conversation rather than a lockout.
mirrorOf also derives the pending plan, which is how the UI can say "Pro until 14 November, then Free" from one row:
function pendingPlanOf(sub: Stripe.Subscription, plan: PaidPlanId, periodEnd: number): PlanId | null {
if (sub.cancel_at_period_end || sub.cancel_at !== null) return "free";
if (!sub.schedule || typeof sub.schedule === "string") return null;
const next = sub.schedule.phases.find((p) => p.start_date >= periodEnd);
...
}
One more rule worth stating out loud, because it is a policy question disguised as a status check:
export const grantsPlan = (status: SubscriptionStatus) =>
status === "active" || status === "trialing" || status === "past_due";
past_due keeps the plan. That is Stripe's retry window after a card fails, and cutting access off the instant a card expires punishes the customer for their bank's behaviour. When Stripe gives up, the status becomes canceled and the plan goes.
The confirmation dialog is generated from the limits
The last piece ties back to the promise on the pricing page. The text in the downgrade dialog is built from the target plan's limits object, not written by hand:
function downgradeEffects(to: PlanId): string {
const l = PLANS[to].limits;
return [
`Running campaigns beyond ${l.campaigns} are paused, newest launches kept.`,
`Each campaign searches up to ${l.keywordsPerCampaign} keywords; the rest are turned off.`,
`${l.unlocksPerMonth} contact unlocks and ${l.draftsPerMonth} drafts a month.`,
l.export ? null : "CSV export is turned off.",
"Creators you've already unlocked stay unlocked, and drafts you've written stay.",
].filter(Boolean).join(" ");
}
That same limits object renders the plan cards and the full comparison table on the pricing page, and is what the server enforces. Open the comparison table and every row you see is a field somebody's code reads. Changing a number moves the marketing page, the downgrade warning and the enforcement in one commit, which is the only version of this I trust.
If you want to see the switcher, the free plan at nakodo.app needs no card, and Settings then Plan is where the four verbs live.
Top comments (0)