fix(payment): NHN KCP 다통화 결제 금액 검증 예외 처리 보강

This commit is contained in:
thisgun
2026-07-06 10:54:32 +09:00
parent c59819645a
commit dee5a12ded
10 changed files with 283 additions and 10 deletions
@@ -10,6 +10,7 @@
- 간편결제 주문의 원 결제수단이 사용자 주문완료/주문상세, 관리자 주문목록/주문상세에서 신용카드로만 보이지 않도록 표시 라벨과 관리자 목록 표시 맵을 보강했습니다.
- 동일 주문의 NHN KCP 승인/가상계좌 콜백이 동시에 도착해도 결제완료 훅과 주문 부작용이 중복 실행되지 않도록 주문번호 단위 콜백 락을 추가했습니다.
- 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 close-report, 재시도 API, 모바일 승인키, 승인 콜백에서 500 오류로 이어지지 않고 결제 통화 설정 오류로 명시 처리되도록 보강했습니다.
- 결제창 닫힘, 인증 취소, 승인 실패, 금액 불일치 등 NHN KCP 실패 경로에서 주문 실패 상태와 결제 레코드의 PG별 실패 메타가 함께 기록되도록 보강했습니다.
- 결제 실패/취소 후 같은 주문번호로 결제창을 다시 열 때 안전한 실패 주문만 `pending_order` / `ready` 상태로 복구하는 재시도 준비 API를 추가했습니다.
- PC/모바일 결제창 호출 직전에 재시도 준비 API를 호출해 사용자가 결제 실패 후 동일 주문 결제를 다시 시도할 수 있도록 보강했습니다.
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use InvalidArgumentException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -26,6 +27,33 @@ trait RecordsPaymentWindowClosure
return app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($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('NHN KCP: 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 */
@@ -129,8 +157,13 @@ trait RecordsPaymentWindowClosure
return false;
}
if ($amount !== null && $amount !== $this->expectedPaymentPrice($order)) {
return false;
if ($amount !== null) {
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'retry_restore', [
'received_amount' => $amount,
]);
if ($expectedAmount === null || $amount !== $expectedAmount) {
return false;
}
}
if (! $this->isRetryableKcpFailure($order, $payment)) {
@@ -119,7 +119,19 @@ class MobileApprovalController
], 403);
}
$expectedAmount = $this->expectedPaymentPrice($order);
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'mobile_approval_key', [
'order_number' => $validated['order_number'],
'requested_amount' => $validated['amount'],
'requested_currency' => $validated['currency'] ?? null,
'ip' => $request->ip(),
]);
if ($expectedAmount === null) {
return response()->json([
'success' => false,
'error' => 'Payment currency is not chargeable.',
], 422);
}
if ((int) $validated['amount'] !== $expectedAmount) {
return response()->json([
'success' => false,
@@ -16,7 +16,6 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
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\PayNhnkcp\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
@@ -221,7 +220,17 @@ class PaymentCallbackController
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
$expectedAmount = $this->expectedPaymentPrice($order);
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'auth_callback', [
'ordr_idxx' => $ordrIdxx,
'good_mny' => $goodMny,
]);
if ($expectedAmount === null) {
return redirect($this->resolveFailUrl([
'error' => 'invalid_payment_currency',
'orderId' => $ordrIdxx,
]));
}
if (! $this->isKrwOrder($order)) {
Log::warning('KCP: unsupported order currency', [
'ordr_idxx' => $ordrIdxx,
@@ -303,7 +312,7 @@ class PaymentCallbackController
// base≠결제 통화(예: base JPY, 결제 KRW)에서도 PG 청구 통화와 단위가 일치한다.
$approvedAmt = $goodMny > 0
? $goodMny
: app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
: $expectedAmount;
// PG 측 승인이 확정된 시점 — 후속 처리 실패 시 cancel 필요. catch 에서 참조.
$approvedTno = $tno;
@@ -771,6 +780,10 @@ class PaymentCallbackController
}
$expectedAmount = $this->expectedVbankNotifyAmount($order);
if ($expectedAmount === null) {
return 'invalid_payment_currency';
}
if ($amount !== $expectedAmount) {
return 'amount_mismatch';
}
@@ -778,7 +791,7 @@ class PaymentCallbackController
return null;
}
private function expectedVbankNotifyAmount(Order $order): int
private function expectedVbankNotifyAmount(Order $order): ?int
{
$payment = $order->payment;
$paidAmount = (int) round((float) ($payment?->paid_amount_local ?? 0));
@@ -787,7 +800,9 @@ class PaymentCallbackController
return $paidAmount;
}
return $this->expectedPaymentPrice($order);
return $this->resolveExpectedPaymentPriceOrNull($order, 'vbank_notify', [
'transaction_id' => $payment?->transaction_id,
]);
}
/**
@@ -56,7 +56,18 @@ class PaymentCloseReportController
]);
}
if ($price !== $this->expectedPaymentPrice($order)) {
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [
'oid' => $oid,
'received_price' => $price,
'ip' => $request->ip(),
]);
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.'],
]);
@@ -52,7 +52,18 @@ class PaymentRetryController
]);
}
if ($price !== $this->expectedPaymentPrice($order)) {
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'payment_retry', [
'oid' => $oid,
'received_price' => $price,
'ip' => $request->ip(),
]);
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.'],
]);
@@ -73,6 +73,31 @@ class MobileApprovalControllerTest extends PluginTestCase
];
}
private static function invalidUsdCurrencySnapshot(): array
{
return [
'base_currency' => 'KRW',
'order_currency' => 'USD',
'base_unit' => 1000,
'exchange_rates' => [
'KRW' => [
'rate' => 1,
'rounding_unit' => '1',
'rounding_method' => 'round',
'decimal_places' => 0,
'base_unit' => 1000,
],
'USD' => [
'rate' => 0,
'rounding_unit' => '0.01',
'rounding_method' => 'round',
'decimal_places' => 2,
'base_unit' => 1,
],
],
];
}
private function mockSoapService(string $approvalKey = 'TEST_APPROVAL_KEY'): void
{
$mock = $this->createMock(KcpSoapService::class);
@@ -237,6 +262,24 @@ class MobileApprovalControllerTest extends PluginTestCase
->assertJsonPath('success', false);
}
public function test_get_approval_key_rejects_invalid_payment_currency_without_server_error(): void
{
$order = $this->createOrder(10000);
$order->update([
'currency' => 'USD',
'currency_snapshot' => self::invalidUsdCurrencySnapshot(),
]);
$this->expectSoapServiceNotCalled();
$this->mockPluginSettings();
$response = $this->actingAs($order->user)
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order, ['currency' => 'USD']));
$response->assertUnprocessable()
->assertJsonPath('success', false)
->assertJsonPath('error', 'Payment currency is not chargeable.');
}
public function test_get_approval_key_rejects_non_krw_order(): void
{
$order = $this->createOrder(10000);
@@ -109,6 +109,31 @@ class PaymentCallbackControllerTest extends PluginTestCase
];
}
private static function invalidUsdCurrencySnapshot(): array
{
return [
'base_currency' => 'KRW',
'order_currency' => 'USD',
'base_unit' => 1000,
'exchange_rates' => [
'KRW' => [
'rate' => 1,
'rounding_unit' => '1',
'rounding_method' => 'round',
'decimal_places' => 0,
'base_unit' => 1000,
],
'USD' => [
'rate' => 0,
'rounding_unit' => '0.01',
'rounding_method' => 'round',
'decimal_places' => 2,
'base_unit' => 1,
],
],
];
}
private function mockPluginSettings(array $overrides = []): void
{
$defaults = [
@@ -314,6 +339,32 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
}
public function test_auth_callback_rejects_invalid_payment_currency_before_cli_approval(): void
{
$order = $this->createTestOrder(50000);
$order->update([
'currency' => 'USD',
'currency_snapshot' => self::invalidUsdCurrencySnapshot(),
]);
$this->mockPluginSettings();
$mock = $this->createMock(NhnKcpApiService::class);
$mock->expects($this->never())->method('approvePayment');
$this->app->instance(NhnKcpApiService::class, $mock);
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
$this->makeCallbackParams($order->order_number, 50000)
);
$response->assertRedirect();
$this->assertStringContainsString('error=invalid_payment_currency', $response->headers->get('Location'));
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
$this->assertEquals(PaymentStatusEnum::READY, $order->payment->payment_status);
}
public function test_auth_callback_stores_only_sanitized_pg_response_fields(): void
{
$order = $this->createTestOrder(50000);
@@ -74,6 +74,31 @@ class PaymentCloseReportControllerTest extends PluginTestCase
->assertJsonPath('errors.message.0', 'Payment amount does not match the order amount.');
}
public function test_close_report_rejects_invalid_payment_currency_without_server_error(): void
{
$order = $this->makeOrder('ORD-KCP-CLOSE-CURRENCY-001', 10000);
$order->currency = 'USD';
$order->currency_snapshot = self::invalidUsdCurrencySnapshot();
$orderService = Mockery::mock(OrderProcessingService::class);
$orderService->shouldReceive('findByOrderNumber')
->once()
->with('ORD-KCP-CLOSE-CURRENCY-001')
->andReturn($order);
$orderService->shouldNotReceive('failPayment');
$orderService->shouldNotReceive('recordPaymentCancellation');
$this->app->instance(OrderProcessingService::class, $orderService);
$response = $this->postJson('/api/plugins/sirsoft-pay_nhnkcp/payment/close-report', [
'oid' => 'ORD-KCP-CLOSE-CURRENCY-001',
'price' => 10000,
]);
$response->assertStatus(422)
->assertJsonPath('errors.message.0', 'Payment currency is not chargeable.');
}
public function test_close_report_rejects_buyer_mismatch(): void
{
$order = $this->makeOrder('ORD-KCP-CLOSE-003', 10000);
@@ -186,4 +211,29 @@ class PaymentCloseReportControllerTest extends PluginTestCase
],
];
}
private static function invalidUsdCurrencySnapshot(): array
{
return [
'base_currency' => 'KRW',
'order_currency' => 'USD',
'base_unit' => 1000,
'exchange_rates' => [
'KRW' => [
'rate' => 1,
'rounding_unit' => '1',
'rounding_method' => 'round',
'decimal_places' => 0,
'base_unit' => 1000,
],
'USD' => [
'rate' => 0,
'rounding_unit' => '0.01',
'rounding_method' => 'round',
'decimal_places' => 2,
'base_unit' => 1,
],
],
];
}
}
@@ -72,6 +72,31 @@ class PaymentRetryControllerTest extends PluginTestCase
];
}
private static function invalidUsdCurrencySnapshot(): array
{
return [
'base_currency' => 'KRW',
'order_currency' => 'USD',
'base_unit' => 1000,
'exchange_rates' => [
'KRW' => [
'rate' => 1,
'rounding_unit' => '1',
'rounding_method' => 'round',
'decimal_places' => 0,
'base_unit' => 1000,
],
'USD' => [
'rate' => 0,
'rounding_unit' => '0.01',
'rounding_method' => 'round',
'decimal_places' => 2,
'base_unit' => 1,
],
],
];
}
public function test_retry_returns_ready_for_pending_order(): void
{
$order = $this->createOrder(10000);
@@ -89,6 +114,27 @@ class PaymentRetryControllerTest extends PluginTestCase
$this->assertEquals(PaymentStatusEnum::READY, $order->payment->payment_status);
}
public function test_retry_rejects_invalid_payment_currency_without_server_error(): void
{
$order = $this->createOrder(10000);
$order->update([
'currency' => 'USD',
'currency_snapshot' => self::invalidUsdCurrencySnapshot(),
]);
$response = $this->postJson(self::ENDPOINT, [
'oid' => $order->order_number,
'price' => 10000,
]);
$response->assertStatus(422)
->assertJsonPath('errors.message.0', 'Payment currency is not chargeable.');
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
$this->assertEquals(PaymentStatusEnum::READY, $order->payment->payment_status);
}
public function test_retry_restores_cancelled_kcp_failure_order(): void
{
$order = $this->createOrder(