Feature: Moving Payment Methods to DB, adding fees, Transaction Updates, Refund Updates, General Updates to Payments
This commit is contained in:
@@ -3,21 +3,57 @@
|
||||
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\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;
|
||||
|
||||
/**
|
||||
* One row per payment type key (config('lunar.payments.types')), seeded by
|
||||
* InstallLunarCommand — never created/deleted here, only edited. `enabled`
|
||||
* toggles inline; `data.fee` (currently the only type-specific setting, for
|
||||
* cash-on-delivery's flat surcharge — see ApplyCashOnDeliveryFee) is edited
|
||||
* via a modal action rather than a dedicated form field, since not every
|
||||
* type has the same data keys.
|
||||
* 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) is surfaced as its own table
|
||||
* column, deliberately distinct from `enabled` — an admin needs to tell
|
||||
* "I turned this off" apart from "the driver code was removed" at a
|
||||
* glance, not have both look like the same disabled state.
|
||||
*
|
||||
* `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
|
||||
{
|
||||
@@ -35,10 +71,31 @@ class PaymentMethodResource extends Resource
|
||||
{
|
||||
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()
|
||||
->trueIcon('heroicon-o-exclamation-triangle')
|
||||
->falseIcon('heroicon-o-check-circle')
|
||||
->trueColor('danger')
|
||||
->falseColor('success')
|
||||
->tooltip(fn (PaymentMethod $record) => $record->driver_missing_at
|
||||
? 'Driver not found as of '.$record->driver_missing_at->diffForHumans()
|
||||
: 'Driver resolves correctly'),
|
||||
ToggleColumn::make('enabled')
|
||||
->label('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
|
||||
@@ -48,10 +105,110 @@ class PaymentMethodResource extends Resource
|
||||
->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('type');
|
||||
->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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +233,7 @@ class PaymentMethodResource extends Resource
|
||||
'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null,
|
||||
])
|
||||
->action(function (PaymentMethod $record, array $data) {
|
||||
$record->update([
|
||||
app(PaymentMethodService::class)->update($record, [
|
||||
'data' => [
|
||||
...$record->data->toArray(),
|
||||
'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null,
|
||||
@@ -85,20 +242,22 @@ class PaymentMethodResource extends Resource
|
||||
});
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
private static function deleteAction(): Action
|
||||
{
|
||||
return [
|
||||
'index' => ListPaymentMethods::route('/'),
|
||||
];
|
||||
return Action::make('delete')
|
||||
->label('Delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(fn (PaymentMethod $record) => app(PaymentMethodService::class)->delete($record));
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
private static function driverLabel(?string $key): string
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if ($key === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
public static function canDelete($record = null): bool
|
||||
{
|
||||
return false;
|
||||
return app(PaymentDriverRegistry::class)->label($key) ?? $key;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user