Payments
A Payment (Fomvasss\Billing\Models\Payment, table billing_payments) is one movement of money: a charge, or a refund of one. Your app creates the row; a gateway driver turns it into a checkout; the gateway’s webhook decides whether it was paid.
Payable and billable
Section titled “Payable and billable”Every payment has two polymorphic owners:
payable— what is being paid for: yourOrder, a top-up, the package’s ownSubscriptionfor renewalsbillable— who pays: aUser, anOrganization
Any Eloquent model can be a payable. Implementing the marker interface Contracts\Payable is optional — it only helps type-hinting. A payable that implements Contracts\HasReceiptItems provides the fiscal basket.
A billable should implement Contracts\Billable — the package calls its tenantId() to pick per-tenant credentials. The Concerns\Billable trait implements it (tenantId() returns null) and adds relations:
use Fomvasss\Billing\Concerns\Billable as BillableConcern;use Fomvasss\Billing\Contracts\Billable;
class Organization extends Model implements Billable{ use BillableConcern;
// override only for multi-tenancy, see "Tenants and multiple accounts" public function tenantId(): ?string { return (string) $this->id; }}$organization->payments; // morphMany — chain scopes: ->payments()->paid()$organization->subscriptions;$organization->paymentMethods;$organization->defaultPaymentMethod; // one default card (per gateway — see below)$organization->defaultPaymentMethodFor('monobank');$organization->activeSubscription('pro'); // ?Subscription, by plan code; null code = any plan$organization->hasActiveSubscription('pro'); // bool — the gate/middleware one-lineris_default is tracked per gateway, so a billable with cards on two gateways has two defaults — defaultPaymentMethod returns one of them, defaultPaymentMethodFor() is the precise pick.
tenant_id on payments, subscriptions, payment methods and invoices is filled from the billable’s tenantId() on create when you leave it empty; a value you pass is never overwritten.
Charging
Section titled “Charging”use Fomvasss\Billing\DTO\ChargeOptions;use Fomvasss\Billing\Facades\Billing;use Fomvasss\Billing\Models\Payment;
$payment = Payment::create([ 'gateway' => 'liqpay', 'amount' => 10000, // always minor units — 100.00 'currency' => 'UAH', 'payable_type' => $order->getMorphClass(), 'payable_id' => $order->id, 'billable_type' => $user->getMorphClass(), 'billable_id' => $user->id,]);
$result = Billing::charge($payment, new ChargeOptions( description: "Order #{$order->number}", customerEmail: $user->email,));
return redirect($payment->payment_url);status defaults to pending and type to charge at the database level.
charge() resolves the driver for $payment->gateway with the billable’s tenant, auto-fills the fiscal basket, checks it against the amount, calls the driver and writes back onto the row:
| Column | Value |
|---|---|
external_id | The gateway’s reference for this checkout (Monobank invoice id, Stripe session id, Paddle transaction id). LiqPay, WayForPay and Hutko return none — they find the payment by its own id, and the webhook fills in their transaction id where they have one |
payment_url | Always a plain redirectable link |
payment_url_expires_at | The checkout’s lifetime (link_ttl_minutes, or the gateway’s own for Stripe) |
initiation | manual, unless ChargeOptions::$initiation says otherwise |
raw_response | The gateway’s response, for support and debugging |
status | Reset to pending — unless the payment is already paid |
It returns a PaymentResult (url or form, expiresAt, externalId, raw). Use it only if you build your own response (an SPA API): every gateway sets url except LiqPay, which sets form (['action' => ..., 'fields' => [...]]) — payment_url already wraps that form in a self-submitting page.
charge() is safe to call again on the same payment once the link expired — the permanent pay link does exactly that.
ChargeOptions
Section titled “ChargeOptions”Fomvasss\Billing\DTO\ChargeOptions, all arguments optional:
| Argument | Type | Meaning |
|---|---|---|
receiptItems | array | Fiscal basket, [['name', 'qty', 'unitAmount', 'sku'?], ...]. Auto-filled from a HasReceiptItems payable when empty. Must add up to the amount — see Fiscal receipt items |
customerEmail | ?string | Monobank customerEmails, WayForPay clientEmail, Hutko sender_email, Stripe customer_email |
customerIp | ?string | Off-session charges: LiqPay ip, Hutko client_ip. Both fall back to 127.0.0.1. Also counted by the checkout rate limit |
locale | ?string | LiqPay language (narrowed to uk/en), Hutko lang, Stripe locale, Paddle checkout locale |
description | ?string | Checkout description. Fallback is Payment #{id} where the gateway needs one |
saveCard | bool | Tokenize the card during this charge, see Saved cards |
successUrl | ?string | Send the customer here instead of the package return route |
failUrl | ?string | Same, for the failure slot (Stripe, Paddle) |
webhookUrlParams | array | Extra query params on the callback URL — a routing hint, never trusted. The package adds tenant itself |
returnParams | array | Query params forwarded to your return page, see Return pages |
initiation | ?PaymentInitiation | Who started the charge — overrides the default |
raw | array | Gateway-specific request fields, merged under the driver’s own: they can add a field, never override the amount or the reference |
raw is read only by the driver you charge through. Examples: LiqPay rro_info, Monobank agentFeePercent, Stripe automatic_tax, Paddle discount_id. On Hutko off-session charges raw keys are signed — an unknown key breaks the signature.
Checkout rate limit
Section titled “Checkout rate limit”Every checkout is another page to try stolen cards on, and for card testing the gateway fines or blocks the merchant, not the attacker. checkout_rate_limit caps how many checkouts can be opened in a window:
BILLING_CHECKOUT_RATE_LIMIT=10 # checkouts per window; off when emptyBILLING_CHECKOUT_RATE_LIMIT_DECAY=10 # window, minutescharge()andstartSubscription()count per billable, and per IP when you passChargeOptions::$customerIp— pass it from your checkout controller ($request->ip()): a guest flow that creates a billable per order is only caught by IP.- The pay link counts the visitor’s IP on every re-issue; a redirect to a live checkout isn’t counted.
- Not counted: off-session charges (
chargeWithMethod(), renewals) andcharge()withinitiation: PaymentInitiation::Automatic.
Over the limit Fomvasss\Billing\Exceptions\TooManyCheckoutsException (extends BillingException) is thrown before the gateway is called; $e->retryAfter holds the seconds to wait. Uncaught, it renders as HTTP 429 with Retry-After. Counters live in the cache store — use a shared one across servers.
Manual and offline payments
Section titled “Manual and offline payments”A cash or bank-transfer payment needs no driver — create the row in its final state:
Payment::create([ 'status' => PaymentStatus::Paid, 'gateway' => null, // or a free-text label such as 'cash' — never charged through 'amount' => 10000, 'currency' => 'UAH', 'payable_type' => $order->getMorphClass(), 'payable_id' => $order->id, 'billable_type' => $user->getMorphClass(), 'billable_id' => $user->id,]);paid_at is stamped automatically whenever status becomes paid (and cleared when it moves away). A row created this way fires no PaymentSucceeded — dispatch your own logic, and call Billing::issueReceipt() yourself if you use invoices.
Payment numbers
Section titled “Payment numbers”A UUID is a poor thing to read over the phone. payments.number (unique) is a human-facing reference for emails and support, and Payment::findByNumber() looks it up. The package never generates it — numbering schemes are project-specific — assign yours in a hook:
// AppServiceProvider::boot()Payment::creating(function (Payment $payment) { $payment->number ??= 'PAY-' . now()->format('Y') . '-' . str_pad((string) PaymentSequence::next(), 6, '0', STR_PAD_LEFT);});Invoices and receipts have their own numbers, see Invoices.
Who started the charge
Section titled “Who started the charge”payments.initiation (PaymentInitiation) records the initiator, not the mechanism:
manual | automatic | |
|---|---|---|
| Written by | charge(), startSubscription() | chargeWithMethod(), provider-created renewal rows |
| Means | a person was there | nobody was — a scheduled renewal, a dunning retry |
$payment->isManual();$payment->isAutomatic();Override it when the two disagree:
// one-click "pay with the saved card": off-session code path, person presentBilling::chargeWithMethod($payment, $method, new ChargeOptions(initiation: PaymentInitiation::Manual));The column is nullable and never guessed: a row created outside those methods has no initiation, and both helpers answer false.
Gateway fee and net amount
Section titled “Gateway fee and net amount”amount is always what the customer paid — refund caps, amount verification and reconciliation rely on it. The merchant side lives next to it:
payments.fee— the gateway’s commission in minor units, filled from the paid callback (or status poll) where the gateway reports it: MonobankpaymentInfo.fee, LiqPayreceiver_commission, WayForPayfee, Hutkofee, Paddledetails.totals.fee. Stripe’s webhook carries no fee — it staysnull$payment->netAmount()—amount - fee,nullwhile the fee is unknown
null means unknown, 0 means known to be zero. The column is yours to write too — PaymentSucceeded fires after the driver filled what the gateway reported:
Event::listen(function (PaymentSucceeded $event) { $payment = $event->payment;
if ($payment->fee === null) { $payment->update(['fee' => (int) round($payment->amount * 2.9 / 100)]); }});What a payment is for
Section titled “What a payment is for”A payment alone says who paid and how much, not what for. Either point payable at a model of your own (StorageAddonPurchase, Order) and branch on instanceof in the listener, or keep it simple with payments.meta — a JSON column the package never reads:
$payment = Payment::create([ // ... 'payable_type' => $organization->getMorphClass(), 'payable_id' => $organization->id, 'meta' => ['product' => 'storage_addon', 'gb' => 5],]);
Event::listen(function (PaymentSucceeded $event) { if (($event->payment->meta['product'] ?? null) === 'storage_addon') { $event->payment->payable->increment('extra_storage_gb', $event->payment->meta['gb']); }});Outcomes
Section titled “Outcomes”The webhook (or reconciliation) is the only thing that changes a payment’s status, and it fires:
| Status | Event | Meaning |
|---|---|---|
paid | PaymentSucceeded | Money received, amount and currency verified |
failed | PaymentFailed | The card was tried and refused |
canceled | PaymentCanceled | The checkout expired or was voided, or reconciliation wrote it off — nobody’s card was refused |
A paid payment is never moved to another status by a later callback (Payment::transitionTo()); a failed/canceled one can still become paid — the customer paid a re-issued checkout. A voided one is never re-issued. See Webhooks → Guarantees.
Helpers: isPaid(), isPending(), isFailed(), isVoided(), isRefund(), hasActivePaymentUrl(), scopes paid(), pending(), forBillable($model). Full list — Models.
Why a payment failed
Section titled “Why a payment failed”A failed payment records the gateway’s own reason, from the callback or the status poll:
payments.failure_code— the gateway’s code: WayForPayreasonCode, MonobankerrCode, LiqPayerr_code, Hutkoresponse_code, Stripelast_payment_error.decline_code(the bank’s reason), else.code(Stripe’s own)payments.failure_message— its text: WayForPayreason, MonobankfailureReason, LiqPayerr_description, Hutkoresponse_description, Stripelast_payment_error.message$payment->failureReason()— both for a person to read:Insufficient funds (1104);nullwhen the payment isn’t failed or the gateway gave no reason
Codes and texts are the gateway’s, not normalized — 59 from Monobank and insufficient_funds from Stripe mean the same thing. A field the gateway didn’t send stays null. canceled (an expired checkout) has no reason: nobody’s card was refused. A row paid after a decline — Stripe reuses the PaymentIntent, WayForPay and Hutko the order reference — has both cleared.
Not covered:
- Stripe Checkout: a declined card inside an open session isn’t an outcome (the customer tries another card on the same page), and an expired session carries no reason. Only off-session charges get one
- Paddle:
transaction.payment_failedreports onlyerror_code, and the package doesn’t act on it for a checkout
Event::listen(function (PaymentFailed $event) { Log::info('Payment refused', ['payment' => $event->payment->id, 'reason' => $event->payment->failureReason()]);});Voiding an unpaid payment
Section titled “Voiding an unpaid payment”An invoice sent by mistake, or paid some other way: withdraw it so the customer can no longer pay it.
Billing::voidPayment($payment);The gateway invalidates the checkout, the row becomes canceled with voided_at, payment_url is cleared and PaymentVoided fires. Unlike an expired canceled payment, a voided one is final:
- the pay link sends it to
return_urls.failedinstead of issuing a fresh checkout, andcharge()throwsBillingException— create a newPaymentinstead - a later
failure/expiredcallback for its old checkout is ignored; asuccessone (the money arrived anyway) still makes itpaid, with a log warning PaymentCanceledis not fired, so a voided renewal doesn’t start dunning
Voiding twice is a no-op. Refused with BillingException: a paid payment (refund it), a refund row, and an off-session charge waiting for its webhook (pending, with external_id, without payment_url) — the card is already being charged. When the gateway reports the checkout paid or still processing, voidPayment() throws and the row stays as it was.
A pending payment with an open checkout (payment_url) needs a driver with VoidsPayments (Billing::gateways()[$name]['capabilities']['voids']) — otherwise NotSupportedException, rather than a local void that leaves the checkout payable. A payment nobody opened a checkout for, and a failed/canceled one, is voided without an API call. A cached LiqPay form page (billing.checkout-form) of a voided payment answers 410.
| Gateway | Voiding a live checkout |
|---|---|
| Monobank | ✓ (/invoice/remove) |
| Stripe | ✓ (/checkout/sessions/{id}/expire) |
| Paddle | ✓ (transaction → canceled) |
| Fake | ✓ (its page answers 410) |
| LiqPay | — no API to withdraw a checkout: the signed form isn’t registered with LiqPay until it’s submitted |
| WayForPay | — no API to withdraw a Purchase checkout: REMOVE_INVOICE answers 1127 Order Not Found for it, REFUND 1126 Illegal Order State (checked on the test merchant) |
| Hutko | — no API to withdraw an order; it ends only by its lifetime. reverse/order_id refuses an unpaid order (1000 General decline, checked on the test merchant) |