10 KiB
Payment — Design Notes
Status: abstraction layer built, drivers/wiring in progress. Payment is designed as a
standalone module: it never calls into Checkout or Order, never touches their Eloquent
models, and communicates only via events. This document is the design spec for that
abstraction — contracts, DTOs, events — independent of how Checkout/Order end up consuming
it (that wiring is a separate, later pass).
Operations, not gateways
The driver contracts model the actual operations a payment gateway can perform, not vendor terminology. Every real gateway checked while designing this converges on the same small set under different names:
| Operation | Mastercard | Stripe | Nexi |
|---|---|---|---|
| Atomic charge (authorize+capture in one call) | Pay |
capture_method: automatic |
ActionType::PAY() |
| Hold only, settle/release later | Authorize |
capture_method: manual |
ActionType::PREAUTH() |
| Settle a prior hold | Capture |
PaymentIntent::capture() |
CaptureRequest/CaptureResponse |
| Release a prior hold without settling | Void/Cancel |
PaymentIntent::cancel() |
CancelRequest/CancelResponse |
| Reverse settled funds | Refund |
Refund::create() |
(refund endpoint) |
A driver implements only the interfaces its gateway actually supports:
- An offline/cash type (
cash-on-delivery,cash-in-hand) only ever settles atomically — implementsSupportsPayalone. - A card gateway capable of either mode per-transaction (Stripe, most card processors)
implements
SupportsPay,SupportsAuthorization,SupportsCaptures,SupportsVoids, andSupportsRefundsall at once — which one gets called for a given attempt is the caller's policy choice (e.g.config('lunar.stripe.policy')), not something baked into the driver's shape. - A redirect/wallet gateway with no separate hold step (Viva/Klarna in typical flows)
implements
SupportsPayandSupportsRefunds, neverSupportsCaptures/SupportsVoids.
pay() and authorize() stay separate methods even when a gateway implements both as "the same call with a flag"
Stripe has no separate authorize/pay API endpoints — one PaymentIntent, confirmed with
either capture_method: automatic or manual. Mastercard and Nexi do have genuinely
separate operations. The contract abstracts over both shapes uniformly: every driver capable
of both exposes two distinct methods, pay() and authorize(). A Mastercard-style driver
calls two different endpoints under the hood; a Stripe-style driver calls the same endpoint
twice with a different flag each time. Neither difference is visible to a caller.
capture()/void() are only ever valid against a prior authorize()
They are not standalone operations — capture() settles a specific hold identified by the
reference authorize() returned; void() releases that same hold instead. A driver that
never implements SupportsAuthorization never produces a reference either of these methods
could act on.
PaymentResult — the one return shape, every operation, every driver
enum PaymentResultStatus { case Succeeded; case Failed; case Pending; }
final class PaymentResult {
public function __construct(
public readonly PaymentResultStatus $status,
public readonly string $reference,
public readonly int $amount,
public readonly ?string $failureReason = null,
public readonly bool $retriable = false,
public readonly array $raw = [],
public readonly array $meta = [],
) {}
}
Real gateway responses vary wildly in richness — confirmed by reading three SDKs directly:
- Stripe's
PaymentIntentis rich:status,amount,amount_capturable,amount_received,last_payment_error, a fullgetLastResponse(). - Nexi's
CaptureResponse/CancelResponseare minimal: justoperationId+operationTime— no echoed amount or status at all. Success is inferred from getting a response rather than an SDK exception. - Mastercard's gateway sits in between, with
gatewayCode/acquirerCode/merchantAdviceCode.
PaymentResult only requires what every driver can always know: status, reference,
amount (the amount we requested — not necessarily echoed back by a sparse gateway like
Nexi's capture). Everything else is best-effort: failureReason/retriable are normalized
only when the gateway has something to normalize from; raw is the unconditional escape
hatch — the untouched gateway response body, always populated, for genuine audit fidelity
regardless of how sparse the normalized fields ended up.
retriable — real on some gateways, absent on others
Stripe classifies declines as soft (do_not_honor, insufficient_funds — worth retrying,
after a delay) vs. hard (stolen_card, expired_card — never retry the same method).
Mastercard has the equivalent via authorizationResponse.merchantAdviceCode and card-scheme
soft-decline codes. Nexi has no such signal at all — OperationResult is just
DECLINED/DENIED_BY_RISK/FAILED/etc. with no retriability classification. retriable
therefore defaults to false (assume not safely retriable) rather than guessing when a
driver's gateway has nothing to base it on.
Events — one terminal pair per operation, keyed to the business fact, not the call path
Modules\Core\Payment\Events:
| Event pair | Dispatched by |
|---|---|
PaymentAuthorized / PaymentAuthorizationFailed |
SupportsAuthorization::authorize(), or a later HandlesPaymentCallback::handleCallback() resolving it |
PaymentCaptured / PaymentCaptureFailed |
SupportsPay::pay() or SupportsCaptures::capture() |
PaymentVoided / PaymentVoidFailed |
SupportsVoids::void() |
PaymentRefunded / PaymentRefundFailed |
SupportsRefunds::refund() |
PaymentCaptured is deliberately the same event whether money was taken via pay() (one
gateway call) or authorize() → capture() (two calls) — "a payment has been captured" is
the same business fact either way, and a listener reacting to it never needs to know which
path produced it. There is no separate "payment succeeded" wrapper event distinct from
PaymentCaptured.
Every event carries {type: string, result: PaymentResult, context: array}. Payment has no
concept of a Cart, an Order, or a checkout fingerprint — $context is an opaque bag the
caller hands in on the way down (pay($type, $data, $context)) and gets back untouched on
whichever event that call (or a later handleCallback()) produces. Each listener interprets
$context on its own terms, or ignores the event if the keys it needs aren't present —
Checkout is only one possible consumer of these events, not the only one.
Async resolution — HandlesPaymentCallback
Only implemented by a driver whose pay()/authorize() can return PaymentResultStatus::Pending
— a redirect the shopper completes elsewhere, a webhook that arrives later. A driver whose
gateway always resolves synchronously never implements this.
public function handleCallback(string $reference, array $data, array $context = []): PaymentResult;
Resolves into the same event pair the original pay()/authorize() call would have
produced had it resolved synchronously.
The correlation problem: handleCallback() runs in a different request
$context passed into the original pay()/authorize() call does not survive to
handleCallback() on its own — that call is typically a separate HTTP request (a webhook)
with no memory of the request that started the payment. Something has to persist enough to
answer "which order/cart does gateway reference X belong to?" between the two calls.
Read directly from lunarphp/stripe's own source (StripePaymentType::authorize(),
ProcessStripeWebhook, WebhookController) to see how Lunar itself solves this — confirmed
it does not stash a generic opaque blob. It writes the correlating ids as real, typed
columns on Lunar\Stripe\Models\StripePaymentIntent (cart_id, order_id) at the moment the
intent is created/first seen, then reads them back the same way when the webhook arrives:
// ProcessStripeWebhook::handle() — falls back through two real lookups,
// neither of them a generic context blob:
$cart = StripePaymentIntent::where('intent_id', $this->paymentIntentId)->first()?->cart
?: Cart::where('meta->payment_intent', '=', $this->paymentIntentId)->first();
StripePaymentDriver follows this exact precedent: it reads cart_id/order_id out of
$context at pay()/authorize() time and writes them onto its own StripePaymentIntent
row (a table already owned by lunarphp/stripe, already shaped for exactly this), then reads
them back the same way in handleCallback(). No generic context json column, no new table.
This pattern is per-driver, not a shared table
stripe_payment_intents is Stripe-specific — keyed on intent_id, typed around
Stripe\PaymentIntent's own status values. It cannot be reused as-is for a future non-Stripe
async driver (Nexi, Viva): that driver's own gateway reference has a different shape entirely,
and shoehorning it into Stripe-named columns would make the table misleading. The pattern
generalizes — any driver needing async callback resolution owns a small table keyed by its
own gateway's reference, storing whatever correlation data that driver specifically needs —
but each driver gets its own table, matching what it actually needs to correlate, rather than
a shared generic one.
Explicitly out of scope for this pass
Checkout/Orderwiring — howCheckoutcalls intoPayment, howOrder/Checkoutreact toPayment's events, where a draftOrdergets created relative to whenPaymentis called. Deliberately designed and built separately, afterPaymentitself was complete —Paymentmust stand on its own regardless of what ends up consuming it.Transactionpersistence — Lunar's owntransactionstable (type:intent/capture/refund,parent_transaction_idchaining) already models the audit trail these events would feed, once a listener is built to write to it.Paymentitself does not writeTransactionrows — see the events table above; that is a listener's job, in whichever module ends up owning the write (likelyOrder, sinceTransaction.order_idis required).