Feat: Updates to Payments, Checkout Services, Payment Events
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@ 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\Checkout\Contracts\PaymentDriver class
|
| it's the Modules\Core\Payment\Contracts\PaymentDriver class
|
||||||
| CheckoutService::confirmPayment() resolves via the container and calls
|
| CheckoutService::confirmPayment() resolves via the container and calls
|
||||||
| confirm() on. Kept on the same row as 'driver' rather than a second,
|
| confirm() on. Kept on the same row as 'driver' rather than a second,
|
||||||
| separately-keyed map, so a type's full definition — Lunar's driver,
|
| separately-keyed map, so a type's full definition — Lunar's driver,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Checkout\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by a PaymentDriver once it has independently decided (by
|
||||||
|
* whatever mechanism is native to its gateway) that payment succeeded —
|
||||||
|
* the event-driven counterpart to what used to be a direct
|
||||||
|
* CheckoutService::placeOrder() call from inside confirm(). Listened to by
|
||||||
|
* CheckoutService itself, which places the order and dispatches
|
||||||
|
* OrderPlaced.
|
||||||
|
*
|
||||||
|
* $type/$data are carried through for the same reason PaymentDriver::
|
||||||
|
* confirm() takes them — a driver-specific post-placement step (e.g.
|
||||||
|
* OfflinePaymentDriver's status mapping, StripePaymentDriver's
|
||||||
|
* UpdateOrderFromIntent) still needs them, but can no longer receive the
|
||||||
|
* placed Order as a return value. Each driver instead listens for
|
||||||
|
* OrderPlaced and checks $order->meta['payment_method'] against its own
|
||||||
|
* type(s) to recognize which OrderPlaced is its own — carrying $fingerprint
|
||||||
|
* here too lets a driver correlate its own OrderPlaced listener call back
|
||||||
|
* to the specific confirmation that triggered it, if it needs to.
|
||||||
|
*/
|
||||||
|
class PaymentConfirmed
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly Cart $cart,
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly string $fingerprint,
|
||||||
|
public readonly array $data = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -12,15 +12,16 @@ use Lunar\Facades\ShippingManifest;
|
|||||||
use Lunar\Models\Cart;
|
use Lunar\Models\Cart;
|
||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
use Modules\Core\Cart\Services\CartService;
|
use Modules\Core\Cart\Services\CartService;
|
||||||
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
|
||||||
use Modules\Core\Checkout\Events\BillingAddressSet;
|
use Modules\Core\Checkout\Events\BillingAddressSet;
|
||||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
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\Models\PaymentMethod;
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
|
use Modules\Core\Payment\Services\PaymentDriverResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storefront-facing checkout operations, mirroring
|
* Storefront-facing checkout operations, mirroring
|
||||||
@@ -41,6 +42,7 @@ class CheckoutService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CartService $cart,
|
private readonly CartService $cart,
|
||||||
|
private readonly PaymentDriverResolver $paymentDrivers,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function setShippingAddress(array|Addressable $address): Cart
|
public function setShippingAddress(array|Addressable $address): Cart
|
||||||
@@ -149,7 +151,7 @@ class CheckoutService
|
|||||||
{
|
{
|
||||||
return PaymentMethod::where('enabled', true)
|
return PaymentMethod::where('enabled', true)
|
||||||
->pluck('type')
|
->pluck('type')
|
||||||
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false)
|
->filter(fn (string $type) => $this->paymentDrivers->resolve($type)?->isConfigured() ?? false)
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@@ -198,11 +200,18 @@ class CheckoutService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves $type's registered PaymentDriver and calls confirm() —
|
* Resolves $type's registered PaymentDriver and calls confirm() — the
|
||||||
* the driver decides whether/when the order actually gets placed (see
|
* driver independently decides whether payment succeeded and, if so,
|
||||||
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data
|
* dispatches PaymentConfirmed (see PaymentDriver's docblock) rather
|
||||||
* carries whatever that driver needs (Stripe's payment_intent id, a
|
* than placing the order itself or returning it here. This method is
|
||||||
* future redirect-based provider's callback payload).
|
* fire-and-forget as far as the Order is concerned: a caller that
|
||||||
|
* needs it back listens for OrderPlaced, the same way a driver's own
|
||||||
|
* post-placement step does — see PaymentConfirmed's docblock for why a
|
||||||
|
* direct return value doesn't fit every gateway (async/webhook-driven
|
||||||
|
* confirmations have no synchronous caller waiting for one at all).
|
||||||
|
*
|
||||||
|
* $data carries whatever that driver needs (Stripe's payment_intent
|
||||||
|
* id, a future redirect-based provider's callback payload).
|
||||||
*
|
*
|
||||||
* The fingerprint passed to the driver is the one captured by
|
* The fingerprint passed to the driver is the one captured by
|
||||||
* selectPaymentMethod(), not supplied by the caller — see that
|
* selectPaymentMethod(), not supplied by the caller — see that
|
||||||
@@ -220,7 +229,7 @@ class CheckoutService
|
|||||||
* @throws \Lunar\Exceptions\FingerprintMismatchException
|
* @throws \Lunar\Exceptions\FingerprintMismatchException
|
||||||
* @throws \Lunar\Exceptions\Carts\CartException
|
* @throws \Lunar\Exceptions\Carts\CartException
|
||||||
*/
|
*/
|
||||||
public function confirmPayment(string $type, array $data = []): Order
|
public function confirmPayment(string $type, array $data = []): void
|
||||||
{
|
{
|
||||||
if (! in_array($type, $this->getPaymentMethods(), true)) {
|
if (! in_array($type, $this->getPaymentMethods(), true)) {
|
||||||
throw new UnknownPaymentTypeException($type);
|
throw new UnknownPaymentTypeException($type);
|
||||||
@@ -229,20 +238,6 @@ class CheckoutService
|
|||||||
$cart = $this->cart->currentOrCreate();
|
$cart = $this->cart->currentOrCreate();
|
||||||
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
|
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
|
||||||
|
|
||||||
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data);
|
$this->paymentDrivers->resolve($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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Modules\Core\Payment\DataTransferObjects\PaymentInitiation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The synchronous half of a payment driver — every driver implements this,
|
||||||
|
* since every provider has some notion of "start a payment," even if (like
|
||||||
|
* an offline/cash type) there's no real gateway round-trip involved.
|
||||||
|
*
|
||||||
|
* This is deliberately synchronous, unlike the rest of the payment
|
||||||
|
* lifecycle: a storefront request needing a redirect URL, or frontend JS
|
||||||
|
* needing a client secret to render an embedded payment form, has nothing
|
||||||
|
* to redirect to or render until initiate() returns — there is no event
|
||||||
|
* that can hand a mid-request controller a value it needs for its own HTTP
|
||||||
|
* response. Everything after this point (the payment actually completing,
|
||||||
|
* failing, a chargeback) is genuinely async and belongs on
|
||||||
|
* HandlesPaymentCallback / PaymentSucceeded / PaymentFailed instead.
|
||||||
|
*/
|
||||||
|
interface InitiatesPayment
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Whether this driver can actually be used right now — e.g. checking
|
||||||
|
* an API key is configured. Independent of
|
||||||
|
* Modules\Core\Payment\Models\PaymentMethod::enabled (the admin
|
||||||
|
* on/off toggle).
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* $type is the payment type key being initiated (e.g.
|
||||||
|
* 'cash-on-delivery', 'viva', 'stripe') — passed through even though
|
||||||
|
* most drivers only ever serve one type, because a driver shared
|
||||||
|
* across several types needs it to look up that type's own config.
|
||||||
|
*
|
||||||
|
* $data carries whatever the gateway needs to start this payment
|
||||||
|
* (amount, currency, return/webhook URLs, customer details) — the
|
||||||
|
* caller's responsibility to assemble, since a driver has no notion
|
||||||
|
* of a cart or order to pull them from itself.
|
||||||
|
*
|
||||||
|
* $context is opaque to the driver (see PaymentDriver — actually
|
||||||
|
* PaymentSucceeded's docblock — for the full reasoning): carried
|
||||||
|
* through untouched into whatever PaymentSucceeded/PaymentFailed this
|
||||||
|
* payment eventually produces, so the caller can correlate the result
|
||||||
|
* back to whatever it needs (a cart id and fingerprint, for
|
||||||
|
* Checkout), without this driver or Payment generally needing to know
|
||||||
|
* what that is.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function initiate(string $type, array $data, array $context = []): PaymentInitiation;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Lunar\Exceptions\FingerprintMismatchException;
|
||||||
|
use Lunar\Exceptions\Carts\CartException;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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."
|
||||||
|
*
|
||||||
|
* 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 dispatches Modules\Core\Checkout\Events\
|
||||||
|
* PaymentConfirmed — no driver ever calls CheckoutService::placeOrder() or
|
||||||
|
* Lunar\Models\Cart::createOrder() directly. CheckoutService itself listens
|
||||||
|
* for PaymentConfirmed and places the order from there; a driver that needs
|
||||||
|
* to do something to the placed Order afterward (status mapping, syncing
|
||||||
|
* gateway state) listens for the resulting OrderPlaced itself, matching it
|
||||||
|
* via $order->meta['payment_method'] — see PaymentConfirmed's docblock for
|
||||||
|
* why. This split is what makes an async/webhook-driven gateway (payment
|
||||||
|
* confirmed in a request that has no synchronous caller waiting for an
|
||||||
|
* Order at all) and a synchronous one (Stripe) work through the exact same
|
||||||
|
* contract. See docs/checkout.md / docs/payments.md.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* 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 FingerprintMismatchException
|
||||||
|
* @throws CartException
|
||||||
|
*/
|
||||||
|
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Payment\DataTransferObjects\CaptureResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional capability for payment drivers whose gateway supports a
|
||||||
|
* separate authorize-then-capture step. Many redirect/wallet-style
|
||||||
|
* gateways (Viva Wallet included, for most flows) charge in full at
|
||||||
|
* checkout and never need this — SupportsRefunds is the one they're more
|
||||||
|
* likely to implement instead.
|
||||||
|
*/
|
||||||
|
interface SupportsCaptures
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* $reference is the gateway's own identifier for the authorized charge
|
||||||
|
* — see SupportsRefunds::refund() for why this isn't a Lunar
|
||||||
|
* Transaction. $amount is in the currency's minor unit.
|
||||||
|
*/
|
||||||
|
public function capture(Order $order, string $reference, int $amount, ?string $notes = null): CaptureResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Payment\DataTransferObjects\RefundResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional capability for payment drivers whose gateway supports refunding
|
||||||
|
* a prior charge. Drivers without a refund API (or that never got that far
|
||||||
|
* — e.g. an offline/manual driver) simply don't implement it. Mirrors
|
||||||
|
* Shipping\Contracts\SupportsTracking's opt-in shape.
|
||||||
|
*/
|
||||||
|
interface SupportsRefunds
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* $reference is the gateway's own identifier for the charge being
|
||||||
|
* refunded (e.g. a Viva Wallet transaction id) — not a Lunar
|
||||||
|
* Transaction model, since not every gateway's refund flow maps
|
||||||
|
* cleanly onto one. $amount is in the currency's minor unit, same
|
||||||
|
* convention as Lunar\Base\Casts\Price.
|
||||||
|
*/
|
||||||
|
public function refund(Order $order, string $reference, int $amount, ?string $notes = null): RefundResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\DataTransferObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned by SupportsCaptures::capture() — see RefundResult for why this
|
||||||
|
* carries nothing Lunar-shaped.
|
||||||
|
*/
|
||||||
|
class CaptureResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $success,
|
||||||
|
public readonly int $amount,
|
||||||
|
public readonly ?string $reference = null,
|
||||||
|
public readonly ?string $message = null,
|
||||||
|
public readonly array $meta = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\DataTransferObjects;
|
||||||
|
|
||||||
|
use Modules\Core\Payment\Enums\PaymentInitiationMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned by InitiatesPayment::initiate() — the one thing a caller needs
|
||||||
|
* synchronously, in the same request, regardless of which provider is
|
||||||
|
* behind it. redirectUrl/clientSecret are mutually exclusive in practice
|
||||||
|
* (only the one matching $mode is ever set) but both nullable rather than
|
||||||
|
* split into per-mode subclasses — see PaymentInitiationMode for why.
|
||||||
|
*
|
||||||
|
* $reference is the gateway's own identifier for this payment attempt
|
||||||
|
* (an order/session/intent id) — the same value HandlesPaymentCallback's
|
||||||
|
* driver will later see again in the callback payload, and what
|
||||||
|
* PaymentSucceeded/PaymentFailed carry forward. A driver in Immediate
|
||||||
|
* mode still returns one, even though there's no callback to correlate
|
||||||
|
* against, since it's also what gets recorded as the Transaction's
|
||||||
|
* reference.
|
||||||
|
*/
|
||||||
|
class PaymentInitiation
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly PaymentInitiationMode $mode,
|
||||||
|
public readonly string $reference,
|
||||||
|
public readonly ?string $redirectUrl = null,
|
||||||
|
public readonly ?string $clientSecret = null,
|
||||||
|
public readonly array $meta = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\DataTransferObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned by SupportsRefunds::refund() — gateway-agnostic, carries nothing
|
||||||
|
* Lunar-shaped (no Transaction, no Lunar DTO). TransactionRecorder turns
|
||||||
|
* this into a Transaction row afterward; the driver itself never writes
|
||||||
|
* one.
|
||||||
|
*/
|
||||||
|
class RefundResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $success,
|
||||||
|
public readonly int $amount,
|
||||||
|
public readonly ?string $reference = null,
|
||||||
|
public readonly ?string $message = null,
|
||||||
|
public readonly array $meta = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -2,35 +2,28 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Drivers;
|
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\Cart;
|
||||||
use Lunar\Models\Order;
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
use Modules\Core\Checkout\Events\PaymentConfirmed;
|
||||||
use Modules\Core\Checkout\Services\CheckoutService;
|
use Modules\Core\Payment\Contracts\PaymentDriver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared by every payment type with no real gateway to confirm against —
|
* 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
|
* 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
|
* delivery, not at checkout. confirm() has nothing to wait on, so it
|
||||||
* the order immediately, same as Lunar's own OfflinePayment would, but
|
* dispatches PaymentConfirmed immediately, same moment Lunar's own
|
||||||
* through CheckoutService::placeOrder() so it goes through the same
|
* OfflinePayment would place the order — but the actual placement now
|
||||||
* fingerprint check every other driver does. $data is unused: nothing about
|
* happens in CheckoutService::onPaymentConfirmed(), not here. $data is
|
||||||
* this confirmation depends on gateway-specific payload.
|
* unused: nothing about this confirmation depends on gateway-specific
|
||||||
|
* payload.
|
||||||
*
|
*
|
||||||
* Sets the order status to config("lunar.payments.types.{$type}.authorized")
|
* The status-mapping step this driver used to do inline right after
|
||||||
* afterward, using the type actually confirmed — not a hardcoded key —
|
* placeOrder() returned now happens in onOrderPlaced() below instead —
|
||||||
* since this one driver is shared across multiple types.
|
* see PaymentDriver's docblock for why a driver can no longer rely on
|
||||||
* placeOrder() itself leaves the order at Lunar's configured draft_status,
|
* placeOrder()'s return value.
|
||||||
* same as every driver is responsible for moving it on from.
|
|
||||||
*/
|
*/
|
||||||
class OfflinePaymentDriver implements PaymentDriver
|
class OfflinePaymentDriver implements PaymentDriver
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly CheckoutService $checkout,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always true — no external dependency to be missing.
|
* Always true — no external dependency to be missing.
|
||||||
*/
|
*/
|
||||||
@@ -39,19 +32,30 @@ class OfflinePaymentDriver implements PaymentDriver
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): void
|
||||||
* @throws FingerprintMismatchException
|
|
||||||
* @throws CartException
|
|
||||||
* @throws DisallowMultipleCartOrdersException
|
|
||||||
*/
|
|
||||||
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
|
|
||||||
{
|
{
|
||||||
$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([
|
$order->update([
|
||||||
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
|
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $order->refresh();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,40 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Drivers;
|
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\Cart;
|
||||||
use Lunar\Models\Order;
|
|
||||||
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
|
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
|
||||||
use Lunar\Stripe\Facades\Stripe;
|
use Lunar\Stripe\Facades\Stripe;
|
||||||
use Lunar\Stripe\Models\StripePaymentIntent;
|
use Lunar\Stripe\Models\StripePaymentIntent;
|
||||||
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
use Modules\Core\Checkout\Services\CheckoutService;
|
use Modules\Core\Checkout\Events\PaymentConfirmed;
|
||||||
|
use Modules\Core\Payment\Contracts\PaymentDriver;
|
||||||
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
|
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
|
||||||
use Stripe\PaymentIntent;
|
use Stripe\PaymentIntent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
|
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
|
||||||
* Modules\Core\Checkout\Contracts\PaymentDriver — calls
|
* Modules\Core\Payment\Contracts\PaymentDriver — dispatches
|
||||||
* CheckoutService::placeOrder($fingerprint) at the moment Stripe confirms
|
* PaymentConfirmed at the moment Stripe confirms payment, instead of the
|
||||||
* payment, instead of the vendor's own Cart::createOrder() call.
|
* vendor's own Cart::createOrder() call.
|
||||||
*
|
*
|
||||||
* This is a fork, not a decoration: StripePaymentType::authorize() is
|
* This is a fork, not a decoration: StripePaymentType::authorize() is
|
||||||
* `final` and calls Cart::createOrder() directly with no seam to redirect
|
* `final` and calls Cart::createOrder() directly with no seam to redirect
|
||||||
* that one call — so this class reimplements authorize()'s logic (intent
|
* that one call — so this class reimplements authorize()'s logic (intent
|
||||||
* retrieval, capture-on-policy, status mapping via UpdateOrderFromIntent)
|
* retrieval, capture-on-policy) rather than wrapping the vendor method.
|
||||||
* rather than wrapping the vendor method. Kept deliberately close to the
|
* Kept deliberately close to the original so a lunarphp/stripe upgrade is
|
||||||
* original so a lunarphp/stripe upgrade is easy to diff against. See
|
* easy to diff against. See docs/payments.md.
|
||||||
* 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
|
class StripePaymentDriver implements PaymentDriver
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly CheckoutService $checkout,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same key lunarphp/stripe's own StripeManager reads its API key from
|
* Same key lunarphp/stripe's own StripeManager reads its API key from
|
||||||
* (Stripe::setApiKey(config('services.stripe.key')) in
|
* (Stripe::setApiKey(config('services.stripe.key')) in
|
||||||
@@ -47,13 +48,11 @@ class StripePaymentDriver implements PaymentDriver
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @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, or the gateway
|
||||||
* placed, or the gateway call itself fails) — nothing here should be
|
* call itself fails) — nothing here should be treated as "confirm
|
||||||
* treated as "place the order anyway."
|
* anyway."
|
||||||
* @throws FingerprintMismatchException
|
|
||||||
* @throws CartException
|
|
||||||
*/
|
*/
|
||||||
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'];
|
$paymentIntentId = $data['payment_intent'];
|
||||||
|
|
||||||
@@ -93,19 +92,34 @@ class StripePaymentDriver implements PaymentDriver
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$paymentIntentModel->status = $paymentIntent->status;
|
||||||
$order = $this->checkout->placeOrder($fingerprint);
|
$paymentIntentModel->save();
|
||||||
} catch (DisallowMultipleCartOrdersException|CartException $e) {
|
|
||||||
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e);
|
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->order_id = $order->id;
|
||||||
$paymentIntentModel->status = $paymentIntent->status;
|
|
||||||
$paymentIntentModel->processed_at = now();
|
$paymentIntentModel->processed_at = now();
|
||||||
$paymentIntentModel->save();
|
$paymentIntentModel->save();
|
||||||
|
|
||||||
UpdateOrderFromIntent::execute($order, $paymentIntent);
|
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($paymentIntentModel->intent_id);
|
||||||
|
|
||||||
return $order->refresh();
|
UpdateOrderFromIntent::execute($order, $paymentIntent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a caller of InitiatesPayment::initiate() needs to do right now with
|
||||||
|
* the PaymentInitiation it got back.
|
||||||
|
*/
|
||||||
|
enum PaymentInitiationMode: string
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send the shopper to redirectUrl (Viva, Klarna, EasyPay-style
|
||||||
|
* redirect flows) — they leave the site, pay, and return via a
|
||||||
|
* callback/webhook the driver handles separately.
|
||||||
|
*/
|
||||||
|
case Redirect = 'redirect';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand clientSecret to frontend JS, which completes payment in-page
|
||||||
|
* (Stripe Elements, Nexi hosted fields) — no redirect away from the
|
||||||
|
* site.
|
||||||
|
*/
|
||||||
|
case ClientSecret = 'client_secret';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing further to do — the driver has already dispatched
|
||||||
|
* PaymentSucceeded (or will throw) by the time initiate() returns.
|
||||||
|
* Offline/no-gateway types (cash-on-delivery) are always this mode:
|
||||||
|
* there's no gateway round-trip to wait on.
|
||||||
|
*/
|
||||||
|
case Immediate = 'immediate';
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by a PaymentDriver once it has independently decided (by
|
||||||
|
* whatever mechanism is native to its gateway) that a payment succeeded.
|
||||||
|
* Deliberately carries nothing but what a payment fundamentally is —
|
||||||
|
* $type, $reference, $amount — plus $context, an opaque bag the driver
|
||||||
|
* received from whoever called confirm() and hands back unchanged here.
|
||||||
|
*
|
||||||
|
* Payment has no concept of a cart, an order, or a checkout fingerprint —
|
||||||
|
* those are Checkout's concepts, and Checkout is only one possible
|
||||||
|
* consumer of a successful payment (a future Subscriptions module renewing
|
||||||
|
* on a recurring charge is another). $context is how a caller like
|
||||||
|
* CheckoutService::confirmPayment() smuggles what it needs to react
|
||||||
|
* (cart_id, fingerprint) through Payment without Payment ever reading or
|
||||||
|
* caring what's inside — each listener interprets $context on its own
|
||||||
|
* terms, or ignores the event entirely if the keys it needs aren't there.
|
||||||
|
*/
|
||||||
|
class PaymentSucceeded
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* $amount is in the currency's minor unit, same convention as
|
||||||
|
* Lunar\Base\Casts\Price.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly string $reference,
|
||||||
|
public readonly int $amount,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use RuntimeException;
|
|||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown by a Modules\Core\Checkout\Contracts\PaymentDriver when the
|
* Thrown by a Modules\Core\Payment\Contracts\PaymentDriver when the
|
||||||
* gateway has not confirmed payment — wrong/expired intent, already
|
* gateway has not confirmed payment — wrong/expired intent, already
|
||||||
* processed, or the gateway itself rejects the confirmation. A driver
|
* processed, or the gateway itself rejects the confirmation. A driver
|
||||||
* throws this instead of silently placing the order: CheckoutService::
|
* throws this instead of silently placing the order: CheckoutService::
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Listeners;
|
||||||
|
|
||||||
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
|
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status-mapping step OfflinePaymentDriver used to do inline right
|
||||||
|
* after CheckoutService::placeOrder() returned — moved out to a listener
|
||||||
|
* since confirm() can no longer rely on that return value (see
|
||||||
|
* PaymentDriver's docblock).
|
||||||
|
*
|
||||||
|
* Every offline-style type shares OfflinePaymentDriver, 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 listener's to act on — a Stripe order placed via
|
||||||
|
* StripePaymentDriver fires the same event.
|
||||||
|
*/
|
||||||
|
class ApplyOfflinePaymentStatus
|
||||||
|
{
|
||||||
|
public function handle(OrderPlaced $event): void
|
||||||
|
{
|
||||||
|
$order = $event->order;
|
||||||
|
$type = $order->meta['payment_method'] ?? null;
|
||||||
|
|
||||||
|
if (! $type || config("lunar.payments.types.{$type}.payment_driver") !== OfflinePaymentDriver::class) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->update([
|
||||||
|
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Services;
|
||||||
|
|
||||||
|
use Modules\Core\Payment\Contracts\PaymentDriver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a payment type key (e.g. 'stripe', 'cash-on-delivery') to its
|
||||||
|
* registered PaymentDriver — extracted out of CheckoutService so both it
|
||||||
|
* and anything else needing the same lookup (e.g. a listener reacting to
|
||||||
|
* OrderPlaced, which has no reason to depend on Checkout's own service)
|
||||||
|
* share one implementation instead of duplicating this config read.
|
||||||
|
*/
|
||||||
|
class PaymentDriverResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Null if $type has no 'payment_driver' registered in
|
||||||
|
* config('lunar.payments.types.<type>') at all — deliberately
|
||||||
|
* non-throwing so a caller like CheckoutService::getPaymentMethods()
|
||||||
|
* can filter unresolvable types silently rather than treating "not
|
||||||
|
* registered" as an error condition when just checking availability.
|
||||||
|
*/
|
||||||
|
public function resolve(string $type): ?PaymentDriver
|
||||||
|
{
|
||||||
|
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
|
||||||
|
|
||||||
|
return $driverClass ? app($driverClass) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Services;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Lunar\Models\Transaction;
|
||||||
|
use Modules\Core\Payment\DataTransferObjects\CaptureResult;
|
||||||
|
use Modules\Core\Payment\DataTransferObjects\RefundResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the Transaction row a SupportsRefunds/SupportsCaptures driver's
|
||||||
|
* result becomes — the one place that translates a gateway-agnostic
|
||||||
|
* RefundResult/CaptureResult into Lunar's own transactions table, in the
|
||||||
|
* same shape lunarphp/stripe's StoreCharges already writes (type, success,
|
||||||
|
* amount, reference, driver, notes). Kept here rather than inside each
|
||||||
|
* driver so every driver's rows land in a consistent shape that
|
||||||
|
* Order::paymentStatus() and TransactionObserver both already understand,
|
||||||
|
* without any driver needing to know about either.
|
||||||
|
*/
|
||||||
|
class TransactionRecorder
|
||||||
|
{
|
||||||
|
public function recordRefund(Order $order, string $driver, RefundResult $result, ?string $notes = null): Transaction
|
||||||
|
{
|
||||||
|
return $order->transactions()->create([
|
||||||
|
'success' => $result->success,
|
||||||
|
'type' => 'refund',
|
||||||
|
'driver' => $driver,
|
||||||
|
'amount' => $result->amount,
|
||||||
|
'reference' => $result->reference,
|
||||||
|
'status' => $result->success ? 'succeeded' : 'failed',
|
||||||
|
'notes' => $notes ?? $result->message,
|
||||||
|
'meta' => $result->meta,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordCapture(Order $order, string $driver, CaptureResult $result, ?string $notes = null): Transaction
|
||||||
|
{
|
||||||
|
return $order->transactions()->create([
|
||||||
|
'success' => $result->success,
|
||||||
|
'type' => 'capture',
|
||||||
|
'driver' => $driver,
|
||||||
|
'amount' => $result->amount,
|
||||||
|
'reference' => $result->reference,
|
||||||
|
'status' => $result->success ? 'succeeded' : 'failed',
|
||||||
|
'notes' => $notes ?? $result->message,
|
||||||
|
'meta' => $result->meta,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user