Files
core/src/Payment/Filament/Resources/PaymentMethodResource.php
T

298 lines
12 KiB
PHP

<?php
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
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 Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Event;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Payment\Services\PaymentMethodService;
/**
* The DB-instance layer for Payment (see docs/payments.md) — admin
* creatable/deletable, same as Lunar's own ShippingMethodResource. A row's
* `driver` is picked from a Select populated by
* PaymentDriverRegistry::labels() (mirrors Modules\Core\Shipping\
* Extensions\ShippingMethodResourceExtension::driverSelect()'s use of
* Shipping::getSupportedDrivers()), not a hardcoded options list, and
* never the raw driver class name — a third-party driver registered from
* its own package's service provider shows up here with no change to
* this class.
*
* Every write goes through Modules\Core\Payment\Services\
* PaymentMethodService — create/edit/delete/the enabled toggle all call
* it, not PaymentMethod::create()/update()/delete() directly, so cache
* invalidation and event dispatch happen in one place. The ONE exception
* is drag-to-reorder: Filament's own reorderTable() always writes the new
* `position` values via its own raw bulk SQL query before our
* afterReordering() hook ever runs — there is no seam to route that
* specific write through the service (short of disabling drag-reorder
* entirely and rebuilding it from scratch), so that hook only forgets the
* cache and dispatches PaymentMethodsReordered; the data itself is
* already correct in the database by the time it fires.
*
* `driver_missing_at` (set by the `boboko:payment:sync-drivers` command
* when a row's driver no longer resolves) drives the "Driver status"
* column, deliberately distinct from `enabled` — an admin needs to tell
* "I turned this off" apart from "this driver isn't usable right now" at
* a glance, not have both look like the same disabled state. That column
* also folds in Configurable::isConfigured() (e.g. Stripe with no API key
* set) — a class-resolves-but-isn't-usable state that CheckoutService::
* getPaymentMethods() filters out identically to a missing driver, so an
* admin needs the same at-a-glance warning for it, not just a silently
* absent checkout option.
*
* `authorized_status` only appears in the form when `capture_mode` is
* "Hold now, charge later" — it's simply unreachable for a "Charge
* immediately" method (that mode only ever produces PaymentCaptured,
* never PaymentAuthorized), so showing it unconditionally would just be
* a confusing, always-irrelevant field for most methods.
*/
class PaymentMethodResource extends Resource
{
protected static ?string $model = PaymentMethod::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Payment Method';
protected static ?string $pluralModelLabel = 'Payment Methods';
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('position')
->label('Order')
->sortable(),
TextColumn::make('name')
->label('Name')
->searchable(),
TextColumn::make('type')
->label('Type'),
TextColumn::make('driver')
->label('Driver')
->formatStateUsing(fn (?string $state) => static::driverLabel($state)),
IconColumn::make('driver_missing_at')
->label('Driver status')
->boolean()
->state(fn (PaymentMethod $record) => ! $record->driver_missing_at && static::driverIsConfigured($record->driver))
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-exclamation-triangle')
->trueColor('success')
->falseColor('danger')
->tooltip(fn (PaymentMethod $record) => static::driverStatusTooltip($record)),
ToggleColumn::make('enabled')
->label('Enabled')
->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class)
->update($record, ['enabled' => $state])),
TextColumn::make('data.fee')
->label('Fee')
->formatStateUsing(fn (?int $state) => $state
? number_format($state / 100, 2)
: '—'),
TextColumn::make('updated_at')
->label('Last updated')
->dateTime(),
])
->reorderable('position')
->afterReordering(function (array $order) {
app(PaymentMethodCache::class)->forget();
Event::dispatch(new PaymentMethodsReordered(array_map('intval', array_values($order))));
})
->recordActions([
static::editAction(),
static::editFeeAction(),
static::deleteAction(),
])
->defaultSort('position');
}
/**
* @return array<Component>
*/
public static function getFormComponents(): array
{
return [
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
TextInput::make('type')
->label('Type')
->helperText('Machine-facing slug — stored on the cart/order, used by other code to identify this method. Cannot be changed once orders reference it.')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
static::getDriverFormComponent(),
Select::make('capture_mode')
->label('Capture mode')
->helperText('Whether checkout charges immediately, or places a hold to settle later.')
->options([
'pay' => 'Charge immediately',
'authorize' => 'Hold now, charge later',
])
->default('pay')
->live()
->required(),
static::getOrderStatusSelect('captured_status', 'Order status once paid')
->helperText('Applied the moment a payment is fully charged.'),
static::getOrderStatusSelect('authorized_status', 'Order status once held')
->helperText('Applied the moment a hold is placed, before it\'s charged.')
->visible(fn (Get $get) => $get('capture_mode') === 'authorize'),
static::getOrderStatusSelect('refunded_status', 'Order status once refunded')
->helperText('Applied when a payment taken through this method is refunded — even if the refund itself is processed through a different method.'),
];
}
public static function getDriverFormComponent(): Component
{
return Select::make('driver')
->label('Driver')
->options(fn () => app(PaymentDriverRegistry::class)->labels())
->required();
}
/**
* Lunar's own Order::status is a plain, admin-extensible string
* (config('lunar.orders.statuses')) rather than a fixed enum —
* deliberately so a store can add its own custom status without a
* code change (see docs/payments.md). This Select still reads from
* that same open-ended list, just so an admin picks a real status
* instead of typing a slug from memory.
*/
private static function getOrderStatusSelect(string $name, string $label): Select
{
return Select::make($name)
->label($label)
->options(collect(config('lunar.orders.statuses', []))
->map(fn (array $status) => $status['label'] ?? $status)
->all())
->native(false);
}
public static function getPages(): array
{
return [
'index' => ListPaymentMethods::route('/'),
];
}
public static function canCreate(): bool
{
return true;
}
public static function canDelete($record = null): bool
{
return true;
}
private static function editAction(): Action
{
return Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([
'name', 'type', 'driver', 'capture_mode', 'captured_status', 'authorized_status', 'refunded_status',
]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
}
/**
* $data['fee'] is stored as an integer minor unit (cents), matching
* Lunar's own Price convention everywhere else in this codebase — the
* form collects/displays a decimal and converts at the boundary.
*/
private static function editFeeAction(): Action
{
return Action::make('edit_fee')
->label('Edit fee')
->icon('heroicon-o-pencil')
->schema([
TextInput::make('fee')
->label('Fee')
->numeric()
->minValue(0)
->step(0.01)
->helperText('Flat surcharge added when this payment method is selected.'),
])
->fillForm(fn (PaymentMethod $record) => [
'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null,
])
->action(function (PaymentMethod $record, array $data) {
app(PaymentMethodService::class)->update($record, [
'data' => [
...$record->data->toArray(),
'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null,
],
]);
});
}
private static function deleteAction(): Action
{
return Action::make('delete')
->label('Delete')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(fn (PaymentMethod $record) => app(PaymentMethodService::class)->delete($record));
}
private static function driverLabel(?string $key): string
{
if ($key === null) {
return '—';
}
return app(PaymentDriverRegistry::class)->label($key) ?? $key;
}
/**
* False for a missing driver too, since Configurable::isConfigured()
* has nothing to ask in that case — driverStatusTooltip() below is
* what tells the two reasons apart for the admin.
*/
private static function driverIsConfigured(?string $key): bool
{
$driver = $key ? app(PaymentDriverRegistry::class)->resolve($key) : null;
if (! $driver instanceof Configurable) {
return false;
}
return $driver->isConfigured();
}
private static function driverStatusTooltip(PaymentMethod $record): string
{
if ($record->driver_missing_at) {
return 'Driver not found as of '.$record->driver_missing_at->diffForHumans();
}
if (! static::driverIsConfigured($record->driver)) {
return 'Driver resolves, but is missing required configuration (e.g. an API key) — it will not be offered at checkout.';
}
return 'Driver resolves correctly and is fully configured.';
}
}