← Larameter 8 / 12

Charging and topping up

Two buckets, and the plan goes first. Spending draws on the plan allowance and falls through to purchased credits for the overflow. Topping up fills the second bucket, which sits outside every window.

Credits out

$org->credits()->charge('create_form');                    // fixed price by name
$org->credits()->meter('gpt-4o', 'token', $in, $out);      // priced per unit

Both return the UsageRecord that was written, and both take the same optional context:

$org->credits()->charge(
    'generate_report',
    actor: $user,          // who triggered it. Null for scheduled work
    subject: $report,      // what it was about, for tracing spend back to it
    credits: 40,           // an explicit amount, overriding the price table
    metadata: ['pages' => 12],
);

actor and subject are both nullableMorphs, so they take any model and the package never learns what you call them. They are the difference between a usage table you can invoice from and one you can only total.

Charging does not refuse

An account with nothing left still gets its usage row, recorded as an overdraft. A half finished turn is worse than a small negative number: the work was done, the provider was paid, and refusing at the point of writing the row would lose the record of it without undoing the cost.

So refusal is the caller's job, and asking has to be cheap:

if (! $org->credits()->allows('generate_report')) {
    return back()->with('error', 'Not enough credits.');
}

See Reading usage.

The split, and why it is stored

Every usage row records where its credits came from:

credits What it cost.
credits_from_plan Covered by the allowance.
credits_from_purchased Covered by the top-up balance.

The two adding to less than credits is exactly when the account overdrew, which is how an overdraft stays visible instead of being rounded away.

The split is recorded rather than recomputed because rates and plans change, and a bill from last March has to still add up next year. Recomputing it would mean re-pricing history against today's table, which quietly rewrites what a customer was charged.

How a charge lands

Account::apply() runs in a transaction with the account row locked:

  1. Take the headroom, which is the tightest window's remaining allowance.
  2. Pay as much as possible from the plan, up to that headroom.
  3. Add that amount to every declared window, creating the row if it does not exist, restarting it first if it expired.
  4. Pay the overflow from purchased_credits, up to what is there.
  5. Anything still unpaid is the overdraft, and nothing goes negative.

Step 3 is the one worth reading twice. Spending is charged against all windows at once, not against the one that happened to bind, so the session, the week and the month all move together.

Credits in

$org->credits()->deposit(5_000, reason: 'purchase', source: $payment);
$org->credits()->deposit(500, reason: 'gift', note: 'launch promo');
$org->credits()->deposit(-200, reason: 'adjustment', note: 'duplicate charge');

One call, two tables: the deposit row and the balance move together and cannot be written apart.

reason is a free string, because a package should not have to ship a migration every time an application finds a new reason to hand somebody credits. purchase, gift, refund and adjustment are conventions, not an enum.

Negative is allowed, which is how a correction is written, and the balance clamps at zero rather than becoming a debt nobody can spend their way out of. The deposit row keeps its negative value regardless, so the history still reads in one direction.

source is a nullableMorphs: the Stripe payment, the order, the administrator who did it by hand.

Purchased credits sit outside every window

They survive every reset, and, this is the part that matters, what they pay for is not counted against the windows. Only credits_from_plan is added to credits_used.

So somebody who runs out of session, buys more usage and carries on has not moved their week meanwhile. Without that rule, a top-up would buy time now and take it away later, which is the opposite of what the customer thought they were paying for.

The observers

Neither the balance nor the split is written by the tracker. Two observers do it:

UsageRecordObserver::created Calls apply() and writes the split back with saveQuietly().
DepositObserver::created Moves purchased_credits, clamped at zero, with the account row locked.

That is deliberate, and it is what makes the tables trustworthy. A usage row written by any route at all still moves the balance: a backfill, a console command, an application pricing something the package never hears about. You cannot record consumption that nobody is charged for, nor hand out credits that never reach the balance.

The one consequence to know about: a historical import through the model will apply every row to today's balance. Insert with the query builder instead, and set the split yourself. See Installation.

Reading the history

$org->credits()->records();    // Builder over larameter_usage for this account
$org->credits()->deposits();   // Builder over larameter_deposits

Both are plain Eloquent builders, so the usual applies:

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

To break a window down further, per person or per operation, start from startedAt() on the window rather than from a calendar month, or the total will not match what the screen shows:

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

$org->credits()->records()->where('created_at', '>=', $since)->sum('credits');