giftcon_dev/app/Services/Payments/PaymentService.php
2026-03-03 15:13:16 +09:00

560 lines
25 KiB
PHP

<?php
namespace App\Services\Payments;
use App\Models\Payments\GcPinOrder;
use App\Repositories\Payments\GcPinOrderRepository;
use App\Repositories\Member\MemInfoRepository;
use App\Repositories\Payments\GcPaymentAttemptRepository;
use App\Providers\Danal\Gateways\CardGateway;
use App\Providers\Danal\Gateways\VactGateway;
use App\Providers\Danal\Gateways\PhoneGateway;
use App\Providers\Danal\Gateways\WireGateway;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Facades\DB;
final class PaymentService
{
public function __construct(
private readonly GcPinOrderRepository $orders,
private readonly GcPaymentAttemptRepository $attempts,
private readonly MemInfoRepository $members,
private readonly CiSeedCrypto $seed,
private readonly CardGateway $card,
private readonly VactGateway $vact,
private readonly WireGateway $wire,
private readonly PhoneGateway $phone,
) {}
/** 결제 시작: 카드/가상계좌/휴대폰 */
public function start(string $oid, int $memNo, string $method, array $opt): array
{
return DB::transaction(function () use ($oid, $memNo, $method, $opt) {
$order = $this->orders->findByOidForUpdate($oid);
if (!$order) return $this->fail('404', '주문을 찾을 수 없습니다.');
if ((int)$order->mem_no !== $memNo) return $this->fail('403', '권한이 없습니다.');
if ($order->stat_pay === 'p') return $this->fail('ALREADY', '이미 결제 완료된 주문입니다.');
if ((int)$order->pay_money <= 0) return $this->fail('AMOUNT', '결제금액이 올바르지 않습니다.');
$isMobile = (bool)($opt['is_mobile'] ?? false);
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
// attempt upsert(락)
$attempt = $this->attempts->upsertForUpdate([
'provider' => 'danal',
'oid' => $order->oid,
'mem_no' => $memNo,
'order_id' => $order->id,
'pay_method' => $method,
'amount' => (int)$order->pay_money,
'token_hash' => $tokenHash,
'card_kind' => $opt['card_kind'] ?? null,
'vact_kind' => $opt['vact_kind'] ?? null,
'user_agent' => request()->userAgent(),
'user_ip' => request()->ip() ? inet_pton(request()->ip()) : null,
]);
$order->pay_method = $method;
$order->ordered_at = $order->ordered_at ?: now();
$order->save();
$meta = ['token'=>$token, 'oid'=>$order->oid, 'method'=>$method];
if ($method === 'card') {
$kind = $opt['card_kind'] ?? 'general';
$out = $this->card->auth($order, $token, $kind, $isMobile);
$this->attempts->markRedirected($attempt, $out['req'], $out['res']);
return $this->ensureStart($out, $meta);
} elseif ($method === 'vact') {
$out = $this->vact->auth($order, $token, $isMobile);
$this->attempts->markRedirected($attempt, $out['req'], $out['res']);
return $this->ensureStart($out, $meta);
} elseif ($method === 'wire') {
$mem = $this->members->findForWirePay($memNo);
if (!$mem) return $this->fail('404', '회원정보를 찾을 수 없습니다.');
$memberName = trim((string)($mem->name ?? '')) ?: '고객';
$memberEmail = trim((string)($mem->email ?? ''));
if ($memberEmail === '') return $this->fail('EMAIL_REQUIRED', '계좌이체 결제를 위해 이메일 정보가 필요합니다.');
$memberPhoneDigits = '';
$rawPhoneEnc = (string)($mem->cell_phone ?? '');
if ($rawPhoneEnc !== '') {
try {
$plainPhone = (string)$this->seed->decrypt($rawPhoneEnc);
$memberPhoneDigits = preg_replace('/\D+/', '', $plainPhone) ?: '';
} catch (\Throwable $e) {
$memberPhoneDigits = '';
}
}
$out = $this->wire->auth($order, $token, $isMobile, [
'user_name' => $memberName,
'user_id' => (string)$memNo,
'user_email' => $memberEmail,
'user_phone' => $memberPhoneDigits,
]);
$this->attempts->markRedirected($attempt, $out['req'], $out['res']);
return $this->ensureStart($out, $meta);
}elseif ($method === 'phone') {
$mode = $opt['phone_mode'] ?? 'prod'; // prod|dev
$out = $this->phone->ready($order, $token, $mode, $isMobile, [
'cp_name' => $opt['cp_name'] ?? '핀포유',
'email' => $opt['email'] ?? '',
'ci_url' => $opt['ci_url'] ?? null,
'member_phone_enc' => $opt['member_phone_digits'] ?? null,
'member_cell_corp' => $opt['member_carrier'] ?? null,
]);
if (isset($out['error'])) {
$this->attempts->markFailed($attempt, (string)$out['error']['code'], (string)$out['error']['msg'], ['ready'=>$out]);
$this->orders->markFailed($order, (string)$out['error']['code'], (string)$out['error']['msg'], ['phone_ready'=>$out]);
return $this->fail((string)$out['error']['code'], (string)$out['error']['msg']);
}
$this->attempts->markRedirected($attempt, $out['req'], $out['res']);
$start = $out['start'] ?? null;
if (!$start || empty($start['actionUrl'])) {
$this->attempts->markFailed($attempt, 'NO_START', '휴대폰 결제 시작 정보 누락', ['ready'=>$out]);
$this->orders->markFailed($order, 'NO_START', '휴대폰 결제 시작 정보 누락', ['phone_ready'=>$out]);
return $this->fail('NO_START', '휴대폰 결제 시작 실패');
}
return [
'ok' => true,
'type' => 'redirect',
'start' => $start,
'meta' => [
'token' => $token,
'oid' => $order->oid,
'method' => $method,
'phone_mode' => $mode,
],
];
}
return $this->fail('METHOD', '지원하지 않는 결제수단입니다.');
});
}
/** 카드 RETURN -> BILL -> paid */
public function handleCardReturn(string $attemptToken, array $post): array
{
return DB::transaction(function () use ($attemptToken, $post) {
$attempt = $this->attempts->findByTokenForUpdate('card', $attemptToken);
if (!$attempt) return $this->fail('404', '시도를 찾을 수 없습니다.');
$order = $this->orders->findByOidForUpdate((string)$attempt->oid);
if (!$order) return $this->fail('404', '주문을 찾을 수 없습니다.');
$returnParams = (string)($post['RETURNPARAMS'] ?? '');
if ($returnParams === '') {
$this->attempts->markFailed($attempt, 'RET_PARAM', 'RETURNPARAMS 누락', ['post'=>$post]);
$this->orders->markFailed($order, 'RET_PARAM', 'RETURNPARAMS 누락', ['post'=>$post]);
return $this->fail('RET_PARAM', 'RETURNPARAMS 누락');
}
$cardKind = (string)($attempt->card_kind ?: 'general');
$retMap = $this->card->decryptReturn($cardKind, $returnParams);
// 주문번호/금액 검증
if (($retMap['ORDERID'] ?? '') !== $order->oid) {
$this->attempts->markFailed($attempt, 'OID_MISMATCH', '주문번호 불일치', ['ret'=>$retMap]);
$this->orders->markFailed($order, 'OID_MISMATCH', '주문번호 불일치', ['ret'=>$retMap]);
return $this->fail('OID_MISMATCH', '주문번호 불일치');
}
$retCode = (string)($retMap['RETURNCODE'] ?? '');
$retMsg = (string)($retMap['RETURNMSG'] ?? '');
if ($retCode !== '0000') {
$this->attempts->markFailed($attempt, $retCode ?: 'AUTH_FAIL', $retMsg ?: '카드 인증 실패', ['ret'=>$retMap]);
$this->orders->markFailed($order, $retCode ?: 'AUTH_FAIL', $retMsg ?: '카드 인증 실패', ['ret'=>$retMap]);
return $this->fail($retCode ?: 'AUTH_FAIL', $retMsg ?: '카드 인증 실패');
}
$tid = (string)($retMap['TID'] ?? '');
if ($tid === '') {
$this->attempts->markFailed($attempt, 'NO_TID', 'TID 누락', ['ret'=>$retMap]);
$this->orders->markFailed($order, 'NO_TID', 'TID 누락', ['ret'=>$retMap]);
return $this->fail('NO_TID', 'TID 누락');
}
$bill = $this->card->bill($order, $cardKind, $tid);
$billCode = (string)($bill['res']['RETURNCODE'] ?? '');
$billMsg = (string)($bill['res']['RETURNMSG'] ?? '');
if ($billCode === '0000') {
$payload = ['card_return'=>$retMap, 'card_bill'=>$bill['res']];
$this->attempts->markReturned($attempt, $payload, (string)($bill['res']['TID'] ?? $tid), $billCode, $billMsg, 'paid');
$this->orders->markPaid($order, (string)($bill['res']['TID'] ?? $tid), $billCode, $billMsg, $payload);
return [
'ok' => true,
'status' => 'paid',
'meta' => [
'attempt_id' => (int)$attempt->id,
'tid' => $tid,
],
];
}
$this->attempts->markFailed($attempt, $billCode ?: 'BILL_FAIL', $billMsg ?: '카드 승인 실패', ['ret'=>$retMap,'bill'=>$bill]);
$this->orders->markFailed($order, $billCode ?: 'BILL_FAIL', $billMsg ?: '카드 승인 실패', ['ret'=>$retMap,'bill'=>$bill]);
return $this->fail($billCode ?: 'BILL_FAIL', $billMsg ?: '카드 승인 실패');
});
}
/** 가상계좌 RETURN -> ISSUEVACCOUNT -> issued(입금대기 w) */
public function handleVactReturn(string $attemptToken, array $post): array
{
return DB::transaction(function () use ($attemptToken, $post) {
$attempt = $this->attempts->findByTokenForUpdate('vact', $attemptToken);
if (!$attempt) return $this->fail('404', '시도를 찾을 수 없습니다.');
$order = $this->orders->findByOidForUpdate((string)$attempt->oid);
if (!$order) return $this->fail('404', '주문을 찾을 수 없습니다.');
$returnParams = (string)($post['RETURNPARAMS'] ?? '');
if ($returnParams === '') {
$this->attempts->markFailed($attempt, 'RET_PARAM', 'RETURNPARAMS 누락', ['post'=>$post]);
$this->orders->markFailed($order, 'RET_PARAM', 'RETURNPARAMS 누락', ['post'=>$post]);
return $this->fail('RET_PARAM', 'RETURNPARAMS 누락');
}
$retMap = $this->vact->decryptReturn($returnParams);
if (($retMap['ORDERID'] ?? '') !== $order->oid) {
$this->attempts->markFailed($attempt, 'OID_MISMATCH', '주문번호 불일치', ['ret'=>$retMap]);
$this->orders->markFailed($order, 'OID_MISMATCH', '주문번호 불일치', ['ret'=>$retMap]);
return $this->fail('OID_MISMATCH', '주문번호 불일치');
}
$retCode = (string)($retMap['RETURNCODE'] ?? '');
$retMsg = (string)($retMap['RETURNMSG'] ?? '');
if ($retCode !== '0000') {
$this->attempts->markFailed($attempt, $retCode ?: 'AUTH_FAIL', $retMsg ?: '가상계좌 인증 실패', ['ret'=>$retMap]);
$this->orders->markFailed($order, $retCode ?: 'AUTH_FAIL', $retMsg ?: '가상계좌 인증 실패', ['ret'=>$retMap]);
return $this->fail($retCode ?: 'AUTH_FAIL', $retMsg ?: '가상계좌 인증 실패');
}
$tid = (string)($retMap['TID'] ?? '');
if ($tid === '') {
$this->attempts->markFailed($attempt, 'NO_TID', 'TID 누락', ['ret'=>$retMap]);
$this->orders->markFailed($order, 'NO_TID', 'TID 누락', ['ret'=>$retMap]);
return $this->fail('NO_TID', 'TID 누락');
}
$issue = $this->vact->issue($order, $tid);
$code = (string)($issue['res']['RETURNCODE'] ?? '');
$msg = (string)($issue['res']['RETURNMSG'] ?? '');
if ($code === '0000') {
$payload = ['vact_return'=>$retMap, 'vact_issue'=>$issue['res']];
$this->attempts->markReturned($attempt, $payload, (string)($issue['res']['TID'] ?? $tid), $code, $msg, 'issued');
$this->orders->markVactIssued($order, (string)($issue['res']['TID'] ?? $tid), $issue['res']);
return [
'ok' => true,
'status' => 'issued',
'meta' => [
'attempt_id' => (int)$attempt->id,
'tid' => $tid,
],
];
}
$this->attempts->markFailed($attempt, $code ?: 'ISSUE_FAIL', $msg ?: '가상계좌 발급 실패', ['ret'=>$retMap,'issue'=>$issue]);
$this->orders->markFailed($order, $code ?: 'ISSUE_FAIL', $msg ?: '가상계좌 발급 실패', ['ret'=>$retMap,'issue'=>$issue]);
return $this->fail($code ?: 'ISSUE_FAIL', $msg ?: '가상계좌 발급 실패');
});
}
/** 가상계좌 NOTI(입금완료) -> paid (반드시 OK 반환) */
public function handleVactNoti(array $post): void
{
DB::transaction(function () use ($post) {
// NOTI는 CPID/DATA 형태일 수도, 풀 map일 수도 있음.
// 여기선 시도/주문을 oid로 찾고, 한번만 paid 전환.
$oid = (string)($post['ORDERID'] ?? '');
$tid = (string)($post['TID'] ?? '');
$amount = (int)($post['AMOUNT'] ?? 0);
$code = (string)($post['RETURNCODE'] ?? '');
// DATA로 오는 케이스는 실제 운영에서 추가 파싱이 필요할 수 있으니,
// 지금은 가장 흔한 KEY 기반으로 처리 + 추후 필요 시 확장.
if ($oid === '' || $tid === '' || $amount <= 0) return;
if ($code !== '' && $code !== '0000') return;
$order = $this->orders->findByOidForUpdate($oid);
if (!$order) return;
// 주문 paid 멱등
$payload = ['vact_noti' => $post];
$this->orders->markPaid($order, $tid, '0000', 'NOTI', $payload);
// attempt paid 멱등
$attempt = $this->attempts->findByTokenForUpdate('vact', $this->findAttemptTokenFromNop($order)); // token이 없으면 아래 fallback
if ($attempt) {
$this->attempts->markNotiPaid($attempt, $post, $tid, $amount);
} else {
// token 없는 경우: oid+method로 직접 찾기(멱등 락을 위해)
$row = \App\Models\Payments\GcPaymentAttempt::query()
->where('provider','danal')->where('oid',$oid)->where('pay_method','vact')
->lockForUpdate()->first();
if ($row) {
$this->attempts->markNotiPaid($row, $post, $tid, $amount);
}
}
});
}
public function handleWireReturn(string $attemptToken, array $post): array
{
return DB::transaction(function () use ($attemptToken, $post) {
$attempt = $this->attempts->findByTokenForUpdate('wire', $attemptToken);
if (!$attempt) return $this->fail('404', '시도를 찾을 수 없습니다.');
$order = $this->orders->findByOidForUpdate((string)$attempt->oid);
if (!$order) return $this->fail('404', '주문을 찾을 수 없습니다.');
$returnParams = (string)($post['RETURNPARAMS'] ?? '');
if ($returnParams === '') {
$this->attempts->markFailed($attempt, 'RET_PARAM', 'RETURNPARAMS 누락', ['post'=>$post]);
$this->orders->markFailed($order, 'RET_PARAM', 'RETURNPARAMS 누락', ['post'=>$post]);
return $this->fail('RET_PARAM', 'RETURNPARAMS 누락');
}
$retMap = $this->wire->decryptReturn($returnParams);
if (($retMap['ORDERID'] ?? '') !== $order->oid) {
$this->attempts->markFailed($attempt, 'OID_MISMATCH', '주문번호 불일치', ['ret'=>$retMap]);
$this->orders->markFailed($order, 'OID_MISMATCH', '주문번호 불일치', ['ret'=>$retMap]);
return $this->fail('OID_MISMATCH', '주문번호 불일치');
}
$retCode = (string)($retMap['RETURNCODE'] ?? '');
$retMsg = (string)($retMap['RETURNMSG'] ?? '');
if ($retCode !== '0000') {
$this->attempts->markFailed($attempt, $retCode ?: 'AUTH_FAIL', $retMsg ?: '계좌 인증 실패', ['ret'=>$retMap]);
$this->orders->markFailed($order, $retCode ?: 'AUTH_FAIL', $retMsg ?: '계좌 인증 실패', ['ret'=>$retMap]);
return $this->fail($retCode ?: 'AUTH_FAIL', $retMsg ?: '계좌 인증 실패');
}
$tid = (string)($retMap['TID'] ?? '');
if ($tid === '') {
$this->attempts->markFailed($attempt, 'NO_TID', 'TID 누락', ['ret'=>$retMap]);
$this->orders->markFailed($order, 'NO_TID', 'TID 누락', ['ret'=>$retMap]);
return $this->fail('NO_TID', 'TID 누락');
}
$bill = $this->wire->bill($order, $tid);
$billCode = (string)($bill['res']['RETURNCODE'] ?? '');
$billMsg = (string)($bill['res']['RETURNMSG'] ?? '');
if ($billCode === '0000') {
$payload = ['wire_return'=>$retMap, 'wire_bill'=>$bill['res']];
$this->attempts->markReturned($attempt, $payload, (string)($bill['res']['TID'] ?? $tid), $billCode, $billMsg, 'paid');
$this->orders->markPaid($order, (string)($bill['res']['TID'] ?? $tid), $billCode, $billMsg, $payload);
return [
'ok' => true,
'status' => 'paid',
'meta' => ['attempt_id' => (int)$attempt->id, 'tid' => $tid],
];
}
$this->attempts->markFailed($attempt, $billCode ?: 'BILL_FAIL', $billMsg ?: '출금요청 실패', ['ret'=>$retMap,'bill'=>$bill]);
$this->orders->markFailed($order, $billCode ?: 'BILL_FAIL', $billMsg ?: '출금요청 실패', ['ret'=>$retMap,'bill'=>$bill]);
return $this->fail($billCode ?: 'BILL_FAIL', $billMsg ?: '출금요청 실패');
});
}
public function handleWireNoti(array $post): void
{
DB::transaction(function () use ($post) {
// DATA가 오면 복호화해서 map으로
$map = $post;
if (!empty($post['DATA']) && is_string($post['DATA'])) {
try {
$map = $this->wire->decryptNotiData((string)$post['DATA']);
} catch (\Throwable $e) {
return;
}
}
$oid = (string)($map['ORDERID'] ?? '');
$tid = (string)($map['TID'] ?? '');
$amount = (int)($map['AMOUNT'] ?? 0);
$code = (string)($map['RETURNCODE'] ?? '');
if ($oid === '' || $tid === '' || $amount <= 0) return;
if ($code !== '' && $code !== '0000') return;
$order = $this->orders->findByOidForUpdate($oid);
if (!$order) return;
if ((int)$order->pay_money !== $amount) return;
$payload = ['wire_noti' => $map];
$this->orders->markPaid($order, $tid, '0000', 'NOTI', $payload);
$token = $this->extractAttemptTokenFromBypass((string)($map['BYPASSVALUE'] ?? ''));
if ($token !== '') {
$attempt = $this->attempts->findByTokenForUpdate('wire', $token);
if ($attempt) $this->attempts->markNotiPaid($attempt, $map, $tid, $amount);
return;
}
$row = \App\Models\Payments\GcPaymentAttempt::query()
->where('provider','danal')->where('oid',$oid)->where('pay_method','wire')
->lockForUpdate()->first();
if ($row) $this->attempts->markNotiPaid($row, $map, $tid, $amount);
});
}
private function extractAttemptTokenFromBypass(string $bypass): string
{
if ($bypass === '') return '';
if (preg_match('/(?:^|[;\s])AT=([0-9a-f]{64})/i', $bypass, $m)) return (string)$m[1];
return '';
}
/** 휴대폰 RETURN(TargetURL) -> NCONFIRM/NBILL -> paid */
public function handlePhoneReturn(array $post): array
{
return DB::transaction(function () use ($post) {
$attemptId = 0;
$result = $this->phone->confirmAndBill($post, function (string $oid, string $token, string $tid, int $amount, array $payload) use (&$attemptId) {
$attempt = $this->attempts->findByTokenForUpdate('phone', $token);
if (!$attempt) return;
$attemptId = (int)$attempt->id;
$order = $this->orders->findByOidForUpdate($oid);
if (!$order) return;
// 금액 검증(변조 방지)
if ((int)$order->pay_money !== (int)$amount) {
$this->attempts->markFailed($attempt, 'AMOUNT_MISMATCH', '결제금액 불일치', $payload);
$this->orders->markFailed($order, 'AMOUNT_MISMATCH', '결제금액 불일치', $payload);
return;
}
$this->attempts->markReturned($attempt, $payload, $tid, '0000', 'OK', 'paid');
$this->orders->markPaid($order, $tid, '0000', 'OK', ['phone'=>$payload]);
});
if (!$result['ok']) {
return $this->fail((string)$result['code'], (string)$result['msg']);
}
return [
'ok' => true,
'status' => 'paid',
'meta' => [
'attempt_id' => $attemptId,
'tid' => $result['tid'],
],
];
});
}
/** 휴대폰 BackURL(취소) */
public function handlePhoneCancel(array $post): array
{
return DB::transaction(function () use ($post) {
$result = $this->phone->cancel($post, function (string $oid, string $token, array $payload) {
$attempt = $this->attempts->findByTokenForUpdate('phone', $token);
if ($attempt) $this->attempts->markCancelled($attempt, $payload);
$order = $this->orders->findByOidForUpdate($oid);
if ($order) $this->orders->markCancelled($order, 'USER_CANCEL', '사용자 결제 취소', ['phone_cancel'=>$payload]);
});
if (!$result['ok']) {
return $this->fail((string)$result['code'], (string)$result['msg']);
}
return $this->fail('CANCEL', '구매를 취소했습니다.');
});
}
/** 카드/가상계좌 CancelURL */
public function handleCancel(string $attemptToken): array
{
return DB::transaction(function () use ($attemptToken) {
$attempt = $this->attempts->findAnyByTokenForUpdate($attemptToken);
if (!$attempt) return $this->fail('404', '시도를 찾을 수 없습니다.');
$order = $this->orders->findByOidForUpdate((string)$attempt->oid);
if ($order) $this->orders->markCancelled($order, 'USER_CANCEL', '사용자 결제 취소', ['cancel'=>true]);
$this->attempts->markCancelled($attempt, ['cancel'=>true]);
return $this->fail('CANCEL', '구매를 취소했습니다.');
});
}
private function ensureStart(array $out, array $meta): array
{
$code = (string)($out['res']['RETURNCODE'] ?? '');
if ($code !== '0000') {
return $this->fail($code ?: 'PG_FAIL', (string)($out['res']['RETURNMSG'] ?? 'PG 호출 실패'));
}
$start = $out['start'] ?? null;
if (!$start || empty($start['actionUrl'])) {
return $this->fail('NO_START', 'STARTURL 누락');
}
return [
'ok' => true,
'type' => 'redirect',
'start' => $start,
'meta' => $meta, // 여기서 meta 유지
];
}
private function ok(string $message, array $meta = []): array
{
return ['ok'=>true, 'type'=>'result', 'status'=>'success', 'message'=>$message, 'meta'=>$meta];
}
private function fail(string $code, string $message): array
{
return ['ok'=>false, 'type'=>'result', 'status'=>'fail', 'message'=>$message, 'meta'=>['code'=>$code]];
}
/**
* NOTI payload로 token을 직접 받지 못하는 환경이 있을 수 있어 placeholder.
* (운영에서 NOTI DATA 복호화 후 BYPASSVALUE=AT=token 을 넣으면 여기 보강 가능)
*/
private function findAttemptTokenFromNop(GcPinOrder $order): string
{
return '';
}
}