AUTH */ public function auth(GcPaymentAttempt $attempt, string $token, array $order, string $vactKind = 'a'): array { // 현재 config는 vact만 있으니 a(무통장) 기준 구현 // v(인증계좌/WIRETRANSFER)는 config 확장 시 추가 if ($vactKind !== 'a') { throw new \RuntimeException("Vact kind not supported yet: {$vactKind}"); } $cfg = config('danal.vact'); if (!$cfg || empty($cfg['cpid']) || empty($cfg['key']) || empty($cfg['iv']) || empty($cfg['url'])) { throw new \RuntimeException("Danal vact config missing"); } $oid = (string)($order['oid'] ?? ''); $amount = (int)($order['amount'] ?? $order['pay_money'] ?? 0); $itemName = (string)($order['itemName'] ?? $order['pin_name'] ?? '상품권'); $isMobile = (bool)($order['is_mobile'] ?? false); $userAgent = $isMobile ? 'MW' : 'PC'; $holder = (string)($cfg['holder'] ?? '핀포유'); $returnUrl = route('web.payments.danal.vact.return', ['t' => $token], true); $cancelUrl = route('web.payments.danal.cancel', ['o' => $oid, 't' => $token], true); $notiUrl = route('web.payments.danal.vact.noti', [], true); // BYPASSVALUE는 ; 구분 (token 포함) $memNo = (string)($order['mem_no'] ?? $attempt->mem_no ?? ''); $bypass = "PAYMETHOD=VACT;MEMNO={$memNo};AMOUNT={$amount};TOKEN={$token};"; $req = [ // CI3처럼 CPID를 내부 DATA에도 넣는다 'CPID' => (string)$cfg['cpid'], 'SUBCPID' => '', 'ACCOUNTHOLDER' => $this->toEucKr($holder), 'EXPIREDATE' => now()->addHours(23)->format('Ymd'), 'ORDERID' => $oid, 'ITEMNAME' => $this->toEucKr($itemName), 'AMOUNT' => (string)$amount, 'ISCASHRECEIPTUI' => 'N', 'USERNAME' => $this->toEucKr((string)($order['user_name'] ?? '')), 'USERID' => (string)($order['user_id'] ?? $order['mem_no'] ?? ''), 'USEREMAIL' => (string)($order['user_email'] ?? ''), 'USERPHONE' => '', 'USERAGENT' => $userAgent, 'TXTYPE' => 'AUTH', 'SERVICETYPE' => 'DANALVACCOUNT', 'RETURNURL' => $returnUrl, 'NOTIURL' => $notiUrl, 'CANCELURL' => $cancelUrl, 'BYPASSVALUE' => $bypass, ]; $attempt->vact_kind = 'a'; $attempt->request_payload = $req; $attempt->save(); $res = $this->postCpcgi( (string)$cfg['url'], (string)$cfg['cpid'], $req, (string)$cfg['key'], (string)$cfg['iv'] ); $attempt->response_payload = $res; $attempt->save(); if (($res['RETURNCODE'] ?? '') !== '0000') { $msg = (string)($res['RETURNMSG'] ?? '모듈 호출 실패'); throw new \RuntimeException("Danal vact AUTH failed: {$res['RETURNCODE']} {$msg}"); } return [ 'actionUrl' => (string)$res['STARTURL'], 'params' => [ 'STARTPARAMS' => (string)$res['STARTPARAMS'], ], 'acceptCharset' => 'EUC-KR', ]; } /** * CI3: payment_danal_vact_return() -> RETURN decrypt -> ISSUEVACCOUNT */ public function issueVaccountOnReturn(GcPaymentAttempt $attempt, string $token, array $order, array $post): array { $cfg = config('danal.vact'); if (!$cfg || empty($cfg['cpid']) || empty($cfg['key']) || empty($cfg['iv']) || empty($cfg['url'])) { throw new \RuntimeException("Danal vact config missing"); } $returnParams = (string)($post['RETURNPARAMS'] ?? ''); if ($returnParams === '') { return $this->fail('C990', 'RETURNPARAMS 누락'); } $retStr = $this->aes->decrypt($returnParams, (string)$cfg['key'], (string)$cfg['iv']); $retMap = $this->aes->parseQuery($retStr); $oid = (string)($retMap['ORDERID'] ?? ''); if ($oid === '' || $oid !== (string)$attempt->oid) { return $this->fail('C991', '주문번호 불일치'); } $retCode = (string)($retMap['RETURNCODE'] ?? ''); $retMsg = (string)($retMap['RETURNMSG'] ?? ''); if ($retCode !== '0000') { return $this->fail($retCode ?: 'C992', $retMsg ?: '가상계좌 인증 실패'); } $amount = (int)($order['amount'] ?? $order['pay_money'] ?? $attempt->amount ?? 0); $issueReq = [ 'CPID' => (string)$cfg['cpid'], 'TID' => (string)($retMap['TID'] ?? ''), 'AMOUNT' => (string)$amount, 'TXTYPE' => 'ISSUEVACCOUNT', 'SERVICETYPE' => 'DANALVACCOUNT', ]; $res = $this->postCpcgi( (string)$cfg['url'], (string)$cfg['cpid'], $issueReq, (string)$cfg['key'], (string)$cfg['iv'] ); $res = $this->eucKrArrayToUtf8($res); if (($res['RETURNCODE'] ?? '') === '0000') { $attempt->status = 'issued'; $attempt->pg_tid = (string)($res['TID'] ?? $retMap['TID'] ?? ''); $attempt->returned_at = now(); $attempt->return_payload = ['ret' => $retMap, 'issue' => $res]; $attempt->save(); return [ 'status' => 'success', 'message' => '가상계좌가 발급되었습니다. 마이페이지에서 확인해주세요.', 'redirectUrl' => url('/mypage/usage'), 'meta' => [ 'oid' => $oid, 'tid' => (string)($res['TID'] ?? ''), 'amount' => $amount, 'code' => (string)($res['RETURNCODE'] ?? ''), 'msg' => (string)($res['RETURNMSG'] ?? ''), 'ret_data' => $res, ], ]; } return $this->fail((string)($res['RETURNCODE'] ?? 'C993'), (string)($res['RETURNMSG'] ?? '가상계좌 발급 실패')); } /** * NOTI 수신 처리 (서버->서버) * - post에 DATA가 오면 복호화해서 맵으로 변환 * - 성공이면 onPaid 콜백 실행 */ public function handleNoti(array $post, callable $onPaid): void { $cfg = config('danal.vact'); if (!$cfg || empty($cfg['key']) || empty($cfg['iv'])) { throw new \RuntimeException("Danal vact config missing"); } $map = $post; // NOTI가 CPID/DATA 형태로 오면 DATA 복호화 if (isset($post['DATA']) && is_string($post['DATA']) && $post['DATA'] !== '') { $dec = $this->aes->decrypt((string)$post['DATA'], (string)$cfg['key'], (string)$cfg['iv']); $map = $this->aes->parseQuery($dec); } $oid = (string)($map['ORDERID'] ?? ''); $tid = (string)($map['TID'] ?? ''); $amount = (int)($map['AMOUNT'] ?? 0); // 성공 판단은 최소한으로: RETURNCODE=0000 + 필수키 존재 $code = (string)($map['RETURNCODE'] ?? ''); if ($oid === '' || $tid === '' || $amount <= 0) return; if ($code !== '' && $code !== '0000') return; $onPaid($oid, $tid, $amount, $map); } private function postCpcgi(string $url, string $outerCpid, array $reqData, string $hexKey, string $hexIv): array { $plain = $this->aes->buildQuery($reqData); $enc = $this->aes->encrypt($plain, $hexKey, $hexIv); $payload = 'CPID=' . $outerCpid . '&DATA=' . urlencode($enc); $ch = curl_init(); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, (int)config('danal.authtel.connect_timeout', 5)); curl_setopt($ch, CURLOPT_TIMEOUT, (int)config('danal.authtel.timeout', 30)); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-type:application/x-www-form-urlencoded; charset=euc-kr"]); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); $resStr = curl_exec($ch); if (($errno = curl_errno($ch)) !== 0) { $err = curl_error($ch); curl_close($ch); return [ 'RETURNCODE' => 'E_NETWORK', 'RETURNMSG' => "NETWORK({$errno}:{$err})", ]; } curl_close($ch); $resMap = $this->aes->parseQuery((string)$resStr); if (isset($resMap['DATA'])) { $dec = $this->aes->decrypt((string)$resMap['DATA'], $hexKey, $hexIv); $resMap = $this->aes->parseQuery($dec); } return $resMap; } private function toEucKr(string $s): string { if ($s === '') return ''; $out = @iconv('UTF-8', 'EUC-KR//IGNORE', $s); return $out === false ? $s : $out; } private function eucKrArrayToUtf8(array $arr): array { foreach ($arr as $k => $v) { if (!is_string($v) || $v === '') continue; $u = @iconv('EUC-KR', 'UTF-8//IGNORE', $v); if ($u !== false) $arr[$k] = $u; } return $arr; } private function fail(string $code, string $msg): array { return [ 'status' => 'fail', 'message' => $msg ?: '결제에 실패했습니다.', 'redirectUrl' => url('/mypage/usage'), 'meta' => [ 'code' => $code, 'msg' => $msg, ], ]; } }