Feat: Updates to Payments, Checkout Services, Payment Events

This commit is contained in:
2026-09-02 16:14:52 +03:00
parent 3497553b41
commit 8cb54e065e
18 changed files with 553 additions and 84 deletions
@@ -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;
}
+65
View File
@@ -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;
}
+24
View File
@@ -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;
}