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:
- Take the headroom, which is the tightest window's remaining allowance.
- Pay as much as possible from the plan, up to that headroom.
- Add that amount to every declared window, creating the row if it does not exist, restarting it first if it expired.
- Pay the overflow from
purchased_credits, up to what is there. - 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');