'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 */ 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); });