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

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. See Webhooks → Guarantees.

Helpers: isPaid(), isPending(), isFailed(), isRefund(), hasActivePaymentUrl(), scopes paid(), pending(), forBillable($model). Full list — Models.