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