From 456943dc748527a1af3bf284079f8e62c2d0ce73 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 3 Sep 2026 17:27:34 +0300 Subject: [PATCH] Feature: Payment resolver, Payment Provider, Completing Stripe Webhooks, Wiring Payments to checkout service --- config/payment.php | 14 +- src/Checkout/Events/OrderPlaced.php | 18 ++- src/Checkout/Services/CheckoutService.php | 151 +++++++++--------- .../Listeners/ApplyResolvedPaymentStatus.php | 51 +++++- src/Payment/DTOs/PaymentContinuation.php | 20 +++ src/Payment/DTOs/PaymentResult.php | 6 + src/Payment/Drivers/StripePaymentDriver.php | 7 + src/Payment/Enums/PaymentContinuationType.php | 17 ++ .../PaymentNotConfirmedException.php | 22 --- .../Controllers/StripeWebhookController.php | 46 ++++++ .../Services/PaymentDriverResolver.php | 19 ++- src/Payment/routes/webhooks.php | 14 ++ src/Providers/OrderServiceProvider.php | 5 + src/Providers/PaymentServiceProvider.php | 2 + 14 files changed, 266 insertions(+), 126 deletions(-) create mode 100644 src/Payment/DTOs/PaymentContinuation.php create mode 100644 src/Payment/Enums/PaymentContinuationType.php delete mode 100644 src/Payment/Exceptions/PaymentNotConfirmedException.php create mode 100644 src/Payment/Http/Controllers/StripeWebhookController.php create mode 100644 src/Payment/routes/webhooks.php diff --git a/config/payment.php b/config/payment.php index eed4dc0..70b07bc 100644 --- a/config/payment.php +++ b/config/payment.php @@ -14,18 +14,20 @@ return [ | Lunar's own config. | | 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key — - | it's the Modules\Core\Payment\Contracts\PaymentDriver class - | CheckoutService::confirmPayment() resolves via the container and calls - | confirm() on. Kept on the same row as 'driver' rather than a second, - | separately-keyed map, so a type's full definition — Lunar's driver, - | its config, and its PaymentDriver — lives in one place. + | the driver instance Modules\Core\Payment\Services\PaymentDriverResolver + | resolves via the container. 'capture_mode' ('pay' or 'authorize') is + | also boboko-owned — which contract method + | CheckoutService::initiatePayment() calls for this type. Kept on the + | same row as 'driver' rather than a second, separately-keyed map, so a + | type's full definition lives in one place. | */ 'types' => [ 'cash-on-delivery' => [ 'driver' => 'offline', 'payment_driver' => OfflinePaymentDriver::class, - 'authorized' => 'awaiting-payment', + 'capture_mode' => 'pay', + 'captured_status' => 'payment-offline', 'fee' => 0, ], ], diff --git a/src/Checkout/Events/OrderPlaced.php b/src/Checkout/Events/OrderPlaced.php index 1c71f5d..262e22d 100644 --- a/src/Checkout/Events/OrderPlaced.php +++ b/src/Checkout/Events/OrderPlaced.php @@ -5,13 +5,17 @@ namespace Modules\Core\Checkout\Events; use Lunar\Models\Order; /** - * Dispatched by CheckoutService::placeOrder() the moment an Order exists — - * the handoff point between Checkout and Order (see docs/checkout.md's - * "Three-stage lifecycle"). Checkout has no opinion about what happens - * after this fires; Order's own listeners (not built yet — Order is a - * named-but-unscoped concern, same status Recovery had before it existed) - * would be what reacts to it — e.g. a confirmation email, initializing - * order status tracking. + * Dispatched once an Order's placed_at is set — the handoff point between + * Checkout/Payment and Order (see docs/checkout.md's "Three-stage + * lifecycle"). Fired by Modules\Core\Order\Listeners\ + * ApplyResolvedPaymentStatus once it resolves a PaymentCaptured/ + * PaymentAuthorized event into an actual order status change, not by + * CheckoutService directly — a draft Order can exist (via + * CheckoutService::initiatePayment()) well before this fires, if payment + * resolves asynchronously (e.g. a redirect-based gateway). Checkout has no + * opinion about what happens after this fires; Order's own listeners are + * what react to it — e.g. a confirmation email, initializing order status + * tracking. */ class OrderPlaced { diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php index 6f432d3..0981ac9 100644 --- a/src/Checkout/Services/CheckoutService.php +++ b/src/Checkout/Services/CheckoutService.php @@ -10,16 +10,14 @@ use Lunar\Base\Addressable; use Lunar\DataTypes\ShippingOption; use Lunar\Facades\ShippingManifest; use Lunar\Models\Cart; -use Lunar\Models\Order; use Modules\Core\Cart\Services\CartService; use Modules\Core\Checkout\Events\BillingAddressSet; -use Modules\Core\Checkout\Events\OrderPlaced; -use Modules\Core\Checkout\Events\PaymentConfirmed; use Modules\Core\Checkout\Events\PaymentMethodSelected; use Modules\Core\Checkout\Events\ShippingAddressSet; use Modules\Core\Checkout\Events\ShippingOptionSelected; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; +use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Services\PaymentDriverResolver; @@ -30,9 +28,11 @@ use Modules\Core\Payment\Services\PaymentDriverResolver; * implementation detail. See docs/checkout.md for the full design — * Checkout is the middle of a three-stage lifecycle (Cart → Checkout → * Order): it owns the placement moment itself (address, shipping selection, - * placeOrder()) and ends the instant an Order exists. What happens to that - * Order afterward (status transitions, fulfillment) is deliberately out of - * scope here — see OrderPlaced's docblock. + * ensuring a draft Order exists) and hands off to Payment the instant that + * draft exists — see initiatePayment(). What happens to that Order + * afterward (status transitions, fulfillment) is deliberately out of + * scope here — see docs/payments.md and Checkout\Events\OrderPlaced's + * docblock for where that now lives. * * Depends on CartService for cart access rather than reaching into * Lunar\Facades\CartSession directly a second time, so Checkout stays @@ -99,47 +99,12 @@ class CheckoutService return $cart; } - /** - * $fingerprint is mandatory, not optional — the caller must prove the - * cart total the shopper last saw (Cart::fingerprint()) still matches - * before an order is placed. Cart::checkFingerprint() throws Lunar's own - * FingerprintMismatchException on a mismatch (a line's price changed, - * stock adjusted the total, another tab modified the cart) rather than - * silently placing an order at a different total than what was shown. - * - * Not called directly by a storefront — see confirmPayment(), which is - * the only caller and supplies the fingerprint captured in - * selectPaymentMethod(), not one the storefront has to obtain itself. - * - * No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation - * (run inside Cart::createOrder()) already throws - * Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag - * ($exception->errors()) for address/shipping-option validation and the - * duplicate-order guard — already the right shape for a storefront to - * render as form errors directly. FingerprintMismatchException - * propagates the same way, for the same reason. - * - * @throws FingerprintMismatchException - * @throws CartException - */ - public function placeOrder(string $fingerprint): Order - { - $cart = $this->cart->currentOrCreate(); - $cart->checkFingerprint($fingerprint); - - $order = $cart->createOrder(); - - Event::dispatch(new OrderPlaced($order)); - - return $order; - } - /** * Every payment type currently offered to the storefront — every key * in config('lunar.payments.types') that is BOTH administratively * enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND - * whose registered PaymentDriver reports itself usable right now - * (PaymentDriver::isConfigured() — e.g. Stripe with no API key set is + * whose registered driver reports itself usable right now + * (Configurable::isConfigured() — e.g. Stripe with no API key set is * never offered, regardless of the enabled toggle). A type with no * PaymentMethod row at all (never seeded) is treated as not offered, * same as disabled — nothing here creates one; see @@ -167,13 +132,13 @@ class CheckoutService * including any payment-type-specific adjustment (e.g. a COD * surcharge), which only exists once payment_method is set and the * cart recalculates. Captured here, server-side, rather than asked of - * the storefront: this is the last moment before confirmPayment() that - * the shopper's reviewed total is known, and confirmPayment() reads it + * the storefront: this is the last moment before initiatePayment() that + * the shopper's reviewed total is known, and initiatePayment() reads it * back internally instead of taking a fingerprint parameter — a * storefront should never need to know Cart::fingerprint() exists. * - * Does not itself call a PaymentDriver — selecting a method and - * confirming payment against it are deliberately separate steps, same + * Does not itself call a payment driver — selecting a method and + * initiating payment against it are deliberately separate steps, same * as selecting a shipping option happens before placing the order. * * @throws UnknownPaymentTypeException if $type isn't currently offered @@ -200,44 +165,76 @@ class CheckoutService } /** - * Resolves $type's registered PaymentDriver and calls confirm() — the - * driver independently decides whether payment succeeded and, if so, - * dispatches PaymentConfirmed (see PaymentDriver's docblock) rather - * than placing the order itself or returning it here. This method is - * fire-and-forget as far as the Order is concerned: a caller that - * needs it back listens for OrderPlaced, the same way a driver's own - * post-placement step does — see PaymentConfirmed's docblock for why a - * direct return value doesn't fit every gateway (async/webhook-driven - * confirmations have no synchronous caller waiting for one at all). + * The one storefront-facing "place this order and pay for it" call — + * the point where Checkout hands off to Payment. Ensures a draft + * Order exists (Cart::createOrder() — confirmed idempotent against a + * cart's own pre-existing, not-yet-placed-at draft; see + * vendor/lunarphp/core/src/Actions/Carts/CreateOrder.php), then + * resolves the payment type selected by selectPaymentMethod() and + * calls pay() or authorize() on its driver, per that type's + * config('lunar.payments.types.{type}.capture_mode') — boboko-core's + * own types (config/payment.php) are merged into that same Lunar + * config key by PaymentServiceProvider::boot(). * - * $data carries whatever that driver needs (Stripe's payment_intent - * id, a future redirect-based provider's callback payload). + * Returns the driver's own PaymentResult UNCHANGED — this method does + * not wait for or resolve anything past what pay()/authorize() itself + * returns synchronously. A Pending result (an async gateway like + * Stripe requiring 3-D Secure/a redirect) is a normal, expected + * outcome, not an error — the caller (a storefront controller) is + * responsible for whatever the gateway needs next. * - * The fingerprint passed to the driver is the one captured by - * selectPaymentMethod(), not supplied by the caller — see that - * method's docblock. Throws the same FingerprintMismatchException a - * caller-supplied one would if the cart's total has since changed; - * missing entirely (selectPaymentMethod() was never called for this - * cart) is treated the same as a mismatch, not a different error. + * KNOWN GAP, explicitly out of scope for now: PaymentResult alone does + * not carry gateway-specific continuation data (e.g. Stripe's + * PaymentIntent client_secret for a Pending result needing frontend + * confirmation) — that concept existed on the deleted PaymentInitiation + * DTO and was intentionally removed from Payment's abstraction layer. + * Nothing here re-introduces it; only OfflinePaymentDriver's + * always-Immediate-Succeeded path is fully wired end-to-end today. * - * @param array $data + * The draft order's own $order->total (not the Cart's) is what gets + * passed as $amount — Order::$total is Lunar's own Price-cast + * attribute, already resolving the correct Currency via the order's + * own currency_code, and is the authoritative total once the draft + * row exists. * - * @throws UnknownPaymentTypeException if $type isn't currently offered - * (see getPaymentMethods()) — re-checked here, not just in - * selectPaymentMethod(), since a type could be disabled between - * selection and confirmation - * @throws \Lunar\Exceptions\FingerprintMismatchException - * @throws \Lunar\Exceptions\Carts\CartException + * $context passed to the driver is {cart_id, order_id} — the exact + * keys Modules\Core\Payment\Drivers\StripePaymentDriver:: + * rememberIntent() already reads. + * + * Same fingerprint precondition the old placeOrder() had: mandatory, + * not optional, checked before the draft is created. + * + * @param array $data passed through untouched to + * the driver's pay()/authorize() — e.g. Stripe's payment_method + * token. + * + * @throws UnknownPaymentTypeException if the cart's selected + * payment_method (from selectPaymentMethod()) is no longer offered + * — re-checked here, not just at selection time, since a type could + * be disabled in between + * @throws FingerprintMismatchException + * @throws CartException */ - public function confirmPayment(string $type, array $data = []): void + public function initiatePayment(string $fingerprint, array $data = []): PaymentResult { - if (! in_array($type, $this->getPaymentMethods(), true)) { - throw new UnknownPaymentTypeException($type); + $cart = $this->cart->currentOrCreate(); + $cart->checkFingerprint($fingerprint); + + $type = $cart->meta['payment_method'] ?? null; + + if ($type === null || ! in_array($type, $this->getPaymentMethods(), true)) { + throw new UnknownPaymentTypeException((string) $type); } - $cart = $this->cart->currentOrCreate(); - $fingerprint = $cart->meta['checkout_fingerprint'] ?? ''; + $order = $cart->createOrder(); - $this->paymentDrivers->resolve($type)->confirm($cart, $type, $fingerprint, $data); + $driver = $this->paymentDrivers->resolve($type); + $captureMode = config("lunar.payments.types.{$type}.capture_mode", 'pay'); + + $context = ['cart_id' => $cart->id, 'order_id' => $order->id]; + + return $captureMode === 'authorize' + ? $driver->authorize($type, $order->total, $data, $context) + : $driver->pay($type, $order->total, $data, $context); } } diff --git a/src/Order/Listeners/ApplyResolvedPaymentStatus.php b/src/Order/Listeners/ApplyResolvedPaymentStatus.php index 645b6aa..d590dd3 100644 --- a/src/Order/Listeners/ApplyResolvedPaymentStatus.php +++ b/src/Order/Listeners/ApplyResolvedPaymentStatus.php @@ -2,25 +2,62 @@ namespace Modules\Core\Order\Listeners; +use Illuminate\Support\Facades\Event; use Lunar\Models\Order; -use Modules\Core\Payment\Events\OrderPaymentStatusResolved; +use Modules\Core\Checkout\Events\OrderPlaced; +use Modules\Core\Payment\Events\PaymentAuthorized; +use Modules\Core\Payment\Events\PaymentCaptured; /** * The only place an Order's status column is written in reaction to a - * payment outcome — Payment dispatches OrderPaymentStatusResolved with - * what the status should become, never touching the Order model itself; - * this listener, living in Order's own module, is what applies it. + * payment outcome. Registered against BOTH PaymentCaptured and + * PaymentAuthorized (see OrderServiceProvider) — same handler either way, + * since both carry the same {type, result, context} shape and only differ + * in which config key decides the resulting status. + * + * Reads $event->context['order_id'] to find which Order this outcome + * belongs to — Payment has no concept of an Order, so this is the one + * place that context key gets consumed on the Order side (Payment's own + * StripePaymentDriver reads $context['order_id'] independently, for its + * own unrelated correlation need — see that class's rememberIntent()). * * Loads and saves the model (not a bulk ::whereKey()->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." + * + * Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set — + * see that event's own docblock for why this, not CheckoutService, is now + * the dispatch point. */ class ApplyResolvedPaymentStatus { - public function handle(OrderPaymentStatusResolved $event): void + public function handle(PaymentCaptured|PaymentAuthorized $event): void { - $order = Order::findOrFail($event->orderId); - $order->update(['status' => $event->status]); + $orderId = $event->context['order_id'] ?? null; + + if ($orderId === null) { + return; + } + + $order = Order::findOrFail($orderId); + + $configKey = $event instanceof PaymentCaptured ? 'captured_status' : 'authorized_status'; + $status = config("lunar.payments.types.{$event->type}.{$configKey}"); + + if ($status === null) { + return; + } + + $wasPlaced = ! blank($order->placed_at); + + $order->update([ + 'status' => $status, + 'placed_at' => $order->placed_at ?? now(), + ]); + + if (! $wasPlaced) { + Event::dispatch(new OrderPlaced($order)); + } } } diff --git a/src/Payment/DTOs/PaymentContinuation.php b/src/Payment/DTOs/PaymentContinuation.php new file mode 100644 index 0000000..ba4e8fb --- /dev/null +++ b/src/Payment/DTOs/PaymentContinuation.php @@ -0,0 +1,20 @@ + PaymentResultStatus::Pending, }; + $continuation = $status === PaymentResultStatus::Pending + ? new PaymentContinuation(PaymentContinuationType::ClientSecret, $paymentIntent->client_secret) + : null; + $result = new PaymentResult( status: $status, reference: $paymentIntent->id, amount: $amount, failureReason: $paymentIntent->last_payment_error->message ?? null, raw: $paymentIntent->toArray(), + continuation: $continuation, ); if ($status === PaymentResultStatus::Pending) { diff --git a/src/Payment/Enums/PaymentContinuationType.php b/src/Payment/Enums/PaymentContinuationType.php new file mode 100644 index 0000000..68cc2cc --- /dev/null +++ b/src/Payment/Enums/PaymentContinuationType.php @@ -0,0 +1,17 @@ +value (a URL) — a redirect-based gateway. */ + case Redirect; + + /** Hand $continuation->value (a client secret) to frontend JS — Stripe Elements-style. */ + case ClientSecret; +} diff --git a/src/Payment/Exceptions/PaymentNotConfirmedException.php b/src/Payment/Exceptions/PaymentNotConfirmedException.php deleted file mode 100644 index 0c5af47..0000000 --- a/src/Payment/Exceptions/PaymentNotConfirmedException.php +++ /dev/null @@ -1,22 +0,0 @@ -getContent(), + $request->header('Stripe-Signature'), + config('services.stripe.webhooks.lunar'), + ); + + $driver->handleCallback($event->data->object->id, $event->data->object->toArray()); + + return response()->json(['webhook_successful' => true]); + } +} diff --git a/src/Payment/Services/PaymentDriverResolver.php b/src/Payment/Services/PaymentDriverResolver.php index d583da1..da935d3 100644 --- a/src/Payment/Services/PaymentDriverResolver.php +++ b/src/Payment/Services/PaymentDriverResolver.php @@ -2,14 +2,19 @@ namespace Modules\Core\Payment\Services; -use Modules\Core\Payment\Contracts\PaymentDriver; - /** * Resolves a payment type key (e.g. 'stripe', 'cash-on-delivery') to its - * registered PaymentDriver — extracted out of CheckoutService so both it - * and anything else needing the same lookup (e.g. a listener reacting to - * OrderPlaced, which has no reason to depend on Checkout's own service) - * share one implementation instead of duplicating this config read. + * registered driver instance — extracted out of CheckoutService so both it + * and anything else needing the same lookup share one implementation + * instead of duplicating this config read. + * + * Returns a plain object, not a shared interface — Payment's own drivers + * implement several independent, orthogonal capability interfaces at once + * (Configurable, SupportsPay, SupportsAuthorization, ...; see + * StripePaymentDriver implementing all six). There is no single common + * "PaymentDriver" contract to type this against; a caller checks + * `instanceof SupportsPay` / `instanceof SupportsAuthorization` itself, + * the same way Payment's own contracts are designed to be consumed. */ class PaymentDriverResolver { @@ -20,7 +25,7 @@ class PaymentDriverResolver * can filter unresolvable types silently rather than treating "not * registered" as an error condition when just checking availability. */ - public function resolve(string $type): ?PaymentDriver + public function resolve(string $type): ?object { $driverClass = config("lunar.payments.types.{$type}.payment_driver"); diff --git a/src/Payment/routes/webhooks.php b/src/Payment/routes/webhooks.php new file mode 100644 index 0000000..96899be --- /dev/null +++ b/src/Payment/routes/webhooks.php @@ -0,0 +1,14 @@ +middleware([StripeWebhookMiddleware::class, 'api']) + ->withoutMiddleware([VerifyCsrfToken::class]) + ->name('payment.stripe.webhook'); diff --git a/src/Providers/OrderServiceProvider.php b/src/Providers/OrderServiceProvider.php index 02d59d2..73ca692 100644 --- a/src/Providers/OrderServiceProvider.php +++ b/src/Providers/OrderServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Support\ServiceProvider; use Lunar\Models\Order; use Lunar\Models\Transaction; use Modules\Core\Notification\NotificationRegistry; +use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; use Modules\Core\Order\Notifications\OrderCapturedNotification; use Modules\Core\Order\Notifications\OrderDeliveredNotification; @@ -15,6 +16,8 @@ use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification; use Modules\Core\Order\Observers\OrderObserver; use Modules\Core\Order\Observers\TransactionObserver; use Modules\Core\Order\Support\OrderStatus; +use Modules\Core\Payment\Events\PaymentAuthorized; +use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; class OrderServiceProvider extends ServiceProvider @@ -28,6 +31,8 @@ class OrderServiceProvider extends ServiceProvider Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this)); Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class); + Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class); + Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class); NotificationRegistry::get()->register([ OrderDeliveredNotification::class, diff --git a/src/Providers/PaymentServiceProvider.php b/src/Providers/PaymentServiceProvider.php index 9e9e6fe..3beca72 100644 --- a/src/Providers/PaymentServiceProvider.php +++ b/src/Providers/PaymentServiceProvider.php @@ -38,5 +38,7 @@ class PaymentServiceProvider extends ServiceProvider } config(['lunar.cart.pipelines.cart' => $cartPipeline]); + + $this->loadRoutesFrom(__DIR__ . '/../Payment/routes/webhooks.php'); } }