whereKey($attemptId)->lockForUpdate()->first(); if (!$attempt) return ['ok'=>false, 'message'=>'결제내역을 찾을 수 없습니다.']; $order = $this->orders->findByOidForUpdate((string)$attempt->oid); if (!$order) return ['ok'=>false, 'message'=>'주문을 찾을 수 없습니다.']; // 권한: user면 소유자만 if (($actor['type'] ?? 'user') === 'user') { if ((int)$attempt->mem_no !== (int)($actor['mem_no'] ?? 0)) { return ['ok'=>false, 'message'=>'권한이 없습니다.']; } } // 상태 체크 if ((string)$attempt->status !== 'paid' || (string)$order->stat_pay !== 'p') { return ['ok'=>false, 'message'=>'취소 가능한 상태가 아닙니다.']; } // 이미 성공 취소 if ((string)($attempt->cancel_status ?? 'none') === 'success' || (string)($order->cancel_status ?? 'none') === 'success') { return ['ok'=>false, 'message'=>'이미 취소 완료된 결제입니다.']; } // 핀 오픈 전 조건 (이번 범위에서 “핀 반납” 제외지만, 오픈 후 취소는 금지) if ($pinsOpened) { return ['ok'=>false, 'message'=>'핀을 확인한 이후에는 취소할 수 없습니다.']; } $tid = (string)($attempt->pg_tid ?: $order->pg_tid); $amount = (int)$order->pay_money; if ($tid === '' || $amount <= 0) return ['ok'=>false, 'message'=>'취소에 필요한 거래정보가 부족합니다.']; // 1) requested 상태 선반영 $now = now(); $attempt->cancel_status = 'requested'; $attempt->cancel_requested_at = $now; $attempt->cancel_last_msg = null; $attempt->cancel_last_code = null; $attempt->save(); $order->cancel_status = 'requested'; $order->cancel_requested_at = $now; $order->cancel_reason = $reason; $order->save(); // 2) PG 취소 호출 $payMethod = (string)$attempt->pay_method; $req = []; $res = []; $ok = false; $code = null; $msg = null; try { if ($payMethod === 'card') { $cardKind = (string)($attempt->card_kind ?: 'general'); $cfg = $this->danalCfg->card($cardKind); $req = [ 'TID' => $tid, 'AMOUNT' => (string)$amount, 'CANCELTYPE' => 'C', 'CANCELREQUESTER' => $this->requesterLabel($actor), 'CANCELDESC' => $reason ?: '사용자 요청', 'TXTYPE' => 'CANCEL', 'SERVICETYPE' => 'DANALCARD', ]; $res = $this->cpcgi->call($cfg['url'], $cfg['cpid'], $req, $cfg['key'], $cfg['iv']); $code = (string)($res['RETURNCODE'] ?? ''); $msg = (string)($res['RETURNMSG'] ?? ''); $ok = ($code === '0000'); } elseif ($payMethod === 'wire') { $cfg = $this->danalCfg->wiretransfer(); $req = [ 'TID' => $tid, 'AMOUNT' => (string)$amount, 'CANCELTYPE' => 'C', 'CANCELREQUESTER' => $this->requesterLabel($actor), 'CANCELDESC' => $reason ?: '사용자 요청', 'TXTYPE' => 'CANCEL', 'SERVICETYPE' => 'WIRETRANSFER', ]; $res = $this->cpcgi->call((string)$cfg['tx_url'], (string)$cfg['cpid'], $req, (string)$cfg['key'], (string)$cfg['iv']); $code = (string)($res['RETURNCODE'] ?? ''); $msg = (string)($res['RETURNMSG'] ?? ''); $ok = ($code === '0000'); } elseif ($payMethod === 'phone') { $mode = $this->resolvePhoneModeFromAttempt($attempt->request_payload); $out = $this->phoneGateway->billCancel($mode, $tid); // (수정: PhoneGateway에 메서드 추가 필요) $req = $out['req'] ?? []; $res = $out['res'] ?? []; $code = (string)($res['Result'] ?? ''); $msg = (string)($res['ErrMsg'] ?? ''); $ok = ($code === '0'); } else { $code = 'METHOD'; $msg = '지원하지 않는 결제수단입니다.'; $ok = false; } } catch (\Throwable $e) { $code = $code ?: 'EX'; $msg = $msg ?: ('취소 처리 중 오류: ' . $e->getMessage()); $ok = false; } // 3) 로그 저장 (req/res 전문) $logId = DB::table('gc_payment_cancel_logs')->insertGetId([ 'attempt_id' => (int)$attempt->id, 'order_id' => (int)$order->id, 'mem_no' => (int)$attempt->mem_no, 'provider' => (string)($attempt->provider ?: 'danal'), 'pay_method' => $payMethod, 'tid' => $tid, 'amount' => $amount, 'cancel_type' => 'C', 'status' => $ok ? 'success' : 'failed', 'requester_type' => (string)($actor['type'] ?? 'user'), 'requester_id' => (int)($actor['id'] ?? 0) ?: null, 'reason' => $reason, 'req_payload' => json_encode($req, JSON_UNESCAPED_UNICODE), 'res_payload' => json_encode($res, JSON_UNESCAPED_UNICODE), 'result_code' => $code, 'result_msg' => $msg, 'requested_at' => $now, 'done_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); // 4) 결과 반영 (상태값 변경 + 요약 저장) if ($ok) { $attempt->status = 'cancelled'; $attempt->cancel_status = 'success'; $attempt->cancel_done_at = now(); $attempt->cancel_last_code = $code; $attempt->cancel_last_msg = $msg; $attempt->save(); $order->stat_pay = 'c'; $order->cancelled_at = now(); $order->cancel_status = 'success'; $order->cancel_done_at = now(); $order->cancel_last_code = $code; $order->cancel_last_msg = $msg; $order->cancel_reason = $reason; // ret_data에 “취소 요약”만 append (전문은 logs 테이블) $ret = (array)($order->ret_data ?? []); $ret['cancel_last'] = [ 'log_id' => (int)$logId, 'method' => $payMethod, 'tid' => $tid, 'code' => $code, 'msg' => $msg, 'at' => now()->toDateTimeString(), ]; $order->ret_data = $ret; $order->save(); return ['ok'=>true, 'message'=>'결제가 취소되었습니다.', 'meta'=>['log_id'=>$logId]]; } // 실패: status는 paid 유지, cancel_status만 failed $attempt->cancel_status = 'failed'; $attempt->cancel_done_at = now(); $attempt->cancel_last_code = (string)$code; $attempt->cancel_last_msg = (string)$msg; $attempt->save(); $order->cancel_status = 'failed'; $order->cancel_done_at = now(); $order->cancel_last_code = (string)$code; $order->cancel_last_msg = (string)$msg; $order->save(); return ['ok'=>false, 'message'=>($msg ?: '취소 실패'), 'meta'=>['code'=>$code]]; }); } private function requesterLabel(array $actor): string { $t = (string)($actor['type'] ?? 'user'); $id = (int)($actor['id'] ?? 0); if ($t === 'admin') return "admin:{$id}"; if ($t === 'system') return "system:{$id}"; return "user:" . (int)($actor['mem_no'] ?? $id); } private function resolvePhoneModeFromAttempt($requestPayload): string { // request_payload가 array/json/string 어떤 형태든 대응 $arr = []; if (is_array($requestPayload)) $arr = $requestPayload; elseif (is_string($requestPayload) && trim($requestPayload) !== '') { $tmp = json_decode($requestPayload, true); if (is_array($tmp)) $arr = $tmp; } $id = (string)($arr['ID'] ?? ''); $prod = (string)config('danal.phone.prod.cpid', ''); $dev = (string)config('danal.phone.dev.cpid', ''); if ($id !== '' && $dev !== '' && $id === $dev) return 'dev'; if ($id !== '' && $prod !== '' && $id === $prod) return 'prod'; return (string)config('danal.phone.default_mode', 'prod'); } }