From 95290365855bbf1123ad49e1b843ba1d0c3cdc9f Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Mon, 31 Aug 2026 14:12:21 +0300 Subject: [PATCH] Feat: Updating PaymentDrivers and CheckoutService to handle payment methods --- config/payment.php | 4 +- src/Checkout/Contracts/PaymentDriver.php | 21 +++- src/Checkout/Services/CheckoutService.php | 100 ++++++++++++++---- src/Command/InstallLunarCommand.php | 10 +- .../Drivers/CashOnDeliveryPaymentDriver.php | 46 -------- src/Payment/Drivers/OfflinePaymentDriver.php | 57 ++++++++++ src/Payment/Drivers/StripePaymentDriver.php | 12 ++- 7 files changed, 179 insertions(+), 71 deletions(-) delete mode 100644 src/Payment/Drivers/CashOnDeliveryPaymentDriver.php create mode 100644 src/Payment/Drivers/OfflinePaymentDriver.php diff --git a/config/payment.php b/config/payment.php index 02ee66d..64889eb 100644 --- a/config/payment.php +++ b/config/payment.php @@ -1,6 +1,6 @@ [ 'cash-on-delivery' => [ 'driver' => 'offline', - 'payment_driver' => CashOnDeliveryPaymentDriver::class, + 'payment_driver' => OfflinePaymentDriver::class, 'authorized' => 'awaiting-payment', 'fee' => 0, ], diff --git a/src/Checkout/Contracts/PaymentDriver.php b/src/Checkout/Contracts/PaymentDriver.php index 4f458c0..f03c302 100644 --- a/src/Checkout/Contracts/PaymentDriver.php +++ b/src/Checkout/Contracts/PaymentDriver.php @@ -26,6 +26,25 @@ use Lunar\Models\Order; 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 @@ -39,5 +58,5 @@ interface PaymentDriver * @throws FingerprintMismatchException * @throws CartException */ - public function confirm(Cart $cart, string $fingerprint, array $data): Order; + public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order; } diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php index 27c2b00..a82a2ec 100644 --- a/src/Checkout/Services/CheckoutService.php +++ b/src/Checkout/Services/CheckoutService.php @@ -20,6 +20,7 @@ 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; /** * Storefront-facing checkout operations, mirroring @@ -104,6 +105,10 @@ class CheckoutService * 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 @@ -127,26 +132,66 @@ class CheckoutService 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 + * 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 + * InstallLunarCommand::seedPaymentMethods(). + * + * @return array + */ + public function getPaymentMethods(): array + { + return PaymentMethod::where('enabled', true) + ->pluck('type') + ->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false) + ->values() + ->all(); + } + /** * Records which payment type the shopper picked (Cart::meta * ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\ * Cart\ApplyCashOnDeliveryFee to add that type's own cart-total - * adjustments before the shopper reaches placeOrder()/confirmPayment(). + * adjustments before recalculation. + * + * Also snapshots Cart::fingerprint() into meta, *after* saving the + * chosen type — the fingerprint has to reflect the final total + * 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 + * 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 * as selecting a shipping option happens before placing the order. * - * @throws UnknownPaymentTypeException if $type has no registered - * PaymentDriver (config('lunar.payments.types..payment_driver')) + * @throws UnknownPaymentTypeException if $type isn't currently offered + * — see getPaymentMethods() for what that means (registered, + * administratively enabled, and its driver reports itself usable) */ public function selectPaymentMethod(string $type): Cart { - $this->paymentDriverFor($type); + if (! in_array($type, $this->getPaymentMethods(), true)) { + throw new UnknownPaymentTypeException($type); + } $cart = $this->cart->currentOrCreate(); $cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type]; $cart->save(); + $cart = $cart->calculate(); + $cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()]; + $cart->save(); + Event::dispatch(new PaymentMethodSelected($cart, $type)); return $cart; @@ -159,30 +204,45 @@ class CheckoutService * 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 + * 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. + * * @param array $data * - * @throws UnknownPaymentTypeException if $type has no registered driver + * @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 */ - public function confirmPayment(string $type, string $fingerprint, array $data = []): Order + public function confirmPayment(string $type, array $data = []): Order { - $driver = $this->paymentDriverFor($type); - - return $driver->confirm($this->cart->currentOrCreate(), $fingerprint, $data); - } - - /** - * @throws UnknownPaymentTypeException - */ - private function paymentDriverFor(string $type): PaymentDriver - { - $driverClass = config("lunar.payments.types.{$type}.payment_driver"); - - if (! $driverClass) { + if (! in_array($type, $this->getPaymentMethods(), true)) { throw new UnknownPaymentTypeException($type); } - return app($driverClass); + $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; } } diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index 324dc68..6b92788 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -290,6 +290,14 @@ class InstallLunarCommand extends Command * left untouched. Safe to re-run after a new payment type is added to * config('lunar.payments.types') (e.g. installing a Stripe/Nexi * package), which is the whole reason this isn't a one-time-only seed. + * + * Seeded disabled — a newly-seeded row (whether from this store's + * initial install, or a payment provider package installed later) + * shouldn't go live for shoppers before staff have actually reviewed + * it (real credentials configured, a fee set, etc.) and turned it on + * via the Payment Methods resource. See CheckoutService:: + * getPaymentMethods(), which only offers a type once both 'enabled' + * here and its driver's own isConfigured() check pass. */ private function seedPaymentMethods(): void { @@ -302,7 +310,7 @@ class InstallLunarCommand extends Command PaymentMethod::create([ 'type' => $type, - 'enabled' => true, + 'enabled' => false, 'data' => [], ]); } diff --git a/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php b/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php deleted file mode 100644 index 79c927c..0000000 --- a/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php +++ /dev/null @@ -1,46 +0,0 @@ -checkout->placeOrder($fingerprint); - - $order->update([ - 'status' => config('lunar.payments.types.cash-on-delivery.authorized', $order->status), - ]); - - return $order->refresh(); - } -} diff --git a/src/Payment/Drivers/OfflinePaymentDriver.php b/src/Payment/Drivers/OfflinePaymentDriver.php new file mode 100644 index 0000000..ff3db38 --- /dev/null +++ b/src/Payment/Drivers/OfflinePaymentDriver.php @@ -0,0 +1,57 @@ +checkout->placeOrder($fingerprint); + + $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 c18f7f4..51b0582 100644 --- a/src/Payment/Drivers/StripePaymentDriver.php +++ b/src/Payment/Drivers/StripePaymentDriver.php @@ -35,6 +35,16 @@ class StripePaymentDriver implements PaymentDriver private readonly CheckoutService $checkout, ) {} + /** + * 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. + */ + public function isConfigured(): bool + { + return filled(config('services.stripe.key')); + } + /** * @throws PaymentNotConfirmedException if Stripe hasn't confirmed the * payment intent (wrong intent id, already processed, order already @@ -43,7 +53,7 @@ class StripePaymentDriver implements PaymentDriver * @throws FingerprintMismatchException * @throws CartException */ - public function confirm(Cart $cart, string $fingerprint, array $data): Order + public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order { $paymentIntentId = $data['payment_intent'];