failureNotification(...); $action->failure(); $action->halt(); * but Filament\Actions\Concerns\InteractsWithActions::callMountedAction() * only ever calls sendFailureNotification() from a match($action->getStatus()) * block that runs AFTER the action's call() returns normally — halt() throws * Filament\Support\Exceptions\Halt, which is caught in an earlier catch block * that rolls back the DB transaction and returns null, never reaching that * match block. So the notification set via failureNotification() is built * but never sent: the admin sees the modal just close/reset with no * indication anything happened. This was always broken in Lunar; it was * invisible before because nothing in this codebase's Transaction::driver() * could return a real, honest failure — see Payment\Support\ * TransactionDriverAdapter's own docblock for that history. * * Fix, for refund: same notification fix, but the action() closure is * replaced outright (not wrapped) rather than reused, because refund also * needs a "Refund via" driver Select added to the modal (see * fixRefundAction()) and the actual call routed through * Payment\Support\TransactionDriverAdapter::refundVia() instead of * Lunar\Models\Transaction::refund() — see fixRefundAction()'s own * docblock. * * Fix, for capture: same notification fix, but the action() closure is * also replaced outright — the actual call is routed through * Payment\Support\TransactionDriverAdapter::capture() instead of * Lunar\Models\Transaction::capture() (see fixCaptureAction()), so a * manual backoffice capture goes through the app's own payment driver * registry and dispatches Payment\Events\PaymentCaptured exactly like a * checkout-time capture does — the vendor path resolved * Lunar\Facades\Payments (an entirely separate, unused driver registry) * and never dispatched that event, which is why Order::status used to * stay stuck on 'awaiting_payment' after a manual capture even though * Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus now advances it * on PaymentCaptured. */ class OrderActionsExtension extends ViewPageExtension { public function headerActions(array $actions): array { return array_map( fn (Action $action) => match ($action->getName()) { 'refund' => $this->fixRefundAction($action), 'capture' => $this->fixCaptureAction($action), default => $action, }, $actions, ); } /** * Combines both refund-only changes on top of the failure-notification * fix every action here gets: adds a "Refund via" driver Select * (defaulting to the transaction's own driver) to the modal, and * replaces the actual refund call with one that honours that field — * calling Payment\Support\TransactionDriverAdapter::refundVia() * directly (bypassing Lunar\Models\Transaction::refund(), whose fixed * refund(int $amount, $notes = null) signature has no room for a * driver override) whenever the admin picked a driver other than the * transaction's own. When left at the default, behaviour is identical * to calling $transaction->refund() — refundVia() resolves to the same * driver either way. * * The Select is appended to Lunar's own schema closure (read via * reflection — HasSchema::$schema has no public getter) rather than * replacing it outright, so the transaction/amount/notes/confirm * fields Lunar already built are untouched. */ private function fixRefundAction(Action $action): Action { $originalSchema = $this->readProtectedProperty($action, 'schema'); $action->schema(function (array $arguments) use ($action, $originalSchema) { $fields = is_callable($originalSchema) ? $action->evaluate($originalSchema, $arguments) : ($originalSchema ?? []); return [ ...$fields, Select::make('driver') ->label('Refund via') ->options(fn () => $this->refundCapableDriverLabels()) ->default(fn ($get) => $this->driverKeyForTransaction($get('transaction'))) ->native(false) ->required(), ]; }); return $action->action(function (array $data, Action $action) { $transaction = Transaction::find($data['transaction']); if (! $transaction instanceof CoreTransaction) { $action->failureNotification(fn () => Notification::make('refund_failure')->danger()->title('Transaction not found.')) ->sendFailureNotification(); throw new Halt; } $adapter = app(TransactionDriverAdapter::class); $driverKey = $data['driver'] ?? $adapter->driverKeyFor($transaction); $response = $adapter->refundVia($transaction, $driverKey, (int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor), $data['notes'] ?? null); if (! $response->success) { $action->failureNotification( fn () => Notification::make('refund_failure')->color('danger')->title($response->message) )->sendFailureNotification(); throw new Halt; } $action->success(); }); } /** * Mirrors fixRefundAction()'s notification fix, but for the "amount" * field already on the vendor schema — no extra field needed, since * capture always goes back through the transaction's own original * driver (there's no equivalent to refunding via a different driver). */ private function fixCaptureAction(Action $action): Action { return $action->action(function (array $data, Action $action) { $transaction = Transaction::find($data['transaction']); if (! $transaction instanceof CoreTransaction) { $action->failureNotification(fn () => Notification::make('capture_failure')->danger()->title('Transaction not found.')) ->sendFailureNotification(); throw new Halt; } $response = app(TransactionDriverAdapter::class)->capture( $transaction, (int) bcmul((string) $data['amount'], (string) $transaction->order->currency->factor), ); if (! $response->success) { $action->failureNotification( fn () => Notification::make('capture_failure')->color('danger')->title($response->message) )->sendFailureNotification(); throw new Halt; } $action->success(); }); } /** * @return array */ private function refundCapableDriverLabels(): array { $registry = app(PaymentDriverRegistry::class); $labels = []; foreach ($registry->all() as $key => $driverClass) { if (app($driverClass) instanceof SupportsRefunds) { $labels[$key] = $registry->label($key) ?? $key; } } return $labels; } private function driverKeyForTransaction(mixed $transactionId): ?string { if (blank($transactionId)) { return null; } $transaction = Transaction::find($transactionId); if (! $transaction instanceof CoreTransaction) { return null; } return app(TransactionDriverAdapter::class)->driverKeyFor($transaction); } private function readProtectedProperty(object $object, string $property): mixed { $reflected = new ReflectionProperty($object, $property); $reflected->setAccessible(true); return $reflected->getValue($object); } }