validated(); $resCd = (string) ($validated['res_cd'] ?? ''); $resMsg = $this->decodeKcpMessage($validated['res_msg'] ?? ''); $encData = $validated['enc_data'] ?? ''; // 모바일 취소 시 미포함 $encInfo = $validated['enc_info'] ?? ''; // 모바일 취소 시 미포함 $ordrIdxx = $validated['ordr_idxx']; $goodMny = isset($validated['good_mny']) ? (int) $validated['good_mny'] : 0; $custIp = $request->ip() ?? '127.0.0.1'; Log::info('KCP: authCallback received', [ 'ordr_idxx' => $ordrIdxx, 'res_cd' => $resCd, 'good_mny' => $goodMny, 'has_enc_data' => ! empty($encData), 'has_enc_info' => ! empty($encInfo), 'post_keys' => array_keys($request->all()), ]); // 1단계: KCP 브라우저 결과 코드 확인 if ($resCd !== self::SUCCESS_RES_CD) { $isCancelled = in_array($resCd, self::CANCEL_RES_CODES, true); Log::info('KCP: payment result non-success', [ 'ordr_idxx' => $ordrIdxx, 'res_cd' => $resCd, 'res_msg' => $resMsg, 'is_cancelled' => $isCancelled, ]); // authCallback is a browser-return endpoint without PG-server signature/IP proof. // Do not mutate order state from a non-success browser result alone; the // authenticated close-report path records real window closures. // 사용자 취소는 오류 없이 체크아웃으로 복귀 if ($isCancelled) { return redirect($this->resolveFailUrl()); } return redirect($this->resolveFailUrl([ 'error' => $resCd, 'message' => $resMsg, 'orderId' => $ordrIdxx, ])); } // Approve 성공 후 후속 처리 실패 시 PG 측 자동 cancel 을 위한 추적 변수. // catch 블록에서 tno 가 set 되어 있으면 KCP 측 승인이 이미 발생한 상태이므로 // cancelPayment 를 호출해 PG 잔존 승인을 해제한다 (사용자 환불 보장). $order = null; $approvedTno = null; $approvedAmtForCancel = 0; $callbackLock = null; try { // 2단계: 주문 조회 $order = $this->orderService->findByOrderNumber($ordrIdxx); if (! $order) { Log::error('KCP: order not found', ['ordr_idxx' => $ordrIdxx]); return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $ordrIdxx])); } $callbackLock = $this->acquireOrderCallbackLock('authCallback', $ordrIdxx); $order = $order->fresh('payment') ?? $order; if ($order->order_status === OrderStatusEnum::CANCELLED) { $restored = $this->restoreRetryableKcpOrder($order, $goodMny > 0 ? $goodMny : null); if (! $restored) { Log::warning('KCP: cancelled order is not retryable', ['ordr_idxx' => $ordrIdxx]); return redirect($this->resolveFailUrl([ 'error' => 'order_not_retryable', 'orderId' => $ordrIdxx, ])); } $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->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, 'currency' => $this->orderCurrency($order), ]); return redirect($this->resolveFailUrl(['error' => 'currency_not_supported', 'orderId' => $ordrIdxx])); } if ($goodMny > 0 && $goodMny !== $expectedAmount) { Log::warning('KCP: browser callback amount mismatch before approval', [ 'ordr_idxx' => $ordrIdxx, 'expected' => $expectedAmount, 'actual' => $goodMny, ]); $failedOrder = $this->orderService->failPayment($order, 'AMOUNT_MISMATCH', 'KCP callback amount mismatch'); $this->markKcpPaymentFailureRecord( $failedOrder, 'AMOUNT_MISMATCH', 'KCP callback amount mismatch', 'amount_mismatch', ); return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $ordrIdxx])); } // 가상계좌: 계좌 발급 완료 처리 (실제 입금은 vbankNotify에서 처리) // KCP 콜백의 use_pay_method=VCNT 또는 주문의 payment_method=vbank 로 감지 $isVbank = ($validated['use_pay_method'] ?? '') === 'VCNT' || in_array($order->payment?->payment_method?->value, ['vbank', 'virtual_account'], true); if ($isVbank) { return $this->handleVbankIssued($validated, $order, $encData, $encInfo, $ordrIdxx, $custIp, $request); } HookManager::doAction('sirsoft-pay_nhnkcp.payment.before_confirm', $order, $validated); // 3단계: KCP CLI로 최종 승인 확인 $pgResponse = $this->apiService->approvePayment($encData, $encInfo, $ordrIdxx, $custIp); HookManager::doAction('sirsoft-pay_nhnkcp.payment.after_confirm', $order, $pgResponse); $pgResCd = $pgResponse['res_cd'] ?? ''; if ($pgResCd !== self::SUCCESS_RES_CD) { Log::warning('KCP: CLI approval failed', [ 'ordr_idxx' => $ordrIdxx, 'res_cd' => $pgResCd, 'res_msg' => $pgResponse['res_msg'] ?? '', ]); $failedOrder = $this->orderService->failPayment($order, $pgResCd, $pgResponse['res_msg'] ?? ''); $this->markKcpPaymentFailureRecord( $failedOrder, $pgResCd, $pgResponse['res_msg'] ?? '', 'approval_failed', ); return redirect($this->resolveFailUrl([ 'error' => $pgResCd, 'message' => $pgResponse['res_msg'] ?? '', 'orderId' => $ordrIdxx, ])); } $tno = $pgResponse['tno'] ?? ($validated['tno'] ?? ''); // Replay 가드: 동일 tno 가 이미 paid 상태면 중복 처리하지 않고 성공 페이지로 복귀 if ($this->wasAlreadyPaid($tno)) { $this->logReplayDetected($tno, $ordrIdxx, 'authCallback (card)'); return redirect($this->resolveSuccessUrl($ordrIdxx)); } // KCP는 CLI 응답에 good_mny가 없는 경우가 많으므로 주문 금액으로 검증. // 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (마일리지/예치금 차감 후 실청구액) — // 클라이언트 결제 요청액(buildPgPaymentData)·코어 최종 승인 검증과 동일 기준. // base≠결제 통화(예: base JPY, 결제 KRW)에서도 PG 청구 통화와 단위가 일치한다. $approvedAmt = $goodMny > 0 ? $goodMny : $expectedAmount; // PG 측 승인이 확정된 시점 — 후속 처리 실패 시 cancel 필요. catch 에서 참조. $approvedTno = $tno; $approvedAmtForCancel = $approvedAmt; $isEscrow = ($pgResponse['escw_yn'] ?? '') === 'Y'; $easyPayMeta = $this->resolveEasyPayMeta($validated); // 4단계: 주문 완료 처리 $this->orderService->completePayment($order, [ 'transaction_id' => $tno, 'card_approval_number' => $pgResponse['app_no'] ?? null, 'card_number_masked' => $pgResponse['card_no'] ?? $pgResponse['account'] ?? null, 'card_name' => $pgResponse['card_name'] ?? $pgResponse['bank_name'] ?? null, 'card_installment_months' => (int) ($pgResponse['quota'] ?? 0), 'is_interest_free' => false, '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, 'vnbank_expire_date' => $pgResponse['vnbank_expire_date'] ?? null, 'escw_yn' => $pgResponse['escw_yn'] ?? null, 'pg_response_sanitized' => true, 'pg_raw_response' => $this->sanitizePgResponse($pgResponse, self::AUTH_RESPONSE_KEYS), ], 'payment_device' => DeviceDetector::detect($request), ], $approvedAmt); // 에스크로 결제인 경우 is_escrow 플래그 저장 if ($isEscrow) { $order->payment()->update(['is_escrow' => true]); } // KCP가 실제로 결제를 처리했으므로 pg_provider를 nhnkcp로 보정 // (기본 PG가 타 PG일 때 주문이 해당 PG provider로 생성되는 경우 대비) $order->payment()->update(['pg_provider' => 'nhnkcp']); return redirect($this->resolveSuccessUrl($ordrIdxx)); } catch (LockTimeoutException $e) { Log::warning('KCP: authCallback skipped because callback lock is busy', [ 'ordr_idxx' => $ordrIdxx, ]); $latestOrder = $this->orderService->findByOrderNumber($ordrIdxx); if (($latestOrder?->payment?->isPaid() ?? false) || $latestOrder?->order_status === OrderStatusEnum::PAYMENT_COMPLETE) { return redirect($this->resolveSuccessUrl($ordrIdxx)); } return redirect($this->resolveFailUrl([ 'error' => 'callback_locked', 'orderId' => $ordrIdxx, ])); } catch (PaymentAmountMismatchException $e) { Log::error('KCP: amount mismatch', [ 'ordr_idxx' => $ordrIdxx, 'expected' => $e->getExpectedAmount(), 'actual' => $e->getActualAmount(), ]); // Approve 가 이미 KCP 측에서 발생했으면 자동 취소로 PG 잔존 승인 해제 $this->autoCancelIfApproved($approvedTno, $ordrIdxx, $approvedAmtForCancel, 'amount_mismatch'); if ($order instanceof Order) { $failedOrder = $this->orderService->failPayment($order, 'AMOUNT_MISMATCH', $e->getMessage()); $this->markKcpPaymentFailureRecord( $failedOrder, 'AMOUNT_MISMATCH', $e->getMessage(), 'amount_mismatch', ); } return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $ordrIdxx])); } catch (\Exception $e) { Log::error('KCP: confirm exception', [ 'ordr_idxx' => $ordrIdxx, 'error' => $e->getMessage(), ]); $this->autoCancelIfApproved($approvedTno, $ordrIdxx, $approvedAmtForCancel, 'confirm_failed'); if ($order instanceof Order) { $failedOrder = $this->orderService->failPayment($order, 'CONFIRM_FAILED', $e->getMessage()); $this->markKcpPaymentFailureRecord( $failedOrder, 'CONFIRM_FAILED', $e->getMessage(), 'confirm_failed', ); } return redirect($this->resolveFailUrl([ 'error' => 'confirm_failed', 'message' => $e->getMessage(), 'orderId' => $ordrIdxx, ])); } finally { $this->releaseOrderCallbackLock($callbackLock); } } /** * KCP 가상계좌 입금 통보 처리 * * POST /plugins/sirsoft-pay_nhnkcp/payment/vbank-notify * (KCP 서버 → 우리 서버, CSRF 제외) * * KCP 공식 webhook 페이로드 (그누보드5 settle_kcp_common.php 참고): * tx_cd=TX00 (가상계좌 입금) + op_cd 50/01/13 + ipgm_mnyx + order_no + noti_id * * 응답: