2026-09-03 17:00:24 +03:00
# 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.
2026-09-16 01:39:23 +03:00
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.
2026-09-03 17:00:24 +03:00
### 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.
---
2026-09-16 01:39:23 +03:00
## 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.
---
2026-09-03 17:00:24 +03:00
## 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).