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.
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::$amountis neither sent nor believed. Passnew Money(0, $currency)— read as "the catalogue owns this" — and read the real figure back offCheckoutSession::$amount. Passing the true price compiles and works, and is exactly the duplication catalogue mode removes. A request carryinglineItemsis 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.