Skip to content

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.

Every payment has two polymorphic owners:

  • payable — what is being paid for: your Order, a top-up, the package’s own Subscription for renewals
  • billable — who pays: a User, an Organization

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-liner

is_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.

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:

ColumnValue
external_idThe 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_urlAlways a plain redirectable link
payment_url_expires_atThe checkout’s lifetime (link_ttl_minutes, or the gateway’s own for Stripe)
initiationmanual, unless ChargeOptions::$initiation says otherwise
raw_responseThe gateway’s response, for support and debugging
statusReset 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.

Fomvasss\Billing\DTO\ChargeOptions, all arguments optional:

ArgumentTypeMeaning
receiptItemsarrayFiscal basket, [['name', 'qty', 'unitAmount', 'sku'?], ...]. Auto-filled from a HasReceiptItems payable when empty. Must add up to the amount — see Fiscal receipt items
customerEmail?stringMonobank customerEmails, WayForPay clientEmail, Hutko sender_email, Stripe customer_email
customerIp?stringOff-session charges: LiqPay ip, Hutko client_ip. Both fall back to 127.0.0.1. Also counted by the checkout rate limit
locale?stringLiqPay language (narrowed to uk/en), Hutko lang, Stripe locale, Paddle checkout locale
description?stringCheckout description. Fallback is Payment #{id} where the gateway needs one
saveCardboolTokenize the card during this charge, see Saved cards
successUrl?stringSend the customer here instead of the package return route
failUrl?stringSame, for the failure slot (Stripe, Paddle)
webhookUrlParamsarrayExtra query params on the callback URL — a routing hint, never trusted. The package adds tenant itself
returnParamsarrayQuery params forwarded to your return page, see Return pages
initiation?PaymentInitiationWho started the charge — overrides the default
rawarrayGateway-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.

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 empty
BILLING_CHECKOUT_RATE_LIMIT_DECAY=10 # window, minutes
  • charge() and startSubscription() count per billable, and per IP when you pass ChargeOptions::$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) and charge() with initiation: 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.

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.

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.

payments.initiation (PaymentInitiation) records the initiator, not the mechanism:

manualautomatic
Written bycharge(), startSubscription()chargeWithMethod(), provider-created renewal rows
Meansa person was therenobody 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 present
Billing::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.

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: Monobank paymentInfo.fee, LiqPay receiver_commission, WayForPay fee, Hutko fee, Paddle details.totals.fee. Stripe’s webhook carries no fee — it stays null
  • $payment->netAmount() — amount - fee, null while 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)]);
}
});

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']);
}
});

The webhook (or reconciliation) is the only thing that changes a payment’s status, and it fires:

StatusEventMeaning
paidPaymentSucceededMoney received, amount and currency verified
failedPaymentFailedThe card was tried and refused
canceledPaymentCanceledThe 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.

A failed payment records the gateway’s own reason, from the callback or the status poll:

  • payments.failure_code — the gateway’s code: WayForPay reasonCode, Monobank errCode, LiqPay err_code, Hutko response_code, Stripe last_payment_error.decline_code (the bank’s reason), else .code (Stripe’s own)
  • payments.failure_message — its text: WayForPay reason, Monobank failureReason, LiqPay err_description, Hutko response_description, Stripe last_payment_error.message
  • $payment->failureReason() — both for a person to read: Insufficient funds (1104); null when 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_failed reports only error_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()]);
});

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.failed instead of issuing a fresh checkout, and charge() throws BillingException — create a new Payment instead
  • a later failure/expired callback for its old checkout is ignored; a success one (the money arrived anyway) still makes it paid, with a log warning
  • PaymentCanceled is 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.

GatewayVoiding 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)