2026-08-31 13:54:20 +03:00
<? php
namespace Modules\Core\Payment\Filament\Resources ;
use Filament\Actions\Action ;
2026-09-09 00:48:09 +03:00
use Filament\Forms\Components\Select ;
2026-08-31 13:54:20 +03:00
use Filament\Forms\Components\TextInput ;
use Filament\Resources\Resource ;
2026-09-09 00:48:09 +03:00
use Filament\Schemas\Components\Component ;
use Filament\Schemas\Components\Utilities\Get ;
use Filament\Tables\Columns\IconColumn ;
2026-08-31 13:54:20 +03:00
use Filament\Tables\Columns\TextColumn ;
use Filament\Tables\Columns\ToggleColumn ;
use Filament\Tables\Table ;
2026-09-09 00:48:09 +03:00
use Illuminate\Support\Facades\Event ;
2026-09-10 00:14:42 +03:00
use Modules\Core\Payment\Contracts\Configurable ;
2026-09-09 00:48:09 +03:00
use Modules\Core\Payment\Events\PaymentMethodsReordered ;
2026-08-31 13:54:20 +03:00
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods ;
use Modules\Core\Payment\Models\PaymentMethod ;
2026-09-09 00:48:09 +03:00
use Modules\Core\Payment\Services\PaymentDriverRegistry ;
use Modules\Core\Payment\Services\PaymentMethodCache ;
use Modules\Core\Payment\Services\PaymentMethodService ;
2026-08-31 13:54:20 +03:00
/**
2026-09-09 00:48:09 +03:00
* 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
2026-09-10 00:14:42 +03:00
* when a row's driver no longer resolves) drives the "Driver status"
2026-09-09 00:48:09 +03:00
* column, deliberately distinct from `enabled` — an admin needs to tell
2026-09-10 00:14:42 +03:00
* "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.
2026-09-09 00:48:09 +03:00
*
* `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.
2026-08-31 13:54:20 +03:00
*/
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 ([
2026-09-09 00:48:09 +03:00
TextColumn :: make ( 'position' )
-> label ( 'Order' )
-> sortable (),
TextColumn :: make ( 'name' )
-> label ( 'Name' )
-> searchable (),
2026-08-31 13:54:20 +03:00
TextColumn :: make ( 'type' )
-> label ( 'Type' ),
2026-09-09 00:48:09 +03:00
TextColumn :: make ( 'driver' )
-> label ( 'Driver' )
-> formatStateUsing ( fn ( ? string $state ) => static :: driverLabel ( $state )),
IconColumn :: make ( 'driver_missing_at' )
-> label ( 'Driver status' )
-> boolean ()
2026-09-10 00:14:42 +03:00
-> 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 )),
2026-08-31 13:54:20 +03:00
ToggleColumn :: make ( 'enabled' )
2026-09-09 00:48:09 +03:00
-> label ( 'Enabled' )
-> updateStateUsing ( fn ( PaymentMethod $record , $state ) => app ( PaymentMethodService :: class )
-> update ( $record , [ 'enabled' => $state ])),
2026-08-31 13:54:20 +03:00
TextColumn :: make ( 'data.fee' )
-> label ( 'Fee' )
-> formatStateUsing ( fn ( ? int $state ) => $state
? number_format ( $state / 100 , 2 )
: '—' ),
TextColumn :: make ( 'updated_at' )
-> label ( 'Last updated' )
-> dateTime (),
])
2026-09-09 00:48:09 +03:00
-> reorderable ( 'position' )
-> afterReordering ( function ( array $order ) {
app ( PaymentMethodCache :: class ) -> forget ();
Event :: dispatch ( new PaymentMethodsReordered ( array_map ( 'intval' , array_values ( $order ))));
})
2026-08-31 13:54:20 +03:00
-> recordActions ([
2026-09-09 00:48:09 +03:00
static :: editAction (),
2026-08-31 13:54:20 +03:00
static :: editFeeAction (),
2026-09-09 00:48:09 +03:00
static :: deleteAction (),
2026-08-31 13:54:20 +03:00
])
2026-09-09 00:48:09 +03:00
-> 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 ));
2026-08-31 13:54:20 +03:00
}
/**
* $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 ) {
2026-09-09 00:48:09 +03:00
app ( PaymentMethodService :: class ) -> update ( $record , [
2026-08-31 13:54:20 +03:00
'data' => [
... $record -> data -> toArray (),
'fee' => filled ( $data [ 'fee' ]) ? ( int ) round ( $data [ 'fee' ] * 100 ) : null ,
],
]);
});
}
2026-09-09 00:48:09 +03:00
private static function deleteAction () : Action
2026-08-31 13:54:20 +03:00
{
2026-09-09 00:48:09 +03:00
return Action :: make ( 'delete' )
-> label ( 'Delete' )
-> icon ( 'heroicon-o-trash' )
-> color ( 'danger' )
-> requiresConfirmation ()
-> action ( fn ( PaymentMethod $record ) => app ( PaymentMethodService :: class ) -> delete ( $record ));
2026-08-31 13:54:20 +03:00
}
2026-09-09 00:48:09 +03:00
private static function driverLabel ( ? string $key ) : string
2026-08-31 13:54:20 +03:00
{
2026-09-09 00:48:09 +03:00
if ( $key === null ) {
return '—' ;
}
2026-08-31 13:54:20 +03:00
2026-09-09 00:48:09 +03:00
return app ( PaymentDriverRegistry :: class ) -> label ( $key ) ?? $key ;
2026-08-31 13:54:20 +03:00
}
2026-09-10 00:14:42 +03:00
/**
* 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.' ;
}
2026-08-31 13:54:20 +03:00
}