diff --git a/database/migrations/2026_09_03_000002_add_context_to_stripe_payment_intents.php b/database/migrations/2026_09_03_000002_add_context_to_stripe_payment_intents.php new file mode 100644 index 0000000..4fb9360 --- /dev/null +++ b/database/migrations/2026_09_03_000002_add_context_to_stripe_payment_intents.php @@ -0,0 +1,39 @@ +json('context')->nullable()->after('status'); + $table->string('payment_type')->nullable()->after('context'); + }); + } + + public function down(): void + { + Schema::table('stripe_payment_intents', function (Blueprint $table) { + $table->dropColumn(['context', 'payment_type']); + }); + } +}; \ No newline at end of file diff --git a/docs/payments.md b/docs/payments.md new file mode 100644 index 0000000..31bdde6 --- /dev/null +++ b/docs/payments.md @@ -0,0 +1,189 @@ +# 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). diff --git a/src/Order/Listeners/ApplyResolvedPaymentStatus.php b/src/Order/Listeners/ApplyResolvedPaymentStatus.php new file mode 100644 index 0000000..645b6aa --- /dev/null +++ b/src/Order/Listeners/ApplyResolvedPaymentStatus.php @@ -0,0 +1,26 @@ +update()) so + * Order::observe()'s updated() hook fires and OrderStatusUpdated goes out + * the same as any other status write — see that event's own docblock for + * why it's meant to fire "regardless of what wrote it." + */ +class ApplyResolvedPaymentStatus +{ + public function handle(OrderPaymentStatusResolved $event): void + { + $order = Order::findOrFail($event->orderId); + $order->update(['status' => $event->status]); + } +} diff --git a/src/Payment/Contracts/Configurable.php b/src/Payment/Contracts/Configurable.php new file mode 100644 index 0000000..caa7da0 --- /dev/null +++ b/src/Payment/Contracts/Configurable.php @@ -0,0 +1,22 @@ + $data * @param array $context */ - public function authorize(string $type, array $data, array $context = []): PaymentResult; -} \ No newline at end of file + public function authorize(string $type, Price $amount, array $data = [], array $context = []): PaymentResult; +} diff --git a/src/Payment/Contracts/SupportsCaptures.php b/src/Payment/Contracts/SupportsCaptures.php index 6b6fa59..dd4e6c6 100644 --- a/src/Payment/Contracts/SupportsCaptures.php +++ b/src/Payment/Contracts/SupportsCaptures.php @@ -2,6 +2,7 @@ namespace Modules\Core\Payment\Contracts; +use Lunar\DataTypes\Price; use Modules\Core\Payment\DTOs\PaymentResult; /** @@ -23,12 +24,16 @@ interface SupportsCaptures * $reference is the identifier SupportsAuthorization::authorize() * returned (PaymentResult::$reference) for the hold being settled. * - * $amount lets a driver capture less than the full authorized amount - * (e.g. shipping less than ordered) — up to the driver/gateway - * whether a partial capture also releases the remainder or leaves it - * capturable again later (multicapture-style gateways). + * $amount is Lunar's own Price (never a gateway's own minor-unit + * scale — see PaymentResult's docblock), and lets a driver capture + * less than the full authorized amount (e.g. shipping less than + * ordered) — up to the driver/gateway whether a partial capture also + * releases the remainder or leaves it capturable again later + * (multicapture-style gateways). Required explicitly, not derived by + * the driver from a live gateway lookup — the caller (whatever placed + * the original authorize() call) already knows it. * * @param array $context */ - public function capture(string $reference, int $amount, array $context = []): PaymentResult; + public function capture(string $reference, Price $amount, array $context = []): PaymentResult; } \ No newline at end of file diff --git a/src/Payment/Contracts/SupportsPay.php b/src/Payment/Contracts/SupportsPay.php index b8272ac..ea3cfc2 100644 --- a/src/Payment/Contracts/SupportsPay.php +++ b/src/Payment/Contracts/SupportsPay.php @@ -2,6 +2,7 @@ namespace Modules\Core\Payment\Contracts; +use Lunar\DataTypes\Price; use Modules\Core\Payment\DTOs\PaymentResult; /** @@ -27,10 +28,16 @@ interface SupportsPay * one type, because a driver shared across several types needs it to * look up that type's own config. * - * $data carries whatever the gateway needs (amount, currency, customer - * details, a payment method token) — the caller's responsibility to - * assemble, since a driver has no notion of a cart or order to pull - * them from itself. + * $amount is required, not optional data a caller might omit — there + * is no way to process a payment without knowing what to charge. + * Lunar's own Price (bundling its own currency) — the same money + * representation every other Payment contract method takes/returns, + * see PaymentResult's own docblock. + * + * $data carries whatever ELSE the gateway needs (customer details, a + * payment method token) — the caller's responsibility to assemble, + * since a driver has no notion of a cart or order to pull them from + * itself. * * $context is opaque to the driver — carried through untouched into * whichever Payment event this call (or a later handleCallback() @@ -41,5 +48,5 @@ interface SupportsPay * @param array $data * @param array $context */ - public function pay(string $type, array $data, array $context = []): PaymentResult; -} \ No newline at end of file + public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult; +} diff --git a/src/Payment/Contracts/SupportsRefunds.php b/src/Payment/Contracts/SupportsRefunds.php index 1ace080..1950f99 100644 --- a/src/Payment/Contracts/SupportsRefunds.php +++ b/src/Payment/Contracts/SupportsRefunds.php @@ -2,6 +2,7 @@ namespace Modules\Core\Payment\Contracts; +use Lunar\DataTypes\Price; use Modules\Core\Payment\DTOs\PaymentResult; /** @@ -22,10 +23,13 @@ interface SupportsRefunds * SupportsCaptures::capture() call returned for the settled funds * being refunded. * - * $amount allows a partial refund; a gateway may allow multiple - * partial refunds against one settlement, up to its own total. + * $amount is Lunar's own Price (never a gateway's own minor-unit + * scale — see PaymentResult's docblock), allowing a partial refund; a + * gateway may allow multiple partial refunds against one settlement, + * up to its own total. Required explicitly, same reasoning as + * SupportsCaptures::capture()'s own $amount. * * @param array $context */ - public function refund(string $reference, int $amount, array $context = []): PaymentResult; + public function refund(string $reference, Price $amount, array $context = []): PaymentResult; } diff --git a/src/Payment/Contracts/SupportsVoids.php b/src/Payment/Contracts/SupportsVoids.php index df23292..8612c9f 100644 --- a/src/Payment/Contracts/SupportsVoids.php +++ b/src/Payment/Contracts/SupportsVoids.php @@ -2,6 +2,7 @@ namespace Modules\Core\Payment\Contracts; +use Lunar\DataTypes\Price; use Modules\Core\Payment\DTOs\PaymentResult; /** @@ -20,7 +21,15 @@ interface SupportsVoids * $reference is the identifier SupportsAuthorization::authorize() * returned for the hold being released. * + * $amount is the authorized amount being released — Lunar's own + * Price, same as every other Payment contract method (see + * PaymentResult's own docblock). Required explicitly: the caller + * (whatever placed the original authorize() call) already knows it, + * same reasoning as SupportsCaptures::capture()'s own $amount — a + * driver shouldn't need a live gateway lookup just to know what it's + * releasing. + * * @param array $context */ - public function void(string $reference, array $context = []): PaymentResult; -} \ No newline at end of file + public function void(string $reference, Price $amount, array $context = []): PaymentResult; +} diff --git a/src/Payment/DTOs/PaymentResult.php b/src/Payment/DTOs/PaymentResult.php index a0e67e0..9db2eb7 100644 --- a/src/Payment/DTOs/PaymentResult.php +++ b/src/Payment/DTOs/PaymentResult.php @@ -2,6 +2,7 @@ namespace Modules\Core\Payment\DTOs; +use Lunar\DataTypes\Price; use Modules\Core\Payment\Enums\PaymentResultStatus; /** @@ -23,6 +24,13 @@ use Modules\Core\Payment\Enums\PaymentResultStatus; final class PaymentResult { /** + * @param $amount Lunar's own money type (Lunar\DataTypes\Price — + * integer minor units bundled with its Currency), the SAME + * representation every contract method takes/returns — never a + * gateway's own minor-unit scale. Each driver converts at its own + * boundary (e.g. StripeManager::toStripeAmount()/fromStripeAmount()) + * before calling out to, or after reading back from, its gateway — + * Payment itself only ever speaks Lunar's Price. * @param $failureReason a human-readable reason, only meaningful * when $status is Failed — the driver's own normalization of * whatever the gateway called it (Stripe's decline_code message, @@ -43,7 +51,7 @@ final class PaymentResult public function __construct( public readonly PaymentResultStatus $status, public readonly string $reference, - public readonly int $amount, + public readonly Price $amount, public readonly ?string $failureReason = null, public readonly bool $retriable = false, public readonly array $raw = [], diff --git a/src/Payment/Drivers/OfflinePaymentDriver.php b/src/Payment/Drivers/OfflinePaymentDriver.php index 6fe43a7..506b9e2 100644 --- a/src/Payment/Drivers/OfflinePaymentDriver.php +++ b/src/Payment/Drivers/OfflinePaymentDriver.php @@ -2,27 +2,27 @@ namespace Modules\Core\Payment\Drivers; -use Lunar\Models\Cart; -use Modules\Core\Checkout\Events\OrderPlaced; -use Modules\Core\Checkout\Events\PaymentConfirmed; -use Modules\Core\Payment\Contracts\PaymentDriver; +use Illuminate\Support\Str; +use Lunar\DataTypes\Price; +use Modules\Core\Payment\Contracts\Configurable; +use Modules\Core\Payment\Contracts\SupportsPay; +use Modules\Core\Payment\DTOs\PaymentResult; +use Modules\Core\Payment\Enums\PaymentResultStatus; +use Modules\Core\Payment\Events\PaymentCaptured; /** * Shared by every payment type with no real gateway to confirm against — * cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on - * delivery, not at checkout. confirm() has nothing to wait on, so it - * dispatches PaymentConfirmed immediately, same moment Lunar's own - * OfflinePayment would place the order — but the actual placement now - * happens in CheckoutService::onPaymentConfirmed(), not here. $data is - * unused: nothing about this confirmation depends on gateway-specific - * payload. + * delivery, not at checkout. There is no separate hold-then-settle model + * (SupportsAuthorization/SupportsCaptures/SupportsVoids) and no async + * resolution (HandlesPaymentCallback) — pay() decides success immediately + * and dispatches PaymentCaptured before returning. * - * The status-mapping step this driver used to do inline right after - * placeOrder() returned now happens in onOrderPlaced() below instead — - * see PaymentDriver's docblock for why a driver can no longer rely on - * placeOrder()'s return value. + * $reference is generated here (not supplied by a gateway, since there is + * none) purely so PaymentCaptured, and anything downstream keying on it, + * have something to identify this attempt by. */ -class OfflinePaymentDriver implements PaymentDriver +class OfflinePaymentDriver implements Configurable, SupportsPay { /** * Always true — no external dependency to be missing. @@ -32,30 +32,18 @@ class OfflinePaymentDriver implements PaymentDriver return true; } - public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void + public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult { - PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data); + $reference = 'offline-'.Str::uuid(); + + $result = new PaymentResult( + status: PaymentResultStatus::Succeeded, + reference: $reference, + amount: $amount, + ); + + PaymentCaptured::dispatch($type, $result, $context); + + return $result; } - - /** - * Registered in PaymentServiceProvider. Every offline-style type - * shares this one driver, so $order->meta['payment_method'] is checked - * against config('lunar.payments.types') to confirm the placed order - * actually belongs to one of them, rather than assuming every - * OrderPlaced is this driver's to act on — a Stripe order placed via - * StripePaymentDriver fires the same event. - */ - public function onOrderPlaced(OrderPlaced $event): void - { - $order = $event->order; - $type = $order->meta['payment_method'] ?? null; - - if (! $type || config("lunar.payments.types.{$type}.payment_driver") !== self::class) { - return; - } - - $order->update([ - 'status' => config("lunar.payments.types.{$type}.authorized", $order->status), - ]); - } -} +} \ No newline at end of file diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index 839417f..2852370 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -2,44 +2,67 @@ namespace Modules\Core\Payment\Drivers; -use Lunar\Models\Cart; -use Lunar\Stripe\Actions\UpdateOrderFromIntent; +use Lunar\DataTypes\Price; +use Lunar\Models\Currency; use Lunar\Stripe\Facades\Stripe; +use Lunar\Stripe\Managers\StripeManager; use Lunar\Stripe\Models\StripePaymentIntent; -use Modules\Core\Checkout\Events\OrderPlaced; -use Modules\Core\Checkout\Events\PaymentConfirmed; -use Modules\Core\Payment\Contracts\PaymentDriver; -use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException; +use Modules\Core\Payment\Contracts\Configurable; +use Modules\Core\Payment\Contracts\HandlesPaymentCallback; +use Modules\Core\Payment\Contracts\SupportsAuthorization; +use Modules\Core\Payment\Contracts\SupportsCaptures; +use Modules\Core\Payment\Contracts\SupportsPay; +use Modules\Core\Payment\Contracts\SupportsRefunds; +use Modules\Core\Payment\Contracts\SupportsVoids; +use Modules\Core\Payment\DTOs\PaymentResult; +use Modules\Core\Payment\Enums\PaymentResultStatus; +use Modules\Core\Payment\Events\PaymentAuthorizationFailed; +use Modules\Core\Payment\Events\PaymentAuthorized; +use Modules\Core\Payment\Events\PaymentCaptureFailed; +use Modules\Core\Payment\Events\PaymentCaptured; +use Modules\Core\Payment\Events\PaymentRefundFailed; +use Modules\Core\Payment\Events\PaymentRefunded; +use Modules\Core\Payment\Events\PaymentVoidFailed; +use Modules\Core\Payment\Events\PaymentVoided; +use Stripe\Exception\ApiErrorException; use Stripe\PaymentIntent; /** - * Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy - * Modules\Core\Payment\Contracts\PaymentDriver — dispatches - * PaymentConfirmed at the moment Stripe confirms payment, instead of the - * vendor's own Cart::createOrder() call. + * Talks to Stripe's PaymentIntent API directly — deliberately NOT via + * Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which + * take a Lunar\Models\Cart and derive amount/currency from it. Payment + * must never receive a Cart (see docs/payments.md) — pay()/authorize() + * already receive $amount explicitly as their own required Lunar Price + * parameter (see PaymentResult's own docblock), the caller's job to + * assemble, same as every other driver. * - * This is a fork, not a decoration: StripePaymentType::authorize() is - * `final` and calls Cart::createOrder() directly with no seam to redirect - * that one call — so this class reimplements authorize()'s logic (intent - * retrieval, capture-on-policy) rather than wrapping the vendor method. - * Kept deliberately close to the original so a lunarphp/stripe upgrade is - * easy to diff against. See docs/payments.md. + * Every amount that crosses this class's own boundary is converted right + * there: Lunar's Price -> Stripe's minor-unit int going INTO a gateway + * call (StripeManager::toStripeAmount()), Stripe's response amount -> + * Lunar's Price coming back OUT (StripeManager::fromStripeAmount()). + * Nothing outside this class ever sees a Stripe-scaled integer. * - * The status-mapping step (UpdateOrderFromIntent) this driver used to do - * inline right after placeOrder() returned now happens in onOrderPlaced() - * below instead — see PaymentDriver's docblock for why a driver can no - * longer rely on placeOrder()'s return value. Since that step needs the - * live Stripe PaymentIntent, not just the Order, onOrderPlaced() re-fetches - * it from Stripe via the StripePaymentIntent row this method already wrote - * (keyed by the order's cart_id) rather than carrying the PaymentIntent - * object across the event boundary itself. + * Correlating a later handleCallback() (a separate request — a webhook) + * back to whatever $context identified this attempt is solved the same + * way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve + * it: real cart_id/order_id columns on Lunar\Stripe\Models\ + * StripePaymentIntent (a table already owned by lunarphp/stripe, already + * shaped for exactly this), not a generic context blob. See + * docs/payments.md "Async resolution" for the full reasoning. */ -class StripePaymentDriver implements PaymentDriver +class StripePaymentDriver implements + Configurable, + SupportsPay, + SupportsAuthorization, + SupportsCaptures, + SupportsVoids, + SupportsRefunds, + HandlesPaymentCallback { /** * Same key lunarphp/stripe's own StripeManager reads its API key from - * (Stripe::setApiKey(config('services.stripe.key')) in - * StripeManager::__construct()) — no key, no usable driver. + * (Stripe::setApiKey(config('services.stripe.key'))) — no key, no + * usable driver. */ public function isConfigured(): bool { @@ -47,79 +70,307 @@ class StripePaymentDriver implements PaymentDriver } /** - * @throws PaymentNotConfirmedException if Stripe hasn't confirmed the - * payment intent (wrong intent id, already processed, or the gateway - * call itself fails) — nothing here should be treated as "confirm - * anyway." + * Atomic charge — capture_method: automatic. Stripe still frequently + * confirms into requires_action/requires_confirmation rather than + * succeeded in the same call (3-D Secure, most real cards) — Pending + * is a normal outcome here, not an edge case, resolved later via + * handleCallback(). */ - public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void + public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult { - $paymentIntentId = $data['payment_intent']; - - $paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntentId)->first(); - - if ($paymentIntentModel && ! $paymentIntentModel->isActive()) { - throw new PaymentNotConfirmedException('Payment intent already processed.'); - } - - if (! $paymentIntentModel) { - $paymentIntentModel = StripePaymentIntent::create([ - 'intent_id' => $paymentIntentId, - 'cart_id' => $cart->id, - ]); - } - - $paymentIntentModel->update(['processing_at' => now()]); - - $stripe = Stripe::getClient(); - $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); - - if (! $paymentIntent) { - throw new PaymentNotConfirmedException('Unable to locate payment intent.'); - } - - $policy = config('lunar.stripe.policy', 'automatic'); - - if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') { - $paymentIntent = $stripe->paymentIntents->capture($paymentIntentId); - } - - if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) { - $paymentIntentModel->update(['status' => $paymentIntent->status]); - - throw new PaymentNotConfirmedException( - $paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}." - ); - } - - $paymentIntentModel->status = $paymentIntent->status; - $paymentIntentModel->save(); - - PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data); + return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'automatic'); } /** - * Registered in PaymentServiceProvider. Matches via the order's - * cart_id against the StripePaymentIntent row confirm() wrote, so a - * non-Stripe OrderPlaced (offline types fire the same event) is - * ignored rather than acted on. + * Hold only — capture_method: manual. Resolves to Pending or an + * authorized (requires_capture) intent, never succeeded directly: + * Stripe never captures on its own for a manual intent. */ - public function onOrderPlaced(OrderPlaced $event): void + public function authorize(string $type, Price $amount, array $data = [], array $context = []): PaymentResult { - $order = $event->order; + return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'manual'); + } - $paymentIntentModel = StripePaymentIntent::where('cart_id', $order->cart_id)->first(); - - if (! $paymentIntentModel) { - return; + private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult + { + try { + $paymentIntent = Stripe::getClient()->paymentIntents->create([ + 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency), + 'currency' => $amount->currency->code, + 'capture_method' => $captureMethod, + 'confirm' => true, + 'payment_method' => $data['payment_method'] ?? null, + 'automatic_payment_methods' => isset($data['payment_method']) + ? null + : ['enabled' => true], + ]); + } catch (ApiErrorException $e) { + return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual'); } - $paymentIntentModel->order_id = $order->id; - $paymentIntentModel->processed_at = now(); - $paymentIntentModel->save(); + $this->rememberIntent($paymentIntent, $type, $context); - $paymentIntent = Stripe::getClient()->paymentIntents->retrieve($paymentIntentModel->intent_id); + return $this->resultFromIntent($type, $paymentIntent, $amount, $context, authorizing: $captureMethod === 'manual'); + } - UpdateOrderFromIntent::execute($order, $paymentIntent); + public function handleCallback(string $reference, array $data, array $context = []): PaymentResult + { + [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? ''); + + $paymentIntent = Stripe::getClient()->paymentIntents->retrieve($reference); + + $authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL; + + if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && ! $authorizing) { + // automatic capture_method, but Stripe stopped short of + // capturing (rare, but the API contract allows it) — finish + // the job pay() started. + $paymentIntent = Stripe::getClient()->paymentIntents->capture($reference); + } + + $intentModel?->update(['status' => $paymentIntent->status]); + + $amount = $this->priceFromIntent($paymentIntent); + + return $this->resultFromIntent($type, $paymentIntent, $amount, $context, $authorizing); + } + + public function capture(string $reference, Price $amount, array $context = []): PaymentResult + { + [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); + + try { + $paymentIntent = Stripe::getClient()->paymentIntents->capture($reference, [ + 'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency), + ]); + } catch (ApiErrorException $e) { + $result = $this->failure($amount, $e, $reference); + PaymentCaptureFailed::dispatch($type, $result, $context); + + return $result; + } + + $intentModel?->update(['status' => $paymentIntent->status]); + + $result = new PaymentResult( + status: $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED + ? PaymentResultStatus::Succeeded + : PaymentResultStatus::Failed, + reference: $paymentIntent->id, + amount: $amount, + raw: $paymentIntent->toArray(), + ); + + $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED + ? PaymentCaptured::dispatch($type, $result, $context) + : PaymentCaptureFailed::dispatch($type, $result, $context); + + return $result; + } + + public function void(string $reference, Price $amount, array $context = []): PaymentResult + { + [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); + + try { + $paymentIntent = Stripe::getClient()->paymentIntents->cancel($reference); + } catch (ApiErrorException $e) { + $result = $this->failure($amount, $e, $reference); + PaymentVoidFailed::dispatch($type, $result, $context); + + return $result; + } + + $intentModel?->update(['status' => $paymentIntent->status]); + + $result = new PaymentResult( + status: $paymentIntent->status === PaymentIntent::STATUS_CANCELED + ? PaymentResultStatus::Succeeded + : PaymentResultStatus::Failed, + reference: $paymentIntent->id, + amount: $amount, + raw: $paymentIntent->toArray(), + ); + + $paymentIntent->status === PaymentIntent::STATUS_CANCELED + ? PaymentVoided::dispatch($type, $result, $context) + : PaymentVoidFailed::dispatch($type, $result, $context); + + return $result; + } + + public function refund(string $reference, Price $amount, array $context = []): PaymentResult + { + [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); + + try { + $refund = Stripe::getClient()->refunds->create([ + 'payment_intent' => $reference, + 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency), + ]); + } catch (ApiErrorException $e) { + $result = $this->failure($amount, $e, $reference); + PaymentRefundFailed::dispatch($type, $result, $context); + + return $result; + } + + $result = new PaymentResult( + status: $refund->status !== 'failed' ? PaymentResultStatus::Succeeded : PaymentResultStatus::Failed, + reference: $refund->id, + amount: $amount, + raw: $refund->toArray(), + ); + + $refund->status !== 'failed' + ? PaymentRefunded::dispatch($type, $result, $context) + : PaymentRefundFailed::dispatch($type, $result, $context); + + return $result; + } + + private function rememberIntent(PaymentIntent $paymentIntent, string $type, array $context): ?StripePaymentIntent + { + if (! ($context['cart_id'] ?? null)) { + return null; + } + + return StripePaymentIntent::create([ + 'intent_id' => $paymentIntent->id, + 'cart_id' => $context['cart_id'], + 'order_id' => $context['order_id'] ?? null, + 'status' => $paymentIntent->status, + 'payment_type' => $type, + 'context' => json_encode($context), + ]); + } + + /** + * The one lookup every method past initiate() shares: find the + * StripePaymentIntent row this $reference belongs to, then recover + * $type/$context from it — the original context, if any, always + * takes precedence over whatever the caller passed in (see + * handleCallback()'s own note: a webhook caller usually has none of + * its own). + * + * $typeFallback only matters when there's no $intentModel to read + * payment_type from — handleCallback() has its own $data['type'] to + * fall back to; capture()/void()/refund() have nothing better than ''. + * + * @return array{0: ?StripePaymentIntent, 1: string, 2: array} + */ + private function resolveIntentModel(string $reference, array $context, string $typeFallback = ''): array + { + $intentModel = StripePaymentIntent::where('intent_id', $reference)->first(); + + return [ + $intentModel, + $intentModel?->payment_type ?? $typeFallback, + $this->decodeContext($intentModel) ?? $context, + ]; + } + + /** + * StripePaymentIntent is a vendor model (lunarphp/stripe) with no cast + * declared for our own 'context' column (added by boboko-core's own + * migration, see database/migrations/..._add_context_to_stripe_ + * payment_intents.php) — we can't edit the vendor model to add one, so + * decode manually here instead of assuming Eloquent already did it. + * + * @return array|null + */ + private function decodeContext(?StripePaymentIntent $intentModel): ?array + { + if (! $intentModel || ! $intentModel->context) { + return null; + } + + return json_decode($intentModel->context, associative: true) ?: null; + } + + /** + * Converts a live Stripe PaymentIntent's own amount/currency back + * into Lunar's Price — the one place this class reads a Stripe + * response's amount without already holding the Price that produced + * it (handleCallback() has no $data['amount'] to fall back on, unlike + * pay()/authorize()). + */ + private function priceFromIntent(PaymentIntent $paymentIntent): Price + { + $currency = Currency::whereRaw('lower(code) = ?', [strtolower($paymentIntent->currency)])->firstOrFail(); + + return new Price( + (int) StripeManager::fromStripeAmount($paymentIntent->amount, $currency), + $currency, + ); + } + + private function resultFromIntent( + string $type, + PaymentIntent $paymentIntent, + Price $amount, + array $context, + bool $authorizing, + ): PaymentResult { + $status = match ($paymentIntent->status) { + PaymentIntent::STATUS_SUCCEEDED => PaymentResultStatus::Succeeded, + PaymentIntent::STATUS_REQUIRES_CAPTURE => $authorizing ? PaymentResultStatus::Succeeded : PaymentResultStatus::Pending, + PaymentIntent::STATUS_CANCELED => PaymentResultStatus::Failed, + default => PaymentResultStatus::Pending, + }; + + $result = new PaymentResult( + status: $status, + reference: $paymentIntent->id, + amount: $amount, + failureReason: $paymentIntent->last_payment_error->message ?? null, + raw: $paymentIntent->toArray(), + ); + + if ($status === PaymentResultStatus::Pending) { + return $result; + } + + $succeeded = $status === PaymentResultStatus::Succeeded; + + if ($authorizing) { + $succeeded + ? PaymentAuthorized::dispatch($type, $result, $context) + : PaymentAuthorizationFailed::dispatch($type, $result, $context); + } else { + $succeeded + ? PaymentCaptured::dispatch($type, $result, $context) + : PaymentCaptureFailed::dispatch($type, $result, $context); + } + + return $result; + } + + private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult + { + $result = $this->failure($amount, $e); + + $authorizing + ? PaymentAuthorizationFailed::dispatch($type, $result, $context) + : PaymentCaptureFailed::dispatch($type, $result, $context); + + return $result; + } + + private function failure(Price $amount, ApiErrorException $e, string $reference = ''): PaymentResult + { + $stripeError = $e->getError(); + + return new PaymentResult( + status: PaymentResultStatus::Failed, + reference: $reference ?: ($stripeError->payment_intent->id ?? ''), + amount: $amount, + failureReason: $e->getMessage(), + retriable: in_array($stripeError->decline_code ?? null, [ + 'do_not_honor', 'insufficient_funds', 'card_velocity_exceeded', + 'processing_error', 'try_again_later', 'issuer_not_available', + ], true), + raw: $stripeError?->toArray() ?? [], + ); } } diff --git a/src/Payment/Listeners/ApplyOfflinePaymentStatus.php b/src/Payment/Listeners/ApplyOfflinePaymentStatus.php deleted file mode 100644 index 856c3d0..0000000 --- a/src/Payment/Listeners/ApplyOfflinePaymentStatus.php +++ /dev/null @@ -1,36 +0,0 @@ -meta['payment_method'] is checked against - * config('lunar.payments.types') to confirm the placed order actually - * belongs to one of them, rather than assuming every OrderPlaced is - * this listener's to act on — a Stripe order placed via - * StripePaymentDriver fires the same event. - */ -class ApplyOfflinePaymentStatus -{ - public function handle(OrderPlaced $event): void - { - $order = $event->order; - $type = $order->meta['payment_method'] ?? null; - - if (! $type || config("lunar.payments.types.{$type}.payment_driver") !== OfflinePaymentDriver::class) { - return; - } - - $order->update([ - 'status' => config("lunar.payments.types.{$type}.authorized", $order->status), - ]); - } -}