Fix: Updates to OrderFullfilmentServices and box now clients, order views and checkout services

This commit is contained in:
2026-09-18 00:54:43 +03:00
parent dcdc998eee
commit 12aaa43f10
15 changed files with 450 additions and 18 deletions
@@ -8,6 +8,7 @@ use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentDeferred;
/**
* Cash-on-delivery/cash-on-pickup — the shopper pays staff in person, at
@@ -31,6 +32,17 @@ use Modules\Core\Payment\Enums\PaymentResultStatus;
* marking it received (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), offered by the single "Update
* Status" action at any time, independent of status.
*
* Despite returning Pending, this order IS fully placed the moment pay()
* returns — unlike a Stripe 3-D Secure Pending, nothing will ever resolve
* this into a later PaymentCaptured/PaymentAuthorized (COD has no gateway
* callback at all). Without PaymentDeferred, no listener ever set
* Order::placed_at for a COD order: invisible in customer order history,
* no stock decrement (Modules\Core\Order\Listeners\
* DecrementStockOnOrderPlaced only reacts to Checkout\Events\OrderPlaced),
* and the storefront's own post-checkout confirmation could never find it
* — a real bug, not a hypothetical, caught and fixed after the fact. See
* PaymentDeferred's own docblock for the full reasoning.
*/
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay
{
@@ -41,10 +53,14 @@ class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
return new PaymentResult(
$result = new PaymentResult(
status: PaymentResultStatus::Pending,
reference: 'cod-'.Str::uuid(),
amount: $amount,
);
PaymentDeferred::dispatch($type, $result, $context);
return $result;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Payment\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Core\Payment\DTOs\PaymentResult;
/**
* "This order has no money to collect yet, and never will via a gateway
* callback — nothing further will ever resolve this PaymentResult's
* Pending status into Captured/Authorized." Distinct from a Stripe-style
* Pending (3-D Secure, still resolving asynchronously via a later webhook
* or client-side confirmation) — that case correctly dispatches nothing
* yet, since PaymentCaptured/PaymentAuthorized WILL still follow once
* resolved.
*
* Dispatched by Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver::
* pay() the moment it returns Pending — a COD order is fully placed at
* that instant, with reconciliation (Order::paid) happening independently,
* anywhere from same-day to months later, entirely outside any gateway's
* knowledge. Any other current or future "deferred capture, no gateway
* callback" driver dispatches this the same way, rather than each
* reinventing its own "mark placed" event.
*
* Handled by Modules\Core\Order\Listeners\MarkOrderPlacedOnDeferredPayment
* — sets ONLY Order::placed_at and fires Checkout\Events\OrderPlaced.
* Deliberately does not touch Order::paid/paid_at (see
* OrderStatusWriter::markPaid(), the only path that ever does) or create a
* Transaction row (RecordPaymentTransaction listens to PaymentCaptured/
* PaymentAuthorized/PaymentVoided/PaymentRefunded only — correctly not
* this event, since no money has moved and there is nothing to record
* yet).
*/
class PaymentDeferred
{
use Dispatchable;
/**
* @param array<string, mixed> $context
*/
public function __construct(
public readonly string $type,
public readonly PaymentResult $result,
public readonly array $context = [],
) {}
}
@@ -3,10 +3,13 @@
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
@@ -14,6 +17,7 @@ use Filament\Tables\Table;
use Illuminate\Support\Facades\Event;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsAuthorization;
use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
@@ -144,7 +148,31 @@ class PaymentMethodResource extends Resource
])
->default('pay')
->live()
->required(),
// Only meaningful for a driver that actually implements
// SupportsAuthorization — CheckoutService::initiatePayment()
// calls $driver->authorize() when capture_mode is
// "authorize", which fatals on a driver missing that method
// entirely (e.g. CashOnDeliveryPaymentDriver, which only
// ever implements SupportsPay: the shopper pays staff in
// person, at an unknown future moment — there is no
// "hold now, settle later" operation to offer for that at
// all). Hidden rather than merely disabled, since a
// hidden field is also excluded from validation/dehydration
// — required() below would otherwise still block saving.
->visible(fn (Get $get) => static::driverSupportsAuthorization($get('driver')))
->required(fn (Get $get) => static::driverSupportsAuthorization($get('driver'))),
// Every OTHER fillForm() value not backed by a real component
// here is silently dropped — an Action::schema() modal only
// dehydrates fields present in its own schema, unlike a
// resource's form(); ListPaymentMethods::getHeaderActions()'s
// CreateAction::fillForm() used to set 'position' this same
// way and it never reached PaymentMethodService::create(),
// so every new method saved with the column's raw DB default
// (0) regardless of what fillForm() computed. Hidden here
// purely so it actually dehydrates; the table's own
// reorderable('position') drag-and-drop remains the real
// staff-facing way to change it afterward.
Hidden::make('position'),
];
}
@@ -153,9 +181,23 @@ class PaymentMethodResource extends Resource
return Select::make('driver')
->label('Driver')
->options(fn () => app(PaymentDriverRegistry::class)->labels())
->live()
->required();
}
private static function driverSupportsAuthorization(?string $driver): bool
{
if (! $driver) {
return true;
}
try {
return app(PaymentDriverRegistry::class)->resolve($driver) instanceof SupportsAuthorization;
} catch (InvalidArgumentException) {
return true;
}
}
public static function getPages(): array
{
return [
@@ -180,7 +222,7 @@ class PaymentMethodResource extends Resource
->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([
'name', 'type', 'driver', 'capture_mode',
'name', 'type', 'driver', 'capture_mode', 'position',
]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
}
@@ -5,6 +5,7 @@ namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Lunar\Models\Language;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentMethodService;
@@ -22,6 +23,15 @@ class ListPaymentMethods extends ListRecords
'position' => (PaymentMethod::max('position') ?? 0) + 1,
'enabled' => false,
'data' => [],
// TranslatedText's own default() (getLanguageDefaults())
// never reaches this mounted action's initial state —
// unlike a resource's own form(), an Action::schema()
// modal starts from exactly what fillForm() returns, so
// `name` was landing as null rather than the expected
// per-locale array, and every locale's sub-input
// silently failed to bind to it (required() on the
// default locale then correctly rejected the null).
'name' => Language::pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all(),
])
// Every PaymentMethod write goes through PaymentMethodService
// — see PaymentMethodResource's own docblock — so this