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 ''; } }