LarsaSub is launching soon. Purchases and account registration open shortly.

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

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:

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

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:

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.