Merge branch 'feature/payment-module-phase5'
This commit is contained in:
@@ -51,6 +51,7 @@ BOOKING_BACK_SEAT_ENABLED=
|
||||
BOOKING_WHOLE_VEHICLE_ENABLED=
|
||||
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=
|
||||
|
||||
KBZ_APP_ID=
|
||||
KBZ_MERCHANT_CODE=
|
||||
KBZ_MERCHANT_KEY=
|
||||
KBZ_BASE_URL=
|
||||
|
||||
@@ -164,6 +164,11 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
- For binaries not wrapped by Sail's own commands (e.g. Pint), run them inside the container: `./vendor/bin/sail exec laravel.test vendor/bin/pint --dirty --format agent`.
|
||||
- Check containers are up first with `./vendor/bin/sail ps` before running commands; start them with `./vendor/bin/sail up -d` if they aren't.
|
||||
|
||||
## Database / Migrations
|
||||
|
||||
- **Never run `php artisan migrate:fresh`, `migrate:refresh`, `migrate:reset`, or `db:wipe` against the dev database unless the user explicitly asks for it in that turn.** These drop/recreate all tables and destroy dev data. Use `php artisan migrate` (apply pending) and `php artisan migrate:rollback` (undo the last batch) instead for normal migration work.
|
||||
- Dev data loss happened once (2026-08-08 ~22:05 local) from exactly this kind of command — do not repeat it.
|
||||
|
||||
## Architecture / ERD Diagram
|
||||
|
||||
- The canonical tldraw board for this project's architecture and ERD lives at `/home/marcspecta/Documents/EV Booking System Architecture.tldraw` (outside the repo — not committed). Use this path when opening/updating the board with the tldraw-offline skill/agent.
|
||||
|
||||
@@ -5,17 +5,37 @@ namespace Modules\Booking\Actions;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Actions\RefundBookingAction;
|
||||
|
||||
/**
|
||||
* Unpaid path only — a pending_payment booking has no money moved yet, so
|
||||
* it can be cancelled directly. A confirmed (paid) booking must go through
|
||||
* a refund first; this action explicitly guards against bypassing that
|
||||
* (domain.md §5). Wired into that refund path in T5.12.
|
||||
* A pending_payment booking has no money moved yet, so it cancels directly.
|
||||
* A confirmed (paid) booking is cancelled by refunding it in full first —
|
||||
* delegates to RefundBookingAction (Payment module); the booking only
|
||||
* actually flips to cancelled once that refund succeeds, via
|
||||
* RefundProcessed/MarkBookingRefunded, not here (domain.md §5). Any other
|
||||
* status (already cancelled/expired) is rejected outright.
|
||||
*/
|
||||
class CancelBookingAction
|
||||
{
|
||||
public function handle(Booking $booking): Booking
|
||||
private const CANCELLATION_REFUND_REASON = 'Booking cancellation';
|
||||
|
||||
public function __construct(
|
||||
private RefundBookingAction $refundBookingAction,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, ?int $requestedBy = null): Booking
|
||||
{
|
||||
if ($booking->status === BookingStatus::Confirmed) {
|
||||
$this->refundBookingAction->handle(
|
||||
$booking,
|
||||
(string) $booking->price,
|
||||
self::CANCELLATION_REFUND_REASON,
|
||||
$requestedBy,
|
||||
);
|
||||
|
||||
return $booking->refresh();
|
||||
}
|
||||
|
||||
if ($booking->status !== BookingStatus::PendingPayment) {
|
||||
throw BookingCannotBeCancelledException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
@@ -4,19 +4,20 @@ namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use RuntimeException;
|
||||
|
||||
class BookingCannotBeCancelledException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* Confirmed bookings no longer reach this — CancelBookingAction (T5.12)
|
||||
* refunds them instead. This is only for statuses that can't be
|
||||
* cancelled at all (already cancelled/expired).
|
||||
*/
|
||||
public static function notPendingPayment(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot be cancelled directly because its status is [{$booking->status->value}]."
|
||||
.($booking->status === BookingStatus::Confirmed
|
||||
? ' A confirmed (paid) booking must go through a refund first.'
|
||||
: '')
|
||||
"Booking [{$booking->booking_ref}] cannot be cancelled because its status is [{$booking->status->value}]."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
class BookingInfolist
|
||||
{
|
||||
@@ -92,6 +93,34 @@ class BookingInfolist
|
||||
TextEntry::make('car_model')->label('Car Model')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
// A booking can have more than one payment attempt if an
|
||||
// earlier one failed and the customer retried (domain.md §1)
|
||||
// — full detail (gateway response, refunds) lives on the
|
||||
// Payment/Refund Filament resources (T5.13), this is just a
|
||||
// quick-glance summary from the booking side.
|
||||
Section::make('Payments')
|
||||
->schema([
|
||||
RepeatableEntry::make('payments')
|
||||
->label('')
|
||||
->schema([
|
||||
Grid::make(6)
|
||||
->schema([
|
||||
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('amount')->numeric(2),
|
||||
TextEntry::make('currency'),
|
||||
TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID')->placeholder('—'),
|
||||
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
||||
]),
|
||||
])
|
||||
->placeholder('No payment attempts yet.'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +88,11 @@ class BookingController extends Controller
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function cancel(Booking $booking): BookingResource
|
||||
public function cancel(Request $request, Booking $booking): BookingResource
|
||||
{
|
||||
Gate::authorize('cancel', $booking);
|
||||
|
||||
$this->cancelBookingAction->handle($booking);
|
||||
$this->cancelBookingAction->handle($booking, $request->user()?->id);
|
||||
|
||||
return new BookingResource($booking->load(self::EAGER_LOADS));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use Modules\Booking\Database\Factories\BookingFactory;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
class Booking extends Model
|
||||
@@ -85,4 +86,9 @@ class Booking extends Model
|
||||
{
|
||||
return $this->hasMany(BookingVehicleOption::class);
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payment::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Booking\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class BookingPolicy
|
||||
@@ -33,12 +34,18 @@ class BookingPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking's owner may cancel their own (still pending_payment only —
|
||||
* enforced by CancelBookingAction, not here); staff can cancel any
|
||||
* booking via manage_bookings (domain.md §8).
|
||||
* A booking's owner may cancel their own pending_payment booking; staff
|
||||
* can cancel any pending_payment booking via manage_bookings. Cancelling
|
||||
* a confirmed (paid) booking refunds it (CancelBookingAction, T5.12) —
|
||||
* that's the same authorization boundary as refund(), staff only
|
||||
* (domain.md §8: refund initiation is a staff-only operation).
|
||||
*/
|
||||
public function cancel(User $user, Booking $booking): bool
|
||||
{
|
||||
if ($booking->status === BookingStatus::Confirmed) {
|
||||
return $user->can('process_refunds');
|
||||
}
|
||||
|
||||
return $user->id === $booking->user_id || $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
@@ -46,4 +53,14 @@ class BookingPolicy
|
||||
{
|
||||
return $user->can('process_refunds');
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking's owner may pay for their own (still pending_payment only —
|
||||
* enforced by InitiatePaymentAction, not here); staff can initiate on
|
||||
* behalf of a customer via manage_bookings.
|
||||
*/
|
||||
public function pay(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->id === $booking->user_id || $user->can('manage_bookings');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,48 @@
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
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\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests.
|
||||
*/
|
||||
class FakeCancelApiRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Permission::findOrCreate('manage_bookings', 'web');
|
||||
foreach (['manage_bookings', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelApiRefundGateway::class);
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
@@ -23,10 +61,43 @@ test('the owner can cancel their own pending_payment booking', function () {
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('cancelling a confirmed booking surfaces as 422 and leaves it untouched', function () {
|
||||
test('the owner cannot cancel their own confirmed booking without process_refunds', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertForbidden();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('staff with process_refunds can cancel a confirmed booking, which refunds it in full', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-CANCEL-API-1',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.status', BookingStatus::Cancelled->value);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('cancelling a confirmed booking with no completed payment surfaces as 422 and leaves it untouched', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertStatus(422);
|
||||
|
||||
|
||||
@@ -7,15 +7,18 @@ use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['view_bookings', 'manage_bookings'] as $permission) {
|
||||
foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings']);
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings', 'process_refunds']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
@@ -114,6 +117,29 @@ test('can view a booking\'s detail page', function () {
|
||||
->assertSee($booking->dropoff_address);
|
||||
});
|
||||
|
||||
test('the booking detail page shows its related payments', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-INFOLIST-TEST-1',
|
||||
]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee('EVB-INFOLIST-TEST-1')
|
||||
->assertSee(PaymentStatus::Completed->value);
|
||||
});
|
||||
|
||||
test('the booking detail page shows a placeholder when there are no payments yet', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee('No payment attempts yet.');
|
||||
});
|
||||
|
||||
test('the assign driver action is visible for a confirmed booking and hidden otherwise', function () {
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
@@ -4,35 +4,84 @@ use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
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\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
test('it cancels a pending_payment booking', function () {
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests.
|
||||
*/
|
||||
class FakeCancelRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static ?string $lastAmount = null;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
self::$lastAmount = $amount;
|
||||
|
||||
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
FakeCancelRefundGateway::$lastAmount = null;
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelRefundGateway::class);
|
||||
});
|
||||
|
||||
test('it cancels a pending_payment booking directly', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$cancelled = (new CancelBookingAction)->handle($booking);
|
||||
$cancelled = app(CancelBookingAction::class)->handle($booking);
|
||||
|
||||
expect($cancelled->status)->toBe(BookingStatus::Cancelled)
|
||||
->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('it guards against cancelling a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
test('it cancels a confirmed booking by refunding it in full', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-CANCEL-TEST-1',
|
||||
]);
|
||||
|
||||
expect(fn () => (new CancelBookingAction)->handle($booking))
|
||||
->toThrow(BookingCannotBeCancelledException::class);
|
||||
$cancelled = app(CancelBookingAction::class)->handle($booking);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
expect($cancelled->status)->toBe(BookingStatus::Cancelled)
|
||||
->and(FakeCancelRefundGateway::$lastAmount)->toBe('15000.00');
|
||||
});
|
||||
|
||||
test('it guards against cancelling an already cancelled booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect(fn () => (new CancelBookingAction)->handle($booking))
|
||||
expect(fn () => app(CancelBookingAction::class)->handle($booking))
|
||||
->toThrow(BookingCannotBeCancelledException::class);
|
||||
});
|
||||
|
||||
test('it guards against cancelling an expired booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
expect(fn () => (new CancelBookingAction)->handle($booking))
|
||||
expect(fn () => app(CancelBookingAction::class)->handle($booking))
|
||||
->toThrow(BookingCannotBeCancelledException::class);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* @extends Factory<Payment>
|
||||
*/
|
||||
class PaymentFactory extends Factory
|
||||
{
|
||||
protected $model = Payment::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'booking_id' => Booking::factory(),
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'status' => PaymentStatus::Pending,
|
||||
'amount' => $this->faker->randomFloat(2, 5000, 50000),
|
||||
'currency' => 'MMK',
|
||||
'gateway_transaction_id' => Str::uuid()->toString(),
|
||||
'gateway_payload' => null,
|
||||
'initiated_at' => now(),
|
||||
'completed_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => PaymentStatus::Completed,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => PaymentStatus::Failed,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
|
||||
/**
|
||||
* @extends Factory<Refund>
|
||||
*/
|
||||
class RefundFactory extends Factory
|
||||
{
|
||||
protected $model = Refund::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'payment_id' => Payment::factory()->completed(),
|
||||
'status' => RefundStatus::Pending,
|
||||
'amount' => $this->faker->randomFloat(2, 1000, 50000),
|
||||
'reason' => $this->faker->sentence(),
|
||||
'gateway_refund_id' => null,
|
||||
'gateway_payload' => null,
|
||||
'requested_by' => null,
|
||||
'requested_at' => now(),
|
||||
'completed_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => RefundStatus::Completed,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function failed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => RefundStatus::Failed,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per payment attempt against a Booking — a Booking can have
|
||||
* more than one row here if an earlier attempt failed and the customer
|
||||
* retried (domain.md §1, §6).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('booking_id')->constrained('bookings')->cascadeOnDelete();
|
||||
$table->string('gateway');
|
||||
$table->string('status')->default('pending');
|
||||
$table->decimal('amount', 10, 2);
|
||||
$table->string('currency')->default('MMK');
|
||||
$table->string('gateway_transaction_id')->nullable()->index();
|
||||
$table->jsonb('gateway_payload')->nullable();
|
||||
$table->timestamp('initiated_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['booking_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('payments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* A Refund reverses a specific successful Payment, not the Booking
|
||||
* directly — a Payment can have more than one Refund row for partial
|
||||
* refunds (domain.md §1, §6).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('refunds', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('payment_id')->constrained('payments')->cascadeOnDelete();
|
||||
$table->string('status')->default('pending');
|
||||
$table->decimal('amount', 10, 2);
|
||||
$table->text('reason');
|
||||
$table->string('gateway_refund_id')->nullable()->index();
|
||||
$table->jsonb('gateway_payload')->nullable();
|
||||
$table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('requested_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['payment_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('refunds');
|
||||
}
|
||||
};
|
||||
@@ -1 +1,18 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Payment\Http\Controllers\PaymentController;
|
||||
use Modules\Payment\Http\Controllers\PaymentWebhookController;
|
||||
use Modules\Payment\Http\Controllers\RefundController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () {
|
||||
Route::post('/payments/{booking:booking_ref}/initiate', [PaymentController::class, 'initiate'])->name('payment.payments.initiate');
|
||||
Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund');
|
||||
});
|
||||
|
||||
// No auth:sanctum — the gateway authenticates itself via its own signed
|
||||
// payload (verified inside each gateway's handleWebhook()), not a bearer
|
||||
// token (domain.md §6).
|
||||
Route::prefix('api/v1')->middleware(['api', 'throttle:60,1'])->group(function () {
|
||||
Route::post('/webhooks/{method}/{encryptBookingId?}', [PaymentWebhookController::class, 'handle'])->name('payment.webhooks.handle');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
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).
|
||||
*
|
||||
* Strategies are deliberately ignorant of Payment/Booking Eloquent models —
|
||||
* they only take/return DTOs, so booking-status changes stay in listeners
|
||||
* reacting to PaymentCompleted/PaymentFailed/RefundProcessed (domain.md §6).
|
||||
*/
|
||||
interface PaymentGatewayInterface
|
||||
{
|
||||
/**
|
||||
* Start a payment attempt with the gateway.
|
||||
*/
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData;
|
||||
|
||||
/**
|
||||
* Re-check a payment's current status with the gateway (defense-in-depth
|
||||
* re-verification, and used inside webhook processing — domain.md §6).
|
||||
*/
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData;
|
||||
|
||||
/**
|
||||
* 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,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Data;
|
||||
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
|
||||
/**
|
||||
* What a gateway needs to start a payment (PaymentGatewayInterface::initiate).
|
||||
*/
|
||||
readonly class PaymentRequestData
|
||||
{
|
||||
public function __construct(
|
||||
public int $bookingId,
|
||||
public string $merchantOrderId,
|
||||
public string $amount,
|
||||
public string $currency,
|
||||
public PaymentMethod $method,
|
||||
public ?string $notifyUrl = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Data;
|
||||
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
/**
|
||||
* What a gateway hands back from initiate()/verify() (PaymentGatewayInterface).
|
||||
*/
|
||||
readonly class PaymentResultData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $gatewayPayload Raw gateway response, persisted verbatim for audit.
|
||||
*/
|
||||
public function __construct(
|
||||
public PaymentStatus $status,
|
||||
public ?string $gatewayTransactionId,
|
||||
public array $gatewayPayload,
|
||||
public ?string $message = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Data;
|
||||
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
|
||||
/**
|
||||
* What a gateway hands back from refund() (PaymentGatewayInterface).
|
||||
*/
|
||||
readonly class RefundResultData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $gatewayPayload Raw gateway response, persisted verbatim for audit.
|
||||
*/
|
||||
public function __construct(
|
||||
public RefundStatus $status,
|
||||
public ?string $gatewayRefundId,
|
||||
public array $gatewayPayload,
|
||||
public ?string $message = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Enums;
|
||||
|
||||
/**
|
||||
* Which gateway a Payment is processed through.
|
||||
*
|
||||
* Only KBZ Mini App is supported in v1 — the enum exists so
|
||||
* PaymentGatewayFactory can resolve additional gateways later
|
||||
* without call-site changes (domain.md §6, §7).
|
||||
*/
|
||||
enum PaymentMethod: string
|
||||
{
|
||||
case KbzMiniApp = 'kbz_mini_app';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Enums;
|
||||
|
||||
enum PaymentStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Enums;
|
||||
|
||||
enum RefundStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@@ -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,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Factories;
|
||||
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Resolves a PaymentGatewayInterface implementation by PaymentMethod.
|
||||
*
|
||||
* Replaces bnf_event's 3x duplicated `switch($payment_type)` at each call
|
||||
* site (domain.md §6). Call sites depend only on this factory, never on a
|
||||
* concrete gateway class — swapping/adding a gateway is a `register()` call
|
||||
* here, no controller/action changes.
|
||||
*/
|
||||
class PaymentGatewayFactory
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<PaymentGatewayInterface>>
|
||||
*/
|
||||
private array $bindings = [];
|
||||
|
||||
/**
|
||||
* @param class-string<PaymentGatewayInterface> $gatewayClass
|
||||
*/
|
||||
public function register(PaymentMethod $method, string $gatewayClass): void
|
||||
{
|
||||
$this->bindings[$method->value] = $gatewayClass;
|
||||
}
|
||||
|
||||
public function make(PaymentMethod $method): PaymentGatewayInterface
|
||||
{
|
||||
$gatewayClass = $this->bindings[$method->value]
|
||||
?? throw new RuntimeException("No payment gateway registered for method [{$method->value}].");
|
||||
|
||||
return app($gatewayClass);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Gateways;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
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;
|
||||
|
||||
/**
|
||||
* KBZ Mini App gateway strategy, ported from bnf_event's
|
||||
* `App\Strategies\Payments\KBZMiniApp`/`KBZPay` (domain.md §6).
|
||||
*
|
||||
* Reads credentials from config('services.kbz') by default; a config array
|
||||
* may be injected directly (used by tests / the factory).
|
||||
*/
|
||||
class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
{
|
||||
private readonly string $appId;
|
||||
|
||||
private readonly string $merchantCode;
|
||||
|
||||
private readonly string $merchantKey;
|
||||
|
||||
private readonly string $baseUrl;
|
||||
|
||||
private readonly ?string $notifyUrl;
|
||||
|
||||
private readonly ?string $certPath;
|
||||
|
||||
private readonly ?string $certKeyPath;
|
||||
|
||||
private readonly ?string $caPath;
|
||||
|
||||
private readonly ?string $certPassword;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $config
|
||||
*/
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$config ??= (array) config('services.kbz');
|
||||
|
||||
$this->appId = (string) ($config['app_id'] ?? '');
|
||||
$this->merchantCode = (string) ($config['merchant_code'] ?? '');
|
||||
$this->merchantKey = (string) ($config['merchant_key'] ?? '');
|
||||
$this->baseUrl = (string) ($config['base_url'] ?? '');
|
||||
$this->notifyUrl = $config['notify_url'] ?? null;
|
||||
$this->certPath = $config['cert_path'] ?? null;
|
||||
$this->certKeyPath = $config['cert_key_path'] ?? null;
|
||||
$this->caPath = $config['ca_path'] ?? null;
|
||||
$this->certPassword = $config['cert_password'] ?? null;
|
||||
}
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
$params = $this->buildPrecreateParams($data);
|
||||
|
||||
try {
|
||||
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]);
|
||||
} catch (ConnectionException $exception) {
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: null,
|
||||
gatewayPayload: [],
|
||||
message: $exception->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $body */
|
||||
$body = $response->json('Response', []);
|
||||
|
||||
if (! $response->successful() || ($body['result'] ?? null) !== 'SUCCESS') {
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: $body['prepay_id'] ?? null,
|
||||
gatewayPayload: $body,
|
||||
message: $body['msg'] ?? 'KBZ precreate failed.',
|
||||
);
|
||||
}
|
||||
|
||||
return new PaymentResultData(
|
||||
// KBZ's queryorder/refund calls both key off our own merch_order_id,
|
||||
// not their prepay_id — so that's what gets stored/passed forward as
|
||||
// the gateway transaction id (prepay_id still lives in the payload).
|
||||
status: PaymentStatus::Pending,
|
||||
gatewayTransactionId: $data->merchantOrderId,
|
||||
gatewayPayload: $body,
|
||||
);
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
$params = $this->buildQueryOrderParams($gatewayTransactionId);
|
||||
|
||||
try {
|
||||
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]);
|
||||
} catch (ConnectionException $exception) {
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: $gatewayTransactionId,
|
||||
gatewayPayload: [],
|
||||
message: $exception->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $body */
|
||||
$body = $response->json('Response', []);
|
||||
|
||||
return new PaymentResultData(
|
||||
status: $this->mapTradeStatus($body['trade_status'] ?? null),
|
||||
gatewayTransactionId: $gatewayTransactionId,
|
||||
gatewayPayload: $body,
|
||||
message: $body['trade_status'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
$params = $this->buildRefundParams($gatewayTransactionId, $amount, $reason);
|
||||
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->withOptions($this->mtlsOptions())
|
||||
->post($this->baseUrl, ['Request' => $params]);
|
||||
} catch (ConnectionException $exception) {
|
||||
return new RefundResultData(
|
||||
status: RefundStatus::Failed,
|
||||
gatewayRefundId: null,
|
||||
gatewayPayload: [],
|
||||
message: $exception->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $body */
|
||||
$body = $response->json('Response', []);
|
||||
|
||||
if (! $response->successful() || ($body['result'] ?? null) !== 'SUCCESS') {
|
||||
return new RefundResultData(
|
||||
status: RefundStatus::Failed,
|
||||
gatewayRefundId: $body['refund_order_id'] ?? null,
|
||||
gatewayPayload: $body,
|
||||
message: $body['msg'] ?? 'KBZ refund failed.',
|
||||
);
|
||||
}
|
||||
|
||||
return new RefundResultData(
|
||||
status: RefundStatus::Completed,
|
||||
gatewayRefundId: $body['refund_order_id'] ?? null,
|
||||
gatewayPayload: $body,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
private function buildPrecreateParams(PaymentRequestData $data): array
|
||||
{
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.precreate',
|
||||
'notify_url' => $data->notifyUrl ?? $this->notifyUrl,
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
'merch_code' => $this->merchantCode,
|
||||
'merch_order_id' => $data->merchantOrderId,
|
||||
'trade_type' => 'MINIAPP',
|
||||
'total_amount' => $data->amount,
|
||||
'trans_currency' => $data->currency,
|
||||
'callback_info' => 'urlencode',
|
||||
],
|
||||
];
|
||||
|
||||
$params['sign'] = KbzSignature::sign($params, $this->merchantKey);
|
||||
$params['sign_type'] = 'SHA256';
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildQueryOrderParams(string $merchantOrderId): array
|
||||
{
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.queryorder',
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
'merch_code' => $this->merchantCode,
|
||||
'merch_order_id' => $merchantOrderId,
|
||||
],
|
||||
];
|
||||
|
||||
$params['sign'] = KbzSignature::sign($params, $this->merchantKey);
|
||||
$params['sign_type'] = 'SHA256';
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* KBZ's queryorder trade_status values — mapped conservatively: anything
|
||||
* not explicitly a success/pending state is treated as failed rather
|
||||
* than silently left as an unhandled status (domain.md §6).
|
||||
*/
|
||||
private function mapTradeStatus(?string $tradeStatus): PaymentStatus
|
||||
{
|
||||
return match ($tradeStatus) {
|
||||
'PAY_SUCCESS' => PaymentStatus::Completed,
|
||||
'WAIT_PAY', 'USERPAYING' => PaymentStatus::Pending,
|
||||
default => PaymentStatus::Failed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildRefundParams(string $merchantOrderId, string $amount, string $reason): array
|
||||
{
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.refund',
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
'merch_code' => $this->merchantCode,
|
||||
'merch_order_id' => $merchantOrderId,
|
||||
'refund_request_no' => $this->refundRequestNo(),
|
||||
// Unlike bnf_event (refund_amount was commented out, full-refund
|
||||
// only), this is wired through to support partial refunds —
|
||||
// domain.md §6.
|
||||
'refund_amount' => $amount,
|
||||
'refund_reason' => $reason,
|
||||
],
|
||||
];
|
||||
|
||||
$params['sign'] = KbzSignature::sign($params, $this->merchantKey);
|
||||
$params['sign_type'] = 'SHA256';
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
private function refundRequestNo(): string
|
||||
{
|
||||
return now()->format('YmdHi').strtoupper(Str::random(8));
|
||||
}
|
||||
|
||||
/**
|
||||
* mTLS options for the refund call — KBZ requires a client cert/key +
|
||||
* CA bundle on `kbz.payment.refund` specifically (domain.md §6).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mtlsOptions(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
if ($this->certPath !== null) {
|
||||
$options['cert'] = $this->certPassword !== null
|
||||
? [$this->certPath, $this->certPassword]
|
||||
: $this->certPath;
|
||||
}
|
||||
|
||||
if ($this->certKeyPath !== null) {
|
||||
$options['ssl_key'] = $this->certPassword !== null
|
||||
? [$this->certKeyPath, $this->certPassword]
|
||||
: $this->certKeyPath;
|
||||
}
|
||||
|
||||
if ($this->caPath !== null) {
|
||||
$options['verify'] = $this->caPath;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -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,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Database\Factories\PaymentFactory;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
/**
|
||||
* One attempt to pay for a Booking through a gateway — a Booking can have
|
||||
* more than one Payment row if an earlier attempt failed and the customer
|
||||
* retried (domain.md §1).
|
||||
*/
|
||||
class Payment extends Model
|
||||
{
|
||||
/** @use HasFactory<PaymentFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'booking_id',
|
||||
'gateway',
|
||||
'status',
|
||||
'amount',
|
||||
'currency',
|
||||
'gateway_transaction_id',
|
||||
'gateway_payload',
|
||||
'initiated_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'gateway' => PaymentMethod::class,
|
||||
'status' => PaymentStatus::class,
|
||||
'amount' => 'decimal:2',
|
||||
'gateway_payload' => 'array',
|
||||
'initiated_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function booking(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Booking::class);
|
||||
}
|
||||
|
||||
public function refunds(): HasMany
|
||||
{
|
||||
return $this->hasMany(Refund::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Payment\Database\Factories\RefundFactory;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
|
||||
/**
|
||||
* A reversal against a specific successful Payment (not against the Booking
|
||||
* directly) — a Payment can have more than one Refund row for partial
|
||||
* refunds (domain.md §1, §6).
|
||||
*/
|
||||
class Refund extends Model
|
||||
{
|
||||
/** @use HasFactory<RefundFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'payment_id',
|
||||
'status',
|
||||
'amount',
|
||||
'reason',
|
||||
'gateway_refund_id',
|
||||
'gateway_payload',
|
||||
'requested_by',
|
||||
'requested_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => RefundStatus::class,
|
||||
'amount' => 'decimal:2',
|
||||
'gateway_payload' => 'array',
|
||||
'requested_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function payment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Payment::class);
|
||||
}
|
||||
|
||||
public function requestedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requested_by');
|
||||
}
|
||||
}
|
||||
@@ -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,11 +2,31 @@
|
||||
|
||||
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
|
||||
{
|
||||
public function register(): void {}
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(PaymentGatewayFactory::class, function (): PaymentGatewayFactory {
|
||||
$factory = new PaymentGatewayFactory;
|
||||
$factory->register(PaymentMethod::KbzMiniApp, KbzMiniAppGateway::class);
|
||||
|
||||
public function boot(): void {}
|
||||
return $factory;
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(PaymentCompleted::class, MarkBookingPaid::class);
|
||||
Event::listen(RefundProcessed::class, MarkBookingRefunded::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Services;
|
||||
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
|
||||
/**
|
||||
* Thin orchestrator delegating to the gateway resolved by
|
||||
* PaymentGatewayFactory — the single call site every Payment Action goes
|
||||
* through, so no Action ever depends on a concrete gateway class.
|
||||
*/
|
||||
class PaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentGatewayFactory $gateways,
|
||||
) {}
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
return $this->gateways->make($data->method)->initiate($data);
|
||||
}
|
||||
|
||||
public function verify(PaymentMethod $method, string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
return $this->gateways->make($method)->verify($gatewayTransactionId);
|
||||
}
|
||||
|
||||
public function refund(PaymentMethod $method, string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
return $this->gateways->make($method)->refund($gatewayTransactionId, $amount, $reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Support;
|
||||
|
||||
/**
|
||||
* KBZ's signing scheme, ported verbatim from bnf_event's `KBZPay::joinKeyVal`/
|
||||
* `signature` (domain.md §6): flatten the request array (excluding `sign`/
|
||||
* `sign_type`, at any nesting level — `biz_content` included) into sorted
|
||||
* `key=val` pairs joined by `&`, append `&key={merchant_key}`, SHA-256 hash,
|
||||
* uppercase. Shared by initiate()/verify()/refund() on every gateway.
|
||||
*/
|
||||
class KbzSignature
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param list<string> $skips Additional top-level/nested keys to exclude beyond sign/sign_type.
|
||||
*/
|
||||
public static function joinKeyVal(array $data, array $skips = []): string
|
||||
{
|
||||
$skips = [...$skips, 'sign', 'sign_type'];
|
||||
|
||||
$fields = [];
|
||||
self::collect($data, $skips, $fields);
|
||||
|
||||
usort($fields, fn (array $a, array $b): int => strcmp($a['key'], $b['key']));
|
||||
|
||||
$pairs = [];
|
||||
foreach ($fields as $field) {
|
||||
if ($field['val'] !== null && trim((string) $field['val']) !== '') {
|
||||
$pairs[] = $field['key'].'='.$field['val'];
|
||||
}
|
||||
}
|
||||
|
||||
return implode('&', $pairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param list<string> $skips
|
||||
*/
|
||||
public static function sign(array $data, string $merchantKey, array $skips = []): string
|
||||
{
|
||||
$joined = self::joinKeyVal($data, $skips);
|
||||
|
||||
return strtoupper(hash('sha256', $joined.'&key='.$merchantKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $skips
|
||||
* @param list<array{key: string, val: mixed}> $fields
|
||||
*/
|
||||
private static function collect(mixed $value, array $skips, array &$fields, string $key = ''): void
|
||||
{
|
||||
if (in_array($key, $skips, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $subKey => $subVal) {
|
||||
self::collect($subVal, $skips, $fields, (string) $subKey);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($key === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$fields[] = ['key' => $key, 'val' => $value];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Actions\ConfirmPaymentAction;
|
||||
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\Events\PaymentCompleted;
|
||||
use Modules\Payment\Events\PaymentFailed;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* Fake gateway standing in for KbzMiniAppGateway::verify() — never hits the
|
||||
* real KBZ API in tests, and counts calls so idempotency (only one real
|
||||
* re-verification per Payment) is provable.
|
||||
*/
|
||||
class FakeConfirmGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static int $verifyCallCount = 0;
|
||||
|
||||
public static PaymentStatus $verifyStatus = PaymentStatus::Completed;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
self::$verifyCallCount++;
|
||||
|
||||
return new PaymentResultData(
|
||||
status: self::$verifyStatus,
|
||||
gatewayTransactionId: $gatewayTransactionId,
|
||||
gatewayPayload: ['trade_status' => self::$verifyStatus->value],
|
||||
);
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
FakeConfirmGateway::$verifyCallCount = 0;
|
||||
FakeConfirmGateway::$verifyStatus = PaymentStatus::Completed;
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeConfirmGateway::class);
|
||||
});
|
||||
|
||||
test('confirming a pending payment as completed dispatches PaymentCompleted once', function () {
|
||||
Event::fake([PaymentCompleted::class, PaymentFailed::class]);
|
||||
|
||||
$payment = Payment::factory()->create([
|
||||
'status' => PaymentStatus::Pending,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-TXN-001',
|
||||
]);
|
||||
|
||||
$result = app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-001');
|
||||
|
||||
expect($result->id)->toBe($payment->id)
|
||||
->and($result->status)->toBe(PaymentStatus::Completed)
|
||||
->and(FakeConfirmGateway::$verifyCallCount)->toBe(1);
|
||||
|
||||
Event::assertDispatchedTimes(PaymentCompleted::class, 1);
|
||||
Event::assertNotDispatched(PaymentFailed::class);
|
||||
});
|
||||
|
||||
test('a redelivered confirmation for an already-completed payment is a no-op', function () {
|
||||
Event::fake([PaymentCompleted::class, PaymentFailed::class]);
|
||||
|
||||
Payment::factory()->create([
|
||||
'status' => PaymentStatus::Pending,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-TXN-002',
|
||||
]);
|
||||
|
||||
$action = app(ConfirmPaymentAction::class);
|
||||
$action->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-002');
|
||||
$second = $action->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-002');
|
||||
|
||||
expect($second->status)->toBe(PaymentStatus::Completed)
|
||||
->and(FakeConfirmGateway::$verifyCallCount)->toBe(1);
|
||||
|
||||
Event::assertDispatchedTimes(PaymentCompleted::class, 1);
|
||||
});
|
||||
|
||||
test('confirming a payment the gateway reports as failed dispatches PaymentFailed and leaves the booking untouched', function () {
|
||||
Event::fake([PaymentCompleted::class, PaymentFailed::class]);
|
||||
FakeConfirmGateway::$verifyStatus = PaymentStatus::Failed;
|
||||
|
||||
$payment = Payment::factory()->create([
|
||||
'status' => PaymentStatus::Pending,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-TXN-003',
|
||||
]);
|
||||
|
||||
$result = app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-003');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed);
|
||||
Event::assertDispatchedTimes(PaymentFailed::class, 1);
|
||||
Event::assertNotDispatched(PaymentCompleted::class);
|
||||
});
|
||||
|
||||
test('a gateway verify still reporting pending leaves the payment pending and dispatches nothing', function () {
|
||||
Event::fake([PaymentCompleted::class, PaymentFailed::class]);
|
||||
FakeConfirmGateway::$verifyStatus = PaymentStatus::Pending;
|
||||
|
||||
Payment::factory()->create([
|
||||
'status' => PaymentStatus::Pending,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-TXN-004',
|
||||
]);
|
||||
|
||||
$result = app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-004');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Pending);
|
||||
Event::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('returns null when no payment matches the gateway transaction id', function () {
|
||||
$result = app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'does-not-exist');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('confirming a completed payment flips its booking to confirmed via MarkBookingPaid', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Payment::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'status' => PaymentStatus::Pending,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-TXN-005',
|
||||
]);
|
||||
|
||||
app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-005');
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
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\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
/**
|
||||
* A fake PaymentGatewayInterface swapped in via the factory — never call the
|
||||
* real KBZ gateway in tests (T5.8).
|
||||
*/
|
||||
class FakeInitiatePaymentGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static ?PaymentRequestData $lastRequest = null;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
self::$lastRequest = $data;
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Pending,
|
||||
gatewayTransactionId: $data->merchantOrderId,
|
||||
gatewayPayload: ['prepay_id' => 'PREPAY123'],
|
||||
);
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Permission::findOrCreate('manage_bookings', 'web');
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeInitiatePaymentGateway::class);
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('the owner can initiate payment for their own pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'price' => 15000,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', PaymentStatus::Pending->value)
|
||||
->assertJsonPath('data.gateway_payload.prepay_id', 'PREPAY123');
|
||||
|
||||
expect(Payment::where('booking_id', $booking->id)->count())->toBe(1);
|
||||
|
||||
$payment = Payment::where('booking_id', $booking->id)->first();
|
||||
expect($payment->status)->toBe(PaymentStatus::Pending)
|
||||
->and($payment->gateway)->toBe(PaymentMethod::KbzMiniApp)
|
||||
->and((float) $payment->amount)->toBe(15000.0)
|
||||
->and($payment->gateway_transaction_id)->toBe("{$booking->booking_ref}-1");
|
||||
});
|
||||
|
||||
test('a retried payment attempt gets a unique merchant order id', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
Payment::factory()->failed()->create(['booking_id' => $booking->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated();
|
||||
|
||||
expect(FakeInitiatePaymentGateway::$lastRequest->merchantOrderId)->toBe("{$booking->booking_ref}-2");
|
||||
});
|
||||
|
||||
test('initiating payment on a non-pending_payment booking surfaces as 422', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertStatus(422);
|
||||
|
||||
expect(Payment::where('booking_id', $booking->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a non-owner without manage_bookings cannot initiate payment for someone else\'s booking', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertForbidden();
|
||||
|
||||
expect(Payment::where('booking_id', $booking->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('staff with manage_bookings can initiate payment on behalf of a customer', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated();
|
||||
});
|
||||
|
||||
test('unauthenticated requests are rejected', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('404s for a booking that does not exist', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/payments/EVB-DOES-NOT-EXIST/initiate')
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Exceptions\InvalidWebhookSignatureException;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
$config = [
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
$signedKbzNotification = function (array $overrides, string $merchantKey): array {
|
||||
$notification = array_merge([
|
||||
'appid' => 'APPID123',
|
||||
'notify_time' => '1576842150',
|
||||
'merch_code' => 'MERCH001',
|
||||
'merch_order_id' => 'EVB-FIXTURE-001-1',
|
||||
'mm_order_id' => '01001814070006560257',
|
||||
'trans_currency' => 'MMK',
|
||||
'total_amount' => '15000',
|
||||
'trade_status' => 'PAY_SUCCESS',
|
||||
'trans_end_time' => '1576834704',
|
||||
'nonce_str' => '513ba55344ad44c8b69465aae66f7703',
|
||||
'sign_type' => 'SHA256',
|
||||
], $overrides);
|
||||
|
||||
$notification['sign'] = KbzSignature::sign($notification, $merchantKey);
|
||||
|
||||
return $notification;
|
||||
};
|
||||
|
||||
test('handleWebhook maps a validly signed PAY_SUCCESS notification to a completed PaymentResultData', function () use ($config, $signedKbzNotification) {
|
||||
$notification = $signedKbzNotification([], $config['merchant_key']);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->handleWebhook(['Request' => $notification]);
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Completed)
|
||||
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001-1')
|
||||
->and($result->gatewayPayload)->toBe($notification);
|
||||
});
|
||||
|
||||
test('handleWebhook maps a validly signed WAIT_PAY notification to a pending PaymentResultData', function () use ($config, $signedKbzNotification) {
|
||||
$notification = $signedKbzNotification(['trade_status' => 'WAIT_PAY'], $config['merchant_key']);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->handleWebhook(['Request' => $notification]);
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Pending);
|
||||
});
|
||||
|
||||
test('handleWebhook throws InvalidWebhookSignatureException when the sign does not match', function () use ($config, $signedKbzNotification) {
|
||||
$notification = $signedKbzNotification([], $config['merchant_key']);
|
||||
$notification['sign'] = 'not-the-real-signature';
|
||||
|
||||
expect(fn () => (new KbzMiniAppGateway($config))->handleWebhook(['Request' => $notification]))
|
||||
->toThrow(InvalidWebhookSignatureException::class);
|
||||
});
|
||||
|
||||
test('handleWebhook throws InvalidWebhookSignatureException when signed with the wrong merchant key', function () use ($config, $signedKbzNotification) {
|
||||
$notification = $signedKbzNotification([], 'a-different-merchant-key');
|
||||
|
||||
expect(fn () => (new KbzMiniAppGateway($config))->handleWebhook(['Request' => $notification]))
|
||||
->toThrow(InvalidWebhookSignatureException::class);
|
||||
});
|
||||
|
||||
test('handleWebhook throws InvalidWebhookSignatureException when the sign field is missing entirely', function () use ($config) {
|
||||
expect(fn () => (new KbzMiniAppGateway($config))->handleWebhook(['Request' => ['trade_status' => 'PAY_SUCCESS']]))
|
||||
->toThrow(InvalidWebhookSignatureException::class);
|
||||
});
|
||||
|
||||
test('handleWebhook throws InvalidWebhookSignatureException when the Request key is missing entirely', function () use ($config) {
|
||||
expect(fn () => (new KbzMiniAppGateway($config))->handleWebhook([]))
|
||||
->toThrow(InvalidWebhookSignatureException::class);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
$config = [
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
'notify_url' => 'https://app.test/api/v1/webhooks/kbz',
|
||||
];
|
||||
|
||||
$paymentRequest = new PaymentRequestData(
|
||||
bookingId: 1,
|
||||
merchantOrderId: 'EVB-FIXTURE-001',
|
||||
amount: '15000',
|
||||
currency: 'MMK',
|
||||
method: PaymentMethod::KbzMiniApp,
|
||||
);
|
||||
|
||||
test('initiate posts a correctly signed precreate request to the configured base_url', function () use ($config, $paymentRequest) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']])]);
|
||||
|
||||
(new KbzMiniAppGateway($config))->initiate($paymentRequest);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$body = $request->data()['Request'];
|
||||
|
||||
return $request->url() === 'https://kbz.test/gateway'
|
||||
&& $body['method'] === 'kbz.payment.precreate'
|
||||
&& $body['sign_type'] === 'SHA256'
|
||||
&& $body['biz_content']['appid'] === 'APPID123'
|
||||
&& $body['biz_content']['merch_code'] === 'MERCH001'
|
||||
&& $body['biz_content']['merch_order_id'] === 'EVB-FIXTURE-001'
|
||||
&& $body['biz_content']['trade_type'] === 'MINIAPP'
|
||||
&& $body['biz_content']['total_amount'] === '15000'
|
||||
&& $body['biz_content']['trans_currency'] === 'MMK'
|
||||
&& $body['sign'] === KbzSignature::sign($body, 'test-merchant-key');
|
||||
});
|
||||
});
|
||||
|
||||
test('initiate returns a pending PaymentResultData on a successful precreate', function () use ($config, $paymentRequest) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->initiate($paymentRequest);
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Pending)
|
||||
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001')
|
||||
->and($result->gatewayPayload)->toBe(['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']);
|
||||
});
|
||||
|
||||
test('initiate returns a failed PaymentResultData when KBZ rejects the request', function () use ($config, $paymentRequest) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'FAIL', 'msg' => 'Invalid merchant']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->initiate($paymentRequest);
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed)
|
||||
->and($result->gatewayTransactionId)->toBeNull()
|
||||
->and($result->message)->toBe('Invalid merchant');
|
||||
});
|
||||
|
||||
test('initiate returns a failed PaymentResultData when the connection fails', function () use ($config, $paymentRequest) {
|
||||
Http::fake(['kbz.test/*' => fn () => throw new ConnectionException('Connection refused')]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->initiate($paymentRequest);
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed)
|
||||
->and($result->gatewayTransactionId)->toBeNull()
|
||||
->and($result->gatewayPayload)->toBe([])
|
||||
->and($result->message)->toBe('Connection refused');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
$config = [
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
'notify_url' => 'https://app.test/api/v1/webhooks/kbz',
|
||||
];
|
||||
|
||||
test('refund posts a correctly signed refund request, wiring the partial amount through', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'refund_order_id' => 'REFUND123']])]);
|
||||
|
||||
(new KbzMiniAppGateway($config))->refund('EVB-FIXTURE-001', '8000', 'customer requested partial refund');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$body = $request->data()['Request'];
|
||||
|
||||
return $request->url() === 'https://kbz.test/gateway'
|
||||
&& $body['method'] === 'kbz.payment.refund'
|
||||
&& $body['sign_type'] === 'SHA256'
|
||||
&& $body['biz_content']['appid'] === 'APPID123'
|
||||
&& $body['biz_content']['merch_code'] === 'MERCH001'
|
||||
&& $body['biz_content']['merch_order_id'] === 'EVB-FIXTURE-001'
|
||||
// unlike bnf_event (amount hardcoded/commented out), this is wired through
|
||||
&& $body['biz_content']['refund_amount'] === '8000'
|
||||
&& $body['biz_content']['refund_reason'] === 'customer requested partial refund'
|
||||
&& ! empty($body['biz_content']['refund_request_no'])
|
||||
&& $body['sign'] === KbzSignature::sign($body, 'test-merchant-key');
|
||||
});
|
||||
});
|
||||
|
||||
test('refund returns a completed RefundResultData on success', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'refund_order_id' => 'REFUND123']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->refund('EVB-FIXTURE-001', '8000', 'customer request');
|
||||
|
||||
expect($result->status)->toBe(RefundStatus::Completed)
|
||||
->and($result->gatewayRefundId)->toBe('REFUND123')
|
||||
->and($result->gatewayPayload)->toBe(['result' => 'SUCCESS', 'refund_order_id' => 'REFUND123']);
|
||||
});
|
||||
|
||||
test('refund returns a failed RefundResultData when KBZ rejects the request', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'FAIL', 'msg' => 'Refund window expired']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->refund('EVB-FIXTURE-001', '8000', 'customer request');
|
||||
|
||||
expect($result->status)->toBe(RefundStatus::Failed)
|
||||
->and($result->gatewayRefundId)->toBeNull()
|
||||
->and($result->message)->toBe('Refund window expired');
|
||||
});
|
||||
|
||||
test('refund returns a failed RefundResultData when the connection fails', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => fn () => throw new ConnectionException('Connection refused')]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->refund('EVB-FIXTURE-001', '8000', 'customer request');
|
||||
|
||||
expect($result->status)->toBe(RefundStatus::Failed)
|
||||
->and($result->gatewayRefundId)->toBeNull()
|
||||
->and($result->gatewayPayload)->toBe([])
|
||||
->and($result->message)->toBe('Connection refused');
|
||||
});
|
||||
|
||||
test('refund builds mTLS cert/ssl_key/verify options from config', function () {
|
||||
$gateway = new KbzMiniAppGateway([
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
'cert_path' => '/certs/merch.pem',
|
||||
'cert_key_path' => '/certs/merch.key',
|
||||
'ca_path' => '/certs/ca.crt',
|
||||
'cert_password' => 'secret',
|
||||
]);
|
||||
|
||||
$options = (new ReflectionMethod($gateway, 'mtlsOptions'))->invoke($gateway);
|
||||
|
||||
expect($options)->toBe([
|
||||
'cert' => ['/certs/merch.pem', 'secret'],
|
||||
'ssl_key' => ['/certs/merch.key', 'secret'],
|
||||
'verify' => '/certs/ca.crt',
|
||||
]);
|
||||
});
|
||||
|
||||
test('refund omits mTLS options entirely when cert config is not set', function () use ($config) {
|
||||
$gateway = new KbzMiniAppGateway($config);
|
||||
|
||||
$options = (new ReflectionMethod($gateway, 'mtlsOptions'))->invoke($gateway);
|
||||
|
||||
expect($options)->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
$config = [
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
'notify_url' => 'https://app.test/api/v1/webhooks/kbz',
|
||||
];
|
||||
|
||||
test('verify posts a correctly signed queryorder request keyed by our own merch_order_id', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['trade_status' => 'PAY_SUCCESS']])]);
|
||||
|
||||
(new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$body = $request->data()['Request'];
|
||||
|
||||
return $request->url() === 'https://kbz.test/gateway'
|
||||
&& $body['method'] === 'kbz.payment.queryorder'
|
||||
&& $body['sign_type'] === 'SHA256'
|
||||
&& $body['biz_content']['appid'] === 'APPID123'
|
||||
&& $body['biz_content']['merch_code'] === 'MERCH001'
|
||||
&& $body['biz_content']['merch_order_id'] === 'EVB-FIXTURE-001'
|
||||
&& ! array_key_exists('total_amount', $body['biz_content'])
|
||||
&& $body['sign'] === KbzSignature::sign($body, 'test-merchant-key');
|
||||
});
|
||||
});
|
||||
|
||||
test('verify maps PAY_SUCCESS to a completed PaymentResultData', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['trade_status' => 'PAY_SUCCESS', 'mm_order_id' => 'MM123']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Completed)
|
||||
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001')
|
||||
->and($result->gatewayPayload)->toBe(['trade_status' => 'PAY_SUCCESS', 'mm_order_id' => 'MM123']);
|
||||
});
|
||||
|
||||
test('verify maps WAIT_PAY to a pending PaymentResultData', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['trade_status' => 'WAIT_PAY']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Pending);
|
||||
});
|
||||
|
||||
test('verify maps any other/unrecognized trade_status to a failed PaymentResultData', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['trade_status' => 'PAY_ERROR']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed)
|
||||
->and($result->message)->toBe('PAY_ERROR');
|
||||
});
|
||||
|
||||
test('verify returns a failed PaymentResultData when the connection fails', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => fn () => throw new ConnectionException('Connection refused')]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed)
|
||||
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001')
|
||||
->and($result->gatewayPayload)->toBe([])
|
||||
->and($result->message)->toBe('Connection refused');
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function signedKbzWebhookBody(array $overrides = []): array
|
||||
{
|
||||
$merchantKey = 'test-merchant-key';
|
||||
|
||||
$notification = array_merge([
|
||||
'appid' => 'APPID123',
|
||||
'notify_time' => '1576842150',
|
||||
'merch_code' => 'MERCH001',
|
||||
'merch_order_id' => 'EVB-FIXTURE-001-1',
|
||||
'mm_order_id' => '01001814070006560257',
|
||||
'trans_currency' => 'MMK',
|
||||
'total_amount' => '15000',
|
||||
'trade_status' => 'PAY_SUCCESS',
|
||||
'trans_end_time' => '1576834704',
|
||||
'nonce_str' => '513ba55344ad44c8b69465aae66f7703',
|
||||
'sign_type' => 'SHA256',
|
||||
], $overrides);
|
||||
|
||||
$notification['sign'] = KbzSignature::sign($notification, $merchantKey);
|
||||
|
||||
return ['Request' => $notification];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
config(['services.kbz.merchant_key' => 'test-merchant-key']);
|
||||
});
|
||||
|
||||
test('a validly signed kbz webhook is acknowledged with the literal success body', function () {
|
||||
Log::spy();
|
||||
|
||||
$this->postJson('/api/v1/webhooks/kbz_mini_app', signedKbzWebhookBody())
|
||||
->assertOk()
|
||||
->assertSee('success');
|
||||
|
||||
Log::shouldHaveReceived('info')->once();
|
||||
});
|
||||
|
||||
test('an invalidly signed kbz webhook is rejected with 400, never 500', function () {
|
||||
Log::spy();
|
||||
|
||||
$body = signedKbzWebhookBody();
|
||||
$body['Request']['sign'] = 'tampered';
|
||||
|
||||
$this->postJson('/api/v1/webhooks/kbz_mini_app', $body)
|
||||
->assertStatus(400);
|
||||
|
||||
Log::shouldHaveReceived('warning')->once();
|
||||
});
|
||||
|
||||
test('an unknown gateway in the route segment 404s rather than reaching a controller', function () {
|
||||
$this->postJson('/api/v1/webhooks/not_a_real_gateway', signedKbzWebhookBody())
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('a missing signature is rejected with 400', function () {
|
||||
$this->postJson('/api/v1/webhooks/kbz_mini_app', ['Request' => ['trade_status' => 'PAY_SUCCESS']])
|
||||
->assertStatus(400);
|
||||
});
|
||||
|
||||
test('the optional encrypted booking id segment is accepted and decrypted for logging', function () {
|
||||
Log::spy();
|
||||
|
||||
$booking = Booking::factory()->create();
|
||||
$encrypted = Crypt::encryptString((string) $booking->id);
|
||||
|
||||
$this->postJson("/api/v1/webhooks/kbz_mini_app/{$encrypted}", signedKbzWebhookBody())
|
||||
->assertOk();
|
||||
|
||||
Log::shouldHaveReceived('info')->withArgs(
|
||||
fn (string $message, array $context): bool => $context['booking_id'] === $booking->id
|
||||
)->once();
|
||||
});
|
||||
|
||||
test('a garbage encrypted booking id segment does not fail the webhook', function () {
|
||||
$this->postJson('/api/v1/webhooks/kbz_mini_app/not-a-real-ciphertext', signedKbzWebhookBody())
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('no auth:sanctum is required to reach the webhook', function () {
|
||||
$this->postJson('/api/v1/webhooks/kbz_mini_app', signedKbzWebhookBody())
|
||||
->assertOk();
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
/**
|
||||
* Keeps KbzMiniAppGateway's real handleWebhook() (so the inbound signature
|
||||
* check in the webhook route stays genuine end-to-end) but stubs verify()
|
||||
* so the double-delivery/idempotency test never hits the real KBZ API and
|
||||
* can count re-verification calls.
|
||||
*/
|
||||
class FakeVerifyingKbzGateway extends KbzMiniAppGateway
|
||||
{
|
||||
public static int $verifyCallCount = 0;
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
self::$verifyCallCount++;
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Completed,
|
||||
gatewayTransactionId: $gatewayTransactionId,
|
||||
gatewayPayload: ['trade_status' => 'PAY_SUCCESS'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
FakeVerifyingKbzGateway::$verifyCallCount = 0;
|
||||
|
||||
config(['services.kbz.merchant_key' => 'test-merchant-key']);
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeVerifyingKbzGateway::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function signedKbzConfirmationBody(string $merchOrderId): array
|
||||
{
|
||||
$notification = [
|
||||
'appid' => 'APPID123',
|
||||
'notify_time' => '1576842150',
|
||||
'merch_code' => 'MERCH001',
|
||||
'merch_order_id' => $merchOrderId,
|
||||
'mm_order_id' => '01001814070006560257',
|
||||
'trans_currency' => 'MMK',
|
||||
'total_amount' => '15000',
|
||||
'trade_status' => 'PAY_SUCCESS',
|
||||
'trans_end_time' => '1576834704',
|
||||
'nonce_str' => '513ba55344ad44c8b69465aae66f7703',
|
||||
'sign_type' => 'SHA256',
|
||||
];
|
||||
|
||||
$notification['sign'] = KbzSignature::sign($notification, 'test-merchant-key');
|
||||
|
||||
return ['Request' => $notification];
|
||||
}
|
||||
|
||||
test('a double-delivered kbz webhook only confirms the payment and its booking once', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$payment = Payment::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'status' => PaymentStatus::Pending,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-DUPTEST-1',
|
||||
]);
|
||||
|
||||
$body = signedKbzConfirmationBody('EVB-DUPTEST-1');
|
||||
|
||||
$this->postJson('/api/v1/webhooks/kbz_mini_app', $body)->assertOk();
|
||||
|
||||
expect($payment->refresh()->status)->toBe(PaymentStatus::Completed)
|
||||
->and($booking->refresh()->status)->toBe(BookingStatus::Confirmed)
|
||||
->and(FakeVerifyingKbzGateway::$verifyCallCount)->toBe(1);
|
||||
|
||||
// KBZ redelivers the same notification — must not re-verify or re-confirm.
|
||||
$this->postJson('/api/v1/webhooks/kbz_mini_app', $body)->assertOk();
|
||||
|
||||
expect($payment->refresh()->status)->toBe(PaymentStatus::Completed)
|
||||
->and($booking->refresh()->status)->toBe(BookingStatus::Confirmed)
|
||||
->and(FakeVerifyingKbzGateway::$verifyCallCount)->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Events\PaymentCompleted;
|
||||
use Modules\Payment\Listeners\MarkBookingPaid;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
test('flips a pending_payment booking to confirmed', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$payment = Payment::factory()->completed()->create(['booking_id' => $booking->id]);
|
||||
|
||||
(new MarkBookingPaid)->handle(new PaymentCompleted($payment));
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('does not touch a booking that already moved on for another reason', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
$payment = Payment::factory()->completed()->create(['booking_id' => $booking->id, 'status' => PaymentStatus::Completed]);
|
||||
|
||||
(new MarkBookingPaid)->handle(new PaymentCompleted($payment));
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
|
||||
test('a payment belongs to a booking', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
$payment = Payment::factory()->create(['booking_id' => $booking->id]);
|
||||
|
||||
expect($payment->booking)->toBeInstanceOf(Booking::class)
|
||||
->and($payment->booking->is($booking))->toBeTrue()
|
||||
->and($booking->payments->first()->is($payment))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a booking can have more than one payment attempt', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Payment::factory()->failed()->create(['booking_id' => $booking->id]);
|
||||
Payment::factory()->completed()->create(['booking_id' => $booking->id]);
|
||||
|
||||
expect($booking->payments)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('gateway and status cast to their enums', function () {
|
||||
$payment = Payment::factory()->create([
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'status' => PaymentStatus::Completed,
|
||||
]);
|
||||
|
||||
expect($payment->gateway)->toBe(PaymentMethod::KbzMiniApp)
|
||||
->and($payment->status)->toBe(PaymentStatus::Completed);
|
||||
});
|
||||
|
||||
test('a payment defaults to pending', function () {
|
||||
$payment = Payment::factory()->create();
|
||||
|
||||
expect($payment->status)->toBe(PaymentStatus::Pending);
|
||||
});
|
||||
|
||||
test('a refund belongs to a payment, not the booking directly', function () {
|
||||
$payment = Payment::factory()->completed()->create();
|
||||
|
||||
$refund = Refund::factory()->create(['payment_id' => $payment->id]);
|
||||
|
||||
expect($refund->payment)->toBeInstanceOf(Payment::class)
|
||||
->and($refund->payment->is($payment))->toBeTrue()
|
||||
->and($payment->refunds->first()->is($refund))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a payment can have more than one refund for partial refunds', function () {
|
||||
$payment = Payment::factory()->completed()->create(['amount' => 20000]);
|
||||
|
||||
Refund::factory()->completed()->create(['payment_id' => $payment->id, 'amount' => 8000]);
|
||||
Refund::factory()->create(['payment_id' => $payment->id, 'amount' => 12000]);
|
||||
|
||||
expect($payment->refunds)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('refund status casts to its enum and defaults to pending', function () {
|
||||
$refund = Refund::factory()->create();
|
||||
|
||||
expect($refund->status)->toBe(RefundStatus::Pending);
|
||||
});
|
||||
|
||||
test('deleting a payment cascades to its refunds', function () {
|
||||
$payment = Payment::factory()->completed()->create();
|
||||
$refund = Refund::factory()->create(['payment_id' => $payment->id]);
|
||||
|
||||
$payment->delete();
|
||||
|
||||
expect(Refund::find($refund->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('gateway_transaction_id cannot be shared across unrelated retried attempts without being unique-constrained', function () {
|
||||
// gateway_transaction_id is not unique-constrained since a failed attempt
|
||||
// may legitimately be retried under a fresh Payment row with its own id;
|
||||
// this just documents the column accepts duplicates without throwing.
|
||||
Payment::factory()->create(['gateway_transaction_id' => 'kbz-txn-1']);
|
||||
|
||||
expect(fn () => Payment::factory()->create(['gateway_transaction_id' => 'kbz-txn-1']))
|
||||
->not->toThrow(QueryException::class);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
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\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
|
||||
/**
|
||||
* Stands in for "a second gateway" being added — proves call sites that only
|
||||
* depend on PaymentGatewayFactory (never a concrete gateway class) don't
|
||||
* need to change when a gateway implementation is swapped/added.
|
||||
*/
|
||||
class FakePaymentGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
return new PaymentResultData(status: PaymentStatus::Pending, gatewayTransactionId: 'fake-txn', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
return new PaymentResultData(status: PaymentStatus::Completed, gatewayTransactionId: $gatewayTransactionId, gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
test('the factory resolves KbzMiniAppGateway for the KbzMiniApp method by default', function () {
|
||||
$gateway = app(PaymentGatewayFactory::class)->make(PaymentMethod::KbzMiniApp);
|
||||
|
||||
expect($gateway)->toBeInstanceOf(KbzMiniAppGateway::class);
|
||||
});
|
||||
|
||||
test('registering a fake gateway swaps the resolved implementation with no call-site changes', function () {
|
||||
$factory = app(PaymentGatewayFactory::class);
|
||||
$factory->register(PaymentMethod::KbzMiniApp, FakePaymentGateway::class);
|
||||
|
||||
// A call site that only knows about the factory + interface, never the
|
||||
// concrete gateway class — this is exactly what an Action/controller does.
|
||||
$callSite = fn (PaymentGatewayFactory $factory, PaymentMethod $method): PaymentGatewayInterface => $factory->make($method);
|
||||
|
||||
expect($callSite($factory, PaymentMethod::KbzMiniApp))->toBeInstanceOf(FakePaymentGateway::class);
|
||||
});
|
||||
|
||||
test('the factory throws when no gateway is registered for a method', function () {
|
||||
$factory = new PaymentGatewayFactory;
|
||||
|
||||
expect(fn () => $factory->make(PaymentMethod::KbzMiniApp))->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
test('the factory is bound as a singleton', function () {
|
||||
expect(app(PaymentGatewayFactory::class))->toBe(app(PaymentGatewayFactory::class));
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Filament\Resources\Payments\Pages\ListPayments;
|
||||
use Modules\Payment\Filament\Resources\Payments\Pages\ViewPayment;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['view_payments', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
});
|
||||
|
||||
test('a user with view_payments can list payments', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_payments');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$payments = Payment::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListPayments::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($payments);
|
||||
});
|
||||
|
||||
test('can filter payments by status', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_payments');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$pending = Payment::factory()->create(['status' => PaymentStatus::Pending]);
|
||||
$completed = Payment::factory()->completed()->create();
|
||||
|
||||
Livewire::test(ListPayments::class)
|
||||
->filterTable('status', PaymentStatus::Completed->value)
|
||||
->assertCanSeeTableRecords([$completed])
|
||||
->assertCanNotSeeTableRecords([$pending]);
|
||||
});
|
||||
|
||||
test('can view a payment\'s detail page', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_payments');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$booking = Booking::factory()->create();
|
||||
$payment = Payment::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-VIEWTEST-1',
|
||||
]);
|
||||
|
||||
Livewire::test(ViewPayment::class, ['record' => $payment->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee($booking->booking_ref)
|
||||
->assertSee('EVB-VIEWTEST-1');
|
||||
});
|
||||
|
||||
test('the gateway response is visible to a user with process_refunds', function () {
|
||||
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
|
||||
$this->actingAs($admin);
|
||||
|
||||
$payment = Payment::factory()->create(['gateway_payload' => ['prepay_id' => 'PREPAY-SECRET-123']]);
|
||||
|
||||
Livewire::test(ViewPayment::class, ['record' => $payment->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee('PREPAY-SECRET-123');
|
||||
});
|
||||
|
||||
test('the gateway response is hidden from a user without process_refunds', function () {
|
||||
$support = User::factory()->create()->givePermissionTo('view_payments');
|
||||
$this->actingAs($support);
|
||||
|
||||
$payment = Payment::factory()->create(['gateway_payload' => ['prepay_id' => 'PREPAY-SECRET-123']]);
|
||||
|
||||
Livewire::test(ViewPayment::class, ['record' => $payment->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertDontSee('PREPAY-SECRET-123');
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Actions\RefundBookingAction;
|
||||
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\RefundStatus;
|
||||
use Modules\Payment\Events\RefundProcessed;
|
||||
use Modules\Payment\Exceptions\RefundFailedException;
|
||||
use Modules\Payment\Exceptions\RefundNotAllowedException;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests.
|
||||
*/
|
||||
class FakeRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static ?RefundStatus $resultStatus = RefundStatus::Completed;
|
||||
|
||||
public static ?string $lastAmount = null;
|
||||
|
||||
public static ?string $lastReason = null;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
self::$lastAmount = $amount;
|
||||
self::$lastReason = $reason;
|
||||
|
||||
return new RefundResultData(
|
||||
status: self::$resultStatus,
|
||||
gatewayRefundId: self::$resultStatus === RefundStatus::Completed ? 'REFUND123' : null,
|
||||
gatewayPayload: ['result' => self::$resultStatus->value],
|
||||
message: self::$resultStatus === RefundStatus::Failed ? 'Refund window expired' : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
FakeRefundGateway::$resultStatus = RefundStatus::Completed;
|
||||
FakeRefundGateway::$lastAmount = null;
|
||||
FakeRefundGateway::$lastReason = null;
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeRefundGateway::class);
|
||||
});
|
||||
|
||||
function confirmedBookingWithPayment(string $amount = '15000'): Booking
|
||||
{
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => $amount]);
|
||||
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => $amount,
|
||||
'gateway_transaction_id' => 'EVB-REFUND-TEST-1',
|
||||
]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
test('a full refund on a confirmed booking completes and flips the booking to cancelled', function () {
|
||||
$booking = confirmedBookingWithPayment('15000');
|
||||
|
||||
$refund = app(RefundBookingAction::class)->handle($booking, '15000', 'customer requested cancellation');
|
||||
|
||||
expect($refund->status)->toBe(RefundStatus::Completed)
|
||||
->and($refund->amount)->toBe('15000.00')
|
||||
->and($refund->gateway_refund_id)->toBe('REFUND123')
|
||||
->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled)
|
||||
->and(FakeRefundGateway::$lastAmount)->toBe('15000')
|
||||
->and(FakeRefundGateway::$lastReason)->toBe('customer requested cancellation');
|
||||
});
|
||||
|
||||
test('a partial refund is wired through to the gateway and does not exceed the payment amount', function () {
|
||||
$booking = confirmedBookingWithPayment('15000');
|
||||
|
||||
$refund = app(RefundBookingAction::class)->handle($booking, '8000', 'partial refund');
|
||||
|
||||
expect($refund->status)->toBe(RefundStatus::Completed)
|
||||
->and($refund->amount)->toBe('8000.00')
|
||||
->and(FakeRefundGateway::$lastAmount)->toBe('8000');
|
||||
});
|
||||
|
||||
test('a second partial refund is validated against the remaining refundable balance, not the original total', function () {
|
||||
$booking = confirmedBookingWithPayment('15000');
|
||||
|
||||
app(RefundBookingAction::class)->handle($booking, '10000', 'first partial refund');
|
||||
|
||||
expect(fn () => app(RefundBookingAction::class)->handle($booking, '6000', 'second partial refund'))
|
||||
->toThrow(RefundNotAllowedException::class);
|
||||
});
|
||||
|
||||
test('a second partial refund within the remaining balance succeeds', function () {
|
||||
$booking = confirmedBookingWithPayment('15000');
|
||||
|
||||
app(RefundBookingAction::class)->handle($booking, '10000', 'first partial refund');
|
||||
$second = app(RefundBookingAction::class)->handle($booking, '5000', 'second partial refund');
|
||||
|
||||
expect($second->status)->toBe(RefundStatus::Completed);
|
||||
});
|
||||
|
||||
test('refunding a non-confirmed booking throws RefundNotAllowedException', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
expect(fn () => app(RefundBookingAction::class)->handle($booking, '5000', 'reason'))
|
||||
->toThrow(RefundNotAllowedException::class);
|
||||
});
|
||||
|
||||
test('refunding a confirmed booking with no completed payment throws RefundNotAllowedException', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
expect(fn () => app(RefundBookingAction::class)->handle($booking, '5000', 'reason'))
|
||||
->toThrow(RefundNotAllowedException::class);
|
||||
});
|
||||
|
||||
test('a failed gateway refund is persisted as failed, leaves the booking untouched, and throws RefundFailedException', function () {
|
||||
Event::fake([RefundProcessed::class]);
|
||||
FakeRefundGateway::$resultStatus = RefundStatus::Failed;
|
||||
|
||||
$booking = confirmedBookingWithPayment('15000');
|
||||
|
||||
try {
|
||||
app(RefundBookingAction::class)->handle($booking, '15000', 'reason');
|
||||
$this->fail('Expected RefundFailedException to be thrown.');
|
||||
} catch (RefundFailedException $exception) {
|
||||
expect($exception->getMessage())->toBe('Refund window expired');
|
||||
}
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed)
|
||||
->and(Refund::where('status', RefundStatus::Failed)->count())->toBe(1);
|
||||
|
||||
Event::assertNotDispatched(RefundProcessed::class);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
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\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
class FakeRefundApiGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static RefundStatus $resultStatus = RefundStatus::Completed;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
return new RefundResultData(
|
||||
status: self::$resultStatus,
|
||||
gatewayRefundId: self::$resultStatus === RefundStatus::Completed ? 'REFUND123' : null,
|
||||
gatewayPayload: [],
|
||||
message: self::$resultStatus === RefundStatus::Failed ? 'Refund failed at gateway' : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
FakeRefundApiGateway::$resultStatus = RefundStatus::Completed;
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeRefundApiGateway::class);
|
||||
|
||||
Permission::findOrCreate('process_refunds', 'web');
|
||||
|
||||
$this->staff = User::factory()->create()->givePermissionTo('process_refunds');
|
||||
$this->staffToken = $this->staff->createToken('staff-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('staff with process_refunds can refund a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-API-REFUND-1',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [
|
||||
'amount' => 15000,
|
||||
'reason' => 'customer requested cancellation',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', RefundStatus::Completed->value);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('a customer without process_refunds cannot refund their own booking', function () {
|
||||
$owner = User::factory()->create();
|
||||
$token = $owner->createToken('customer-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-API-REFUND-2',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [
|
||||
'amount' => 15000,
|
||||
'reason' => 'customer requested cancellation',
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('refunding a pending_payment booking surfaces as 422', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [
|
||||
'amount' => 5000,
|
||||
'reason' => 'reason',
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('a failed gateway refund surfaces the gateway message as 422 and leaves the booking confirmed', function () {
|
||||
FakeRefundApiGateway::$resultStatus = RefundStatus::Failed;
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-API-REFUND-3',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [
|
||||
'amount' => 15000,
|
||||
'reason' => 'reason',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', 'Refund failed at gateway');
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('unauthenticated requests are rejected', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", ['amount' => 100, 'reason' => 'x'])
|
||||
->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
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\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Filament\Resources\Refunds\Pages\ListRefunds;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests.
|
||||
*/
|
||||
class FakeFilamentRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['view_payments', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeFilamentRefundGateway::class);
|
||||
});
|
||||
|
||||
test('a user with view_payments can list refunds', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_payments');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$refunds = Refund::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListRefunds::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($refunds);
|
||||
});
|
||||
|
||||
test('the process action is hidden from a user without process_refunds', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_payments');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
Livewire::test(ListRefunds::class)
|
||||
->assertActionHidden('process');
|
||||
});
|
||||
|
||||
test('the process action is visible to a user with process_refunds', function () {
|
||||
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test(ListRefunds::class)
|
||||
->assertActionVisible('process');
|
||||
});
|
||||
|
||||
test('processing a refund via the action calls RefundBookingAction and cancels the booking', function () {
|
||||
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
|
||||
$this->actingAs($admin);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
$payment = Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-FILAMENT-REFUND-1',
|
||||
]);
|
||||
|
||||
Livewire::test(ListRefunds::class)
|
||||
->callAction('process', data: [
|
||||
'payment_id' => $payment->id,
|
||||
'amount' => 15000,
|
||||
'reason' => 'customer requested cancellation',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled)
|
||||
->and(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a non-completed payment is not offered in the process action\'s payment select', function () {
|
||||
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
|
||||
$this->actingAs($admin);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$pendingPayment = Payment::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
]);
|
||||
|
||||
// The Select itself rejects a value outside its "Completed only"
|
||||
// options (domain.md §6) — RefundBookingAction's own guard against a
|
||||
// non-confirmed booking is covered directly in RefundBookingActionTest.
|
||||
Livewire::test(ListRefunds::class)
|
||||
->callAction('process', data: [
|
||||
'payment_id' => $pendingPayment->id,
|
||||
'amount' => 1000,
|
||||
'reason' => 'reason',
|
||||
])
|
||||
->assertHasFormErrors(['payment_id']);
|
||||
|
||||
expect(Refund::where('payment_id', $pendingPayment->id)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
/**
|
||||
* Fixture derived by running bnf_event's own `KBZPay::joinKeyVal`/`signature`
|
||||
* (App\Strategies\Payments\KBZPay) against this exact params array and key —
|
||||
* this pins the port to the old code's actual behavior (domain.md §6).
|
||||
*/
|
||||
$fixtureParams = [
|
||||
'timestamp' => '1700000000',
|
||||
'method' => 'kbz.payment.precreate',
|
||||
'notify_url' => 'https://example.com/api/v1/webhooks/kbz',
|
||||
'nonce_str' => 'fixture-nonce',
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => 'APPID123',
|
||||
'merch_code' => 'MERCH001',
|
||||
'merch_order_id' => 'EVB-FIXTURE-001',
|
||||
'trade_type' => 'MINIAPP',
|
||||
'total_amount' => '15000',
|
||||
'trans_currency' => 'MMK',
|
||||
'callback_info' => 'urlencode',
|
||||
],
|
||||
];
|
||||
$fixtureKey = 'test-merchant-key';
|
||||
|
||||
test('joinKeyVal flattens nested biz_content and sorts keys, matching the old algorithm', function () use ($fixtureParams) {
|
||||
expect(KbzSignature::joinKeyVal($fixtureParams))->toBe(
|
||||
'appid=APPID123&callback_info=urlencode&merch_code=MERCH001&merch_order_id=EVB-FIXTURE-001'
|
||||
.'&method=kbz.payment.precreate&nonce_str=fixture-nonce¬ify_url=https://example.com/api/v1/webhooks/kbz'
|
||||
.'×tamp=1700000000&total_amount=15000&trade_type=MINIAPP&trans_currency=MMK&version=1.0'
|
||||
);
|
||||
});
|
||||
|
||||
test('sign matches the known fixture hash produced by the old KBZPay::signature', function () use ($fixtureParams, $fixtureKey) {
|
||||
expect(KbzSignature::sign($fixtureParams, $fixtureKey))
|
||||
->toBe('29F95FB3DCCEC866A68A07E9A1B25BEEE9F355529B5D37395A04B2282FC48BB1');
|
||||
});
|
||||
|
||||
test('sign is uppercase SHA-256 and stable for the same input', function () use ($fixtureParams, $fixtureKey) {
|
||||
$signature = KbzSignature::sign($fixtureParams, $fixtureKey);
|
||||
|
||||
expect($signature)->toBe(strtoupper($signature))
|
||||
->and($signature)->toHaveLength(64)
|
||||
->and(KbzSignature::sign($fixtureParams, $fixtureKey))->toBe($signature);
|
||||
});
|
||||
|
||||
test('sign and joinKeyVal ignore any pre-existing sign/sign_type values', function () use ($fixtureParams, $fixtureKey) {
|
||||
$withStaleSign = [...$fixtureParams, 'sign' => 'stale', 'sign_type' => 'SHA256'];
|
||||
|
||||
expect(KbzSignature::sign($withStaleSign, $fixtureKey))
|
||||
->toBe(KbzSignature::sign($fixtureParams, $fixtureKey));
|
||||
});
|
||||
|
||||
test('joinKeyVal drops null and empty-string values', function () {
|
||||
$params = ['a' => 'x', 'b' => null, 'c' => '', 'd' => ' '];
|
||||
|
||||
expect(KbzSignature::joinKeyVal($params))->toBe('a=x');
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
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\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
|
||||
test('initiate resolves the gateway for the request method and delegates to its initiate()', function () {
|
||||
$gateway = Mockery::mock(PaymentGatewayInterface::class);
|
||||
$data = new PaymentRequestData(
|
||||
bookingId: 1,
|
||||
merchantOrderId: 'EVB-001',
|
||||
amount: '15000',
|
||||
currency: 'MMK',
|
||||
method: PaymentMethod::KbzMiniApp,
|
||||
);
|
||||
$expected = new PaymentResultData(status: PaymentStatus::Pending, gatewayTransactionId: 'EVB-001', gatewayPayload: []);
|
||||
|
||||
$gateway->shouldReceive('initiate')->once()->with($data)->andReturn($expected);
|
||||
|
||||
$factory = Mockery::mock(PaymentGatewayFactory::class);
|
||||
$factory->shouldReceive('make')->once()->with(PaymentMethod::KbzMiniApp)->andReturn($gateway);
|
||||
|
||||
$result = (new PaymentService($factory))->initiate($data);
|
||||
|
||||
expect($result)->toBe($expected);
|
||||
});
|
||||
|
||||
test('verify resolves the gateway for the given method and delegates to its verify()', function () {
|
||||
$gateway = Mockery::mock(PaymentGatewayInterface::class);
|
||||
$expected = new PaymentResultData(status: PaymentStatus::Completed, gatewayTransactionId: 'EVB-001', gatewayPayload: []);
|
||||
|
||||
$gateway->shouldReceive('verify')->once()->with('EVB-001')->andReturn($expected);
|
||||
|
||||
$factory = Mockery::mock(PaymentGatewayFactory::class);
|
||||
$factory->shouldReceive('make')->once()->with(PaymentMethod::KbzMiniApp)->andReturn($gateway);
|
||||
|
||||
$result = (new PaymentService($factory))->verify(PaymentMethod::KbzMiniApp, 'EVB-001');
|
||||
|
||||
expect($result)->toBe($expected);
|
||||
});
|
||||
|
||||
test('refund resolves the gateway for the given method and delegates to its refund()', function () {
|
||||
$gateway = Mockery::mock(PaymentGatewayInterface::class);
|
||||
$expected = new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND-1', gatewayPayload: []);
|
||||
|
||||
$gateway->shouldReceive('refund')->once()->with('EVB-001', '8000', 'customer request')->andReturn($expected);
|
||||
|
||||
$factory = Mockery::mock(PaymentGatewayFactory::class);
|
||||
$factory->shouldReceive('make')->once()->with(PaymentMethod::KbzMiniApp)->andReturn($gateway);
|
||||
|
||||
$result = (new PaymentService($factory))->refund(PaymentMethod::KbzMiniApp, 'EVB-001', '8000', 'customer request');
|
||||
|
||||
expect($result)->toBe($expected);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use Modules\Booking\BookingPlugin;
|
||||
use Modules\Catalog\CatalogPlugin;
|
||||
use Modules\Payment\PaymentPlugin;
|
||||
use Modules\Routing\RoutingPlugin;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
@@ -45,6 +46,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
CatalogPlugin::make(),
|
||||
RoutingPlugin::make(),
|
||||
BookingPlugin::make(),
|
||||
PaymentPlugin::make(),
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
|
||||
@@ -36,6 +36,7 @@ return [
|
||||
],
|
||||
|
||||
'kbz' => [
|
||||
'app_id' => env('KBZ_APP_ID'),
|
||||
'merchant_code' => env('KBZ_MERCHANT_CODE'),
|
||||
'merchant_key' => env('KBZ_MERCHANT_KEY'),
|
||||
'base_url' => env('KBZ_BASE_URL'),
|
||||
|
||||
Reference in New Issue
Block a user