SaaS Billing Edge Cases: Proration, Credits, and Failed Payments That Break Your Revenue Model

#saas billing edge cases
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Most Stripe tutorials end where billing problems begin. Creating a subscription, receiving a webhook, and updating a database row is a weekend project. The SaaS billing edge cases that actually damage a business show up later: a customer upgrades mid month and the invoice looks wrong, a card fails silently and the account stays active for three months, a refund lands in a different quarter than the revenue it reverses. None of these are exotic. Every subscription business hits all of them within the first year, and the ones that have not prepared end up reconstructing their revenue history from Stripe exports and support tickets.

This post walks through the edge cases we see most often in billing code audits and what correct handling looks like for each.

Why SaaS billing edge cases deserve their own design pass

Billing bugs differ from ordinary bugs in one important way: they are visible to your customers and your accountant before they are visible to you. A broken API endpoint throws errors into your monitoring. A wrong proration calculation produces a syntactically valid invoice that a customer pays without complaint, and the mistake only surfaces when someone reconciles Stripe payouts against recognized revenue. By then the incorrect data has propagated into financial reports.

The common thread through everything below is that the subscription state in your database is a projection, and Stripe is the source of truth for money. When the two disagree, your projection is wrong. Design every edge case handler around that assumption.

Proration on mid period upgrades and downgrades

When a customer on a 50 euro monthly plan upgrades to a 100 euro plan on day 15, Stripe calculates two line items: a credit for the unused half of the old plan (roughly 25 euro) and a charge for the remaining half of the new plan (roughly 50 euro). The word "roughly" is doing real work there. Stripe prorates by the second, so the exact amounts depend on the timestamp of the change, and your own back of the envelope math will be off by a few cents. Do not recompute proration yourself and compare it to Stripe's numbers with strict equality. Either trust the invoice line items or compare within a tolerance.

The more consequential decision is when the proration gets collected. With the default behavior, proration items sit on the upcoming invoice and get charged at the next renewal. With proration_behavior combined with an immediate invoice, the customer pays the difference right away. Immediate payment is usually right for upgrades, because the customer gets more value starting now. For downgrades, most teams schedule the change for the end of the period instead of prorating at all, which avoids issuing credits and keeps the accounting simple. Whatever you choose, choose it explicitly. The teams that get burned are the ones who never made the decision and shipped the default without knowing what it was.

Free trial conversion and the first invoice

Trial to paid conversion looks trivial until tax enters the picture. During a trial there is no invoice, which means you may not yet have validated the customer's tax location. The first paid invoice is where VAT or sales tax gets calculated, and if the billing address or tax ID was never collected, that invoice can be wrong in a way that is tedious to correct, since tax authorities do not accept "we fixed it in the next invoice" as a correction method. Collect the billing address and any VAT ID before the trial starts, even though it adds friction, or at minimum block conversion until the data exists.

Trial extensions are the other trap. Extending a trial by moving trial_end forward is fine once. Doing it repeatedly through support requests creates subscriptions whose trial has been extended five times, and your analytics now count a nine month old signup as a fresh trial. Track extensions as first class events so your conversion metrics stay honest.

Failed payments and dunning

Treat a failed renewal payment as the start of a process, and give the process an owner. Stripe's Smart Retries will reattempt the charge at machine chosen intervals over a configurable window, which outperforms any fixed schedule you would write yourself. What Stripe does not decide for you is what happens to the account in the meantime.

The pattern that works: keep full service through the first retry window, degrade to read only access after a defined number of days past due, and cut off access only after the subscription reaches its final state. Immediate cutoff on first failure is a mistake, since a large share of failures are temporary (expired card, monthly limit reached, bank level declines) and recover on retry without the customer ever knowing. Silent indefinite grace is the opposite mistake, and it is more expensive: we have audited systems where accounts ran for months after the last successful charge because nobody wired the invoice.payment_failed webhook to anything.

Send your own dunning emails rather than relying only on Stripe's, because your emails can link to your billing portal, mention what the customer will lose, and match your product's voice. And record every state transition of the dunning process in your own database. When a customer disputes a cutoff, "the webhook fired on the 14th and we degraded access on the 21st per policy" is a defensible answer. "Stripe handled it" is not.

Credits and refunds

Credits sound simple and are not. When a customer holds both a promotional credit and a proration credit, the application order determines how much actual money changes hands, and by extension how much revenue you recognize. Stripe applies customer balance credits before charging the payment method. If you also layer coupons on top, work through one concrete invoice by hand before shipping, and put the expected outcome in a test.

Refunds have a tax dimension that engineers routinely miss. A partial refund of an invoice that included VAT must refund the proportional VAT as well, and the credit note that documents this is a legal document in most of the EU. If you issue refunds through your admin panel via the raw refund API without generating credit notes, your accountant will eventually have a very bad week. Use Stripe's credit note mechanism instead of bare refunds for anything that touched a tax inclusive invoice.

Promotional credits should expire. Credits without expiry accumulate as a liability on your books that grows forever, and finance will ask engineering to reconstruct the outstanding balance eventually. Store an expiry date from day one, even if the first version never enforces it.

Annual plans, cancellations, and multi currency

Annual subscriptions concentrate a year of revenue into one payment, which makes their cancellation policy a financial decision rather than a UX preference. Decide upfront whether a mid year cancellation refunds the remaining months, converts them to credit, or simply runs out the term, and write it into your terms of service. From an accounting perspective the annual payment is deferred revenue recognized monthly, so a month seven refund reverses five months of unrecognized revenue. Your event log (next section) should make that calculation mechanical.

Multi currency adds a quieter class of problems. If you price in EUR and USD, a customer's invoices exist in their currency while your reporting exists in your base currency, and the exchange rate at invoice time differs from the rate at payout time. Store the original currency amount, the settlement amount, and the rate used, per transaction. Trying to recover historical exchange rates later, for a reconciliation someone urgently needs, is painful and imprecise.

An event log that survives audits

Everything above becomes manageable with one architectural decision: record every billing state change as an immutable event in your own database, instead of storing only the current state.

CREATE TABLE billing_events (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    occurred_at     TIMESTAMPTZ NOT NULL,
    recorded_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    customer_id     UUID        NOT NULL,
    subscription_id TEXT,
    event_type      TEXT        NOT NULL,
    amount_cents    BIGINT,
    currency        CHAR(3),
    base_amount_cents BIGINT,
    exchange_rate   NUMERIC(18, 8),
    stripe_event_id TEXT UNIQUE,
    stripe_object_id TEXT,
    payload         JSONB       NOT NULL,
    UNIQUE (stripe_event_id)
);

CREATE INDEX idx_billing_events_customer
    ON billing_events (customer_id, occurred_at);

A few deliberate choices in that schema. The stripe_event_id unique constraint makes webhook ingestion idempotent, so a redelivered event inserts nothing. Separating occurred_at from recorded_at lets you distinguish when something happened from when you learned about it, which matters for late arriving webhooks. The raw payload column means you can answer questions you did not anticipate at design time. And because rows are never updated or deleted, the table remains trustworthy during a disputed charge, a tax audit, or a due diligence process, all situations where "the current value of a mutable column" convinces nobody.

Rebuild your subscription state from this log in a nightly reconciliation job and compare it against both your application tables and the Stripe API. Discrepancies become alerts instead of surprises.

Where to start if your billing already exists

If you are building billing from scratch, put the event log in before launch and make the proration and dunning decisions explicitly. If you already run a subscription system, the highest value first step is a reconciliation script that compares your database against Stripe for every active customer. In our code audits, that script almost always finds drift, and the size of the drift tells you how urgent the rest of this list is. For teams that want the billing layer rebuilt properly, that is the kind of work we take on as custom software development projects.

Questions about a specific edge case your billing has already hit? Write to hello@wolf-tech.io or reach out through wolf-tech.io/contact. We have probably seen that failure mode before.