fix(payment): NHN KCP 다통화 결제 금액 검증 예외 처리 보강
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
-1
@@ -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.'],
|
||||
]);
|
||||
|
||||
+43
@@ -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);
|
||||
|
||||
+51
@@ -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);
|
||||
|
||||
+50
@@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user