← Larameter 11 / 12

The data model

Four tables, all prefixed larameter_, all cascading from the account. The published migration stub is heavily commented and reading it is the fastest way to understand what the package stores.

larameter_accounts    what does not expire: the plan, and credits bought on top
larameter_windows     what the plan has covered, per window, per account
larameter_deposits    credits in: purchases, gifts, refunds, adjustments
larameter_usage       credits out, append-only

Why the balance is stored

It starts as a SUM() over the usage table, and that is correct right up to the day you sell a top-up.

A purchase is not consumption, so it cannot be expressed as a sum of what was spent. Once credits can arrive as well as leave, the balance is a running total of two opposing streams, and the aggregate has to become a column.

Storing it is therefore not an optimisation but the model itself. What keeps it honest is that both streams have their own table, so the number stays checkable, which is what you want the first time somebody asks why an account has five thousand credits.

larameter_accounts

One row per thing you bill, created on first sight, holding only what does not expire.

meterable_type, meterable_id Whatever you bill. Morphs, so the package never learns what you call it. Unique together.
plan A plan handle, nullable. A fallback, not the source: with HasPlans it is only reached when no provider answers.
purchased_credits Bought, gifted or adjusted. Sits outside every window. A cache of the deposits table.

Consumption is deliberately not here. It lives in the windows, because an allowance is always an allowance per something, and how long that something lasts is your decision rather than this package's.

Account::for($model) is the accessor, and it handles the race: firstOrCreate inside a try, falling back to a re-read on the unique constraint, so two simultaneous first charges do not blow up.

larameter_windows

One row per account per declared window, created the first time that window is actually charged.

account_id Cascades on delete.
key session, weekly, whatever you declared. Unique with the account.
credits_used What the plan has covered in this window.
started_at When the window running now began.

Created on charge and never on read. For a rolling window the row is the clock, and writing one starts the five hours.

Credits paid for out of the purchased bucket are deliberately not counted here. They are extra usage on top of the plan, so they must not eat the next window too.

A row is not deleted when its window expires. It is restarted in place on the next charge: credits_used back to zero, and started_at either to now, for a rolling window, or forward along the grid to the slot containing now, for a fixed one.

larameter_deposits

Credits in. A purchase, a welcome gift, a refund, a correction somebody made by hand because a job failed and it was not the customer's fault.

credits Signed, so an adjustment downwards is the same kind of row as a purchase and the history reads in one direction.
reason A free string. purchase by default.
source_type, source_id Nullable morphs: the payment, the order, the administrator.
note, metadata Yours.

Indexed on account_id, created_at, which is the query a statement makes.

larameter_usage

Credits out. Append-only, and never the source of the balance.

actor_type, actor_id Who triggered it. Null for scheduled work.
subject_type, subject_id What it was about, for tracing spend back to it.
operation create_form for a fixed price, or the priced thing for metered usage, which for a model call is the model name.
unit action, or the metered unit: token, minute, page. A label.
quantity_in, quantity_out Zero for a fixed price.
credits What it cost the account.
credits_from_plan, credits_from_purchased Where it was paid from. Adding to less than credits is an overdraft.

This is what you audit with, invoice from and reconcile against. Deleting from it does not hand anybody their credits back, which is the right way round: the balance is on the account, and a usage row is a record of something that already happened.

The two observers

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

They are observers rather than lines inside UsageTracker so that a 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 nobody is charged for, nor hand out credits that never reach the balance.

Both take a lockForUpdate on the account inside a transaction, so concurrent charges do not lose each other.

Reconciling

The balance should equal what came in, minus what the plan did not cover:

$deposited = $account->deposits()->sum('credits');
$paidFromPurchased = $account->usage()->sum('credits_from_purchased');

$expected = max(0, $deposited - $paidFromPurchased);
$expected === $account->purchased_credits;

max(0, ...) because the balance clamps at zero: a negative adjustment larger than the balance leaves its full value on the deposit row and takes the balance to zero rather than below it.

Overdrafts are found the same way, on the usage side:

UsageRecord::whereColumn(
    DB::raw('credits_from_plan + credits_from_purchased'), '<', 'credits'
)->get();

Models

All four are plain Eloquent models under EduLazaro\Larameter\Models, with the usual relations: Account has many windows, deposits and usage, and morphs to meterable; Deposit and UsageRecord belong to an account and morph to source, actor and subject.

They respect the morph map, so if you register aliases the tables store organization rather than a class name.