Files
core/docs/payments.md
T

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 — 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.

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/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).