diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index 7f303241..c7dc1975 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -30,6 +30,8 @@ - 표준/모바일 결제 서명 API에서 현재 모드의 MID와 서명키가 준비되지 않은 경우 서명을 생성하지 않고 중단하도록 보강했습니다. - PC/모바일/CBT 결제 콜백에서 로컬 결제완료 저장 후 예외가 발생한 경우 PG 취소·환불 보상 호출을 건너뛰도록 보강했습니다. 로컬 주문은 결제완료인데 PG 거래만 취소되는 상태 불일치를 방지합니다. - 일본 편의점결제(CBT CVS) 만료 처리와 통보 이력 저장 시 결제 row를 잠근 뒤 최신 상태를 다시 확인하도록 보강해 입금통보와 관리자 만료 처리의 동시성 충돌 가능성을 줄였습니다. +- 통화 스냅샷의 환율 설정이 비정상인 주문은 표준/모바일/CBT/가상계좌 결제 흐름에서 서버 오류 대신 검증 실패 또는 재통보 가능한 응답으로 처리하도록 보강했습니다. +- PC/모바일/CBT/가상계좌 결제 완료 콜백을 주문 단위로 직렬화해 완전 동시 콜백에서 결제완료 후속 처리 훅이 중복 실행될 가능성을 줄였습니다. ### Fixed diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/SerializesPaymentCallbacks.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/SerializesPaymentCallbacks.php new file mode 100644 index 00000000..6b6f8390 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/SerializesPaymentCallbacks.php @@ -0,0 +1,57 @@ +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; + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/ValidatesCbtOrderContext.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/ValidatesCbtOrderContext.php index f89325f7..b8fd6d2d 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/ValidatesCbtOrderContext.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Concerns/ValidatesCbtOrderContext.php @@ -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 */ diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCallbackController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCallbackController.php index 7117f914..af5ce159 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/CbtCallbackController.php @@ -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). diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php index d5680808..79ffd303 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php @@ -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); } } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileSignatureController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileSignatureController.php index 8b712482..a0f38bb0 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileSignatureController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileSignatureController.php @@ -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.', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php index 85ba2800..b359c92c 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php @@ -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, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php index 014e238d..30553835 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php @@ -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.'], ]); diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentSignatureController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentSignatureController.php index c730183f..e86b22f3 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentSignatureController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentSignatureController.php @@ -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.', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCvsOperationsService.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCvsOperationsService.php index 2bfb7934..81e07227 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCvsOperationsService.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Services/CbtCvsOperationsService.php @@ -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 diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php new file mode 100644 index 00000000..c2a8f52a --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Concerns/SerializesPaymentCallbacksTest.php @@ -0,0 +1,74 @@ +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'), + ); + } +} diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php index d437e394..22659e74 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/AdminCbtTestProductControllerTest.php @@ -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', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php index a3158143..cb0d6a8e 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/CbtPaymentControllerTest.php @@ -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, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php index 44994807..5b4475dc 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php @@ -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, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php index 85dee8e1..8e3fbcb4 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php @@ -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; } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php index 38cfe35a..1cfd532b 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/SignatureControllerTest.php @@ -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; } diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php index b39e4b60..bdefa05e 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php @@ -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, diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php index 19b23cc2..c47c2b2b 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/PluginTestCase.php @@ -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 에 명시 등록. */