Feature: Moving Payment Methods to DB, adding fees, Transaction Updates, Refund Updates, General Updates to Payments
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Filament\Extensions;
|
||||
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Filament\Tables\Table;
|
||||
use Lunar\Admin\Support\Extending\BaseExtension;
|
||||
|
||||
/**
|
||||
* Same fix as OrderRefundActionsExtension, applied to the order lines
|
||||
* table's "bulk_refund" toolbar action (Lunar\Admin\...\OrderItemsTable::
|
||||
* getBulkRefundAction()) — see that class's docblock for the underlying
|
||||
* Filament bug (failureNotification()+failure()+halt() never actually
|
||||
* sends the notification, because halt()'s Halt exception is caught before
|
||||
* Filament reaches the code that would send it).
|
||||
*/
|
||||
class OrderItemsTableExtension extends BaseExtension
|
||||
{
|
||||
public function extendTable(Table $table): Table
|
||||
{
|
||||
return $table->toolbarActions(
|
||||
array_map(
|
||||
fn ($action) => $action instanceof BulkAction && $action->getName() === 'bulk_refund'
|
||||
? $this->fixFailureNotification($action)
|
||||
: $action,
|
||||
$table->getToolbarActions(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private function fixFailureNotification(BulkAction $action): BulkAction
|
||||
{
|
||||
$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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Filament\Extensions;
|
||||
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||
use Modules\Core\Order\Filament\Infolists\TransactionEntry;
|
||||
|
||||
/**
|
||||
* Swaps Lunar\Admin\Support\Infolists\Components\Transaction for our own
|
||||
* TransactionEntry in the order page's transactions list — same component,
|
||||
* different Blade view, so a Transaction.meta['notes'] value (written by
|
||||
* a manual/attested driver like Payment\Drivers\BankTransferPaymentDriver)
|
||||
* actually renders somewhere, instead of only the notes column Lunar's own
|
||||
* view reads (see TransactionEntry's own docblock for why that column is
|
||||
* usually empty for a successful manual payment/refund).
|
||||
*
|
||||
* Uses the extendTransactionsRepeatableEntry hook ManageOrder's own
|
||||
* DisplaysTransactions trait already calls
|
||||
* (getTransactionsRepeatableEntry() → callStaticLunarHook(
|
||||
* 'extendTransactionsRepeatableEntry', ...)) — a class/component swap via
|
||||
* a Lunar-provided hook, the same category of extension already used
|
||||
* throughout CorePlugin, not a Blade view-path override.
|
||||
*/
|
||||
class OrderTransactionsExtension extends ViewPageExtension
|
||||
{
|
||||
public function extendTransactionsRepeatableEntry(RepeatableEntry $entry): RepeatableEntry
|
||||
{
|
||||
return $entry->schema([
|
||||
TransactionEntry::make('transaction_detail'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Filament\Infolists;
|
||||
|
||||
use Lunar\Admin\Support\Infolists\Components\Transaction as LunarTransactionEntry;
|
||||
|
||||
/**
|
||||
* Same component as Lunar's own Transaction infolist entry — only the
|
||||
* Blade view differs, to also show Transaction.meta['notes'] (what
|
||||
* Payment\Drivers\BankTransferPaymentDriver and any other manual/attested
|
||||
* driver write a staff-entered note into — see that driver's own
|
||||
* docblock) when the notes column itself is empty. The notes column is
|
||||
* populated by Order\Services\TransactionRecorder from
|
||||
* PaymentResult::$failureReason, which is only ever set on a FAILED
|
||||
* result — a successful manual payment/refund's note would otherwise be
|
||||
* recorded (Transaction.meta) but never shown anywhere in the admin
|
||||
* panel, since Lunar's own view only ever reads the notes column.
|
||||
*
|
||||
* Registered in place of Lunar's own Transaction component via
|
||||
* Order\Filament\Extensions\OrderTransactionsExtension's
|
||||
* extendTransactionsRepeatableEntry() hook (see that class), not a
|
||||
* view-path override — this is the same "swap the concrete
|
||||
* class/component" pattern already used throughout CorePlugin
|
||||
* (LunarPanel::extensions()), rather than shadowing Lunar's Blade file
|
||||
* from underneath it.
|
||||
*/
|
||||
class TransactionEntry extends LunarTransactionEntry
|
||||
{
|
||||
protected string $view = 'core::order.infolists.transaction';
|
||||
}
|
||||
@@ -7,13 +7,17 @@ use Lunar\Models\Order;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
use Modules\Core\Payment\Services\PaymentMethodCache;
|
||||
|
||||
/**
|
||||
* The only place an Order's status column is written in reaction to a
|
||||
* payment outcome. Registered against BOTH PaymentCaptured and
|
||||
* PaymentAuthorized (see OrderServiceProvider) — same handler either way,
|
||||
* since both carry the same {type, result, context} shape and only differ
|
||||
* in which config key decides the resulting status.
|
||||
* payment outcome. Registered against PaymentCaptured, PaymentAuthorized,
|
||||
* AND PaymentRefunded (see OrderServiceProvider) — same handler for all
|
||||
* three, differing only in which PaymentMethod column decides the
|
||||
* resulting status and, for a refund, which PaymentMethod row that even
|
||||
* is (see resolvePaymentMethod()).
|
||||
*
|
||||
* Reads $event->context['order_id'] to find which Order this outcome
|
||||
* belongs to — Payment has no concept of an Order, so this is the one
|
||||
@@ -28,11 +32,19 @@ use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
*
|
||||
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set —
|
||||
* see that event's own docblock for why this, not CheckoutService, is now
|
||||
* the dispatch point.
|
||||
* the dispatch point. Never fires from the PaymentRefunded path — a
|
||||
* refund can only ever happen after an order was already placed.
|
||||
*
|
||||
* Deliberately does NOT react to PaymentVoided — see PaymentMethod's own
|
||||
* docblock for why there's no void_status column at all yet.
|
||||
*/
|
||||
class ApplyResolvedPaymentStatus
|
||||
{
|
||||
public function handle(PaymentCaptured|PaymentAuthorized $event): void
|
||||
public function __construct(
|
||||
private readonly PaymentMethodCache $paymentMethods,
|
||||
) {}
|
||||
|
||||
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
|
||||
{
|
||||
$orderId = $event->context['order_id'] ?? null;
|
||||
|
||||
@@ -42,8 +54,13 @@ class ApplyResolvedPaymentStatus
|
||||
|
||||
$order = Order::findOrFail($orderId);
|
||||
|
||||
$configKey = $event instanceof PaymentCaptured ? 'captured_status' : 'authorized_status';
|
||||
$status = config("lunar.payments.types.{$event->type}.{$configKey}");
|
||||
$method = $this->resolvePaymentMethod($event, $order);
|
||||
$column = match (true) {
|
||||
$event instanceof PaymentCaptured => 'captured_status',
|
||||
$event instanceof PaymentAuthorized => 'authorized_status',
|
||||
$event instanceof PaymentRefunded => 'refunded_status',
|
||||
};
|
||||
$status = $method?->{$column};
|
||||
|
||||
if ($status === null) {
|
||||
return;
|
||||
@@ -56,8 +73,40 @@ class ApplyResolvedPaymentStatus
|
||||
'placed_at' => $order->placed_at ?? now(),
|
||||
]);
|
||||
|
||||
if (! $wasPlaced) {
|
||||
if (! $wasPlaced && ! $event instanceof PaymentRefunded) {
|
||||
Event::dispatch(new OrderPlaced($order));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PaymentCaptured/PaymentAuthorized carry $event->type as the
|
||||
* PaymentMethod.type that was actually charged — a direct lookup.
|
||||
*
|
||||
* PaymentRefunded's $event->type is the REFUND driver's own registry
|
||||
* key (e.g. 'bank-transfer' — see BankTransferPaymentDriver::refund()),
|
||||
* which may not correspond to any PaymentMethod row at all when the
|
||||
* admin refunded through a different driver than the one that took
|
||||
* the original payment (Payment\Support\TransactionDriverAdapter::
|
||||
* refundVia()). refunded_status is a business decision about the
|
||||
* ORIGINAL payment method, not the refund mechanism, so this instead
|
||||
* finds the order's earliest successful capture/intent transaction —
|
||||
* the actual payment the refund is reversing — and resolves that
|
||||
* transaction's own driver (a real PaymentMethod.type) instead.
|
||||
*/
|
||||
private function resolvePaymentMethod(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event, Order $order): ?PaymentMethod
|
||||
{
|
||||
if (! $event instanceof PaymentRefunded) {
|
||||
return $this->paymentMethods->all()->firstWhere('type', $event->type);
|
||||
}
|
||||
|
||||
$originalType = $order->transactions()
|
||||
->whereIn('type', ['capture', 'intent'])
|
||||
->where('success', true)
|
||||
->oldest('created_at')
|
||||
->value('driver');
|
||||
|
||||
return $originalType !== null
|
||||
? $this->paymentMethods->all()->firstWhere('type', $originalType)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user