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 capture: wrap the action's own action() closure so that, on * Halt, we call $action->sendFailureNotification() ourselves before letting * the Halt continue propagating — everything else is untouched. * * 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. */ class OrderRefundActionsExtension extends ViewPageExtension { public function headerActions(array $actions): array { return array_map( fn (Action $action) => match ($action->getName()) { 'refund' => $this->fixRefundAction($action), 'capture' => $this->fixFailureNotification($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(); }); } /** * @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); } /** * Wraps the action's own configured action() closure so that, if it * halts (Lunar's closures throw via $action->halt() to signal failure — * see this class's own docblock for why that alone never sends the * notification queued via failureNotification()), we send that * notification ourselves before letting the Halt continue propagating * (still needed — it's what stops callMountedAction() from treating * this as a success and closing the modal/committing the DB transaction). * * $this->evaluate() (not a plain call) matches exactly how Action::call() * itself invokes the closure — Lunar's closures type-hint $data/$record/ * $action and rely on Filament's own container-style parameter * resolution, not positional arguments. */ private function fixFailureNotification(Action $action): Action { $originalAction = $action->getActionFunction(); if ($originalAction === null) { return $action; } return $action->action(function (array $arguments) use ($action, $originalAction) { try { return $action->evaluate($originalAction, $arguments); } catch (Halt $exception) { $action->sendFailureNotification(); throw $exception; } }); } }