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
- Requesting a refund
- What you get back
- Retries and idempotency
- Why a refund is refused
- Charges paid partly with credit
- Giving collected money back as credit
- Refunds and plan changes
- Credit notes
- Connect
- Tenancy
- Testing
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
);
$amountis in the payment's currency.nullrefunds everything that is still refundable (see the ceiling below).$descriptionis sent to Mollie, which may show it to the customer depending on the payment method.nulluses the payment's own description, or its reference when it has none. It is cut to 255 characters.$idempotencyKey— pass one from anything that can retry. See Retries and idempotency.
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:
- it does not ask Mollie, so it can be higher than what
refund()accepts after its own sync; - it applies none of the guards — an unpaid charge, a mandate verification
or a charge settled entirely with credit still gets a number; check
paid_onandprovider_payment_idyourself; - it throws
RefundServiceException(UnreadableHistory) on an unreadable stored entry; - leave its second parameter alone — that is where
refund()passes Mollie's own remaining amount.
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
queuedrefund 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 forqueued,pendingorcanceledrefunds, only forprocessing,refundedandfailed.
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:
- Pass your own key from any job, listener or command: your reason for refunding (a support ticket, an order line, a follow-up charge id). Never the refund id — you only have that after the call succeeded.
- Without a key the package derives one from the payment, the amount and how
many refunds were stored before the call. That protects a retry that happens
before anything else changed on the payment. Once another refund was stored in
between — by a webhook, or by the sync at the end of a successful
refund()— the derived key moves on, and a retry is a new refund.
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:
InvalidArgumentException— the model is not aRecurringChargeor aSinglePayment(a programming error);- the Mollie exception, unchanged — a timeout, 5xx or 409: retry with the same key (see above);
- whatever your own code throws from a refund-completion hook
(
IsPayable::onPaymentRefunded(), a synchronous…RefundedEventlistener): the sync at the start ofrefund()delivers refunds that completed since the last webhook, and a throw there aborts before anything is requested.
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):
$upTois a cumulative target: "bring what was returned from this charge up to this much".nullmeans all of the applied credit; a target above it is capped. That makes it idempotent with an amount too — a job that asks for €10 and is retried asks for €10 again and nothing more goes back. It returns what this call returned.$descriptionis the text of the credit-ledger row. The customer sees it in the credit dashboard and, when thecredit_addednotification is on (the default), in the credit-added mail that this call triggers. The default is a translated line in the billable's language naming the charge's reference; pass your own text in the customer's language.- It never changes the charge's
amountorcredit_applied. - It returns
0.0without doing anything for an unpaid charge or one without applied credit, and throwsInvalidArgumentExceptionfor a target ≤ 0.
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}");
-
$amountis per call (null= the whole ceiling); above the ceiling it is refused withabove_ceiling, exactly asrefund()refuses it — never cut down to what still fits; ≤ 0 throwsInvalidArgumentException. It returns what stands booked under$keyafter the call. -
$keyis required and is the idempotency key: a second call with the same key books nothing and returns what the first one booked. An empty key throwsInvalidArgumentException. -
It refuses like
refund(), with aRefundServiceException:not_paid,mandate_setup,no_provider_payment(nothing was collected — give applied credit back withreturnAppliedCredit()),above_ceilingandunreadable_history. -
The ceiling comes from the STORED refund and chargeback sets — no Mollie call from a credit primitive. A refund made in the Mollie dashboard since the last webhook is not seen. Sync first, as the package's own settlement does — the key only matters for adopting a refund, so any string will do, and in Connect it needs the tenant's
refunds.read:PaymentProviderFactory::make($charge->paymentMethod->provider) ->prepareRefund($charge, hash('sha256', "sync|{$charge->id}")); -
The ledger row carries
meta.kind = CreditTransaction::KIND_COLLECTED_RETURNandmeta.key. Because that money was at Mollie, the ceiling subtracts it —refund()afterwards refuses the same amount — and the invoice gets a credit note for it like a refund would.
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:
- An invoice created after a refund or credit return — invoicing switched on later, or first downloaded then — totals on the full amount and gets no credit note for what already went back.
- The same holds after a chargeback:
larsasub:generate-missing-invoicesinvoices a charged-back original in full, and its paid follow-up has an invoice of its own — the period is then invoiced twice against one collection. When you backfill with invoicing just switched on, check the charges with chargebacks or refunds afterwards, and issue the credit note for what went back yourself withInvoiceService::createCreditNote()on the invoice the backfill created. - A credit return after a chargeback that the bank reversed gets no note of its own: the chargeback's note still stands (the package has no debit note).
Connect
- The refund runs on the tenant's Mollie account and comes off its balance, including Mollie's refund fee.
- Application fees are not refunded. Mollie keeps the platform's fee on the platform's balance; the package does not move it back. See Connect.
- It needs
refunds.write(to create) andrefunds.read. The defaultconnect.scopesinclude both; if you overrode the list, both the boot check andlarsasub:doctorwarn. The two fail differently:- without
refunds.write,refund()fails withProviderRefused; - without
refunds.readthe package cannot read refunds at all. No refund ever completes in the package — no event, no credit note, not even for a refund made in the Mollie dashboard — while the firstrefund()on a payment still sends money back. Only a later one on the same payment is refused.
- without
- A tenant whose account cannot be resolved is refused with
ProviderAccountUnresolvable— the request is never routed to the platform's own key.
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.