278 lines
9.8 KiB
PHP

<?php
namespace App\Services\Payments\Danal\Gateways;
use App\Models\GcPaymentAttempt;
use App\Support\DanalAes256CbcHex;
final class DanalVactGateway
{
public function __construct(
private readonly DanalAes256CbcHex $aes,
) {}
/**
* CI3: payment_danal_vact() -> 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,
],
];
}
}