Feat: Recording Payment Transactions
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Services\TransactionRecorder;
|
||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
use Modules\Core\Payment\Events\PaymentVoided;
|
||||
|
||||
/**
|
||||
* Writes the Transaction row for a successful payment outcome — the
|
||||
* "record what happened" half of reacting to Payment's events, separate
|
||||
* from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update
|
||||
* the order's status" half. Both listen to the same events for the same
|
||||
* reason: two independent reactions to one payment outcome, neither
|
||||
* calling the other (see docs/payments.md).
|
||||
*
|
||||
* Only registered against the SUCCESS events (PaymentCaptured,
|
||||
* PaymentAuthorized, PaymentVoided, PaymentRefunded) — a Failed event
|
||||
* never reaches here, since a failed attempt moved no money and settled
|
||||
* nothing worth auditing as a Transaction row (see OrderServiceProvider's
|
||||
* registration and docs/payments.md's "Explicitly out of scope" section
|
||||
* on why no Failed-side Order reaction exists at all).
|
||||
*
|
||||
* Same defensive $context['order_id'] ?? null early-return as
|
||||
* ApplyResolvedPaymentStatus — $context is caller-supplied and optional,
|
||||
* and this listener must not crash for a future non-Checkout caller of
|
||||
* pay()/authorize() with no order_id in its context.
|
||||
*/
|
||||
class RecordPaymentTransaction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TransactionRecorder $transactions,
|
||||
) {}
|
||||
|
||||
public function handle(PaymentCaptured|PaymentAuthorized|PaymentVoided|PaymentRefunded $event): void
|
||||
{
|
||||
$orderId = $event->context['order_id'] ?? null;
|
||||
|
||||
if ($orderId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = Order::findOrFail($orderId);
|
||||
|
||||
$type = match ($event::class) {
|
||||
PaymentAuthorized::class => 'intent',
|
||||
PaymentCaptured::class => 'capture',
|
||||
PaymentRefunded::class => 'refund',
|
||||
PaymentVoided::class => 'void',
|
||||
};
|
||||
|
||||
$this->transactions->record($order, $type, $event->type, $event->result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
|
||||
/**
|
||||
* Writes the Transaction row a Payment operation's PaymentResult becomes —
|
||||
* the one place that translates Payment's gateway-agnostic result into
|
||||
* Lunar's own transactions table, in the same shape lunarphp/stripe's own
|
||||
* StoreCharges already writes (type, success, amount, reference, driver).
|
||||
* Lives in Order, not Payment — Transaction.order_id is required, and
|
||||
* Payment never writes to another module's models (see docs/payments.md);
|
||||
* this is the "read the event, do the write" half of that boundary, same
|
||||
* shape as Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus.
|
||||
*
|
||||
* Kept as its own class (not inlined into the listener that calls it) so a
|
||||
* future admin action (a manually-triggered capture/refund from Filament)
|
||||
* can write a row the same way, without going through an event at all.
|
||||
*/
|
||||
class TransactionRecorder
|
||||
{
|
||||
/**
|
||||
* $type is Lunar's own transaction type string — 'intent' (an
|
||||
* authorize()-produced hold), 'capture' (settled funds, whether via
|
||||
* pay() directly or capture() settling a prior intent), 'refund',
|
||||
* 'void' is NOT one of Lunar's three built-in types (Order::
|
||||
* paymentStatus() only ever reads 'intent'/'capture'/'refund' — see
|
||||
* Modules\Core\Order\Support\OrderStatus::payment()) — a void never
|
||||
* moved money, so it's still recorded for audit but $success reflects
|
||||
* whether the RELEASE succeeded, not a captured amount.
|
||||
*
|
||||
* $driver is the payment type key (e.g. 'stripe', 'cash-on-delivery'),
|
||||
* not a class name — matches the $type PaymentCaptured/etc. events
|
||||
* themselves carry, and what Transaction.driver already means
|
||||
* elsewhere in this codebase (see the old, now-removed
|
||||
* TransactionRecorder this replaces).
|
||||
*/
|
||||
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
|
||||
{
|
||||
return $order->transactions()->create([
|
||||
'success' => $result->status === PaymentResultStatus::Succeeded,
|
||||
'type' => $type,
|
||||
'driver' => $driver,
|
||||
'amount' => $result->amount->value,
|
||||
'reference' => $result->reference,
|
||||
'status' => $result->status->name,
|
||||
'notes' => $result->failureReason,
|
||||
'meta' => $result->meta,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Payment\DTOs\CaptureResult;
|
||||
use Modules\Core\Payment\DTOs\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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Lunar\Models\Transaction;
|
||||
use Modules\Core\Notification\NotificationRegistry;
|
||||
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
|
||||
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
|
||||
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
|
||||
use Modules\Core\Order\Notifications\OrderCapturedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
|
||||
use Modules\Core\Order\Notifications\OrderRefundedNotification;
|
||||
@@ -18,6 +19,8 @@ use Modules\Core\Order\Observers\TransactionObserver;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
use Modules\Core\Payment\Events\PaymentVoided;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
class OrderServiceProvider extends ServiceProvider
|
||||
@@ -33,6 +36,10 @@ class OrderServiceProvider extends ServiceProvider
|
||||
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
|
||||
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
|
||||
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
|
||||
|
||||
NotificationRegistry::get()->register([
|
||||
OrderDeliveredNotification::class,
|
||||
|
||||
Reference in New Issue
Block a user