Add Payment module: contracts, payments/refunds tables, KBZ gateway, factory, orchestrator (T5.1-T5.7)
- T5.1 PaymentGatewayInterface, DTOs, PaymentMethod/PaymentStatus/RefundStatus enums - T5.2 payments/refunds tables, models, factories - T5.3-T5.5 KbzMiniAppGateway: initiate()/verify()/refund(), ported KBZ signing scheme, wired refund_amount through for partial refunds, mTLS options for refund - T5.6 PaymentGatewayFactory resolving gateways by PaymentMethod - T5.7 PaymentService orchestrator delegating to the resolved gateway
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* @extends Factory<Payment>
|
||||
*/
|
||||
class PaymentFactory extends Factory
|
||||
{
|
||||
protected $model = Payment::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
|
||||
/**
|
||||
* @extends Factory<Refund>
|
||||
*/
|
||||
class RefundFactory extends Factory
|
||||
{
|
||||
protected $model = Refund::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per payment attempt against a Booking — a Booking can have
|
||||
* more than one row here if an earlier attempt failed and the customer
|
||||
* retried (domain.md §1, §6).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* A Refund reverses a specific successful Payment, not the Booking
|
||||
* directly — a Payment can have more than one Refund row for partial
|
||||
* refunds (domain.md §1, §6).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('refunds', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Contracts;
|
||||
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
|
||||
/**
|
||||
* Contract every payment gateway strategy implements (e.g. KbzMiniAppGateway).
|
||||
*
|
||||
* Strategies are deliberately ignorant of Payment/Booking Eloquent models —
|
||||
* they only take/return DTOs, so booking-status changes stay in listeners
|
||||
* reacting to PaymentCompleted/PaymentFailed/RefundProcessed (domain.md §6).
|
||||
*/
|
||||
interface PaymentGatewayInterface
|
||||
{
|
||||
/**
|
||||
* Start a payment attempt with the gateway.
|
||||
*/
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData;
|
||||
|
||||
/**
|
||||
* Re-check a payment's current status with the gateway (defense-in-depth
|
||||
* re-verification, and used inside webhook processing — domain.md §6).
|
||||
*/
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData;
|
||||
|
||||
/**
|
||||
* Reverse (all or part of) a successful payment.
|
||||
*/
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Data;
|
||||
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
|
||||
/**
|
||||
* What a gateway needs to start a payment (PaymentGatewayInterface::initiate).
|
||||
*/
|
||||
readonly class PaymentRequestData
|
||||
{
|
||||
public function __construct(
|
||||
public int $bookingId,
|
||||
public string $merchantOrderId,
|
||||
public string $amount,
|
||||
public string $currency,
|
||||
public PaymentMethod $method,
|
||||
public ?string $notifyUrl = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Data;
|
||||
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
/**
|
||||
* What a gateway hands back from initiate()/verify() (PaymentGatewayInterface).
|
||||
*/
|
||||
readonly class PaymentResultData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $gatewayPayload Raw gateway response, persisted verbatim for audit.
|
||||
*/
|
||||
public function __construct(
|
||||
public PaymentStatus $status,
|
||||
public ?string $gatewayTransactionId,
|
||||
public array $gatewayPayload,
|
||||
public ?string $message = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Data;
|
||||
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
|
||||
/**
|
||||
* What a gateway hands back from refund() (PaymentGatewayInterface).
|
||||
*/
|
||||
readonly class RefundResultData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $gatewayPayload Raw gateway response, persisted verbatim for audit.
|
||||
*/
|
||||
public function __construct(
|
||||
public RefundStatus $status,
|
||||
public ?string $gatewayRefundId,
|
||||
public array $gatewayPayload,
|
||||
public ?string $message = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Enums;
|
||||
|
||||
/**
|
||||
* Which gateway a Payment is processed through.
|
||||
*
|
||||
* Only KBZ Mini App is supported in v1 — the enum exists so
|
||||
* PaymentGatewayFactory can resolve additional gateways later
|
||||
* without call-site changes (domain.md §6, §7).
|
||||
*/
|
||||
enum PaymentMethod: string
|
||||
{
|
||||
case KbzMiniApp = 'kbz_mini_app';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Enums;
|
||||
|
||||
enum PaymentStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Enums;
|
||||
|
||||
enum RefundStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Factories;
|
||||
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Resolves a PaymentGatewayInterface implementation by PaymentMethod.
|
||||
*
|
||||
* Replaces bnf_event's 3x duplicated `switch($payment_type)` at each call
|
||||
* site (domain.md §6). Call sites depend only on this factory, never on a
|
||||
* concrete gateway class — swapping/adding a gateway is a `register()` call
|
||||
* here, no controller/action changes.
|
||||
*/
|
||||
class PaymentGatewayFactory
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<PaymentGatewayInterface>>
|
||||
*/
|
||||
private array $bindings = [];
|
||||
|
||||
/**
|
||||
* @param class-string<PaymentGatewayInterface> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Gateways;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
/**
|
||||
* KBZ Mini App gateway strategy, ported from bnf_event's
|
||||
* `App\Strategies\Payments\KBZMiniApp`/`KBZPay` (domain.md §6).
|
||||
*
|
||||
* Reads credentials from config('services.kbz') by default; a config array
|
||||
* may be injected directly (used by tests / the factory).
|
||||
*/
|
||||
class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
{
|
||||
private readonly string $appId;
|
||||
|
||||
private readonly string $merchantCode;
|
||||
|
||||
private readonly string $merchantKey;
|
||||
|
||||
private readonly string $baseUrl;
|
||||
|
||||
private readonly ?string $notifyUrl;
|
||||
|
||||
private readonly ?string $certPath;
|
||||
|
||||
private readonly ?string $certKeyPath;
|
||||
|
||||
private readonly ?string $caPath;
|
||||
|
||||
private readonly ?string $certPassword;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Database\Factories\PaymentFactory;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
|
||||
/**
|
||||
* One attempt to pay for a Booking through a gateway — a Booking can have
|
||||
* more than one Payment row if an earlier attempt failed and the customer
|
||||
* retried (domain.md §1).
|
||||
*/
|
||||
class Payment extends Model
|
||||
{
|
||||
/** @use HasFactory<PaymentFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'booking_id',
|
||||
'gateway',
|
||||
'status',
|
||||
'amount',
|
||||
'currency',
|
||||
'gateway_transaction_id',
|
||||
'gateway_payload',
|
||||
'initiated_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Payment\Database\Factories\RefundFactory;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
|
||||
/**
|
||||
* A reversal against a specific successful Payment (not against the Booking
|
||||
* directly) — a Payment can have more than one Refund row for partial
|
||||
* refunds (domain.md §1, §6).
|
||||
*/
|
||||
class Refund extends Model
|
||||
{
|
||||
/** @use HasFactory<RefundFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'payment_id',
|
||||
'status',
|
||||
'amount',
|
||||
'reason',
|
||||
'gateway_refund_id',
|
||||
'gateway_payload',
|
||||
'requested_by',
|
||||
'requested_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Services;
|
||||
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
|
||||
/**
|
||||
* Thin orchestrator delegating to the gateway resolved by
|
||||
* PaymentGatewayFactory — the single call site every Payment Action goes
|
||||
* through, so no Action ever depends on a concrete gateway class.
|
||||
*/
|
||||
class PaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentGatewayFactory $gateways,
|
||||
) {}
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Payment\Support;
|
||||
|
||||
/**
|
||||
* KBZ's signing scheme, ported verbatim from bnf_event's `KBZPay::joinKeyVal`/
|
||||
* `signature` (domain.md §6): flatten the request array (excluding `sign`/
|
||||
* `sign_type`, at any nesting level — `biz_content` included) into sorted
|
||||
* `key=val` pairs joined by `&`, append `&key={merchant_key}`, SHA-256 hash,
|
||||
* uppercase. Shared by initiate()/verify()/refund() on every gateway.
|
||||
*/
|
||||
class KbzSignature
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param list<string> $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<string, mixed> $data
|
||||
* @param list<string> $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<string> $skips
|
||||
* @param list<array{key: string, val: mixed}> $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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
$config = [
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
'notify_url' => 'https://app.test/api/v1/webhooks/kbz',
|
||||
];
|
||||
|
||||
$paymentRequest = new PaymentRequestData(
|
||||
bookingId: 1,
|
||||
merchantOrderId: 'EVB-FIXTURE-001',
|
||||
amount: '15000',
|
||||
currency: 'MMK',
|
||||
method: PaymentMethod::KbzMiniApp,
|
||||
);
|
||||
|
||||
test('initiate posts a correctly signed precreate request to the configured base_url', function () use ($config, $paymentRequest) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']])]);
|
||||
|
||||
(new KbzMiniAppGateway($config))->initiate($paymentRequest);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$body = $request->data()['Request'];
|
||||
|
||||
return $request->url() === 'https://kbz.test/gateway'
|
||||
&& $body['method'] === 'kbz.payment.precreate'
|
||||
&& $body['sign_type'] === 'SHA256'
|
||||
&& $body['biz_content']['appid'] === 'APPID123'
|
||||
&& $body['biz_content']['merch_code'] === 'MERCH001'
|
||||
&& $body['biz_content']['merch_order_id'] === 'EVB-FIXTURE-001'
|
||||
&& $body['biz_content']['trade_type'] === 'MINIAPP'
|
||||
&& $body['biz_content']['total_amount'] === '15000'
|
||||
&& $body['biz_content']['trans_currency'] === 'MMK'
|
||||
&& $body['sign'] === KbzSignature::sign($body, 'test-merchant-key');
|
||||
});
|
||||
});
|
||||
|
||||
test('initiate returns a pending PaymentResultData on a successful precreate', function () use ($config, $paymentRequest) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->initiate($paymentRequest);
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Pending)
|
||||
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001')
|
||||
->and($result->gatewayPayload)->toBe(['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']);
|
||||
});
|
||||
|
||||
test('initiate returns a failed PaymentResultData when KBZ rejects the request', function () use ($config, $paymentRequest) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'FAIL', 'msg' => 'Invalid merchant']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->initiate($paymentRequest);
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed)
|
||||
->and($result->gatewayTransactionId)->toBeNull()
|
||||
->and($result->message)->toBe('Invalid merchant');
|
||||
});
|
||||
|
||||
test('initiate returns a failed PaymentResultData when the connection fails', function () use ($config, $paymentRequest) {
|
||||
Http::fake(['kbz.test/*' => fn () => throw new ConnectionException('Connection refused')]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->initiate($paymentRequest);
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed)
|
||||
->and($result->gatewayTransactionId)->toBeNull()
|
||||
->and($result->gatewayPayload)->toBe([])
|
||||
->and($result->message)->toBe('Connection refused');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
$config = [
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
'notify_url' => 'https://app.test/api/v1/webhooks/kbz',
|
||||
];
|
||||
|
||||
test('refund posts a correctly signed refund request, wiring the partial amount through', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'refund_order_id' => 'REFUND123']])]);
|
||||
|
||||
(new KbzMiniAppGateway($config))->refund('EVB-FIXTURE-001', '8000', 'customer requested partial refund');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$body = $request->data()['Request'];
|
||||
|
||||
return $request->url() === 'https://kbz.test/gateway'
|
||||
&& $body['method'] === 'kbz.payment.refund'
|
||||
&& $body['sign_type'] === 'SHA256'
|
||||
&& $body['biz_content']['appid'] === 'APPID123'
|
||||
&& $body['biz_content']['merch_code'] === 'MERCH001'
|
||||
&& $body['biz_content']['merch_order_id'] === 'EVB-FIXTURE-001'
|
||||
// unlike bnf_event (amount hardcoded/commented out), this is wired through
|
||||
&& $body['biz_content']['refund_amount'] === '8000'
|
||||
&& $body['biz_content']['refund_reason'] === 'customer requested partial refund'
|
||||
&& ! empty($body['biz_content']['refund_request_no'])
|
||||
&& $body['sign'] === KbzSignature::sign($body, 'test-merchant-key');
|
||||
});
|
||||
});
|
||||
|
||||
test('refund returns a completed RefundResultData on success', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'refund_order_id' => 'REFUND123']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->refund('EVB-FIXTURE-001', '8000', 'customer request');
|
||||
|
||||
expect($result->status)->toBe(RefundStatus::Completed)
|
||||
->and($result->gatewayRefundId)->toBe('REFUND123')
|
||||
->and($result->gatewayPayload)->toBe(['result' => 'SUCCESS', 'refund_order_id' => 'REFUND123']);
|
||||
});
|
||||
|
||||
test('refund returns a failed RefundResultData when KBZ rejects the request', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'FAIL', 'msg' => 'Refund window expired']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->refund('EVB-FIXTURE-001', '8000', 'customer request');
|
||||
|
||||
expect($result->status)->toBe(RefundStatus::Failed)
|
||||
->and($result->gatewayRefundId)->toBeNull()
|
||||
->and($result->message)->toBe('Refund window expired');
|
||||
});
|
||||
|
||||
test('refund returns a failed RefundResultData when the connection fails', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => fn () => throw new ConnectionException('Connection refused')]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->refund('EVB-FIXTURE-001', '8000', 'customer request');
|
||||
|
||||
expect($result->status)->toBe(RefundStatus::Failed)
|
||||
->and($result->gatewayRefundId)->toBeNull()
|
||||
->and($result->gatewayPayload)->toBe([])
|
||||
->and($result->message)->toBe('Connection refused');
|
||||
});
|
||||
|
||||
test('refund builds mTLS cert/ssl_key/verify options from config', function () {
|
||||
$gateway = new KbzMiniAppGateway([
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
'cert_path' => '/certs/merch.pem',
|
||||
'cert_key_path' => '/certs/merch.key',
|
||||
'ca_path' => '/certs/ca.crt',
|
||||
'cert_password' => 'secret',
|
||||
]);
|
||||
|
||||
$options = (new ReflectionMethod($gateway, 'mtlsOptions'))->invoke($gateway);
|
||||
|
||||
expect($options)->toBe([
|
||||
'cert' => ['/certs/merch.pem', 'secret'],
|
||||
'ssl_key' => ['/certs/merch.key', 'secret'],
|
||||
'verify' => '/certs/ca.crt',
|
||||
]);
|
||||
});
|
||||
|
||||
test('refund omits mTLS options entirely when cert config is not set', function () use ($config) {
|
||||
$gateway = new KbzMiniAppGateway($config);
|
||||
|
||||
$options = (new ReflectionMethod($gateway, 'mtlsOptions'))->invoke($gateway);
|
||||
|
||||
expect($options)->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
$config = [
|
||||
'app_id' => 'APPID123',
|
||||
'merchant_code' => 'MERCH001',
|
||||
'merchant_key' => 'test-merchant-key',
|
||||
'base_url' => 'https://kbz.test/gateway',
|
||||
'notify_url' => 'https://app.test/api/v1/webhooks/kbz',
|
||||
];
|
||||
|
||||
test('verify posts a correctly signed queryorder request keyed by our own merch_order_id', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['trade_status' => 'PAY_SUCCESS']])]);
|
||||
|
||||
(new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$body = $request->data()['Request'];
|
||||
|
||||
return $request->url() === 'https://kbz.test/gateway'
|
||||
&& $body['method'] === 'kbz.payment.queryorder'
|
||||
&& $body['sign_type'] === 'SHA256'
|
||||
&& $body['biz_content']['appid'] === 'APPID123'
|
||||
&& $body['biz_content']['merch_code'] === 'MERCH001'
|
||||
&& $body['biz_content']['merch_order_id'] === 'EVB-FIXTURE-001'
|
||||
&& ! array_key_exists('total_amount', $body['biz_content'])
|
||||
&& $body['sign'] === KbzSignature::sign($body, 'test-merchant-key');
|
||||
});
|
||||
});
|
||||
|
||||
test('verify maps PAY_SUCCESS to a completed PaymentResultData', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['trade_status' => 'PAY_SUCCESS', 'mm_order_id' => 'MM123']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Completed)
|
||||
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001')
|
||||
->and($result->gatewayPayload)->toBe(['trade_status' => 'PAY_SUCCESS', 'mm_order_id' => 'MM123']);
|
||||
});
|
||||
|
||||
test('verify maps WAIT_PAY to a pending PaymentResultData', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['trade_status' => 'WAIT_PAY']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Pending);
|
||||
});
|
||||
|
||||
test('verify maps any other/unrecognized trade_status to a failed PaymentResultData', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => Http::response(['Response' => ['trade_status' => 'PAY_ERROR']])]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed)
|
||||
->and($result->message)->toBe('PAY_ERROR');
|
||||
});
|
||||
|
||||
test('verify returns a failed PaymentResultData when the connection fails', function () use ($config) {
|
||||
Http::fake(['kbz.test/*' => fn () => throw new ConnectionException('Connection refused')]);
|
||||
|
||||
$result = (new KbzMiniAppGateway($config))->verify('EVB-FIXTURE-001');
|
||||
|
||||
expect($result->status)->toBe(PaymentStatus::Failed)
|
||||
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001')
|
||||
->and($result->gatewayPayload)->toBe([])
|
||||
->and($result->message)->toBe('Connection refused');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
|
||||
test('a payment belongs to a booking', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
$payment = Payment::factory()->create(['booking_id' => $booking->id]);
|
||||
|
||||
expect($payment->booking)->toBeInstanceOf(Booking::class)
|
||||
->and($payment->booking->is($booking))->toBeTrue()
|
||||
->and($booking->payments->first()->is($payment))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a booking can have more than one payment attempt', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Payment::factory()->failed()->create(['booking_id' => $booking->id]);
|
||||
Payment::factory()->completed()->create(['booking_id' => $booking->id]);
|
||||
|
||||
expect($booking->payments)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('gateway and status cast to their enums', function () {
|
||||
$payment = Payment::factory()->create([
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'status' => PaymentStatus::Completed,
|
||||
]);
|
||||
|
||||
expect($payment->gateway)->toBe(PaymentMethod::KbzMiniApp)
|
||||
->and($payment->status)->toBe(PaymentStatus::Completed);
|
||||
});
|
||||
|
||||
test('a payment defaults to pending', function () {
|
||||
$payment = Payment::factory()->create();
|
||||
|
||||
expect($payment->status)->toBe(PaymentStatus::Pending);
|
||||
});
|
||||
|
||||
test('a refund belongs to a payment, not the booking directly', function () {
|
||||
$payment = Payment::factory()->completed()->create();
|
||||
|
||||
$refund = Refund::factory()->create(['payment_id' => $payment->id]);
|
||||
|
||||
expect($refund->payment)->toBeInstanceOf(Payment::class)
|
||||
->and($refund->payment->is($payment))->toBeTrue()
|
||||
->and($payment->refunds->first()->is($refund))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a payment can have more than one refund for partial refunds', function () {
|
||||
$payment = Payment::factory()->completed()->create(['amount' => 20000]);
|
||||
|
||||
Refund::factory()->completed()->create(['payment_id' => $payment->id, 'amount' => 8000]);
|
||||
Refund::factory()->create(['payment_id' => $payment->id, 'amount' => 12000]);
|
||||
|
||||
expect($payment->refunds)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('refund status casts to its enum and defaults to pending', function () {
|
||||
$refund = Refund::factory()->create();
|
||||
|
||||
expect($refund->status)->toBe(RefundStatus::Pending);
|
||||
});
|
||||
|
||||
test('deleting a payment cascades to its refunds', function () {
|
||||
$payment = Payment::factory()->completed()->create();
|
||||
$refund = Refund::factory()->create(['payment_id' => $payment->id]);
|
||||
|
||||
$payment->delete();
|
||||
|
||||
expect(Refund::find($refund->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('gateway_transaction_id cannot be shared across unrelated retried attempts without being unique-constrained', function () {
|
||||
// gateway_transaction_id is not unique-constrained since a failed attempt
|
||||
// may legitimately be retried under a fresh Payment row with its own id;
|
||||
// this just documents the column accepts duplicates without throwing.
|
||||
Payment::factory()->create(['gateway_transaction_id' => 'kbz-txn-1']);
|
||||
|
||||
expect(fn () => Payment::factory()->create(['gateway_transaction_id' => 'kbz-txn-1']))
|
||||
->not->toThrow(QueryException::class);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
|
||||
/**
|
||||
* Stands in for "a second gateway" being added — proves call sites that only
|
||||
* depend on PaymentGatewayFactory (never a concrete gateway class) don't
|
||||
* need to change when a gateway implementation is swapped/added.
|
||||
*/
|
||||
class FakePaymentGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
return new PaymentResultData(status: PaymentStatus::Pending, gatewayTransactionId: 'fake-txn', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
return new PaymentResultData(status: PaymentStatus::Completed, gatewayTransactionId: $gatewayTransactionId, gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
test('the factory resolves KbzMiniAppGateway for the KbzMiniApp method by default', function () {
|
||||
$gateway = app(PaymentGatewayFactory::class)->make(PaymentMethod::KbzMiniApp);
|
||||
|
||||
expect($gateway)->toBeInstanceOf(KbzMiniAppGateway::class);
|
||||
});
|
||||
|
||||
test('registering a fake gateway swaps the resolved implementation with no call-site changes', function () {
|
||||
$factory = app(PaymentGatewayFactory::class);
|
||||
$factory->register(PaymentMethod::KbzMiniApp, FakePaymentGateway::class);
|
||||
|
||||
// A call site that only knows about the factory + interface, never the
|
||||
// concrete gateway class — this is exactly what an Action/controller does.
|
||||
$callSite = fn (PaymentGatewayFactory $factory, PaymentMethod $method): PaymentGatewayInterface => $factory->make($method);
|
||||
|
||||
expect($callSite($factory, PaymentMethod::KbzMiniApp))->toBeInstanceOf(FakePaymentGateway::class);
|
||||
});
|
||||
|
||||
test('the factory throws when no gateway is registered for a method', function () {
|
||||
$factory = new PaymentGatewayFactory;
|
||||
|
||||
expect(fn () => $factory->make(PaymentMethod::KbzMiniApp))->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
test('the factory is bound as a singleton', function () {
|
||||
expect(app(PaymentGatewayFactory::class))->toBe(app(PaymentGatewayFactory::class));
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Modules\Payment\Support\KbzSignature;
|
||||
|
||||
/**
|
||||
* Fixture derived by running bnf_event's own `KBZPay::joinKeyVal`/`signature`
|
||||
* (App\Strategies\Payments\KBZPay) against this exact params array and key —
|
||||
* this pins the port to the old code's actual behavior (domain.md §6).
|
||||
*/
|
||||
$fixtureParams = [
|
||||
'timestamp' => '1700000000',
|
||||
'method' => 'kbz.payment.precreate',
|
||||
'notify_url' => 'https://example.com/api/v1/webhooks/kbz',
|
||||
'nonce_str' => 'fixture-nonce',
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => 'APPID123',
|
||||
'merch_code' => 'MERCH001',
|
||||
'merch_order_id' => 'EVB-FIXTURE-001',
|
||||
'trade_type' => 'MINIAPP',
|
||||
'total_amount' => '15000',
|
||||
'trans_currency' => 'MMK',
|
||||
'callback_info' => 'urlencode',
|
||||
],
|
||||
];
|
||||
$fixtureKey = 'test-merchant-key';
|
||||
|
||||
test('joinKeyVal flattens nested biz_content and sorts keys, matching the old algorithm', function () use ($fixtureParams) {
|
||||
expect(KbzSignature::joinKeyVal($fixtureParams))->toBe(
|
||||
'appid=APPID123&callback_info=urlencode&merch_code=MERCH001&merch_order_id=EVB-FIXTURE-001'
|
||||
.'&method=kbz.payment.precreate&nonce_str=fixture-nonce¬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');
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
|
||||
test('initiate resolves the gateway for the request method and delegates to its initiate()', function () {
|
||||
$gateway = Mockery::mock(PaymentGatewayInterface::class);
|
||||
$data = new PaymentRequestData(
|
||||
bookingId: 1,
|
||||
merchantOrderId: 'EVB-001',
|
||||
amount: '15000',
|
||||
currency: 'MMK',
|
||||
method: PaymentMethod::KbzMiniApp,
|
||||
);
|
||||
$expected = new PaymentResultData(status: PaymentStatus::Pending, gatewayTransactionId: 'EVB-001', gatewayPayload: []);
|
||||
|
||||
$gateway->shouldReceive('initiate')->once()->with($data)->andReturn($expected);
|
||||
|
||||
$factory = Mockery::mock(PaymentGatewayFactory::class);
|
||||
$factory->shouldReceive('make')->once()->with(PaymentMethod::KbzMiniApp)->andReturn($gateway);
|
||||
|
||||
$result = (new PaymentService($factory))->initiate($data);
|
||||
|
||||
expect($result)->toBe($expected);
|
||||
});
|
||||
|
||||
test('verify resolves the gateway for the given method and delegates to its verify()', function () {
|
||||
$gateway = Mockery::mock(PaymentGatewayInterface::class);
|
||||
$expected = new PaymentResultData(status: PaymentStatus::Completed, gatewayTransactionId: 'EVB-001', gatewayPayload: []);
|
||||
|
||||
$gateway->shouldReceive('verify')->once()->with('EVB-001')->andReturn($expected);
|
||||
|
||||
$factory = Mockery::mock(PaymentGatewayFactory::class);
|
||||
$factory->shouldReceive('make')->once()->with(PaymentMethod::KbzMiniApp)->andReturn($gateway);
|
||||
|
||||
$result = (new PaymentService($factory))->verify(PaymentMethod::KbzMiniApp, 'EVB-001');
|
||||
|
||||
expect($result)->toBe($expected);
|
||||
});
|
||||
|
||||
test('refund resolves the gateway for the given method and delegates to its refund()', function () {
|
||||
$gateway = Mockery::mock(PaymentGatewayInterface::class);
|
||||
$expected = new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND-1', gatewayPayload: []);
|
||||
|
||||
$gateway->shouldReceive('refund')->once()->with('EVB-001', '8000', 'customer request')->andReturn($expected);
|
||||
|
||||
$factory = Mockery::mock(PaymentGatewayFactory::class);
|
||||
$factory->shouldReceive('make')->once()->with(PaymentMethod::KbzMiniApp)->andReturn($gateway);
|
||||
|
||||
$result = (new PaymentService($factory))->refund(PaymentMethod::KbzMiniApp, 'EVB-001', '8000', 'customer request');
|
||||
|
||||
expect($result)->toBe($expected);
|
||||
});
|
||||
Reference in New Issue
Block a user