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,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();
|
||||
});
|
||||
Reference in New Issue
Block a user