From 47378380218a56768ec600bc50148fdb41f4e513 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:42:16 +0700 Subject: [PATCH 1/2] 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 --- .env.example | 1 + app-modules/booking/src/Models/Booking.php | 6 + .../database/factories/PaymentFactory.php | 54 ++++ .../database/factories/RefundFactory.php | 52 ++++ ...026_08_08_100000_create_payments_table.php | 40 +++ ...2026_08_08_100100_create_refunds_table.php | 40 +++ .../src/Contracts/PaymentGatewayInterface.php | 33 ++ .../payment/src/Data/PaymentRequestData.php | 20 ++ .../payment/src/Data/PaymentResultData.php | 21 ++ .../payment/src/Data/RefundResultData.php | 21 ++ .../payment/src/Enums/PaymentMethod.php | 15 + .../payment/src/Enums/PaymentStatus.php | 10 + .../payment/src/Enums/RefundStatus.php | 10 + .../src/Factories/PaymentGatewayFactory.php | 39 +++ .../src/Gateways/KbzMiniAppGateway.php | 287 ++++++++++++++++++ app-modules/payment/src/Models/Payment.php | 63 ++++ app-modules/payment/src/Models/Refund.php | 60 ++++ .../src/Providers/PaymentServiceProvider.php | 13 +- .../payment/src/Services/PaymentService.php | 36 +++ .../payment/src/Support/KbzSignature.php | 72 +++++ .../Feature/KbzMiniAppGatewayInitiateTest.php | 77 +++++ .../Feature/KbzMiniAppGatewayRefundTest.php | 97 ++++++ .../Feature/KbzMiniAppGatewayVerifyTest.php | 72 +++++ .../tests/Feature/PaymentAndRefundTest.php | 88 ++++++ .../Feature/PaymentGatewayFactoryTest.php | 60 ++++ .../payment/tests/Unit/KbzSignatureTest.php | 60 ++++ .../payment/tests/Unit/PaymentServiceTest.php | 60 ++++ config/services.php | 1 + 28 files changed, 1407 insertions(+), 1 deletion(-) create mode 100644 app-modules/payment/database/factories/PaymentFactory.php create mode 100644 app-modules/payment/database/factories/RefundFactory.php create mode 100644 app-modules/payment/database/migrations/2026_08_08_100000_create_payments_table.php create mode 100644 app-modules/payment/database/migrations/2026_08_08_100100_create_refunds_table.php create mode 100644 app-modules/payment/src/Contracts/PaymentGatewayInterface.php create mode 100644 app-modules/payment/src/Data/PaymentRequestData.php create mode 100644 app-modules/payment/src/Data/PaymentResultData.php create mode 100644 app-modules/payment/src/Data/RefundResultData.php create mode 100644 app-modules/payment/src/Enums/PaymentMethod.php create mode 100644 app-modules/payment/src/Enums/PaymentStatus.php create mode 100644 app-modules/payment/src/Enums/RefundStatus.php create mode 100644 app-modules/payment/src/Factories/PaymentGatewayFactory.php create mode 100644 app-modules/payment/src/Gateways/KbzMiniAppGateway.php create mode 100644 app-modules/payment/src/Models/Payment.php create mode 100644 app-modules/payment/src/Models/Refund.php create mode 100644 app-modules/payment/src/Services/PaymentService.php create mode 100644 app-modules/payment/src/Support/KbzSignature.php create mode 100644 app-modules/payment/tests/Feature/KbzMiniAppGatewayInitiateTest.php create mode 100644 app-modules/payment/tests/Feature/KbzMiniAppGatewayRefundTest.php create mode 100644 app-modules/payment/tests/Feature/KbzMiniAppGatewayVerifyTest.php create mode 100644 app-modules/payment/tests/Feature/PaymentAndRefundTest.php create mode 100644 app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php create mode 100644 app-modules/payment/tests/Unit/KbzSignatureTest.php create mode 100644 app-modules/payment/tests/Unit/PaymentServiceTest.php 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'), From d19a14a45ed9f99eb8e55e95bede37f36799f084 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:20:21 +0700 Subject: [PATCH 2/2] 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 --- CLAUDE.md | 5 + .../src/Actions/CancelBookingAction.php | 30 +++- .../BookingCannotBeCancelledException.php | 11 +- .../Bookings/Schemas/BookingInfolist.php | 29 ++++ .../Http/Controllers/BookingController.php | 4 +- .../booking/src/Policies/BookingPolicy.php | 23 ++- .../tests/Feature/BookingCancelApiTest.php | 75 ++++++++- .../tests/Feature/BookingResourceTest.php | 30 +++- .../tests/Unit/CancelBookingActionTest.php | 67 +++++++- app-modules/payment/routes/payment-routes.php | 17 ++ .../src/Actions/ConfirmPaymentAction.php | 62 +++++++ .../src/Actions/InitiatePaymentAction.php | 88 ++++++++++ .../src/Actions/RefundBookingAction.php | 84 ++++++++++ .../src/Contracts/PaymentGatewayInterface.php | 15 ++ .../payment/src/Events/PaymentCompleted.php | 18 ++ .../payment/src/Events/PaymentFailed.php | 18 ++ .../payment/src/Events/RefundProcessed.php | 18 ++ .../InvalidWebhookSignatureException.php | 26 +++ .../PaymentInitiationNotAllowedException.php | 27 +++ .../src/Exceptions/RefundFailedException.php | 31 ++++ .../Exceptions/RefundNotAllowedException.php | 40 +++++ .../payment/src/Filament/Pages/.gitkeep | 0 .../Resources/Payments/Pages/ListPayments.php | 18 ++ .../Resources/Payments/Pages/ViewPayment.php | 11 ++ .../Resources/Payments/PaymentResource.php | 47 ++++++ .../Payments/Schemas/PaymentInfolist.php | 55 +++++++ .../Payments/Tables/PaymentsTable.php | 66 ++++++++ .../Refunds/Actions/ProcessRefundAction.php | 74 +++++++++ .../Resources/Refunds/Pages/ListRefunds.php | 21 +++ .../Resources/Refunds/RefundResource.php | 39 +++++ .../Resources/Refunds/Tables/RefundsTable.php | 62 +++++++ .../payment/src/Filament/Widgets/.gitkeep | 0 .../src/Gateways/KbzMiniAppGateway.php | 42 +++++ .../Http/Controllers/PaymentController.php | 28 ++++ .../Controllers/PaymentWebhookController.php | 92 +++++++++++ .../src/Http/Controllers/RefundController.php | 36 ++++ .../Http/Requests/RefundBookingRequest.php | 28 ++++ .../src/Http/Resources/PaymentResource.php | 31 ++++ .../src/Http/Resources/RefundResource.php | 29 ++++ .../payment/src/Listeners/MarkBookingPaid.php | 25 +++ .../src/Listeners/MarkBookingRefunded.php | 25 +++ app-modules/payment/src/PaymentPlugin.php | 38 +++++ .../src/Providers/PaymentServiceProvider.php | 11 +- .../Feature/ConfirmPaymentActionTest.php | 153 +++++++++++++++++ .../tests/Feature/InitiatePaymentApiTest.php | 137 ++++++++++++++++ .../KbzMiniAppGatewayHandleWebhookTest.php | 79 +++++++++ .../tests/Feature/KbzWebhookApiTest.php | 93 +++++++++++ .../Feature/KbzWebhookConfirmationTest.php | 89 ++++++++++ .../tests/Feature/MarkBookingPaidTest.php | 26 +++ .../Feature/PaymentGatewayFactoryTest.php | 5 + .../tests/Feature/PaymentResourceTest.php | 80 +++++++++ .../tests/Feature/RefundBookingActionTest.php | 154 ++++++++++++++++++ .../tests/Feature/RefundBookingApiTest.php | 136 ++++++++++++++++ .../tests/Feature/RefundResourceTest.php | 126 ++++++++++++++ app/Providers/Filament/AdminPanelProvider.php | 2 + 55 files changed, 2547 insertions(+), 29 deletions(-) create mode 100644 app-modules/payment/src/Actions/ConfirmPaymentAction.php create mode 100644 app-modules/payment/src/Actions/InitiatePaymentAction.php create mode 100644 app-modules/payment/src/Actions/RefundBookingAction.php create mode 100644 app-modules/payment/src/Events/PaymentCompleted.php create mode 100644 app-modules/payment/src/Events/PaymentFailed.php create mode 100644 app-modules/payment/src/Events/RefundProcessed.php create mode 100644 app-modules/payment/src/Exceptions/InvalidWebhookSignatureException.php create mode 100644 app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php create mode 100644 app-modules/payment/src/Exceptions/RefundFailedException.php create mode 100644 app-modules/payment/src/Exceptions/RefundNotAllowedException.php create mode 100644 app-modules/payment/src/Filament/Pages/.gitkeep create mode 100644 app-modules/payment/src/Filament/Resources/Payments/Pages/ListPayments.php create mode 100644 app-modules/payment/src/Filament/Resources/Payments/Pages/ViewPayment.php create mode 100644 app-modules/payment/src/Filament/Resources/Payments/PaymentResource.php create mode 100644 app-modules/payment/src/Filament/Resources/Payments/Schemas/PaymentInfolist.php create mode 100644 app-modules/payment/src/Filament/Resources/Payments/Tables/PaymentsTable.php create mode 100644 app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php create mode 100644 app-modules/payment/src/Filament/Resources/Refunds/Pages/ListRefunds.php create mode 100644 app-modules/payment/src/Filament/Resources/Refunds/RefundResource.php create mode 100644 app-modules/payment/src/Filament/Resources/Refunds/Tables/RefundsTable.php create mode 100644 app-modules/payment/src/Filament/Widgets/.gitkeep create mode 100644 app-modules/payment/src/Http/Controllers/PaymentController.php create mode 100644 app-modules/payment/src/Http/Controllers/PaymentWebhookController.php create mode 100644 app-modules/payment/src/Http/Controllers/RefundController.php create mode 100644 app-modules/payment/src/Http/Requests/RefundBookingRequest.php create mode 100644 app-modules/payment/src/Http/Resources/PaymentResource.php create mode 100644 app-modules/payment/src/Http/Resources/RefundResource.php create mode 100644 app-modules/payment/src/Listeners/MarkBookingPaid.php create mode 100644 app-modules/payment/src/Listeners/MarkBookingRefunded.php create mode 100644 app-modules/payment/src/PaymentPlugin.php create mode 100644 app-modules/payment/tests/Feature/ConfirmPaymentActionTest.php create mode 100644 app-modules/payment/tests/Feature/InitiatePaymentApiTest.php create mode 100644 app-modules/payment/tests/Feature/KbzMiniAppGatewayHandleWebhookTest.php create mode 100644 app-modules/payment/tests/Feature/KbzWebhookApiTest.php create mode 100644 app-modules/payment/tests/Feature/KbzWebhookConfirmationTest.php create mode 100644 app-modules/payment/tests/Feature/MarkBookingPaidTest.php create mode 100644 app-modules/payment/tests/Feature/PaymentResourceTest.php create mode 100644 app-modules/payment/tests/Feature/RefundBookingActionTest.php create mode 100644 app-modules/payment/tests/Feature/RefundBookingApiTest.php create mode 100644 app-modules/payment/tests/Feature/RefundResourceTest.php diff --git a/CLAUDE.md b/CLAUDE.md index d669edf..a8290a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,6 +164,11 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - For binaries not wrapped by Sail's own commands (e.g. Pint), run them inside the container: `./vendor/bin/sail exec laravel.test vendor/bin/pint --dirty --format agent`. - Check containers are up first with `./vendor/bin/sail ps` before running commands; start them with `./vendor/bin/sail up -d` if they aren't. +## Database / Migrations + +- **Never run `php artisan migrate:fresh`, `migrate:refresh`, `migrate:reset`, or `db:wipe` against the dev database unless the user explicitly asks for it in that turn.** These drop/recreate all tables and destroy dev data. Use `php artisan migrate` (apply pending) and `php artisan migrate:rollback` (undo the last batch) instead for normal migration work. +- Dev data loss happened once (2026-08-08 ~22:05 local) from exactly this kind of command — do not repeat it. + ## Architecture / ERD Diagram - The canonical tldraw board for this project's architecture and ERD lives at `/home/marcspecta/Documents/EV Booking System Architecture.tldraw` (outside the repo — not committed). Use this path when opening/updating the board with the tldraw-offline skill/agent. diff --git a/app-modules/booking/src/Actions/CancelBookingAction.php b/app-modules/booking/src/Actions/CancelBookingAction.php index 231be9f..c086442 100644 --- a/app-modules/booking/src/Actions/CancelBookingAction.php +++ b/app-modules/booking/src/Actions/CancelBookingAction.php @@ -5,17 +5,37 @@ namespace Modules\Booking\Actions; use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Exceptions\BookingCannotBeCancelledException; use Modules\Booking\Models\Booking; +use Modules\Payment\Actions\RefundBookingAction; /** - * Unpaid path only — a pending_payment booking has no money moved yet, so - * it can be cancelled directly. A confirmed (paid) booking must go through - * a refund first; this action explicitly guards against bypassing that - * (domain.md §5). Wired into that refund path in T5.12. + * A pending_payment booking has no money moved yet, so it cancels directly. + * A confirmed (paid) booking is cancelled by refunding it in full first — + * delegates to RefundBookingAction (Payment module); the booking only + * actually flips to cancelled once that refund succeeds, via + * RefundProcessed/MarkBookingRefunded, not here (domain.md §5). Any other + * status (already cancelled/expired) is rejected outright. */ class CancelBookingAction { - public function handle(Booking $booking): Booking + private const CANCELLATION_REFUND_REASON = 'Booking cancellation'; + + public function __construct( + private RefundBookingAction $refundBookingAction, + ) {} + + public function handle(Booking $booking, ?int $requestedBy = null): Booking { + if ($booking->status === BookingStatus::Confirmed) { + $this->refundBookingAction->handle( + $booking, + (string) $booking->price, + self::CANCELLATION_REFUND_REASON, + $requestedBy, + ); + + return $booking->refresh(); + } + if ($booking->status !== BookingStatus::PendingPayment) { throw BookingCannotBeCancelledException::notPendingPayment($booking); } diff --git a/app-modules/booking/src/Exceptions/BookingCannotBeCancelledException.php b/app-modules/booking/src/Exceptions/BookingCannotBeCancelledException.php index c59ed6e..a1543b1 100644 --- a/app-modules/booking/src/Exceptions/BookingCannotBeCancelledException.php +++ b/app-modules/booking/src/Exceptions/BookingCannotBeCancelledException.php @@ -4,19 +4,20 @@ namespace Modules\Booking\Exceptions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Models\Booking; use RuntimeException; class BookingCannotBeCancelledException extends RuntimeException { + /** + * Confirmed bookings no longer reach this — CancelBookingAction (T5.12) + * refunds them instead. This is only for statuses that can't be + * cancelled at all (already cancelled/expired). + */ public static function notPendingPayment(Booking $booking): self { return new self( - "Booking [{$booking->booking_ref}] cannot be cancelled directly because its status is [{$booking->status->value}]." - .($booking->status === BookingStatus::Confirmed - ? ' A confirmed (paid) booking must go through a refund first.' - : '') + "Booking [{$booking->booking_ref}] cannot be cancelled because its status is [{$booking->status->value}]." ); } diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php b/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php index eb31e4e..d5ff989 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php @@ -8,6 +8,7 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Modules\Booking\Enums\BookingStatus; +use Modules\Payment\Enums\PaymentStatus; class BookingInfolist { @@ -92,6 +93,34 @@ class BookingInfolist TextEntry::make('car_model')->label('Car Model')->placeholder('—'), ]), ]), + // A booking can have more than one payment attempt if an + // earlier one failed and the customer retried (domain.md §1) + // — full detail (gateway response, refunds) lives on the + // Payment/Refund Filament resources (T5.13), this is just a + // quick-glance summary from the booking side. + Section::make('Payments') + ->schema([ + RepeatableEntry::make('payments') + ->label('') + ->schema([ + Grid::make(6) + ->schema([ + TextEntry::make('gateway')->badge(), + TextEntry::make('status') + ->badge() + ->color(fn (PaymentStatus $state) => match ($state) { + PaymentStatus::Pending => 'warning', + PaymentStatus::Completed => 'success', + PaymentStatus::Failed => 'danger', + }), + TextEntry::make('amount')->numeric(2), + TextEntry::make('currency'), + TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID')->placeholder('—'), + TextEntry::make('completed_at')->dateTime()->placeholder('—'), + ]), + ]) + ->placeholder('No payment attempts yet.'), + ]), ]); } } diff --git a/app-modules/booking/src/Http/Controllers/BookingController.php b/app-modules/booking/src/Http/Controllers/BookingController.php index f19e0ba..42aee6f 100644 --- a/app-modules/booking/src/Http/Controllers/BookingController.php +++ b/app-modules/booking/src/Http/Controllers/BookingController.php @@ -88,11 +88,11 @@ class BookingController extends Controller ->setStatusCode(201); } - public function cancel(Booking $booking): BookingResource + public function cancel(Request $request, Booking $booking): BookingResource { Gate::authorize('cancel', $booking); - $this->cancelBookingAction->handle($booking); + $this->cancelBookingAction->handle($booking, $request->user()?->id); return new BookingResource($booking->load(self::EAGER_LOADS)); } diff --git a/app-modules/booking/src/Policies/BookingPolicy.php b/app-modules/booking/src/Policies/BookingPolicy.php index 372f95c..b6cd470 100644 --- a/app-modules/booking/src/Policies/BookingPolicy.php +++ b/app-modules/booking/src/Policies/BookingPolicy.php @@ -3,6 +3,7 @@ namespace Modules\Booking\Policies; use App\Models\User; +use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Models\Booking; class BookingPolicy @@ -33,12 +34,18 @@ class BookingPolicy } /** - * A booking's owner may cancel their own (still pending_payment only — - * enforced by CancelBookingAction, not here); staff can cancel any - * booking via manage_bookings (domain.md §8). + * A booking's owner may cancel their own pending_payment booking; staff + * can cancel any pending_payment booking via manage_bookings. Cancelling + * a confirmed (paid) booking refunds it (CancelBookingAction, T5.12) — + * that's the same authorization boundary as refund(), staff only + * (domain.md §8: refund initiation is a staff-only operation). */ public function cancel(User $user, Booking $booking): bool { + if ($booking->status === BookingStatus::Confirmed) { + return $user->can('process_refunds'); + } + return $user->id === $booking->user_id || $user->can('manage_bookings'); } @@ -46,4 +53,14 @@ class BookingPolicy { return $user->can('process_refunds'); } + + /** + * A booking's owner may pay for their own (still pending_payment only — + * enforced by InitiatePaymentAction, not here); staff can initiate on + * behalf of a customer via manage_bookings. + */ + public function pay(User $user, Booking $booking): bool + { + return $user->id === $booking->user_id || $user->can('manage_bookings'); + } } diff --git a/app-modules/booking/tests/Feature/BookingCancelApiTest.php b/app-modules/booking/tests/Feature/BookingCancelApiTest.php index 77e62ae..9a3985f 100644 --- a/app-modules/booking/tests/Feature/BookingCancelApiTest.php +++ b/app-modules/booking/tests/Feature/BookingCancelApiTest.php @@ -3,10 +3,48 @@ 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; +/** + * Never calls the real KBZ refund API in tests. + */ +class FakeCancelApiRefundGateway 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 () { - Permission::findOrCreate('manage_bookings', 'web'); + foreach (['manage_bookings', 'process_refunds'] as $permission) { + Permission::findOrCreate($permission, 'web'); + } + + app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelApiRefundGateway::class); $this->owner = User::factory()->create(); $this->token = $this->owner->createToken('test-token')->plainTextToken; @@ -23,10 +61,43 @@ test('the owner can cancel their own pending_payment booking', function () { expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); }); -test('cancelling a confirmed booking surfaces as 422 and leaves it untouched', function () { +test('the owner cannot cancel their own confirmed booking without process_refunds', function () { $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]); $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertForbidden(); + + expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed); +}); + +test('staff with process_refunds can cancel a confirmed booking, which refunds it in full', function () { + $staff = User::factory()->create()->givePermissionTo('process_refunds'); + $staffToken = $staff->createToken('staff-token')->plainTextToken; + + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]); + Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-CANCEL-API-1', + ]); + + $this->withHeader('Authorization', "Bearer {$staffToken}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertSuccessful() + ->assertJsonPath('data.status', BookingStatus::Cancelled->value); + + expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); +}); + +test('cancelling a confirmed booking with no completed payment surfaces as 422 and leaves it untouched', function () { + $staff = User::factory()->create()->givePermissionTo('process_refunds'); + $staffToken = $staff->createToken('staff-token')->plainTextToken; + + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]); + + $this->withHeader('Authorization', "Bearer {$staffToken}") ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") ->assertStatus(422); diff --git a/app-modules/booking/tests/Feature/BookingResourceTest.php b/app-modules/booking/tests/Feature/BookingResourceTest.php index bfcdafd..8fb26f3 100644 --- a/app-modules/booking/tests/Feature/BookingResourceTest.php +++ b/app-modules/booking/tests/Feature/BookingResourceTest.php @@ -7,15 +7,18 @@ use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings; use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking; use Modules\Booking\Models\Booking; use Modules\Booking\Models\BookingVehicleOption; +use Modules\Payment\Enums\PaymentMethod; +use Modules\Payment\Enums\PaymentStatus; +use Modules\Payment\Models\Payment; use Modules\Shared\Enums\VehicleOption; use Spatie\Permission\Models\Permission; beforeEach(function () { - foreach (['view_bookings', 'manage_bookings'] as $permission) { + foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) { Permission::findOrCreate($permission, 'web'); } - $this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings']); + $this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings', 'process_refunds']); $this->actingAs($this->admin); }); @@ -114,6 +117,29 @@ test('can view a booking\'s detail page', function () { ->assertSee($booking->dropoff_address); }); +test('the booking detail page shows its related payments', function () { + $booking = Booking::factory()->create(); + + Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'gateway_transaction_id' => 'EVB-INFOLIST-TEST-1', + ]); + + Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()]) + ->assertOk() + ->assertSee('EVB-INFOLIST-TEST-1') + ->assertSee(PaymentStatus::Completed->value); +}); + +test('the booking detail page shows a placeholder when there are no payments yet', function () { + $booking = Booking::factory()->create(); + + Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()]) + ->assertOk() + ->assertSee('No payment attempts yet.'); +}); + test('the assign driver action is visible for a confirmed booking and hidden otherwise', function () { $confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]); $pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); diff --git a/app-modules/booking/tests/Unit/CancelBookingActionTest.php b/app-modules/booking/tests/Unit/CancelBookingActionTest.php index 1422beb..aedaf90 100644 --- a/app-modules/booking/tests/Unit/CancelBookingActionTest.php +++ b/app-modules/booking/tests/Unit/CancelBookingActionTest.php @@ -4,35 +4,84 @@ use Modules\Booking\Actions\CancelBookingAction; use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Exceptions\BookingCannotBeCancelledException; 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; -test('it cancels a pending_payment booking', function () { +/** + * Never calls the real KBZ refund API in tests. + */ +class FakeCancelRefundGateway implements PaymentGatewayInterface +{ + public static ?string $lastAmount = 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; + + 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 () { + FakeCancelRefundGateway::$lastAmount = null; + app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelRefundGateway::class); +}); + +test('it cancels a pending_payment booking directly', function () { $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); - $cancelled = (new CancelBookingAction)->handle($booking); + $cancelled = app(CancelBookingAction::class)->handle($booking); expect($cancelled->status)->toBe(BookingStatus::Cancelled) ->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled); }); -test('it guards against cancelling a confirmed booking', function () { - $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); +test('it cancels a confirmed booking by refunding it in full', 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-CANCEL-TEST-1', + ]); - expect(fn () => (new CancelBookingAction)->handle($booking)) - ->toThrow(BookingCannotBeCancelledException::class); + $cancelled = app(CancelBookingAction::class)->handle($booking); - expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed); + expect($cancelled->status)->toBe(BookingStatus::Cancelled) + ->and(FakeCancelRefundGateway::$lastAmount)->toBe('15000.00'); }); test('it guards against cancelling an already cancelled booking', function () { $booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]); - expect(fn () => (new CancelBookingAction)->handle($booking)) + expect(fn () => app(CancelBookingAction::class)->handle($booking)) ->toThrow(BookingCannotBeCancelledException::class); }); test('it guards against cancelling an expired booking', function () { $booking = Booking::factory()->create(['status' => BookingStatus::Expired]); - expect(fn () => (new CancelBookingAction)->handle($booking)) + expect(fn () => app(CancelBookingAction::class)->handle($booking)) ->toThrow(BookingCannotBeCancelledException::class); }); diff --git a/app-modules/payment/routes/payment-routes.php b/app-modules/payment/routes/payment-routes.php index b3d9bbc..d1d63c0 100644 --- a/app-modules/payment/routes/payment-routes.php +++ b/app-modules/payment/routes/payment-routes.php @@ -1 +1,18 @@ middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { + Route::post('/payments/{booking:booking_ref}/initiate', [PaymentController::class, 'initiate'])->name('payment.payments.initiate'); + Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund'); +}); + +// No auth:sanctum — the gateway authenticates itself via its own signed +// payload (verified inside each gateway's handleWebhook()), not a bearer +// token (domain.md §6). +Route::prefix('api/v1')->middleware(['api', 'throttle:60,1'])->group(function () { + Route::post('/webhooks/{method}/{encryptBookingId?}', [PaymentWebhookController::class, 'handle'])->name('payment.webhooks.handle'); +}); diff --git a/app-modules/payment/src/Actions/ConfirmPaymentAction.php b/app-modules/payment/src/Actions/ConfirmPaymentAction.php new file mode 100644 index 0000000..7d77340 --- /dev/null +++ b/app-modules/payment/src/Actions/ConfirmPaymentAction.php @@ -0,0 +1,62 @@ +where('gateway_transaction_id', $gatewayTransactionId) + ->first(); + + if ($payment === null || $payment->status !== PaymentStatus::Pending) { + return $payment; + } + + $verified = $this->paymentService->verify($method, $gatewayTransactionId); + + if ($verified->status === PaymentStatus::Pending) { + return $payment; + } + + return DB::transaction(function () use ($payment, $verified) { + $payment->update([ + 'status' => $verified->status, + 'gateway_payload' => $verified->gatewayPayload, + 'completed_at' => now(), + ]); + + match ($verified->status) { + PaymentStatus::Completed => PaymentCompleted::dispatch($payment), + PaymentStatus::Failed => PaymentFailed::dispatch($payment), + PaymentStatus::Pending => null, + }; + + return $payment; + }); + } +} diff --git a/app-modules/payment/src/Actions/InitiatePaymentAction.php b/app-modules/payment/src/Actions/InitiatePaymentAction.php new file mode 100644 index 0000000..a71a902 --- /dev/null +++ b/app-modules/payment/src/Actions/InitiatePaymentAction.php @@ -0,0 +1,88 @@ +status !== BookingStatus::PendingPayment) { + throw PaymentInitiationNotAllowedException::notPendingPayment($booking); + } + + $merchantOrderId = $this->merchantOrderId($booking); + + $result = $this->paymentService->initiate(new PaymentRequestData( + bookingId: $booking->id, + merchantOrderId: $merchantOrderId, + amount: (string) $booking->price, + currency: self::CURRENCY, + method: $method, + notifyUrl: $this->notifyUrl($booking, $method), + )); + + return DB::transaction(fn () => Payment::create([ + 'booking_id' => $booking->id, + 'gateway' => $method, + 'status' => $result->status, + 'amount' => $booking->price, + 'currency' => self::CURRENCY, + 'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId, + 'gateway_payload' => $result->gatewayPayload, + 'initiated_at' => now(), + ])); + } + + /** + * A booking can have more than one payment attempt (retry after + * failure), so the merchant order id must be unique per attempt, not + * just per booking — suffixed with the attempt number. + */ + private function merchantOrderId(Booking $booking): string + { + $attempt = $booking->payments()->count() + 1; + + return "{$booking->booking_ref}-{$attempt}"; + } + + /** + * Embeds the booking id (encrypted, so the URL doesn't leak a raw + * sequential id) as an optional path segment on the webhook URL — + * mirrors bnf_event's `{encryptOrderId?}` on `paymentComplete`, giving + * the webhook a direct way to locate the booking as a redundant check + * alongside `merch_order_id` in the signed payload. Uses Laravel's + * Crypt facade rather than porting bnf_event's hand-rolled openssl + * helper (BNFEventEncryption) — same idea, standard implementation. + */ + private function notifyUrl(Booking $booking, PaymentMethod $method): string + { + return route('payment.webhooks.handle', [ + 'method' => $method->value, + 'encryptBookingId' => Crypt::encryptString((string) $booking->id), + ]); + } +} diff --git a/app-modules/payment/src/Actions/RefundBookingAction.php b/app-modules/payment/src/Actions/RefundBookingAction.php new file mode 100644 index 0000000..e93d0d0 --- /dev/null +++ b/app-modules/payment/src/Actions/RefundBookingAction.php @@ -0,0 +1,84 @@ +status !== BookingStatus::Confirmed) { + throw RefundNotAllowedException::notConfirmed($booking); + } + + $payment = $booking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first(); + + if ($payment === null) { + throw RefundNotAllowedException::noCompletedPayment($booking); + } + + $this->assertWithinRefundableBalance($payment, $amount); + + $result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason); + + $refund = DB::transaction(function () use ($payment, $amount, $reason, $result, $requestedBy) { + $refund = Refund::create([ + 'payment_id' => $payment->id, + 'status' => $result->status, + 'amount' => $amount, + 'reason' => $reason, + 'gateway_refund_id' => $result->gatewayRefundId, + 'gateway_payload' => $result->gatewayPayload, + 'requested_by' => $requestedBy, + 'requested_at' => now(), + 'completed_at' => now(), + ]); + + if ($result->status === RefundStatus::Completed) { + RefundProcessed::dispatch($refund); + } + + return $refund; + }); + + if ($result->status === RefundStatus::Failed) { + throw RefundFailedException::fromResult($result); + } + + return $refund; + } + + private function assertWithinRefundableBalance(Payment $payment, string $amount): void + { + $alreadyRefunded = (string) $payment->refunds()->where('status', RefundStatus::Completed->value)->sum('amount'); + $remaining = bcsub((string) $payment->amount, $alreadyRefunded, 2); + + if (bccomp($amount, $remaining, 2) === 1) { + throw RefundNotAllowedException::exceedsRefundableBalance($payment, $amount, $remaining); + } + } +} diff --git a/app-modules/payment/src/Contracts/PaymentGatewayInterface.php b/app-modules/payment/src/Contracts/PaymentGatewayInterface.php index 4679211..0d0f7ec 100644 --- a/app-modules/payment/src/Contracts/PaymentGatewayInterface.php +++ b/app-modules/payment/src/Contracts/PaymentGatewayInterface.php @@ -5,6 +5,7 @@ namespace Modules\Payment\Contracts; use Modules\Payment\Data\PaymentRequestData; use Modules\Payment\Data\PaymentResultData; use Modules\Payment\Data\RefundResultData; +use Modules\Payment\Exceptions\InvalidWebhookSignatureException; /** * Contract every payment gateway strategy implements (e.g. KbzMiniAppGateway). @@ -30,4 +31,18 @@ interface PaymentGatewayInterface * Reverse (all or part of) a successful payment. */ public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData; + + /** + * Handle an inbound webhook notification — verifies the payload is + * genuinely from the gateway (throwing InvalidWebhookSignatureException + * if not) and maps it to a PaymentResultData. Every gateway implements + * this itself since the notification shape and signing scheme are + * gateway-specific; the webhook controller only routes by PaymentMethod + * and never branches on gateway (domain.md §6). + * + * @param array $payload The raw, as-posted webhook body. + * + * @throws InvalidWebhookSignatureException + */ + public function handleWebhook(array $payload): PaymentResultData; } diff --git a/app-modules/payment/src/Events/PaymentCompleted.php b/app-modules/payment/src/Events/PaymentCompleted.php new file mode 100644 index 0000000..5dade9c --- /dev/null +++ b/app-modules/payment/src/Events/PaymentCompleted.php @@ -0,0 +1,18 @@ +value}]."); + } + + public function render(Request $request): ?JsonResponse + { + return response()->json(['message' => $this->getMessage()], 400); + } +} diff --git a/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php b/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php new file mode 100644 index 0000000..00bda2e --- /dev/null +++ b/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php @@ -0,0 +1,27 @@ +booking_ref}] cannot have a payment initiated because its status is [{$booking->status->value}], not pending_payment." + ); + } + + public function render(Request $request): ?JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['message' => $this->getMessage()], 422); + } + + return null; + } +} diff --git a/app-modules/payment/src/Exceptions/RefundFailedException.php b/app-modules/payment/src/Exceptions/RefundFailedException.php new file mode 100644 index 0000000..1065d86 --- /dev/null +++ b/app-modules/payment/src/Exceptions/RefundFailedException.php @@ -0,0 +1,31 @@ +message ?? 'Refund failed.'); + } + + public function render(Request $request): ?JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['message' => $this->getMessage()], 422); + } + + return null; + } +} diff --git a/app-modules/payment/src/Exceptions/RefundNotAllowedException.php b/app-modules/payment/src/Exceptions/RefundNotAllowedException.php new file mode 100644 index 0000000..6875058 --- /dev/null +++ b/app-modules/payment/src/Exceptions/RefundNotAllowedException.php @@ -0,0 +1,40 @@ +booking_ref}] cannot be refunded because its status is [{$booking->status->value}], not confirmed." + ); + } + + public static function noCompletedPayment(Booking $booking): self + { + return new self("Booking [{$booking->booking_ref}] has no completed payment to refund."); + } + + public static function exceedsRefundableBalance(Payment $payment, string $amount, string $remaining): self + { + return new self( + "Refund amount [{$amount}] exceeds the remaining refundable balance [{$remaining}] on payment [{$payment->id}]." + ); + } + + public function render(Request $request): ?JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['message' => $this->getMessage()], 422); + } + + return null; + } +} diff --git a/app-modules/payment/src/Filament/Pages/.gitkeep b/app-modules/payment/src/Filament/Pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/payment/src/Filament/Resources/Payments/Pages/ListPayments.php b/app-modules/payment/src/Filament/Resources/Payments/Pages/ListPayments.php new file mode 100644 index 0000000..a99ed11 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Payments/Pages/ListPayments.php @@ -0,0 +1,18 @@ + ListPayments::route('/'), + 'view' => ViewPayment::route('/{record}'), + ]; + } +} diff --git a/app-modules/payment/src/Filament/Resources/Payments/Schemas/PaymentInfolist.php b/app-modules/payment/src/Filament/Resources/Payments/Schemas/PaymentInfolist.php new file mode 100644 index 0000000..e57fd61 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Payments/Schemas/PaymentInfolist.php @@ -0,0 +1,55 @@ +components([ + Section::make('Payment') + ->schema([ + Grid::make(4) + ->schema([ + TextEntry::make('booking.booking_ref')->label('Booking'), + TextEntry::make('gateway')->badge(), + TextEntry::make('status') + ->badge() + ->color(fn (PaymentStatus $state) => match ($state) { + PaymentStatus::Pending => 'warning', + PaymentStatus::Completed => 'success', + PaymentStatus::Failed => 'danger', + }), + TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID'), + TextEntry::make('amount')->numeric(2), + TextEntry::make('currency'), + TextEntry::make('initiated_at')->dateTime(), + TextEntry::make('completed_at')->dateTime()->placeholder('—'), + ]), + ]), + // Raw gateway response — may include data not meant for the + // support role, so it's gated the same as refund initiation + // (process_refunds: admin/super_admin only, domain.md §6). + Section::make('Gateway Response') + ->visible(fn () => auth()->user()?->can('process_refunds') ?? false) + ->schema([ + TextEntry::make('gateway_payload') + ->label('') + ->formatStateUsing(fn (mixed $state) => match (true) { + is_array($state) => json_encode($state, JSON_PRETTY_PRINT), + is_string($state) && $state !== '' => $state, + default => null, + }) + ->placeholder('—') + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app-modules/payment/src/Filament/Resources/Payments/Tables/PaymentsTable.php b/app-modules/payment/src/Filament/Resources/Payments/Tables/PaymentsTable.php new file mode 100644 index 0000000..46160e7 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Payments/Tables/PaymentsTable.php @@ -0,0 +1,66 @@ +modifyQueryUsing(fn (Builder $query) => $query->with('booking')) + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('booking.booking_ref') + ->label('Booking') + ->searchable() + ->sortable(), + TextColumn::make('gateway') + ->badge(), + TextColumn::make('status') + ->badge() + ->color(fn (PaymentStatus $state) => match ($state) { + PaymentStatus::Pending => 'warning', + PaymentStatus::Completed => 'success', + PaymentStatus::Failed => 'danger', + }), + TextColumn::make('amount') + ->numeric(2) + ->sortable(), + TextColumn::make('currency'), + TextColumn::make('gateway_transaction_id') + ->label('Gateway Txn ID') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('initiated_at') + ->dateTime() + ->sortable(), + TextColumn::make('completed_at') + ->dateTime() + ->placeholder('—') + ->sortable(), + ]) + ->filters([ + SelectFilter::make('status') + ->options(array_combine( + array_map(fn (PaymentStatus $status) => $status->value, PaymentStatus::cases()), + array_map(fn (PaymentStatus $status) => str($status->value)->headline()->toString(), PaymentStatus::cases()), + )), + SelectFilter::make('gateway') + ->options(array_combine( + array_map(fn (PaymentMethod $method) => $method->value, PaymentMethod::cases()), + array_map(fn (PaymentMethod $method) => str($method->value)->headline()->toString(), PaymentMethod::cases()), + )), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php new file mode 100644 index 0000000..2c5ab98 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php @@ -0,0 +1,74 @@ +label('Process Refund') + ->icon(Heroicon::OutlinedReceiptRefund) + ->color('danger') + ->visible(fn (): bool => auth()->user()?->can('process_refunds') ?? false) + ->schema([ + Select::make('payment_id') + ->label('Payment') + ->options(fn () => Payment::query() + ->where('status', PaymentStatus::Completed->value) + ->with('booking') + ->get() + ->mapWithKeys(fn (Payment $payment) => [ + $payment->id => "{$payment->booking?->booking_ref} — {$payment->amount} {$payment->currency} (#{$payment->id})", + ])) + ->searchable() + ->required(), + TextInput::make('amount') + ->numeric() + ->minValue(0.01) + ->required(), + Textarea::make('reason') + ->required(), + ]) + ->action(function (array $data): void { + $payment = Payment::with('booking')->findOrFail($data['payment_id']); + + try { + app(RefundBookingAction::class)->handle( + $payment->booking, + (string) $data['amount'], + $data['reason'], + auth()->id(), + ); + + Notification::make() + ->title('Refund processed') + ->success() + ->send(); + } catch (RefundNotAllowedException|RefundFailedException $exception) { + Notification::make() + ->title('Refund failed') + ->body($exception->getMessage()) + ->danger() + ->send(); + } + }); + } +} diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Pages/ListRefunds.php b/app-modules/payment/src/Filament/Resources/Refunds/Pages/ListRefunds.php new file mode 100644 index 0000000..6d9dcdb --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Refunds/Pages/ListRefunds.php @@ -0,0 +1,21 @@ + ListRefunds::route('/'), + ]; + } +} diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Tables/RefundsTable.php b/app-modules/payment/src/Filament/Resources/Refunds/Tables/RefundsTable.php new file mode 100644 index 0000000..ac94908 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Refunds/Tables/RefundsTable.php @@ -0,0 +1,62 @@ +modifyQueryUsing(fn (Builder $query) => $query->with(['payment.booking', 'requestedBy'])) + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('payment.booking.booking_ref') + ->label('Booking') + ->searchable() + ->sortable(), + TextColumn::make('payment.gateway') + ->label('Gateway') + ->badge(), + TextColumn::make('status') + ->badge() + ->color(fn (RefundStatus $state) => match ($state) { + RefundStatus::Pending => 'warning', + RefundStatus::Completed => 'success', + RefundStatus::Failed => 'danger', + }), + TextColumn::make('amount') + ->numeric(2) + ->sortable(), + TextColumn::make('reason') + ->limit(40) + ->tooltip(fn (?string $state) => $state), + TextColumn::make('gateway_refund_id') + ->label('Gateway Refund ID') + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('requestedBy.name') + ->label('Requested By') + ->placeholder('—'), + TextColumn::make('requested_at') + ->dateTime() + ->sortable(), + TextColumn::make('completed_at') + ->dateTime() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->options(array_combine( + array_map(fn (RefundStatus $status) => $status->value, RefundStatus::cases()), + array_map(fn (RefundStatus $status) => str($status->value)->headline()->toString(), RefundStatus::cases()), + )), + ]); + } +} diff --git a/app-modules/payment/src/Filament/Widgets/.gitkeep b/app-modules/payment/src/Filament/Widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/payment/src/Gateways/KbzMiniAppGateway.php b/app-modules/payment/src/Gateways/KbzMiniAppGateway.php index 692d33a..8d13fa7 100644 --- a/app-modules/payment/src/Gateways/KbzMiniAppGateway.php +++ b/app-modules/payment/src/Gateways/KbzMiniAppGateway.php @@ -9,8 +9,10 @@ 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\Exceptions\InvalidWebhookSignatureException; use Modules\Payment\Support\KbzSignature; /** @@ -157,6 +159,46 @@ class KbzMiniAppGateway implements PaymentGatewayInterface ); } + /** + * bnf_event's equivalent (`OrderController::paymentComplete` / + * `KBZMiniApp::save`) trusted the raw `trade_status` from the POST body + * and only re-verified via `queryorder` afterward — it never checked + * `sign` on the inbound payload at all. This closes that gap: KBZ signs + * webhook notifications with the same scheme as our outbound calls + * (confirmed against KBZ's "6 Callback Interface" spec), so the + * signature is checked first, before any of the payload is trusted. + * + * @param array $payload + */ + public function handleWebhook(array $payload): PaymentResultData + { + /** @var array $notification */ + $notification = (array) ($payload['Request'] ?? []); + + if (! $this->hasValidSignature($notification)) { + throw InvalidWebhookSignatureException::forGateway(PaymentMethod::KbzMiniApp); + } + + return new PaymentResultData( + status: $this->mapTradeStatus($notification['trade_status'] ?? null), + gatewayTransactionId: $notification['merch_order_id'] ?? null, + gatewayPayload: $notification, + message: $notification['trade_status'] ?? null, + ); + } + + /** + * @param array $notification + */ + private function hasValidSignature(array $notification): bool + { + if (! isset($notification['sign']) || ! is_string($notification['sign']) || $this->merchantKey === '') { + return false; + } + + return hash_equals(KbzSignature::sign($notification, $this->merchantKey), strtoupper($notification['sign'])); + } + /** * @return array */ diff --git a/app-modules/payment/src/Http/Controllers/PaymentController.php b/app-modules/payment/src/Http/Controllers/PaymentController.php new file mode 100644 index 0000000..9d213ea --- /dev/null +++ b/app-modules/payment/src/Http/Controllers/PaymentController.php @@ -0,0 +1,28 @@ +initiatePaymentAction->handle($booking); + + return (new PaymentResource($payment)) + ->response() + ->setStatusCode(201); + } +} diff --git a/app-modules/payment/src/Http/Controllers/PaymentWebhookController.php b/app-modules/payment/src/Http/Controllers/PaymentWebhookController.php new file mode 100644 index 0000000..bcc02c0 --- /dev/null +++ b/app-modules/payment/src/Http/Controllers/PaymentWebhookController.php @@ -0,0 +1,92 @@ +all(); + + // Optional, mirrors bnf_event's `{encryptOrderId?}` — a redundant, + // signature-independent way to locate the booking directly from the + // URL (used by ConfirmPaymentAction, T5.10) alongside whatever + // order id the gateway's own signed payload carries. Never fatal if + // it's missing or fails to decrypt; the signature check is what + // actually authenticates this request. + $bookingId = $this->decryptBookingId($encryptBookingId); + + try { + $result = $this->gateways->make($method)->handleWebhook($payload); + } catch (InvalidWebhookSignatureException $exception) { + // Raw payload persisted regardless of outcome (domain.md §6). + Log::warning('Payment webhook rejected: invalid signature', [ + 'gateway' => $method->value, + 'booking_id' => $bookingId, + 'payload' => $payload, + ]); + + throw $exception; + } + + Log::info('Payment webhook received', [ + 'gateway' => $method->value, + 'booking_id' => $bookingId, + 'status' => $result->status->value, + 'gateway_transaction_id' => $result->gatewayTransactionId, + 'payload' => $payload, + ]); + + $payment = $result->gatewayTransactionId !== null + ? $this->confirmPaymentAction->handle($method, $result->gatewayTransactionId) + : null; + + if ($payment === null) { + Log::warning('Payment webhook has no matching payment to confirm', [ + 'gateway' => $method->value, + 'gateway_transaction_id' => $result->gatewayTransactionId, + ]); + } + + // KBZ retries any delivery that doesn't get back this exact literal + // body — we acknowledge regardless of whether a matching payment + // was found, since retrying won't fix that mismatch. + return response('success', 200); + } + + private function decryptBookingId(?string $encryptBookingId): ?int + { + if ($encryptBookingId === null) { + return null; + } + + try { + return (int) Crypt::decryptString($encryptBookingId); + } catch (DecryptException) { + return null; + } + } +} diff --git a/app-modules/payment/src/Http/Controllers/RefundController.php b/app-modules/payment/src/Http/Controllers/RefundController.php new file mode 100644 index 0000000..1256b9d --- /dev/null +++ b/app-modules/payment/src/Http/Controllers/RefundController.php @@ -0,0 +1,36 @@ +validated(); + + $refund = $this->refundBookingAction->handle( + $booking, + (string) $validated['amount'], + $validated['reason'], + $request->user()?->id, + ); + + return (new RefundResource($refund)) + ->response() + ->setStatusCode(201); + } +} diff --git a/app-modules/payment/src/Http/Requests/RefundBookingRequest.php b/app-modules/payment/src/Http/Requests/RefundBookingRequest.php new file mode 100644 index 0000000..e226ae9 --- /dev/null +++ b/app-modules/payment/src/Http/Requests/RefundBookingRequest.php @@ -0,0 +1,28 @@ +> + */ + public function rules(): array + { + return [ + 'amount' => ['required', 'numeric', 'gt:0'], + 'reason' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/app-modules/payment/src/Http/Resources/PaymentResource.php b/app-modules/payment/src/Http/Resources/PaymentResource.php new file mode 100644 index 0000000..aeed1d1 --- /dev/null +++ b/app-modules/payment/src/Http/Resources/PaymentResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'booking_id' => $this->booking_id, + 'gateway' => $this->gateway, + 'status' => $this->status, + 'amount' => $this->amount, + 'currency' => $this->currency, + 'gateway_transaction_id' => $this->gateway_transaction_id, + // The gateway's raw response — the client needs this to render the + // KBZ Mini App payment sheet (e.g. prepay_id). + 'gateway_payload' => $this->gateway_payload, + 'initiated_at' => $this->initiated_at, + ]; + } +} diff --git a/app-modules/payment/src/Http/Resources/RefundResource.php b/app-modules/payment/src/Http/Resources/RefundResource.php new file mode 100644 index 0000000..43c35c2 --- /dev/null +++ b/app-modules/payment/src/Http/Resources/RefundResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'payment_id' => $this->payment_id, + 'status' => $this->status, + 'amount' => $this->amount, + 'reason' => $this->reason, + 'gateway_refund_id' => $this->gateway_refund_id, + 'requested_by' => $this->requested_by, + 'requested_at' => $this->requested_at, + 'completed_at' => $this->completed_at, + ]; + } +} diff --git a/app-modules/payment/src/Listeners/MarkBookingPaid.php b/app-modules/payment/src/Listeners/MarkBookingPaid.php new file mode 100644 index 0000000..c4bf839 --- /dev/null +++ b/app-modules/payment/src/Listeners/MarkBookingPaid.php @@ -0,0 +1,25 @@ +payment->booking; + + if ($booking->status === BookingStatus::PendingPayment) { + $booking->update(['status' => BookingStatus::Confirmed]); + } + } +} diff --git a/app-modules/payment/src/Listeners/MarkBookingRefunded.php b/app-modules/payment/src/Listeners/MarkBookingRefunded.php new file mode 100644 index 0000000..952500d --- /dev/null +++ b/app-modules/payment/src/Listeners/MarkBookingRefunded.php @@ -0,0 +1,25 @@ +refund->payment->booking; + + if ($booking->status === BookingStatus::Confirmed) { + $booking->update(['status' => BookingStatus::Cancelled]); + } + } +} diff --git a/app-modules/payment/src/PaymentPlugin.php b/app-modules/payment/src/PaymentPlugin.php new file mode 100644 index 0000000..cc74a1a --- /dev/null +++ b/app-modules/payment/src/PaymentPlugin.php @@ -0,0 +1,38 @@ +discoverResources( + in: __DIR__.'/Filament/Resources', + for: 'Modules\Payment\Filament\Resources', + ) + ->discoverPages( + in: __DIR__.'/Filament/Pages', + for: 'Modules\Payment\Filament\Pages', + ) + ->discoverWidgets( + in: __DIR__.'/Filament/Widgets', + for: 'Modules\Payment\Filament\Widgets', + ); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(static::class); + } +} diff --git a/app-modules/payment/src/Providers/PaymentServiceProvider.php b/app-modules/payment/src/Providers/PaymentServiceProvider.php index 8a027c9..9fa2af7 100644 --- a/app-modules/payment/src/Providers/PaymentServiceProvider.php +++ b/app-modules/payment/src/Providers/PaymentServiceProvider.php @@ -2,10 +2,15 @@ namespace Modules\Payment\Providers; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Modules\Payment\Enums\PaymentMethod; +use Modules\Payment\Events\PaymentCompleted; +use Modules\Payment\Events\RefundProcessed; use Modules\Payment\Factories\PaymentGatewayFactory; use Modules\Payment\Gateways\KbzMiniAppGateway; +use Modules\Payment\Listeners\MarkBookingPaid; +use Modules\Payment\Listeners\MarkBookingRefunded; class PaymentServiceProvider extends ServiceProvider { @@ -19,5 +24,9 @@ class PaymentServiceProvider extends ServiceProvider }); } - public function boot(): void {} + public function boot(): void + { + Event::listen(PaymentCompleted::class, MarkBookingPaid::class); + Event::listen(RefundProcessed::class, MarkBookingRefunded::class); + } } diff --git a/app-modules/payment/tests/Feature/ConfirmPaymentActionTest.php b/app-modules/payment/tests/Feature/ConfirmPaymentActionTest.php new file mode 100644 index 0000000..91d6033 --- /dev/null +++ b/app-modules/payment/tests/Feature/ConfirmPaymentActionTest.php @@ -0,0 +1,153 @@ + 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); +}); diff --git a/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php b/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php new file mode 100644 index 0000000..253bccc --- /dev/null +++ b/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php @@ -0,0 +1,137 @@ +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(); +}); diff --git a/app-modules/payment/tests/Feature/KbzMiniAppGatewayHandleWebhookTest.php b/app-modules/payment/tests/Feature/KbzMiniAppGatewayHandleWebhookTest.php new file mode 100644 index 0000000..f9040d4 --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzMiniAppGatewayHandleWebhookTest.php @@ -0,0 +1,79 @@ + 'APPID123', + 'merchant_code' => 'MERCH001', + 'merchant_key' => 'test-merchant-key', + 'base_url' => 'https://kbz.test/gateway', +]; + +/** + * @return array + */ +$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); +}); diff --git a/app-modules/payment/tests/Feature/KbzWebhookApiTest.php b/app-modules/payment/tests/Feature/KbzWebhookApiTest.php new file mode 100644 index 0000000..5637a08 --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzWebhookApiTest.php @@ -0,0 +1,93 @@ + $overrides + * @return array + */ +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(); +}); diff --git a/app-modules/payment/tests/Feature/KbzWebhookConfirmationTest.php b/app-modules/payment/tests/Feature/KbzWebhookConfirmationTest.php new file mode 100644 index 0000000..d4c08d0 --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzWebhookConfirmationTest.php @@ -0,0 +1,89 @@ + '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); +}); diff --git a/app-modules/payment/tests/Feature/MarkBookingPaidTest.php b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php new file mode 100644 index 0000000..67037e0 --- /dev/null +++ b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php @@ -0,0 +1,26 @@ +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); +}); diff --git a/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php b/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php index a1b9994..62d83d2 100644 --- a/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php +++ b/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php @@ -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 () { diff --git a/app-modules/payment/tests/Feature/PaymentResourceTest.php b/app-modules/payment/tests/Feature/PaymentResourceTest.php new file mode 100644 index 0000000..19caef6 --- /dev/null +++ b/app-modules/payment/tests/Feature/PaymentResourceTest.php @@ -0,0 +1,80 @@ +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'); +}); diff --git a/app-modules/payment/tests/Feature/RefundBookingActionTest.php b/app-modules/payment/tests/Feature/RefundBookingActionTest.php new file mode 100644 index 0000000..ef59361 --- /dev/null +++ b/app-modules/payment/tests/Feature/RefundBookingActionTest.php @@ -0,0 +1,154 @@ + 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); +}); diff --git a/app-modules/payment/tests/Feature/RefundBookingApiTest.php b/app-modules/payment/tests/Feature/RefundBookingApiTest.php new file mode 100644 index 0000000..fbab93f --- /dev/null +++ b/app-modules/payment/tests/Feature/RefundBookingApiTest.php @@ -0,0 +1,136 @@ +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(); +}); diff --git a/app-modules/payment/tests/Feature/RefundResourceTest.php b/app-modules/payment/tests/Feature/RefundResourceTest.php new file mode 100644 index 0000000..a12c84e --- /dev/null +++ b/app-modules/payment/tests/Feature/RefundResourceTest.php @@ -0,0 +1,126 @@ +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(); +}); diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index ec078bc..4731363 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -21,6 +21,7 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Modules\Booking\BookingPlugin; use Modules\Catalog\CatalogPlugin; +use Modules\Payment\PaymentPlugin; use Modules\Routing\RoutingPlugin; class AdminPanelProvider extends PanelProvider @@ -45,6 +46,7 @@ class AdminPanelProvider extends PanelProvider CatalogPlugin::make(), RoutingPlugin::make(), BookingPlugin::make(), + PaymentPlugin::make(), ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')