A software engineer, head in hands, studies multiple screens displaying complex financial data, overwhelmed by Stripe subscription proration edge cases.
Production engineering patternUpdated

Stripe Proration Edge Cases on Mid-Cycle Plan Changes

Mid-cycle plan changes are where Stripe's defaults stop matching your customers' expectations. This is the pattern that keeps proration boring.

The problem

The ticket reads "you charged me $47 to switch plans and the new plan is cheaper than the old one." The customer is right. They upgraded from a $29 plan to a $79 plan on the 15th of a 30-day billing cycle and you charged them what looked like a sensible prorated difference. Two weeks later they downgraded back to $29 expecting a credit, and Stripe quietly invoiced them another $24 because the unused-time credit on the $79 plan was smaller than the remaining-time charge on the $29 plan — once you do the math, after the cycle anchor reset, the unused portion of the old period was tiny.

Invoices that do not match the click

The customer does not care which Stripe API parameter caused that. They care that the invoice does not match what they expected when they clicked the button. And the engineer reading the ticket cannot reproduce it from the dashboard, because the only way to see what actually happened is to pull the subscription's latest_invoice, the upcoming-invoice preview that was shown at upgrade time, and the proration_date (if any) that was passed when subscriptions.update was called. Most teams log none of those.

Trial-to-active mid-cycle ambiguity

The other shape of this bug is the trial-to-active transition. A customer signs up for a 14-day trial on a $49 plan. On day 7 they upgrade themselves to the $99 plan, because they want to use a feature that requires it. Your code calls subscriptions.update to swap the price. Depending on exactly how that call is shaped — whether trial_end is preserved, whether proration_behavior is set, whether billing_cycle_anchor is touched — the customer either keeps their trial and pays $99 on day 14, or loses the trial immediately and gets billed a prorated $99 right now, or the trial silently extends because the new price has its own trial configuration. Three customers, three different invoices, one product flow.

Asymmetric over- and under-charge consequences

The business consequence is asymmetric. Over-charging produces tickets, refund work, and the kind of review that says "they billed me wrong twice." Under-charging is silent: revenue you should have collected leaks out one mid-cycle change at a time, and nobody notices until finance tries to reconcile MRR against invoices and the numbers stop tying. The product team has an opinion about what should happen on every plan change. Stripe has a default. When the two disagree, the customer is the one who finds out.

Two engineers intently collaborate, reviewing complex proration logic on a large monitor, crafting a robust Stripe API solution.

What changes for your business

One helper, one call path

Every mid-cycle plan change has to pass through one helper. Not three places in the codebase. Not the dashboard. One helper that takes the inputs the product team actually thinks about — from_plan, to_plan, effective_at (now or period_end), bill_immediately (yes or no), preserve_trial (yes or no) — and translates those into the exact Stripe call. Everything else routes through it, including the admin panel, the customer-facing self-service upgrade, and the internal CLI that account managers use to comp accounts.

The reason this matters is that the four Stripe levers — proration_behavior, proration_date, billing_cycle_anchor, and trial_end — interact non-obviously. The helper centralizes that interaction in one place where you can write tests for every combination, log the inputs, and change the policy globally when product changes its mind. Without it, every developer adding a new upgrade path makes the same decisions over again, and one of them gets it wrong.

Lever one: proration_behavior

The first lever is proration_behavior. The API documents three values: create_prorations (the default), always_invoice, and none. The default creates proration items but only invoices them under certain conditions, which in practice means they land on the next regular invoice. always_invoice creates the prorations and immediately tries to collect payment. none skips proration entirely and bills the new price at the next cycle. Picking the right one is a policy decision, not a technical one — but if you do not pick, you get create_prorations, and that is the default that produces most of the "why did my upgrade not charge me?" tickets.

Lever two: proration_date

The second lever is proration_date. Stripe prorates to the second, so the prorated amount can differ between the preview and the update if any time passes between them. On a high-value plan, that drift is real money. The helper should preview the invoice, pin the moment with a Unix timestamp, show the customer the exact number, and then pass the same timestamp to the update call so the actual invoice matches the quote to the cent.

Lever three: billing_cycle_anchor

The third lever is billing_cycle_anchor. Most plan changes leave it alone. The cases where you do touch it are the trial-end transition (the default behavior resets the anchor to the trial end, which is what you usually want) and a deliberate "restart the billing cycle on plan change" policy (rare, but valid for products that bill on calendar months). Touching it accidentally is a frequent bug source — passing billing_cycle_anchor=now on what you thought was a simple price change resets the cycle and surprises the customer.

Lever four: trial_end

The fourth lever is trial_end. If the customer is on a trial and you call subscriptions.update to change the price without setting trial_end, the behavior depends on the API version and the specific call shape — sometimes the trial survives, sometimes it ends, sometimes the new price's own trial settings take over. The safe default is to read the current trial_end, preserve it across the update if the policy is "trial survives plan changes," and explicitly clear it (trial_end=now) if the policy is "upgrading ends the trial."

Helper shape

Here is the shape of the helper:

type PlanChangeInput = {
  subscriptionId: string;
  fromPriceId: string;
  toPriceId: string;
  effective: "now" | "period_end";
  billImmediately: boolean;
  preserveTrial: boolean;
};

type PlanChangePreview = {
  prorationDate: number; // unix seconds, pinned
  immediateCharge: number; // cents, 0 if no immediate invoice
  nextInvoiceTotal: number; // cents on next regular invoice
  nextInvoiceDate: number; // unix seconds
  trialEndAfter: number | null;
};

async function previewPlanChange(
  input: PlanChangeInput,
): Promise<PlanChangePreview> {
  const sub = await stripe.subscriptions.retrieve(input.subscriptionId);
  const prorationDate = Math.floor(Date.now() / 1000);

  if (input.effective === "period_end") {
    // Schedules don't proration — they swap at the boundary.
    // Preview returns the upcoming invoice as-is.
    const upcoming = await stripe.invoices.retrieveUpcoming({
      subscription: input.subscriptionId,
    });
    return {
      prorationDate,
      immediateCharge: 0,
      nextInvoiceTotal: upcoming.amount_due,
      nextInvoiceDate: upcoming.period_end,
      trialEndAfter: input.preserveTrial ? (sub.trial_end ?? null) : null,
    };
  }

  const itemId = sub.items.data.find(
    (i) => i.price.id === input.fromPriceId,
  )?.id;
  if (!itemId) throw new Error("from_price_id not on subscription");

  const upcoming = await stripe.invoices.retrieveUpcoming({
    subscription: input.subscriptionId,
    subscription_items: [{ id: itemId, price: input.toPriceId }],
    subscription_proration_date: prorationDate,
    subscription_proration_behavior: input.billImmediately
      ? "always_invoice"
      : "create_prorations",
    subscription_trial_end: input.preserveTrial
      ? (sub.trial_end ?? undefined)
      : "now",
  });

  // immediateCharge is what always_invoice would bill right now;
  // nextInvoiceTotal is what create_prorations would defer.
  const prorationLines = upcoming.lines.data.filter(
    (l) => l.proration === true,
  );
  const prorationTotal = prorationLines.reduce(
    (sum, l) => sum + l.amount,
    0,
  );

  return {
    prorationDate,
    immediateCharge: input.billImmediately ? prorationTotal : 0,
    nextInvoiceTotal: upcoming.amount_due,
    nextInvoiceDate: upcoming.period_end,
    trialEndAfter: input.preserveTrial ? (sub.trial_end ?? null) : null,
  };
}

The corresponding applyPlanChange takes the same input plus the prorationDate from the preview, so the customer is invoiced for the exact number they were quoted:

async function applyPlanChange(
  input: PlanChangeInput,
  prorationDate: number,
): Promise<Stripe.Subscription> {
  const sub = await stripe.subscriptions.retrieve(input.subscriptionId);
  const itemId = sub.items.data.find(
    (i) => i.price.id === input.fromPriceId,
  )?.id;
  if (!itemId) throw new Error("from_price_id not on subscription");

  if (input.effective === "period_end") {
    // Create a subscription schedule that holds the current plan
    // until period end, then transitions to the new plan.
    const schedule = await stripe.subscriptionSchedules.create({
      from_subscription: input.subscriptionId,
    });
    const currentPhase = schedule.phases[0];
    return stripe.subscriptionSchedules.update(schedule.id, {
      phases: [
        {
          items: [{ price: input.fromPriceId, quantity: 1 }],
          start_date: currentPhase.start_date,
          end_date: currentPhase.end_date,
          proration_behavior: "none",
        },
        {
          items: [{ price: input.toPriceId, quantity: 1 }],
          proration_behavior: "none",
        },
      ],
    }) as unknown as Stripe.Subscription;
  }

  return stripe.subscriptions.update(input.subscriptionId, {
    items: [{ id: itemId, price: input.toPriceId }],
    proration_date: prorationDate,
    proration_behavior: input.billImmediately
      ? "always_invoice"
      : "create_prorations",
    trial_end: input.preserveTrial
      ? (sub.trial_end ?? undefined)
      : "now",
  });
}

Audit logging on every call site

Every call site logs the full PlanChangeInput, the returned PlanChangePreview, the actual Stripe response, and the customer's billing_mode. When the customer-support ticket arrives three weeks later asking why they were billed $47, the audit row has every input that produced that number.

A confident CTO reviews a clear, color-blocked dashboard on a large monitor, reflecting accurate Stripe subscription billing and financial clarity.

More on this

Common failure modes

The first sharp edge is the default create_prorations surprise. A developer calls subscriptions.update to swap a price, leaves proration_behavior unset, and assumes the customer was either charged immediately or will see the difference next month. The actual behavior depends on conditions Stripe does not surface in the response. The fix is to refuse any call to subscriptions.update for a plan change without explicitly setting proration_behavior — make the linter or a wrapper enforce it.

The second is the downgrade-that-charges-more bug. Customer downgrades from a $79 plan to a $29 plan with a week left in the cycle. Default proration math credits them roughly $18 for unused time on the $79 plan and bills them roughly $7 for the remaining week on the $29 plan, netting an $11 credit on their next invoice. That part is correct. The bug shape is that customers do not understand the credit shows up on the next invoice rather than as a refund to their card, so they open a ticket complaining you owe them money. The fix is product, not engineering: either show the credit clearly in the UI confirmation, or use a subscription schedule that holds the $79 plan until period end and switches to $29 at the boundary with no proration math at all.

The third is trialing-to-active mid-trial plan changes. The customer is on day 4 of a 14-day trial on the $49 plan. They upgrade to $99. If your code passes the new price without trial_end, the trial may or may not survive depending on the API version and the rest of the call shape. The Stripe trial documentation is explicit that when a trial ends with the default billing_cycle_anchor=now, Stripe resets the anchor and invoices a full regular interval — but that only fires at the trial's natural end, not at a mid-trial price change. The safest pattern is to read trial_end before the update and pass it back explicitly so the trial timeline does not move.

The fourth is the preview-drift bug. The customer hits "Confirm upgrade" and sees "$23.41 will be charged today." Your code calls subscriptions.update 800 milliseconds later. Stripe recomputes the proration as of the actual update time and bills $23.43. Two cents is invisible until you hit a customer who screenshots the preview and the invoice and sends both to support. The fix is to pass proration_date on every preview and every update, using the same Unix timestamp captured at preview time.

The fifth is billing_mode mismatch across the customer base. Subscriptions created under classic billing_mode calculate credit prorations differently than ones created under flexible, and the same logical plan change can produce different invoices depending on which mode each subscription is on. Most older Stripe accounts have a mix, because flexible became the default for new accounts on API version 2025-09-30.clover and later. The fix is to surface billing_mode on every subscription row in your admin UI, log it on every plan change, and assume any cross-customer discrepancy report is at least partly explained by it.

The sixth is the schedule-vs-immediate confusion. Engineers reach for subscriptions.update for every change because it is the call they already know. But subscriptions.update applies immediately by definition. Period-end changes need a subscriptionSchedules.create based on the existing subscription, with phases that hold the current plan to period end and then transition. The pattern is to make the helper above pick between the two paths based on effective: "now" | "period_end", so call sites do not have to know which underlying API to use.

What this looks like in production

At BFEAI we run a dual-pool credit model on top of Stripe subscriptions — purchased credits and granted credits with their own rules — and every plan change has to leave both pools in a state that matches what the customer was shown. The helper above is the choke point that makes that possible. There is exactly one path through the codebase that calls subscriptions.update or subscriptionSchedules.create for a plan change, and every other call site routes through it. The admin panel uses it. The self-service upgrade page uses it. The CLI command that account managers run to migrate a customer between plans uses it. The integration test suite runs every documented input combination against the Stripe test mode and asserts on the resulting invoice.

The audit log row for every plan change has eight fields: the input (PlanChangeInput), the preview (PlanChangePreview), the actual Stripe API call (method + parameters), the actual Stripe response, the customer's billing_mode, the subscription's current_period_start and current_period_end at the time of the call, and the trial_end at the time of the call. When the ticket arrives saying "I was charged wrong," the answer is a single query against that log, not a 40-minute investigation across the dashboard, the webhook logs, and the customer's account state.

The dashboard a CTO actually wants for proration has three numbers: count of plan changes in the last 7 days where the actual invoice total differed from the preview by more than a cent (this should be zero — a non-zero means proration_date is not being pinned somewhere), count of plan changes where proration_behavior was unset on the underlying call (this should also be zero — a non-zero means there is a code path bypassing the helper), and count of trialing-customer plan changes where the post-change trial_end differs from the pre-change trial_end against policy (this should be zero unless the policy is "upgrading ends the trial," in which case it should equal the upgrade count).

The runbook for the "preview differed from invoice" alert is short. Step one: pull the audit row. Step two: confirm whether proration_date was passed on both preview and update. If not, the call site needs to be fixed and routed through the helper. If proration_date was pinned and the totals still differ, the most common cause is a tax rate change between preview and apply — separate pre-tax totals from post-tax totals in the comparison and the noise usually clears.

What to watch in your own implementation

Open your codebase and search for every call to stripe.subscriptions.update. For each one, answer four questions: Is proration_behavior explicitly set? Is proration_date passed? Is trial_end either explicitly preserved or explicitly cleared? Is the customer's billing_mode logged? If the answer to any is no, that is a call site that produces unpredictable invoices.

Then search for plan-change paths that should be period-end-effective and confirm they use subscriptionSchedules rather than subscriptions.update. Downgrades are the most common case where the product intent is "at period end, no proration" but the code calls subscriptions.update with default arguments, which means the change is immediate and the customer is charged net-positive on a downgrade. Either fix the policy or fix the call.

Finally, run a one-shot reconciliation over the last 90 days of plan changes. For each one, recompute what the helper above would have done given the same inputs, and compare against what actually got invoiced. The plan changes where the two disagree are the customers most likely to open a ticket, and the count itself is the number to take to your team — it is the size of the surface that the pattern in this document closes.

Outcomes you should expect

What this delivers

  • Mid-cycle plan changes match what the customer expected to be charged, so support tickets about surprise invoices stop showing up.
  • Downgrades behave the way the product team designed them to behave, not the way Stripe's default proration_behavior happens to compute them.
  • The trialing-to-active transition produces one predictable invoice instead of a mystery charge that depends on which path the customer took through onboarding.
  • Engineering can quote, in advance, what any given plan change will invoice — because every change goes through a single audited helper, not five places that each call subscriptions.update with different flags.

Primary sources

By the numbers

  • The proration_behavior parameter on a subscription update has three possible options: create_prorations, always_invoice, and none, and the default is create_prorations.

    Source ↗

  • With proration_behavior=create_prorations, proration items are created when applicable but are only invoiced immediately under certain conditions, meaning the proration normally lands on the next regular invoice rather than billing the customer now.

    Source ↗

  • To bill a customer immediately for a change to a subscription on the same billing period, set proration_behavior to always_invoice when you modify the subscription.

    Source ↗

  • Stripe prorates to the second, so prorated amounts can change between the time they are previewed and the time the update is made; passing proration_date pins the calculation.

    Source ↗

  • When a trial offer ends with the default billing_cycle_anchor of now, Stripe resets the anchor to the time the trial ends and generates a full-amount invoice for the first regular interval with no proration applied.

    Source ↗

  • Credit prorations are issued when customers downgrade their subscriptions or cancel subscription items before the end of their billing period, and the calculation depends on whether the subscription's billing_mode is classic or flexible.

    Source ↗

  • For API version 2025-09-30.clover (GA) and any later GA version, the default billing_mode is flexible, which provides more accurate billing for prorations, usage-based pricing, and trial settings; for all other API versions, the default is classic.

    Source ↗

Live in production today

The same engineering, shipped in production at BFEAI.

I'm co-founder & CTO of Be Found Everywhere (BFEAI), a 7-app AI SaaS platform running today. The work I deliver for clients is the work I do every week on my own platform.

7

Production apps

200K+

Keywords generated

1,500+

AI scans run

7,000+

Sites automated

Common questions

What buyers ask before reaching out

What does proration_behavior actually default to if I do not set it?

The default on subscription update is create_prorations. That sounds harmless, but it has a specific consequence: Stripe creates the proration line items immediately, but only invoices them immediately under certain conditions. In most mid-cycle updates the proration sits as pending invoice items and lands on the customer's next regular invoice, which means a customer who upgrades on the 15th does not see a charge until the next billing date — and then sees a larger-than-expected charge.

When should I use always_invoice instead of create_prorations?

Use always_invoice when you want the customer charged right now for the change — upgrades you want to recognize revenue on today, seat additions on a plan with a per-seat price, or anything where the customer expects an immediate receipt. The trade-off is that always_invoice attempts to collect payment on the spot, so a card decline becomes part of the upgrade flow and you have to handle the failure path inline.

Why did my downgrade charge the customer instead of crediting them?

The most common cause is calling subscriptions.update with proration_behavior unset, which defaults to create_prorations and writes both a negative line item for the unused portion of the old price and a positive line item for the remaining portion of the new price. If the new price's remaining portion is larger than the credit (it usually is for short remaining periods), the net is positive. The fix is either to set proration_behavior=none so the downgrade takes effect at the next billing cycle with no proration, or to schedule the change with a subscription schedule so it applies at period end.

What is proration_date and when do I need it?

proration_date pins the moment Stripe uses to calculate the proration. Because Stripe prorates to the second, the amount you previewed with the invoice preview endpoint can drift by a few cents by the time the update call lands — and on high-value plans, those few cents become a customer complaint. Pass the same Unix timestamp to both the preview and the update so the customer sees exactly the number that was quoted.

What happens to proration when a trial ends?

With the default behavior, the billing_cycle_anchor resets to the moment the trial ends and the customer is invoiced for a full regular interval with no proration. That sounds clean, but it bites when a customer changes plans during the trial — the change applies immediately and the trial may or may not survive depending on how you called the API. The safe path is to make trial-period plan changes go through a single helper that explicitly preserves trial_end.

Does billing_mode classic vs flexible change the proration math?

Yes. Flexible billing mode, which is the default on API version 2025-09-30.clover and later, calculates credit prorations differently than classic and surfaces discount amounts on prorated line items more accurately. Subscriptions created under classic stay on classic and cannot migrate to flexible without recreating them, so most older Stripe accounts have a mix. The practical implication is that two subscriptions with the same plan change can produce different invoices depending on which billing_mode they were created under.

How do I stop a downgrade from taking effect immediately?

Use a subscription schedule with a phase boundary at the current period end. The schedule keeps the customer on the current plan until period end and then transitions them to the downgraded plan without any proration math, which matches how most customers expect downgrades to work. The alternative — calling subscriptions.update with proration_behavior=none — also avoids proration but takes effect immediately, which means the customer loses the rest of the period they already paid for.

Why does the same upgrade produce different invoices for two customers?

Usually one of three reasons. The two subscriptions were created under different billing_mode settings. They have different billing_cycle_anchor dates, so the remaining period being prorated is different. Or one is on a trial and one is not, and the trial-end path bypasses proration entirely. The fix at the engineering layer is to log billing_mode, current_period_start, current_period_end, and trial_end on every plan change so you can replay any customer-reported discrepancy from the audit log instead of guessing.

Ready to see if this is a fit?

A 15-minute call. No deck, no slides. We talk about what you're shipping and where engineering is the bottleneck. Either way, you walk away with a senior engineer's read on your situation.