Complete Payment module: initiate/webhook/confirm/refund actions, Filament resources (T5.8-T5.13)
- InitiatePaymentAction + POST /api/v1/payments/{booking}/initiate
- Generic KBZ webhook (POST /api/v1/webhooks/{method}/{encryptBookingId?}),
signature verification per KBZ's real callback spec, PaymentGatewayInterface::handleWebhook()
- ConfirmPaymentAction: idempotent confirmation, PaymentCompleted/PaymentFailed events,
MarkBookingPaid listener
- RefundBookingAction + POST /api/v1/bookings/{booking}/refund: partial refunds validated
against remaining balance, RefundProcessed event, MarkBookingRefunded listener
- CancelBookingAction now refunds confirmed bookings instead of rejecting; BookingPolicy::cancel
requires process_refunds for confirmed bookings
- PaymentPlugin + PaymentResource/RefundResource Filament admin UI (read-only payments,
refund list + Process action)
- Booking detail page now shows related payments
- Fix CACHE_STORE mismatch (database -> redis) so tagged route caching works
- CLAUDE.md: never run migrate:fresh/migrate:refresh/db:wipe on dev without being asked
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Events\PaymentCompleted;
|
||||
use Modules\Payment\Events\PaymentFailed;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
|
||||
/**
|
||||
* Confirms a Payment following an inbound webhook notification — never
|
||||
* trusts the webhook's own trade_status directly, re-verifies with the
|
||||
* gateway first (domain.md §6, bnf_event's client-driven-confirmation
|
||||
* fallback pattern).
|
||||
*
|
||||
* Idempotent: KBZ may redeliver the same notification (or this may run more
|
||||
* than once for the same transaction for other reasons), so a Payment only
|
||||
* ever transitions out of `pending` once — a redelivery after that is a
|
||||
* no-op that doesn't re-call the gateway or re-dispatch events.
|
||||
*/
|
||||
class ConfirmPaymentAction
|
||||
{
|
||||
public function __construct(
|
||||
private PaymentService $paymentService,
|
||||
) {}
|
||||
|
||||
public function handle(PaymentMethod $method, string $gatewayTransactionId): ?Payment
|
||||
{
|
||||
$payment = Payment::where('gateway', $method)
|
||||
->where('gateway_transaction_id', $gatewayTransactionId)
|
||||
->first();
|
||||
|
||||
if ($payment === null || $payment->status !== PaymentStatus::Pending) {
|
||||
return $payment;
|
||||
}
|
||||
|
||||
$verified = $this->paymentService->verify($method, $gatewayTransactionId);
|
||||
|
||||
if ($verified->status === PaymentStatus::Pending) {
|
||||
return $payment;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($payment, $verified) {
|
||||
$payment->update([
|
||||
'status' => $verified->status,
|
||||
'gateway_payload' => $verified->gatewayPayload,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
match ($verified->status) {
|
||||
PaymentStatus::Completed => PaymentCompleted::dispatch($payment),
|
||||
PaymentStatus::Failed => PaymentFailed::dispatch($payment),
|
||||
PaymentStatus::Pending => null,
|
||||
};
|
||||
|
||||
return $payment;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Exceptions\PaymentInitiationNotAllowedException;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
|
||||
/**
|
||||
* Starts a payment attempt for a booking — calls the resolved gateway via
|
||||
* PaymentService, then persists the attempt as a `payments` row regardless
|
||||
* of outcome (a failed precreate is still a recorded attempt, domain.md §6).
|
||||
*
|
||||
* Booking status only ever flips to `confirmed` once the gateway confirms
|
||||
* success via the webhook/verify path (T5.9/T5.10) — never here.
|
||||
*/
|
||||
class InitiatePaymentAction
|
||||
{
|
||||
private const CURRENCY = 'MMK';
|
||||
|
||||
public function __construct(
|
||||
private PaymentService $paymentService,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment
|
||||
{
|
||||
if ($booking->status !== BookingStatus::PendingPayment) {
|
||||
throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
$merchantOrderId = $this->merchantOrderId($booking);
|
||||
|
||||
$result = $this->paymentService->initiate(new PaymentRequestData(
|
||||
bookingId: $booking->id,
|
||||
merchantOrderId: $merchantOrderId,
|
||||
amount: (string) $booking->price,
|
||||
currency: self::CURRENCY,
|
||||
method: $method,
|
||||
notifyUrl: $this->notifyUrl($booking, $method),
|
||||
));
|
||||
|
||||
return DB::transaction(fn () => Payment::create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => $method,
|
||||
'status' => $result->status,
|
||||
'amount' => $booking->price,
|
||||
'currency' => self::CURRENCY,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
'initiated_at' => now(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking can have more than one payment attempt (retry after
|
||||
* failure), so the merchant order id must be unique per attempt, not
|
||||
* just per booking — suffixed with the attempt number.
|
||||
*/
|
||||
private function merchantOrderId(Booking $booking): string
|
||||
{
|
||||
$attempt = $booking->payments()->count() + 1;
|
||||
|
||||
return "{$booking->booking_ref}-{$attempt}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds the booking id (encrypted, so the URL doesn't leak a raw
|
||||
* sequential id) as an optional path segment on the webhook URL —
|
||||
* mirrors bnf_event's `{encryptOrderId?}` on `paymentComplete`, giving
|
||||
* the webhook a direct way to locate the booking as a redundant check
|
||||
* alongside `merch_order_id` in the signed payload. Uses Laravel's
|
||||
* Crypt facade rather than porting bnf_event's hand-rolled openssl
|
||||
* helper (BNFEventEncryption) — same idea, standard implementation.
|
||||
*/
|
||||
private function notifyUrl(Booking $booking, PaymentMethod $method): string
|
||||
{
|
||||
return route('payment.webhooks.handle', [
|
||||
'method' => $method->value,
|
||||
'encryptBookingId' => Crypt::encryptString((string) $booking->id),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Events\RefundProcessed;
|
||||
use Modules\Payment\Exceptions\RefundFailedException;
|
||||
use Modules\Payment\Exceptions\RefundNotAllowedException;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
|
||||
/**
|
||||
* Usable from both the API and Filament (T5.13) — resolves the gateway via
|
||||
* PaymentService/PaymentGatewayFactory, never a concrete gateway class.
|
||||
*
|
||||
* Unlike bnf_event (refund amount was effectively hardcoded to full-amount),
|
||||
* partial refunds are explicitly supported: `amount` is validated against
|
||||
* the Payment's remaining refundable balance, not just its original total
|
||||
* (domain.md §6).
|
||||
*/
|
||||
class RefundBookingAction
|
||||
{
|
||||
public function __construct(
|
||||
private PaymentService $paymentService,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, string $amount, string $reason, ?int $requestedBy = null): Refund
|
||||
{
|
||||
if ($booking->status !== BookingStatus::Confirmed) {
|
||||
throw RefundNotAllowedException::notConfirmed($booking);
|
||||
}
|
||||
|
||||
$payment = $booking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
|
||||
|
||||
if ($payment === null) {
|
||||
throw RefundNotAllowedException::noCompletedPayment($booking);
|
||||
}
|
||||
|
||||
$this->assertWithinRefundableBalance($payment, $amount);
|
||||
|
||||
$result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason);
|
||||
|
||||
$refund = DB::transaction(function () use ($payment, $amount, $reason, $result, $requestedBy) {
|
||||
$refund = Refund::create([
|
||||
'payment_id' => $payment->id,
|
||||
'status' => $result->status,
|
||||
'amount' => $amount,
|
||||
'reason' => $reason,
|
||||
'gateway_refund_id' => $result->gatewayRefundId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
'requested_by' => $requestedBy,
|
||||
'requested_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
if ($result->status === RefundStatus::Completed) {
|
||||
RefundProcessed::dispatch($refund);
|
||||
}
|
||||
|
||||
return $refund;
|
||||
});
|
||||
|
||||
if ($result->status === RefundStatus::Failed) {
|
||||
throw RefundFailedException::fromResult($result);
|
||||
}
|
||||
|
||||
return $refund;
|
||||
}
|
||||
|
||||
private function assertWithinRefundableBalance(Payment $payment, string $amount): void
|
||||
{
|
||||
$alreadyRefunded = (string) $payment->refunds()->where('status', RefundStatus::Completed->value)->sum('amount');
|
||||
$remaining = bcsub((string) $payment->amount, $alreadyRefunded, 2);
|
||||
|
||||
if (bccomp($amount, $remaining, 2) === 1) {
|
||||
throw RefundNotAllowedException::exceedsRefundableBalance($payment, $amount, $remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Payment\Contracts;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Exceptions\InvalidWebhookSignatureException;
|
||||
|
||||
/**
|
||||
* Contract every payment gateway strategy implements (e.g. KbzMiniAppGateway).
|
||||
@@ -30,4 +31,18 @@ interface PaymentGatewayInterface
|
||||
* Reverse (all or part of) a successful payment.
|
||||
*/
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData;
|
||||
|
||||
/**
|
||||
* Handle an inbound webhook notification — verifies the payload is
|
||||
* genuinely from the gateway (throwing InvalidWebhookSignatureException
|
||||
* if not) and maps it to a PaymentResultData. Every gateway implements
|
||||
* this itself since the notification shape and signing scheme are
|
||||
* gateway-specific; the webhook controller only routes by PaymentMethod
|
||||
* and never branches on gateway (domain.md §6).
|
||||
*
|
||||
* @param array<string, mixed> $payload The raw, as-posted webhook body.
|
||||
*
|
||||
* @throws InvalidWebhookSignatureException
|
||||
*/
|
||||
public function handleWebhook(array $payload): PaymentResultData;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* Dispatched once a Payment is confirmed successful — gateways stay
|
||||
* ignorant of Booking, so booking-status changes react to this event
|
||||
* instead (domain.md §6, see MarkBookingPaid).
|
||||
*/
|
||||
class PaymentCompleted
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Payment $payment) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* Dispatched once a Payment is confirmed failed. No listener flips booking
|
||||
* status on this — a failed attempt just leaves the Booking pending_payment
|
||||
* so the customer can retry (domain.md §5).
|
||||
*/
|
||||
class PaymentFailed
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Payment $payment) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Payment\Models\Refund;
|
||||
|
||||
/**
|
||||
* Dispatched once a Refund completes successfully — gateways/actions stay
|
||||
* ignorant of Booking, so booking-status changes react to this event
|
||||
* instead (domain.md §6, see MarkBookingRefunded).
|
||||
*/
|
||||
class RefundProcessed
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Refund $refund) {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown by a gateway's handleWebhook() when the inbound payload's signature
|
||||
* doesn't check out — never let an unverified webhook be treated as genuine
|
||||
* (domain.md §6).
|
||||
*/
|
||||
class InvalidWebhookSignatureException extends RuntimeException
|
||||
{
|
||||
public static function forGateway(PaymentMethod $method): self
|
||||
{
|
||||
return new self("Webhook signature verification failed for gateway [{$method->value}].");
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
return response()->json(['message' => $this->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use RuntimeException;
|
||||
|
||||
class PaymentInitiationNotAllowedException extends RuntimeException
|
||||
{
|
||||
public static function notPendingPayment(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot have a payment initiated because its status is [{$booking->status->value}], not pending_payment."
|
||||
);
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown by RefundBookingAction after a failed refund attempt is already
|
||||
* persisted (refunds.status = failed) — the booking is deliberately left
|
||||
* untouched, and the gateway's own message is surfaced to the caller
|
||||
* (domain.md §6).
|
||||
*/
|
||||
class RefundFailedException extends RuntimeException
|
||||
{
|
||||
public static function fromResult(RefundResultData $result): self
|
||||
{
|
||||
return new self($result->message ?? 'Refund failed.');
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use RuntimeException;
|
||||
|
||||
class RefundNotAllowedException extends RuntimeException
|
||||
{
|
||||
public static function notConfirmed(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot be refunded because its status is [{$booking->status->value}], not confirmed."
|
||||
);
|
||||
}
|
||||
|
||||
public static function noCompletedPayment(Booking $booking): self
|
||||
{
|
||||
return new self("Booking [{$booking->booking_ref}] has no completed payment to refund.");
|
||||
}
|
||||
|
||||
public static function exceedsRefundableBalance(Payment $payment, string $amount, string $remaining): self
|
||||
{
|
||||
return new self(
|
||||
"Refund amount [{$amount}] exceeds the remaining refundable balance [{$remaining}] on payment [{$payment->id}]."
|
||||
);
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Payments\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Payment\Filament\Resources\Payments\PaymentResource;
|
||||
|
||||
class ListPayments extends ListRecords
|
||||
{
|
||||
protected static string $resource = PaymentResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — payments are created through the API/webhook
|
||||
// flow (T5.8–T5.10), not hand-entered here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Payments\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Payment\Filament\Resources\Payments\PaymentResource;
|
||||
|
||||
class ViewPayment extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PaymentResource::class;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Payments;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Payment\Filament\Resources\Payments\Pages\ListPayments;
|
||||
use Modules\Payment\Filament\Resources\Payments\Pages\ViewPayment;
|
||||
use Modules\Payment\Filament\Resources\Payments\Schemas\PaymentInfolist;
|
||||
use Modules\Payment\Filament\Resources\Payments\Tables\PaymentsTable;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Read-only by design: payments are created through the API/webhook flow
|
||||
* (T5.8–T5.10), not hand-entered in the admin — so this resource has no
|
||||
* create/edit form, just a list with filters and a detail view.
|
||||
*/
|
||||
class PaymentResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Payment::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBanknotes;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Operations';
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return PaymentsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return PaymentInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPayments::route('/'),
|
||||
'view' => ViewPayment::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Payments\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
class PaymentInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Payment')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('booking.booking_ref')->label('Booking'),
|
||||
TextEntry::make('gateway')->badge(),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (PaymentStatus $state) => match ($state) {
|
||||
PaymentStatus::Pending => 'warning',
|
||||
PaymentStatus::Completed => 'success',
|
||||
PaymentStatus::Failed => 'danger',
|
||||
}),
|
||||
TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID'),
|
||||
TextEntry::make('amount')->numeric(2),
|
||||
TextEntry::make('currency'),
|
||||
TextEntry::make('initiated_at')->dateTime(),
|
||||
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
// Raw gateway response — may include data not meant for the
|
||||
// support role, so it's gated the same as refund initiation
|
||||
// (process_refunds: admin/super_admin only, domain.md §6).
|
||||
Section::make('Gateway Response')
|
||||
->visible(fn () => auth()->user()?->can('process_refunds') ?? false)
|
||||
->schema([
|
||||
TextEntry::make('gateway_payload')
|
||||
->label('')
|
||||
->formatStateUsing(fn (mixed $state) => match (true) {
|
||||
is_array($state) => json_encode($state, JSON_PRETTY_PRINT),
|
||||
is_string($state) && $state !== '' => $state,
|
||||
default => null,
|
||||
})
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Payments\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
class PaymentsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('booking'))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('booking.booking_ref')
|
||||
->label('Booking')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('gateway')
|
||||
->badge(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (PaymentStatus $state) => match ($state) {
|
||||
PaymentStatus::Pending => 'warning',
|
||||
PaymentStatus::Completed => 'success',
|
||||
PaymentStatus::Failed => 'danger',
|
||||
}),
|
||||
TextColumn::make('amount')
|
||||
->numeric(2)
|
||||
->sortable(),
|
||||
TextColumn::make('currency'),
|
||||
TextColumn::make('gateway_transaction_id')
|
||||
->label('Gateway Txn ID')
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('initiated_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('completed_at')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options(array_combine(
|
||||
array_map(fn (PaymentStatus $status) => $status->value, PaymentStatus::cases()),
|
||||
array_map(fn (PaymentStatus $status) => str($status->value)->headline()->toString(), PaymentStatus::cases()),
|
||||
)),
|
||||
SelectFilter::make('gateway')
|
||||
->options(array_combine(
|
||||
array_map(fn (PaymentMethod $method) => $method->value, PaymentMethod::cases()),
|
||||
array_map(fn (PaymentMethod $method) => str($method->value)->headline()->toString(), PaymentMethod::cases()),
|
||||
)),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Refunds\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Modules\Payment\Actions\RefundBookingAction;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Exceptions\RefundFailedException;
|
||||
use Modules\Payment\Exceptions\RefundNotAllowedException;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* Header action on ListRefunds — staff pick a successful Payment (only
|
||||
* Completed ones are offered, domain.md §6) and an amount/reason, which
|
||||
* calls RefundBookingAction the same way the API endpoint does (T5.11).
|
||||
*/
|
||||
class ProcessRefundAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('process')
|
||||
->label('Process Refund')
|
||||
->icon(Heroicon::OutlinedReceiptRefund)
|
||||
->color('danger')
|
||||
->visible(fn (): bool => auth()->user()?->can('process_refunds') ?? false)
|
||||
->schema([
|
||||
Select::make('payment_id')
|
||||
->label('Payment')
|
||||
->options(fn () => Payment::query()
|
||||
->where('status', PaymentStatus::Completed->value)
|
||||
->with('booking')
|
||||
->get()
|
||||
->mapWithKeys(fn (Payment $payment) => [
|
||||
$payment->id => "{$payment->booking?->booking_ref} — {$payment->amount} {$payment->currency} (#{$payment->id})",
|
||||
]))
|
||||
->searchable()
|
||||
->required(),
|
||||
TextInput::make('amount')
|
||||
->numeric()
|
||||
->minValue(0.01)
|
||||
->required(),
|
||||
Textarea::make('reason')
|
||||
->required(),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$payment = Payment::with('booking')->findOrFail($data['payment_id']);
|
||||
|
||||
try {
|
||||
app(RefundBookingAction::class)->handle(
|
||||
$payment->booking,
|
||||
(string) $data['amount'],
|
||||
$data['reason'],
|
||||
auth()->id(),
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Refund processed')
|
||||
->success()
|
||||
->send();
|
||||
} catch (RefundNotAllowedException|RefundFailedException $exception) {
|
||||
Notification::make()
|
||||
->title('Refund failed')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Refunds\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Payment\Filament\Resources\Refunds\Actions\ProcessRefundAction;
|
||||
use Modules\Payment\Filament\Resources\Refunds\RefundResource;
|
||||
|
||||
class ListRefunds extends ListRecords
|
||||
{
|
||||
protected static string $resource = RefundResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — a Refund only ever comes from ProcessRefundAction
|
||||
// calling RefundBookingAction, never a hand-filled create form.
|
||||
return [
|
||||
ProcessRefundAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Refunds;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Payment\Filament\Resources\Refunds\Pages\ListRefunds;
|
||||
use Modules\Payment\Filament\Resources\Refunds\Tables\RefundsTable;
|
||||
use Modules\Payment\Models\Refund;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* List of past refunds, plus a "Process" header action (staff-only) that
|
||||
* initiates a new one against a successful Payment via RefundBookingAction
|
||||
* (T5.11). No create/edit form — a Refund only ever comes from that action,
|
||||
* never hand-entered.
|
||||
*/
|
||||
class RefundResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Refund::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedReceiptRefund;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Operations';
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return RefundsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListRefunds::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Filament\Resources\Refunds\Tables;
|
||||
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
|
||||
class RefundsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with(['payment.booking', 'requestedBy']))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('payment.booking.booking_ref')
|
||||
->label('Booking')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('payment.gateway')
|
||||
->label('Gateway')
|
||||
->badge(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (RefundStatus $state) => match ($state) {
|
||||
RefundStatus::Pending => 'warning',
|
||||
RefundStatus::Completed => 'success',
|
||||
RefundStatus::Failed => 'danger',
|
||||
}),
|
||||
TextColumn::make('amount')
|
||||
->numeric(2)
|
||||
->sortable(),
|
||||
TextColumn::make('reason')
|
||||
->limit(40)
|
||||
->tooltip(fn (?string $state) => $state),
|
||||
TextColumn::make('gateway_refund_id')
|
||||
->label('Gateway Refund ID')
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('requestedBy.name')
|
||||
->label('Requested By')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('requested_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('completed_at')
|
||||
->dateTime()
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options(array_combine(
|
||||
array_map(fn (RefundStatus $status) => $status->value, RefundStatus::cases()),
|
||||
array_map(fn (RefundStatus $status) => str($status->value)->headline()->toString(), RefundStatus::cases()),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Exceptions\InvalidWebhookSignatureException;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
/**
|
||||
@@ -157,6 +159,46 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* bnf_event's equivalent (`OrderController::paymentComplete` /
|
||||
* `KBZMiniApp::save`) trusted the raw `trade_status` from the POST body
|
||||
* and only re-verified via `queryorder` afterward — it never checked
|
||||
* `sign` on the inbound payload at all. This closes that gap: KBZ signs
|
||||
* webhook notifications with the same scheme as our outbound calls
|
||||
* (confirmed against KBZ's "6 Callback Interface" spec), so the
|
||||
* signature is checked first, before any of the payload is trusted.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
/** @var array<string, mixed> $notification */
|
||||
$notification = (array) ($payload['Request'] ?? []);
|
||||
|
||||
if (! $this->hasValidSignature($notification)) {
|
||||
throw InvalidWebhookSignatureException::forGateway(PaymentMethod::KbzMiniApp);
|
||||
}
|
||||
|
||||
return new PaymentResultData(
|
||||
status: $this->mapTradeStatus($notification['trade_status'] ?? null),
|
||||
gatewayTransactionId: $notification['merch_order_id'] ?? null,
|
||||
gatewayPayload: $notification,
|
||||
message: $notification['trade_status'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $notification
|
||||
*/
|
||||
private function hasValidSignature(array $notification): bool
|
||||
{
|
||||
if (! isset($notification['sign']) || ! is_string($notification['sign']) || $this->merchantKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(KbzSignature::sign($notification, $this->merchantKey), strtoupper($notification['sign']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Actions\InitiatePaymentAction;
|
||||
use Modules\Payment\Http\Resources\PaymentResource;
|
||||
|
||||
class PaymentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private InitiatePaymentAction $initiatePaymentAction,
|
||||
) {}
|
||||
|
||||
public function initiate(Booking $booking): JsonResponse
|
||||
{
|
||||
Gate::authorize('pay', $booking);
|
||||
|
||||
$payment = $this->initiatePaymentAction->handle($booking);
|
||||
|
||||
return (new PaymentResource($payment))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Payment\Actions\ConfirmPaymentAction;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Exceptions\InvalidWebhookSignatureException;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
|
||||
/**
|
||||
* One inbound webhook route for every gateway, routed by PaymentMethod and
|
||||
* resolved through PaymentGatewayFactory (T5.6) — mirrors bnf_event's
|
||||
* `OrderController::paymentComplete`/`{method}` dispatch, but through the
|
||||
* factory instead of a switch, so adding a gateway needs no controller
|
||||
* change (domain.md §6).
|
||||
*/
|
||||
class PaymentWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PaymentGatewayFactory $gateways,
|
||||
private ConfirmPaymentAction $confirmPaymentAction,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, PaymentMethod $method, ?string $encryptBookingId = null): Response
|
||||
{
|
||||
$payload = $request->all();
|
||||
|
||||
// Optional, mirrors bnf_event's `{encryptOrderId?}` — a redundant,
|
||||
// signature-independent way to locate the booking directly from the
|
||||
// URL (used by ConfirmPaymentAction, T5.10) alongside whatever
|
||||
// order id the gateway's own signed payload carries. Never fatal if
|
||||
// it's missing or fails to decrypt; the signature check is what
|
||||
// actually authenticates this request.
|
||||
$bookingId = $this->decryptBookingId($encryptBookingId);
|
||||
|
||||
try {
|
||||
$result = $this->gateways->make($method)->handleWebhook($payload);
|
||||
} catch (InvalidWebhookSignatureException $exception) {
|
||||
// Raw payload persisted regardless of outcome (domain.md §6).
|
||||
Log::warning('Payment webhook rejected: invalid signature', [
|
||||
'gateway' => $method->value,
|
||||
'booking_id' => $bookingId,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
Log::info('Payment webhook received', [
|
||||
'gateway' => $method->value,
|
||||
'booking_id' => $bookingId,
|
||||
'status' => $result->status->value,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
$payment = $result->gatewayTransactionId !== null
|
||||
? $this->confirmPaymentAction->handle($method, $result->gatewayTransactionId)
|
||||
: null;
|
||||
|
||||
if ($payment === null) {
|
||||
Log::warning('Payment webhook has no matching payment to confirm', [
|
||||
'gateway' => $method->value,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId,
|
||||
]);
|
||||
}
|
||||
|
||||
// KBZ retries any delivery that doesn't get back this exact literal
|
||||
// body — we acknowledge regardless of whether a matching payment
|
||||
// was found, since retrying won't fix that mismatch.
|
||||
return response('success', 200);
|
||||
}
|
||||
|
||||
private function decryptBookingId(?string $encryptBookingId): ?int
|
||||
{
|
||||
if ($encryptBookingId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (int) Crypt::decryptString($encryptBookingId);
|
||||
} catch (DecryptException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Actions\RefundBookingAction;
|
||||
use Modules\Payment\Http\Requests\RefundBookingRequest;
|
||||
use Modules\Payment\Http\Resources\RefundResource;
|
||||
|
||||
class RefundController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private RefundBookingAction $refundBookingAction,
|
||||
) {}
|
||||
|
||||
public function refund(RefundBookingRequest $request, Booking $booking): JsonResponse
|
||||
{
|
||||
Gate::authorize('refund', $booking);
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$refund = $this->refundBookingAction->handle(
|
||||
$booking,
|
||||
(string) $validated['amount'],
|
||||
$validated['reason'],
|
||||
$request->user()?->id,
|
||||
);
|
||||
|
||||
return (new RefundResource($refund))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Shape validation only — the refundable-balance check and completed-payment
|
||||
* lookup stay in RefundBookingAction, not here.
|
||||
*/
|
||||
class RefundBookingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'gt:0'],
|
||||
'reason' => ['required', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PaymentResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'booking_id' => $this->booking_id,
|
||||
'gateway' => $this->gateway,
|
||||
'status' => $this->status,
|
||||
'amount' => $this->amount,
|
||||
'currency' => $this->currency,
|
||||
'gateway_transaction_id' => $this->gateway_transaction_id,
|
||||
// The gateway's raw response — the client needs this to render the
|
||||
// KBZ Mini App payment sheet (e.g. prepay_id).
|
||||
'gateway_payload' => $this->gateway_payload,
|
||||
'initiated_at' => $this->initiated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class RefundResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'payment_id' => $this->payment_id,
|
||||
'status' => $this->status,
|
||||
'amount' => $this->amount,
|
||||
'reason' => $this->reason,
|
||||
'gateway_refund_id' => $this->gateway_refund_id,
|
||||
'requested_by' => $this->requested_by,
|
||||
'requested_at' => $this->requested_at,
|
||||
'completed_at' => $this->completed_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Listeners;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Payment\Events\PaymentCompleted;
|
||||
|
||||
/**
|
||||
* Flips the Booking to confirmed once its Payment is confirmed successful
|
||||
* (domain.md §5). Queued — ConfirmPaymentAction's own idempotency check is
|
||||
* what actually prevents this firing twice for the same Payment; the guard
|
||||
* here is just to never clobber a booking that moved on for another reason.
|
||||
*/
|
||||
class MarkBookingPaid implements ShouldQueue
|
||||
{
|
||||
public function handle(PaymentCompleted $event): void
|
||||
{
|
||||
$booking = $event->payment->booking;
|
||||
|
||||
if ($booking->status === BookingStatus::PendingPayment) {
|
||||
$booking->update(['status' => BookingStatus::Confirmed]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Listeners;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Payment\Events\RefundProcessed;
|
||||
|
||||
/**
|
||||
* Flips the Booking to cancelled once a Refund against one of its Payments
|
||||
* completes (domain.md §5). Guarded the same way as MarkBookingPaid — only
|
||||
* ever moves a still-confirmed booking, never clobbers one that moved on
|
||||
* for another reason.
|
||||
*/
|
||||
class MarkBookingRefunded implements ShouldQueue
|
||||
{
|
||||
public function handle(RefundProcessed $event): void
|
||||
{
|
||||
$booking = $event->refund->payment->booking;
|
||||
|
||||
if ($booking->status === BookingStatus::Confirmed) {
|
||||
$booking->update(['status' => BookingStatus::Cancelled]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class PaymentPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'payment';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel
|
||||
->discoverResources(
|
||||
in: __DIR__.'/Filament/Resources',
|
||||
for: 'Modules\Payment\Filament\Resources',
|
||||
)
|
||||
->discoverPages(
|
||||
in: __DIR__.'/Filament/Pages',
|
||||
for: 'Modules\Payment\Filament\Pages',
|
||||
)
|
||||
->discoverWidgets(
|
||||
in: __DIR__.'/Filament/Widgets',
|
||||
for: 'Modules\Payment\Filament\Widgets',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
namespace Modules\Payment\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Events\PaymentCompleted;
|
||||
use Modules\Payment\Events\RefundProcessed;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Listeners\MarkBookingPaid;
|
||||
use Modules\Payment\Listeners\MarkBookingRefunded;
|
||||
|
||||
class PaymentServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -19,5 +24,9 @@ class PaymentServiceProvider extends ServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void {}
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(PaymentCompleted::class, MarkBookingPaid::class);
|
||||
Event::listen(RefundProcessed::class, MarkBookingRefunded::class);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user