diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md index df3f3879..48dbd0a5 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md @@ -17,6 +17,7 @@ ### Fixed +- 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 SignData, 결제 승인 콜백, 결제창 닫힘 보고에서 예외가 일반 승인 오류로 뭉개지지 않도록 결제 통화 설정 오류로 명시 처리했습니다. - 나이스페이먼츠 간편결제 주문이 주문완료, 마이페이지 주문상세, 관리자 주문목록/상세에서 실제 간편결제 수단 대신 `신용카드`로만 표시되던 문제를 수정했습니다. - 관리자 주문목록과 주문상세 레이아웃이 리뉴얼되거나 플러그인 업데이트 과정에서 나이스페이먼츠 테스트결제 배지, 테스트 모드 안내, 결제 조회/결제 정보 패널이 누락될 수 있던 문제를 수정했습니다. - 운영 모드에서 live MID가 비어 있으면 `SR` 같은 잘못된 MID로 결제창을 만들지 않고 서명 요청 단계에서 실패하도록 보강했습니다. diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Concerns/RecordsPaymentWindowClosure.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Concerns/RecordsPaymentWindowClosure.php index a01fd949..12ca61ba 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Concerns/RecordsPaymentWindowClosure.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Concerns/RecordsPaymentWindowClosure.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayNicepayments\Concerns; +use InvalidArgumentException; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderAddress; @@ -20,6 +22,42 @@ 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 invalidPaymentCurrencyFailureMessage(InvalidArgumentException $e): string + { + $message = trim($e->getMessage()); + + return $message !== '' + ? $message + : '나이스페이먼츠 결제 통화 환율 설정이 올바르지 않습니다.'; + } + + protected function logInvalidPaymentCurrency( + Order $order, + InvalidArgumentException $e, + string $context, + array $logContext = [], + ): void { + Log::error('NicePayments: 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_nicepayments/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCallbackController.php index d95854b7..3405e72b 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCallbackController.php @@ -8,6 +8,7 @@ use App\Contracts\Extension\CacheInterface; use App\Extension\HookManager; use App\Services\PluginSettingsService; use Carbon\Carbon; +use InvalidArgumentException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -360,6 +361,25 @@ class PaymentCallbackController return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $moid])); + } catch (InvalidArgumentException $e) { + Log::error('NicePayments: invalid payment currency during authorization', [ + 'moid' => $moid, + 'error' => $e->getMessage(), + ]); + + $this->apiService->sendNetCancel($netCancelUrl, $txTid, $authToken, $amt); + + if (isset($order)) { + $failureMessage = $this->invalidPaymentCurrencyFailureMessage($e); + $failedOrder = $this->orderService->failPayment($order, 'INVALID_PAYMENT_CURRENCY', $failureMessage); + $this->markNicePaymentFailed($failedOrder, 'INVALID_PAYMENT_CURRENCY', $failureMessage); + } + + return redirect($this->resolveFailUrl([ + 'error' => 'invalid_payment_currency', + 'orderId' => $moid, + ])); + } catch (\Exception $e) { Log::error('NicePayments: authorize exception', [ 'moid' => $moid, @@ -600,7 +620,15 @@ class PaymentCallbackController // 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (마일리지/예치금 차감 후 실청구액). // 클라이언트 SignData 요청액(amt = pg_payment_data.amount = resolveOrderPaymentChargeAmount)· // 코어 최종 승인 검증과 동일 기준. base≠결제 통화(예: base JPY, 결제 KRW)에서도 단위가 일치한다. - $expectedChargeAmount = $this->expectedPaymentPrice($order); + $expectedChargeAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'sign_data', [ + 'moid' => $moid, + 'requested_amt' => $amt, + 'ip' => $request->ip(), + ]); + if ($expectedChargeAmount === null) { + return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.invalid_request')], 422); + } + if ($expectedChargeAmount !== $amt) { Log::warning('NicePayments: SignData amount mismatch', [ 'moid' => $moid, @@ -850,7 +878,15 @@ class PaymentCallbackController return 'vbank_account_mismatch'; } - if ($amount !== $this->expectedPaymentPrice($order)) { + $expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'vbank_notify', [ + 'tid' => $tid, + 'received_amt' => $amount, + ]); + if ($expectedAmount === null) { + return 'invalid_payment_currency'; + } + + if ($amount !== $expectedAmount) { return 'amount_mismatch'; } @@ -944,7 +980,15 @@ class PaymentCallbackController try { $order = $this->orderService->findByOrderNumber($moid); - if (! $order || $amount !== $this->expectedPaymentPrice($order)) { + if (! $order) { + return; + } + + $expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'auth_phase_failure', [ + 'moid' => $moid, + 'received_amt' => $amount, + ]); + if ($expectedAmount === null || $amount !== $expectedAmount) { return; } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php index 020feaaf..66899473 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/PaymentCloseReportController.php @@ -58,7 +58,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.'], ]); @@ -77,6 +88,12 @@ class PaymentCloseReportController ]); } + if (($result['status'] ?? null) === 'invalid_payment_currency') { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Payment currency is not chargeable.'], + ]); + } + if (($result['status'] ?? null) === 'ignored') { return ResponseHelper::success('messages.success', [ 'status' => 'ignored', @@ -120,7 +137,15 @@ class PaymentCloseReportController return ['status' => 'ignored', 'reason' => 'payment_already_paid']; } - if ($price !== $this->expectedPaymentPrice($lockedOrder)) { + $expectedPrice = $this->resolveExpectedPaymentPriceOrNull($lockedOrder, 'close_report_locked', [ + 'oid' => $oid, + 'received_price' => $price, + ]); + if ($expectedPrice === null) { + return ['status' => 'invalid_payment_currency']; + } + + if ($price !== $expectedPrice) { return ['status' => 'amount_mismatch']; } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php index 8f3ddc1c..aef36264 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php @@ -204,6 +204,28 @@ class PaymentCallbackControllerTest extends PluginTestCase ->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_amount')); } + public function test_sign_data_rejects_unchargeable_payment_currency(): void + { + $order = $this->createTestOrder(50000); + $order->update([ + 'currency' => 'USD', + 'currency_snapshot' => self::unchargeableUsdCurrencySnapshot(), + ]); + $this->mockPluginSettings(); + + $response = $this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [ + 'amt' => 50000, + 'moid' => $order->order_number, + ]); + + $response->assertStatus(422) + ->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_request')); + + $order->refresh(); + $this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status); + $this->assertEquals(PaymentStatusEnum::READY, $order->payment->payment_status); + } + public function test_sign_data_rejects_non_payable_order(): void { $order = $this->createTestOrder(50000); @@ -677,6 +699,41 @@ class PaymentCallbackControllerTest extends PluginTestCase $this->assertEquals('AUTHORIZE_EXCEPTION', $payment->payment_meta['failure_code'] ?? null); } + public function test_auth_callback_records_invalid_payment_currency_as_failed_payment(): void + { + $order = $this->createTestOrder(50000); + $order->update([ + 'currency' => 'USD', + 'currency_snapshot' => self::unchargeableUsdCurrencySnapshot(), + ]); + $this->mockPluginSettings(); + + $params = $this->makeCallbackParams($order->order_number, 50000); + + Http::fake([ + 'pay.nicepay.co.kr/v1/authorize' => Http::response( + $this->makeAuthorizeResponse('TID_INVALID_CURRENCY', $order->order_number, 50000), + 200 + ), + 'pay.nicepay.co.kr/v1/netcancel' => Http::response('OK', 200), + ]); + + $response = $this->post('/plugins/sirsoft-pay_nicepayments/payment/callback', $params); + + $response->assertRedirect(); + $this->assertStringContainsString('error=invalid_payment_currency', $response->headers->get('Location')); + + $order->refresh(); + $payment = $order->payment->fresh(); + + $this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status); + $this->assertEquals('INVALID_PAYMENT_CURRENCY', $order->order_meta['payment_failure_code'] ?? null); + $this->assertEquals(PaymentStatusEnum::FAILED, $payment->payment_status); + $this->assertEquals('INVALID_PAYMENT_CURRENCY', $payment->payment_meta['failure_code'] ?? null); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v1/netcancel')); + } + public function test_auth_callback_redirects_to_fail_url_on_missing_params(): void { $this->mockPluginSettings(); diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCloseReportControllerTest.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCloseReportControllerTest.php index a109302e..ea323b78 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCloseReportControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/PaymentCloseReportControllerTest.php @@ -113,6 +113,31 @@ 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_marking_failed(): void + { + $order = $this->makeOrder('ORD-NICE-CLOSE-CURRENCY', 10000); + $order->currency = 'USD'; + $order->currency_snapshot = self::unchargeableUsdCurrencySnapshot(); + + $orderService = Mockery::mock(OrderProcessingService::class); + $orderService->shouldReceive('findByOrderNumber') + ->once() + ->with('ORD-NICE-CLOSE-CURRENCY') + ->andReturn($order); + $orderService->shouldNotReceive('failPayment'); + $orderService->shouldNotReceive('recordPaymentCancellation'); + + $this->app->instance(OrderProcessingService::class, $orderService); + + $response = $this->postJson('/api/plugins/sirsoft-pay_nicepayments/payment/close-report', [ + 'oid' => 'ORD-NICE-CLOSE-CURRENCY', + '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-NICE-CLOSE-003', 10000); diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php index 9b6c8b62..abdd26df 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/PluginTestCase.php @@ -69,6 +69,31 @@ abstract class PluginTestCase extends TestCase ]; } + protected static function unchargeableUsdCurrencySnapshot(): array + { + return [ + 'base_currency' => 'KRW', + 'order_currency' => 'USD', + 'base_unit' => 1, + 'exchange_rates' => [ + 'KRW' => [ + 'rate' => 1, + 'rounding_unit' => '1', + 'rounding_method' => 'round', + 'decimal_places' => 0, + 'base_unit' => 1, + ], + 'USD' => [ + 'rate' => 0, + 'rounding_unit' => '0.01', + 'rounding_method' => 'round', + 'decimal_places' => 2, + 'base_unit' => 1, + ], + ], + ]; + } + protected function registerModuleAutoload(): void { $moduleBasePath = base_path('modules/sirsoft-ecommerce/src/');