# 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. --- ## GDPR erasure/export `Modules\Core\Payment\Privacy\PaymentDataProvider` covers `lunar_transactions` (`card_type`/`last_four`) and `stripe_payment_intents` — see `docs/privacy.md` for the full right-of-erasure/right-of-access design. Pseudonymizes card metadata on erasure (same tax/accounting retention reasoning `Order`'s own provider uses) and deletes the Stripe correlation rows outright, since their only purpose — resolving an async webhook callback, see "Async resolution" above — has already been served by the time an erasure request runs. No Stripe Customer object exists anywhere in this app (see "Reconciliation" above) for this provider to also request deletion of. --- ## 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).