Checkout
Requires Clarity 1.2.0 or newer. The skeleton pins
monad/clarity: ^1.0, which also resolves to releases without this service —
run composer update monad/clarity if Services\Checkout is missing.
Checkout is an abstract facade every payment gateway adapter implements, in the same
shape as LLM: four methods, taking and returning
gateway-agnostic value objects, so switching gateway changes one constructor call and nothing
else. Two adapters ship:
CheckoutAdapters\StripeCheckout (1.2.0), Stripe's hosted Checkout Sessions, and
CheckoutAdapters\PaddleCheckout (1.3.0), Paddle Billing one-time payments.
They are not interchangeable in every respect, and the difference is not cosmetic: Stripe is a payment processor, Paddle is a merchant of record — it is the legal seller, and it calculates and remits sales tax for you. That is the reason to choose it, and the reason it behaves differently in the three places noted under Paddle below.
use Monad\Clarity\Services\CheckoutAdapters\StripeCheckout;
use Monad\Clarity\Services\Checkout\{CheckoutRequest, Money};
use Monad\Clarity\Services\HttpClient;
$checkout = new StripeCheckout(
apiKey: $stripeSecretKey,
httpClient: new HttpClient(),
webhookSecret: $stripeWebhookSecret,
);
$session = $checkout->createCheckout(new CheckoutRequest(
reference: 'ORDER-1001', // your own order reference
amount: new Money(2500, 'USD'), // 2500 cents
successUrl: 'https://shop.test/thanks',
cancelUrl: 'https://shop.test/cart',
));
$session->redirectUrl; // send the customer here
Amounts are integer minor units
Money holds a whole number of a currency's smallest unit beside its ISO 4217 code.
No decimal or float type appears anywhere in Checkout, and that is deliberate: JPY
and KRW have no minor unit, and BHD and KWD have three
decimal places, so any "multiply by 100" conversion layer is a rounding bug waiting for the
first non-USD merchant.
new Money(2500, 'USD'); // $25.00
new Money(2500, 'JPY'); // ¥2500 — not ¥25, and never multiplied
The four operations
$checkout->createCheckout(CheckoutRequest $request): CheckoutSession;
$checkout->retrieveStatus(string $reference, int $timeoutSeconds = 30): TransactionSnapshot;
$checkout->parseCallback(string $rawBody, array $headers): CallbackEvent;
$checkout->refund(RefundRequest $request): RefundResult;
Every one throws Checkout\CheckoutException rather than returning a failure value,
so a gateway error cannot be mistaken for a declined payment.
Paddle
Requires Clarity 1.3.0 or newer. Same four methods, same value objects; what follows is only where Paddle differs from Stripe.
use Monad\Clarity\Services\CheckoutAdapters\PaddleCheckout;
$checkout = new PaddleCheckout(
apiKey: $paddleApiKey, // pdl_live_apikey_... / pdl_sdbx_apikey_...
httpClient: new HttpClient(),
webhookSecret: $paddleNotificationSecret, // pdl_ntfset_... — not the API key
hostedCheckoutUrl: 'https://pay.paddle.io/checkout/hsc_...',
taxCategory: 'standard',
baseUri: 'https://sandbox-api.paddle.com', // omit for live
);
Two ways to reach the payment page — pick exactly one
Paddle has no gateway-hosted page by default; it renders checkout through Paddle.js. So the adapter takes one of two arguments and refuses to create a checkout with neither or both:
-
hostedCheckoutUrl— the link copied from Paddle › Checkout › Hosted checkout. Paddle hosts the page; you build nothing. Pass the whole URL verbatim. Its post-payment redirect is configured on the link itself, sosuccessUrlandcancelUrlcannot be honoured per checkout in this mode — they travel in the transaction'scustom_data, where your reconciliation code can still read them. -
paymentPageUrl— your own page running Paddle.js. Honours the wholeCheckoutRequest, at the cost of hosting a page.
Two account settings must be in place first, or transaction creation fails
outright rather than at the redirect. Set a default payment link under Paddle ›
Checkout › Checkout settings — without one Paddle refuses to create any
transaction, and a per-transaction override does not substitute for it. And if you use
paymentPageUrl, approve that domain under Paddle › Checkout ›
Website approval. These are two separate lists, in sandbox as much as in live: Paddle.js
will happily render on a domain the API still rejects as a checkout URL.
No idempotency keys
Stripe takes an Idempotency-Key on every write. Paddle takes one on none, so
CheckoutRequest's key is carried as metadata for auditing and cannot be enforced.
A retried createCheckout() creates a second Paddle transaction — harmless in
itself, since it is a draft and no money moves, but do not retry blindly. For refunds, where a
blind retry costs real money, the adapter reads the transaction's line items and every
adjustment already made against it and refuses one that would exceed the remaining balance.
Refunds are asynchronous
A live Paddle refund is created pending_approval and reviewed by Paddle before the
money moves; sandbox approves automatically on a timer, which is exactly the difference that
hides this in testing. RefundResult::$status carries Paddle's own word verbatim, so
check it rather than assuming a returned RefundResult means refunded. While one
refund is pending, Paddle refuses any further adjustment against that transaction — so
partial refunds on one transaction are serialised behind its review.
taxCategory is worth a moment: Paddle is the merchant of record, so this decides
how the sale is taxed. The default standard is right for ordinary goods and
services and wrong for ebooks, SaaS, and software, each of which has its own category.
Verifying a callback
Pass Request's rawBody() — the exact bytes,
never a decoded-and-re-encoded body. Signatures are computed over the bytes as sent, so
re-serialising fails verification even for a genuine callback. A CallbackEvent only
ever exists for a callback whose signature already verified; construction is the proof.
Route::post('/webhooks/stripe', function (Request $request) {
$event = $checkout->parseCallback($request->rawBody(), [
'Stripe-Signature' => $request->header('Stripe-Signature') ?? '',
]);
$ledger->recordCallback($event); // idempotent — see below
return Response::noContent();
});
The header array is built explicitly because Request
exposes header() for a single header, not a bulk accessor. Names are matched
case-insensitively, so the casing you use here does not matter. For Paddle the header is
Paddle-Signature and the secret is the notification destination's
pdl_ntfset_... key — one per destination, and not the API key.
Both adapters accept only their own checkout events and throw on anything else, which matters
because an endpoint receives every event type enabled on it. Stripe sends every enabled type to
one URL by default; a Paddle notification destination does the same. Route only checkout events
to parseCallback(), or catch the exception and ignore the rest — otherwise an
unrelated customer.created is an error in your logs on a schedule.
Exclude that path from Csrf — a gateway has no session and no token. The route must also be publicly reachable and unauthenticated.
The transaction ledger
Checkout\TransactionLedger is the stateful half, kept out of the facade so every
gateway shares one persistence layer. It records transactions, an insert-only status history,
and refunds. Create its tables with
checkout:install.
$ledger = new TransactionLedger();
$transactionId = $ledger->open($request, $session); // status: pending
$ledger->recordCallback($event); // returns false on a redelivery
$ledger->recordSnapshot($snapshot); // reconcile a callback that never arrived
$ledger->recordRefund($transactionId, $refundResult);
$ledger->refundableAmount($transactionId); // Money
$ledger->statusHistory($transactionId); // every status the gateway ever reported
Both are safe to call twice. Gateways redeliver callbacks by design, and a gateway call that times out after being accepted gets retried — so callbacks and refunds are keyed on the gateway's own identifiers under unique indexes. A redelivery is recognised and ignored, not recorded again.
Four statuses, and refunds are not one of them
TransactionStatus::Pending | Success | Failed | Cancelled
A refund is its own record, not a fifth status. A partially refunded transaction has no honest
single-status answer, so a transaction that succeeded stays Success for its
lifetime while refunds accumulate against it — which is also what makes partial and repeated
refunds representable.
Reconciling a callback that never arrived
Callbacks get dropped, delayed, or arrive while your site is down. retrieveStatus()
asks the gateway directly and is the authoritative path; the ledger treats a snapshot exactly as
it treats a callback. Run it for any transaction still Pending after a sensible
interval rather than trusting the webhook to be your only signal.
Events
The ledger dispatches Event::PAYMENT_COMPLETED
when a transaction settles as successful — once per transaction, never on a redelivered
callback, so a listener that ships goods or emails a receipt runs exactly once.
What's deliberately not here
No custom checkout page (Stripe Elements/PaymentIntents) and no built-in reporting — both are
specified but unbuilt. Paddle covers one-time payments only; its subscription surface is not
wrapped, and the adapter never sends a billing cycle, so a subscription cannot be created by
accident. Tracking a Paddle refund from pending_approval to its final state is
also not built — the adapter initiates refunds and reports what Paddle returned.
Eight further gateway adapters are reserved and unbuilt; an unbuilt
adapter is an absent file, never a stub, so a missing class means it genuinely does not exist
yet rather than existing and silently doing nothing.
Next steps
- checkout:install — creating the three tables the ledger needs.
- HttpClient — what every adapter sends requests over.
- Csrf — excluding your callback route.