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
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 shape | subscribe() result | Invoice at subscribe |
|---|---|---|
price > 0, requires_payment = true (default) | Pending — no access | initial invoice issued |
price = 0 or requires_payment = false | Active immediately | none |
withTrial + trial_days > 0 | OnTrial — access granted | none (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 + SubscriptionActivatedThis 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 + SubscriptionRenewedadvancePeriod() 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:
| Policy | Action |
|---|---|
cancel (default) | Grace-cancel the subscription. |
skip | Log and retry next run. |
extend_grace | Push 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 ─(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)▶ ExpiredThe behavior is configured under dunning:
retry_days(default[1, 3, 5]) — days afterdue_dateat which an attempt fires.suspend_after_attempts(default3) — suspend once attempts reach this.cancel_after_suspend_days(default7) — expire a still-unpaid suspended subscription this many days after suspension.keep_access_while_past_due(defaulttrue) — soft dunning: aPastDuesubscription keeps access during the retry window.Suspendednever has access. Setfalsefor hard dunning (cut access on the firstPastDue).
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 + SubscriptionReactivatedThis 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:
| Method | Writes | Invoice effect | Event |
|---|---|---|---|
recordPayment($invoice, …) | a success transaction | markAsPaid() → activate / advancePeriod / reactivate | PaymentRecorded (+ InvoicePaid) |
recordFailedPayment($invoice, …) | a failed transaction | none — stays Pending for dunning | PaymentFailed |
recordRefund($transaction, …) | a refund on the original transaction | markAsRefunded() on a full refund only | PaymentRefunded |
$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 targetsThese 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:
| Method | Shape | Use for |
|---|---|---|
changePlan($new) | In place — same row, period + usage kept | Upgrades / lateral moves on a live plan |
switchPlan($new) | Cancel old + create new | Cross-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
prorationinvoice: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
| Event | Fired when |
|---|---|
SubscriptionActivated | Pending → Active (first payment), or a free plan created active |
SubscriptionPastDue | A renewal invoice goes unpaid past due — carries the invoice + attempt |
InvoiceOverdue | Each dunning attempt (your app re-charges) |
SubscriptionSuspended | Dunning retries exhausted — access cut |
SubscriptionReactivated | A lapsed subscription recovered by payment |
SubscriptionPlanChanged | An in-place plan change — carries the proration amount + invoice |
SubscriptionRenewed | A renewal invoice paid and the period advanced |
PaymentRecorded | recordPayment() wrote a successful transaction — carries the transaction + invoice |
PaymentFailed | recordFailedPayment() wrote a failed transaction — carries the transaction + invoice |
PaymentRefunded | recordRefund() recorded a refund — carries the transaction (cumulative refunded_amount) + invoice |