← Larameter 10 / 12

Reading usage

Everything a usage screen needs, and everything a guard clause needs, without any of it writing to the database.

That last part is not a detail. For a rolling window the row is the clock, so a read that opened a window would spend five hours of somebody's session on a page they opened to look at their balance. Nothing on this page creates or restarts anything. See Windows.

Before spending

$org->credits()->allows();                     // is there one credit left?
$org->credits()->allows(250);                  // are there 250?
$org->credits()->allows('generate_report');    // enough for what that action costs?

allows() takes a number of credits or the name of an action, so the caller never has to look a price up first. If asking is awkward, nobody asks, and an unchecked ceiling is the bug this package exists to stop.

The answer is remaining() >= $credits, which is the plan headroom plus purchased credits, so a top-up counts.

What things cost

$org->credits()->price('send_email');                 // 1, from the price table
$org->credits()->meterPrice('gpt-4o', $in, $out);     // what a metered call would cost

Neither charges. meterPrice() uses exactly the same rate resolution as meter(), wildcard and fallback included, so the figure you show and the figure you charge cannot drift. See Prices and rates.

Balances

remaining() Headroom in the tightest window, plus purchased credits. What a customer thinks of as their balance.
headroom() The plan only, tightest window. What is left of the allowance.
allowanceIn('weekly') What the plan grants there, before anything is spent.
resetsAt() When spending becomes possible again, or null if nothing is blocking.

headroom() is PHP_INT_MAX when no windows are declared, and skips any window whose allowance is negative, since an unlimited window narrows nothing.

resetsAt() returns null whenever there is headroom left or purchased credits in hand, because in that case nothing is blocking and a date would be misleading. Only when both are exhausted does it answer, with the earliest end among the windows that actually cap.

One window, and all of them

$weekly = $org->credits()->in('weekly');
$all    = $org->credits()->windows();     // keyed by window key

in() throws an InvalidArgumentException naming what is declared when you ask for a window that is not, rather than returning an empty one. A typo in a window key is a mistake, not a state.

windows() always returns one entry per declared window, whether a row exists or not, so a screen never has to handle the account that has never spent anything.

Each is a read-only WindowUsage:

foreach ($org->credits()->windows() as $window) {
    $window->key;            // 'weekly'
    $window->allowance();    // 12_500
    $window->used();         // 12_000
    $window->remaining();    // 500, or PHP_INT_MAX when unlimited
    $window->percentUsed();  // 96.0, and 0.0 when unlimited
    $window->isUnlimited();
    $window->startedAt();    // Carbon, or null when none is running
    $window->endsAt();       // Carbon, or null when none is running
    $window->toArray();
}

Three behaviours worth knowing:

  • An expired window reports as full. used() is zero once the window has run out, because the allowance is back even though nothing has restarted the row yet.
  • endsAt() is not what the row says for an expired fixed window. The grid ran on without it, so a row claiming a Monday three weeks back answers with the Monday coming, which is the only answer a screen can show.
  • Both dates are null for a rolling window that has expired, and for one that has never been charged. In both cases no window is running, and the next one starts whenever spending resumes.

percentUsed() returns 0.0 for an unlimited or zero allowance, so a progress bar does not have to special case either.

Building the screen

public function render()
{
    return view('billing.usage', [
        'plan'    => $this->organization->plan(),
        'windows' => $this->organization->credits()->windows(),
        'quotas'  => $this->organization->quota()->summary(),
        'balance' => $this->organization->credits()->remaining(),
        'resets'  => $this->organization->credits()->resetsAt(),
    ]);
}

That is the whole page: what was bought, what has been spent, and how many of things exist. See Quotas and meters.

To break a window down further, per person or per operation, count from the window's own start rather than from a calendar month, or the total will not match the figure beside it:

$since = $org->credits()->in('weekly')->startedAt();

$org->credits()->records()
    ->when($since, fn ($q) => $q->where('created_at', '>=', $since))
    ->selectRaw('operation, SUM(credits) as credits')
    ->groupBy('operation')
    ->get();

Hot paths

UsageTracker answers the same questions for a model directly, and has one extra:

app(UsageTracker::class)->hasCreditsMemoized($org);

which answers once per instance, for a loop that asks repeatedly. It does not notice spending that happens afterwards, deliberately: a turn that starts with credit finishes, and the overshoot is bounded to one turn rather than leaving work half done in the middle.

The binding is scoped, not a singleton, so a queue worker does not keep one turn's answer alive across every job it goes on to process.

Eager loading

credits()->account() is not memoised, because the observer that moves the balance works on its own copy of the row, and holding one here would answer with a balance from before the last charge.

For a list of accounts, eager load the relation instead. Credits uses an already loaded meterAccount when there is one:

$orgs = Organization::with('meterAccount.windows')->get();

foreach ($orgs as $org) {
    $org->credits()->remaining();   // no query per row
}

Without windows on the end you get one query per account for its windows, which is the usual N+1 and the reason the relation is nested there.