Feature: Payment resolver, Payment Provider, Completing Stripe Webhooks, Wiring Payments to checkout service

This commit is contained in:
2026-09-03 17:27:34 +03:00
parent 35c3334690
commit 456943dc74
14 changed files with 266 additions and 126 deletions
+8 -6
View File
@@ -14,18 +14,20 @@ return [
| Lunar's own config. | Lunar's own config.
| |
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key — | 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| it's the Modules\Core\Payment\Contracts\PaymentDriver class | the driver instance Modules\Core\Payment\Services\PaymentDriverResolver
| CheckoutService::confirmPayment() resolves via the container and calls | resolves via the container. 'capture_mode' ('pay' or 'authorize') is
| confirm() on. Kept on the same row as 'driver' rather than a second, | also boboko-owned — which contract method
| separately-keyed map, so a type's full definition — Lunar's driver, | CheckoutService::initiatePayment() calls for this type. Kept on the
| its config, and its PaymentDriver — lives in one place. | same row as 'driver' rather than a second, separately-keyed map, so a
| type's full definition lives in one place.
| |
*/ */
'types' => [ 'types' => [
'cash-on-delivery' => [ 'cash-on-delivery' => [
'driver' => 'offline', 'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class, 'payment_driver' => OfflinePaymentDriver::class,
'authorized' => 'awaiting-payment', 'capture_mode' => 'pay',
'captured_status' => 'payment-offline',
'fee' => 0, 'fee' => 0,
], ],
], ],
+11 -7
View File
@@ -5,13 +5,17 @@ namespace Modules\Core\Checkout\Events;
use Lunar\Models\Order; use Lunar\Models\Order;
/** /**
* Dispatched by CheckoutService::placeOrder() the moment an Order exists — * Dispatched once an Order's placed_at is set — the handoff point between
* the handoff point between Checkout and Order (see docs/checkout.md's * Checkout/Payment and Order (see docs/checkout.md's "Three-stage
* "Three-stage lifecycle"). Checkout has no opinion about what happens * lifecycle"). Fired by Modules\Core\Order\Listeners\
* after this fires; Order's own listeners (not built yet — Order is a * ApplyResolvedPaymentStatus once it resolves a PaymentCaptured/
* named-but-unscoped concern, same status Recovery had before it existed) * PaymentAuthorized event into an actual order status change, not by
* would be what reacts to it — e.g. a confirmation email, initializing * CheckoutService directly — a draft Order can exist (via
* order status tracking. * CheckoutService::initiatePayment()) well before this fires, if payment
* resolves asynchronously (e.g. a redirect-based gateway). Checkout has no
* opinion about what happens after this fires; Order's own listeners are
* what react to it — e.g. a confirmation email, initializing order status
* tracking.
*/ */
class OrderPlaced class OrderPlaced
{ {
+74 -77
View File
@@ -10,16 +10,14 @@ use Lunar\Base\Addressable;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest; use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\Order;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Events\BillingAddressSet; 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\PaymentMethodSelected;
use Modules\Core\Checkout\Events\ShippingAddressSet; 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\DTOs\PaymentResult;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverResolver; use Modules\Core\Payment\Services\PaymentDriverResolver;
@@ -30,9 +28,11 @@ use Modules\Core\Payment\Services\PaymentDriverResolver;
* implementation detail. See docs/checkout.md for the full design — * implementation detail. See docs/checkout.md for the full design —
* Checkout is the middle of a three-stage lifecycle (Cart → Checkout → * Checkout is the middle of a three-stage lifecycle (Cart → Checkout →
* Order): it owns the placement moment itself (address, shipping selection, * Order): it owns the placement moment itself (address, shipping selection,
* placeOrder()) and ends the instant an Order exists. What happens to that * ensuring a draft Order exists) and hands off to Payment the instant that
* Order afterward (status transitions, fulfillment) is deliberately out of * draft exists — see initiatePayment(). What happens to that Order
* scope here — see OrderPlaced's docblock. * afterward (status transitions, fulfillment) is deliberately out of
* scope here — see docs/payments.md and Checkout\Events\OrderPlaced's
* docblock for where that now lives.
* *
* Depends on CartService for cart access rather than reaching into * Depends on CartService for cart access rather than reaching into
* Lunar\Facades\CartSession directly a second time, so Checkout stays * Lunar\Facades\CartSession directly a second time, so Checkout stays
@@ -99,47 +99,12 @@ class CheckoutService
return $cart; return $cart;
} }
/**
* $fingerprint is mandatory, not optional — the caller must prove the
* cart total the shopper last saw (Cart::fingerprint()) still matches
* before an order is placed. Cart::checkFingerprint() throws Lunar's own
* FingerprintMismatchException on a mismatch (a line's price changed,
* 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
* ($exception->errors()) for address/shipping-option validation and the
* duplicate-order guard — already the right shape for a storefront to
* render as form errors directly. FingerprintMismatchException
* propagates the same way, for the same reason.
*
* @throws FingerprintMismatchException
* @throws CartException
*/
public function placeOrder(string $fingerprint): Order
{
$cart = $this->cart->currentOrCreate();
$cart->checkFingerprint($fingerprint);
$order = $cart->createOrder();
Event::dispatch(new OrderPlaced($order));
return $order;
}
/** /**
* Every payment type currently offered to the storefront — every key * Every payment type currently offered to the storefront — every key
* in config('lunar.payments.types') that is BOTH administratively * in config('lunar.payments.types') that is BOTH administratively
* enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND * enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND
* whose registered PaymentDriver reports itself usable right now * whose registered driver reports itself usable right now
* (PaymentDriver::isConfigured() — e.g. Stripe with no API key set is * (Configurable::isConfigured() — e.g. Stripe with no API key set is
* never offered, regardless of the enabled toggle). A type with no * never offered, regardless of the enabled toggle). A type with no
* PaymentMethod row at all (never seeded) is treated as not offered, * PaymentMethod row at all (never seeded) is treated as not offered,
* same as disabled — nothing here creates one; see * same as disabled — nothing here creates one; see
@@ -167,13 +132,13 @@ class CheckoutService
* including any payment-type-specific adjustment (e.g. a COD * including any payment-type-specific adjustment (e.g. a COD
* surcharge), which only exists once payment_method is set and the * surcharge), which only exists once payment_method is set and the
* cart recalculates. Captured here, server-side, rather than asked of * cart recalculates. Captured here, server-side, rather than asked of
* the storefront: this is the last moment before confirmPayment() that * the storefront: this is the last moment before initiatePayment() that
* the shopper's reviewed total is known, and confirmPayment() reads it * the shopper's reviewed total is known, and initiatePayment() reads it
* back internally instead of taking a fingerprint parameter — a * back internally instead of taking a fingerprint parameter — a
* storefront should never need to know Cart::fingerprint() exists. * storefront should never need to know Cart::fingerprint() exists.
* *
* Does not itself call a PaymentDriver — selecting a method and * Does not itself call a payment driver — selecting a method and
* confirming payment against it are deliberately separate steps, same * initiating 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 isn't currently offered * @throws UnknownPaymentTypeException if $type isn't currently offered
@@ -200,44 +165,76 @@ class CheckoutService
} }
/** /**
* Resolves $type's registered PaymentDriver and calls confirm() — the * The one storefront-facing "place this order and pay for it" call —
* driver independently decides whether payment succeeded and, if so, * the point where Checkout hands off to Payment. Ensures a draft
* dispatches PaymentConfirmed (see PaymentDriver's docblock) rather * Order exists (Cart::createOrder() — confirmed idempotent against a
* than placing the order itself or returning it here. This method is * cart's own pre-existing, not-yet-placed-at draft; see
* fire-and-forget as far as the Order is concerned: a caller that * vendor/lunarphp/core/src/Actions/Carts/CreateOrder.php), then
* needs it back listens for OrderPlaced, the same way a driver's own * resolves the payment type selected by selectPaymentMethod() and
* post-placement step does — see PaymentConfirmed's docblock for why a * calls pay() or authorize() on its driver, per that type's
* direct return value doesn't fit every gateway (async/webhook-driven * config('lunar.payments.types.{type}.capture_mode') — boboko-core's
* confirmations have no synchronous caller waiting for one at all). * own types (config/payment.php) are merged into that same Lunar
* config key by PaymentServiceProvider::boot().
* *
* $data carries whatever that driver needs (Stripe's payment_intent * Returns the driver's own PaymentResult UNCHANGED — this method does
* id, a future redirect-based provider's callback payload). * not wait for or resolve anything past what pay()/authorize() itself
* returns synchronously. A Pending result (an async gateway like
* Stripe requiring 3-D Secure/a redirect) is a normal, expected
* outcome, not an error — the caller (a storefront controller) is
* responsible for whatever the gateway needs next.
* *
* The fingerprint passed to the driver is the one captured by * KNOWN GAP, explicitly out of scope for now: PaymentResult alone does
* selectPaymentMethod(), not supplied by the caller — see that * not carry gateway-specific continuation data (e.g. Stripe's
* method's docblock. Throws the same FingerprintMismatchException a * PaymentIntent client_secret for a Pending result needing frontend
* caller-supplied one would if the cart's total has since changed; * confirmation) — that concept existed on the deleted PaymentInitiation
* missing entirely (selectPaymentMethod() was never called for this * DTO and was intentionally removed from Payment's abstraction layer.
* cart) is treated the same as a mismatch, not a different error. * Nothing here re-introduces it; only OfflinePaymentDriver's
* always-Immediate-Succeeded path is fully wired end-to-end today.
* *
* @param array<string, mixed> $data * The draft order's own $order->total (not the Cart's) is what gets
* passed as $amount — Order::$total is Lunar's own Price-cast
* attribute, already resolving the correct Currency via the order's
* own currency_code, and is the authoritative total once the draft
* row exists.
* *
* @throws UnknownPaymentTypeException if $type isn't currently offered * $context passed to the driver is {cart_id, order_id} — the exact
* (see getPaymentMethods()) — re-checked here, not just in * keys Modules\Core\Payment\Drivers\StripePaymentDriver::
* selectPaymentMethod(), since a type could be disabled between * rememberIntent() already reads.
* selection and confirmation *
* @throws \Lunar\Exceptions\FingerprintMismatchException * Same fingerprint precondition the old placeOrder() had: mandatory,
* @throws \Lunar\Exceptions\Carts\CartException * not optional, checked before the draft is created.
*
* @param array<string, mixed> $data passed through untouched to
* the driver's pay()/authorize() — e.g. Stripe's payment_method
* token.
*
* @throws UnknownPaymentTypeException if the cart's selected
* payment_method (from selectPaymentMethod()) is no longer offered
* — re-checked here, not just at selection time, since a type could
* be disabled in between
* @throws FingerprintMismatchException
* @throws CartException
*/ */
public function confirmPayment(string $type, array $data = []): void public function initiatePayment(string $fingerprint, array $data = []): PaymentResult
{ {
if (! in_array($type, $this->getPaymentMethods(), true)) { $cart = $this->cart->currentOrCreate();
throw new UnknownPaymentTypeException($type); $cart->checkFingerprint($fingerprint);
$type = $cart->meta['payment_method'] ?? null;
if ($type === null || ! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException((string) $type);
} }
$cart = $this->cart->currentOrCreate(); $order = $cart->createOrder();
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
$this->paymentDrivers->resolve($type)->confirm($cart, $type, $fingerprint, $data); $driver = $this->paymentDrivers->resolve($type);
$captureMode = config("lunar.payments.types.{$type}.capture_mode", 'pay');
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
return $captureMode === 'authorize'
? $driver->authorize($type, $order->total, $data, $context)
: $driver->pay($type, $order->total, $data, $context);
} }
} }
@@ -2,25 +2,62 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Payment\Events\OrderPaymentStatusResolved; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
/** /**
* The only place an Order's status column is written in reaction to a * The only place an Order's status column is written in reaction to a
* payment outcome — Payment dispatches OrderPaymentStatusResolved with * payment outcome. Registered against BOTH PaymentCaptured and
* what the status should become, never touching the Order model itself; * PaymentAuthorized (see OrderServiceProvider) — same handler either way,
* this listener, living in Order's own module, is what applies it. * since both carry the same {type, result, context} shape and only differ
* in which config key decides the resulting status.
*
* Reads $event->context['order_id'] to find which Order this outcome
* belongs to — Payment has no concept of an Order, so this is the one
* place that context key gets consumed on the Order side (Payment's own
* StripePaymentDriver reads $context['order_id'] independently, for its
* own unrelated correlation need — see that class's rememberIntent()).
* *
* Loads and saves the model (not a bulk ::whereKey()->update()) so * Loads and saves the model (not a bulk ::whereKey()->update()) so
* Order::observe()'s updated() hook fires and OrderStatusUpdated goes out * Order::observe()'s updated() hook fires and OrderStatusUpdated goes out
* the same as any other status write — see that event's own docblock for * the same as any other status write — see that event's own docblock for
* why it's meant to fire "regardless of what wrote it." * why it's meant to fire "regardless of what wrote it."
*
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set —
* see that event's own docblock for why this, not CheckoutService, is now
* the dispatch point.
*/ */
class ApplyResolvedPaymentStatus class ApplyResolvedPaymentStatus
{ {
public function handle(OrderPaymentStatusResolved $event): void public function handle(PaymentCaptured|PaymentAuthorized $event): void
{ {
$order = Order::findOrFail($event->orderId); $orderId = $event->context['order_id'] ?? null;
$order->update(['status' => $event->status]);
if ($orderId === null) {
return;
}
$order = Order::findOrFail($orderId);
$configKey = $event instanceof PaymentCaptured ? 'captured_status' : 'authorized_status';
$status = config("lunar.payments.types.{$event->type}.{$configKey}");
if ($status === null) {
return;
}
$wasPlaced = ! blank($order->placed_at);
$order->update([
'status' => $status,
'placed_at' => $order->placed_at ?? now(),
]);
if (! $wasPlaced) {
Event::dispatch(new OrderPlaced($order));
}
} }
} }
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Payment\DTOs;
use Modules\Core\Payment\Enums\PaymentContinuationType;
/**
* What a caller does next with a Pending PaymentResult, gateway-agnostic —
* see PaymentContinuationType for the two shapes. Deliberately minimal:
* this is NOT a return to the deleted PaymentInitiation DTO (which also
* carried mode/reference/meta) — reference already lives on PaymentResult
* itself, and mode is now this DTO's own $type.
*/
final class PaymentContinuation
{
public function __construct(
public readonly PaymentContinuationType $type,
public readonly string $value,
) {}
}
+6
View File
@@ -47,6 +47,11 @@ final class PaymentResult
* normalize into anything above. * normalize into anything above.
* @param $meta driver-specific extras that don't fit the normalized * @param $meta driver-specific extras that don't fit the normalized
* fields above (e.g. a card's last four digits). * fields above (e.g. a card's last four digits).
* @param $continuation only meaningful when $status is Pending —
* what the caller does next (a redirect URL, a client secret for
* frontend JS), gateway-agnostic. Null for every other status, and
* for any driver whose pay()/authorize() never returns Pending
* (e.g. OfflinePaymentDriver).
*/ */
public function __construct( public function __construct(
public readonly PaymentResultStatus $status, public readonly PaymentResultStatus $status,
@@ -56,5 +61,6 @@ final class PaymentResult
public readonly bool $retriable = false, public readonly bool $retriable = false,
public readonly array $raw = [], public readonly array $raw = [],
public readonly array $meta = [], public readonly array $meta = [],
public readonly ?PaymentContinuation $continuation = null,
) {} ) {}
} }
@@ -14,7 +14,9 @@ use Modules\Core\Payment\Contracts\SupportsCaptures;
use Modules\Core\Payment\Contracts\SupportsPay; use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\Contracts\SupportsRefunds; use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\Contracts\SupportsVoids; use Modules\Core\Payment\Contracts\SupportsVoids;
use Modules\Core\Payment\DTOs\PaymentContinuation;
use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentContinuationType;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentAuthorizationFailed; use Modules\Core\Payment\Events\PaymentAuthorizationFailed;
use Modules\Core\Payment\Events\PaymentAuthorized; use Modules\Core\Payment\Events\PaymentAuthorized;
@@ -319,12 +321,17 @@ class StripePaymentDriver implements
default => PaymentResultStatus::Pending, default => PaymentResultStatus::Pending,
}; };
$continuation = $status === PaymentResultStatus::Pending
? new PaymentContinuation(PaymentContinuationType::ClientSecret, $paymentIntent->client_secret)
: null;
$result = new PaymentResult( $result = new PaymentResult(
status: $status, status: $status,
reference: $paymentIntent->id, reference: $paymentIntent->id,
amount: $amount, amount: $amount,
failureReason: $paymentIntent->last_payment_error->message ?? null, failureReason: $paymentIntent->last_payment_error->message ?? null,
raw: $paymentIntent->toArray(), raw: $paymentIntent->toArray(),
continuation: $continuation,
); );
if ($status === PaymentResultStatus::Pending) { if ($status === PaymentResultStatus::Pending) {
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Payment\Enums;
/**
* What a caller of a Pending PaymentResult needs to do next, gateway-
* agnostically. Only meaningful when PaymentResult::$continuation is not
* null (status === Pending).
*/
enum PaymentContinuationType
{
/** Send the shopper to $continuation->value (a URL) — a redirect-based gateway. */
case Redirect;
/** Hand $continuation->value (a client secret) to frontend JS — Stripe Elements-style. */
case ClientSecret;
}
@@ -1,22 +0,0 @@
<?php
namespace Modules\Core\Payment\Exceptions;
use RuntimeException;
use Throwable;
/**
* 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::
* 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);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Payment\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\Payment\Drivers\StripePaymentDriver;
use Stripe\Webhook;
/**
* A boboko-owned webhook endpoint for Stripe — deliberately NOT
* lunarphp/stripe's own route (vendor/lunarphp/stripe/routes/webhooks.php),
* which dispatches into Lunar's own Payments::driver('stripe') flow (the
* flow StripePaymentDriver was built to replace, see that class's own
* docblock). Signature verification is handled by
* Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware, registered on this
* route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
* verification + event-type filtering, safe to reuse even though this
* controller never touches the rest of that vendor package's flow. This
* controller verifies the signature again itself (Webhook::constructEvent())
* to get the constructed Event object — the middleware doesn't stash one
* anywhere reusable, it only gates the request through.
*
* Resolves the driver directly by class, not via
* Modules\Core\Payment\Services\PaymentDriverResolver — this endpoint is
* inherently Stripe-specific (Stripe's own webhook payload carries no
* boboko payment-type key, only its own payment_intent id), and
* StripePaymentDriver::handleCallback() already recovers $type itself
* from the StripePaymentIntent row pay()/authorize() wrote.
*/
class StripeWebhookController extends Controller
{
public function __invoke(Request $request, StripePaymentDriver $driver): JsonResponse
{
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhooks.lunar'),
);
$driver->handleCallback($event->data->object->id, $event->data->object->toArray());
return response()->json(['webhook_successful' => true]);
}
}
+12 -7
View File
@@ -2,14 +2,19 @@
namespace Modules\Core\Payment\Services; namespace Modules\Core\Payment\Services;
use Modules\Core\Payment\Contracts\PaymentDriver;
/** /**
* Resolves a payment type key (e.g. 'stripe', 'cash-on-delivery') to its * Resolves a payment type key (e.g. 'stripe', 'cash-on-delivery') to its
* registered PaymentDriver — extracted out of CheckoutService so both it * registered driver instance — extracted out of CheckoutService so both it
* and anything else needing the same lookup (e.g. a listener reacting to * and anything else needing the same lookup share one implementation
* OrderPlaced, which has no reason to depend on Checkout's own service) * instead of duplicating this config read.
* share one implementation instead of duplicating this config read. *
* Returns a plain object, not a shared interface — Payment's own drivers
* implement several independent, orthogonal capability interfaces at once
* (Configurable, SupportsPay, SupportsAuthorization, ...; see
* StripePaymentDriver implementing all six). There is no single common
* "PaymentDriver" contract to type this against; a caller checks
* `instanceof SupportsPay` / `instanceof SupportsAuthorization` itself,
* the same way Payment's own contracts are designed to be consumed.
*/ */
class PaymentDriverResolver class PaymentDriverResolver
{ {
@@ -20,7 +25,7 @@ class PaymentDriverResolver
* can filter unresolvable types silently rather than treating "not * can filter unresolvable types silently rather than treating "not
* registered" as an error condition when just checking availability. * registered" as an error condition when just checking availability.
*/ */
public function resolve(string $type): ?PaymentDriver public function resolve(string $type): ?object
{ {
$driverClass = config("lunar.payments.types.{$type}.payment_driver"); $driverClass = config("lunar.payments.types.{$type}.payment_driver");
+14
View File
@@ -0,0 +1,14 @@
<?php
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route;
use Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware;
use Modules\Core\Payment\Http\Controllers\StripeWebhookController;
Route::post(
config('payment.stripe.webhook_path', 'payments/stripe/webhook'),
StripeWebhookController::class
)
->middleware([StripeWebhookMiddleware::class, 'api'])
->withoutMiddleware([VerifyCsrfToken::class])
->name('payment.stripe.webhook');
+5
View File
@@ -7,6 +7,7 @@ use Illuminate\Support\ServiceProvider;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Models\Transaction; use Lunar\Models\Transaction;
use Modules\Core\Notification\NotificationRegistry; use Modules\Core\Notification\NotificationRegistry;
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Notifications\OrderCapturedNotification; use Modules\Core\Order\Notifications\OrderCapturedNotification;
use Modules\Core\Order\Notifications\OrderDeliveredNotification; use Modules\Core\Order\Notifications\OrderDeliveredNotification;
@@ -15,6 +16,8 @@ use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
use Modules\Core\Order\Observers\OrderObserver; use Modules\Core\Order\Observers\OrderObserver;
use Modules\Core\Order\Observers\TransactionObserver; use Modules\Core\Order\Observers\TransactionObserver;
use Modules\Core\Order\Support\OrderStatus; use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
class OrderServiceProvider extends ServiceProvider class OrderServiceProvider extends ServiceProvider
@@ -28,6 +31,8 @@ class OrderServiceProvider extends ServiceProvider
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this)); Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class); Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
NotificationRegistry::get()->register([ NotificationRegistry::get()->register([
OrderDeliveredNotification::class, OrderDeliveredNotification::class,
+2
View File
@@ -38,5 +38,7 @@ class PaymentServiceProvider extends ServiceProvider
} }
config(['lunar.cart.pipelines.cart' => $cartPipeline]); config(['lunar.cart.pipelines.cart' => $cartPipeline]);
$this->loadRoutesFrom(__DIR__ . '/../Payment/routes/webhooks.php');
} }
} }