# 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 ```php 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. ```php 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: ```php // 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).