diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index f7563fcf..cce347b9 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -10,6 +10,7 @@ - 간편결제 주문의 원 결제수단이 사용자 주문완료/주문상세, 관리자 주문목록/주문상세에서 신용카드로만 보이지 않도록 표시 라벨과 관리자 목록 표시 맵을 보강했습니다. - 동일 주문의 NHN KCP 승인/가상계좌 콜백이 동시에 도착해도 결제완료 훅과 주문 부작용이 중복 실행되지 않도록 주문번호 단위 콜백 락을 추가했습니다. +- 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 close-report, 재시도 API, 모바일 승인키, 승인 콜백에서 500 오류로 이어지지 않고 결제 통화 설정 오류로 명시 처리되도록 보강했습니다. - 결제창 닫힘, 인증 취소, 승인 실패, 금액 불일치 등 NHN KCP 실패 경로에서 주문 실패 상태와 결제 레코드의 PG별 실패 메타가 함께 기록되도록 보강했습니다. - 결제 실패/취소 후 같은 주문번호로 결제창을 다시 열 때 안전한 실패 주문만 `pending_order` / `ready` 상태로 복구하는 재시도 준비 API를 추가했습니다. - PC/모바일 결제창 호출 직전에 재시도 준비 API를 호출해 사용자가 결제 실패 후 동일 주문 결제를 다시 시도할 수 있도록 보강했습니다. diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/RecordsPaymentWindowClosure.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/RecordsPaymentWindowClosure.php index 7d4d0be6..ea971df7 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/RecordsPaymentWindowClosure.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/RecordsPaymentWindowClosure.php @@ -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)) { diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php index e82d91dc..2d122af9 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/MobileApprovalController.php @@ -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, diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php index 345e0fc4..94e5f13f 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php @@ -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, + ]); } /** diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php index 5ecd20b1..459ec7b9 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCloseReportController.php @@ -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.'], ]); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php index 60503458..a5e115d4 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentRetryController.php @@ -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.'], ]); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/MobileApprovalControllerTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/MobileApprovalControllerTest.php index a486f0ba..570df6ed 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/MobileApprovalControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/MobileApprovalControllerTest.php @@ -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); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCallbackControllerTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCallbackControllerTest.php index 53992cd4..6250299b 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCallbackControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCallbackControllerTest.php @@ -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); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php index e37255ef..bcc22976 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCloseReportControllerTest.php @@ -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, + ], + ], + ]; + } } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentRetryControllerTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentRetryControllerTest.php index 94177d2e..4149bef5 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentRetryControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentRetryControllerTest.php @@ -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(