Feat: Updates to Payments, Checkout Services, Payment Events

This commit is contained in:
2026-09-02 16:14:52 +03:00
parent 3497553b41
commit 8cb54e065e
18 changed files with 553 additions and 84 deletions
+33 -29
View File
@@ -2,35 +2,28 @@
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;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Events\PaymentConfirmed;
use Modules\Core\Payment\Contracts\PaymentDriver;
/**
* 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.
* 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.
*
* 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.
* 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.
*/
class OfflinePaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* Always true — no external dependency to be missing.
*/
@@ -39,19 +32,30 @@ class OfflinePaymentDriver implements PaymentDriver
return true;
}
/**
* @throws FingerprintMismatchException
* @throws CartException
* @throws DisallowMultipleCartOrdersException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void
{
$order = $this->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();
}
}
+44 -30
View File
@@ -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);
}
}