Complete Payment module: initiate/webhook/confirm/refund actions, Filament resources (T5.8-T5.13)
- InitiatePaymentAction + POST /api/v1/payments/{booking}/initiate
- Generic KBZ webhook (POST /api/v1/webhooks/{method}/{encryptBookingId?}),
signature verification per KBZ's real callback spec, PaymentGatewayInterface::handleWebhook()
- ConfirmPaymentAction: idempotent confirmation, PaymentCompleted/PaymentFailed events,
MarkBookingPaid listener
- RefundBookingAction + POST /api/v1/bookings/{booking}/refund: partial refunds validated
against remaining balance, RefundProcessed event, MarkBookingRefunded listener
- CancelBookingAction now refunds confirmed bookings instead of rejecting; BookingPolicy::cancel
requires process_refunds for confirmed bookings
- PaymentPlugin + PaymentResource/RefundResource Filament admin UI (read-only payments,
refund list + Process action)
- Booking detail page now shows related payments
- Fix CACHE_STORE mismatch (database -> redis) so tagged route caching works
- CLAUDE.md: never run migrate:fresh/migrate:refresh/db:wipe on dev without being asked
This commit is contained in:
@@ -0,0 +1,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,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);
|
||||
});
|
||||
@@ -30,6 +30,11 @@ class FakePaymentGateway implements PaymentGatewayInterface
|
||||
{
|
||||
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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user