Feat: Updating PaymentDrivers and CheckoutService to handle payment methods
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver;
|
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
|
||||||
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
|
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -24,7 +24,7 @@ return [
|
|||||||
'types' => [
|
'types' => [
|
||||||
'cash-on-delivery' => [
|
'cash-on-delivery' => [
|
||||||
'driver' => 'offline',
|
'driver' => 'offline',
|
||||||
'payment_driver' => CashOnDeliveryPaymentDriver::class,
|
'payment_driver' => OfflinePaymentDriver::class,
|
||||||
'authorized' => 'awaiting-payment',
|
'authorized' => 'awaiting-payment',
|
||||||
'fee' => 0,
|
'fee' => 0,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -26,6 +26,25 @@ use Lunar\Models\Order;
|
|||||||
interface PaymentDriver
|
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
|
* $data carries whatever the gateway needs to confirm this specific
|
||||||
* payment (Stripe: ['payment_intent' => $id], a redirect-based
|
* payment (Stripe: ['payment_intent' => $id], a redirect-based
|
||||||
* provider: its callback payload) — passed explicitly by the caller
|
* provider: its callback payload) — passed explicitly by the caller
|
||||||
@@ -39,5 +58,5 @@ interface PaymentDriver
|
|||||||
* @throws FingerprintMismatchException
|
* @throws FingerprintMismatchException
|
||||||
* @throws CartException
|
* @throws CartException
|
||||||
*/
|
*/
|
||||||
public function confirm(Cart $cart, string $fingerprint, array $data): Order;
|
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use Modules\Core\Checkout\Events\ShippingAddressSet;
|
|||||||
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
||||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||||
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storefront-facing checkout operations, mirroring
|
* Storefront-facing checkout operations, mirroring
|
||||||
@@ -104,6 +105,10 @@ class CheckoutService
|
|||||||
* stock adjusted the total, another tab modified the cart) rather than
|
* stock adjusted the total, another tab modified the cart) rather than
|
||||||
* silently placing an order at a different total than what was shown.
|
* 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
|
* No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation
|
||||||
* (run inside Cart::createOrder()) already throws
|
* (run inside Cart::createOrder()) already throws
|
||||||
* Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag
|
* Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag
|
||||||
@@ -127,26 +132,66 @@ class CheckoutService
|
|||||||
return $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
|
||||||
|
* 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<string>
|
||||||
|
*/
|
||||||
|
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
|
* Records which payment type the shopper picked (Cart::meta
|
||||||
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
|
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
|
||||||
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
|
* 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
|
* Does not itself call a PaymentDriver — selecting a method and
|
||||||
* confirming payment against it are deliberately separate steps, same
|
* confirming payment against it are deliberately separate steps, same
|
||||||
* as selecting a shipping option happens before placing the order.
|
* as selecting a shipping option happens before placing the order.
|
||||||
*
|
*
|
||||||
* @throws UnknownPaymentTypeException if $type has no registered
|
* @throws UnknownPaymentTypeException if $type isn't currently offered
|
||||||
* PaymentDriver (config('lunar.payments.types.<type>.payment_driver'))
|
* — see getPaymentMethods() for what that means (registered,
|
||||||
|
* administratively enabled, and its driver reports itself usable)
|
||||||
*/
|
*/
|
||||||
public function selectPaymentMethod(string $type): Cart
|
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 = $this->cart->currentOrCreate();
|
||||||
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type];
|
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type];
|
||||||
$cart->save();
|
$cart->save();
|
||||||
|
|
||||||
|
$cart = $cart->calculate();
|
||||||
|
$cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()];
|
||||||
|
$cart->save();
|
||||||
|
|
||||||
Event::dispatch(new PaymentMethodSelected($cart, $type));
|
Event::dispatch(new PaymentMethodSelected($cart, $type));
|
||||||
|
|
||||||
return $cart;
|
return $cart;
|
||||||
@@ -159,30 +204,45 @@ class CheckoutService
|
|||||||
* carries whatever that driver needs (Stripe's payment_intent id, a
|
* carries whatever that driver needs (Stripe's payment_intent id, a
|
||||||
* future redirect-based provider's callback payload).
|
* 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<string, mixed> $data
|
* @param array<string, mixed> $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\FingerprintMismatchException
|
||||||
* @throws \Lunar\Exceptions\Carts\CartException
|
* @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);
|
if (! in_array($type, $this->getPaymentMethods(), true)) {
|
||||||
|
|
||||||
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) {
|
|
||||||
throw new UnknownPaymentTypeException($type);
|
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.<type>')
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,14 @@ class InstallLunarCommand extends Command
|
|||||||
* left untouched. Safe to re-run after a new payment type is added to
|
* left untouched. Safe to re-run after a new payment type is added to
|
||||||
* config('lunar.payments.types') (e.g. installing a Stripe/Nexi
|
* config('lunar.payments.types') (e.g. installing a Stripe/Nexi
|
||||||
* package), which is the whole reason this isn't a one-time-only seed.
|
* 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
|
private function seedPaymentMethods(): void
|
||||||
{
|
{
|
||||||
@@ -302,7 +310,7 @@ class InstallLunarCommand extends Command
|
|||||||
|
|
||||||
PaymentMethod::create([
|
PaymentMethod::create([
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'enabled' => true,
|
'enabled' => false,
|
||||||
'data' => [],
|
'data' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Payment\Drivers;
|
|
||||||
|
|
||||||
use Lunar\Exceptions\Carts\CartException;
|
|
||||||
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
|
|
||||||
use Lunar\Exceptions\FingerprintMismatchException;
|
|
||||||
use Lunar\Models\Cart;
|
|
||||||
use Lunar\Models\Order;
|
|
||||||
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
|
||||||
use Modules\Core\Checkout\Services\CheckoutService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cash-on-delivery has no gateway to confirm against — the shopper pays the
|
|
||||||
* courier on delivery, not at checkout — so confirm() has nothing to wait
|
|
||||||
* on and places the order immediately, same as Lunar's own OfflinePayment
|
|
||||||
* would, but through CheckoutService::placeOrder() so it goes through the
|
|
||||||
* same fingerprint check every other driver does. $data is unused: nothing
|
|
||||||
* about this confirmation depends on gateway-specific payload.
|
|
||||||
*
|
|
||||||
* Sets the order status to config('lunar.payments.types.cash-on-delivery.authorized')
|
|
||||||
* afterward — placeOrder() itself leaves the order at Lunar's configured
|
|
||||||
* draft_status, same as every driver is responsible for moving it on from.
|
|
||||||
*/
|
|
||||||
class CashOnDeliveryPaymentDriver implements PaymentDriver
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CheckoutService $checkout,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws FingerprintMismatchException
|
|
||||||
* @throws CartException
|
|
||||||
* @throws DisallowMultipleCartOrdersException
|
|
||||||
*/
|
|
||||||
public function confirm(Cart $cart, string $fingerprint, array $data): Order
|
|
||||||
{
|
|
||||||
$order = $this->checkout->placeOrder($fingerprint);
|
|
||||||
|
|
||||||
$order->update([
|
|
||||||
'status' => config('lunar.payments.types.cash-on-delivery.authorized', $order->status),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $order->refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Drivers;
|
||||||
|
|
||||||
|
use Lunar\Exceptions\Carts\CartException;
|
||||||
|
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
|
||||||
|
use Lunar\Exceptions\FingerprintMismatchException;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
||||||
|
use Modules\Core\Checkout\Services\CheckoutService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 places
|
||||||
|
* the order immediately, same as Lunar's own OfflinePayment would, but
|
||||||
|
* through CheckoutService::placeOrder() so it goes through the same
|
||||||
|
* fingerprint check every other driver does. $data is unused: nothing about
|
||||||
|
* this confirmation depends on gateway-specific payload.
|
||||||
|
*
|
||||||
|
* Sets the order status to config("lunar.payments.types.{$type}.authorized")
|
||||||
|
* afterward, using the type actually confirmed — not a hardcoded key —
|
||||||
|
* since this one driver is shared across multiple types.
|
||||||
|
* placeOrder() itself leaves the order at Lunar's configured draft_status,
|
||||||
|
* same as every driver is responsible for moving it on from.
|
||||||
|
*/
|
||||||
|
class OfflinePaymentDriver implements PaymentDriver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CheckoutService $checkout,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always true — no external dependency to be missing.
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws FingerprintMismatchException
|
||||||
|
* @throws CartException
|
||||||
|
* @throws DisallowMultipleCartOrdersException
|
||||||
|
*/
|
||||||
|
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
|
||||||
|
{
|
||||||
|
$order = $this->checkout->placeOrder($fingerprint);
|
||||||
|
|
||||||
|
$order->update([
|
||||||
|
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $order->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,16 @@ class StripePaymentDriver implements PaymentDriver
|
|||||||
private readonly CheckoutService $checkout,
|
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
|
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the
|
||||||
* payment intent (wrong intent id, already processed, order already
|
* payment intent (wrong intent id, already processed, order already
|
||||||
@@ -43,7 +53,7 @@ class StripePaymentDriver implements PaymentDriver
|
|||||||
* @throws FingerprintMismatchException
|
* @throws FingerprintMismatchException
|
||||||
* @throws CartException
|
* @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'];
|
$paymentIntentId = $data['payment_intent'];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user