244 lines
8.4 KiB
PHP
244 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payments\Danal\Gateways;
|
|
|
|
use App\Models\GcPaymentAttempt;
|
|
use App\Support\DanalAes256CbcHex;
|
|
|
|
final class DanalCardGateway
|
|
{
|
|
public function __construct(
|
|
private readonly DanalAes256CbcHex $aes,
|
|
) {}
|
|
|
|
/**
|
|
* CI3: payment_danal_card() -> AUTH
|
|
* return: redirect form info
|
|
*/
|
|
public function auth(GcPaymentAttempt $attempt, string $token, array $order, string $cardKind = 'general'): array
|
|
{
|
|
$cfg = config("danal.card.$cardKind");
|
|
if (!$cfg || empty($cfg['cpid']) || empty($cfg['key']) || empty($cfg['iv']) || empty($cfg['url'])) {
|
|
throw new \RuntimeException("Danal card config missing: $cardKind");
|
|
}
|
|
|
|
$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 ? 'WM' : 'PC';
|
|
|
|
// Return/Cancel: token을 query로 붙여서 세션 의존 제거
|
|
$returnUrl = route('web.payments.danal.card.return', ['o' => $oid, 't' => $token], true);
|
|
$cancelUrl = route('web.payments.danal.cancel', ['o' => $oid, 't' => $token], true);
|
|
|
|
// BYPASSVALUE는 '&' 금지(다날 주의) → ; 구분
|
|
$bypass = "TOKEN={$token};OID={$oid};AMOUNT={$amount};";
|
|
|
|
$req = [
|
|
'SUBCPID' => '',
|
|
'AMOUNT' => (string)$amount,
|
|
'CURRENCY' => '410',
|
|
'ITEMNAME' => $this->toEucKr($itemName),
|
|
'USERAGENT' => $userAgent,
|
|
'ORDERID' => $oid,
|
|
'OFFERPERIOD' => '',
|
|
|
|
'USERNAME' => '',
|
|
'USERID' => (string)($order['mem_no'] ?? ''),
|
|
'USEREMAIL' => '',
|
|
|
|
'CANCELURL' => $cancelUrl,
|
|
'RETURNURL' => $returnUrl,
|
|
|
|
'TXTYPE' => 'AUTH',
|
|
'SERVICETYPE' => 'DANALCARD',
|
|
'ISNOTI' => 'N',
|
|
'BYPASSVALUE' => $bypass,
|
|
];
|
|
|
|
$attempt->card_kind = $cardKind;
|
|
$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 card AUTH failed: {$res['RETURNCODE']} {$msg}");
|
|
}
|
|
|
|
return [
|
|
'actionUrl' => (string)$res['STARTURL'],
|
|
'params' => [
|
|
'STARTPARAMS' => (string)$res['STARTPARAMS'],
|
|
],
|
|
'acceptCharset' => 'EUC-KR',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* CI3: payment_danal_card_return() -> RETURN decrypt -> BILL
|
|
* return: result view payload
|
|
*/
|
|
public function billOnReturn(GcPaymentAttempt $attempt, string $token, array $order, array $post): array
|
|
{
|
|
$cardKind = $attempt->card_kind ?: 'general';
|
|
$cfg = config("danal.card.$cardKind");
|
|
if (!$cfg || empty($cfg['cpid']) || empty($cfg['key']) || empty($cfg['iv']) || empty($cfg['url'])) {
|
|
throw new \RuntimeException("Danal card config missing: $cardKind");
|
|
}
|
|
|
|
$oid = (string)($order['oid'] ?? $attempt->oid);
|
|
$amount = (int)($order['amount'] ?? $order['pay_money'] ?? $attempt->amount ?? 0);
|
|
|
|
$returnParams = (string)($post['RETURNPARAMS'] ?? '');
|
|
if ($returnParams === '') {
|
|
return $this->fail('C990', 'RETURNPARAMS 누락');
|
|
}
|
|
|
|
// RETURNPARAMS 복호화
|
|
$retStr = $this->aes->decrypt($returnParams, (string)$cfg['key'], (string)$cfg['iv']);
|
|
$retMap = $this->aes->parseQuery($retStr);
|
|
|
|
// 주문번호 일치 검증
|
|
if (($retMap['ORDERID'] ?? '') !== $oid) {
|
|
return $this->fail('C991', '주문번호 불일치');
|
|
}
|
|
|
|
// 인증 결과 확인
|
|
$retCode = (string)($retMap['RETURNCODE'] ?? '');
|
|
$retMsg = (string)($retMap['RETURNMSG'] ?? '');
|
|
|
|
if ($retCode !== '0000') {
|
|
return $this->fail($retCode ?: 'C992', $retMsg ?: '카드 인증 실패');
|
|
}
|
|
|
|
// BILL 요청
|
|
$billReq = [
|
|
'TID' => (string)($retMap['TID'] ?? ''),
|
|
'AMOUNT' => (string)$amount,
|
|
'TXTYPE' => 'BILL',
|
|
'SERVICETYPE' => 'DANALCARD',
|
|
];
|
|
|
|
$res = $this->postCpcgi(
|
|
(string)$cfg['url'],
|
|
(string)$cfg['cpid'],
|
|
$billReq,
|
|
(string)$cfg['key'],
|
|
(string)$cfg['iv']
|
|
);
|
|
|
|
$res = $this->eucKrArrayToUtf8($res);
|
|
|
|
if (($res['RETURNCODE'] ?? '') === '0000') {
|
|
$attempt->status = 'paid';
|
|
$attempt->pg_tid = (string)($res['TID'] ?? '');
|
|
$attempt->return_code = (string)($res['RETURNCODE'] ?? '');
|
|
$attempt->return_msg = (string)($res['RETURNMSG'] ?? '');
|
|
$attempt->returned_at = now();
|
|
$attempt->return_payload = ['ret' => $retMap, 'bill' => $res];
|
|
$attempt->save();
|
|
|
|
return [
|
|
'status' => 'success',
|
|
'message' => '결제가 완료되었습니다.',
|
|
'redirectUrl' => url('/mypage/usage'),
|
|
// 필요하면 아래를 서비스에서 order_complete에 사용
|
|
'meta' => [
|
|
'oid' => $oid,
|
|
'tid' => (string)($res['TID'] ?? ''),
|
|
'amount' => (int)($res['AMOUNT'] ?? $amount),
|
|
'code' => (string)($res['RETURNCODE'] ?? ''),
|
|
'msg' => (string)($res['RETURNMSG'] ?? ''),
|
|
'ret_data' => $res,
|
|
],
|
|
];
|
|
}
|
|
|
|
$msg = (string)($res['RETURNMSG'] ?? '카드 결제 실패');
|
|
return $this->fail((string)($res['RETURNCODE'] ?? 'C993'), $msg);
|
|
}
|
|
|
|
private function postCpcgi(string $url, string $outerCpid, array $reqData, string $hexKey, string $hexIv): array
|
|
{
|
|
// CI3 CallCredit/CallVAccount 규칙 그대로:
|
|
// data2str -> AES -> base64 -> urlencode -> CPID=...&DATA=...
|
|
$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,
|
|
],
|
|
];
|
|
}
|
|
}
|