190 lines
10 KiB
Markdown
190 lines
10 KiB
Markdown
# 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).
|