fix(payment): 나이스페이 다통화 결제 예외 처리 보강
환율 스냅샷이 누락되거나 0인 주문에서 SignData와 콜백 처리 중 통화 예외가 일반 승인 오류로 뭉개지지 않도록 실패 사유를 분리한다.
This commit is contained in:
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로 결제창을 만들지 않고 서명 요청 단계에서 실패하도록 보강했습니다.
|
||||
|
||||
+38
@@ -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 */
|
||||
|
||||
+47
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
+57
@@ -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();
|
||||
|
||||
+25
@@ -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/');
|
||||
|
||||
Reference in new issue
Block a user