Fix: Updates to OrderFullfilmentServices and box now clients, order views and checkout services
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user