Skip to content
Revenue-critical code cover
writing

Subscriptions are just licensing that also has to bill you on time

A subscription is a state machine bolted to a billing clock. Dunning, grace periods, and proration are where it leaks money. Here is how I model each.

Hasan MisbahHasan Misbah9 min read
subscriptions
licensing
billing
state-machine
laravel
TL;DR

A subscription is a licensing state machine with a billing clock bolted on, and it leaks money in three places: dunning, grace periods, and proration. Fix each by keeping access computed, history append-only, money in cents, and time on the server.

A subscription is a licensing system with a billing clock attached. The hard part was never “can this account use the product,” which is the licensing question I have answered many times in WordPress plugins. The hard part is keeping that answer correct while a payment clock ticks in the background and occasionally misfires. If you model a subscription as a status column instead of a state machine, it will leak money in three predictable places: dunning, grace periods, and proration. This post walks each one.

Start from the licensing question, then add the clock

I build license-key systems, so I am used to reducing entitlement to one function: given this account and this moment, is access allowed? A subscription is that same function with a payment obligation layered on. The mistake is letting billing events write access directly. A failed charge should not immediately flip the customer to “no access,” and a successful charge should not be the only thing that grants it. Access is derived from state. Billing events move the state.

So the first thing I do is name the states honestly:

trialing -> active -> past_due -> grace -> canceled
                          \-> active (recovered)
                grace     -> canceled (dunning exhausted)

Every transition is an event with a reason and a timestamp, written to a log, never an in-place overwrite of a status string. When a customer disputes why they lost access, I want to replay the sequence, not guess from a single mutable field. This is the same discipline I use for license state: entitlement is computed, its history is append-only.

Where it leaks, part one: dunning

Dunning is the retry schedule for a failed renewal, and it is the single biggest revenue leak in naive subscription code. The failure mode is binary thinking: the charge failed, so cancel. But most failed renewals are not customers leaving. They are an expired card, a temporary hold, or a bank declining a foreign transaction. Cancel on the first failure and you are firing paying customers over a card that would have worked on Tuesday.

The fix is a retry schedule with backoff, driven by a scheduled job, that only gives up after the schedule is exhausted:

// Retry a failed renewal on a widening schedule, in days.
const DUNNING_SCHEDULE = [1, 3, 5, 7];

public function attemptRenewal(Subscription $sub): void
{
    $result = $this->gateway->charge($sub->amount_cents, $sub->renewal_key);

    if ($result->succeeded()) {
        $sub->transitionTo('active', reason: 'renewal_paid');
        return;
    }

    $attempt = $sub->dunning_attempt + 1;

    if ($attempt > count(self::DUNNING_SCHEDULE)) {
        $sub->transitionTo('canceled', reason: 'dunning_exhausted');
        return;
    }

    $sub->update([
        'status'          => 'past_due',
        'dunning_attempt' => $attempt,
        'next_retry_at'   => now()->addDays(self::DUNNING_SCHEDULE[$attempt - 1]),
    ]);
    $this->notifyPaymentFailed($sub, $attempt); // and give them a link to fix the card
}

Two things earn their keep here. The widening schedule (1, 3, 5, 7 days) spreads retries across the customer’s likely payday and gives a declined bank time to relent. And renewal_key is a stable idempotency key, so if the charge actually succeeded but the response was lost, the next attempt does not bill them twice. Dunning that double-charges is worse than dunning that gives up early, because now you have created the chargeback yourself.

The principle I hold is that dunning is a recovery mechanism, not an eviction notice. Widening the gaps between retries almost always recovers more than tightening them, because you are waiting for paydays to arrive and bank holds to clear, not punishing a customer for a card that would have worked in a few days.

Where it leaks, part two: grace periods

A grace period is the gap between “payment is late” and “access is gone.” Set it to zero and you punish good customers for a bank’s timing. Set it to infinite and you give the product away. This is the exact tradeoff I live with in offline license checks, where the activation server can be briefly unreachable and I still owe the paying customer a working product.

The rule I use: fail open for a bounded window, then fail closed. Access stays on through past_due and a defined grace window, and only flips off when dunning is genuinely exhausted:

public function hasAccess(Subscription $sub, Carbon $now): bool
{
    return match ($sub->status) {
        'trialing', 'active' => true,
        'past_due'           => true, // still retrying, keep them in
        'grace'              => $now->lessThan($sub->grace_ends_at),
        default              => false, // canceled, expired
    };
}

The number in grace_ends_at is a business decision, not an engineering one, and it should be a config value someone can change without a deploy. What is not negotiable is that the window is measured against server time, never the client’s clock. A license or subscription that reads the device clock for its grace math can be defeated by rolling the clock back, and I have seen that attempted. Server time makes the money decision. The client only caches the last answer.

Where it leaks, part three: proration

Proration is what you owe when someone changes plans mid-cycle. Upgrade on day ten of a thirty-day month and you charge for the unused twenty days at the new rate, credit the unused twenty at the old rate, and the difference is what they pay today. Get the arithmetic wrong and every plan change either overcharges (support ticket) or undercharges (silent leak), and at scale the silent leak is the expensive one because nobody complains about being charged too little.

The mistakes are always the same two: floating point, and counting the boundary day twice. I do proration in integer cents, on whole days, with the seconds truncated so a change at 11pm and a change at 1am on the same day behave identically:

public function prorationCents(Subscription $sub, Plan $newPlan, Carbon $now): int
{
    $periodDays  = $sub->current_period_start->diffInDays($sub->current_period_end);
    $usedDays    = $sub->current_period_start->diffInDays($now); // whole days
    $unusedDays  = max(0, $periodDays - $usedDays);

    $creditCents = intdiv($sub->plan->amount_cents * $unusedDays, $periodDays);
    $chargeCents = intdiv($newPlan->amount_cents  * $unusedDays, $periodDays);

    return $chargeCents - $creditCents; // may be negative: that is a credit, not a charge
}

The negative case is the one juniors forget. A downgrade produces a credit, and a credit is not a refund you push to a card, it is a balance you carry to the next invoice. If your code assumes proration is always a positive charge, downgrades silently drop money on the floor or, worse, try to “refund” a credit that was never a real payment.

The unifying view

Licensing answers one question: is access allowed right now. Subscriptions answer the same question while a billing clock runs and sometimes fails. Every leak I have named is a place where a billing event was allowed to overwrite access instead of nudging a state machine that derives it. Keep access computed, keep the history append-only, keep money in integer cents, keep time on the server, and let dunning, grace, and proration each be an explicit, testable transition.

Do that and a subscription stops being a fragile status column and becomes what it always was: licensing that also has to bill you on time, and get both right at once.

The takeaway

Every subscription leak I have named comes from the same mistake: letting a billing event write access directly instead of nudging a state machine that derives it. Answer the licensing question with computed access, move the state with events you log, keep money in integer cents, and keep time on the server, and dunning, grace, and proration each become an explicit, testable transition. That is the difference between a subscription that quietly bleeds revenue and one that gets both jobs right at once.

FAQ

Why treat a subscription as a state machine rather than a status column?

Because access has to stay correct while a billing clock ticks and sometimes misfires, and a single mutable status field cannot record why a transition happened. Deriving access from logged state lets you replay the sequence when a customer disputes losing access.

Should a failed renewal charge cancel the subscription immediately?

No. Most failed renewals are an expired card, a temporary hold, or a bank declining a foreign transaction, not a customer leaving, so cancel on the first failure and you fire paying customers over a card that would have worked days later.

What stops dunning retries from double-charging?

A stable idempotency key on the renewal, so if a charge actually succeeded but the response was lost, the next attempt is a no-op at the gateway. Dunning that double-charges is worse than dunning that gives up, because you create the chargeback yourself.

How long should a grace period be?

That is a business decision, so make it a config value someone can change without a deploy. The engineering rule is fixed: fail open for a bounded window, then fail closed, and measure the window against server time so it cannot be defeated by rolling the client clock back.

Is proration always a charge?

No, and the negative case is the one juniors forget. A downgrade produces a credit, which is a balance you carry to the next invoice, not a refund you push to a card, so code that assumes a positive charge silently drops that money.

How is this different from plain licensing?

Licensing answers one question: is access allowed right now. A subscription answers the same question while a billing clock runs and occasionally fails, so it is licensing plus dunning, grace, and proration done correctly at the same time.

let's talk

Building something where this kind of work matters?

I'm in GMT+6 and work async-first, so your timezone is never a reason not to reach out.

Get in touch
Copyright © Hasan Misbah. All rights reserved.
Hasan Misbah