diff --git a/.env.example b/.env.example index 0fd9afe..376249d 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app-modules/booking/src/Models/Booking.php b/app-modules/booking/src/Models/Booking.php index aaaca06..771b15f 100644 --- a/app-modules/booking/src/Models/Booking.php +++ b/app-modules/booking/src/Models/Booking.php @@ -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); + } } diff --git a/app-modules/payment/database/factories/PaymentFactory.php b/app-modules/payment/database/factories/PaymentFactory.php new file mode 100644 index 0000000..c838397 --- /dev/null +++ b/app-modules/payment/database/factories/PaymentFactory.php @@ -0,0 +1,54 @@ + + */ +class PaymentFactory extends Factory +{ + protected $model = Payment::class; + + /** + * Define the model's default state. + * + * @return array + */ + 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(), + ]); + } +} diff --git a/app-modules/payment/database/factories/RefundFactory.php b/app-modules/payment/database/factories/RefundFactory.php new file mode 100644 index 0000000..4d61726 --- /dev/null +++ b/app-modules/payment/database/factories/RefundFactory.php @@ -0,0 +1,52 @@ + + */ +class RefundFactory extends Factory +{ + protected $model = Refund::class; + + /** + * Define the model's default state. + * + * @return array + */ + 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(), + ]); + } +} diff --git a/app-modules/payment/database/migrations/2026_08_08_100000_create_payments_table.php b/app-modules/payment/database/migrations/2026_08_08_100000_create_payments_table.php new file mode 100644 index 0000000..8726f92 --- /dev/null +++ b/app-modules/payment/database/migrations/2026_08_08_100000_create_payments_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/app-modules/payment/database/migrations/2026_08_08_100100_create_refunds_table.php b/app-modules/payment/database/migrations/2026_08_08_100100_create_refunds_table.php new file mode 100644 index 0000000..7eca79b --- /dev/null +++ b/app-modules/payment/database/migrations/2026_08_08_100100_create_refunds_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/app-modules/payment/src/Contracts/PaymentGatewayInterface.php b/app-modules/payment/src/Contracts/PaymentGatewayInterface.php new file mode 100644 index 0000000..4679211 --- /dev/null +++ b/app-modules/payment/src/Contracts/PaymentGatewayInterface.php @@ -0,0 +1,33 @@ + $gatewayPayload Raw gateway response, persisted verbatim for audit. + */ + public function __construct( + public PaymentStatus $status, + public ?string $gatewayTransactionId, + public array $gatewayPayload, + public ?string $message = null, + ) {} +} diff --git a/app-modules/payment/src/Data/RefundResultData.php b/app-modules/payment/src/Data/RefundResultData.php new file mode 100644 index 0000000..21675ee --- /dev/null +++ b/app-modules/payment/src/Data/RefundResultData.php @@ -0,0 +1,21 @@ + $gatewayPayload Raw gateway response, persisted verbatim for audit. + */ + public function __construct( + public RefundStatus $status, + public ?string $gatewayRefundId, + public array $gatewayPayload, + public ?string $message = null, + ) {} +} diff --git a/app-modules/payment/src/Enums/PaymentMethod.php b/app-modules/payment/src/Enums/PaymentMethod.php new file mode 100644 index 0000000..0165086 --- /dev/null +++ b/app-modules/payment/src/Enums/PaymentMethod.php @@ -0,0 +1,15 @@ +> + */ + private array $bindings = []; + + /** + * @param class-string $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); + } +} diff --git a/app-modules/payment/src/Gateways/KbzMiniAppGateway.php b/app-modules/payment/src/Gateways/KbzMiniAppGateway.php new file mode 100644 index 0000000..692d33a --- /dev/null +++ b/app-modules/payment/src/Gateways/KbzMiniAppGateway.php @@ -0,0 +1,287 @@ +|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 $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 $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 $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, + ); + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/app-modules/payment/src/Models/Payment.php b/app-modules/payment/src/Models/Payment.php new file mode 100644 index 0000000..b40c557 --- /dev/null +++ b/app-modules/payment/src/Models/Payment.php @@ -0,0 +1,63 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'booking_id', + 'gateway', + 'status', + 'amount', + 'currency', + 'gateway_transaction_id', + 'gateway_payload', + 'initiated_at', + 'completed_at', + ]; + + /** + * @return array + */ + 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); + } +} diff --git a/app-modules/payment/src/Models/Refund.php b/app-modules/payment/src/Models/Refund.php new file mode 100644 index 0000000..c8160ff --- /dev/null +++ b/app-modules/payment/src/Models/Refund.php @@ -0,0 +1,60 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'payment_id', + 'status', + 'amount', + 'reason', + 'gateway_refund_id', + 'gateway_payload', + 'requested_by', + 'requested_at', + 'completed_at', + ]; + + /** + * @return array + */ + 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'); + } +} diff --git a/app-modules/payment/src/Providers/PaymentServiceProvider.php b/app-modules/payment/src/Providers/PaymentServiceProvider.php index f2ae1c2..8a027c9 100644 --- a/app-modules/payment/src/Providers/PaymentServiceProvider.php +++ b/app-modules/payment/src/Providers/PaymentServiceProvider.php @@ -3,10 +3,21 @@ namespace Modules\Payment\Providers; use Illuminate\Support\ServiceProvider; +use Modules\Payment\Enums\PaymentMethod; +use Modules\Payment\Factories\PaymentGatewayFactory; +use Modules\Payment\Gateways\KbzMiniAppGateway; 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); + + return $factory; + }); + } public function boot(): void {} } diff --git a/app-modules/payment/src/Services/PaymentService.php b/app-modules/payment/src/Services/PaymentService.php new file mode 100644 index 0000000..75a5ac4 --- /dev/null +++ b/app-modules/payment/src/Services/PaymentService.php @@ -0,0 +1,36 @@ +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); + } +} diff --git a/app-modules/payment/src/Support/KbzSignature.php b/app-modules/payment/src/Support/KbzSignature.php new file mode 100644 index 0000000..6b1a4bf --- /dev/null +++ b/app-modules/payment/src/Support/KbzSignature.php @@ -0,0 +1,72 @@ + $data + * @param list $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 $data + * @param list $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 $skips + * @param list $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]; + } +} diff --git a/app-modules/payment/tests/Feature/KbzMiniAppGatewayInitiateTest.php b/app-modules/payment/tests/Feature/KbzMiniAppGatewayInitiateTest.php new file mode 100644 index 0000000..f68ec8d --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzMiniAppGatewayInitiateTest.php @@ -0,0 +1,77 @@ + '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'); +}); diff --git a/app-modules/payment/tests/Feature/KbzMiniAppGatewayRefundTest.php b/app-modules/payment/tests/Feature/KbzMiniAppGatewayRefundTest.php new file mode 100644 index 0000000..0bee9c6 --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzMiniAppGatewayRefundTest.php @@ -0,0 +1,97 @@ + '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([]); +}); diff --git a/app-modules/payment/tests/Feature/KbzMiniAppGatewayVerifyTest.php b/app-modules/payment/tests/Feature/KbzMiniAppGatewayVerifyTest.php new file mode 100644 index 0000000..20f62e2 --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzMiniAppGatewayVerifyTest.php @@ -0,0 +1,72 @@ + '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'); +}); diff --git a/app-modules/payment/tests/Feature/PaymentAndRefundTest.php b/app-modules/payment/tests/Feature/PaymentAndRefundTest.php new file mode 100644 index 0000000..95131b8 --- /dev/null +++ b/app-modules/payment/tests/Feature/PaymentAndRefundTest.php @@ -0,0 +1,88 @@ +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); +}); diff --git a/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php b/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php new file mode 100644 index 0000000..a1b9994 --- /dev/null +++ b/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php @@ -0,0 +1,60 @@ +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)); +}); diff --git a/app-modules/payment/tests/Unit/KbzSignatureTest.php b/app-modules/payment/tests/Unit/KbzSignatureTest.php new file mode 100644 index 0000000..54c191e --- /dev/null +++ b/app-modules/payment/tests/Unit/KbzSignatureTest.php @@ -0,0 +1,60 @@ + '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'); +}); diff --git a/app-modules/payment/tests/Unit/PaymentServiceTest.php b/app-modules/payment/tests/Unit/PaymentServiceTest.php new file mode 100644 index 0000000..767dfae --- /dev/null +++ b/app-modules/payment/tests/Unit/PaymentServiceTest.php @@ -0,0 +1,60 @@ +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); +}); diff --git a/config/services.php b/config/services.php index ee75030..57389e4 100644 --- a/config/services.php +++ b/config/services.php @@ -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'),