Feature: Abstraction on Payments based on their operations

This commit is contained in:
2026-09-03 16:01:33 +03:00
parent 0fa2188146
commit a987a2d57c
25 changed files with 497 additions and 381 deletions
@@ -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 = [],
) {}
}
+32
View File
@@ -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 = [],
) {}
}
+31
View File
@@ -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 = [],
) {}
}
+27
View File
@@ -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 = [],
) {}
}
-39
View File
@@ -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 = [],
) {}
}
+25
View File
@@ -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 = [],
) {}
}
+27
View File
@@ -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 = [],
) {}
}