298 lines
12 KiB
PHP
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.';
|
|
}
|
|
}
|