Feature: Payment resolver, Payment Provider, Completing Stripe Webhooks, Wiring Payments to checkout service
This commit is contained in:
+8
-6
@@ -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,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user