fix(payment): NHN KCP 비회원 영수증 조회 지원

This commit is contained in:
thisgun
2026-07-06 12:26:39 +09:00
parent dee5a12ded
commit 646efeb91c
8 changed files with 360 additions and 8 deletions
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [Unreleased]
### Fixed
- 비회원 NHN KCP 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다.
## [1.0.0-beta.5] - 2026-06-23
### Fixed
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use Illuminate\Cookie\CookieJar;
use Symfony\Component\HttpFoundation\Cookie;
trait IssuesReceiptCookie
{
public const RECEIPT_COOKIE_NAME = 'nhnkcp_receipt_token';
private const RECEIPT_COOKIE_TTL_MINUTES = 5;
protected function queueReceiptCookie(string $orderNumber): void
{
$expiresTs = time() + (self::RECEIPT_COOKIE_TTL_MINUTES * 60);
$signature = $this->signReceiptToken($orderNumber, $expiresTs);
$tokenValue = $orderNumber.'|'.$expiresTs.'|'.$signature;
/** @var CookieJar $jar */
$jar = app(CookieJar::class);
$jar->queue(new Cookie(
name: self::RECEIPT_COOKIE_NAME,
value: $tokenValue,
expire: $expiresTs,
path: '/',
domain: null,
secure: request()->isSecure(),
httpOnly: true,
raw: false,
sameSite: Cookie::SAMESITE_LAX,
));
}
protected function verifyReceiptCookie(?string $cookieValue, string $expectedOrderNumber): bool
{
if (! is_string($cookieValue) || $cookieValue === '') {
return false;
}
$parts = explode('|', $cookieValue);
if (count($parts) !== 3) {
return false;
}
[$orderNumber, $expiresTs, $signature] = $parts;
if ($orderNumber !== $expectedOrderNumber) {
return false;
}
if (! ctype_digit($expiresTs) || (int) $expiresTs < time()) {
return false;
}
if (! ctype_xdigit($signature) || strlen($signature) !== 64) {
return false;
}
return hash_equals($this->signReceiptToken($orderNumber, (int) $expiresTs), $signature);
}
private function signReceiptToken(string $orderNumber, int $expiresTs): string
{
return hash_hmac('sha256', $orderNumber.'|'.$expiresTs, (string) config('app.key', ''));
}
}
@@ -18,6 +18,7 @@ use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SanitizesPgResponse;
@@ -37,6 +38,7 @@ use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
class PaymentCallbackController
{
use PreventsReplayCallback;
use IssuesReceiptCookie;
use RecordsPaymentWindowClosure;
use ResolvesEasyPayDisplay;
use SanitizesPgResponse;
@@ -217,6 +219,8 @@ class PaymentCallbackController
'transaction_id' => $order->payment?->transaction_id,
]);
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
@@ -302,6 +306,7 @@ class PaymentCallbackController
// Replay 가드: 동일 tno 가 이미 paid 상태면 중복 처리하지 않고 성공 페이지로 복귀
if ($this->wasAlreadyPaid($tno)) {
$this->logReplayDetected($tno, $ordrIdxx, 'authCallback (card)');
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
@@ -359,6 +364,8 @@ class PaymentCallbackController
// (기본 PG가 타 PG일 때 주문이 해당 PG provider로 생성되는 경우 대비)
$order->payment()->update(['pg_provider' => 'nhnkcp']);
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
} catch (LockTimeoutException $e) {
@@ -369,6 +376,8 @@ class PaymentCallbackController
$latestOrder = $this->orderService->findByOrderNumber($ordrIdxx);
if (($latestOrder?->payment?->isPaid() ?? false)
|| $latestOrder?->order_status === OrderStatusEnum::PAYMENT_COMPLETE) {
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
@@ -740,6 +749,8 @@ class PaymentCallbackController
]));
}
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
@@ -8,11 +8,14 @@ use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
class UserReceiptController
{
use IssuesReceiptCookie;
use ResolvesEasyPayDisplay;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
@@ -30,6 +33,7 @@ class UserReceiptController
public function __construct(
private readonly PluginSettingsService $pluginSettingsService,
private readonly NhnKcpApiService $apiService,
private readonly GuestOrderAuthService $guestOrderAuthService,
) {}
/**
@@ -41,20 +45,37 @@ class UserReceiptController
* 결제 영수증 URL(신용카드)과 현금영수증 URL(현금/계좌이체)을 반환한다.
* receipt_url 이 비어있으면 transaction_id (tno) 로 KCP 영수증 URL 을 동적 생성.
*
* @param Request $request 인증된 사용자 요청
* @param Request $request 회원 또는 비회원 영수증 요청
* @param string $orderNumber 주문번호
* @return JsonResponse receipt_url / cash_receipt_url 또는 404
*/
public function show(Request $request, string $orderNumber): JsonResponse
{
$user = $request->user();
$payment = DB::table('ecommerce_order_payments as p')
$query = DB::table('ecommerce_order_payments as p')
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
->where('o.order_number', $orderNumber)
->where('o.user_id', $user->id)
->where('p.pg_provider', 'nhnkcp')
->whereNotNull('p.transaction_id')
->whereNotNull('p.transaction_id');
if ($user) {
$query->where('o.user_id', $user->id);
} else {
$order = $this->guestOrderAuthService->verifyToken($request->header('X-Guest-Order-Token'), $orderNumber);
if ($order) {
$query->whereNull('o.user_id')->where('o.id', $order->id);
} elseif ($this->verifyReceiptCookie($request->cookie(self::RECEIPT_COOKIE_NAME), $orderNumber)) {
$query->whereNull('o.user_id')
->where('o.id', function ($sub) use ($orderNumber) {
$sub->select('id')->from('ecommerce_orders')->where('order_number', $orderNumber);
});
} else {
return response()->json(['error' => 'Not found'], 404);
}
}
$payment = $query
->select([
'p.transaction_id',
'p.payment_meta',
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Providers;
use App\Extension\BasePluginServiceProvider;
use Illuminate\Cookie\Middleware\EncryptCookies;
class PayNhnkcpServiceProvider extends BasePluginServiceProvider
{
protected string $pluginIdentifier = 'sirsoft-pay_nhnkcp';
public function boot(): void
{
parent::boot();
// PG callback 이 발급하는 영수증 쿠키는 HMAC 으로 자체 무결성 보장하므로
// EncryptCookies 대상에서 제외한다. 암호화 대상이면 callback 응답에서
// 직접 발급한 평문 쿠키가 다음 API 요청에서 복호화 실패로 폐기될 수 있다.
// 값 동기화: IssuesReceiptCookie::RECEIPT_COOKIE_NAME 과 동일해야 함.
EncryptCookies::except(['nhnkcp_receipt_token']);
}
}
@@ -29,10 +29,11 @@ Route::post('/payment/close-report', [PaymentCloseReportController::class, 'stor
Route::post('/payment/retry', [PaymentRetryController::class, 'store'])
->name('payment.retry');
Route::middleware(['auth:sanctum'])->group(function () {
Route::get('/user/orders/{orderNumber}/receipt', [UserReceiptController::class, 'show'])
->name('user.orders.receipt');
Route::get('/user/orders/{orderNumber}/receipt', [UserReceiptController::class, 'show'])
->middleware('optional.sanctum')
->name('user.orders.receipt');
Route::middleware(['auth:sanctum'])->group(function () {
// 테스트 모드 가상계좌 모의입금 정보 (입금대기 상태일 때만 데이터 반환)
Route::get('/user/orders/{orderNumber}/vbank-mock-deposit-info', [UserVbankMockDepositController::class, 'show'])
->name('user.orders.vbank-mock-deposit-info');
@@ -5,15 +5,26 @@ declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Controllers;
use App\Models\User;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
class UserReceiptControllerTest extends PluginTestCase
{
protected function setUp(): void
{
parent::setUp();
// 운영에서는 PayNhnkcpServiceProvider::boot 가 동일하게 등록한다.
EncryptCookies::except(['nhnkcp_receipt_token']);
}
public function test_receipt_response_includes_easy_pay_display_label(): void
{
app()->setLocale('ko');
@@ -62,4 +73,121 @@ class UserReceiptControllerTest extends PluginTestCase
$data['payment_method_display_label'] ?? null,
);
}
public function test_guest_receipt_requires_valid_token(): void
{
$order = $this->createGuestOrder();
$this->createKcpPayment($order, 'KCP_TNO_GUEST_001');
$this->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt")
->assertNotFound();
$this->withHeader('X-Guest-Order-Token', (time() + 1800).'|'.str_repeat('0', 64))
->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt")
->assertNotFound();
}
public function test_guest_receipt_returns_receipt_url_with_valid_header_token(): void
{
app()->setLocale('ko');
$order = $this->createGuestOrder();
$this->createKcpPayment($order, 'KCP_TNO_GUEST_002');
$response = $this->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order))
->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt")
->assertOk()
->assertJsonPath('receipt_url', 'https://testadmin8.kcp.co.kr/assist/bill.BillActionNew.do?cmd=card_bill&tno=KCP_TNO_GUEST_002&order_no='.$order->order_number.'&trade_mony=1000');
$this->assertContains($response->json('payment_method_label'), ['신용카드', 'Credit Card']);
}
public function test_guest_receipt_returns_receipt_url_with_valid_cookie(): void
{
$order = $this->createGuestOrder();
$this->createKcpPayment($order, 'KCP_TNO_GUEST_003');
$this->withUnencryptedCookie('nhnkcp_receipt_token', $this->issueReceiptCookie($order->order_number))
->withCredentials()
->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt")
->assertOk()
->assertJsonPath('receipt_url', 'https://testadmin8.kcp.co.kr/assist/bill.BillActionNew.do?cmd=card_bill&tno=KCP_TNO_GUEST_003&order_no='.$order->order_number.'&trade_mony=1000');
}
public function test_guest_token_for_other_order_cannot_access_receipt(): void
{
$order1 = $this->createGuestOrder();
$order2 = $this->createGuestOrder();
$this->createKcpPayment($order1, 'KCP_TNO_GUEST_004');
$this->createKcpPayment($order2, 'KCP_TNO_GUEST_005');
$this->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order1))
->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order2->order_number}/receipt")
->assertNotFound();
}
public function test_authenticated_user_cannot_access_guest_order_receipt_via_token(): void
{
$user = User::factory()->create();
$order = $this->createGuestOrder();
$this->createKcpPayment($order, 'KCP_TNO_GUEST_006');
$this->actingAs($user)
->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order))
->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt")
->assertNotFound();
}
private function createGuestOrder()
{
return OrderFactory::new()->create([
'user_id' => null,
'guest_lookup_password_hash' => Hash::make('test1234'),
'order_number' => 'ORD-KCP-GUEST-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'total_amount' => 1000,
'total_due_amount' => 0,
'total_paid_amount' => 1000,
'paid_at' => now(),
]);
}
private function createKcpPayment($order, string $transactionId): void
{
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::PAID,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'nhnkcp',
'transaction_id' => $transactionId,
'paid_amount_local' => 1000,
'paid_at' => now(),
'payment_meta' => [],
]);
}
private function issueGuestToken($order): string
{
$svc = app(GuestOrderAuthService::class);
$rc = new \ReflectionClass($svc);
$signMethod = $rc->getMethod('sign');
$signMethod->setAccessible(true);
$suffixMethod = $rc->getMethod('passwordHashSuffix');
$suffixMethod->setAccessible(true);
$expiresTs = time() + 1800;
$suffix = $suffixMethod->invoke($svc, $order);
$sig = $signMethod->invoke($svc, $order->order_number, (int) $order->id, $expiresTs, $suffix);
return $expiresTs.'|'.$sig;
}
private function issueReceiptCookie(string $orderNumber): string
{
$expiresTs = time() + 300;
$payload = $orderNumber.'|'.$expiresTs;
$signature = hash_hmac('sha256', $payload, (string) config('app.key', ''));
return $payload.'|'.$signature;
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Concerns;
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
/**
* 영수증 cookie HMAC 검증 로직 단위 테스트.
*
* HTTP 통합(callback → Set-Cookie → 후속 호출에서 cookie 자동 첨부) 검증은
* Playwright 시나리오로 다룬다 — Laravel 의 EncryptCookies 미들웨어와 PHPUnit
* HTTP 테스트 환경의 cookie 처리 흐름이 production 과 일부 다르기 때문.
*/
class IssuesReceiptCookieTest extends PluginTestCase
{
private object $subject;
protected function setUp(): void
{
parent::setUp();
// IssuesReceiptCookie 는 trait — 익명 클래스로 인스턴스화
$this->subject = new class {
use IssuesReceiptCookie {
verifyReceiptCookie as public;
}
};
}
public function test_verify_succeeds_with_fresh_token_matching_order_number(): void
{
$orderNumber = 'ORD-RECEIPT-12345';
$cookieValue = $this->buildToken($orderNumber, time() + 300);
$this->assertTrue($this->subject->verifyReceiptCookie($cookieValue, $orderNumber));
}
public function test_verify_fails_when_cookie_missing(): void
{
$this->assertFalse($this->subject->verifyReceiptCookie(null, 'ORD-RECEIPT-99999'));
$this->assertFalse($this->subject->verifyReceiptCookie('', 'ORD-RECEIPT-99999'));
}
public function test_verify_fails_when_token_format_invalid(): void
{
// 3-파트 미충족
$this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|123', 'ORD-X'));
// 만료시각이 숫자 아님
$this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|abc|'.str_repeat('0', 64), 'ORD-X'));
// 서명이 hex 아님
$this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|'.(time() + 300).'|invalid_sig', 'ORD-X'));
}
public function test_verify_fails_when_token_expired(): void
{
$orderNumber = 'ORD-RECEIPT-EXPIRED';
$expiredCookie = $this->buildToken($orderNumber, time() - 60);
$this->assertFalse($this->subject->verifyReceiptCookie($expiredCookie, $orderNumber));
}
public function test_verify_fails_when_order_number_mismatched(): void
{
$tokenFor = 'ORD-RECEIPT-AAA';
$accessing = 'ORD-RECEIPT-BBB';
$cookieValue = $this->buildToken($tokenFor, time() + 300);
// cookie 의 payload 가 다른 주문번호이므로 reject
$this->assertFalse($this->subject->verifyReceiptCookie($cookieValue, $accessing));
}
public function test_verify_fails_when_signature_tampered(): void
{
$orderNumber = 'ORD-RECEIPT-TAMPER';
$expiresTs = time() + 300;
// 정상 서명 대신 0으로 가득 찬 위조 서명
$tampered = $orderNumber.'|'.$expiresTs.'|'.str_repeat('0', 64);
$this->assertFalse($this->subject->verifyReceiptCookie($tampered, $orderNumber));
}
private function buildToken(string $orderNumber, int $expiresTs): string
{
$payload = $orderNumber.'|'.$expiresTs;
$signature = hash_hmac('sha256', $payload, (string) config('app.key', ''));
return $orderNumber.'|'.$expiresTs.'|'.$signature;
}
}