Skip to main content

Usage-based billing

A tier can charge for usage on top of its base fee. Declare what it meters in config/billing.php:

'pro' => [
'label' => 'Pro',
'provider_price' => env('BILLING_PRICE_PRO'), // 19 €/month, the base fee
'price_display' => ['amount' => 1900, 'currency' => 'EUR'],

'metered' => [
'emails' => [
'label' => 'Emails sent',
'unit' => 'email',
'provider_price' => env('BILLING_PRICE_EMAILS'), // a metered price
'provider_meter' => 'emails_sent', // the meter it reports into
'package_size' => 1000, // billed per 1 000
'unit_price' => ['amount' => 50, 'currency' => 'EUR'],
'included' => 10000, // first 10 000 free
],
],
],

Then record usage from your own send path — the one call does everything:

app(Pushery\Billing\Support\UsageRecorder::class)->record($team, 'emails', 42_000, sourceKey: "campaign:{$campaign->id}");

It moves the owner's counter (what the usage screen shows) and writes the outbox row the provider is billed from, in a single local write — so what a customer sees and what they are charged for come from the same place. billing:usage:flush (scheduled every minute) reports it.

The parts that would cost real money if they were wrong:

  • A retried job bills once. Pass a sourceKey and the same usage is recorded once, however often your job runs. The key is unique for its owner and its meter, so one campaign can carry the same key for its emails and its texts, and two customers can each have a campaign 17.
  • A retried report bills once. The provider identifier is minted when the usage is recorded and replayed unchanged, so the provider dedups it.
  • A provider outage delays billing; it does not lose it. Reports back off and retry. Usage that truly cannot be reported is marked failed and logged as an error — never dropped quietly.
  • Usage that misses its invoice fails loudly. A period's usage reaches its invoice only while the provider still holds that invoice as a draft: Stripe for an hour after the period ends by default, up to 72 hours if your account says so. Usage that reaches the flush later would be counted in the provider's meter and billed on no invoice, so it is marked failed and logged as an error instead. Set billing.metering.closed_period_grace_minutes to your account's grace.
  • Usage of an owner you soft-delete afterwards is still billed. The usage happened while the owner existed, and a soft delete keeps the row and the provider customer it names, so the flush finds the owner past your model's global scopes. Usage of an owner whose row is gone is marked failed and logged.
  • Usage is billed into the cycle it happened in. record() takes an occurredAt (now by default), and the report carries that moment, so a late flush does not move the usage into a later cycle. A provider takes an event only within a window around its own clock, Stripe up to five minutes ahead, so usage stamped with a moment that has not come yet stays in the outbox and is reported once it has.
  • Usage is reported raw. The allowance and the packaging live in the provider's price and are applied once, to the cycle's total. Configure the same included / package_size on the provider's price (a graduated tier priced at 0 up to the allowance) — the values in config drive the gauge. billing:meters:check fails when the two disagree, and when the tier promises an included that the price does not give away at all: a price that is not graduated, or whose first tier charges per unit, bills from the first unit.
  • Usage follows the subscription's cycle, not the calendar month, so an owner who renews on the 17th is billed for the right window. Usage recorded after a cycle ended and before the next one is written, while its payment is still being collected, counts into the cycle that follows.

A tier that bills for usage on a driver that cannot report usage refuses to boot, rather than counting every unit and invoicing none of them.

Quota enforcement​

A billing.quota:<meter> middleware (and a UsageGate) applies the meter's policy: a hard-stop / refuse meter is blocked past its allowance, a degrade meter still serves but is flagged, a fair-use meter never blocks. For a limit that must not be oversold, meter the work through UsageRecorder::meter() — it holds the allowance under a row lock before the work runs, records only what the work actually consumed, and hands the rest back. The middleware is the cheap pre-check in front of it: on its own it is a point-in-time read, and two simultaneous requests can both pass a boundary check.

Prepaid units (an add-on that grants usage, not credit)​

An add-on can hand the owner units of a meter instead of money:

'addons' => [
'extra_emails' => [
'label' => 'Extra emails',
'provider_price' => 'price_...',
'price_display' => ['amount' => 3000, 'currency' => 'EUR'],
'grants' => ['meter' => 'emails', 'units' => 1000],
],
],
  • The free allowance is spent first, the bought units only after it — never the other way round, or the customer's own units burn while free ones sit unused.
  • included expires with the cycle; prepaid never does. Paid is paid, so unused units roll forever.
  • Prepaid-covered usage is netted out before the provider is told. The provider's price knows nothing about prepaid, so reporting the raw total would bill the customer a second time for units they had already bought. (The tier's included allowance is not netted locally — that one lives in the provider's price.)
  • A refund claws back only what is left. Units already consumed delivered their value and stay spent; the unused remainder comes back, proportionally to the money returned. When a chargeback the bank takes back had caused the clawback, exactly the units it took are given back.
  • The reservation lock defends the prepaid balance too, so a bought unit is never handed to two concurrent requests.

Read a balance with app(Pushery\Billing\Support\PrepaidLedger::class)->balance($owner, 'emails').

Grants that expire​

Units you give rather than sell, such as the allowance a plan renews each period or the credits of a campaign, are a grant: they run out at a moment you set. Give them with UsageGrants::grant():

use Pushery\Billing\Support\UsageGrants;

app(UsageGrants::class)->grant($user, 'emails', 10_000, now()->endOfMonth(), reference: 'plan:'.now()->format('Y-m'));
  • What expires is spent first. Past the tier's included allowance, usage draws on the grants, the one that expires first first, and only then on the bought units. Drawn the other way, a grant would expire unused while units that never expire were spent.
  • An expired grant is written off. billing:usage:flush, which the package schedules every minute, sets what is left to zero, keeps it in expired_units and records the loss as usage.grant_expired in the billing event log.
  • reference makes a grant idempotent per owner and meter: a renewal that runs twice for the same period gives once and answers the first grant.
  • Grant-covered usage is netted out before the provider is told, like prepaid-covered usage, and an erasure takes the grants with their owner. UsageGrants::available() answers what is left of an owner's unexpired grants.

A grace on a blocking meter​

A hard-stop or refuse meter refuses at the ceiling, which cuts off work that is already under way at its last unit. grace lets a request take that many units past the allowance:

'metered' => ['emails' => ['included' => 10_000, 'policy' => 'hard_stop', 'grace' => 200]],

What the grace covers is an overdraft, owed on the owner's balance row as debt. The next grant pays it off before any of its units can be spent, a bought one as well as one that expires, and an owner never owes more than the grace at once: a request that would overdraw further is refused as before. Without grace, or with 0, the meter refuses at the ceiling, and non-blocking meters keep billing past their allowance instead.

A spend cap per customer​

A customer can cap what usage past the included allowance may cost them per billing cycle. Spend caps are off until BILLING_USAGE_SPEND_CAP=true. Then a customer who never set a cap of their own gets billing.usage.spend_cap.default, in minor units of billing.currency, and its default of 0 allows no usage past the allowance at all: the included units stay free, and nothing beyond them is billed.

Set a customer's own cap, or return them to the default with null:

use Pushery\Billing\Support\SpendCaps;
use Pushery\Billing\ValueObjects\Money;

app(SpendCaps::class)->set($team, Money::of(2500, 'EUR')); // 25 EUR per cycle past the allowance
app(SpendCaps::class)->set($team, null); // back to the default

What counts as spent is what the cycle's usage past every allowance costs at each meter's unit_price: past included, past the grants and the prepaid units, a started package charged in full, the way the local billing engine bills it. Units held for requests in flight count as spent. A meter without a unit_price costs nothing here.

  • UsageRecorder::meter() enforces the cap. A request is measured against it, and its hold is taken, under a lock on the customer's cap row, so two requests on two meters cannot both pass it. A request that does not fit throws SpendCapExceeded, whatever the meter's policy. It is a QuotaExceeded, so code that handles a refused quota handles it too, and it carries what the request would cost and what is left.
  • UsageGate and the billing.quota:<meter> middleware ask the same question as a cheap read, and QuotaDecision::$remainingSpend is what is left under the cap.
  • record() is never refused. The usage has already happened, so it is counted whatever the cap says.

UsageSpendCapReached fires the first time in a cycle that the cap stops a request, or that usage recorded past it takes the spend over it, so the app can pause what costs money. Raising the cap fires UsageSpendCapRaised, and the next stop is announced again:

use Illuminate\Support\Facades\Event;
use Pushery\Billing\Events\UsageSpendCapRaised;
use Pushery\Billing\Events\UsageSpendCapReached;

Event::listen(UsageSpendCapReached::class, fn (UsageSpendCapReached $event) => $event->owner->update(['assistant_paused' => true]));
Event::listen(UsageSpendCapRaised::class, fn (UsageSpendCapRaised $event) => $event->owner->update(['assistant_paused' => false]));

The cap stands in one currency. Usage of a meter priced in another currency cannot be measured against it, and is refused once it would cost something.

What the usage screen can show​

The shipped usage screen renders whatever the history provider hands it, and two of those things are optional: a project supplies them if it can, and the screen leaves the section out if it cannot. Neither is computed here, and that is deliberate — both need history this package does not keep.

A forecast, when the project can count backwards​

MeteredDimension carries two optional fields, ratePerDay and exhaustedAt. Fill them in your provider and the gauge gains a "at this rate, you run out on …" line; leave them null and it renders exactly as before. hasForecast() is true only when both are present, because half a forecast is worse than none — a rate with no date makes the reader do the arithmetic, and a date with no rate cannot be sanity-checked.

The package does not derive the rate from its own counters. "Per day" is a claim about how a project counts: a rate over the last 7 days and one over the whole cycle disagree after a quiet weekend, and only the project knows which one it means. A negative rate throws at construction — usage does not run backwards.

A movement stream, when the project keeps one​

The history screen shows periods and totals. A customer asking "why did my usage jump on the 14th?" needs the rows behind the total, and that is a separate contract:

use Pushery\Billing\Contracts\SuppliesUsageMovements;

class MyUsageHistory implements UsageHistoryProvider, SuppliesUsageMovements
{
public function movements(Model $owner, int $perPage = 20, int $page = 1): LengthAwarePaginator
{
// Your own rows: one signed delta each, newest first.
}
}

Implement it beside UsageHistoryProvider and the screen gains a paginated list; implement only UsageHistoryProvider and it does not, with nothing else changing. It is a separate contract rather than another method on UsageHistoryProvider for the same reason as RoutesMoney: that interface is one you implement, so a new method on it would be a fatal error in your code the day you updated this package.

Each movement is one signed delta — never zero — plus the reference that caused it, so a consumption and a credit are the same shape read in opposite directions.

This package's own history deliberately does not implement it, and the reason is worth knowing before you reach for it. billing_usage_events has a row per consumption, but a prepaid grant only increments a balance — there is no row per credit. A stream built from what is on hand would show every unit spent and no unit bought, which reads as a billing error and is really a missing half. If your own ledger records both sides, yours can answer what this one cannot.


← Back to the documentation index