2026-09-09 00:48:09 +03:00
|
|
|
<?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 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.
|
2026-09-15 21:17:56 +03:00
|
|
|
*
|
|
|
|
|
* 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.
|
2026-09-09 00:48:09 +03:00
|
|
|
*/
|
2026-09-15 21:17:56 +03:00
|
|
|
class OrderActionsExtension extends ViewPageExtension
|
2026-09-09 00:48:09 +03:00
|
|
|
{
|
|
|
|
|
public function headerActions(array $actions): array
|
|
|
|
|
{
|
|
|
|
|
return array_map(
|
|
|
|
|
fn (Action $action) => match ($action->getName()) {
|
|
|
|
|
'refund' => $this->fixRefundAction($action),
|
2026-09-15 21:17:56 +03:00
|
|
|
'capture' => $this->fixCaptureAction($action),
|
2026-09-09 00:48:09 +03:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 21:17:56 +03:00
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 00:48:09 +03:00
|
|
|
/**
|
|
|
|
|
* @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);
|
|
|
|
|
}
|
|
|
|
|
}
|