fix(payment): KG 이니시스 결제 콜백 안정성 보강

KG 이니시스 결제에서 통화 스냅샷 이상 시 서버 오류로 새지 않도록 검증 실패/재통보 가능한 응답으로 처리하고, 주문 단위 콜백 락으로 동시 완료 콜백의 중복 후속 처리를 줄입니다.

백엔드 테스트의 통화 스냅샷 fixture도 최신 계약에 맞춰 복구합니다.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
thisgunandCodex committed 2026-07-06 13:46:35 +09:00
1 parent d18aaab1fd
commit 8429d8ada5
18 files changed
+472 -19

No files matched your search

@@ -30,6 +30,8 @@
- 표준/모바일 결제 서명 API에서 현재 모드의 MID와 서명키가 준비되지 않은 경우 서명을 생성하지 않고 중단하도록 보강했습니다.
- PC/모바일/CBT 결제 콜백에서 로컬 결제완료 저장 후 예외가 발생한 경우 PG 취소·환불 보상 호출을 건너뛰도록 보강했습니다. 로컬 주문은 결제완료인데 PG 거래만 취소되는 상태 불일치를 방지합니다.
- 일본 편의점결제(CBT CVS) 만료 처리와 통보 이력 저장 시 결제 row를 잠근 뒤 최신 상태를 다시 확인하도록 보강해 입금통보와 관리자 만료 처리의 동시성 충돌 가능성을 줄였습니다.
- 통화 스냅샷의 환율 설정이 비정상인 주문은 표준/모바일/CBT/가상계좌 결제 흐름에서 서버 오류 대신 검증 실패 또는 재통보 가능한 응답으로 처리하도록 보강했습니다.
- PC/모바일/CBT/가상계좌 결제 완료 콜백을 주문 단위로 직렬화해 완전 동시 콜백에서 결제완료 후속 처리 훅이 중복 실행될 가능성을 줄였습니다.
### Fixed
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayKginicis\Concerns;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
trait SerializesPaymentCallbacks
{
protected function acquireOrderCallbackLock(string $context, string $orderNumber): mixed
{
$key = $this->callbackLockKey($context, $orderNumber);
$lock = Cache::lock($key, $this->callbackLockTtlSeconds());
try {
$lock->block($this->callbackLockWaitSeconds());
} catch (LockTimeoutException $e) {
Log::warning('KG Inicis: callback lock timeout', [
'context' => $context,
'order_number' => $orderNumber,
'lock_key' => $key,
]);
throw $e;
}
return $lock;
}
protected function releaseOrderCallbackLock(mixed $lock): void
{
if (is_object($lock) && method_exists($lock, 'release')) {
$lock->release();
}
}
protected function callbackLockKey(string $context, string $orderNumber): string
{
return 'sirsoft-pay_kginicis:callback:'
. $context
. ':'
. sha1(trim($orderNumber));
}
protected function callbackLockTtlSeconds(): int
{
return 30;
}
protected function callbackLockWaitSeconds(): int
{
return 10;
}
}
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Plugins\Sirsoft\PayKginicis\Concerns;
use InvalidArgumentException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
@@ -24,6 +26,33 @@ trait ValidatesCbtOrderContext
return $this->expectedPaymentPrice($order);
}
protected function resolveExpectedPaymentPriceOrNull(Order $order, string $context, array $logContext = []): ?int
{
try {
return $this->expectedPaymentPrice($order);
} catch (InvalidArgumentException $e) {
$this->logInvalidPaymentCurrency($order, $e, $context, $logContext);
return null;
}
}
protected function logInvalidPaymentCurrency(
Order $order,
InvalidArgumentException $e,
string $context,
array $logContext = [],
): void {
Log::error('KG Inicis: payment currency is not chargeable', array_merge([
'context' => $context,
'order_id' => $order->id,
'order_number' => $order->order_number,
'currency' => $order->currency,
'currency_snapshot' => $order->currency_snapshot,
'error' => $e->getMessage(),
], $logContext));
}
protected function requestMatchesOrderBuyer(Request $request, Order $order): bool
{
/** @var OrderAddress|null $address */
@@ -10,11 +10,12 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayKginicis\Http\Requests\CbtCallbackRequest;
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
use Plugins\Sirsoft\PayKginicis\Services\CbtReconciliationService;
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
@@ -31,6 +32,8 @@ class CbtCallbackController
{
use IssuesReceiptCookie;
use PreventsReplayCallback;
use SerializesPaymentCallbacks;
use ValidatesCbtOrderContext;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
@@ -167,6 +170,7 @@ class CbtCallbackController
// Approve 성공 후 후속 처리 실패 시 PG 자동 cancel 추적 변수.
$approvedTid = null;
$approvedAmount = 0;
$callbackLock = null;
try {
$order = $this->orderService->findByOrderNumber($oid);
@@ -177,6 +181,8 @@ class CbtCallbackController
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $oid]));
}
$callbackLock = $this->acquireOrderCallbackLock('cbtAuthCallback', $oid);
$this->assertPayableCbtOrder($order);
$pgResponse = $this->apiService->approveCbtPayment($sid);
@@ -221,9 +227,6 @@ class CbtCallbackController
}
$this->assertCbtApproveResponseMatchesOrder($order, $pgResponse, $request, $payMethod);
// PG 승인금액이 누락되어 검증 예외가 발생해도 자동환불 대사 레코드에는 결제 통화 청구액을 남긴다
// (buildPgPaymentData 와 동일 SSoT — base≠결제 통화에서도 PG 청구 통화와 단위 일치).
$approvedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
$approvedAmount = $this->resolveApprovedAmount($pgResponse, $order);
$authResponse = $this->sanitizePgResponse($request->except(['_token']), self::CBT_AUTH_RESPONSE_KEYS);
$approveResponse = $this->sanitizePgResponse($pgResponse, self::CBT_APPROVE_RESPONSE_KEYS);
@@ -293,6 +296,8 @@ class CbtCallbackController
);
return redirect($this->resolveFailUrl(['error' => 'cbt_failed', 'orderId' => $oid]));
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
@@ -396,8 +401,14 @@ class CbtCallbackController
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준).
// CBT 는 order_currency=JPY 강제이나 base 통화는 다를 수 있어(예: base KRW, 결제 JPY)
// total_due_amount(base) 직접 비교 시 PG 승인액(JPY)과 단위가 어긋난다.
$expectedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
$pgAmount = $this->firstNonEmptyString($pgResponse, ['amount', 'price', 'bokuApplPrice', 'bokuLocalApplPrice']);
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'cbt_approve', [
'tid' => $pgResponse['tid'] ?? ($pgResponse['transactionId'] ?? null),
'received_amount' => $pgAmount,
]);
if ($expectedAmount === null) {
throw new \RuntimeException('KG Inicis CBT payment currency is not chargeable.');
}
// 일본 CBT 승인 응답은 금액 필드가 비어 온다(매뉴얼상 미사용). 금액이 없으면
// 주문 결제예정액을 승인액으로 신뢰한다(승인 권위 = resultCode OK + tid).
@@ -15,6 +15,8 @@ use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection;
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
use Plugins\Sirsoft\PayKginicis\Http\Requests\MobileCallbackRequest;
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
@@ -33,6 +35,8 @@ class MobileCallbackController
use PreventsReplayCallback;
use ResolvesEasyPaySelection;
use SanitizesPgResponse;
use SerializesPaymentCallbacks;
use ValidatesCbtOrderContext;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
@@ -172,6 +176,7 @@ class MobileCallbackController
// cancelPayment 를 호출해 PG 잔존 승인을 해제한다 (사용자 환불 보장).
$approvedTid = null;
$approvedTotPrice = 0;
$callbackLock = null;
try {
$order = $this->orderService->findByOrderNumber($moid);
@@ -182,6 +187,8 @@ class MobileCallbackController
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $moid]));
}
$callbackLock = $this->acquireOrderCallbackLock('mobileAuthCallback', $moid);
// 서버 승인 요청: POST P_REQ_URL with P_MID + P_TID
$result = $this->apiService->authorizeMobilePayment($reqUrl, $pTid);
@@ -228,6 +235,21 @@ class MobileCallbackController
$approvedTotPrice = $totPrice;
$embeddedPgProvider = $this->resolveEmbeddedPgProvider($selectedEasyPayMethod);
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'mobile_auth_callback', [
'tid' => $tid,
'received_amount' => $totPrice,
]);
if ($expectedAmount === null) {
$this->autoCancelIfApproved($approvedTid, $moid, $approvedTotPrice, 'invalid_payment_currency');
return redirect($this->resolveFailUrl(['error' => 'invalid_payment_currency', 'orderId' => $moid]));
}
if ($expectedAmount > 0 && $totPrice !== $expectedAmount) {
$this->autoCancelIfApproved($approvedTid, $moid, $approvedTotPrice, 'amount_mismatch');
return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $moid]));
}
Log::info('KG Inicis mobile: completing card payment', [
'P_OID' => $moid,
'P_TID' => $tid,
@@ -298,6 +320,8 @@ class MobileCallbackController
'message' => $e->getMessage(),
'orderId' => $moid,
]));
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
@@ -107,7 +107,17 @@ class MobileSignatureController
], 403);
}
if ($price !== $this->expectedPaymentPrice($order)) {
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'mobile_signature', [
'received_amount' => $price,
]);
if ($expectedPrice === null) {
return response()->json([
'success' => false,
'message' => 'Payment currency is not chargeable.',
], 422);
}
if ($price !== $expectedPrice) {
return response()->json([
'success' => false,
'message' => 'Payment amount does not match the order amount.',
@@ -16,12 +16,13 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection;
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
use Plugins\Sirsoft\PayKginicis\Http\Requests\AuthCallbackRequest;
use Plugins\Sirsoft\PayKginicis\Http\Requests\MobileVbankNotifyRequest;
use Plugins\Sirsoft\PayKginicis\Http\Requests\VbankNotifyRequest;
@@ -40,6 +41,8 @@ class PaymentCallbackController
use PreventsReplayCallback;
use ResolvesEasyPaySelection;
use SanitizesPgResponse;
use SerializesPaymentCallbacks;
use ValidatesCbtOrderContext;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
@@ -223,6 +226,7 @@ class PaymentCallbackController
$authUrl = $receivedAuthUrl;
$netCancelUrl = $this->apiService->resolveIdcNetCancelUrl($idcName);
$tid = '';
$callbackLock = null;
try {
$order = $this->orderService->findByOrderNumber($moid);
@@ -233,6 +237,8 @@ class PaymentCallbackController
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $moid]));
}
$callbackLock = $this->acquireOrderCallbackLock('authCallback', $moid);
HookManager::doAction('sirsoft-pay_kginicis.payment.before_authorize', $order, $validated);
$pgResponse = $this->apiService->authorizePayment($authUrl, $authToken);
@@ -280,6 +286,21 @@ class PaymentCallbackController
$totPrice = (int) ($pgResponse['TotPrice'] ?? $pgResponse['totPrice'] ?? 0);
}
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'auth_callback', [
'tid' => $tid,
'received_amount' => $totPrice,
]);
if ($expectedAmount === null) {
$this->apiService->sendNetCancel($netCancelUrl, $authToken);
return redirect($this->resolveFailUrl(['error' => 'invalid_payment_currency', 'orderId' => $moid]));
}
if ($expectedAmount > 0 && $totPrice !== $expectedAmount) {
$this->apiService->sendNetCancel($netCancelUrl, $authToken);
return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $moid]));
}
$embeddedPgProvider = $this->resolveEmbeddedPgProvider($selectedEasyPayMethod);
Log::info('KG Inicis: completing card payment', [
@@ -352,6 +373,8 @@ class PaymentCallbackController
'message' => $e->getMessage(),
'orderId' => $moid,
]));
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
@@ -376,6 +399,8 @@ class PaymentCallbackController
'bank' => $validated['nm_inputbank'] ?? null,
]);
$callbackLock = null;
try {
$order = $this->orderService->findByOrderNumber($moid);
@@ -385,6 +410,8 @@ class PaymentCallbackController
return response('FAIL', 200)->header('Content-Type', 'text/plain');
}
$callbackLock = $this->acquireOrderCallbackLock('vbankNotify', $moid);
if ($this->wasAlreadyPaid($tid)) {
$this->logReplayDetected($tid, $moid, 'PC vbankNotify');
@@ -430,6 +457,8 @@ class PaymentCallbackController
]);
return response('FAIL', 200)->header('Content-Type', 'text/plain');
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
@@ -467,6 +496,8 @@ class PaymentCallbackController
'bank' => $validated['P_FN_NM'] ?? null,
]);
$callbackLock = null;
try {
$order = $this->orderService->findByOrderNumber($moid);
@@ -476,6 +507,8 @@ class PaymentCallbackController
return response('FAIL', 200)->header('Content-Type', 'text/plain');
}
$callbackLock = $this->acquireOrderCallbackLock('mobileVbankNotify', $moid);
if ($this->wasAlreadyPaid($tid)) {
$this->logReplayDetected($tid, $moid, 'mobile vbankNotify');
@@ -521,6 +554,8 @@ class PaymentCallbackController
]);
return response('FAIL', 200)->header('Content-Type', 'text/plain');
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
@@ -599,7 +634,15 @@ class PaymentCallbackController
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준).
// base≠결제 통화에서 PG 통보 금액(환산 통화)과 단위가 일치하도록 한다.
$expectedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'vbank_notify', [
'source' => $source,
'tid' => $notify['tid'],
'received_amount' => $notify['amount'],
]);
if ($expectedAmount === null) {
return false;
}
if ($expectedAmount > 0 && $notify['amount'] !== $expectedAmount) {
Log::warning('KG Inicis: vbank notify rejected - amount mismatch', [
'source' => $source,
@@ -65,7 +65,16 @@ class PaymentCloseReportController
]);
}
if ($price !== $this->expectedPaymentPrice($order)) {
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [
'received_amount' => $price,
]);
if ($expectedPrice === null) {
return ResponseHelper::error('messages.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if ($price !== $expectedPrice) {
return ResponseHelper::error('messages.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
@@ -108,7 +108,17 @@ class PaymentSignatureController
], 403);
}
if ($price !== $this->expectedPaymentPrice($order)) {
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'signature', [
'received_amount' => $price,
]);
if ($expectedPrice === null) {
return response()->json([
'success' => false,
'message' => 'Payment currency is not chargeable.',
], 422);
}
if ($price !== $expectedPrice) {
return response()->json([
'success' => false,
'message' => 'Payment amount does not match the order amount.',
@@ -10,16 +10,19 @@ use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
use Plugins\Sirsoft\PayKginicis\Repositories\CbtCvsOperationsRepositoryInterface;
class CbtCvsOperationsService
{
use PreventsReplayCallback;
use SanitizesPgResponse;
use SerializesPaymentCallbacks;
use ValidatesCbtOrderContext;
private const HISTORY_LIMIT = 20;
@@ -64,6 +67,7 @@ class CbtCvsOperationsService
$amount = (int) ($payload['amount'] ?? 0);
$payment = null;
$existingMeta = [];
$callbackLock = null;
if ($tid === '' || $orderId === '' || $mid === '' || $amount <= 0) {
Log::warning('KG Inicis CBT CVS: invalid notify payload', [
@@ -85,6 +89,8 @@ class CbtCvsOperationsService
return $this->notifyResult('FAIL', 'failed', 'order_not_found');
}
$callbackLock = $this->acquireOrderCallbackLock('cbtCvsNotify', $orderId);
$payment = $this->repository->firstPaymentForOrder($order);
if (! $payment) {
Log::warning('KG Inicis CBT CVS: payment row not found', [
@@ -177,6 +183,16 @@ class CbtCvsOperationsService
}
$expectedAmount = $this->resolveExpectedCvsAmount($order, $existingMeta);
if ($expectedAmount === null) {
$this->storeNotifyHistory($payment, $existingMeta, $payload, 'failed', 'invalid_payment_currency', $source, $remoteIp);
Log::warning('KG Inicis CBT CVS: payment currency is not chargeable', [
'order_id' => $orderId,
'tid' => $tid,
]);
return $this->notifyResult('FAIL', 'failed', 'invalid_payment_currency');
}
if ($expectedAmount > 0 && $amount !== $expectedAmount) {
$this->storeNotifyHistory($payment, $existingMeta, $payload, 'failed', 'amount_mismatch', $source, $remoteIp);
Log::warning('KG Inicis CBT CVS: amount mismatch', [
@@ -251,6 +267,8 @@ class CbtCvsOperationsService
]);
return $this->notifyResult('FAIL', 'failed', 'exception');
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
@@ -338,6 +356,11 @@ class CbtCvsOperationsService
return $this->operationError('messages.cbt_cvs.not_waiting_deposit', 422);
}
$expectedAmount = $this->resolveExpectedCvsAmount($order, $meta);
if ($expectedAmount === null) {
return $this->operationError('messages.cbt_cvs.simulate_failed', 422);
}
$now = now();
$payload = [
'tid' => $payment->transaction_id ?: 'ADMIN_CVS_'.$order->order_number,
@@ -353,7 +376,7 @@ class CbtCvsOperationsService
'confNo' => (string) ($meta['cvs_conf_no'] ?? ''),
'receiptNo' => (string) ($meta['cvs_receipt_no'] ?? ''),
'paymentTerm' => (string) ($meta['cvs_payment_term'] ?? ''),
'amount' => (string) $this->resolveExpectedCvsAmount($order, $meta),
'amount' => (string) $expectedAmount,
'currencyCd' => 'JPY',
];
@@ -599,7 +622,7 @@ class CbtCvsOperationsService
return $paymentTermAt instanceof CarbonImmutable && $paymentTermAt->isPast();
}
private function resolveExpectedCvsAmount(Order $order, array $paymentMeta): int
private function resolveExpectedCvsAmount(Order $order, array $paymentMeta): ?int
{
$metaAmount = (int) ($paymentMeta['cvs_amount'] ?? 0);
if ($metaAmount > 0) {
@@ -608,7 +631,9 @@ class CbtCvsOperationsService
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준).
// base≠결제 통화에서 CVS 통보 금액(결제 통화)과 단위가 일치하도록 한다.
return app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
return $this->resolveExpectedPaymentPriceOrNull($order, 'cbt_cvs_notify', [
'pay_method' => 'CVS',
]);
}
private function parseCbtDateTime(?string $value): ?CarbonImmutable
@@ -0,0 +1,74 @@
<?php
namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Concerns;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
class SerializesPaymentCallbacksTest extends PluginTestCase
{
private object $traitSubject;
protected function setUp(): void
{
parent::setUp();
$this->traitSubject = new class {
use SerializesPaymentCallbacks {
acquireOrderCallbackLock as public;
releaseOrderCallbackLock as public;
callbackLockKey as public;
}
protected function callbackLockWaitSeconds(): int
{
return 0;
}
protected function callbackLockTtlSeconds(): int
{
return 5;
}
};
}
public function test_same_context_and_order_are_serialized(): void
{
$orderNumber = 'ORD-KG-LOCK-' . random_int(10000, 99999);
$firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
try {
$this->expectException(LockTimeoutException::class);
$this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
} finally {
$this->traitSubject->releaseOrderCallbackLock($firstLock);
}
}
public function test_released_lock_allows_next_callback(): void
{
$orderNumber = 'ORD-KG-LOCK-' . random_int(10000, 99999);
$firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
$this->traitSubject->releaseOrderCallbackLock($firstLock);
$secondLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
$this->assertNotNull($secondLock);
$this->traitSubject->releaseOrderCallbackLock($secondLock);
}
public function test_lock_key_is_scoped_by_context_and_order(): void
{
$this->assertNotSame(
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-A'),
$this->traitSubject->callbackLockKey('vbankNotify', 'ORD-KG-LOCK-A'),
);
$this->assertNotSame(
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-A'),
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-B'),
);
}
}
@@ -69,7 +69,7 @@ class AdminCbtTestProductControllerTest extends PluginTestCase
'order_number' => 'JP-ORDER-ADMIN-CBT-001',
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'currency' => 'JPY',
'currency_snapshot' => ['JPY' => 1.0],
'currency_snapshot' => self::jpyCurrencySnapshot(),
'subtotal_amount' => 100,
'total_amount' => 100,
'total_due_amount' => 100,
@@ -86,7 +86,7 @@ class AdminCbtTestProductControllerTest extends PluginTestCase
'paid_amount_local' => 100,
'paid_amount_base' => 100,
'currency' => 'JPY',
'currency_snapshot' => ['JPY' => 1.0],
'currency_snapshot' => self::jpyCurrencySnapshot(),
'payment_meta' => [
'is_cbt' => true,
'cbt_type' => 'JPPG',
@@ -1284,6 +1284,7 @@ class CbtPaymentControllerTest extends PluginTestCase
$order->order_status = OrderStatusEnum::PENDING_ORDER;
$order->currency = 'JPY';
$order->total_due_amount = $amount;
$order->currency_snapshot = self::jpyCurrencySnapshot();
return $order;
}
@@ -1294,7 +1295,7 @@ class CbtPaymentControllerTest extends PluginTestCase
'order_number' => $orderNumber,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'JPY',
'currency_snapshot' => ['JPY' => 1.0],
'currency_snapshot' => self::jpyCurrencySnapshot(),
'subtotal_amount' => $amount,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
@@ -1328,7 +1329,7 @@ class CbtPaymentControllerTest extends PluginTestCase
'paid_amount_local' => 0,
'paid_amount_base' => 0,
'currency' => 'JPY',
'currency_snapshot' => ['JPY' => 1.0],
'currency_snapshot' => self::jpyCurrencySnapshot(),
'paid_at' => null,
]);
@@ -1369,6 +1370,7 @@ class CbtPaymentControllerTest extends PluginTestCase
'order_number' => $orderNumber,
'order_status' => OrderStatusEnum::PENDING_ORDER->value,
'currency' => 'JPY',
'currency_snapshot' => json_encode(self::jpyCurrencySnapshot(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'subtotal_amount' => $amount,
'total_amount' => $amount,
'total_tax_amount' => $amount,
@@ -48,6 +48,8 @@ class PaymentCallbackControllerTest extends PluginTestCase
'user_id' => $user->id,
'order_number' => 'ORD-TEST-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => $totalAmount,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
@@ -77,6 +77,30 @@ class PaymentCloseReportControllerTest extends PluginTestCase
->assertJsonPath('errors.message.0', 'Payment amount does not match the order amount.');
}
public function test_close_report_rejects_unchargeable_payment_currency_without_server_error(): void
{
$order = $this->makeOrder('ORD-CLOSE-CURRENCY-001', 10000);
$order->currency_snapshot = self::unchargeableKrwCurrencySnapshot();
$orderService = Mockery::mock(OrderProcessingService::class);
$orderService->shouldReceive('findByOrderNumber')
->once()
->with('ORD-CLOSE-CURRENCY-001')
->andReturn($order);
$orderService->shouldNotReceive('failPayment');
$orderService->shouldNotReceive('recordPaymentCancellation');
$this->app->instance(OrderProcessingService::class, $orderService);
$response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/close-report', [
'oid' => 'ORD-CLOSE-CURRENCY-001',
'price' => 10000,
]);
$response->assertStatus(422)
->assertJsonPath('errors.message.0', 'Payment currency is not chargeable.');
}
public function test_close_report_rejects_non_krw_order_without_failing_payment(): void
{
$order = $this->makeOrder('ORD-CLOSE-USD-001', 10000, 'USD');
@@ -199,6 +223,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
'order_number' => 'ORD-CLOSE-EASYPAY-001',
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 10000,
'total_amount' => 10000,
'total_due_amount' => 10000,
@@ -246,6 +271,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
$order->order_status = OrderStatusEnum::PENDING_ORDER;
$order->currency = $currency;
$order->total_due_amount = $amount;
$order->currency_snapshot = self::currencySnapshotFor($currency);
return $order;
}
@@ -79,6 +79,33 @@ class SignatureControllerTest extends PluginTestCase
->assertJsonPath('message', 'Payment amount does not match the order amount.');
}
public function test_pc_signature_rejects_unchargeable_payment_currency_without_server_error(): void
{
$order = $this->makePendingOrder('ORD-SIGN-CURRENCY-001', 10000, 'KRW', self::unchargeableKrwCurrencySnapshot());
$orderService = Mockery::mock(OrderProcessingService::class);
$orderService->shouldReceive('findByOrderNumber')
->with('ORD-SIGN-CURRENCY-001')
->andReturn($order);
$apiService = Mockery::mock(KgInicisApiService::class);
$apiService->shouldNotReceive('hasStandardPaymentCredentials');
$apiService->shouldNotReceive('generateSignature');
$apiService->shouldNotReceive('generateVerification');
$this->app->instance(OrderProcessingService::class, $orderService);
$this->app->instance(KgInicisApiService::class, $apiService);
$response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/signature', [
'oid' => 'ORD-SIGN-CURRENCY-001',
'price' => 10000,
'timestamp' => $this->freshEpochMs(),
]);
$response->assertStatus(422)
->assertJsonPath('message', 'Payment currency is not chargeable.');
}
public function test_pc_signature_rejects_non_krw_order(): void
{
$order = $this->makePendingOrder('ORD-SIGN-USD-001', 10000, 'USD');
@@ -216,6 +243,32 @@ class SignatureControllerTest extends PluginTestCase
->assertJsonPath('message', 'KG Inicis mobile payment credentials are not configured.');
}
public function test_mobile_signature_rejects_unchargeable_payment_currency_without_server_error(): void
{
$order = $this->makePendingOrder('ORD-SIGN-CURRENCY-002', 10000, 'KRW', self::unchargeableKrwCurrencySnapshot());
$orderService = Mockery::mock(OrderProcessingService::class);
$orderService->shouldReceive('findByOrderNumber')
->with('ORD-SIGN-CURRENCY-002')
->andReturn($order);
$apiService = Mockery::mock(KgInicisApiService::class);
$apiService->shouldNotReceive('hasMobilePaymentCredentials');
$apiService->shouldNotReceive('generateMobileChkfake');
$this->app->instance(OrderProcessingService::class, $orderService);
$this->app->instance(KgInicisApiService::class, $apiService);
$response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/mobile/signature', [
'oid' => 'ORD-SIGN-CURRENCY-002',
'price' => 10000,
'timestamp' => $this->freshEpochMs(),
]);
$response->assertStatus(422)
->assertJsonPath('message', 'Payment currency is not chargeable.');
}
public function test_pc_signature_expects_converted_price_for_non_base_order_currency(): void
{
// base=USD, 결제통화=KRW. base $6 → KRW 7058 환산이 검증 기준(price)이어야 한다.
@@ -266,7 +319,9 @@ class SignatureControllerTest extends PluginTestCase
$order->order_status = OrderStatusEnum::PENDING_ORDER;
$order->currency = $currency;
$order->total_due_amount = $amount;
$order->currency_snapshot = $currencySnapshot;
$order->currency_snapshot = $currencySnapshot !== []
? $currencySnapshot
: self::currencySnapshotFor($currency);
return $order;
}
@@ -39,6 +39,8 @@ class VbankNotifyReplayTest extends PluginTestCase
'user_id' => $user->id,
'order_number' => 'ORD-VR-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => $amount,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
@@ -139,6 +141,8 @@ class VbankNotifyReplayTest extends PluginTestCase
'user_id' => $user->id,
'order_number' => 'ORD-FA-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 30000,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
@@ -203,6 +207,8 @@ class VbankNotifyReplayTest extends PluginTestCase
'user_id' => User::factory()->create()->id,
'order_number' => 'ORD-DUP-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 20000,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
@@ -62,6 +62,74 @@ abstract class PluginTestCase extends TestCase
$this->registerEcommerceModuleInManager();
}
protected static function krwCurrencySnapshot(): array
{
return [
'base_currency' => 'KRW',
'order_currency' => 'KRW',
'base_unit' => 1,
'exchange_rates' => [
'KRW' => [
'rate' => 1,
'rounding_unit' => '1',
'rounding_method' => 'round',
'decimal_places' => 0,
'base_unit' => 1,
],
],
];
}
protected static function jpyCurrencySnapshot(): array
{
return [
'base_currency' => 'JPY',
'order_currency' => 'JPY',
'base_unit' => 1,
'exchange_rates' => [
'JPY' => [
'rate' => 1,
'rounding_unit' => '1',
'rounding_method' => 'round',
'decimal_places' => 0,
'base_unit' => 1,
],
],
];
}
protected static function unchargeableKrwCurrencySnapshot(): array
{
return [
'base_currency' => 'USD',
'order_currency' => 'KRW',
'base_unit' => 1,
'exchange_rates' => [
'USD' => [
'rate' => 1,
'rounding_unit' => '0.01',
'rounding_method' => 'round',
'decimal_places' => 2,
'base_unit' => 1,
],
'KRW' => [
'rate' => 0,
'rounding_unit' => '1',
'rounding_method' => 'round',
'decimal_places' => 0,
'base_unit' => 1,
],
],
];
}
protected static function currencySnapshotFor(string $currency): array
{
return strtoupper($currency) === 'JPY'
? self::jpyCurrencySnapshot()
: self::krwCurrencySnapshot();
}
/**
* 테스트 환경에서 sirsoft-ecommerce 모듈을 ModuleManager 에 명시 등록.
*/