409 lines
18 KiB
PHP
409 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payments;
|
|
|
|
use App\Models\Payments\GcPinOrder;
|
|
use App\Repositories\Payments\GcPinOrderRepository;
|
|
use App\Repositories\Payments\GcPaymentAttemptRepository;
|
|
use App\Providers\Danal\Gateways\CardGateway;
|
|
use App\Providers\Danal\Gateways\VactGateway;
|
|
use App\Providers\Danal\Gateways\PhoneGateway;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class PaymentService
|
|
{
|
|
public function __construct(
|
|
private readonly GcPinOrderRepository $orders,
|
|
private readonly GcPaymentAttemptRepository $attempts,
|
|
private readonly CardGateway $card,
|
|
private readonly VactGateway $vact,
|
|
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 === '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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/** 휴대폰 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) {
|
|
|
|
$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 '';
|
|
}
|
|
}
|