From 8cb54e065e5c7473cb92104eb54a52ef7e640323 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 2 Sep 2026 16:14:52 +0300 Subject: [PATCH] Feat: Updates to Payments, Checkout Services, Payment Events --- config/payment.php | 2 +- src/Checkout/Events/PaymentConfirmed.php | 36 +++++++++ src/Checkout/Services/CheckoutService.php | 41 +++++----- src/Payment/Contracts/InitiatesPayment.php | 54 ++++++++++++++ src/Payment/Contracts/PaymentDriver.php | 65 ++++++++++++++++ src/Payment/Contracts/SupportsCaptures.php | 23 ++++++ src/Payment/Contracts/SupportsRefunds.php | 24 ++++++ .../DataTransferObjects/CaptureResult.php | 18 +++++ .../DataTransferObjects/PaymentInitiation.php | 31 ++++++++ .../DataTransferObjects/RefundResult.php | 20 +++++ src/Payment/Drivers/OfflinePaymentDriver.php | 62 ++++++++-------- src/Payment/Drivers/StripePaymentDriver.php | 74 +++++++++++-------- src/Payment/Enums/PaymentInitiationMode.php | 32 ++++++++ src/Payment/Events/PaymentSucceeded.php | 39 ++++++++++ .../PaymentNotConfirmedException.php | 2 +- .../Listeners/ApplyOfflinePaymentStatus.php | 36 +++++++++ .../Services/PaymentDriverResolver.php | 29 ++++++++ src/Payment/Services/TransactionRecorder.php | 49 ++++++++++++ 18 files changed, 553 insertions(+), 84 deletions(-) create mode 100644 src/Checkout/Events/PaymentConfirmed.php create mode 100644 src/Payment/Contracts/InitiatesPayment.php create mode 100644 src/Payment/Contracts/PaymentDriver.php create mode 100644 src/Payment/Contracts/SupportsCaptures.php create mode 100644 src/Payment/Contracts/SupportsRefunds.php create mode 100644 src/Payment/DataTransferObjects/CaptureResult.php create mode 100644 src/Payment/DataTransferObjects/PaymentInitiation.php create mode 100644 src/Payment/DataTransferObjects/RefundResult.php create mode 100644 src/Payment/Enums/PaymentInitiationMode.php create mode 100644 src/Payment/Events/PaymentSucceeded.php create mode 100644 src/Payment/Listeners/ApplyOfflinePaymentStatus.php create mode 100644 src/Payment/Services/PaymentDriverResolver.php create mode 100644 src/Payment/Services/TransactionRecorder.php diff --git a/config/payment.php b/config/payment.php index 64889eb..eed4dc0 100644 --- a/config/payment.php +++ b/config/payment.php @@ -14,7 +14,7 @@ return [ | Lunar's own config. | | 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key — - | it's the Modules\Core\Checkout\Contracts\PaymentDriver class + | 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, diff --git a/src/Checkout/Events/PaymentConfirmed.php b/src/Checkout/Events/PaymentConfirmed.php new file mode 100644 index 0000000..ceded07 --- /dev/null +++ b/src/Checkout/Events/PaymentConfirmed.php @@ -0,0 +1,36 @@ +meta['payment_method'] against its own + * type(s) to recognize which OrderPlaced is its own — carrying $fingerprint + * here too lets a driver correlate its own OrderPlaced listener call back + * to the specific confirmation that triggered it, if it needs to. + */ +class PaymentConfirmed +{ + use Dispatchable; + + public function __construct( + public readonly Cart $cart, + public readonly string $type, + public readonly string $fingerprint, + public readonly array $data = [], + ) {} +} diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php index a82a2ec..6f432d3 100644 --- a/src/Checkout/Services/CheckoutService.php +++ b/src/Checkout/Services/CheckoutService.php @@ -12,15 +12,16 @@ use Lunar\Facades\ShippingManifest; use Lunar\Models\Cart; use Lunar\Models\Order; use Modules\Core\Cart\Services\CartService; -use Modules\Core\Checkout\Contracts\PaymentDriver; 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\Models\PaymentMethod; +use Modules\Core\Payment\Services\PaymentDriverResolver; /** * Storefront-facing checkout operations, mirroring @@ -41,6 +42,7 @@ class CheckoutService { public function __construct( private readonly CartService $cart, + private readonly PaymentDriverResolver $paymentDrivers, ) {} public function setShippingAddress(array|Addressable $address): Cart @@ -149,7 +151,7 @@ class CheckoutService { return PaymentMethod::where('enabled', true) ->pluck('type') - ->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false) + ->filter(fn (string $type) => $this->paymentDrivers->resolve($type)?->isConfigured() ?? false) ->values() ->all(); } @@ -198,11 +200,18 @@ class CheckoutService } /** - * Resolves $type's registered PaymentDriver and calls confirm() — - * the driver decides whether/when the order actually gets placed (see - * Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data - * carries whatever that driver needs (Stripe's payment_intent id, a - * future redirect-based provider's callback payload). + * 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). + * + * $data carries whatever that driver needs (Stripe's payment_intent + * id, a future redirect-based provider's callback payload). * * The fingerprint passed to the driver is the one captured by * selectPaymentMethod(), not supplied by the caller — see that @@ -220,7 +229,7 @@ class CheckoutService * @throws \Lunar\Exceptions\FingerprintMismatchException * @throws \Lunar\Exceptions\Carts\CartException */ - public function confirmPayment(string $type, array $data = []): Order + public function confirmPayment(string $type, array $data = []): void { if (! in_array($type, $this->getPaymentMethods(), true)) { throw new UnknownPaymentTypeException($type); @@ -229,20 +238,6 @@ class CheckoutService $cart = $this->cart->currentOrCreate(); $fingerprint = $cart->meta['checkout_fingerprint'] ?? ''; - return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data); - } - - /** - * Resolves $type's registered PaymentDriver, or null if $type has no - * 'payment_driver' registered in config('lunar.payments.types.') - * at all — deliberately non-throwing so getPaymentMethods() can filter - * unresolvable types silently rather than treating "not registered" - * as an error condition when just checking availability. - */ - private function resolvePaymentDriver(string $type): ?PaymentDriver - { - $driverClass = config("lunar.payments.types.{$type}.payment_driver"); - - return $driverClass ? app($driverClass) : null; + $this->paymentDrivers->resolve($type)->confirm($cart, $type, $fingerprint, $data); } } diff --git a/src/Payment/Contracts/InitiatesPayment.php b/src/Payment/Contracts/InitiatesPayment.php new file mode 100644 index 0000000..d042080 --- /dev/null +++ b/src/Payment/Contracts/InitiatesPayment.php @@ -0,0 +1,54 @@ + $data + * @param array $context + */ + public function initiate(string $type, array $data, array $context = []): PaymentInitiation; +} diff --git a/src/Payment/Contracts/PaymentDriver.php b/src/Payment/Contracts/PaymentDriver.php new file mode 100644 index 0000000..1f128bf --- /dev/null +++ b/src/Payment/Contracts/PaymentDriver.php @@ -0,0 +1,65 @@ +meta['payment_method'] — see PaymentConfirmed's docblock for + * why. This split is what makes an async/webhook-driven gateway (payment + * confirmed in a request that has no synchronous caller waiting for an + * Order at all) and a synchronous one (Stripe) work through the exact same + * contract. See docs/checkout.md / docs/payments.md. + */ +interface PaymentDriver +{ + /** + * Whether this driver can actually be used right now — e.g. Stripe + * checking its own API key is present, an offline-style driver always + * returning true since it has no external dependency. Independent of + * Modules\Core\Payment\Models\PaymentMethod::enabled (the admin + * on/off toggle) — CheckoutService::getPaymentMethods() combines both: + * a type is only offered to the storefront if it's administratively + * enabled AND its driver reports itself configured. + */ + public function isConfigured(): bool; + + /** + * $type is the payment type key being confirmed (e.g. 'cash-in-hand', + * 'cash-on-delivery', 'stripe') — passed through even though most + * drivers only ever serve one type, because a driver shared across + * several types (e.g. one "no real confirmation" offline driver behind + * both cash-in-hand and cash-on-delivery) needs it to look up that + * type's own config (e.g. its 'authorized' status) rather than another + * type's. + * + * $data carries whatever the gateway needs to confirm this specific + * payment (Stripe: ['payment_intent' => $id], a redirect-based + * provider: its callback payload) — passed explicitly by the caller + * (a controller, a webhook job) rather than a driver reaching into the + * global request(), so confirm() works the same whether it's called + * from a synchronous HTTP request or an async webhook/job with no + * active request at all. + * + * @param array $data + * + * @throws FingerprintMismatchException + * @throws CartException + */ + public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void; +} diff --git a/src/Payment/Contracts/SupportsCaptures.php b/src/Payment/Contracts/SupportsCaptures.php new file mode 100644 index 0000000..a704d0f --- /dev/null +++ b/src/Payment/Contracts/SupportsCaptures.php @@ -0,0 +1,23 @@ +checkout->placeOrder($fingerprint); + PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data); + } + + /** + * 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), ]); - - return $order->refresh(); } } diff --git a/src/Payment/Drivers/StripePaymentDriver.php b/src/Payment/Drivers/StripePaymentDriver.php index 51b0582..839417f 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -2,39 +2,40 @@ namespace Modules\Core\Payment\Drivers; -use Lunar\Exceptions\FingerprintMismatchException; -use Lunar\Exceptions\Carts\CartException; -use Lunar\Exceptions\DisallowMultipleCartOrdersException; use Lunar\Models\Cart; -use Lunar\Models\Order; use Lunar\Stripe\Actions\UpdateOrderFromIntent; use Lunar\Stripe\Facades\Stripe; use Lunar\Stripe\Models\StripePaymentIntent; -use Modules\Core\Checkout\Contracts\PaymentDriver; -use Modules\Core\Checkout\Services\CheckoutService; +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 Stripe\PaymentIntent; /** * Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy - * Modules\Core\Checkout\Contracts\PaymentDriver — calls - * CheckoutService::placeOrder($fingerprint) at the moment Stripe confirms - * payment, instead of the vendor's own Cart::createOrder() call. + * Modules\Core\Payment\Contracts\PaymentDriver — dispatches + * PaymentConfirmed at the moment Stripe confirms payment, instead of the + * vendor's own Cart::createOrder() call. * * 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, status mapping via UpdateOrderFromIntent) - * 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. + * 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. + * + * 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. */ class StripePaymentDriver implements PaymentDriver { - public function __construct( - private readonly CheckoutService $checkout, - ) {} - /** * Same key lunarphp/stripe's own StripeManager reads its API key from * (Stripe::setApiKey(config('services.stripe.key')) in @@ -47,13 +48,11 @@ class StripePaymentDriver implements PaymentDriver /** * @throws PaymentNotConfirmedException if Stripe hasn't confirmed the - * payment intent (wrong intent id, already processed, order already - * placed, or the gateway call itself fails) — nothing here should be - * treated as "place the order anyway." - * @throws FingerprintMismatchException - * @throws CartException + * payment intent (wrong intent id, already processed, or the gateway + * call itself fails) — nothing here should be treated as "confirm + * anyway." */ - public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order + public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void { $paymentIntentId = $data['payment_intent']; @@ -93,19 +92,34 @@ class StripePaymentDriver implements PaymentDriver ); } - try { - $order = $this->checkout->placeOrder($fingerprint); - } catch (DisallowMultipleCartOrdersException|CartException $e) { - throw new PaymentNotConfirmedException($e->getMessage(), previous: $e); + $paymentIntentModel->status = $paymentIntent->status; + $paymentIntentModel->save(); + + PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data); + } + + /** + * 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. + */ + public function onOrderPlaced(OrderPlaced $event): void + { + $order = $event->order; + + $paymentIntentModel = StripePaymentIntent::where('cart_id', $order->cart_id)->first(); + + if (! $paymentIntentModel) { + return; } $paymentIntentModel->order_id = $order->id; - $paymentIntentModel->status = $paymentIntent->status; $paymentIntentModel->processed_at = now(); $paymentIntentModel->save(); - UpdateOrderFromIntent::execute($order, $paymentIntent); + $paymentIntent = Stripe::getClient()->paymentIntents->retrieve($paymentIntentModel->intent_id); - return $order->refresh(); + UpdateOrderFromIntent::execute($order, $paymentIntent); } } diff --git a/src/Payment/Enums/PaymentInitiationMode.php b/src/Payment/Enums/PaymentInitiationMode.php new file mode 100644 index 0000000..1133847 --- /dev/null +++ b/src/Payment/Enums/PaymentInitiationMode.php @@ -0,0 +1,32 @@ + $context + */ + public function __construct( + public readonly string $type, + public readonly string $reference, + public readonly int $amount, + public readonly array $context = [], + ) {} +} diff --git a/src/Payment/Exceptions/PaymentNotConfirmedException.php b/src/Payment/Exceptions/PaymentNotConfirmedException.php index a5cef0d..0c5af47 100644 --- a/src/Payment/Exceptions/PaymentNotConfirmedException.php +++ b/src/Payment/Exceptions/PaymentNotConfirmedException.php @@ -6,7 +6,7 @@ use RuntimeException; use Throwable; /** - * Thrown by a Modules\Core\Checkout\Contracts\PaymentDriver when the + * Thrown by a Modules\Core\Payment\Contracts\PaymentDriver when the * gateway has not confirmed payment — wrong/expired intent, already * processed, or the gateway itself rejects the confirmation. A driver * throws this instead of silently placing the order: CheckoutService:: diff --git a/src/Payment/Listeners/ApplyOfflinePaymentStatus.php b/src/Payment/Listeners/ApplyOfflinePaymentStatus.php new file mode 100644 index 0000000..856c3d0 --- /dev/null +++ b/src/Payment/Listeners/ApplyOfflinePaymentStatus.php @@ -0,0 +1,36 @@ +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), + ]); + } +} diff --git a/src/Payment/Services/PaymentDriverResolver.php b/src/Payment/Services/PaymentDriverResolver.php new file mode 100644 index 0000000..d583da1 --- /dev/null +++ b/src/Payment/Services/PaymentDriverResolver.php @@ -0,0 +1,29 @@ +') at all — deliberately + * non-throwing so a caller like CheckoutService::getPaymentMethods() + * can filter unresolvable types silently rather than treating "not + * registered" as an error condition when just checking availability. + */ + public function resolve(string $type): ?PaymentDriver + { + $driverClass = config("lunar.payments.types.{$type}.payment_driver"); + + return $driverClass ? app($driverClass) : null; + } +} diff --git a/src/Payment/Services/TransactionRecorder.php b/src/Payment/Services/TransactionRecorder.php new file mode 100644 index 0000000..fcd0c56 --- /dev/null +++ b/src/Payment/Services/TransactionRecorder.php @@ -0,0 +1,49 @@ +transactions()->create([ + 'success' => $result->success, + 'type' => 'refund', + 'driver' => $driver, + 'amount' => $result->amount, + 'reference' => $result->reference, + 'status' => $result->success ? 'succeeded' : 'failed', + 'notes' => $notes ?? $result->message, + 'meta' => $result->meta, + ]); + } + + public function recordCapture(Order $order, string $driver, CaptureResult $result, ?string $notes = null): Transaction + { + return $order->transactions()->create([ + 'success' => $result->success, + 'type' => 'capture', + 'driver' => $driver, + 'amount' => $result->amount, + 'reference' => $result->reference, + 'status' => $result->success ? 'succeeded' : 'failed', + 'notes' => $notes ?? $result->message, + 'meta' => $result->meta, + ]); + } +}