Skip to content
Blog

Adding seats in Stripe produced a refund instead of a charge

The customer bought more seats. The invoice for that change came out negative and money moved toward them.

This is the strangest billing failure we have hit first-hand, and it survived months of nobody noticing, because every invoice in the sequence added up on its own terms.

The combination that causes it

Two ordinary things were true at once. The subscription had been created with a start date in the past, which is what backdate_start_date is for and what every migration off an old billing system reaches for. And the subscription carried a coupon.

When you raise the quantity on a subscription item mid-period, Stripe writes two proration lines. One is a credit for unused time on the old quantity. The other is a charge for the remaining time at the new one. Both are marked as prorations and Stripe prorates down to the second. On an increase the charge should be the larger of the two.

What the credit is measured against depends on the subscription’s billing mode. Under billing_mode=classic, Stripe calculates a credit proration from the subscription item’s current price, tax, quantity and the last discounts used, not from the amount that was originally billed. Under billing_mode=flexible it uses the original debit. Stripe’s own worked example of the classic calculation credits time at a price the customer never paid, and the invoice lands at a negative total.

Discounts touch prorations in one direction only. Stripe computes proration credits and debits from the subscription’s discounted price, then marks the proration lines discountable false so no further discount lands on them. A discount on an invoice that mixes prorations with ordinary lines reduces only the ordinary ones.

In our case the initial payment had been computed without the discount and the proration lines were computed with it. The credit and the charge were therefore measured against two different prices. They did not cancel the way the arithmetic assumes, and on a seat increase the delta landed below zero. Buying more produced a refund.

Why nobody catches this

Every invoice involved is internally consistent. Recompute any one of them from its own lines and it agrees with itself.

The party receiving money is the customer, so no support ticket is ever filed. And nobody writes a test asserting that buying more costs more, because it is not the kind of thing that can go wrong.

Months later somebody reconciles a spreadsheet and finds expansion revenue flowing backwards on exactly the accounts that were growing.

Confirming it

Two places to look.

Start with finished invoices. Any invoice whose total is at or below zero, or any customer whose credit balance grew on a day their seat count went up, is worth opening. The proration lines are the ones where parent.subscription_item_details.proration is true. Check whether the credit exceeds the charge.

Then look at the population that can produce it. Subscriptions created with a back-dated start that also carry a discount are the ones to walk. Compare what each expansion actually billed against what the added seats cost for the time remaining in that period.

Fixing it

For the accounts already affected, work out what the expansion should have cost and bill the difference as a one-off invoice item. Be ready to explain it, since the customer has already seen the credit.

On the change path, preview before you commit. POST /v1/invoices/create_preview takes the subscription changes you are about to make and returns the invoice they would generate, without touching the subscription. Pass subscription_details.proration_date on the preview and the same proration_date on the update, or the two calculations drift, because Stripe prorates to the second. A quantity increase that previews as a negative total is something your code can refuse to apply, on the spot, with an alert instead of a refund.

For migrations, keep backdating and discounts apart. A coupon on a backdated subscription starts its duration at the backdated start date rather than at the API call, so a repeating coupon whose duration is shorter than the backdated period is spent before the first live invoice and is removed from the subscription item. Bill the historical period as its own invoice and let the new subscription start now, or attach the coupon after the subscription has reached a normal cycle. The pair is what makes the arithmetic disagree.

Wrong prorations are one instance of a broader class, where Stripe executed correctly against a configuration nobody intended, and other members of that class are here.