fix(payment): 나이스페이 다통화 결제 예외 처리 보강

환율 스냅샷이 누락되거나 0인 주문에서 SignData와 콜백 처리 중 통화 예외가 일반 승인 오류로 뭉개지지 않도록 실패 사유를 분리한다.
This commit is contained in:
thisgun committed 2026-07-04 11:56:59 +09:00
1 parent 5a9ad97327
commit 1cdef4513a
7 files changed
+220 -5

No files matched your search

@@ -17,6 +17,7 @@
### Fixed
- 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 SignData, 결제 승인 콜백, 결제창 닫힘 보고에서 예외가 일반 승인 오류로 뭉개지지 않도록 결제 통화 설정 오류로 명시 처리했습니다.
- 나이스페이먼츠 간편결제 주문이 주문완료, 마이페이지 주문상세, 관리자 주문목록/상세에서 실제 간편결제 수단 대신 `신용카드`로만 표시되던 문제를 수정했습니다.
- 관리자 주문목록과 주문상세 레이아웃이 리뉴얼되거나 플러그인 업데이트 과정에서 나이스페이먼츠 테스트결제 배지, 테스트 모드 안내, 결제 조회/결제 정보 패널이 누락될 수 있던 문제를 수정했습니다.
- 운영 모드에서 live MID가 비어 있으면 `SR` 같은 잘못된 MID로 결제창을 만들지 않고 서명 요청 단계에서 실패하도록 보강했습니다.
@@ -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 */
@@ -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;
}
@@ -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'];
}
@@ -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();
@@ -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);
@@ -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/');