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.

Subscriptions

Requires Clarity 1.4.0 or newer. PaddleSubscription sits beside PaddleCheckout — one gateway, two genuinely different flows, one class each. It implements the same four methods, which stay strictly transaction-scoped because a subscription's money movements are transactions, and adds seven of its own.

use Monad\Clarity\Services\Checkout\{BillingCycle, BillingInterval};
use Monad\Clarity\Services\CheckoutAdapters\PaddleSubscription;

$plan = new PaddleSubscription(
    apiKey: $paddleApiKey,
    httpClient: new HttpClient(),
    billingCycle: new BillingCycle(BillingInterval::Month, 1),  // every month
    trialPeriod: new BillingCycle(BillingInterval::Day, 14),    // optional
    webhookSecret: $paddleNotificationSecret,
    paymentPageUrl: 'https://your-approved-domain.example/subscribe',
    taxCategory: 'saas',                     // 'standard' is wrong for most subscriptions
);

The billing cycle is constructor configuration, not request data. createCheckout()'s signature is fixed by the facade and CheckoutRequest is frozen, so one adapter instance means one plan's terms — a merchant with monthly and annual tiers constructs two, which is the same shape as constructing one adapter per gateway.

Billing a plan you publish in Paddle

Requires Clarity 1.7.0 or newer. The adapter above describes its plan inline, which needs no Paddle catalogue at all. If you already maintain your tiers in the Paddle dashboard, name the price instead and let the catalogue state the terms:

use Monad\Clarity\Services\CheckoutAdapters\PaddleSubscription;

$plan = PaddleSubscription::forCatalogPrice(
    apiKey: $paddleApiKey,
    httpClient: new HttpClient(),
    catalogPriceId: 'pri_01hxxxxxxxxxxxxxxxxxxxxxxx',
    webhookSecret: $paddleNotificationSecret,
    paymentPageUrl: 'https://your-approved-domain.example/subscribe',
    taxCategory: 'saas',                     // only for a later inline changePlan() — see below
);

No billing cycle and no trial period — the price in Paddle carries both, which is the point. An application that bills published plans should not restate their prices in its own code, because the two disagree the first time one of them changes. PaddleCheckout takes the same catalogPriceId: argument for one-time sales, and one instance still means one plan: three tiers on two cycles is six adapters.

taxCategory is the one apparent exception, and only apparent. The catalogue checkout ignores it and reads the category off the catalogue product. It matters for one later path: a changePlan() onto a SubscriptionItem::inline() plan describes its price in your code, so it must state how that price is taxed in your code too. Pass the same category your catalogue product carries and a customer moved onto a bespoke plan is taxed as before. Omitting it leaves that path on standard — ordinary goods, and wrong for the SaaS a recurring charge usually is. Requires Clarity 1.7.1; in 1.7.0 this argument did not exist and that path was pinned to standard.

Two consequences worth knowing before you meet them in a settlement report:

  • CheckoutRequest::$amount is neither sent nor believed. Pass new Money(0, $currency) — read as "the catalogue owns this" — and read the real figure back off CheckoutSession::$amount. Passing the true price compiles and works, and is exactly the duplication catalogue mode removes. A request carrying lineItems is refused rather than silently overridden.
  • The currency comes from the price, not the request. Paddle does not refuse a currency that disagrees with a catalogue price — it silently converts, turning a 4900 USD price into 4218 EUR. Clarity therefore omits the field entirely in catalogue mode, so Paddle bills the price exactly as you published it.

Quantity is fixed at 1. Seat counts are set after the fact through changePlan() with SubscriptionItem::catalogPrice($priceId, $quantity).

One thing to get right yourself, because Clarity cannot check it: give a recurring price to PaddleSubscription, not to PaddleCheckout. A price id does not say whether it recurs — only Paddle knows — so a recurring pri_… passed to the one-time adapter creates a real subscription on a class that has no cancel(), pause() or changePlan() to manage it, and that reads Paddle's past_due as a dead payment rather than as dunning still in progress.

A subscription is born from a transaction

There is no POST /subscriptions. Paddle creates the subscription when a transaction whose price carries a billing cycle is actually paid — so createCheckout() returns a txn_, and the sub_ every operation below acts on does not exist yet. Two routes lead from one to the other, and it is worth handling both, because either delivery can be dropped:

// once the transaction settles — null means "not yet", never "never"
$subscriptionRef = $plan->subscriptionReferenceOf($snapshot);

// or off the subscription.created delivery, which also names its transaction
$event = $plan->parseSubscriptionCallback($rawBody, $headers);
$event->subscription->gatewayReference;      // sub_...
$event->subscription->transactionReference;  // txn_... (callbacks only)

Paddle creates the subscription asynchronously: a transaction read back the instant it settles can still report no subscription, and carry one seconds later. Poll again, or let the callback bring it.

A cancellation is a scheduled change, not a status

This is the one thing most likely to be got wrong, and it costs a paying customer their service. A customer who clicks cancel does not become Cancelled. They have paid through the end of the period, so the subscription stays Active with a pending change until that period runs out:

$s = $plan->cancel($subscriptionRef, SubscriptionEffectiveFrom::NextBillingPeriod);

$s->status;               // SubscriptionStatus::Active  — still!
$s->status->isTerminal(); // false
$s->isCancelling();       // true  — what a billing page shows
$s->accessEndsAt();       // when service actually stops

Gate access on the status, and show isCancelling() in the UI. Reading "a change is scheduled" as "revoke now" takes away something already paid for; reading only the status is right by accident until someone cancels immediately.

There is deliberately no grantsAccess(). Whether a PastDue customer keeps the product while Paddle retries their card is a product decision — most merchants allow a few days and show a banner, some cut off at once, and both are defensible. Clarity gives you the status, the pending change, and the date; the judgement stays yours.

Changing, pausing, resuming

$plan->changePlan($ref, $items, ProrationBillingMode::ProratedImmediately);
$plan->pause($ref, SubscriptionEffectiveFrom::NextBillingPeriod);
$plan->resume($ref, SubscriptionEffectiveFrom::Immediately);
$plan->removeScheduledChange($ref);   // withdraw a pending cancel or pause

changePlan() replaces the subscription's whole item set rather than adding to it — anything you leave out is removed, so pass add-ons again alongside a new tier. Its ProrationBillingMode is required and has no default, because Paddle has none and the five values differ by real money charged to a real customer: ProratedImmediately for an upgrade, ProratedNextBillingPeriod for a downgrade, and never DoNotBill for self-serve. Items may be catalogue prices or described inline, so no Paddle catalogue is needed for either.

While any change is scheduled, Paddle refuses every further change to that subscription. A cancellation set for the end of the period therefore blocks pausing, re-planning and cancelling immediately for as long as it stands — which is what removeScheduledChange() exists for, and why a customer who changes their mind is not stuck waiting out their own cancellation. Two smaller constraints, both found against the live API and both contradicting Paddle's documentation: a subscription can only be resumed Immediately, and it must already be paused rather than merely scheduled to pause.

Dunning is not failure

PaddleSubscription maps Paddle's past_due to TransactionStatus::Pending, where PaddleCheckout maps it to Failed. That difference is deliberate. On a one-time payment past_due is a dead payment; on a renewal it is dunning — Paddle keeps retrying and the charge often completes days later. Failed is terminal and the ledger will not move a transaction away from it, so sharing the mapping would strand every recovered renewal at failed in your books for good.

The subscription ledger

Checkout\SubscriptionLedger mirrors subscription state into checkout_subscriptions, the fourth table checkout:install creates. It is separate from TransactionLedger because a subscription is one mutable row, where a transaction has an insert-only status history — different invariants, so a different class rather than one whose guarantees have exceptions.

$subs = new SubscriptionLedger();

$subs->record($event);                       // false on a redelivery or an out-of-order event
$subs->recordSnapshot($snapshot);            // reconcile a callback that never arrived
$subs->statusOf($subscriptionRef);           // null if never seen — never throws
$subs->linkTransaction($subscriptionRef, $txnRef);

Redeliveries and out-of-order deliveries are both refused, so record() returning true means state genuinely moved and is a safe trigger for your own side effects. One caveat worth knowing: built-in tables store timestamps to the second, and Paddle can emit two distinct events inside one second, so within that window the events cannot be ordered and the last writer wins.

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. Stripe covers one-time payments only, as does PaddleCheckout, which never sends a billing cycle so a subscription cannot be created by accident; recurring billing is PaddleSubscription's job and Paddle's alone for now. 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 four tables the ledgers need.
  • HttpClient — what every adapter sends requests over.
  • Csrf — excluding your callback route.