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

Refunds

How to give money back: refund a paid charge or single payment at Mollie, return credit that was applied to a charge, or give collected money back as wallet credit. Read this before you build a refund action into your admin; what the package delivers when a refund completes is described under Refunds and chargebacks.

What the package does, and what it leaves to you

Service\RefundService::refund() asks the payment provider to refund a paid RecurringCharge or SinglePayment. That is all it does. Everything after the request comes from the same place it always did: Mollie's webhook tells the package when the refund completes, the package fires RecurringChargeRefundedEvent / SinglePaymentRefundedEvent (and the IsPayable hook), and the credit-note listener writes the credit note.

There is no route, no dashboard button and no policy. A refund is an operator decision; call the service from your own admin action, command or job. The Nova and Filament companions have no refund action yet.

Call it outside a database transaction of your own. The service locks the billable's registration while it checks the amount and releases the lock before it calls Mollie; inside your transaction that lock would stay held across the call until you commit.

Requesting a refund

use PetersDevelopment\LarsaSub\Service\RefundService;

$request = app(RefundService::class)->refund(
    $charge,                        // a paid RecurringCharge or SinglePayment
    amount: 12.50,                  // null = everything still refundable
    description: 'Goodwill, ticket 4711',
    idempotencyKey: 'ticket-4711',  // your reason for this refund — see below
);

The order inside the call is fixed: read Mollie, adopt a refund that already carries your key, check the ceiling under the registration lock, release the lock, ask Mollie for the refund, and read Mollie again so the new refund is stored.

What can still be refunded

The ceiling is the collected amount (amount, never original_amount: credit applied to a charge was never at Mollie), minus standing chargebacks, minus every refund that has not failed or been canceled — queued, pending and processing refunds count, because that money is already promised back — minus collected money already given back as wallet credit through returnCollectedAsCredit() (what dunning.double_paid = credit does), and never more than Mollie says it still allows. The package reads Mollie before it decides, so a refund someone made in the Mollie dashboard a minute ago is counted.

For a form that shows "at most €…": RefundService::ceilingCents($payment) answers from what is stored on the payment, in cents. Treat it as a hint and let refund() decide:

What you get back

A Support\RefundRequest: id (Mollie's re_…), status, amount, currency, and adopted (true when the refund already existed under your key); toArray() gives the same as an array.

It is not money you can book. The status is what Mollie answered at that moment — usually queued or pending — and such a refund can still be canceled or fail. Book money only from RecurringChargeRefundedEvent / SinglePaymentRefundedEvent or IsPayable::onPaymentRefunded(), which carry a Support\PaymentRefund once the refund reached refunded. If you already have a helper that books a PaymentRefund, do not feed it the RefundRequest.

Events\ProviderPayment\RefundRequestedEvent ($payment, $request) carries the same object, after commit, at least once — an adopted refund dispatches it again.

A queued refund is normal. Mollie queues a refund when the account's balance cannot cover it yet, and carries it out when enough comes in. The request succeeded. Mollie sends no webhook for queued, pending or canceled refunds, only for processing, refunded and failed.

Retries and idempotency

refund() sends a key with every request — as Mollie's Idempotency-Key header and in the refund's metadata. Before creating anything it looks for a refund that already carries that key and adopts it instead. So a retry after a lost answer — a timeout, a crashed worker — never refunds twice.

That only works if the retry sends the same key:

Your key is bound to the payment: the same ticket number on two charges gives two different keys, so it cannot hand one charge the other's refund. The key is stored at Mollie only as a hash, and the package logs the refund id, not your key — keep your own record of which reason led to which $request->id.

An adopted refund comes back with whatever status it has now — including failed or canceled. The key means "this reason, this refund": to try again after a refund failed, use a new key.

A failure that provably created nothing (a 4xx from Mollie) throws RefundServiceException with RefundBlocker::ProviderRefused. A failure that may have created the refund — a timeout, a 5xx, or a 409, which Mollie sends when another request with the same key is still being processed — is rethrown unchanged: retry with the same key, and the retry adopts the refund the other request made.

Why a refund is refused

Every refusal is a RefundServiceException whose $reason is a Enums\RefundBlocker. Switch on the reason; the message is translated.

use PetersDevelopment\LarsaSub\Enums\RefundBlocker;
use PetersDevelopment\LarsaSub\Exceptions\RefundServiceException;

try {
    $request = app(RefundService::class)->refund($charge, idempotencyKey: "ticket-{$ticket->id}");
} catch (RefundServiceException $e) {
    if ($e->reason === RefundBlocker::AboveCeiling) {
        // ask for a lower amount
    }
}
Case Value Meaning
NotPaid not_paid The payment was never paid
NoProviderPayment no_provider_payment Settled without a provider payment (credit or €0,00) — use returnAppliedCredit()
MandateSetup mandate_setup A mandate verification; never refunded through the package
InvalidAmount invalid_amount Below €0,01, or not positive
NothingRefundable nothing_refundable Everything already went back, or Mollie says it cannot be refunded
AboveCeiling above_ceiling More than is still refundable
UnreadableHistory unreadable_history A stored refund or chargeback cannot be read; an operator has to look first
ProviderAccountUnresolvable provider_account_unresolvable Connect: the tenant's Mollie account cannot be reached
ProviderUnsupported provider_unsupported The payment's provider does not implement refunds
ProviderRefused provider_refused Mollie refused; its message is appended

Besides refusals, refund() can throw:

Charges paid partly with credit

When credit was applied to a charge, Mollie only collected amount; the rest came out of the wallet and was never at Mollie. refund() returns the Mollie part only. The credit part goes back through the credit service:

use PetersDevelopment\LarsaSub\Service\CreditService;

app(RefundService::class)->refund($charge, idempotencyKey: "reverse-{$charge->id}");
app(CreditService::class)->returnAppliedCredit($charge);

returnAppliedCredit(RecurringCharge $charge, ?float $upTo = null, ?string $description = null):

It does not wait for the Mollie refund: returning credit is a real movement on the wallet, and it gets its own credit note. If you want the credit to follow only a completed Mollie refund, call it from your RecurringChargeRefundedEvent listener instead.

A single payment never has credit applied, so this section — and the next one — is about recurring charges only.

Access. A refund does not change BillableSubscription::paidThrough(): whether refunding a period ends the customer's access is your decision. Listen for RecurringChargeRefundedEvent if it should.

Giving collected money back as credit

returnCollectedAsCredit(RecurringCharge $charge, ?float $amount, string $key, ?string $description = null) gives money Mollie collected on a paid charge back as wallet credit instead of a refund — what dunning.double_paid = credit does. It is the credit-side twin of refund(), not of returnAppliedCredit():

app(CreditService::class)->returnCollectedAsCredit($charge, 10.00, key: "goodwill-{$ticket->id}");

Refunds and plan changes

A plan change credits the unused part of the period from what the customer actually kept, and it reads Mollie first: before pricing the change the package fetches the current period's refunds and chargebacks, so a refund made in the Mollie dashboard is netted and one canceled there is not.

The other direction is not covered: the ceiling does not know about credit already given from this payment. A plan change credits the unused part of the period to the wallet, and ending a cost line refunds its prepaid days as credit. Refund the same period in full afterwards — or return its applied credit with returnAppliedCredit() — and the customer has both. The package nets a refund off a LATER plan-change credit, not the other way round. Check the customer's credit ledger before refunding a period they already changed away from.

Credit notes

With invoicing on, each completed refund and each credit return produces a credit note for exactly that amount, through one cumulative per invoice. A charge refunded at Mollie and given its credit back ends voided, with credit notes that add up to the invoice total and its VAT — in either order. Do not also call InvoiceService::createCreditNote() for these; see Invoicing.

A credit note needs an invoice to credit. A single payment is invoiced only on demand, so a refund of one that was never invoiced gets no credit note.

Known limits:

Connect

Tenancy

refund() is a nested call: it activates the registration's stored tenant and never leaves a context you established. A platform billable has no stored tenant, so for it refund() changes nothing. In a command or job that refunds several billables, switch to each billable's tenant yourself (what your connect.tenant_context hook does), or leave the tenant context for a platform billable, before each call. Otherwise, after a tenant's refund, a platform refund's listeners run in that tenant's context. A stored tenant that no longer resolves, or a tenant-context hook that throws, is refused with ProviderAccountUnresolvable. Your listeners on RefundRequestedEvent run in whatever context your process holds; the completion arrives through the webhook, which establishes its own.

returnAppliedCredit() touches no tenant context at all: everything it writes lives on the package's own connection.

Testing

A fake needs an answer for every request refund() makes, and a missing answer does not always fail loudly — the syncs are best-effort and swallow it:

When Request class
Always, first GetPaymentRequest
The payment links refunds DynamicGetRequest (the refund list, through the link) and GetPaginatedPaymentRefundsRequest (the adoption walk)
The payment links chargebacks another DynamicGetRequest — use a sequence when both links are present
Creating CreatePaymentRefundRequest
After creating GetPaymentRequest again, plus the refund list when linked (the sync that stores the new refund)

For a payment without earlier refunds or chargebacks, the minimum is:

use Mollie\Api\Fake\MockResponse;
use Mollie\Api\Http\Requests\CreatePaymentRefundRequest;
use Mollie\Api\Http\Requests\GetPaymentRequest;
use Mollie\Api\Resources\Payment;
use Mollie\Api\Resources\Refund;
use Mollie\Laravel\Facades\Mollie;

Mollie::fake([
    GetPaymentRequest::class => MockResponse::resource(Payment::class)->with([
        'resource' => 'payment', 'id' => 'tr_123', 'status' => 'paid',
        'amount' => ['value' => '30.00', 'currency' => 'EUR'],
        'amountRemaining' => ['value' => '30.00', 'currency' => 'EUR'],
    ])->create(),
    CreatePaymentRefundRequest::class => MockResponse::resource(Refund::class)->with([
        'resource' => 'refund', 'id' => 're_123', 'status' => 'queued',
        'amount' => ['value' => '30.00', 'currency' => 'EUR'],
    ])->create(),
]);

This leaves the sync after the create unanswered, so provider_refund_details stays empty in that test. To assert on it, answer the sync too. A request class answers once per MockResponse, so give it a SequenceMockResponse; the second payment must link its refunds, and that list is one more DynamicGetRequest:

use Mollie\Api\Fake\SequenceMockResponse;
use Mollie\Api\Http\Requests\DynamicGetRequest;

GetPaymentRequest::class => new SequenceMockResponse($paymentBefore, $paymentWithRefundsLink),
DynamicGetRequest::class => $refundListContainingTheNewRefund,

When a payment links both refunds and chargebacks, DynamicGetRequest is asked in this order: refund list, chargeback list, and — after the create — the refund list again. A payment without amountRemaining is one Mollie will not refund (NothingRefundable). Fake again before a second refund() call.

Mollie::fake() swaps the facade client only. In Connect mode the resolver builds a new client per tenant, which the fake does not reach — test the refund flow in simple mode, or fake at your own seam.