Files
core/src/Order/Filament/Extensions/OrderRefundActionsExtension.php
T

200 lines
8.0 KiB
PHP

<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Transaction;
use Modules\Core\Payment\Contracts\SupportsRefunds;
use Modules\Core\Payment\Models\CoreTransaction;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Support\TransactionDriverAdapter;
use ReflectionProperty;
/**
* Fixes a real bug in Lunar's own admin panel, not anything specific to how
* boboko resolves payment drivers: ManageOrder::getRefundAction() and
* ::getCaptureAction() (vendor/lunarphp/lunar/.../ManageOrder.php) both
* report a failed refund/capture by calling, in this order:
* $action->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<string, string>
*/
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;
}
});
}
}