diff --git a/src/Order/Services/OrderFulfillmentService.php b/src/Order/Services/OrderFulfillmentService.php index 70eec8d..20768af 100644 --- a/src/Order/Services/OrderFulfillmentService.php +++ b/src/Order/Services/OrderFulfillmentService.php @@ -34,6 +34,7 @@ class OrderFulfillmentService private readonly OrderStatusWriter $writer, private readonly OrderStatusFlow $flow, private readonly TransactionRecorder $transactions, + private readonly OrderPaymentResolutionService $resolution, ) {} public function markReady(Order $order): OrderFulfillmentResult @@ -114,9 +115,13 @@ class OrderFulfillmentService } /** - * Independent of `status` entirely — offered by the single "Update - * Status" action regardless of current status (see - * OrderStatusFlow::canMarkPaid()). + * For a COD order, independent of `status` entirely — offered by the + * single "Update Status" action regardless of current status (see + * OrderStatusFlow::canMarkPaid()). For a bank transfer order, status + * genuinely does advance here too (see below) — unlike COD, a bank + * transfer order has been sitting at 'awaiting_payment' since checkout + * (BankTransferPaymentDriver::pay() deliberately never advances it), + * and this click is the only thing that ever will. */ public function markPaid(Order $order): OrderFulfillmentResult { @@ -125,18 +130,20 @@ class OrderFulfillmentService } // canMarkPaid() only ever returns true for an order whose payment - // method resolves to the cash-on-delivery DRIVER (see - // OrderStatusFlow::isCod(), which checks PaymentMethod::driver, - // never the merchant-chosen `type` slug directly — a store could - // name that method "cod", "pay-on-delivery", anything). Such an - // order never runs through Payment's pay()/authorize() flow at - // checkout, so nothing else records a Transaction for it. Money - // changes hands right here, at this click, so this is the one - // place that write can happen; there is no earlier Payment event - // to hang it off of the way Modules\Core\Order\Listeners\ - // RecordPaymentTransaction does for a gateway driver. See - // TransactionRecorder's own docblock — it already anticipated - // exactly this "manually-triggered ... from Filament" call site. + // method resolves to the cash-on-delivery or bank-transfer DRIVER + // (see OrderStatusFlow::isCod()/isBankTransfer(), which check + // PaymentMethod::driver, never the merchant-chosen `type` slug + // directly — a store could name that method "cod", "pay-on-delivery", + // "wire", anything). Neither ever runs a Transaction-recording event + // through to completion at checkout (COD dispatches nothing capture- + // shaped at all; bank transfer's pay() returns Pending with no event + // dispatched — see that driver's own docblock). Money changes hands + // right here, at this click, so this is the one place that write can + // happen; there is no earlier Payment event to hang it off of the way + // Modules\Core\Order\Listeners\RecordPaymentTransaction does for a + // gateway driver. See TransactionRecorder's own docblock — it already + // anticipated exactly this "manually-triggered ... from Filament" + // call site. // // $driver below is the payment method's own `type` slug (whatever // the merchant named it, e.g. 'cash-on-delivery' or 'cod') — @@ -146,19 +153,25 @@ class OrderFulfillmentService // No fallback guess here: CheckoutService::initiatePayment() always // writes Order.meta['payment_method'] before charging, and // canMarkPaid() already guarantees this order got that far. + $type = (string) $order->meta['payment_method']; + $this->transactions->record( $order, type: 'capture', - driver: (string) $order->meta['payment_method'], + driver: $type, result: new PaymentResult( status: PaymentResultStatus::Succeeded, - reference: 'cod-manual-'.$order->id, + reference: "manual-{$type}-{$order->id}", amount: $order->total, ), ); $this->writer->markPaid($order, self::class.'::markPaid'); + if ($this->flow->isBankTransfer($order)) { + $this->resolution->advancePastAwaitingPayment($order, self::class.'::markPaid'); + } + return OrderFulfillmentResult::success('Order marked as paid.'); } diff --git a/src/Order/Services/OrderPaymentResolutionService.php b/src/Order/Services/OrderPaymentResolutionService.php index d990382..8431d8b 100644 --- a/src/Order/Services/OrderPaymentResolutionService.php +++ b/src/Order/Services/OrderPaymentResolutionService.php @@ -92,7 +92,14 @@ class OrderPaymentResolutionService } } - private function advancePastAwaitingPayment(Order $order, string $causeClass): void + /** + * Also called directly by OrderFulfillmentService::markPaid() for a + * bank transfer order — unlike a COD markPaid() (which never touches + * status, since nothing was ever awaited), a bank transfer order + * genuinely sat at 'awaiting_payment' until this moment, and nothing + * else will ever advance it if this doesn't. + */ + public function advancePastAwaitingPayment(Order $order, string $causeClass): void { if ($order->status !== 'awaiting_payment') { return; diff --git a/src/Order/Services/OrderStatusFlow.php b/src/Order/Services/OrderStatusFlow.php index 763289c..f105e2d 100644 --- a/src/Order/Services/OrderStatusFlow.php +++ b/src/Order/Services/OrderStatusFlow.php @@ -54,6 +54,24 @@ class OrderStatusFlow return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery'; } + /** + * Same meta-first/Transaction-fallback resolution as isCod(). Unlike COD + * — where nothing is ever awaited, since payment happens on delivery — + * a bank transfer order genuinely sits at 'awaiting_payment' until staff + * confirm the wire arrived (see BankTransferPaymentDriver's own + * docblock and OrderFulfillmentService::markPaid()). + */ + public function isBankTransfer(Order $order): bool + { + $type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver'); + + if ($type === null) { + return false; + } + + return PaymentMethod::where('type', $type)->value('driver') === 'bank-transfer'; + } + /** * @return array value => label — every status in the * order's own branch (carrier or pickup), plus the refund options, @@ -124,13 +142,16 @@ class OrderStatusFlow /** * Whether the "mark paid" option should be offered right now — - * entirely independent of $order->status. True whenever this is a - * cash-on-delivery order and payment hasn't been recorded yet, - * regardless of fulfillment progress (before OR after completed). + * entirely independent of $order->status for a COD order (true whenever + * payment hasn't been recorded yet, regardless of fulfillment progress, + * before OR after completed). A bank transfer order is also eligible, + * for the same "no earlier Payment event recorded this" reason (see + * OrderFulfillmentService::markPaid()), but unlike COD its own status + * genuinely does need advancing once marked paid — see that method. */ public function canMarkPaid(Order $order): bool { - return ! $order->paid && $this->isCod($order); + return ! $order->paid && ($this->isCod($order) || $this->isBankTransfer($order)); } /** diff --git a/src/Payment/Drivers/BankTransferPaymentDriver.php b/src/Payment/Drivers/BankTransferPaymentDriver.php index da8ab1f..1280a9f 100644 --- a/src/Payment/Drivers/BankTransferPaymentDriver.php +++ b/src/Payment/Drivers/BankTransferPaymentDriver.php @@ -9,30 +9,47 @@ use Modules\Core\Payment\Contracts\SupportsPay; use Modules\Core\Payment\Contracts\SupportsRefunds; use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\Enums\PaymentResultStatus; -use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentRefunded; /** - * Manual/attested, same trust model as OfflinePaymentDriver — there is no - * bank API to call, so both pay() and refund() decide success immediately - * on a staff member's say-so (they've already sent/received the wire - * outside the system). Distinct from OfflinePaymentDriver in intent: this - * exists so a payment taken through a DIFFERENT method (e.g. - * cash-on-delivery) can still be REFUNDED via bank transfer — an admin - * chooses this driver explicitly in the refund action, independent of - * which driver the original payment went through (see + * refund() is manual/attested, same trust model as OfflinePaymentDriver — + * there is no bank API to call, so it decides success immediately on a + * staff member's say-so (they've already sent the wire outside the + * system). Distinct from OfflinePaymentDriver in intent: this exists so a + * payment taken through a DIFFERENT method (e.g. cash-on-delivery) can + * still be REFUNDED via bank transfer — an admin chooses this driver + * explicitly in the refund action, independent of which driver the + * original payment went through (see * Payment\Support\TransactionDriverAdapter::refundVia() and - * Order\Filament\Extensions\OrderActionsExtension). pay() exists so - * the same driver also covers receiving a payment by bank transfer, but - * the admin UI for that (bank reference, notes, proof-of-transfer upload) - * is deliberately not built yet — see the follow-up work tracked from this - * session; pay() itself is complete and usable via the registry today. + * Order\Filament\Extensions\OrderActionsExtension). + * + * pay() is the opposite trust direction from refund(): a bank transfer + * payment requires the money to arrive BEFORE the order can be + * considered paid (unlike cash-on-delivery, where payment happens on + * delivery — see CashOnDeliveryPaymentDriver's own docblock for that + * driver's mirror-image reasoning). So pay() returns Pending, dispatching + * no event at all — no PaymentCaptured (nothing has been paid yet), and + * deliberately NOT PaymentDeferred either (unlike COD, whose + * MarkOrderPlacedOnDeferredPayment listener immediately advances the + * order past 'awaiting_payment' since a COD order has nothing to await at + * checkout). A bank transfer order genuinely DOES have something to + * await: it stays at 'awaiting_payment' with Order::paid false until + * staff confirm the wire arrived via OrderFulfillmentService::markPaid(), + * which — unlike its COD path — also advances the order's status, since + * nothing else ever will (see that method's own docblock). + * CheckoutController::placeOrder() already treats a Pending result with + * no continuation as a fully placed order (see its own docblock), so the + * order is still created and visible to the shopper immediately; only its + * payment/status is what's left outstanding. * * $reference is generated here for the same reason as OfflinePaymentDriver's - * pay(): there is no gateway to hand one back. 'notes' in $context (not - * $data — refund() has no $data parameter) is folded into - * PaymentResult::$meta, which Order\Services\TransactionRecorder::record() - * already writes straight into Transaction.meta with no extra plumbing. + * pay(): there is no gateway to hand one back. refund()'s 'notes' (in + * $context — it has no $data parameter) is folded into PaymentResult::$meta, + * which Order\Services\TransactionRecorder::record() already writes straight + * into Transaction.meta with no extra plumbing; pay() has no equivalent + * write, since nothing ever records a Transaction from its own result (see + * above) — any notes a shopper enters at checkout would need surfacing some + * other way, e.g. when staff mark the order paid. */ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRefunds { @@ -46,16 +63,11 @@ class BankTransferPaymentDriver implements Configurable, SupportsPay, SupportsRe public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult { - $result = new PaymentResult( - status: PaymentResultStatus::Succeeded, + return new PaymentResult( + status: PaymentResultStatus::Pending, reference: 'bank-transfer-'.Str::uuid(), amount: $amount, - meta: array_filter(['notes' => $data['notes'] ?? null]), ); - - PaymentCaptured::dispatch($type, $result, $context); - - return $result; } public function refund(string $reference, Price $amount, array $context = []): PaymentResult