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

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