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, so successUrl and cancelUrl cannot be honoured per checkout in this mode — they travel in the transaction's custom_data, where your reconciliation code can still read them.
  • paymentPageUrl — your own page running Paddle.js. Honours the whole CheckoutRequest, 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.