13 KiB
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 — implementsSupportsPayalone. - A card gateway capable of either mode per-transaction (Stripe, most card processors)
implements
SupportsPay,SupportsAuthorization,SupportsCaptures,SupportsVoids, andSupportsRefundsall 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
SupportsPayandSupportsRefunds, neverSupportsCaptures/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
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
PaymentIntentis rich:status,amount,amount_capturable,amount_received,last_payment_error, a fullgetLastResponse(). - Nexi's
CaptureResponse/CancelResponseare minimal: justoperationId+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.
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/Orderwiring — howCheckoutcalls intoPayment, howOrder/Checkoutreact toPayment's events, where a draftOrdergets created relative to whenPaymentis called. Deliberately designed and built separately, afterPaymentitself was complete —Paymentmust stand on its own regardless of what ends up consuming it.Transactionpersistence — Lunar's owntransactionstable (type:intent/capture/refund,parent_transaction_idchaining) already models the audit trail these events would feed, once a listener is built to write to it.Paymentitself does not writeTransactionrows — see the events table above; that is a listener's job, in whichever module ends up owning the write (likelyOrder, sinceTransaction.order_idis required).