12 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.
The precedent for this originally came from reading lunarphp/stripe's own source
(StripePaymentType::authorize(), ProcessStripeWebhook, WebhookController) — that package
solved this the same way, writing the correlating ids as real, typed columns on its own
StripePaymentIntent model rather than a generic opaque blob. lunarphp/stripe has since
been removed from this project in favour of depending on stripe/stripe-php directly (see
CHANGELOG.md) — Modules\Core\Payment\Models\StripePaymentIntent is now a first-party model
over the same table shape, kept for exactly the same reason.
StripePaymentDriver follows this pattern: it reads cart_id/order_id out of $context
at pay()/authorize() time and writes them onto its own StripePaymentIntent row (src/ Payment/Models/StripePaymentIntent.php, table stripe_payment_intents), then reads them back
the same way in handleCallback(). No generic context json column beyond what that table
already carries (context, added for a different purpose — see that migration's own
docblock), 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.
Reconciliation — a charge that succeeds on Stripe but is never written locally
This app never creates or reuses a Stripe Customer object — every PaymentIntent is a
one-off (StripePaymentDriver::createAndConfirm()'s own $params never includes a customer
key), and nothing calls Stripe's Customer API anywhere in this codebase. That's a deliberate
choice, not an oversight: a Customer object only earns its keep if something actually needs it
(saved/reusable payment methods, subscriptions, Stripe-side lifetime-value grouping across
orders) — none of which exist in this checkout flow today. Creating one anyway would just be
more PII sitting on a third party's servers for no functional benefit, and it would become
another cross-reference a future Payment privacy provider has to account for (detaching/
deleting the Customer on erasure, not just the local PaymentIntent row). If a real feature
needs it later (e.g. "save my card"), add it then, scoped to that feature.
The gap this creates: with no Customer object and no other identifying field previously sent
to Stripe, a PaymentIntent that succeeds on Stripe's side but is never written to our own DB
(e.g. a database outage at exactly the wrong moment, between Stripe confirming the charge and
rememberIntent()'s insert) would be untraceable back to a cart or order — nothing to
search Stripe's dashboard by except amount, timestamp, and card last-4.
Fix: createAndConfirm() now sets metadata: ['cart_id' => ..., 'order_id' => ...]
(array_filter()-ed, since order_id isn't known yet at initial pay()/authorize() time —
same null-coalesce rememberIntent() already does) on every PaymentIntent. This is metadata
only, visible on Stripe's own dashboard/API for manual reconciliation — it does not create a
Customer object and does not change anything about how handleCallback()/webhook correlation
works (that still goes through stripe_payment_intents, per "Async resolution" above). It's
purely a recovery aid for the case where our own write never happened at all.
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).