Feature: Abstraction on Payments based on their operations
This commit is contained in:
@@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Core\Checkout\Contracts;
|
|
||||||
|
|
||||||
use Lunar\Exceptions\FingerprintMismatchException;
|
|
||||||
use Lunar\Exceptions\Carts\CartException;
|
|
||||||
use Lunar\Models\Cart;
|
|
||||||
use Lunar\Models\Order;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A boboko-owned payment driver — wraps a payment gateway's own confirmation
|
|
||||||
* mechanics (Stripe's synchronous authorize() call, a redirect-based
|
|
||||||
* provider's async callback/webhook, anything else) behind one uniform
|
|
||||||
* moment: "payment is confirmed, place the order."
|
|
||||||
*
|
|
||||||
* confirm() is the only thing a driver is required to do: once it has,
|
|
||||||
* by whatever mechanism is native to that gateway, independently decided
|
|
||||||
* the payment succeeded, it calls Modules\Core\Checkout\Services\
|
|
||||||
* CheckoutService::placeOrder($fingerprint) itself — no driver ever calls
|
|
||||||
* Lunar\Models\Cart::createOrder() directly. This is what lets the
|
|
||||||
* storefront checkout sequence stay uniform regardless of which provider is
|
|
||||||
* active: set addresses, select shipping, hand off to whichever driver is
|
|
||||||
* configured, and the driver decides when (or whether) the order actually
|
|
||||||
* gets created. See docs/checkout.md / docs/payments.md.
|
|
||||||
*/
|
|
||||||
interface PaymentDriver
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* 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): Order;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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 = [],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The async counterpart to SupportsPay::pay()/SupportsAuthorization::
|
||||||
|
* authorize() — implemented only by a driver whose gateway can't resolve
|
||||||
|
* one of those synchronously (a redirect the shopper completes elsewhere,
|
||||||
|
* a webhook that arrives later). A driver whose pay()/authorize() always
|
||||||
|
* returns a terminal PaymentResult (Succeeded/Failed) in the same call
|
||||||
|
* never implements this — there is nothing left to call back.
|
||||||
|
*
|
||||||
|
* Resolves into the SAME events the original pay()/authorize() call would
|
||||||
|
* have produced had it resolved synchronously — PaymentCaptured/
|
||||||
|
* PaymentCaptureFailed for a pending pay(), PaymentAuthorized/
|
||||||
|
* PaymentAuthorizationFailed for a pending authorize(). Which pair
|
||||||
|
* applies is up to the driver to track (e.g. against whatever it stored
|
||||||
|
* when the original call returned Pending), not something this method's
|
||||||
|
* signature can express generically.
|
||||||
|
*/
|
||||||
|
interface HandlesPaymentCallback
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* $reference is the gateway's own identifier for the pending attempt
|
||||||
|
* (the same value the original pay()/authorize() call returned via
|
||||||
|
* PaymentResult::$reference) — how the driver finds which attempt
|
||||||
|
* this callback belongs to.
|
||||||
|
*
|
||||||
|
* $data carries whatever the callback/webhook payload contains
|
||||||
|
* (Stripe: ['payment_intent' => $id], a redirect-based provider: its
|
||||||
|
* query params or POST body) — passed explicitly by the caller rather
|
||||||
|
* than the driver reaching into the global request(), so this works
|
||||||
|
* the same whether it's called from a synchronous HTTP request or an
|
||||||
|
* async webhook job with no active request at all.
|
||||||
|
*
|
||||||
|
* $context is opaque to the driver, carried through untouched into
|
||||||
|
* whichever Payment event this callback produces — see SupportsPay::
|
||||||
|
* pay()'s own $context param for the full reasoning. A driver that
|
||||||
|
* needs the ORIGINAL context from the pay()/authorize() call (a
|
||||||
|
* webhook's own payload carries none of its own) must have persisted
|
||||||
|
* it itself when that call returned Pending — Payment provides no
|
||||||
|
* storage for this.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function handleCallback(string $reference, array $data, array $context = []): PaymentResult;
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?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,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A driver's hold-only operation (Mastercard's "Authorize", Nexi's
|
||||||
|
* ActionType::PREAUTH(), Stripe's capture_method=manual) — places a hold
|
||||||
|
* on the customer's payment method without moving any funds. Only a
|
||||||
|
* driver that also implements SupportsCaptures/SupportsVoids can do
|
||||||
|
* anything with the resulting hold afterward; implementing this alone
|
||||||
|
* with neither of those would leave the hold to simply expire
|
||||||
|
* (typically ~7 days, gateway-dependent) with no way to settle or release
|
||||||
|
* it early.
|
||||||
|
*
|
||||||
|
* A driver capable of both authorize-then-settle AND an atomic charge
|
||||||
|
* (most card gateways) implements this alongside SupportsPay — which one
|
||||||
|
* gets called for a given payment attempt is the CALLER's choice (a
|
||||||
|
* policy decision), not something this driver decides for itself.
|
||||||
|
*
|
||||||
|
* Dispatches Modules\Core\Payment\Events\PaymentAuthorized or
|
||||||
|
* PaymentAuthorizationFailed based on the returned PaymentResult's status,
|
||||||
|
* unless $result->status is Pending — see SupportsPay's docblock for the
|
||||||
|
* same async-resolution note.
|
||||||
|
*/
|
||||||
|
interface SupportsAuthorization
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Same $type/$data/$context reasoning as SupportsPay::pay().
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function authorize(string $type, array $data, array $context = []): PaymentResult;
|
||||||
|
}
|
||||||
@@ -2,22 +2,33 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Contracts;
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
use Lunar\Models\Order;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
use Modules\Core\Payment\DataTransferObjects\CaptureResult;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional capability for payment drivers whose gateway supports a
|
* Settles a PRIOR SupportsAuthorization::authorize() hold — only ever
|
||||||
* separate authorize-then-capture step. Many redirect/wallet-style
|
* valid against a reference that call (or a HandlesPaymentCallback
|
||||||
* gateways (Viva Wallet included, for most flows) charge in full at
|
* resolving it) produced, never called standalone. A driver with no
|
||||||
* checkout and never need this — SupportsRefunds is the one they're more
|
* authorize-then-settle model at all (most redirect/wallet gateways, any
|
||||||
* likely to implement instead.
|
* offline driver) never implements this — it settles everything through
|
||||||
|
* SupportsPay::pay() in one step instead.
|
||||||
|
*
|
||||||
|
* Dispatches Modules\Core\Payment\Events\PaymentCaptured or
|
||||||
|
* PaymentCaptureFailed — the same terminal events SupportsPay::pay()
|
||||||
|
* produces, since "money has been captured" is the same business fact
|
||||||
|
* regardless of which path reached it.
|
||||||
*/
|
*/
|
||||||
interface SupportsCaptures
|
interface SupportsCaptures
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* $reference is the gateway's own identifier for the authorized charge
|
* $reference is the identifier SupportsAuthorization::authorize()
|
||||||
* — see SupportsRefunds::refund() for why this isn't a Lunar
|
* returned (PaymentResult::$reference) for the hold being settled.
|
||||||
* Transaction. $amount is in the currency's minor unit.
|
*
|
||||||
|
* $amount lets a driver capture less than the full authorized amount
|
||||||
|
* (e.g. shipping less than ordered) — up to the driver/gateway
|
||||||
|
* whether a partial capture also releases the remainder or leaves it
|
||||||
|
* capturable again later (multicapture-style gateways).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public function capture(Order $order, string $reference, int $amount, ?string $notes = null): CaptureResult;
|
public function capture(string $reference, int $amount, array $context = []): PaymentResult;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A driver's atomic charge — authorize and capture in one gateway call
|
||||||
|
* (Mastercard's own "Pay" operation, Nexi's ActionType::PAY(), Stripe's
|
||||||
|
* capture_method=automatic, or an offline driver with no gateway at all).
|
||||||
|
* Distinct from SupportsAuthorization: a driver that only ever settles in
|
||||||
|
* one step implements this and nothing else — there is no separate hold
|
||||||
|
* to later capture() or void().
|
||||||
|
*
|
||||||
|
* Dispatches Modules\Core\Payment\Events\PaymentCaptured or
|
||||||
|
* PaymentCaptureFailed based on the returned PaymentResult's status,
|
||||||
|
* unless $result->status is Pending (an async gateway that hasn't
|
||||||
|
* resolved yet — see HandlesPaymentCallback for how that gets resolved
|
||||||
|
* later, from a separate call this method's return value does not wait
|
||||||
|
* on).
|
||||||
|
*/
|
||||||
|
interface SupportsPay
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* $type is the payment type key being charged (e.g. 'cash-on-delivery',
|
||||||
|
* '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 (amount, currency, customer
|
||||||
|
* details, a payment method token) — 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 — carried through untouched into
|
||||||
|
* whichever Payment event this call (or a later handleCallback()
|
||||||
|
* resolving it) produces, so the caller can correlate the result back
|
||||||
|
* to whatever it needs, without Payment ever needing to know what
|
||||||
|
* that is.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function pay(string $type, array $data, array $context = []): PaymentResult;
|
||||||
|
}
|
||||||
@@ -2,23 +2,30 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Payment\Contracts;
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
use Lunar\Models\Order;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
use Modules\Core\Payment\DataTransferObjects\RefundResult;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional capability for payment drivers whose gateway supports refunding
|
* Reverses settled funds — independent of SupportsCaptures/SupportsVoids:
|
||||||
* a prior charge. Drivers without a refund API (or that never got that far
|
* a driver that only ever settles via SupportsPay::pay() (no separate
|
||||||
* — e.g. an offline/manual driver) simply don't implement it. Mirrors
|
* authorize step) can still implement this, since a refund targets money
|
||||||
* Shipping\Contracts\SupportsTracking's opt-in shape.
|
* already taken regardless of how it got taken. A driver implements this
|
||||||
|
* whenever its gateway exposes any refund capability at all, whether or
|
||||||
|
* not it also supports authorize-then-capture.
|
||||||
|
*
|
||||||
|
* Dispatches Modules\Core\Payment\Events\PaymentRefunded or
|
||||||
|
* PaymentRefundFailed.
|
||||||
*/
|
*/
|
||||||
interface SupportsRefunds
|
interface SupportsRefunds
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* $reference is the gateway's own identifier for the charge being
|
* $reference is the identifier the original SupportsPay::pay() or
|
||||||
* refunded (e.g. a Viva Wallet transaction id) — not a Lunar
|
* SupportsCaptures::capture() call returned for the settled funds
|
||||||
* Transaction model, since not every gateway's refund flow maps
|
* being refunded.
|
||||||
* cleanly onto one. $amount is in the currency's minor unit, same
|
*
|
||||||
* convention as Lunar\Base\Casts\Price.
|
* $amount allows a partial refund; a gateway may allow multiple
|
||||||
|
* partial refunds against one settlement, up to its own total.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public function refund(Order $order, string $reference, int $amount, ?string $notes = null): RefundResult;
|
public function refund(string $reference, int $amount, array $context = []): PaymentResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Contracts;
|
||||||
|
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels a PRIOR SupportsAuthorization::authorize() hold WITHOUT
|
||||||
|
* settling it — the "actually, never mind" exit SupportsCaptures::capture()
|
||||||
|
* doesn't take. No funds ever moved, so this is not a refund: there is
|
||||||
|
* nothing to give back, only a hold to release early (rather than letting
|
||||||
|
* it simply expire on its own).
|
||||||
|
*
|
||||||
|
* Dispatches Modules\Core\Payment\Events\PaymentVoided or
|
||||||
|
* PaymentVoidFailed.
|
||||||
|
*/
|
||||||
|
interface SupportsVoids
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* $reference is the identifier SupportsAuthorization::authorize()
|
||||||
|
* returned for the hold being released.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function void(string $reference, array $context = []): PaymentResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\DTOs;
|
||||||
|
|
||||||
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one shape every Payment operation (pay, authorize, capture, void,
|
||||||
|
* refund, handleCallback) returns, regardless of driver — a caller never
|
||||||
|
* writes gateway-specific branching to read the outcome.
|
||||||
|
*
|
||||||
|
* Deliberately not one-size-fits-all in richness underneath: a gateway's
|
||||||
|
* own response can be as sparse as Nexi's capture (just an operation id +
|
||||||
|
* timestamp, no echoed amount or status) or as rich as Stripe's
|
||||||
|
* PaymentIntent (status, amounts, decline classification, full error
|
||||||
|
* detail). $status/$reference/$amount are the only fields every driver can
|
||||||
|
* always populate — $amount from what WE requested, not necessarily
|
||||||
|
* echoed by the gateway. Everything else is best-effort normalization;
|
||||||
|
* $raw is the unconditional escape hatch for genuine audit fidelity
|
||||||
|
* (the untouched gateway response), so nothing is ever lost even when a
|
||||||
|
* gateway has no field to normalize into $failureReason/$retriable.
|
||||||
|
*/
|
||||||
|
final class PaymentResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param $failureReason a human-readable reason, only meaningful
|
||||||
|
* when $status is Failed — the driver's own normalization of
|
||||||
|
* whatever the gateway called it (Stripe's decline_code message,
|
||||||
|
* Nexi's ErrorsInner::$description, ...).
|
||||||
|
* @param $retriable whether the caller should offer "try again" with
|
||||||
|
* the SAME payment method, vs. "use a different one" — real,
|
||||||
|
* gateway-native distinction on Stripe (decline_code soft/hard) and
|
||||||
|
* Mastercard (merchantAdviceCode / scheme soft-decline codes), but
|
||||||
|
* Nexi's OperationResult has no such signal at all. Defaults to
|
||||||
|
* false (assume not safely retriable) rather than guessing when a
|
||||||
|
* driver's gateway has no such classification.
|
||||||
|
* @param $raw the untouched gateway response body — always
|
||||||
|
* populated, even when the gateway's own fields were too sparse to
|
||||||
|
* normalize into anything above.
|
||||||
|
* @param $meta driver-specific extras that don't fit the normalized
|
||||||
|
* fields above (e.g. a card's last four digits).
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly PaymentResultStatus $status,
|
||||||
|
public readonly string $reference,
|
||||||
|
public readonly int $amount,
|
||||||
|
public readonly ?string $failureReason = null,
|
||||||
|
public readonly bool $retriable = false,
|
||||||
|
public readonly array $raw = [],
|
||||||
|
public readonly array $meta = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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 = [],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?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 = [],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?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 = [],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The normalized outcome of a single gateway operation (pay, authorize,
|
||||||
|
* capture, void, refund) — never the gateway's own raw status string
|
||||||
|
* (Stripe's "succeeded", Nexi's "EXECUTED", Mastercard's own codes), so a
|
||||||
|
* caller never needs gateway-specific knowledge to know what happened.
|
||||||
|
*/
|
||||||
|
enum PaymentResultStatus
|
||||||
|
{
|
||||||
|
case Succeeded;
|
||||||
|
case Failed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gateway hasn't resolved this operation yet and won't in the same
|
||||||
|
* call — e.g. Stripe's requires_action, a redirect the shopper hasn't
|
||||||
|
* completed. A driver returning this from initiate()/handleCallback()
|
||||||
|
* has not yet dispatched a terminal event; something else (a later
|
||||||
|
* callback) is expected to resolve it.
|
||||||
|
*/
|
||||||
|
case Pending;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched when SupportsAuthorization::authorize() (or a
|
||||||
|
* HandlesPaymentCallback::handleCallback() resolving it later) determines
|
||||||
|
* the gateway did not grant the requested hold. $result->retriable is how
|
||||||
|
* a listener knows whether "try again with the same method" is reasonable
|
||||||
|
* — see PaymentResult's own docblock.
|
||||||
|
*/
|
||||||
|
class PaymentAuthorizationFailed
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly PaymentResult $result,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by a driver's SupportsAuthorization::authorize() (or, for an
|
||||||
|
* async gateway, HandlesPaymentCallback::handleCallback() resolving that
|
||||||
|
* same authorize() call later) once a hold has been placed — no funds have
|
||||||
|
* moved yet, see SupportsCaptures/SupportsVoids for what happens next.
|
||||||
|
*
|
||||||
|
* Carries $result (the full PaymentResult, not just a reference) plus
|
||||||
|
* $type and $context — same reasoning throughout Payment's events: Payment
|
||||||
|
* has no concept of a cart, an order, or a checkout fingerprint, so
|
||||||
|
* whatever a listener needs to react travels through $context untouched,
|
||||||
|
* opaque to Payment itself.
|
||||||
|
*/
|
||||||
|
class PaymentAuthorized
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly PaymentResult $result,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched when SupportsPay::pay() or SupportsCaptures::capture() (or a
|
||||||
|
* HandlesPaymentCallback::handleCallback() resolving either later) fails
|
||||||
|
* to take the money — whether that's a sale-mode gateway declining the
|
||||||
|
* charge outright, or a capture call against an existing authorization
|
||||||
|
* being rejected. Same terminal-event symmetry as PaymentCaptured: this is
|
||||||
|
* the one "capture attempt failed" event regardless of which path
|
||||||
|
* produced it.
|
||||||
|
*/
|
||||||
|
class PaymentCaptureFailed
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly PaymentResult $result,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one "money has actually been taken" event, dispatched from either of
|
||||||
|
* two different call paths that end at the same business fact:
|
||||||
|
* - SupportsPay::pay() — an atomic authorize+capture gateway call
|
||||||
|
* (Mastercard's "Pay", Stripe's capture_method=automatic, an offline
|
||||||
|
* driver's immediate success).
|
||||||
|
* - SupportsCaptures::capture() — settling a PRIOR authorize() hold.
|
||||||
|
* Whether the money moved in one gateway call or two is a driver-internal
|
||||||
|
* detail; a listener reacting to "a payment has been captured" never
|
||||||
|
* needs to know or care which path produced this event.
|
||||||
|
*/
|
||||||
|
class PaymentCaptured
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly PaymentResult $result,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched when SupportsRefunds::refund() fails to return settled funds
|
||||||
|
* — e.g. the gateway rejects refunding more than was originally captured.
|
||||||
|
*/
|
||||||
|
class PaymentRefundFailed
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly PaymentResult $result,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by SupportsRefunds::refund() once settled funds (from a prior
|
||||||
|
* pay() or capture()) have actually been returned — full or partial.
|
||||||
|
* Independent of whether the original settlement was a sale or an
|
||||||
|
* authorize-then-capture: a refund only ever targets money that was
|
||||||
|
* genuinely taken, regardless of how it got taken.
|
||||||
|
*/
|
||||||
|
class PaymentRefunded
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly PaymentResult $result,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?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 = [],
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched when SupportsVoids::void() fails to release a prior
|
||||||
|
* authorization — e.g. the hold already expired or was already captured,
|
||||||
|
* so there was nothing left to void.
|
||||||
|
*/
|
||||||
|
class PaymentVoidFailed
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly PaymentResult $result,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Payment\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by SupportsVoids::void() once a prior authorization hold has
|
||||||
|
* been released without ever settling — the "actually, never mind" exit
|
||||||
|
* from an authorize() that SupportsCaptures::capture() would otherwise
|
||||||
|
* have settled. No funds ever moved, so this is distinct from
|
||||||
|
* PaymentRefunded (which reverses money that was actually taken).
|
||||||
|
*/
|
||||||
|
class PaymentVoided
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $type,
|
||||||
|
public readonly PaymentResult $result,
|
||||||
|
public readonly array $context = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user