goldnead / statamic-payments
Take payments in Statamic with Mollie: hosted checkout, a webhook that never trusts the caller, idempotent fulfilment.
Package info
github.com/goldnead/statamic-payments
Type:statamic-addon
pkg:composer/goldnead/statamic-payments
Requires
- php: ^8.2
- mollie/mollie-api-php: ^2.79 || ^3.0
- statamic/cms: ^6.0
Requires (Dev)
- goldnead/statamic-entitlements: ^1.1
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
README
Statamic Payments
Take payments in Statamic with Mollie — and never believe the caller.
Requirements
Statamic 6 · PHP 8.2+ · a database · a Mollie account.
Mollie rather than Stripe because this is built for a German and European audience: SEPA direct debit, Sofort, iDEAL and Bancontact are what people here actually reach for, and there is no monthly floor — which matters on a client site that takes four payments a month.
Installation
composer require goldnead/statamic-payments php artisan migrate php please vendor:publish --tag=statamic-payments-config
Set MOLLIE_KEY in your environment, then list what you sell.
Usage
Products
// config/statamic-payments.php 'products' => [ 'noten-paket' => [ 'name' => 'Notenpaket „Frühling"', 'amount_cent' => 1900, ], ],
amount_cent is an integer in minor units. Not a float: a float is how a cent goes missing every
thousand orders.
Starting a payment
use Goldnead\StatamicPayments\Support\Checkout; $checkout = app(Checkout::class)->start('noten-paket', [ 'email' => $request->input('email'), 'name' => $request->input('name'), ]); abort_if($checkout === null, 404); // no such product $checkout->payment; // the row return redirect()->away($checkout->checkoutUrl);
The amount is looked up, never accepted. Anything the buyer sends is a label on the order, not a term of it — a checkout that took a posted price would sell a €19 thing for a cent.
Reacting
use Goldnead\StatamicPayments\Events\PaymentPaid; Event::listen(PaymentPaid::class, function (PaymentPaid $event) { $event->payment->product; // what was bought $event->payment->email; // by whom $event->payment->amount_cent; });
Dispatched once per payment, guaranteed by a conditional UPDATE rather than by a check, and
the claim stands in the database before any listener runs. A listener may therefore grant access
without carrying its own idempotency for the ordinary case — redelivery, a duplicated request, two
deliveries landing together. PaymentFailed works the same way, on its own column.
The one case a listener must still think about: its own exception. If a listener throws, the claim is released, the exception reaches the caller, the webhook answers non-2xx and the provider delivers again — so that listener, and every other listener on the event, runs a second time. The alternative would be keeping the claim, and the failure mode of that is a customer who paid, got nothing, and no retry ever comes, silently, because the row says fulfilled. Given the choice, this package repeats rather than loses. Make irreversible work in a listener idempotent, or queue it.
What a payment means belongs to your site. There is one optional exception, below.
Entitlements (optional)
With goldnead/statamic-entitlements installed:
// config/statamic-payments.php 'entitlements' => ['enabled' => true], 'products' => [ 'noten-paket' => ['name' => '…', 'amount_cent' => 1900, 'grants' => 'noten-fruehling'], ],
Off unless all three are true: the sibling installed, the flag on, and the product carrying
grants. A failure in the sibling is logged and swallowed — the money was taken and the row says
so; an entitlements outage must not send the whole webhook round again.
In the Control Panel
Utilities → Payments. When, what, how much, paid or not, fulfilled or not, and who bought it.
Built on core's Listing, so search, sorting and column choice behave like the rest of the CP.
The column that earns the screen is Fulfilled. Mollie can tell you the money arrived; only the site
knows whether the buyer got anything for it. The Filters menu has one entry for exactly that
case: Paid, not fulfilled. It survives sorting, paging and a reload, and it can be kept as a saved
view.
Read-only. Refunds and disputes belong at Mollie, where they are complete and where the audit trail is.
Access is the access payments utility permission, which appears in Statamic's own permission list
once the addon is installed.
Subscriptions
Utilities → Subscriptions, next to it: the agreements rather than the money. Product, whether it
is a subscription or a payment plan, what one cycle costs, the rhythm, how many cycles have been
charged (2 / 3 while there is an end to count towards), the next charge, the status and the buyer.
Sorted by what is charged next, so what is about to happen is at the top.
Two filters: Status, and Still running — the second is isLive() asked of the query, which
is the "who is still being charged" list.
Clicking a product opens a read-only slide-over with the whole agreement and the cycles it has actually been paid: date, amount, status. The cycles are ordinary payments, so they also appear on the Payments screen.
Cancelling is available from a row's menu and from the bulk toolbar, and both run the same
Support\Subscriptions::cancel(): the provider is told first and its answer is what gets written. A
provider that refuses, or that accepts the call and goes on reporting the agreement as running,
produces a red toast and leaves the row untouched. Nothing here creates a subscription — one is
what a confirmed first payment leaves behind, never something typed into a form.
Access is the separate access subscriptions utility permission. "May read the till" is not the same
authority as "may end an agreement".
Order bumps and follow-up offers
A payment carries lines, not a single product, so a checkbox at checkout adding a second item is one payment with two lines:
app(Checkout::class)->start(['noten-paket', 'uebungsblaetter'], $buyer); app(Checkout::class)->start(['noten-paket' => 1, 'uebungsblaetter' => 3], $buyer);
All or none. A handle that is not in the catalogue refuses the whole checkout rather than quietly dropping the line — dropping it would charge the buyer for less than the page offered, and the first anyone hears of it is a customer who paid for two things and got one. Two currencies in one payment are refused for the same reason.
An offer after the payment, charged without new card details, is off by default and has its own page: docs/follow-up-offers.md. Read it before switching it on — the technical part is small, the part that decides whether you may ship it is not.
Extending the catalogue
Another addon can contribute priced things:
use Goldnead\StatamicPayments\Support\Catalogue; Catalogue::extend(function (string $handle): ?array { return $handle === 'offer:fruehling' ? ['name' => 'Frühlingsangebot', 'amount_cent' => 1200] : null; });
The configured catalogue wins. A resolver may add handles, never reprice one the site has already decided about — config is the site owner's word, an addon is a helper. And the amount still never comes from a request: a resolver runs on the server, which is the whole reason this is a seam rather than a parameter.
goldnead/statamic-offers is built on it.
Free products, and discounts
A product may cost zero. It is looked up in the catalogue like any other, the provider is never
called, and the payment is marked paid and fulfilled on the spot — same PaymentPaid event, same
one-time claim, so a listener that grants access cannot tell the difference and a free product is
not an account with nothing in it.
A missing or mistyped price is still refused. That is the distinction worth keeping: 0 is
somebody saying "this one is free"; null, a negative number, or '19,00' is a mistake, and a
mistake must never become a giveaway.
For a total lower than its lines, hand start() a Discount:
use Goldnead\StatamicPayments\Support\Discount; app(Checkout::class)->start( ['kurs', 'begleit-cd'], ['email' => $email], $returnUrl, new Discount(code: 'FRUEHLING', amountCent: 2500), );
This addon knows nothing about coupons and should not: what a code is worth, who may use it and how
often are questions about pricing, and pricing lives in statamic-offers. What lives here is the
consequence — the payment records discount_code and discount_cent, so an old receipt keeps
saying what came off even after the coupon is edited or expires.
A Discount is built by server-side code that looked something up, never from input. The checkout
clamps it anyway: it cannot exceed the total and cannot be negative, because a bug upstream should
cost a wrong price, not a payment the provider rejects.
Refunds
This addon does not make refunds. That happens in the provider's dashboard, where somebody with the authority to move money does it — a button for it behind a Control Panel permission would be a way to refund a customer by misclicking. What it does is take note, so everything downstream can react:
app(Refunds::class)->record($payment, 3000, 're_provider_id');
An amount and a time, never a status. An order half repaid is still a paid order — the money moved and the thing was delivered — and a status forced to choose between "paid" and "refunded" would be wrong about the other half.
PaymentRefunded carries both what came back this time and whether everything has now been repaid,
because those answer different questions. Passing the provider's own refund id makes it idempotent:
a re-announced refund is not booked twice.
On a full refund the access goes with the money. With the entitlements bridge on, every product line of the order is revoked with a reason. This is the one place in the bridge that revokes — a cancelled subscription keeps its paid period, because it was paid for; a refund is the opposite fact. A partial refund leaves access alone: half the money back is not half a course, and there is no honest way to withdraw half an access.
Deleting checkouts that were never paid
// config/statamic-payments.php 'prune_unpaid_after_days' => 30,
php artisan payments:prune-unpaid --dry-run
The reason is not tidiness. A paid order carries a retention obligation; an abandoned checkout carries the opposite — the row holds the name and email of somebody with whom no contract was ever concluded. Deleted rather than anonymised: an anonymised record with no purpose is still a record.
Never touched: anything paid, fulfilled, refunded, or that reached a final status — a failed attempt may still be a question later — and anything inside a running reminder sequence, because an automation whose trigger vanishes underneath it fails halfway through.
What an invoice will need
Two facts are recorded at checkout because they cannot be recovered afterwards.
The buyer's country, frozen on the payment — not a reference to a customer record that changes later. Pass it in the buyer array; it is normalised to ISO 3166-1 alpha-2 and anything else is dropped rather than stored, because a wrong VAT rate looks like an answer:
app(Checkout::class)->start(['kurs'], [ 'email' => 'wer@example.com', 'country' => 'AT', ]);
If the checkout has no country, the provider fills the gap at fulfilment wherever it recorded one —
country_source then names the provider instead of checkout. That distinction matters: the EU
asks for two non-contradictory pieces of evidence for a consumer's location, and "the card issuer
said so" is worth more than "somebody typed it".
The discount, per line. A payment records one discount amount; an invoice has to place it across
lines that may sit at different VAT rates. Sheet music at 7%, a course at 19%, one voucher across
both — from the total alone that split is unrecoverable. payment_items.discount_cent carries it,
distributed proportionally to line value, with leftover cents going to the largest lines first so
the parts always add up to the whole.
Existing rows keep null for the country and 0 for the line discounts. That is the honest state,
and everything downstream has to tolerate it rather than guess.
A subscription and the access it pays for
With statamic-entitlements installed and entitlements.enabled on, a subscription keeps its grant
in step by itself: a renewal pushes the window to the provider's own next_payment_at, and
cancelling or ending closes an open-ended grant at the end of the paid period.
Three rules, and each of them is deliberately not the obvious thing:
- A renewal is not a second grant.
grant()refuses to widen an existing window on purpose — a retry is not a renewal — so calling it once a month would write a grant a month, and a year of membership would be twelve rows. Requiresstatamic-entitlements1.1, which grew arenew()verb for this; against an older sibling the bridge stays quiet rather than writing the wrong thing. - Cancelling is not revoking. Somebody who cancels has paid for the period they are in and keeps it to the end. Revoking would take away time they bought, and in the sibling a revocation carries a reason precisely because it means "taken away deliberately".
- A renewal without a date from the provider changes nothing, and says so in the log. The provider knows when it will charge again; a guess here is a grant that ends too early or too late, and either way the customer finds out first.
Abandoned checkouts
Somebody started a checkout and did not finish it. Off by default:
// config/statamic-payments.php 'abandoned' => [ 'enabled' => true, 'after_minutes' => 60, ],
Then run the sweep on a schedule:
// routes/console.php Schedule::command('payments:sweep-abandoned')->hourly();
Each unpaid checkout past the waiting period dispatches CheckoutAbandoned once — claimed with a
conditional update on its own column, so overlapping sweeps cannot both announce the same one. A
payment that arrives afterwards clears the claim, and the sequence should end on PaymentPaid, which
is the honest signal that they bought it.
failed, expired and canceled are not abandoned: those have PaymentFailed already, and
announcing both would mean two mails about one thing.
With statamic-automations installed, the trigger Checkout Abandoned appears under Payments and
needs no code at all.
Before you build a mail step on this. The address on an unfinished checkout was given to complete a purchase, not to receive advertising. Whether a reminder may go out is a question of consent, not of configuration — and the suppression list belongs in front of the send either way. That is why this ships switched off.
Configuration
| Key | Default | What happens when it is wrong |
|---|---|---|
products |
none | Nothing can be bought. An addon that shipped prices would be wrong about every site. |
currency |
EUR |
Must match what your Mollie account accepts. |
return_url |
/danke |
Where the buyer lands after paying. Not where fulfilment happens. |
rate_limit |
60 |
Per minute, per IP, on the webhook. |
entitlements.enabled |
false |
On, plus a grants key on a product, grants that entitlement to the buyer. |
Security
The webhook has no signature, and does not need one. Mollie posts a payment id; this package reads nothing else from the request. The status is fetched from Mollie by that id, so the worst a forged call can do is make the server ask about a payment that is not paid.
That is a stronger position than a shared secret, because it does not depend on the secret staying secret. It is also the only design that survives someone replaying a genuine delivery.
Three consequences worth knowing:
- Fulfilment runs once. The claim is staked with a conditional
UPDATE, before any listener runs, so two simultaneous deliveries cannot both win it. A read-then-write guard loses that race, and a redelivery arriving twice within milliseconds is exactly that race. The exception is a listener throwing, above. - An id this site never issued creates nothing — even if it really is paid at Mollie. An id we did not issue is not evidence of an order here.
- The endpoint answers identically for known and unknown ids, and asks Mollie in both cases.
Asking only about known ids would answer, in the response time, the question the flat
200refuses: which payment ids this site has seen. - A payment nobody can match is logged, loudly. The one way a real site loses money is a
checkout dying between Mollie creating the payment and the id reaching the database. The buyer
pays, the webhook matches nothing. This package sends its own row id along as metadata and
recovers the payment from it; if even that fails,
Log::warningsays so instead of a silent200.
The return URL proves nothing. A buyer who closes the tab still paid; a buyer who reaches that page has not necessarily paid. Only the webhook decides.
What it stores
Provider and its id, product handle, amount and currency, status, buyer name and address, and the
timestamps for paid, fulfilled and failure-announced. A row is initiated until Mollie has
acknowledged it — deliberately not open, so a checkout that died mid-flight is not counted as an
order in flight. An address the buyer typed is never overwritten by the one on
their Mollie account — those are often different people.
Multi-site
Payments are not site-scoped. A payment is a transaction, not content.
Support
Only the latest version is supported. https://github.com/goldnead/statamic-payments/issues