fix(payment): NHN KCP 간편결제 메타와 중복 콜백 처리 보강

This commit is contained in:
thisgun
2026-07-03 19:03:54 +09:00
parent 76f7300e8f
commit 2a9f521487
10 changed files with 193 additions and 27 deletions
@@ -4,26 +4,6 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [Unreleased]
### Security
- 가상계좌 입금통보와 에스크로 공통통보가 주문번호만으로 처리되지 않도록 저장된 KCP 거래번호, site_cd, 계좌번호, 금액을 교차 검증하도록 보강했습니다.
- 모바일 승인키 발급 시 주문 소유자와 결제금액을 검증해 타인 주문번호 또는 변조 금액으로 승인키가 발급되지 않도록 차단했습니다.
- 무서명 브라우저 실패 콜백만으로 진행 중 주문이 취소/실패 상태로 바뀌지 않도록 차단했습니다.
- NHN KCP가 지원하지 않는 비-KRW 주문이 결제창/승인 콜백으로 진행되지 않도록 서버와 프론트 가드를 추가했습니다.
- KCP CLI 디버그 로그와 에스크로 배송등록 PG 응답 저장에서 비밀키, 카드번호, 수령자 개인정보 등 원문 민감값이 남지 않도록 축소했습니다.
- 에스크로 공통통보 replay 시 동일 payload가 내부 hook을 중복 발행하지 않도록 보강했습니다.
### Fixed
- 플러그인 설정 화면에 NHN KCP 상점관리자의 결제취소 서버 IP 등록 필요 안내와 서버 IP 복사 기능을 추가해 운영 모드 결제취소 실패 원인을 사전에 확인할 수 있도록 했습니다.
- 관리자 주문목록과 주문상세 레이아웃이 리뉴얼되거나 플러그인 업데이트 과정에서 NHN KCP 테스트결제 배지, 테스트 모드 안내, 결제 조회/결제 정보 패널이 누락될 수 있던 문제를 수정했습니다.
- 복합과세 모바일/PC 결제 필드의 공급가액, 부가세, 비과세 합계가 할인/마일리지 반영 후 실결제액과 일치하도록 재배분했습니다.
- 에스크로 테스트 site_cd가 비어 있으면 빈 값이 아닌 일반 테스트 site_cd로 fallback되도록 보정했습니다.
- 환불 처리 시 결제 당시 KCP site_cd와 테스트/운영 모드를 복원하도록 보강했습니다.
- KCP webhook IP 가드가 설정 누락 시 테스트 모드로 우회되지 않고 차단되도록 변경했습니다.
## [1.0.0-beta.5] - 2026-06-23
### Fixed
@@ -40,8 +20,25 @@
- 레이아웃 편집기에서 이 플러그인의 설정 화면을 테스트 모드/운영 모드 상태로 전환해 미리볼 수 있도록 했습니다.
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
### Security
- 가상계좌 입금통보와 에스크로 공통통보가 주문번호만으로 처리되지 않도록 저장된 KCP 거래번호, site_cd, 계좌번호, 금액을 교차 검증하도록 보강했습니다.
- 모바일 승인키 발급 시 주문 소유자와 결제금액을 검증해 타인 주문번호 또는 변조 금액으로 승인키가 발급되지 않도록 차단했습니다.
- 무서명 브라우저 실패 콜백만으로 진행 중 주문이 취소/실패 상태로 바뀌지 않도록 차단했습니다.
- NHN KCP가 지원하지 않는 비-KRW 주문이 결제창/승인 콜백으로 진행되지 않도록 서버와 프론트 가드를 추가했습니다.
- KCP CLI 디버그 로그와 에스크로 배송등록 PG 응답 저장에서 비밀키, 카드번호, 수령자 개인정보 등 원문 민감값이 남지 않도록 축소했습니다.
- 에스크로 공통통보 replay 시 동일 payload가 내부 hook을 중복 발행하지 않도록 보강했습니다.
### Fixed
- NHN KCP 간편결제 성공 시 원 결제수단이 주문 결제 메타와 간편결제 PG 필드에 보존되도록 보강했습니다.
- 이미 결제 완료된 주문의 카드 승인 콜백이 중복 도착하면 KCP 승인 API를 다시 호출하지 않고 멱등 성공 응답으로 처리하도록 보강했습니다.
- 플러그인 설정 화면에 NHN KCP 상점관리자의 결제취소 서버 IP 등록 필요 안내와 서버 IP 복사 기능을 추가해 운영 모드 결제취소 실패 원인을 사전에 확인할 수 있도록 했습니다.
- 관리자 주문목록과 주문상세 레이아웃이 리뉴얼되거나 플러그인 업데이트 과정에서 NHN KCP 테스트결제 배지, 테스트 모드 안내, 결제 조회/결제 정보 패널이 누락될 수 있던 문제를 수정했습니다.
- 복합과세 모바일/PC 결제 필드의 공급가액, 부가세, 비과세 합계가 할인/마일리지 반영 후 실결제액과 일치하도록 재배분했습니다.
- 에스크로 테스트 site_cd가 비어 있으면 빈 값이 아닌 일반 테스트 site_cd로 fallback되도록 보정했습니다.
- 환불 처리 시 결제 당시 KCP site_cd와 테스트/운영 모드를 복원하도록 보강했습니다.
- KCP webhook IP 가드가 설정 누락 시 테스트 모드로 우회되지 않고 차단되도록 변경했습니다.
- 이커머스 관리자 주문설정 결제수단 목록에서 NHN KCP 간편결제 항목이 브랜드 텍스트 배지로 표시되도록 보강했습니다.
- 체크아웃에서 주문설정으로 활성화한 NHN KCP 간편결제가 숨겨지거나 KG 이니시스 브랜드 문구로 잘못 보정되는 문제를 수정했습니다.
- 결제 취소/부분취소 시 KCP CLI 내부 필드 구분자가 안전성 검사에 막혀 취소 요청이 실패할 수 있던 문제를 수정했습니다.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
buildKcpEasyPayReturnFields,
buildKcpTaxFields,
isSupportedKcpCurrency,
requestPaymentHandler,
@@ -212,4 +213,13 @@ describe('requestPaymentHandler', () => {
expect(isSupportedKcpCurrency('KRW')).toBe(true);
expect(isSupportedKcpCurrency('usd')).toBe(false);
});
it('간편결제 원 결제수단을 KCP callback 반환 필드에 싣는다', () => {
expect(buildKcpEasyPayReturnFields('nhnkcp_naverpay', true)).toEqual({
param_opt_1: 'nhnkcp_naverpay',
nhnkcp_easy_pay_method: 'nhnkcp_naverpay',
});
expect(buildKcpEasyPayReturnFields('card', false)).toEqual({});
expect(buildKcpEasyPayReturnFields('nhnkcp_unknown', true)).toEqual({});
});
});
@@ -120,6 +120,18 @@ const KCP_EASY_PAY_DIRECT: Record<string, Record<string, string>> = {
nhnkcp_applepay: { applepay_direct: 'Y' },
};
export function buildKcpEasyPayReturnFields(paymentMethod: string, isEasyPay: boolean): Record<string, string> {
const methodKey = paymentMethod.toLowerCase();
if (!isEasyPay || !Object.prototype.hasOwnProperty.call(KCP_EASY_PAY_DIRECT, methodKey)) {
return {};
}
return {
param_opt_1: methodKey,
nhnkcp_easy_pay_method: methodKey,
};
}
function isKcpPaymentFrameClosed(iframe: HTMLIFrameElement): boolean {
if (!iframe.isConnected) {
return true;
@@ -408,6 +420,7 @@ async function handlePcPayment(
};
Object.assign(fields, buildKcpTaxFields(pgPaymentData));
Object.assign(fields, buildKcpEasyPayReturnFields(paymentMethod, isEasyPay));
// 가상계좌 전용 파라미터
if (payMethod === '001000000000') {
@@ -190,6 +190,7 @@ class MobileApprovalController
if (isset(self::EASY_PAY_DIRECT_FIELDS[$payMethodKey])) {
$fields = [...$fields, ...self::EASY_PAY_DIRECT_FIELDS[$payMethodKey]];
}
$fields = [...$fields, ...$this->buildEasyPayReturnFields($payMethodKey)];
}
return response()->json([
@@ -243,6 +244,21 @@ class MobileApprovalController
];
}
/**
* @return array<string, string>
*/
private function buildEasyPayReturnFields(string $payMethodKey): array
{
if (! isset(self::EASY_PAY_DIRECT_FIELDS[$payMethodKey])) {
return [];
}
return [
'param_opt_1' => $payMethodKey,
'nhnkcp_easy_pay_method' => $payMethodKey,
];
}
private function isKrwPayment(Order $order, ?string $requestedCurrency): bool
{
return $this->normalizeCurrency($requestedCurrency) === 'KRW'
@@ -102,6 +102,29 @@ class PaymentCallbackController
'use_pay_method',
];
private const EASY_PAY_METHODS = [
'nhnkcp_payco' => [
'provider' => 'payco',
'label' => ['ko' => 'PAYCO (페이코)', 'en' => 'PAYCO'],
],
'nhnkcp_naverpay' => [
'provider' => 'naverpay',
'label' => ['ko' => '네이버페이', 'en' => 'NaverPay'],
],
'nhnkcp_naverpay_point' => [
'provider' => 'naverpay_point',
'label' => ['ko' => '네이버페이 포인트결제', 'en' => 'NaverPay Point'],
],
'nhnkcp_kakaopay' => [
'provider' => 'kakaopay',
'label' => ['ko' => '카카오페이', 'en' => 'KakaoPay'],
],
'nhnkcp_applepay' => [
'provider' => 'applepay',
'label' => ['ko' => '애플페이', 'en' => 'Apple Pay'],
],
];
public function __construct(
private readonly OrderProcessingService $orderService,
private readonly PluginSettingsService $pluginSettingsService,
@@ -203,6 +226,15 @@ class PaymentCallbackController
$order = $order->fresh('payment') ?? $order;
}
if (($order->payment?->isPaid() ?? false) || $order->order_status === OrderStatusEnum::PAYMENT_COMPLETE) {
Log::info('KCP: authCallback already paid order, skipping CLI approval', [
'ordr_idxx' => $ordrIdxx,
'transaction_id' => $order->payment?->transaction_id,
]);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
$expectedAmount = $this->expectedPaymentPrice($order);
if (! $this->isKrwOrder($order)) {
Log::warning('KCP: unsupported order currency', [
@@ -292,6 +324,7 @@ class PaymentCallbackController
$approvedAmtForCancel = $approvedAmt;
$isEscrow = ($pgResponse['escw_yn'] ?? '') === 'Y';
$easyPayMeta = $this->resolveEasyPayMeta($validated);
// 4단계: 주문 완료 처리
$this->orderService->completePayment($order, [
@@ -301,11 +334,16 @@ class PaymentCallbackController
'card_name' => $pgResponse['card_name'] ?? $pgResponse['bank_name'] ?? null,
'card_installment_months' => (int) ($pgResponse['quota'] ?? 0),
'is_interest_free' => false,
'embedded_pg_provider' => null,
'embedded_pg_provider' => $easyPayMeta['provider'] ?? null,
'receipt_url' => null,
'payment_meta' => [
'res_cd' => $pgResCd,
...$this->currentCredentialMeta(),
...($easyPayMeta === [] ? [] : [
'nhnkcp_easy_pay_method' => $easyPayMeta['method'],
'nhnkcp_easy_pay_provider' => $easyPayMeta['provider'],
'nhnkcp_easy_pay_label' => $easyPayMeta['label'],
]),
'use_pay_method' => $validated['use_pay_method'] ?? $pgResponse['use_pay_method'] ?? null,
'app_time' => $pgResponse['app_time'] ?? null,
'bank_name' => $pgResponse['bank_name'] ?? null,
@@ -748,6 +786,25 @@ class PaymentCallbackController
];
}
/**
* @return array{method: string, provider: string, label: array{ko: string, en: string}}|array{}
*/
private function resolveEasyPayMeta(array $payload): array
{
$method = strtolower(trim((string) ($payload['nhnkcp_easy_pay_method'] ?? $payload['param_opt_1'] ?? '')));
if (! isset(self::EASY_PAY_METHODS[$method])) {
return [];
}
$definition = self::EASY_PAY_METHODS[$method];
return [
'method' => $method,
'provider' => $definition['provider'],
'label' => $definition['label'],
];
}
private function resolveSuccessUrl(string $orderId): string
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
@@ -46,6 +46,8 @@ class AuthCallbackRequest extends FormRequest
'ordr_idxx' => ['required', 'string'],
'good_mny' => ['nullable', 'numeric', 'min:1'],
'use_pay_method' => ['nullable', 'string'],
'param_opt_1' => ['nullable', 'string', 'max:50'],
'nhnkcp_easy_pay_method' => ['nullable', 'string', 'max:50'],
// 모바일 SmartPhone Pay 가상계좌 콜백 (enc_data 없이 평문 전달)
// KCP가 보내는 변종 키 모두 허용 — handleVbankIssued 에서 우선순위로 처리
'bankname' => ['nullable', 'string'],
@@ -276,7 +276,9 @@ class MobileApprovalControllerTest extends PluginTestCase
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order, ['pay_method' => 'nhnkcp_naverpay']));
$response->assertOk()
->assertJsonPath('data.fields.naverpay_direct', 'Y');
->assertJsonPath('data.fields.naverpay_direct', 'Y')
->assertJsonPath('data.fields.param_opt_1', 'nhnkcp_naverpay')
->assertJsonPath('data.fields.nhnkcp_easy_pay_method', 'nhnkcp_naverpay');
}
public function test_kakaopay_easy_pay_includes_direct_field(): void
@@ -130,10 +130,13 @@ class PaymentCallbackControllerTest extends PluginTestCase
* NhnKcpApiService::approvePayment() 를 exec() 없이 mock.
* 기존 테스트는 Http::fake()로 CLI exec를 막으려 했으나 동작하지 않음.
*/
private function mockApiService(array $cliResponse): void
private function mockApiService(array $cliResponse, ?int $approveCalls = null): void
{
$mock = $this->createMock(NhnKcpApiService::class);
$mock->method('approvePayment')->willReturn($cliResponse);
$expectation = $approveCalls === null
? $mock->method('approvePayment')
: $mock->expects($this->exactly($approveCalls))->method('approvePayment');
$expectation->willReturn($cliResponse);
$this->app->instance(NhnKcpApiService::class, $mock);
}
@@ -201,6 +204,72 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->assertEquals('APP12345', $payment->card_approval_number);
}
public function test_auth_callback_stores_easy_pay_method_meta(): void
{
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tno = 'KCP_TNO_NAVERPAY';
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, 50000));
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
$this->makeCallbackParams($order->order_number, 50000, [
'tno' => $tno,
'param_opt_1' => 'nhnkcp_naverpay',
'nhnkcp_easy_pay_method' => 'nhnkcp_naverpay',
])
);
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
$payment = $order->payment;
$payment->refresh();
$meta = $payment->payment_meta;
$this->assertSame('naverpay', $payment->embedded_pg_provider);
$this->assertSame('nhnkcp_naverpay', $meta['nhnkcp_easy_pay_method'] ?? null);
$this->assertSame('naverpay', $meta['nhnkcp_easy_pay_provider'] ?? null);
$this->assertSame('네이버페이', $meta['nhnkcp_easy_pay_label']['ko'] ?? null);
$this->assertSame('NaverPay', $meta['nhnkcp_easy_pay_label']['en'] ?? null);
}
public function test_auth_callback_skips_cli_approval_when_order_already_paid(): void
{
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$existingTno = 'KCP_TNO_ALREADY_PAID';
$order->update([
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'paid_at' => now(),
'total_paid_amount' => 50000,
'total_due_amount' => 0,
]);
$order->payment->update([
'payment_status' => PaymentStatusEnum::PAID,
'paid_at' => now(),
'paid_amount_local' => 50000,
'paid_amount_base' => 50000,
'transaction_id' => $existingTno,
'card_approval_number' => 'APP_ALREADY',
]);
$this->mockApiService($this->makeCliResponse('KCP_TNO_SHOULD_NOT_APPROVE', $order->order_number, 50000), 0);
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
$this->makeCallbackParams($order->order_number, 50000, ['tno' => $existingTno])
);
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
$payment = $order->payment;
$payment->refresh();
$this->assertSame($existingTno, $payment->transaction_id);
$this->assertSame('APP_ALREADY', $payment->card_approval_number);
}
public function test_auth_callback_rejects_browser_amount_mismatch_before_cli_approval(): void
{
$order = $this->createTestOrder(50000);