fix(payment): NHN KCP 간편결제 메타와 중복 콜백 처리 보강
This commit is contained in:
@@ -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
+10
@@ -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'],
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+71
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user