Files
core/src/Order/Services/OrderPaymentResolutionService.php
T

109 lines
4.1 KiB
PHP

<?php
namespace Modules\Core\Order\Services;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* The actual business decisions behind reacting to a payment outcome —
* previously these lived entirely inside Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus, a listener with no Service behind it, even
* though "should this order be marked paid," "should its status advance,
* and to what," and "what does a refund do to status" are all genuine
* decisions about Order state, not side effects of Payment's own events.
* That listener is now a thin reactor: extract the order id from
* $event->context, load the Order, call this service, done.
*
* See ApplyResolvedPaymentStatus's own docblock for the full business
* reasoning (re-confirmed with the user) behind each rule enforced here —
* this class only re-documents what's specific to the decision logic
* itself, not the "why" already recorded there.
*/
class OrderPaymentResolutionService
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
/**
* A captured or authorized payment: marks the order paid (capture
* only — an authorization is not yet captured funds), advances status
* out of 'awaiting_payment' (capture only), and marks the order
* placed if this is the first payment outcome it's seen.
*/
public function resolveCaptureOrAuthorization(Order $order, string $causeClass, bool $isCapture): void
{
$wasPlaced = ! blank($order->placed_at);
$this->writer->markPaid($order, $causeClass);
if ($isCapture) {
$this->advancePastAwaitingPayment($order, $causeClass);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
/**
* A deferred-capture payment (currently only cash-on-delivery — see
* Payment\Events\PaymentDeferred's own docblock): no money has moved,
* so unlike resolveCaptureOrAuthorization() this never calls
* $writer->markPaid() — Order::paid stays false until staff explicitly
* mark it received. But per OrderStatusFlow's own docblock, payment
* method never affects the status SEQUENCE at all — a COD order has
* nothing to "await" at checkout (no payment attempt happens), so
* 'awaiting_payment' is simply the wrong first status for it. Reuses
* the exact same advancePastAwaitingPayment() a capture uses, since
* the status-sequence logic itself doesn't differ by payment method,
* only whether `paid` also flips alongside it.
*/
public function resolveDeferredPayment(Order $order, string $causeClass): void
{
$this->advancePastAwaitingPayment($order, $causeClass);
}
/**
* Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see
* OrderServiceProvider's listener registration order for
* PaymentRefunded), so the relation is refreshed here rather than
* trusted from a possibly-stale $order instance.
*/
public function resolveRefund(Order $order, string $causeClass): void
{
$order->load('transactions');
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $causeClass);
}
}
private function advancePastAwaitingPayment(Order $order, string $causeClass): void
{
if ($order->status !== 'awaiting_payment') {
return;
}
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
if ($target !== null) {
$this->writer->write($order, $target, $causeClass);
}
}
}