DEV3 Technologies home

Plan-change proration

Pure integer maths for mid-cycle upgrades and downgrades: credit unused days of the old plan, charge remaining days of the new one.

LaravelPaymentsbillingprorationsubscriptionsmoney

Code

<?php

namespace App\Billing;

use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;

/** All amounts are integer minor units (cents). No floats touch money. */
final class Proration
{
    /**
     * @return array{credit:int, charge:int, net:int, action:string, days_left:int, days_total:int}
     */
    public static function quote(
        int $oldPrice,
        int $newPrice,
        CarbonInterface|string $periodStart,
        CarbonInterface|string $periodEnd,
        CarbonInterface|string|null $today = null,
    ): array {
        $start = CarbonImmutable::parse($periodStart)->startOfDay();
        $end = CarbonImmutable::parse($periodEnd)->startOfDay();
        $now = CarbonImmutable::parse($today ?? 'now')->startOfDay();

        $total = max(1, (int) $start->diffInDays($end));
        // Clamp so a change made before the start or after the end never goes out of range.
        $left = max(0, min($total, (int) $now->diffInDays($end, false)));

        $credit = self::share($oldPrice, $left, $total); // unused part of what was paid
        $charge = self::share($newPrice, $left, $total); // same period at the new rate
        $net = $charge - $credit;

        return [
            'credit' => $credit,
            'charge' => $charge,
            'net' => $net,
            'action' => match (true) {
                $net > 0 => 'invoice',      // upgrade: bill the difference now
                $net < 0 => 'wallet',       // downgrade: store the difference as credit
                default => 'none',
            },
            'days_left' => $left,
            'days_total' => $total,
        ];
    }

    /** price * left / total rounded half up using integers only. */
    private static function share(int $price, int $left, int $total): int
    {
        return intdiv($price * $left * 2 + $total, $total * 2);
    }
}

How it works

What it solves

When a customer switches plan halfway through a billing period, they should not pay twice for the same days or lose what they already paid. Proration is simple in principle, but easy to get subtly wrong with floating point numbers and edge dates.

How it works

quote() takes the old and new recurring prices and the current period dates. It counts the total days in the period and the days still left from today to the end date, clamping the remainder between zero and the total so out-of-range dates cannot create negative or inflated amounts.

The old plan's unused share becomes a credit, the new plan for the same remaining days becomes a charge, and the difference is the net amount. A positive net is an upgrade to invoice immediately, a negative net is a downgrade whose difference is stored as account credit, and zero means nothing happens. The returned action string lets the caller branch without repeating the comparison.

Money stays in integer cents. share() multiplies first and rounds half up with integer division, so there is no float drift and the result is identical on every platform.

Adapting it

  • Credit unused days only down to a minimum, for example refuse a negative net below a threshold.
  • Count the change day as used by clamping with max(0, ...) after subtracting one.
  • Add tax by applying the rate to charge and credit separately for correct tax lines.

Pitfalls

  • Decide once whether the current day counts as used and keep the rule consistent.
  • Pass dates in the customer's billing timezone, otherwise the day count shifts around midnight.

How to use it

  1. Put Proration.php in app/Billing/.
  2. Quote a change: Proration::quote(2900, 7900, $sub->period_start, $sub->period_end); (prices in cents).
  3. If action is invoice, create an invoice line for net; if wallet, add abs(net) to the customer's credit balance.
  4. Show credit, charge and days_left on the confirmation screen before applying the change.
  5. Unit test with fixed dates by passing the $today argument.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets