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 * * 응답:
HTML 을 KCP 가 파싱. * result="0000" 이면 통보 성공, 그 외이면 재통보 (최대 10회). * * @param VbankNotifyRequest $request 검증된 입금통보 페이로드 * @return Response 200 + KCP 표준 result HTML */ public function vbankNotify(VbankNotifyRequest $request): Response { $validated = $request->validated(); $txCd = (string) ($validated['tx_cd'] ?? self::KCP_TX_CD_VBANK_DEPOSIT); $tno = $validated['tno']; $orderNo = $validated['order_no']; $opCd = (string) ($validated['op_cd'] ?? ''); $ipgmMny = (int) ($validated['ipgm_mnyx'] ?? 0); $notiId = $validated['noti_id'] ?? null; $callbackLock = null; // tx_cd 가 TX00 가 아니면 본 endpoint 가 처리하지 않음. // (KCP 가 단일 webhook URL 에 모든 tx_cd 를 보낼 수도 있어 안전하게 무시 + result=0000) if ($txCd !== self::KCP_TX_CD_VBANK_DEPOSIT) { Log::info('KCP: vbank-notify ignored non-TX00', [ 'tno' => $tno, 'order_no' => $orderNo, 'tx_cd' => $txCd, 'noti_id' => $notiId, ]); return $this->kcpNotifyResponse(); } // op_cd 분기 (그누보드5 settle_kcp_common.php 와 동일 정책): // 13 (망취소) → 별도 처리 (입금완료 안 함, 결제 취소 정책 미정 — 로깅만) // 그 외 (50/01/18/…) → 입금완료 처리 (KCP testadmin 모의입금은 op_cd=18 사용 확인됨) // KCP 공식 문서가 50/01/13 만 언급하지만 실제 KCP testadmin 발신은 18 등 추가 코드를 // 사용하므로 그누보드5 의 op_cd 무관 입금처리 정책을 따른다 (망취소만 분리). if ($opCd === self::KCP_OP_CD_DEPOSIT_CANCEL) { Log::warning('KCP: vbank deposit cancelled (op_cd=13)', [ 'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId, 'ipgm_mnyx' => $ipgmMny, ]); return $this->kcpNotifyResponse(); } try { $order = $this->orderService->findByOrderNumber($orderNo); if (! $order) { Log::error('KCP: vbank notify - order not found', [ 'order_no' => $orderNo, 'tno' => $tno, 'noti_id' => $notiId, ]); // 영구 실패 — 재시도 의미 없음. result=0000 으로 KCP 재통보 차단. return $this->kcpNotifyResponse(); } $callbackLock = $this->acquireOrderCallbackLock('vbankNotify', $orderNo); $order = $order->fresh('payment') ?? $order; $contextError = $this->validateVbankNotifyContext($order, $validated, $tno, $ipgmMny); if ($contextError !== null) { Log::warning('KCP: vbank notify context mismatch — retry requested', [ 'reason' => $contextError, 'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId, ]); return $this->kcpNotifyRetry(); } // 멱등성 — 이미 결제완료 상태면 재처리 없이 0000 응답 (op_cd=01 재전송 대응) if ($order->payment?->isPaid() ?? false) { Log::info('KCP: vbank-notify already paid (idempotent)', [ 'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId, 'op_cd' => $opCd, ]); return $this->kcpNotifyResponse(); } $this->orderService->completePayment($order, [ 'transaction_id' => $tno, 'payment_meta' => [ 'tx_cd' => $txCd, 'op_cd' => $opCd, 'noti_id' => $notiId, ...$this->currentCredentialMeta((string) ($validated['site_cd'] ?? '')), 'bank_code' => $validated['bank_code'] ?? null, 'ipgm_time' => $validated['tx_tm'] ?? null, 'pg_response_sanitized' => true, 'pg_raw_response' => $this->sanitizePgResponse($validated, self::VBANK_NOTIFY_RESPONSE_KEYS), ], ], $ipgmMny); $order->payment()->update(['pg_provider' => 'nhnkcp']); Log::info('KCP: vbank deposit confirmed', [ 'tno' => $tno, 'order_no' => $orderNo, 'ipgm_mnyx' => $ipgmMny, 'op_cd' => $opCd, 'noti_id' => $notiId, ]); return $this->kcpNotifyResponse(); } catch (LockTimeoutException $e) { Log::warning('KCP: vbank notify lock timeout — retry requested', [ 'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId, ]); return $this->kcpNotifyRetry(); } catch (\Exception $e) { Log::error('KCP: vbank notify failed', [ 'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId, 'error' => $e->getMessage(), ]); // 일시적 실패 (DB 등) — result != 0000 으로 KCP 재통보 유도 return $this->kcpNotifyRetry(); } finally { $this->releaseOrderCallbackLock($callbackLock); } } /** * 가상계좌 발급 처리 * * authCallback 에서 use_pay_method=VCNT 일 때 호출. * * PC Standard Pay: enc_data/enc_info 가 동봉되어 CLI 로 복호화하여 계좌 정보를 얻음. * Mobile SmartPhone Pay: enc_data 없이 평문 필드(bankname/account/depositor/va_date)가 * 콜백 POST 에 직접 포함되므로 CLI 호출을 건너뛰고 요청에서 직접 읽음. * * 발급 성공 = res_cd == 0000 AND bankname/account 가 모두 채워진 경우만. * 그 외 (CLI 9502 / 예외 / 평문 필드 결락) 는 발급 실패로 판정해 failPayment + fail URL. */ private function handleVbankIssued( array $validated, Order $order, string $encData, string $encInfo, string $ordrIdxx, string $custIp, Request $request, ): RedirectResponse { $tno = $validated['tno'] ?? ''; $pgResponse = []; $isMobile = $encData === '' || $encInfo === ''; if ($isMobile) { // 모바일: 콜백 POST 의 평문 필드를 그대로 응답으로 취급 // KCP 변종 키 모두 대응 (bankname|bank_name, depositor|account_holder, va_date|vnbank_expire_date) $pgResponse = [ 'res_cd' => $validated['res_cd'] ?? self::SUCCESS_RES_CD, 'res_msg' => $validated['res_msg'] ?? '', 'tno' => $tno, 'bankname' => $request->input('bankname') ?? $request->input('bank_name'), 'account' => $request->input('account'), 'depositor' => $request->input('depositor') ?? $request->input('account_holder'), 'va_date' => $request->input('va_date') ?? $request->input('vnbank_expire_date'), ]; } else { // PC: CLI 호출. 호출 자체 실패(권한 누락 / pub.key 부재 등)는 발급 여부 미확정이므로 안전하게 실패 처리. try { $pgResponse = $this->apiService->approvePayment($encData, $encInfo, $ordrIdxx, $custIp); } catch (\Exception $e) { Log::error('KCP: vbank CLI exception — treating as issuance failure', [ 'ordr_idxx' => $ordrIdxx, 'error' => $e->getMessage(), ]); $failedOrder = $this->orderService->failPayment($order, 'cli_exception', $e->getMessage()); $this->markKcpPaymentFailureRecord( $failedOrder, 'cli_exception', $e->getMessage(), 'vbank_cli_exception', ); return redirect($this->resolveFailUrl([ 'error' => 'cli_exception', 'message' => $e->getMessage(), 'orderId' => $ordrIdxx, ])); } } $pgResCd = $pgResponse['res_cd'] ?? ''; $bankname = $pgResponse['bankname'] ?? null; $account = $pgResponse['account'] ?? null; // 발급 성공 판정 — 핵심 필드(은행명/계좌번호) 모두 채워진 경우만 성공. // KCP 표준결제창은 결제수단별로 정상 res_cd 가 다르다 (card=0000, vbank=V000). // res_cd 단일 비교 대신 발급 데이터 존재 여부로 판정해 응답 변종에 견고하게 대응. // - V000 + bankname/account 존재 → 정상 발급 (success) // - 0000 + bankname/account 존재 → 정상 발급 (success) // - 9502 + bankname/account 부재 → 발급 실패 (fail) $hasIssuanceData = ($bankname !== null && $bankname !== '') && ($account !== null && $account !== ''); if (! $hasIssuanceData) { $resMsg = $this->decodeKcpMessage($pgResponse['res_msg'] ?? ''); $effectiveCode = $pgResCd !== '' && $pgResCd !== self::SUCCESS_RES_CD ? $pgResCd : 'vbank_issuance_incomplete'; $effectiveMsg = $resMsg !== '' ? $resMsg : '가상계좌 발급 정보 누락'; Log::warning('KCP: vbank issuance failed', [ 'ordr_idxx' => $ordrIdxx, 'res_cd' => $pgResCd, 'res_msg' => $resMsg, 'has_bankname' => $bankname !== null && $bankname !== '', 'has_account' => $account !== null && $account !== '', 'is_mobile' => $isMobile, ]); $failedOrder = $this->orderService->failPayment($order, $effectiveCode, $effectiveMsg); $this->markKcpPaymentFailureRecord( $failedOrder, $effectiveCode, $effectiveMsg, 'vbank_issuance_failed', ); return redirect($this->resolveFailUrl([ 'error' => $effectiveCode, 'message' => $effectiveMsg, 'orderId' => $ordrIdxx, ])); } if ($isMobile) { Log::info('KCP: vbank account issued via mobile callback (no CLI)', [ 'ordr_idxx' => $ordrIdxx, 'tno' => $tno, 'bankname' => $bankname, 'account' => $account, 'va_date' => $pgResponse['va_date'] ?? null, ]); } else { $tno = $pgResponse['tno'] ?? $tno; Log::info('KCP: vbank account issued via CLI', [ 'ordr_idxx' => $ordrIdxx, 'tno' => $tno, 'bankname' => $bankname, 'account' => $account, ]); } // 가상계좌 발급 정보를 OrderPayment vbank 전용 컬럼에 저장 (PENDING_PAYMENT 상태 유지). // 저장 실패 시도 부분 저장으로 사용자에게 잘못된 정보가 노출되지 않도록 failPayment 처리. try { $expireRaw = $pgResponse['va_date'] ?? null; $vbankDueAt = null; if ($expireRaw) { // KCP va_date: YYYYMMDDHHMMSS (14자리) 또는 YYYYMMDD (8자리) try { $vbankDueAt = strlen($expireRaw) <= 8 ? Carbon::createFromFormat('Ymd', $expireRaw)->endOfDay() : Carbon::createFromFormat('YmdHis', $expireRaw); } catch (\Exception) { $vbankDueAt = null; } } $order->payment()->update(array_filter([ 'transaction_id' => $tno ?: null, 'vbank_name' => $bankname, 'vbank_number' => $account, 'vbank_holder' => $pgResponse['depositor'] ?? null, 'vbank_due_at' => $vbankDueAt, 'vbank_issued_at' => now(), 'payment_device' => $isMobile ? 'mobile' : 'pc', 'payment_meta' => [ 'result_code' => $pgResponse['res_cd'] ?? null, 'tno' => $tno ?: null, ...$this->currentCredentialMeta(), 'bankname' => $bankname, 'bankcode' => $pgResponse['bankcode'] ?? $pgResponse['bank_code'] ?? null, 'va_date' => $pgResponse['va_date'] ?? $pgResponse['vnbank_expire_date'] ?? null, 'app_time' => $pgResponse['app_time'] ?? null, 'pg_response_sanitized' => true, 'pg_raw_response' => $this->sanitizePgResponse($pgResponse, self::VBANK_ISSUE_RESPONSE_KEYS), ], ], fn ($v) => $v !== null)); } catch (\Exception $e) { Log::error('KCP: failed to save vbank info to OrderPayment', [ 'ordr_idxx' => $ordrIdxx, 'error' => $e->getMessage(), ]); $failedOrder = $this->orderService->failPayment($order, 'vbank_save_failed', $e->getMessage()); $this->markKcpPaymentFailureRecord( $failedOrder, 'vbank_save_failed', $e->getMessage(), 'vbank_save_failed', ); return redirect($this->resolveFailUrl([ 'error' => 'vbank_save_failed', 'message' => $e->getMessage(), 'orderId' => $ordrIdxx, ])); } return redirect($this->resolveSuccessUrl($ordrIdxx)); } /** * KCP webhook 이 주장한 값이 결제창에서 발급받아 저장한 값과 일치하는지 확인한다. * * KCP 입금통보에는 별도 서명 필드가 없으므로, 주문번호만 믿지 않고 저장된 * tno/site_cd/가상계좌/금액을 모두 대조해 위조 통보를 차단한다. */ private function validateVbankNotifyContext(Order $order, array $validated, string $tno, int $amount): ?string { $payment = $order->payment; if (! $payment) { return 'payment_not_found'; } if (! in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true)) { return 'payment_method_not_vbank'; } $meta = $payment->payment_meta ?? []; $storedTno = trim((string) ($payment->transaction_id ?: ($meta['tno'] ?? ''))); if ($storedTno === '' || ! hash_equals($storedTno, $tno)) { return 'tno_mismatch'; } $storedSiteCd = trim((string) ($meta['site_cd'] ?? '')); $expectedSiteCd = $storedSiteCd !== '' ? $storedSiteCd : trim($this->apiService->getSiteCd()); $receivedSiteCd = trim((string) ($validated['site_cd'] ?? '')); if ($expectedSiteCd === '' || $receivedSiteCd === '' || ! hash_equals($expectedSiteCd, $receivedSiteCd)) { return 'site_cd_mismatch'; } $storedAccount = trim((string) ($payment->vbank_number ?? '')); $receivedAccount = trim((string) ($validated['account'] ?? '')); if ($storedAccount === '' || $receivedAccount === '' || ! hash_equals($storedAccount, $receivedAccount)) { return 'account_mismatch'; } $expectedAmount = $this->expectedVbankNotifyAmount($order); if ($expectedAmount === null) { return 'invalid_payment_currency'; } if ($amount !== $expectedAmount) { return 'amount_mismatch'; } return null; } private function expectedVbankNotifyAmount(Order $order): ?int { $payment = $order->payment; $paidAmount = (int) round((float) ($payment?->paid_amount_local ?? 0)); if (($payment?->isPaid() ?? false) && $paidAmount > 0) { return $paidAmount; } return $this->resolveExpectedPaymentPriceOrNull($order, 'vbank_notify', [ 'transaction_id' => $payment?->transaction_id, ]); } /** * 결제 당시 KCP 상점 설정을 payment_meta 에 남겨 이후 환불/통보 검증에 사용한다. * * site_key 같은 비밀값은 저장하지 않고, 공개 식별자인 site_cd 와 test/live 모드만 저장한다. * * @return array{site_cd: string, is_test_mode: bool} */ private function currentCredentialMeta(string $siteCd = ''): array { return [ 'site_cd' => $siteCd !== '' ? $siteCd : $this->apiService->getSiteCd(), 'is_test_mode' => $this->apiService->isTestMode(), ]; } private function resolveSuccessUrl(string $orderId): string { $settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? []; $urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete'; return $this->absolutize(str_replace('{orderId}', $orderId, $urlTemplate)); } /** * Approve 가 이미 발생한 후 후속 처리 실패 시 PG 측 cancel 을 시도. * * KCP CLI 승인이 완료되었으나 우리 서버에서 completePayment 가 실패한 경우 * 사용자 카드/계좌는 PG 측에서 승인 상태로 잔존 → 환불 불가 위험. 본 메서드는 * cancelPayment API 로 PG 잔존 승인을 즉시 해제해 사용자 보호를 확보한다. * * cancel 자체가 실패해도 사용자 응답 흐름은 막지 않음 — 로깅만 수행하고 * 운영자가 KCP 가맹점 관리자에서 수동 처리하도록 신호. */ private function autoCancelIfApproved( ?string $tno, string $ordrIdxx, int $cancelAmt, string $reason, ): void { if ($tno === null || $tno === '' || $cancelAmt <= 0) { return; } try { $result = $this->apiService->cancelPayment( $tno, $ordrIdxx, $cancelAmt, 'auto-cancel: '.$reason, ); Log::warning('KCP: auto-cancel after post-approve failure', [ 'tno' => $tno, 'ordr_idxx' => $ordrIdxx, 'amount' => $cancelAmt, 'reason' => $reason, 'res_cd' => $result['res_cd'] ?? null, 'res_msg' => $result['res_msg'] ?? null, ]); } catch (\Throwable $e) { Log::error('KCP: auto-cancel FAILED — manual reconciliation required', [ 'tno' => $tno, 'ordr_idxx' => $ordrIdxx, 'amount' => $cancelAmt, 'reason' => $reason, 'error' => $e->getMessage(), ]); } } private function resolveFailUrl(array $queryParams = []): string { $settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? []; $baseUrl = $this->absolutize($settings['redirect_fail_url'] ?? '/shop/checkout'); if (empty($queryParams)) { return $baseUrl; } // error 코드와 message 가 모두 있으면 message 앞에 '[코드] ' 자동 prefix. // 체크아웃 페이지에서 사용자가 오류 종류를 즉시 식별할 수 있도록 한다. // 예: error=9502, message=연동 모듈 호출 오류 → message=[9502] 연동 모듈 호출 오류 if (! empty($queryParams['error']) && ! empty($queryParams['message'])) { $queryParams['message'] = sprintf('[%s] %s', $queryParams['error'], $queryParams['message']); } $query = http_build_query(array_filter($queryParams)); $separator = str_contains($baseUrl, '?') ? '&' : '?'; return $baseUrl.$separator.$query; } /** * 상대 경로면 APP_URL 기준으로 절대 URL 화. * * PG가 브라우저 POST 로 콜백을 보내는 동안 Apache 가 ProxyPreserveHost Off 등 * 으로 Host 헤더를 localhost 로 바꿔서 PHP 에 전달하는 경우, Laravel 의 * redirect('/path') 가 http://localhost/path 를 생성해버린다. config('app.url') * (.env 의 APP_URL)을 명시적 base 로 사용하여 도메인을 보존한다. */ private function absolutize(string $url): string { if (preg_match('#^https?://#i', $url) === 1) { return $url; } $base = rtrim((string) config('app.url'), '/'); $path = $url === '' ? '/' : ($url[0] === '/' ? $url : '/'.$url); return $base.$path; } private function isKrwOrder(Order $order): bool { return $this->orderCurrency($order) === 'KRW'; } private function orderCurrency(Order $order): string { $currency = strtoupper(trim((string) ($order->currency ?? 'KRW'))); return $currency !== '' ? $currency : 'KRW'; } /** * KCP 모바일 게이트웨이는 EUC-KR(CP949) 인코딩으로 res_msg를 POST. * 유효한 UTF-8이 아니면 CP949로 간주하고 변환한다. */ private function decodeKcpMessage(string $msg): string { if ($msg === '' || mb_check_encoding($msg, 'UTF-8')) { return $msg; } $converted = mb_convert_encoding($msg, 'UTF-8', 'CP949'); return $converted !== false ? $converted : $msg; } }