Metered Billing
Metered features charge per unit consumed — think AI tokens, SMS sends, or compute minutes. Every
consume deducts units × unit_price from the subscriber's balance.
Tashil never owns the balance. Charges go through a small interface you implement,
MeteredBilling, which bridges to whatever wallet or ledger your app uses. Tashil owns the snapshot,
the counter, and the event log; your app owns the money.
Fails loud by default
Until you bind a real implementation, the default NullMeteredBilling throws
MeteredBillingNotConfiguredException on every charge — so a misconfigured metered feature surfaces
immediately instead of silently dropping money.
Defining a metered feature
The pivot value for a metered feature is the unit price (a decimal string) in the plan's
currency:
use Foysal50x\Tashil\Facades\Tashil;
$aiTokens = Tashil::feature('ai-tokens')
->name('AI Tokens')
->metered()
->create();
Tashil::package('payg')
->name('Pay-as-you-go')
->price(0)->monthly()
->feature($aiTokens, value: '0.001') // $0.001 per token
->create();The snapshot captures the unit price at subscribe time, so repricing the catalog later doesn't
retro-price existing subscriptions. The counter still tracks units (for analytics) but carries no
limit_value — the gate is the balance check, not a cap.
The MeteredBilling contract
You implement three methods:
interface MeteredBilling
{
public function getBalance(Subscribable $subscriber, string $currency): float;
public function hasSufficientBalance(Subscribable $subscriber, string $currency, float $amount): bool;
public function charge(Subscribable $subscriber, string $currency, float $amount, array $context = []): bool;
}Tashil decides which implementation to use per consume, with no global flag — so two patterns coexist freely.
Pattern A — self-implement on the model
Best when the subscriber model already owns its balance. Add the interface directly to your
Subscribable model:
use Foysal50x\Tashil\Contracts\MeteredBilling;
use Foysal50x\Tashil\Contracts\Subscribable;
use Foysal50x\Tashil\Traits\HasSubscriptions;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements Subscribable, MeteredBilling
{
use HasSubscriptions;
public function getBalance(Subscribable $subscriber, string $currency): float
{
return (float) $this->wallets()->forCurrency($currency)->value('balance');
}
public function hasSufficientBalance(Subscribable $subscriber, string $currency, float $amount): bool
{
return $this->getBalance($subscriber, $currency) >= $amount;
}
public function charge(Subscribable $subscriber, string $currency, float $amount, array $context = []): bool
{
return $this->wallets()->forCurrency($currency)->debit(
$amount,
idempotencyKey: $context['idempotency_key'],
meta: $context,
);
}
}No container binding needed — Tashil discovers the implementation through the model itself because it
instanceof MeteredBilling.
Pattern B — a standalone class in the container
Best when billing lives in its own service layer or is shared across several subscriber types:
use Foysal50x\Tashil\Contracts\MeteredBilling;
use Foysal50x\Tashil\Contracts\Subscribable;
class WalletMeteredBilling implements MeteredBilling
{
public function getBalance(Subscribable $subscriber, string $currency): float
{
return $subscriber->wallet($currency)->balance();
}
public function hasSufficientBalance(Subscribable $subscriber, string $currency, float $amount): bool
{
return $this->getBalance($subscriber, $currency) >= $amount;
}
public function charge(Subscribable $subscriber, string $currency, float $amount, array $context = []): bool
{
return $subscriber->wallet($currency)->debit(
$amount,
idempotencyKey: $context['idempotency_key'],
meta: $context,
);
}
}Bind it in a service provider:
$this->app->bind(
\Foysal50x\Tashil\Contracts\MeteredBilling::class,
\App\Billing\WalletMeteredBilling::class,
);Mixing both patterns
Resolution is per-subscriber, so you can use both at once: a Team self-implements MeteredBilling
(its own team wallet) while User relies on the container binding. Each subscriber's billing path is
decided by what that subscriber implements — not by any global setting.
Consuming
// 100 × 0.001 = $0.10 charged via the provider before the counter advances.
// Returns false if the provider declines (insufficient balance, gateway error).
$ok = $user->useFeature('ai-tokens', 100);
if (! $ok) {
// Listen for MeteredChargeRejected to drive a top-up prompt.
}On success, in order:
MeteredBilling::chargereturns true.- The counter advances by
units(for analytics), inside a DB transaction. - A
usage_logsrow is written (operation = consume, with amount, unit price, and currency). - A
subscription_eventsrow is appended (usage.metered_charged). MeteredChargeddispatches after the DB commit.
On rejection, nothing is written — no counter change, no log, no event — and
MeteredChargeRejected dispatches so your app can prompt a top-up.
Charge before write — never reverse it
The provider is charged before the transaction that records the consume. The alternative (write-then-charge) is worse: it would advance the counter while the balance refuses, losing revenue with no audit trail. See "Orphan charges" below for how the rare failure window is handled.
Idempotency
Every charge() receives an idempotency_key in $context. Your implementation must dedupe on
it so a retried call never double-charges.
// Caller-supplied — recommended for any retryable path. The same token reaching
// the provider twice = the same logical consume = must NOT double-charge.
$user->useFeature('ai-tokens', 100, idempotencyKey: $request->header('X-Idempotency-Key'));
// Auto-generated — a fresh UUID per call (format: metered:{sub}:{feature}:{uuid}).
// Only protects against provider-internal retries within this one attempt; an
// app-level retry (user double-clicks) sends a NEW UUID, so it can't dedupe.
$user->useFeature('ai-tokens', 100);Pass a stable token (request id, job UUID, upstream idempotency token) whenever the caller has a meaningful operation id.
The context passed to charge()
[
'idempotency_key' => string,
'subscription_id' => int,
'feature_id' => int,
'feature_slug' => string,
'units' => float, // the raw amount passed to useFeature()
'unit_price' => float, // from the snapshot
]Orphan charges
The provider charge happens before the DB transaction that records the consume. If that inner transaction fails after the provider already debited the balance, the result is an "orphan charge" — money moved, no Tashil record. When this happens:
Log::critical()fires with the full context (idempotency_key, ids, units, amount, currency, exception) so an operator can detect and reconcile.- The exception is re-thrown, so the caller knows the consume didn't complete cleanly.
To reconcile, match the logged idempotency_key against the provider's charge records and either
retry idempotently (the provider should return success without re-charging) or refund — per your
policy. True cross-system atomicity isn't possible without two-phase commit support from the
provider; charge-then-write is the safer of the two orderings.
Edge cases
reportStorage()is rejected for metered features — the absolute-set model doesn't fit delta-charging.check()delegates tohasSufficientBalancefor metered features — handy for UI gating, but inherently a time-of-check/time-of-use estimate.useFeature()is the authoritative gate.UsageLimitWarningdoesn't fire for metered features (there's no limit). Listen forMeteredChargedto surface spend instead.- Trial subscriptions still charge by default. Tashil makes no free-trial policy — if you want
free metering during trials, return
truefromcharge()without debiting when$subscription->isOnTrial(). - Multi-currency: the charge currency is the subscription's plan currency, so a subscriber on two subscriptions in different currencies has each charge hit the matching currency.