Skip to content

Billing Lifecycle

This page is the heart of how Tashil and your payment integration cooperate. It covers the money-related states: activation (first payment), renewal, dunning (failed payment), reactivation (recovery), plan change (proration), and the transaction ledger (recording payments and refunds).

Keep the boundary in mind: Tashil owns the state machine, the invoices, the transaction ledger, and the schedule. Your app moves the money — it charges cards, funds wallets, and issues gateway refunds, then tells Tashil what happened through Tashil::billing()->record*().

The state machine

subscribe (priced)Pendinginitial invoice issued
subscribe (trial)OnTrialaccess granted
subscribe (free)Activeaccess immediately
PendingActiveinitial invoice paid → activate()
ActivePastDuerenewal unpaid past due (dunning)
PastDueSuspendedretries exhausted
SuspendedExpiredsuspend grace elapsed
lapsedActiveinvoice paid → reactivate()

Activation

Whether a plan grants access immediately is decided by the package's own requires_payment flag. Under the default, it's on, so:

A priced plan does not grant access until its first invoice is paid.

Plan shapesubscribe() resultInvoice at subscribe
price > 0, requires_payment = true (default)Pending — no accessinitial invoice issued
price = 0 or requires_payment = falseActive immediatelynone
withTrial + trial_days > 0OnTrial — access grantednone (issued at conversion)

The flow for a priced, non-trial plan:

subscribe()
  status = Pending, no period, no access (isValid() = false)
  → BillingService::issueInitialInvoice (kind = initial)
  → InvoiceIssued event
your gateway charges the card → Tashil::billing()->recordPayment($invoice, …)  (records txn + markAsPaid)
InvoiceObserver → SubscriptionService::activate($sub, $invoice)
  status = Active
  starts_at / current_period_start / activated_at = invoice.paid_at
  current_period_end = paid_at + billing period   ← anchored to PAYMENT, not subscribe
  feature counters re-anchored to paid_at
  → subscription.activated event + SubscriptionActivated

This closes the "free first period" gap — the period the customer consumes is the one they paid for. activate() is a no-op on any non-pending subscription.

Why the package, not the config

requires_payment is a per-plan decision, so the package row is authoritative at runtime — the service reads only the package (requires_payment && price > 0). The config billing.activate_on_payment is consulted exactly once, when a package is created, to seed the flag if you didn't set it.

Config never overrides data

This avoids a dangerous footgun: a plan explicitly marked requires_payment = true keeps collecting payment even if someone later flips the install-wide default to false. Flipping the config affects only packages created afterward. If you need an emergency "let everyone in" switch (gateway outage), add a separately-named incident flag rather than overloading this default.

Legacy / opt-out

For free, offline, or enterprise-invoiced plans that should activate immediately:

// Per-plan — the clearest option:
Tashil::package('enterprise')->name('Enterprise')->price(0)
    ->requiresPayment(false)
    ->create();
 
// Or change the creation-time default for all new plans:
// config/tashil.php → 'billing' => ['activate_on_payment' => false]

Trial conversion

convertTrial() ends a trial as a paying customer: it sets Active, anchors the first paid period to the conversion moment, and issues the first initial invoice for priced plans. Trials are never billed by the renewal cron. The full details are on the Trials page.

Renewal

tashil:renew-subscriptions issues a renewal invoice for Active, auto-renewing subscriptions whose current_period_end has elapsed. Crucially, it does not advance the period — that happens when your app marks the invoice paid:

InvoiceObserver (renewal paid, Active/OnTrial) → advancePeriod()
  current_period_start = previous current_period_end
  current_period_end   = previous end + billing period   (anchored, no drift)
  → subscription.renewed + SubscriptionRenewed

advancePeriod() is guarded: it's a no-op for any subscription that isn't Active or OnTrial, so a stray paid invoice can never silently shift the period of a cancelled, paused, or expired subscription.

When an unpaid invoice already exists

If a pending invoice is still outstanding at renewal time, the renewal.on_pending_invoice policy decides what to do:

PolicyAction
cancel (default)Grace-cancel the subscription.
skipLog and retry next run.
extend_gracePush current_period_end by renewal.grace_days, capped at renewal.max_grace_extensions. Once exhausted, dunning takes over.

Dunning

When a renewal invoice goes unpaid past its due_date, tashil:process-dunning drives a bounded recovery cycle:

Active
PastDue
Suspended
Expired
Active     ─(retry milestone reached)──▶ PastDue      fires SubscriptionPastDue + InvoiceOverdue
PastDue    ─(each new milestone)──────▶ PastDue (++)  fires InvoiceOverdue (your app re-charges)
PastDue    ─(attempts ≥ suspend_after)▶ Suspended     fires SubscriptionSuspended (access cut)
Suspended  ─(+ cancel_after_suspend_days)▶ Expired

The behavior is configured under dunning:

  • retry_days (default [1, 3, 5]) — days after due_date at which an attempt fires.
  • suspend_after_attempts (default 3) — suspend once attempts reach this.
  • cancel_after_suspend_days (default 7) — expire a still-unpaid suspended subscription this many days after suspension.
  • keep_access_while_past_due (default true) — soft dunning: a PastDue subscription keeps access during the retry window. Suspended never has access. Set false for hard dunning (cut access on the first PastDue).

Your app performs the charge

Tashil fires SubscriptionPastDue / InvoiceOverdue so your app can re-attempt the charge through your gateway — Tashil never moves money. Disable the whole cycle with dunning.enabled = false.

Reactivation

Paying an invoice on a lapsed subscription (PastDue, Suspended, or Expired) recovers it:

InvoiceObserver (lapsed status) → SubscriptionService::reactivate($sub, $invoice)
  status = Active
  dunning_attempts = 0, last_dunning_at = null, suspended_at = null
  period: keeps a still-future period as-is, else starts fresh from payment
  → subscription.reactivated + SubscriptionReactivated

This closes the "paid but still locked out" gap — recovery restores access, it doesn't just shift dates. reactivate() is a no-op for any non-lapsed status, so paying a stray invoice on a cancelled subscription does nothing.

Transaction ledger and refunds

An invoice is the bill; a transaction is the record of a payment attempt against it (the tashil_transactions table, reachable via $invoice->transactions()). Tashil never charges or refunds at the gateway — your app does — but it records the result and keeps the invoice state in lockstep. Three methods on Tashil::billing() own this; each writes the transaction row and reflects the invoice in a single database transaction:

MethodWritesInvoice effectEvent
recordPayment($invoice, …)a success transactionmarkAsPaid() → activate / advancePeriod / reactivatePaymentRecorded (+ InvoicePaid)
recordFailedPayment($invoice, …)a failed transactionnone — stays Pending for dunningPaymentFailed
recordRefund($transaction, …)a refund on the original transactionmarkAsRefunded() on a full refund onlyPaymentRefunded
$billing = Tashil::billing();
 
// A successful charge — records the transaction AND settles the invoice
// (which activates / advances / reactivates by kind + status).
$billing->recordPayment(
    $invoice,
    gateway: 'stripe',
    transactionId: 'ch_3P…',   // gateway id; null for cash/manual → a TXN-… id is stamped
    amount: 30.00,             // defaults to the invoice amount
    gatewayResponse: $payload, // stored as JSON for reconciliation
);
 
// A declined charge — audit only; the invoice stays Pending for dunning.
$billing->recordFailedPayment($invoice, gateway: 'stripe', gatewayResponse: ['decline_code' => 'insufficient_funds']);

Idempotent by design

recordPayment() and recordFailedPayment() dedupe on the UNIQUE(gateway, transaction_id) key, so an at-least-once webhook is safe to replay — a re-delivered charge resolves to the row already written and never settles the invoice twice. You no longer hand-catch UniqueConstraintViolationException.

Refunds

Your app issues the refund at the gateway, then records it. Tashil never issues a gateway refund:

// 1. Your gateway moves the money.
$gateway->refund($transaction->transaction_id, 12.50);
 
// 2. Tashil records it against the original transaction.
$billing->recordRefund($transaction, amount: 12.50, reason: 'customer request');

recordRefund() accumulates refunded_amount (partial refunds add up), stamps refunded_at and refund_reason, and fires PaymentRefunded. A partial refund keeps the transaction Success and the invoice Paid; once the cumulative refund reaches the full charge the transaction flips to Refunded and the invoice moves to Refunded. A full refund does not auto-cancel the subscription — that's a separate host decision. Refunding more than the refundable balance, or refunding a non-successful transaction, throws InvalidArgumentException.

markAsPaid is still there

Invoice::markAsPaid() / markAsVoid() / markAsRefunded() remain as low-level state transitions if you record the transaction yourself, but recordPayment / recordRefund are the complete, idempotent path — they write the ledger row for you.

Reading invoices and transactions

Fetch invoices and the charge behind them through the billing API rather than querying the Invoice model directly — that keeps the access path behind the (overridable) repository:

$billing = Tashil::billing();
 
$billing->latestInvoice($subscription);                       // most recent invoice
$billing->latestInvoice($subscription, InvoiceKind::Initial); // most recent of a kind
$billing->pendingInvoice($subscription);                      // outstanding bill to pay (or null)
$billing->overdueInvoice($subscription);                      // pending + past due — the dunning trigger
$billing->successfulTransaction($invoice);                    // the settled charge a refund targets

These read paths are intentionally not cached — a per-subscription invoice changes on every payment and dunning step, so callers always see fresh status.

Plan change and proration

Two ways to move plans, chosen by intent:

MethodShapeUse for
changePlan($new)In place — same row, period + usage keptUpgrades / lateral moves on a live plan
switchPlan($new)Cancel old + create newCross-product moves, scheduled downgrades

changePlan() classifies by normalized monthly price:

  • Upgrade / lateral → applied immediately in place. Feature snapshots are superseded and re-written from the new plan; usage counters carry forward (caps and reset cadence update, the usage value is kept). The prorated delta for the rest of the period is billed on a proration invoice:

    delta = (newPrice − oldPrice) × (remaining seconds / period seconds)

    It's issued only when delta >= billing.min_proration_amount (dust changes skip the invoice). The period end is unchanged; the next renewal bills the new full price.

  • Downgrade → deferred to period end via scheduleDowngrade() (the current period is already paid).

Cross-currency proration throws — cancel and resubscribe instead.

Pause

pause() freezes the clock: the remaining access time (seconds until ends_at) is banked in metadata.paused_remaining_seconds. unpause() adds it back from the resume moment, so a paused subscription never silently burns the customer's remaining paid time. Lifetime / open-ended subscriptions bank nothing.

The host integration handshake

Putting it together, here's the contract your payment integration implements:

┌─────────────────────────────────────────────────────────────┐
│  Tashil                                                      │
│  - Issues Invoice (status = pending, kind = initial|renewal) │
│  - Fires InvoiceIssued                                       │
├─────────────────────────────────────────────────────────────┤
│  Your listener                                               │
│  - Charges the card via Stripe / Paddle / bKash / …          │
│  - On success → Tashil::billing()->recordPayment($invoice)   │
│    (writes the transaction + marks the invoice paid)         │
├─────────────────────────────────────────────────────────────┤
│  Tashil InvoiceObserver — routes by invoice.kind + status:   │
│  - initial  + Pending         → activate()  (first access)   │
│  - renewal  + Active/OnTrial   → advancePeriod() + renewed    │
│  - any kind + lapsed           → reactivate()                 │
│  - proration / initial-active  → no period change             │
│  - always → InvoicePaid                                      │
└─────────────────────────────────────────────────────────────┘

For the failed-payment retry charge and webhook reconciliation, your app wires listeners on SubscriptionPastDue, InvoiceOverdue, and SubscriptionSuspended. See Events for the full list and Extending Tashil for wiring.

Events reference

EventFired when
SubscriptionActivatedPending → Active (first payment), or a free plan created active
SubscriptionPastDueA renewal invoice goes unpaid past due — carries the invoice + attempt
InvoiceOverdueEach dunning attempt (your app re-charges)
SubscriptionSuspendedDunning retries exhausted — access cut
SubscriptionReactivatedA lapsed subscription recovered by payment
SubscriptionPlanChangedAn in-place plan change — carries the proration amount + invoice
SubscriptionRenewedA renewal invoice paid and the period advanced
PaymentRecordedrecordPayment() wrote a successful transaction — carries the transaction + invoice
PaymentFailedrecordFailedPayment() wrote a failed transaction — carries the transaction + invoice
PaymentRefundedrecordRefund() recorded a refund — carries the transaction (cumulative refunded_amount) + invoice

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