Skip to content

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:

app/Models/User.php
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:

app/Billing/WalletMeteredBilling.php
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:

app/Providers/AppServiceProvider.php
$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:

  1. MeteredBilling::charge returns true.
  2. The counter advances by units (for analytics), inside a DB transaction.
  3. A usage_logs row is written (operation = consume, with amount, unit price, and currency).
  4. A subscription_events row is appended (usage.metered_charged).
  5. MeteredCharged dispatches 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:

  1. Log::critical() fires with the full context (idempotency_key, ids, units, amount, currency, exception) so an operator can detect and reconcile.
  2. 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 to hasSufficientBalance for metered features — handy for UI gating, but inherently a time-of-check/time-of-use estimate. useFeature() is the authoritative gate.
  • UsageLimitWarning doesn't fire for metered features (there's no limit). Listen for MeteredCharged to surface spend instead.
  • Trial subscriptions still charge by default. Tashil makes no free-trial policy — if you want free metering during trials, return true from charge() 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.

Tashil — Subscription management for Laravel. Released under the MIT license.