Files
core/docs/payments.md
T

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 — implements SupportsPay alone.
  • A card gateway capable of either mode per-transaction (Stripe, most card processors) implements SupportsPay, SupportsAuthorization, SupportsCaptures, SupportsVoids, and SupportsRefunds all 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 SupportsPay and SupportsRefunds, never SupportsCaptures/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 PaymentIntent is rich: status, amount, amount_capturable, amount_received, last_payment_error, a full getLastResponse().
  • Nexi's CaptureResponse/CancelResponse are minimal: just operationId + 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/Order wiring — how Checkout calls into Payment, how Order/Checkout react to Payment's events, where a draft Order gets created relative to when Payment is called. Deliberately designed and built separately, after Payment itself was complete — Payment must stand on its own regardless of what ends up consuming it.
  • Transaction persistence — Lunar's own transactions table (type: intent/capture/ refund, parent_transaction_id chaining) already models the audit trail these events would feed, once a listener is built to write to it. Payment itself does not write Transaction rows — see the events table above; that is a listener's job, in whichever module ends up owning the write (likely Order, since Transaction.order_id is required).