Files
core/docs/payments.md
T
2026-09-16 18:55:42 +03:00

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 — 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

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.

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