Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Single payments
A SinglePayment is a one-time, non-recurring payment. Unlike a
recurring charge it is not tied to a subscription and
needs no mandate: use it for orders, bookings, top-ups, or anything else a
customer pays for once, through a checkout.
- Lifecycle
- Creating a payment
- Starting the payment
- Order lines
- Attaching entities: the IsPayable contract
- Refunds and chargebacks
- The model
- Reference codes
- Invoicing
- Notifications
Lifecycle
You create the payment, the customer pays it at the provider's checkout, and the webhook settles it. Your attached business objects are told what happened.
Created SinglePaymentService::createSinglePayment()
↓
Started the customer is sent to the provider's checkout; charged_at set, payable = false
↓
Paid webhook → onPaid()
or Failed / Canceled / Expired → onFailed() / onCanceled() / onExpired() — payable again
↓
Chargeback / Refund (post-payment webhooks)
Unlike a recurring charge, starting the first payment on a SinglePayment is
the normal flow — the recovery-only restriction that applies to recurring charges
does not apply here. A failed, canceled or expired single payment sets
payable = true and can be started again: the new checkout runs on the same
row.
is_payable is the authority for a pay button: it is false once the payment is
paid, while a checkout is being started or runs (payable = false), and while
the provider still reports the payment as live. A single payment never gets a
reminder series or a chargeback follow-up.
Creating a payment
use PetersDevelopment\LarsaSub\Service\SinglePaymentService;
$service = app(SinglePaymentService::class);
$payment = $service->createSinglePayment(
owner: $user, // or a Billable registration
paymentMethod: $ideal,
amount: 25.00, // the amount that gets COLLECTED
currency: 'EUR',
description: 'Order #123',
entities: [$order], // optional: IsPayable models to notify
lines: [], // optional: order lines, see below
);
Creating the payment writes the record and nothing else: no provider call, no mail. Start it to send the customer to the checkout.
The amount is what gets collected. A single payment is never grossed up for
invoicing.prices_include_vat: the amount goes to the provider verbatim, and a
later invoice splits it into net + VAT instead.
The currency argument is validated against larsasub.currency.allowed
(case-insensitively normalized), as it is for
createStandaloneCharge() —
the other entry point where a caller supplies a currency.
What it refuses, each with a SinglePaymentException: a payment method
without single-payment support, an amount of zero or less, a currency outside the
allow-list, an entity that does not implement IsPayable, and order lines that
are invalid or do not add up to the amount (see Order lines).
Starting the payment
With the bundled payments dashboard, send the customer to the route
larsasub.my-payments.single-payment. It checks the payment's policy and
is_payable, validates the optional payment_method_id its method picker
appends, and redirects to the checkout.
Headless, start it through the provider yourself:
use PetersDevelopment\LarsaSub\PaymentProviders\PaymentProviderFactory;
if ($payment->is_payable) {
return PaymentProviderFactory::make($ideal->provider)
->createSinglePayment($payment, $ideal); // a RedirectResponse to the checkout
}
The start is an atomic claim: two tabs cannot both create a checkout. The
method you pass is checked server-side, whatever the request said: it must
support single payments and be active, and in Connect mode the payment's tenant
must offer it. The method you pass becomes the payment's payment_method_id
before the checkout is created, so the checkout is for that method; a payment
that may not start keeps the method it has. A tenant draining a requested
disconnect is refused with ConnectDisconnectPendingException. A start whose
process died between the claim and the provider's answer is resolved by
larsasub:execute-charges, so only with the scheduler running.
A start that fails at Mollie makes the payment payable again only when the
failure provably created nothing: a 4xx other than 409, or an error before
anything was sent. A timeout, a 5xx or a 409 (Mollie still processing an
earlier request with the same idempotency key) may have created the payment,
so it stays unpayable and is flagged needs_attention with
PAYMENT_START_UNCLEAR_MARKER: confirm at Mollie what happened before you
clear the flag. Clearing it hands the payment to that same reconciliation,
which adopts the payment Mollie made or makes it payable again.
Order lines
A single payment stays single-amount by default. Pass lines when the payment is
composed of several items:
$payment = $service->createSinglePayment(
owner: $user,
paymentMethod: $ideal,
amount: 35.00, // must equal the line totals
currency: 'EUR',
description: 'Order #123',
lines: [
['description' => 'Workshop ticket', 'quantity' => 2, 'unit_amount' => 12.50],
['description' => 'Handout bundle', 'unit_amount' => 10.00, 'vat_rate' => 9.0],
],
);
Each line takes description and unit_amount (both required), an optional
quantity (default 1, above zero) and an optional vat_rate (0–100; null = the
resolved config rate). unit_amount may not be negative and, like amount, is a
collected amount. A line's total is quantity × unit_amount, rounded to cents.
Line totals must sum to amount.
When lines exist, the generated invoice takes its specification and VAT split from them:
- a uniform rate produces a normal invoice-level
vat_rate; - mixed rates set the invoice-level
vat_ratetonulland the invoice lines carry the authoritative per-line split (the PDF then shows the rate per line).
Country rules still override everything: reverse charge and non-EU customers are zero-rated regardless of the per-line rates.
$payment->lines; // HasMany SinglePaymentLine, in display order
$payment->hasLines(); // bool
Lines are snapshots written with the payment; they do not change afterwards.
Attaching entities: the IsPayable contract
Business objects — an order, a booking, a ticket — can ride along on a payment
and be told what happened to it. Implement Contracts\IsPayable; every
implementation declares all seven methods, the interface has no defaults:
use PetersDevelopment\LarsaSub\Contracts\IsPayable;
use PetersDevelopment\LarsaSub\Models\SinglePayment;
use PetersDevelopment\LarsaSub\Support\PaymentChargeback;
use PetersDevelopment\LarsaSub\Support\PaymentRefund;
class Order extends Model implements IsPayable
{
public function onPaymentPaid(SinglePayment $payment): void { /* release the order */ }
public function onPaymentFailed(SinglePayment $payment): void { /* ... */ }
public function onPaymentCanceled(SinglePayment $payment): void { /* ... */ }
public function onPaymentExpired(SinglePayment $payment): void { /* ... */ }
public function onPaymentRefunded(SinglePayment $payment, PaymentRefund $refund): void
{
// book back $refund->amount, keyed on $refund->id
}
public function onPaymentChargeback(SinglePayment $payment, PaymentChargeback $chargeback): void
{
// book back $chargeback->amount, keyed on $chargeback->id
}
public function onPaymentChargebackReversed(SinglePayment $payment, PaymentChargeback $chargeback): void
{
// un-book that same id — the chargeback was reversed
}
}
Attach entities at creation (the entities argument) or afterwards:
$payment->attachEntity($order); // firstOrCreate — attaching twice is harmless
$payment->paymentEntities; // the SinglePaymentEntity pivot rows
$payment->entities(); // the resolved entity models
attachEntity() accepts any model, but only one that implements IsPayable is
called; the others are skipped.
How the hooks run
- Where. The hooks run wherever the webhook runs. With owner models or
entities in per-tenant databases, LarsaSub activates your
connect.tenant_contexthook before calling them — see Multi-database tenancy. When that activation fails, no entity is called and the skip is logged. - In a transaction, or not.
onPaymentPaid(),onPaymentRefunded(),onPaymentChargeback()andonPaymentChargebackReversed()run inside a transaction on the LarsaSub connection, under a row lock on the payment — a deadlock retry can run your hook again for the same id.onPaymentFailed(),onPaymentCanceled()andonPaymentExpired()run without one. - Throwing rolls nothing back. Every hook call is caught and logged on the payment, and the next entity is processed as if nothing happened: your exception does not undo the recorded status, and the provider does not retry the webhook. A write to another connection (a tenant database, an external service) is not part of the package's transaction either. If your side failed while the money moved, surface it yourself.
- Paid runs once per entity.
onPaymentPaid()stampsprocessed_aton the pivot row and is not called again for that entity. Every other hook runs for each delivery, so key your idempotency on the value object'sid(refunds, chargebacks) or on the payment id (paid).
Refunds and chargebacks
The rules are the same as for recurring charges, and they are worth repeating because getting them wrong books the wrong amount:
- Delivery is per occurrence, not per payment. Mollie allows multiple partial
refunds, so
$refund->amountis that refund's amount — never the payment total. - A refund is delivered once its id completes. In-flight
queued,pendingorprocessingrefunds andfailed/canceledrefunds are never delivered. - A chargeback is delivered once per id, on first appearance. A chargeback that is already reversed then delivers both hooks in order: the chargeback first, the reversal straight after.
- Webhook redelivery makes at-least-once the only honest guarantee. Key your
idempotency on the value object's
id, and checkisBookable()before you book an amount:amountis the provider's own string, not guaranteed to be a decimal you can compute with.
The same value objects ride on SinglePaymentRefundedEvent,
SinglePaymentChargebackEvent and SinglePaymentChargebackReversedEvent. They
survive SerializesModels, so a queued listener sees the refund the event was
fired for — not the snapshot at execution time. A reversal leaves the payment's
status and payable flag alone: the payment is simply paid again.
To make a refund rather than receive one, see Refunds:
RefundService::refund() takes a single payment as well.
The model
Models\SinglePayment (larsasub_single_payments) extends the same
ProviderPaymentModel base as RecurringCharge, so it shares the provider
status, attention and payability plumbing and the same payment, VAT, platform-fee,
provider and attention columns — see
recurring charge properties. It has none of
the subscription, planning, credit, mandate or follow-up columns. The reserved
provider_payment_status_raw column is there too; nothing writes it yet.
Relations
$payment->billable; // the Billable registration
$payment->paymentMethod;
$payment->paymentEntities; // SinglePaymentEntity pivot rows
$payment->entities(); // resolved entity models
$payment->lines; // SinglePaymentLine
$payment->invoice; // MorphOne to the generated invoice
$payment->settlement; // the Mollie payout it was paid out in (larsasub:sync-settlements)
Status methods
$payment->isPaid();
$payment->isNotPaid();
$payment->hasFailedProviderStatus(); // failed, expired or canceled
$payment->isLiveAttempt(); // started, neither paid nor failed
$payment->hasStandingChargeback(); // a chargeback on it was not reversed
$payment->receivedAmount(); // amount − standing chargebacks − completed refunds, as a string
$payment->is_payable; // attribute — may a checkout be started now
$payment->is_continuable; // attribute — the provider checkout is still open
Transitions — called by the webhook handlers, not by you:
$payment->onPaid(); // payable = false, notifies entities, dispatches the event
$payment->onFailed(); // payable = true — retryable
$payment->onCanceled(); // the same
$payment->onExpired(); // the same
$payment->onChargeback($chargeback);
$payment->onChargebackReversed($chargeback);
$payment->onRefunded($refund);
onNotified() and onCharged() exist for the shared contract and do nothing on
a single payment.
Scopes
SinglePayment::query()->isCharged()->get();
SinglePayment::query()->isNotCharged()->get();
SinglePayment::query()->isPaid()->get();
SinglePayment::query()->isNotPaid()->get();
SinglePayment::query()->isLiveAttempt()->get();
SinglePayment::query()->forBillable($billable)->get();
SinglePayment::query()->needsAttention()->get();
SinglePayment::query()->noAttentionNeeded()->get();
SinglePayment::query()->isNotPayable()->get(); // the payable COLUMN is false
SinglePayment::query()->hasFailedProviderStatus()->get();
SinglePayment::query()->hasPendingProviderStatus()->get(); // open, pending
And on the owner model:
$user->singlePayments(); // HasManyThrough, via the Billable registration
Reference codes
Every single payment gets a human-quotable reference code, formatted
{PREFIX}-{8 chars} — or {TENANT}-{PREFIX}-{8 chars} when a tenant reference
code applies. The prefix comes from larsasub.reference.single_payment_prefix
(default SP). The payment description Mollie shows the customer is the
reference followed by your description.
Invoicing
Single payments are not invoiced automatically. The payments dashboard exposes
an on-demand endpoint (larsasub.my-payments.single-payment-invoice) that
generates the invoice idempotently and streams the PDF in one request; it answers
404 while invoicing.enabled is off and 422 for an unpaid payment. Headless
hosts call InvoiceService::createForSinglePayment() directly — it returns the
existing invoice when there is one, and it checks neither of those two
conditions, so check paid_on and invoicing.enabled yourself before you call
it. The invoice is in the payment's own currency.
One VAT rule is specific to single payments: a single payment is never grossed
up. Its amount is contractually what the provider collects, so with
invoicing.prices_include_vat = false only the invoice changes, not the debit.
See Invoicing and VAT.
A refund or chargeback credits the invoice only when one exists — see Credit notes.
Notifications
| Who | Toggle | When |
|---|---|---|
| Customer | notifications.user.single_payment_paid |
Paid |
| Customer | notifications.user.single_payment_failed |
Failed (not canceled or expired) |
| Company | notifications.company.single_payment_paid |
Paid |
| Company | notifications.company.single_payment_failed |
Failed |
| Company | notifications.company.single_payment_chargeback |
A chargeback |
| Company | notifications.company.single_payment_refund |
A completed refund |
A canceled or expired checkout mails no one; listen to
SinglePaymentCanceledEvent / SinglePaymentExpiredEvent if you want to. See
Notifications and Events.