Add Payment module: contracts, payments/refunds tables, KBZ gateway, factory, orchestrator (T5.1-T5.7)

- T5.1 PaymentGatewayInterface, DTOs, PaymentMethod/PaymentStatus/RefundStatus enums
- T5.2 payments/refunds tables, models, factories
- T5.3-T5.5 KbzMiniAppGateway: initiate()/verify()/refund(), ported KBZ signing scheme,
  wired refund_amount through for partial refunds, mTLS options for refund
- T5.6 PaymentGatewayFactory resolving gateways by PaymentMethod
- T5.7 PaymentService orchestrator delegating to the resolved gateway
This commit is contained in:
Nyan Lin Paing
2026-08-08 22:42:16 +07:00
parent e0bcc5f81a
commit 4737838021
28 changed files with 1407 additions and 1 deletions
@@ -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,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,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\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');
}
}
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,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&notify_url=https://example.com/api/v1/webhooks/kbz'
.'&timestamp=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);
});