Feat: Adding Payment Drivers, configuring stripe

This commit is contained in:
2026-08-31 12:50:29 +03:00
parent 637da37b9b
commit 661e8b9a96
4 changed files with 165 additions and 1 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
"lunarphp/table-rate-shipping": "^1.3",
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8"
"spatie/laravel-translation-loader": "^2.8",
"lunarphp/stripe": "1.3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\Checkout\Contracts;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A boboko-owned payment driver — wraps a payment gateway's own confirmation
* mechanics (Stripe's synchronous authorize() call, a redirect-based
* provider's async callback/webhook, anything else) behind one uniform
* moment: "payment is confirmed, place the order."
*
* confirm() is the only thing a driver is required to do: once it has,
* by whatever mechanism is native to that gateway, independently decided
* the payment succeeded, it calls Modules\Core\Checkout\Services\
* CheckoutService::placeOrder($fingerprint) itself — no driver ever calls
* Lunar\Models\Cart::createOrder() directly. This is what lets the
* storefront checkout sequence stay uniform regardless of which provider is
* active: set addresses, select shipping, hand off to whichever driver is
* configured, and the driver decides when (or whether) the order actually
* gets created. See docs/checkout.md / docs/payments.md.
*/
interface PaymentDriver
{
/**
* $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<string, mixed> $data
*
* @throws \Lunar\Exceptions\FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException
*/
public function confirm(Cart $cart, string $fingerprint, array $data): Order;
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace Modules\Core\Payment\Drivers;
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\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.
*
* 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.
*/
class StripePaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* @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 \Lunar\Exceptions\FingerprintMismatchException
* @throws CartException
*/
public function confirm(Cart $cart, string $fingerprint, array $data): Order
{
$paymentIntentId = $data['payment_intent'];
$paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntentId)->first();
if ($paymentIntentModel && ! $paymentIntentModel->isActive()) {
throw new PaymentNotConfirmedException('Payment intent already processed.');
}
if (! $paymentIntentModel) {
$paymentIntentModel = StripePaymentIntent::create([
'intent_id' => $paymentIntentId,
'cart_id' => $cart->id,
]);
}
$paymentIntentModel->update(['processing_at' => now()]);
$stripe = Stripe::getClient();
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (! $paymentIntent) {
throw new PaymentNotConfirmedException('Unable to locate payment intent.');
}
$policy = config('lunar.stripe.policy', 'automatic');
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') {
$paymentIntent = $stripe->paymentIntents->capture($paymentIntentId);
}
if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) {
$paymentIntentModel->update(['status' => $paymentIntent->status]);
throw new PaymentNotConfirmedException(
$paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}."
);
}
try {
$order = $this->checkout->placeOrder($fingerprint);
} catch (DisallowMultipleCartOrdersException|CartException $e) {
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e);
}
$paymentIntentModel->order_id = $order->id;
$paymentIntentModel->status = $paymentIntent->status;
$paymentIntentModel->processed_at = now();
$paymentIntentModel->save();
UpdateOrderFromIntent::execute($order, $paymentIntent);
return $order->refresh();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Payment\Exceptions;
use RuntimeException;
use Throwable;
/**
* Thrown by a Modules\Core\Checkout\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::
* placeOrder() must only ever be called once a driver has positively
* confirmed payment, never as a fallback.
*/
class PaymentNotConfirmedException extends RuntimeException
{
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}