From fb0cec13ef6ec56f814536487c73d4628b450c0b Mon Sep 17 00:00:00 2001 From: sungro815 Date: Tue, 24 Feb 2026 13:07:23 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EA=B3=84=EC=A2=8C?= =?UTF-8?q?=EC=9D=B4=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Web/Mypage/UsageController.php | 35 ++ .../Web/Payment/DanalController.php | 64 +++- app/Providers/Danal/DanalConfig.php | 13 + app/Providers/Danal/Gateways/WireGateway.php | 117 +++++++ app/Repositories/Member/MemInfoRepository.php | 8 + app/Repositories/Mypage/UsageRepository.php | 82 +++++ app/Services/Mypage/UsageService.php | 236 ++++++++++++++ app/Services/Order/OrderCheckoutService.php | 1 + .../Danal/Clients/DanalTeleditClient.php | 308 ------------------ .../Payments/Danal/DanalPaymentService.php | 278 ---------------- .../Danal/Gateways/DanalCardGateway.php | 243 -------------- .../Danal/Gateways/DanalVactGateway.php | 277 ---------------- app/Services/Payments/PaymentService.php | 153 ++++++++- bootstrap/app.php | 2 + config/danal.php | 13 + .../views/web/mypage/usage/index.blade.php | 201 ++++++++++-- routes/web.php | 7 +- 17 files changed, 905 insertions(+), 1133 deletions(-) create mode 100644 app/Http/Controllers/Web/Mypage/UsageController.php create mode 100644 app/Providers/Danal/Gateways/WireGateway.php create mode 100644 app/Repositories/Mypage/UsageRepository.php create mode 100644 app/Services/Mypage/UsageService.php delete mode 100644 app/Services/Payments/Danal/Clients/DanalTeleditClient.php delete mode 100644 app/Services/Payments/Danal/DanalPaymentService.php delete mode 100644 app/Services/Payments/Danal/Gateways/DanalCardGateway.php delete mode 100644 app/Services/Payments/Danal/Gateways/DanalVactGateway.php diff --git a/app/Http/Controllers/Web/Mypage/UsageController.php b/app/Http/Controllers/Web/Mypage/UsageController.php new file mode 100644 index 0000000..2ed7997 --- /dev/null +++ b/app/Http/Controllers/Web/Mypage/UsageController.php @@ -0,0 +1,35 @@ +route('web.auth.login'); // 프로젝트 로그인 라우트에 맞춰 조정 + } + + $memNo = (int)session('_sess._mno', 0); + if ($memNo <= 0) abort(403); + + $attemptId = $request->query('attempt_id'); + $attemptId = is_numeric($attemptId) ? (int)$attemptId : null; + + $data = $this->service->buildPageData($attemptId, $memNo); + + return view('web.mypage.usage.index', $data); + } +} diff --git a/app/Http/Controllers/Web/Payment/DanalController.php b/app/Http/Controllers/Web/Payment/DanalController.php index cae2037..4ac3ea4 100644 --- a/app/Http/Controllers/Web/Payment/DanalController.php +++ b/app/Http/Controllers/Web/Payment/DanalController.php @@ -17,7 +17,7 @@ final class DanalController extends Controller { $data = $request->validate([ 'oid' => ['required','string','max:64'], - 'method' => ['required','in:card,vact,phone'], + 'method' => ['required','in:card,vact,phone,wire'], 'card_kind' => ['nullable','in:general,exchange'], 'phone_mode' => ['nullable','in:prod,dev'], 'is_mobile' => ['nullable','boolean'], @@ -108,6 +108,68 @@ final class DanalController extends Controller return response('OK', 200)->header('Content-Type', 'text/plain'); } + public function wireReturn(Request $request) + { + $token = (string)$request->query('a', ''); + if ($token === '') abort(404); + + // 🔥 예외 렌더러가 죽지 않도록 요청값 UTF-8 정리 + $post = $this->forceUtf8Array($request->all()); + + $out = $this->service->handleWireReturn($token, $post); + + if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'paid') { + $attemptId = (int)($out['meta']['attempt_id'] ?? 0); + $redirect = url("/mypage/usage?attempt_id={$attemptId}"); + + return view('web.payments.danal.finish_top_action', [ + 'action' => 'close_modal', + 'title' => '결제완료', + 'message' => '결제가 완료되었습니다. 구매페이지로 이동합니다.', + 'redirect' => url($redirect), + ]); + } + + return view('web.payments.danal.finish_top_action', [ + 'action' => 'close_modal', + 'title' => '결제실패', + 'message' => '결제에 실패했습니다.', + ]); + } + + public function wireNoti(Request $request) + { + // 🔥 예외 렌더러가 죽지 않도록 요청값 UTF-8 정리 + $post = $this->forceUtf8Array($request->all()); + + // NOTI는 성공/실패와 무관하게 OK만 주면 다날 재시도 종료 + $this->service->handleWireNoti($post); + return response('OK', 200)->header('Content-Type', 'text/plain'); + } + + private function forceUtf8Array(array $arr): array + { + foreach ($arr as $k => $v) { + if (is_array($v)) { + $arr[$k] = $this->forceUtf8Array($v); + continue; + } + if (!is_string($v) || $v === '') continue; + + // 이미 UTF-8이면 그대로 + if (function_exists('mb_check_encoding') && mb_check_encoding($v, 'UTF-8')) continue; + + // 다날은 EUC-KR 가능성이 높음 → UTF-8로 변환(깨진 바이트 제거) + $out = @iconv('EUC-KR', 'UTF-8//IGNORE', $v); + if ($out === false) $out = ''; + if (function_exists('mb_check_encoding') && !mb_check_encoding($out, 'UTF-8')) { + $out = @iconv('UTF-8', 'UTF-8//IGNORE', $out) ?: ''; + } + $arr[$k] = $out; + } + return $arr; + } + // 휴대폰 TargetURL public function phoneReturn(Request $request) { diff --git a/app/Providers/Danal/DanalConfig.php b/app/Providers/Danal/DanalConfig.php index 3359bd3..54ea13f 100644 --- a/app/Providers/Danal/DanalConfig.php +++ b/app/Providers/Danal/DanalConfig.php @@ -22,6 +22,19 @@ final class DanalConfig return $cfg; } + public function wiretransfer(): array + { + $cfg = config('danal.wiretransfer'); + if (empty($cfg['cpid'])) throw new \RuntimeException('DANAL_AUTHVACT_CPID empty'); + if (!is_string($cfg['key']) || !preg_match('/^[0-9a-fA-F]{64}$/', $cfg['key'])) { + throw new \RuntimeException('DANAL_AUTHVACT_KEY must be 64 hex chars'); + } + if (!is_string($cfg['iv']) || !preg_match('/^[0-9a-fA-F]{32}$/', $cfg['iv'])) { + throw new \RuntimeException('DANAL_AUTHVACT_IV must be 32 hex chars'); + } + return $cfg; + } + public function phone(string $mode): array { $base = config('danal.phone'); diff --git a/app/Providers/Danal/Gateways/WireGateway.php b/app/Providers/Danal/Gateways/WireGateway.php new file mode 100644 index 0000000..215e712 --- /dev/null +++ b/app/Providers/Danal/Gateways/WireGateway.php @@ -0,0 +1,117 @@ +cfg->wiretransfer(); + $userAgent = $isMobile ? 'MW' : 'PC'; + + // wire 라우트 네이밍은 web.php 그대로 사용 + $returnUrl = route('wire.return', ['a' => $attemptToken], true); + $cancelUrl = route('web.payments.danal.cancel', ['a' => $attemptToken], true); + $notiUrl = route('wire.noti', [], true); + + $req = [ + 'TXTYPE' => 'AUTH', + 'SERVICETYPE' => 'WIRETRANSFER', + + 'ORDERID' => $order->oid, + 'ITEMNAME' => $this->safeItemName($this->orderTitle($order)), + 'AMOUNT' => (string)$order->pay_money, + + 'USERNAME' => (string)($buyer['user_name'] ?? ''), + 'USERID' => (string)($buyer['user_id'] ?? (string)$order->mem_no), + 'USEREMAIL' => (string)($buyer['user_email'] ?? ''), + 'USERPHONE' => (string)($buyer['user_phone'] ?? ''), + 'USERAGENT' => $userAgent, + + 'RETURNURL' => $returnUrl, + 'CANCELURL' => $cancelUrl, + + 'ISNOTI' => (string)($c['is_noti'] ?? 'Y'), + 'NOTIURL' => $notiUrl, + + // NOTI에서 token 추출(권장) + 'BYPASSVALUE' => 'AT=' . $attemptToken, + ]; + + \Log::info('danal_wire_auth_req', [ + 'cpid' => $c['cpid'] ?? null, + 'key_len' => isset($c['key']) ? strlen((string)$c['key']) : null, + 'iv_len' => isset($c['iv']) ? strlen((string)$c['iv']) : null, + 'req' => $req, + ]); + + $res = $this->client->call((string)$c['tx_url'], (string)$c['cpid'], $req, (string)$c['key'], (string)$c['iv']); + + $params = ['STARTPARAMS' => (string)($res['STARTPARAMS'] ?? '')]; + if (!empty($c['ci_url'])) $params['CIURL'] = (string)$c['ci_url']; + if (!empty($c['color'])) $params['COLOR'] = (string)$c['color']; + + return [ + 'req' => $req, + 'res' => $res, + 'start' => [ + 'actionUrl' => (string)($res['STARTURL'] ?? ''), + 'params' => $params, + 'acceptCharset' => 'EUC-KR', + ], + ]; + } + + public function decryptReturn(string $returnParams): array + { + $c = $this->cfg->wiretransfer(); + return $this->client->decryptReturnParams($returnParams, (string)$c['key'], (string)$c['iv']); + } + + public function bill(GcPinOrder $order, string $tid): array + { + $c = $this->cfg->wiretransfer(); + + $req = [ + 'TXTYPE' => 'BILL', + 'SERVICETYPE' => 'WIRETRANSFER', + 'TID' => $tid, + // AUTH 금액과 반드시 동일 + 'AMOUNT' => (string)$order->pay_money, + ]; + + $res = $this->client->call((string)$c['tx_url'], (string)$c['cpid'], $req, (string)$c['key'], (string)$c['iv']); + return ['req' => $req, 'res' => $res]; + } + + public function decryptNotiData(string $data): array + { + $c = $this->cfg->wiretransfer(); + return $this->client->decryptReturnParams($data, (string)$c['key'], (string)$c['iv']); + } + + private function orderTitle(GcPinOrder $order): string + { + $items = $order->items()->limit(2)->get(); + if ($items->count() === 0) return '상품권'; + if ($items->count() === 1) return (string)$items[0]->item_name; + return (string)$items[0]->item_name . ' 외'; + } + + private function safeItemName(string $s): string + { + $s = str_replace(["&","'","\"","\\","<",">","|","\r","\n","," , "+"], " ", $s); + return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권'; + } +} diff --git a/app/Repositories/Member/MemInfoRepository.php b/app/Repositories/Member/MemInfoRepository.php index a70b223..fccb656 100644 --- a/app/Repositories/Member/MemInfoRepository.php +++ b/app/Repositories/Member/MemInfoRepository.php @@ -26,4 +26,12 @@ class MemInfoRepository ->where('mem_no', $memNo) ->first(); } + + public function findForWirePay(int $memNo): ?MemInfo + { + return MemInfo::query() + ->select(['mem_no','name','email','cell_phone']) + ->where('mem_no', $memNo) + ->first(); + } } diff --git a/app/Repositories/Mypage/UsageRepository.php b/app/Repositories/Mypage/UsageRepository.php new file mode 100644 index 0000000..5faee44 --- /dev/null +++ b/app/Repositories/Mypage/UsageRepository.php @@ -0,0 +1,82 @@ +leftJoin('gc_pin_order as o', 'o.id', '=', 'a.order_id') + ->select([ + 'a.id as attempt_id', + 'a.provider as attempt_provider', + 'a.oid as attempt_oid', + 'a.mem_no as attempt_mem_no', + 'a.order_id as attempt_order_id', + 'a.pay_method as attempt_pay_method', + 'a.status as attempt_status', + 'a.pg_tid as attempt_pg_tid', + 'a.return_code as attempt_return_code', + 'a.return_msg as attempt_return_msg', + 'a.request_payload as attempt_request_payload', + 'a.response_payload as attempt_response_payload', + 'a.return_payload as attempt_return_payload', + 'a.noti_payload as attempt_noti_payload', + 'a.created_at as attempt_created_at', + 'a.updated_at as attempt_updated_at', + + 'o.id as order_id', + 'o.oid as order_oid', + 'o.mem_no as order_mem_no', + 'o.stat_pay as order_stat_pay', + 'o.provider as order_provider', + 'o.pay_method as order_pay_method', + 'o.pg_tid as order_pg_tid', + 'o.ret_code as order_ret_code', + 'o.ret_msg as order_ret_msg', + 'o.subtotal_amount as order_subtotal_amount', + 'o.fee_amount as order_fee_amount', + 'o.pg_fee_amount as order_pg_fee_amount', + 'o.pay_money as order_pay_money', + 'o.pay_data as order_pay_data', + 'o.ret_data as order_ret_data', + 'o.created_at as order_created_at', + 'o.updated_at as order_updated_at', + ]) + ->where('a.id', $attemptId) + ->first(); + } + + public function getOrderItems(int $orderId) + { + return DB::table('gc_pin_order_items') + ->where('order_id', $orderId) + ->orderBy('id', 'asc') + ->get(); + } + + public function countAssignedPins(int $orderId): int + { + return (int) DB::table('gc_pins') + ->where('order_id', $orderId) + ->count(); + } + + public function getAssignedPinsStatusSummary(int $orderId): array + { + $rows = DB::table('gc_pins') + ->selectRaw('status, COUNT(*) as cnt') + ->where('order_id', $orderId) + ->groupBy('status') + ->get(); + + $out = []; + foreach ($rows as $r) { + $out[(string)$r->status] = (int)$r->cnt; + } + return $out; + } +} diff --git a/app/Services/Mypage/UsageService.php b/app/Services/Mypage/UsageService.php new file mode 100644 index 0000000..a54a32c --- /dev/null +++ b/app/Services/Mypage/UsageService.php @@ -0,0 +1,236 @@ + 'empty', + 'pageTitle' => '구매내역', + 'pageDesc' => '결제 완료 후 핀 확인/발급/매입을 진행할 수 있습니다.', + 'mypageActive' => 'usage', + ]; + } + + $row = $this->repo->findAttemptWithOrder($attemptId); + if (!$row) abort(404); + + // 소유자 검증 (존재 여부 숨김) + $attemptMem = (int)($row->attempt_mem_no ?? 0); + $orderMem = (int)($row->order_mem_no ?? 0); + + if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) { + abort(404); + } + + $orderId = (int)($row->order_id ?? 0); + if ($orderId <= 0) abort(404); + + $items = $this->repo->getOrderItems($orderId); + + $requiredQty = 0; + foreach ($items as $it) { + $requiredQty += (int)($it->qty ?? 0); + } + + $assignedPinsCount = $this->repo->countAssignedPins($orderId); + $pinsSummary = $this->repo->getAssignedPinsStatusSummary($orderId); + + $attemptStatus = (string)($row->attempt_status ?? ''); + $orderStatPay = (string)($row->order_stat_pay ?? ''); + + $stepKey = $this->resolveStepKey($attemptStatus, $orderStatPay, $requiredQty, $assignedPinsCount); + + return [ + 'mode' => 'detail', + 'pageTitle' => '구매내역', + 'pageDesc' => '결제 상태 및 핀 발급/매입 진행을 확인합니다.', + 'mypageActive' => 'usage', + + 'attempt' => $this->attemptViewModel($row), + 'order' => $this->orderViewModel($row), + 'items' => $this->itemsViewModel($items), + + 'requiredQty' => $requiredQty, + 'assignedPinsCount' => $assignedPinsCount, + 'pinsSummary' => $pinsSummary, + + 'stepKey' => $stepKey, + 'steps' => $this->steps(), + 'vactInfo' => $this->extractVactInfo($row), + ]; + } + + private function resolveStepKey(string $attemptStatus, string $orderStatPay, int $requiredQty, int $assignedPinsCount): string + { + // 취소/실패 우선 + if (in_array($orderStatPay, ['c','f'], true) || in_array($attemptStatus, ['cancelled','failed'], true)) { + return 'failed'; + } + + // 가상계좌 입금대기 + if ($orderStatPay === 'w' || $attemptStatus === 'issued') { + return 'deposit_wait'; + } + + // 결제완료 + if ($orderStatPay === 'p' || $attemptStatus === 'paid') { + if ($requiredQty > 0 && $assignedPinsCount >= $requiredQty) return 'pin_done'; + return 'pin_check'; + } + + // 결제 진행 중/확인 중 + if (in_array($attemptStatus, ['ready','redirected','auth_ok'], true) || $orderStatPay === 'ready') { + return 'pay_processing'; + } + + return 'pay_processing'; + } + + private function steps(): array + { + return [ + ['key' => 'deposit_wait', 'label' => '입금대기'], + ['key' => 'pin_check', 'label' => '결제완료'], + ['key' => 'pin_verify', 'label' => '핀발급 확인'], + ['key' => 'pin_done', 'label' => '핀발급완료'], + ['key' => 'buyback', 'label' => '매입진행'], + ['key' => 'settlement', 'label' => '정산완료'], + ]; + } + + private function attemptViewModel(object $row): array + { + return [ + 'id' => (int)$row->attempt_id, + 'provider' => (string)$row->attempt_provider, + 'oid' => (string)$row->attempt_oid, + 'pay_method' => (string)$row->attempt_pay_method, + 'status' => (string)$row->attempt_status, + 'pg_tid' => (string)($row->attempt_pg_tid ?? ''), + 'return_code' => (string)($row->attempt_return_code ?? ''), + 'return_msg' => (string)($row->attempt_return_msg ?? ''), + 'payloads' => [ + 'request' => $this->jsonDecodeOrRaw($row->attempt_request_payload ?? null), + 'response' => $this->jsonDecodeOrRaw($row->attempt_response_payload ?? null), + 'return' => $this->jsonDecodeOrRaw($row->attempt_return_payload ?? null), + 'noti' => $this->jsonDecodeOrRaw($row->attempt_noti_payload ?? null), + ], + 'created_at' => (string)($row->attempt_created_at ?? ''), + ]; + } + + private function orderViewModel(object $row): array + { + return [ + 'id' => (int)$row->order_id, + 'oid' => (string)$row->order_oid, + 'mem_no' => (int)$row->order_mem_no, + 'stat_pay' => (string)$row->order_stat_pay, + 'provider' => (string)($row->order_provider ?? ''), + 'pay_method' => (string)($row->order_pay_method ?? ''), + 'pg_tid' => (string)($row->order_pg_tid ?? ''), + 'ret_code' => (string)($row->order_ret_code ?? ''), + 'ret_msg' => (string)($row->order_ret_msg ?? ''), + 'amounts' => [ + 'subtotal' => (int)($row->order_subtotal_amount ?? 0), + 'fee' => (int)($row->order_fee_amount ?? 0), + 'pg_fee' => (int)($row->order_pg_fee_amount ?? 0), + 'pay_money' => (int)($row->order_pay_money ?? 0), + ], + 'pay_data' => $this->jsonDecodeOrRaw($row->order_pay_data ?? null), + 'ret_data' => $this->jsonDecodeOrRaw($row->order_ret_data ?? null), + 'created_at' => (string)($row->order_created_at ?? ''), + ]; + } + + private function itemsViewModel($items): array + { + $out = []; + foreach ($items as $it) { + $out[] = [ + 'name' => (string)($it->item_name ?? ''), + 'code' => (string)($it->item_code ?? ''), + 'qty' => (int)($it->qty ?? 0), + 'unit' => (int)($it->unit_pay_price ?? 0), + 'total'=> (int)($it->line_total ?? 0), + 'meta' => $this->jsonDecodeOrRaw($it->meta ?? null), + ]; + } + return $out; + } + + private function extractVactInfo(object $row): array + { + // 어떤 키로 들어오든 "있으면 보여주기" 수준의 안전한 추출 + $candidates = [ + $this->jsonDecodeOrArray($row->order_pay_data ?? null), + $this->jsonDecodeOrArray($row->order_ret_data ?? null), + $this->jsonDecodeOrArray($row->attempt_return_payload ?? null), + $this->jsonDecodeOrArray($row->attempt_noti_payload ?? null), + ]; + + $pick = function(array $a, array $keys) { + foreach ($keys as $k) { + if (array_key_exists($k, $a) && $a[$k] !== '' && $a[$k] !== null) return $a[$k]; + } + return null; + }; + + $info = [ + 'bank' => null, + 'account' => null, + 'holder' => null, + 'amount' => null, + 'expire_at' => null, + ]; + + foreach ($candidates as $a) { + if (!$a) continue; + $info['bank'] ??= $pick($a, ['bank', 'vbank', 'Vbank', 'BankName']); + $info['account'] ??= $pick($a, ['account', 'vaccount', 'Vaccount', 'AccountNo']); + $info['holder'] ??= $pick($a, ['holder', 'depositor', 'Depositor', 'AccountHolder']); + $info['amount'] ??= $pick($a, ['amount', 'Amt', 'pay_money', 'PayMoney']); + $info['expire_at'] ??= $pick($a, ['expire_at', 'Vdate', 'vdate', 'ExpireDate']); + } + + // 다 null이면 빈 배열로 처리 + $hasAny = false; + foreach ($info as $v) { if ($v !== null) { $hasAny = true; break; } } + return $hasAny ? $info : []; + } + + private function jsonDecodeOrArray($v): array + { + if ($v === null) return []; + if (is_array($v)) return $v; + $s = trim((string)$v); + if ($s === '') return []; + $decoded = json_decode($s, true); + return is_array($decoded) ? $decoded : []; + } + + private function jsonDecodeOrRaw($v) + { + if ($v === null) return null; + if (is_array($v)) return $v; + $s = trim((string)$v); + if ($s === '') return null; + + $decoded = json_decode($s, true); + if (json_last_error() === JSON_ERROR_NONE) return $decoded; + + // JSON이 아니라면 원문 그대로(운영 디버그용) + return $s; + } +} diff --git a/app/Services/Order/OrderCheckoutService.php b/app/Services/Order/OrderCheckoutService.php index 08a87b5..0df1028 100644 --- a/app/Services/Order/OrderCheckoutService.php +++ b/app/Services/Order/OrderCheckoutService.php @@ -227,6 +227,7 @@ final class OrderCheckoutService 'CREDIT_CARD' => ['card', ['card_kind'=>'general', 'is_mobile'=>$isMobile]], 'CREDIT_CARD_REFUND' => ['card', ['card_kind'=>'exchange', 'is_mobile'=>$isMobile]], 'VACT' => ['vact', ['is_mobile'=>$isMobile]], + 'WIRETRANSFER' => ['wire', ['is_mobile'=>$isMobile]], default => ['card', ['card_kind'=>'general', 'is_mobile'=>$isMobile]], }; } diff --git a/app/Services/Payments/Danal/Clients/DanalTeleditClient.php b/app/Services/Payments/Danal/Clients/DanalTeleditClient.php deleted file mode 100644 index ef5b759..0000000 --- a/app/Services/Payments/Danal/Clients/DanalTeleditClient.php +++ /dev/null @@ -1,308 +0,0 @@ - SClient ITEMSEND2 - * return: redirect info for Start.php - */ - public function ready(GcPaymentAttempt $attempt, string $token, array $order): array - { - $binPath = (string)config('danal.phone.bin_path', ''); - if ($binPath === '') throw new \RuntimeException('DANAL_PHONE_BIN_PATH missing'); - - $cpid = (string)config('danal.phone.cpid', ''); - $pwd = (string)config('danal.phone.pwd', ''); - if ($cpid === '' || $pwd === '') throw new \RuntimeException('DANAL_PHONE_CPID/PWD missing'); - - // CI3 상수(_PG_ITEMCODE_DANAL) 역할: item_code 필요 - $itemCode = (string)config('danal.phone.item_code', ''); - if ($itemCode === '') throw new \RuntimeException('config(danal.phone.item_code) missing (DANAL_PHONE_ITEMCODE 필요)'); - - $oid = (string)($order['oid'] ?? ''); - $amount = (int)($order['amount'] ?? $order['pay_money'] ?? 0); - $itemName = (string)($order['itemName'] ?? $order['pin_name'] ?? '상품권'); - $memNo = (string)($order['mem_no'] ?? $attempt->mem_no ?? ''); - - $cellCorp = (string)($order['cell_corp'] ?? ''); - $dstAddr = preg_replace('/\D+/', '', (string)($order['cell_phone'] ?? '')); - - $carrier = $this->mapCarrier($cellCorp); - - $trans = [ - 'Command' => 'ITEMSEND2', - 'SERVICE' => 'TELEDIT', - 'ItemCount' => '1', - 'OUTPUTOPTION' => 'DEFAULT', - - 'ID' => $cpid, - 'PWD' => $pwd, - - 'SUBCP' => '', - 'USERID' => $memNo, - 'ORDERID' => $oid, - 'IsPreOtbill' => 'N', - 'IsSubscript' => 'N', - - 'ItemInfo' => $this->makeItemInfo($amount, $itemCode, $itemName), - ]; - - $attempt->request_payload = $trans; - $attempt->save(); - - $res = $this->runClient($binPath, 'SClient', $trans); - - $attempt->response_payload = $res; - $attempt->save(); - - if (($res['Result'] ?? '') !== '0') { - $msg = (string)($res['ErrMsg'] ?? '휴대폰 결제 모듈 실패'); - throw new \RuntimeException("Teledit ITEMSEND2 failed: {$msg}"); - } - - // CPCGI로 POST 전달되는 bypass - $byPass = [ - 'BgColor' => '00', - 'TargetURL' => route('web.payments.danal.phone.return', [], true), - 'BackURL' => route('web.payments.danal.phone.cancel', [], true), - 'IsUseCI' => 'N', - 'CIURL' => url('/img/main/top_logo.png'), - 'Email' => (string)($order['email'] ?? ''), - 'IsCharSet' => '', - // 기존 macro->encrypt("DanaLTelediT", "oid/amount/mem_no") 구조 유지 + token 추가 - 'Order' => Crypt::encryptString($oid . '/' . $amount . '/' . $memNo . '/' . $token), - ]; - - $isMobile = (bool)($order['is_mobile'] ?? false); - $actionUrl = $isMobile - ? 'https://ui.teledit.com/Danal/Teledit/Mobile/Start.php' - : 'https://ui.teledit.com/Danal/Teledit/Web/Start.php'; - - // CI3처럼 Result/ErrMsg 제외하고 Res를 폼에 다 넣음 - $params = []; - foreach ($res as $k => $v) { - if ($k === 'Result' || $k === 'ErrMsg') continue; - $params[$k] = $v; - } - foreach ($byPass as $k => $v) $params[$k] = $v; - - // 추가 Hidden - $params['CPName'] = (string)($order['cp_name'] ?? '핀포유'); - $params['ItemName'] = $itemName; - $params['ItemAmt'] = (string)$amount; - $params['IsPreOtbill'] = (string)$trans['IsPreOtbill']; - $params['IsSubscript'] = (string)$trans['IsSubscript']; - $params['IsCarrier'] = $carrier; // SKT/KTF/LGT - $params['IsDstAddr'] = $dstAddr; // 010xxxxxxxx - - return [ - 'actionUrl' => $actionUrl, - 'params' => $params, - 'acceptCharset' => 'EUC-KR', - ]; - } - - /** - * CI3: payment_danal_cell_return() - * - Order 복호화 -> NCONFIRM -> NBILL - * - 성공 시 onPaid(oid, tid, amount, payload) - */ - public function confirmAndBillOnReturn(array $post, array $order, callable $onPaid): array - { - $binPath = (string)config('danal.phone.bin_path', ''); - if ($binPath === '') throw new \RuntimeException('DANAL_PHONE_BIN_PATH missing'); - - $serverInfo = (string)($post['ServerInfo'] ?? ''); - if ($serverInfo === '') return $this->fail('E990', 'ServerInfo 누락'); - - $encOrder = (string)($post['Order'] ?? ''); - if ($encOrder === '') return $this->fail('E991', 'Order 누락'); - - [$oid, $amount, $memNo, $token] = $this->decryptOrder($encOrder); - - // (보안) 휴대폰번호 변조 체크: order에 cell_phone이 있으면 비교 - $dst = preg_replace('/\D+/', '', (string)($post['IsDstAddr'] ?? '')); - $expected = preg_replace('/\D+/', '', (string)($order['cell_phone'] ?? '')); - if ($expected !== '' && $dst !== '' && $dst !== $expected) { - return $this->fail('E992', '휴대폰번호 변조 의심'); - } - - // NCONFIRM - $nConfirm = [ - 'Command' => 'NCONFIRM', - 'OUTPUTOPTION' => 'DEFAULT', - 'ServerInfo' => $serverInfo, - 'IFVERSION' => 'V1.1.2', - 'ConfirmOption' => '0', - ]; - $res1 = $this->runClient($binPath, 'SClient', $nConfirm); - if (($res1['Result'] ?? '') !== '0') { - return $this->fail((string)($res1['Result'] ?? 'E993'), (string)($res1['ErrMsg'] ?? 'NCONFIRM 실패')); - } - - // NBILL - $nBill = [ - 'Command' => 'NBILL', - 'OUTPUTOPTION' => 'DEFAULT', - 'ServerInfo' => $serverInfo, - 'IFVERSION' => 'V1.1.2', - 'BillOption' => '0', - ]; - $res2 = $this->runClient($binPath, 'SClient', $nBill); - if (($res2['Result'] ?? '') !== '0') { - return $this->fail((string)($res2['Result'] ?? 'E994'), (string)($res2['ErrMsg'] ?? 'NBILL 실패')); - } - - // 성공: CI3처럼 Res(=NCONFIRM 결과)에서 TID/AMOUNT/ORDERID 사용 - $tid = (string)($res1['TID'] ?? ''); - $paidAmount = (int)($res1['AMOUNT'] ?? $amount); - - $payload = [ - 'Res' => $res1, - 'Res2' => $res2, - 'Post' => $post, - ]; - - $onPaid($oid, $tid, $paidAmount, $payload); - - return [ - 'status' => 'success', - 'message' => '결제가 완료되었습니다.', - 'redirectUrl' => url('/mypage/usage'), - 'meta' => [ - 'oid' => $oid, - 'tid' => $tid, - 'amount' => $paidAmount, - 'code' => (string)($res2['Result'] ?? '0'), - 'msg' => (string)($res2['ErrMsg'] ?? ''), - 'ret_data' => $payload, - ], - ]; - } - - /** - * CI3: payment_danal_cell_cancel() - */ - public function handleCancel(array $post, callable $onCancel): array - { - $encOrder = (string)($post['Order'] ?? ''); - if ($encOrder === '') return $this->fail('C990', 'Order 누락'); - - [$oid, $amount, $memNo, $token] = $this->decryptOrder($encOrder); - - $onCancel($oid, [ - 'Post' => $post, - 'oid' => $oid, - 'amount' => $amount, - 'mem_no' => $memNo, - ]); - - return [ - 'status' => 'cancel', - 'message' => '사용자 결제 취소', - 'redirectUrl' => url('/mypage/usage'), - ]; - } - - public function decryptOrder(string $encOrder): array - { - $plain = Crypt::decryptString($encOrder); - $parts = explode('/', $plain); - - $oid = (string)($parts[0] ?? ''); - $amount = (int)($parts[1] ?? 0); - $memNo = (string)($parts[2] ?? ''); - $token = (string)($parts[3] ?? ''); - - if ($oid === '' || $amount <= 0 || $memNo === '' || $token === '') { - throw new \RuntimeException('Invalid Order payload'); - } - - return [$oid, $amount, $memNo, $token]; - } - - private function runClient(string $binPath, string $bin, array $params): array - { - $arg = $this->makeParam($params); - - $proc = new Process([$binPath . '/' . $bin, $arg]); - $proc->setTimeout((int)config('danal.authtel.timeout', 30)); - $proc->run(); - - $out = $proc->getOutput(); - if ($out === '' && !$proc->isSuccessful()) { - $err = $proc->getErrorOutput(); - throw new \RuntimeException("Teledit {$bin} failed: {$err}"); - } - - return $this->parseOutput($out); - } - - private function parseOutput(string $out): array - { - // CI3 Parsor(): 라인들을 &로 붙이고 key=value 파싱 + urldecode - $out = str_replace("\r", '', $out); - $lines = array_filter(array_map('trim', explode("\n", $out)), fn($v) => $v !== ''); - $in = implode('&', $lines); - - $map = []; - foreach (explode('&', $in) as $tok) { - $tok = trim($tok); - if ($tok === '') continue; - - $tmp = explode('=', $tok, 2); - $name = trim($tmp[0] ?? ''); - $value = trim($tmp[1] ?? ''); - - if ($name === '') continue; - $map[$name] = urldecode($value); - } - return $map; - } - - private function makeParam(array $arr): string - { - // CI3 MakeParam(): key=value;key=value... - $parts = []; - foreach ($arr as $k => $v) { - $parts[] = $k . '=' . $v; - } - return implode(';', $parts); - } - - private function makeItemInfo(int $amt, string $code, string $name): string - { - // CI3 MakeItemInfo(): substr(code,0,1)|amt|1|code|name - return substr($code, 0, 1) . '|' . $amt . '|1|' . $code . '|' . $name; - } - - private function mapCarrier(string $corp): string - { - // CI3: 01=SKT, 02=KTF, 03=LGT - return match ($corp) { - '01' => 'SKT', - '02' => 'KTF', - '03' => 'LGT', - default => '', - }; - } - - private function fail(string $code, string $msg): array - { - return [ - 'status' => 'fail', - 'message' => $msg ?: '결제에 실패했습니다.', - 'redirectUrl' => url('/mypage/usage'), - 'meta' => [ - 'code' => $code, - 'msg' => $msg, - ], - ]; - } -} diff --git a/app/Services/Payments/Danal/DanalPaymentService.php b/app/Services/Payments/Danal/DanalPaymentService.php deleted file mode 100644 index 64a9082..0000000 --- a/app/Services/Payments/Danal/DanalPaymentService.php +++ /dev/null @@ -1,278 +0,0 @@ -loadOrderForPaymentOrFail($oid, $memNo); - - return DB::transaction(function () use ($memNo, $data, $order, $payMethod, $oid) { - [$attempt, $token] = $this->upsertAttempt($memNo, $order, $data); - - if ($payMethod === 'card') { - $kind = $data['card_kind'] ?? 'general'; // general|exchange - $start = $this->card->auth($attempt, $token, $order, $kind); - } elseif ($payMethod === 'vact') { - $kind = $data['vact_kind'] ?? 'a'; // a|v (기본 a) - $start = $this->vact->auth($attempt, $token, $order, $kind); - } else { // phone - $start = $this->teledit->ready($attempt, $token, $order); - } - - $attempt->status = 'redirected'; - $attempt->redirected_at = now(); - $attempt->save(); - - return $start; - }); - } - - public function handleCardReturn(string $oid, string $token, array $all, array $post): array - { - return DB::transaction(function () use ($oid, $token, $post) { - $attempt = $this->findAttemptOrFail('card', $oid, $token); - $order = $this->loadOrderForPaymentOrFail($attempt->oid, (int)$attempt->mem_no); - - $ret = $this->card->billOnReturn($attempt, $token, $order, $post); - - return $ret; - }); - } - - public function handleVactReturn(string $token, array $post): array - { - return DB::transaction(function () use ($token, $post) { - $attempt = $this->findAttemptByTokenOrFail('vact', $token); - $order = $this->loadOrderForPaymentOrFail($attempt->oid, (int)$attempt->mem_no); - - return $this->vact->issueVaccountOnReturn($attempt, $token, $order, $post); - }); - } - - public function handleVactNoti(array $post): void - { - // NOTI는 idempotent하게: 이미 paid면 그냥 OK - DB::transaction(function () use ($post) { - $this->vact->handleNoti($post, function (string $oid, string $tid, int $amount, array $payload) { - // TODO: 여기서 주문 paid 처리 + 장부 처리 - // $this->markOrderPaid($oid, $tid, $amount, $payload); - - // attempt도 paid로 - $attempt = GcPaymentAttempt::query() - ->where('provider','danal')->where('oid',$oid)->where('pay_method','vact') - ->lockForUpdate()->first(); - - if ($attempt) { - if ($attempt->status !== 'paid') { - $attempt->status = 'paid'; - $attempt->pg_tid = $tid ?: $attempt->pg_tid; - $attempt->amount = $amount ?: $attempt->amount; - $attempt->noti_payload = $payload; - $attempt->noti_at = now(); - $attempt->save(); - } - } - }); - }); - } - - public function handlePhoneReturn(array $post): array - { - return DB::transaction(function () use ($post) { - - // Order 복호화로 oid/mem_no/token 확보 - [$oid, $amount, $memNo, $token] = $this->teledit->decryptOrder((string)($post['Order'] ?? '')); - - $order = $this->loadOrderForPaymentOrFail($oid, (int)$memNo); - - return $this->teledit->confirmAndBillOnReturn($post, $order, function (string $oid, string $tid, int $amount, array $payload) { - // TODO: 주문 paid 처리 + 장부 처리 - // $this->markOrderPaid($oid, $tid, $amount, $payload); - - $attempt = GcPaymentAttempt::query() - ->where('provider','danal')->where('oid',$oid)->where('pay_method','phone') - ->lockForUpdate()->first(); - - if ($attempt) { - $attempt->status = 'paid'; - $attempt->pg_tid = $tid; - $attempt->returned_at = now(); - $attempt->return_payload = $payload; - $attempt->save(); - } - }); - }); - } - - - public function handlePhoneCancel(array $post): array - { - return DB::transaction(function () use ($post) { - - return $this->teledit->handleCancel($post, function (string $oid, array $payload) { - // TODO: 주문 cancel 처리 - // $this->cancelOrder($oid, 'C999', '사용자 결제 취소', $payload); - - $attempt = GcPaymentAttempt::query() - ->where('provider','danal')->where('oid',$oid)->where('pay_method','phone') - ->lockForUpdate()->first(); - - if ($attempt) { - $attempt->status = 'cancelled'; - $attempt->return_payload = $payload; - $attempt->returned_at = now(); - $attempt->save(); - } - }); - }); - } - - - public function handleCancel(string $oid, string $token): array - { - return DB::transaction(function () use ($oid, $token) { - $attempt = $this->findAttemptAnyMethodOrFail($oid, $token); - - // TODO: 주문 cancel 처리 - // $this->cancelOrder($oid, 'USER_CANCEL', '사용자 결제 취소', []); - - $attempt->status = 'cancelled'; - $attempt->returned_at = now(); - $attempt->save(); - - return [ - 'status' => 'cancel', - 'message' => '구매를 취소했습니다.', - 'redirectUrl' => url('/mypage/usage'), - ]; - }); - } - - private function upsertAttempt(int $memNo, array $order, array $data): array - { - $token = bin2hex(random_bytes(32)); - $tokenHash = hash('sha256', $token); - - $attempt = GcPaymentAttempt::query() - ->where('provider','danal') - ->where('oid', (string)$order['oid']) - ->where('pay_method', (string)$data['pay_method']) - ->lockForUpdate() - ->first(); - - if (!$attempt) { - $attempt = new GcPaymentAttempt(); - $attempt->provider = 'danal'; - $attempt->oid = (string)$order['oid']; - $attempt->mem_no = $memNo; - $attempt->pay_method = (string)$data['pay_method']; - $attempt->amount = (int)$order['amount']; - $attempt->currency = 'KRW'; - $attempt->ready_at = now(); - } - - $attempt->status = 'ready'; - $attempt->token_hash = $tokenHash; - $attempt->card_kind = $data['card_kind'] ?? null; - $attempt->vact_kind = $data['vact_kind'] ?? null; - $attempt->user_agent = request()->userAgent(); - $attempt->user_ip = inet_pton(request()->ip() ?: '127.0.0.1'); - $attempt->save(); - - return [$attempt, $token]; - } - - private function findAttemptOrFail(string $method, string $oid, string $token): GcPaymentAttempt - { - $tokenHash = hash('sha256', $token); - - $attempt = GcPaymentAttempt::query() - ->where('provider','danal') - ->where('oid',$oid) - ->where('pay_method',$method) - ->lockForUpdate() - ->first(); - - if (!$attempt) abort(404); - if (!hash_equals((string)$attempt->token_hash, $tokenHash)) abort(403); - - return $attempt; - } - - private function findAttemptByTokenOrFail(string $method, string $token): GcPaymentAttempt - { - $tokenHash = hash('sha256', $token); - - $attempt = GcPaymentAttempt::query() - ->where('provider','danal') - ->where('pay_method',$method) - ->where('token_hash',$tokenHash) - ->lockForUpdate() - ->first(); - - if (!$attempt) abort(404); - - return $attempt; - } - - private function findAttemptAnyMethodOrFail(string $oid, string $token): GcPaymentAttempt - { - $tokenHash = hash('sha256', $token); - - $attempt = GcPaymentAttempt::query() - ->where('provider','danal') - ->where('oid',$oid) - ->where('token_hash',$tokenHash) - ->lockForUpdate() - ->first(); - - if (!$attempt) abort(404); - return $attempt; - } - - /** - * TODO: 네 주문테이블에 맞게 구현해야 하는 핵심 함수 - * - CI3 Product.php payment_danal_ready()에서 하던 검증(oid/mem_no/stat_pay/amount 등) - */ - private function loadOrderForPaymentOrFail(string $oid, int $memNo): array - { - // ✅ 여기만 너 DB 구조에 맞게 바꾸면 나머지 다날 연동은 그대로 감 - // 예시 형태: - // $row = DB::table('gc_orders')->where('oid',$oid)->first(); - // if (!$row) abort(404); - // if ((int)$row->mem_no !== $memNo) abort(403); - // if ($row->stat_pay !== 'w') abort(409); - // return ['oid'=>$row->oid,'amount'=>(int)$row->pay_money,'itemName'=>$row->pin_name,'mid'=>$row->mid]; - - return [ - 'oid' => $oid, - 'amount' => (int) request('amount', 0), // 임시 - 'itemName' => (string) request('itemName', '상품권'), - 'mid' => (string) request('mid', ''), - ]; - } -} diff --git a/app/Services/Payments/Danal/Gateways/DanalCardGateway.php b/app/Services/Payments/Danal/Gateways/DanalCardGateway.php deleted file mode 100644 index bd935f0..0000000 --- a/app/Services/Payments/Danal/Gateways/DanalCardGateway.php +++ /dev/null @@ -1,243 +0,0 @@ - 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, - ], - ]; - } -} diff --git a/app/Services/Payments/Danal/Gateways/DanalVactGateway.php b/app/Services/Payments/Danal/Gateways/DanalVactGateway.php deleted file mode 100644 index 5f61ebf..0000000 --- a/app/Services/Payments/Danal/Gateways/DanalVactGateway.php +++ /dev/null @@ -1,277 +0,0 @@ - AUTH - */ - public function auth(GcPaymentAttempt $attempt, string $token, array $order, string $vactKind = 'a'): array - { - // 현재 config는 vact만 있으니 a(무통장) 기준 구현 - // v(인증계좌/WIRETRANSFER)는 config 확장 시 추가 - if ($vactKind !== 'a') { - throw new \RuntimeException("Vact kind not supported yet: {$vactKind}"); - } - - $cfg = config('danal.vact'); - if (!$cfg || empty($cfg['cpid']) || empty($cfg['key']) || empty($cfg['iv']) || empty($cfg['url'])) { - throw new \RuntimeException("Danal vact config missing"); - } - - $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 ? 'MW' : 'PC'; - - $holder = (string)($cfg['holder'] ?? '핀포유'); - - $returnUrl = route('web.payments.danal.vact.return', ['t' => $token], true); - $cancelUrl = route('web.payments.danal.cancel', ['o' => $oid, 't' => $token], true); - $notiUrl = route('web.payments.danal.vact.noti', [], true); - - // BYPASSVALUE는 ; 구분 (token 포함) - $memNo = (string)($order['mem_no'] ?? $attempt->mem_no ?? ''); - $bypass = "PAYMETHOD=VACT;MEMNO={$memNo};AMOUNT={$amount};TOKEN={$token};"; - - $req = [ - // CI3처럼 CPID를 내부 DATA에도 넣는다 - 'CPID' => (string)$cfg['cpid'], - 'SUBCPID' => '', - 'ACCOUNTHOLDER' => $this->toEucKr($holder), - 'EXPIREDATE' => now()->addHours(23)->format('Ymd'), - - 'ORDERID' => $oid, - 'ITEMNAME' => $this->toEucKr($itemName), - 'AMOUNT' => (string)$amount, - 'ISCASHRECEIPTUI' => 'N', - - 'USERNAME' => $this->toEucKr((string)($order['user_name'] ?? '')), - 'USERID' => (string)($order['user_id'] ?? $order['mem_no'] ?? ''), - 'USEREMAIL' => (string)($order['user_email'] ?? ''), - 'USERPHONE' => '', - 'USERAGENT' => $userAgent, - - 'TXTYPE' => 'AUTH', - 'SERVICETYPE' => 'DANALVACCOUNT', - - 'RETURNURL' => $returnUrl, - 'NOTIURL' => $notiUrl, - 'CANCELURL' => $cancelUrl, - 'BYPASSVALUE' => $bypass, - ]; - - $attempt->vact_kind = 'a'; - $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 vact AUTH failed: {$res['RETURNCODE']} {$msg}"); - } - - return [ - 'actionUrl' => (string)$res['STARTURL'], - 'params' => [ - 'STARTPARAMS' => (string)$res['STARTPARAMS'], - ], - 'acceptCharset' => 'EUC-KR', - ]; - } - - /** - * CI3: payment_danal_vact_return() -> RETURN decrypt -> ISSUEVACCOUNT - */ - public function issueVaccountOnReturn(GcPaymentAttempt $attempt, string $token, array $order, array $post): array - { - $cfg = config('danal.vact'); - if (!$cfg || empty($cfg['cpid']) || empty($cfg['key']) || empty($cfg['iv']) || empty($cfg['url'])) { - throw new \RuntimeException("Danal vact config missing"); - } - - $returnParams = (string)($post['RETURNPARAMS'] ?? ''); - if ($returnParams === '') { - return $this->fail('C990', 'RETURNPARAMS 누락'); - } - - $retStr = $this->aes->decrypt($returnParams, (string)$cfg['key'], (string)$cfg['iv']); - $retMap = $this->aes->parseQuery($retStr); - - $oid = (string)($retMap['ORDERID'] ?? ''); - if ($oid === '' || $oid !== (string)$attempt->oid) { - return $this->fail('C991', '주문번호 불일치'); - } - - $retCode = (string)($retMap['RETURNCODE'] ?? ''); - $retMsg = (string)($retMap['RETURNMSG'] ?? ''); - - if ($retCode !== '0000') { - return $this->fail($retCode ?: 'C992', $retMsg ?: '가상계좌 인증 실패'); - } - - $amount = (int)($order['amount'] ?? $order['pay_money'] ?? $attempt->amount ?? 0); - - $issueReq = [ - 'CPID' => (string)$cfg['cpid'], - 'TID' => (string)($retMap['TID'] ?? ''), - 'AMOUNT' => (string)$amount, - 'TXTYPE' => 'ISSUEVACCOUNT', - 'SERVICETYPE' => 'DANALVACCOUNT', - ]; - - $res = $this->postCpcgi( - (string)$cfg['url'], - (string)$cfg['cpid'], - $issueReq, - (string)$cfg['key'], - (string)$cfg['iv'] - ); - - $res = $this->eucKrArrayToUtf8($res); - - if (($res['RETURNCODE'] ?? '') === '0000') { - $attempt->status = 'issued'; - $attempt->pg_tid = (string)($res['TID'] ?? $retMap['TID'] ?? ''); - $attempt->returned_at = now(); - $attempt->return_payload = ['ret' => $retMap, 'issue' => $res]; - $attempt->save(); - - return [ - 'status' => 'success', - 'message' => '가상계좌가 발급되었습니다. 마이페이지에서 확인해주세요.', - 'redirectUrl' => url('/mypage/usage'), - 'meta' => [ - 'oid' => $oid, - 'tid' => (string)($res['TID'] ?? ''), - 'amount' => $amount, - 'code' => (string)($res['RETURNCODE'] ?? ''), - 'msg' => (string)($res['RETURNMSG'] ?? ''), - 'ret_data' => $res, - ], - ]; - } - - return $this->fail((string)($res['RETURNCODE'] ?? 'C993'), (string)($res['RETURNMSG'] ?? '가상계좌 발급 실패')); - } - - /** - * NOTI 수신 처리 (서버->서버) - * - post에 DATA가 오면 복호화해서 맵으로 변환 - * - 성공이면 onPaid 콜백 실행 - */ - public function handleNoti(array $post, callable $onPaid): void - { - $cfg = config('danal.vact'); - if (!$cfg || empty($cfg['key']) || empty($cfg['iv'])) { - throw new \RuntimeException("Danal vact config missing"); - } - - $map = $post; - - // NOTI가 CPID/DATA 형태로 오면 DATA 복호화 - if (isset($post['DATA']) && is_string($post['DATA']) && $post['DATA'] !== '') { - $dec = $this->aes->decrypt((string)$post['DATA'], (string)$cfg['key'], (string)$cfg['iv']); - $map = $this->aes->parseQuery($dec); - } - - $oid = (string)($map['ORDERID'] ?? ''); - $tid = (string)($map['TID'] ?? ''); - $amount = (int)($map['AMOUNT'] ?? 0); - - // 성공 판단은 최소한으로: RETURNCODE=0000 + 필수키 존재 - $code = (string)($map['RETURNCODE'] ?? ''); - if ($oid === '' || $tid === '' || $amount <= 0) return; - if ($code !== '' && $code !== '0000') return; - - $onPaid($oid, $tid, $amount, $map); - } - - private function postCpcgi(string $url, string $outerCpid, array $reqData, string $hexKey, string $hexIv): array - { - $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, - ], - ]; - } -} diff --git a/app/Services/Payments/PaymentService.php b/app/Services/Payments/PaymentService.php index f3c9c9b..93794d8 100644 --- a/app/Services/Payments/PaymentService.php +++ b/app/Services/Payments/PaymentService.php @@ -4,10 +4,13 @@ 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 @@ -15,8 +18,13 @@ 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, ) {} @@ -70,7 +78,35 @@ final class PaymentService $this->attempts->markRedirected($attempt, $out['req'], $out['res']); return $this->ensureStart($out, $meta); - } elseif ($method === 'phone') { + } 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, [ @@ -290,6 +326,121 @@ final class PaymentService }); } + 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 { diff --git a/bootstrap/app.php b/bootstrap/app.php index d16d9e2..e756734 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -34,6 +34,8 @@ return Application::configure(basePath: dirname(__DIR__)) 'pay/danal/vact/noti', #다날가상계좌 'pay/danal/phone/return', #다날휴대폰 결제 'pay/danal/phone/cancel', #다날휴대폰 결제취소 + 'pay/danal/wire/return', #다날계좌이체 + 'pay/danal/wire/noti', #다날계좌이체 'pay/danal/cancel', ]); diff --git a/config/danal.php b/config/danal.php index fb812cb..29af582 100644 --- a/config/danal.php +++ b/config/danal.php @@ -59,6 +59,19 @@ return [ ], ], + //계좌이체 + 'wiretransfer' => [ + 'tx_url' => 'https://tx-wiretransfer.danalpay.com/bank/', // 메뉴얼:contentReference[oaicite:5]{index=5} + 'cpid' => env('DANAL_AUTHVACT_CPID'), + 'key' => env('DANAL_AUTHVACT_KEY'), + 'iv' => env('DANAL_AUTHVACT_IV'), + + // 선택(결제창 UI) + 'ci_url' => env('DANAL_WIRE_CIURL', ''), // 89x34 권장:contentReference[oaicite:6]{index=6} + 'color' => env('DANAL_WIRE_COLOR', '00'), + 'is_noti'=> env('DANAL_WIRE_ISNOTI', 'Y'), + ], + // 공통 네트워크 옵션 'http' => [ 'connect_timeout' => (int)env('DANAL_CONNECT_TIMEOUT', 5), diff --git a/resources/views/web/mypage/usage/index.blade.php b/resources/views/web/mypage/usage/index.blade.php index 9b075b6..1ed35af 100644 --- a/resources/views/web/mypage/usage/index.blade.php +++ b/resources/views/web/mypage/usage/index.blade.php @@ -1,30 +1,185 @@ -@php - $pageTitle = '이용내역'; - $pageDesc = '구매 → 결제 → 발송 상태를 확인할 수 있어요.'; - - $breadcrumbs = [ - ['label' => '홈', 'url' => url('/')], - ['label' => '마이페이지', 'url' => url('/mypage/info')], - ['label' => '이용내역', 'url' => url()->current()], - ]; - - $mypageActive = 'usage'; -@endphp - @extends('web.layouts.subpage') -@section('title', '이용내역 | PIN FOR YOU') -@section('meta_description', 'PIN FOR YOU 마이페이지 이용내역 입니다. 구매/결제/발송 내역을 확인하세요.') -@section('canonical', url('/mypage/usage')) +@php + // 탭 활성화용 + $mypageActive = $mypageActive ?? 'usage'; +@endphp + +@section('title', $pageTitle ?? '구매내역') @section('subcontent') -
- @include('web.partials.content-head', [ - 'title' => '이용내역', - 'desc' => '기간별로 내역을 확인할 수 있습니다.' - ]) +
- {{-- TODO: 내용 추후 구현 --}} - @include('web.partials.mypage-quick-actions') + @if(($mode ?? 'empty') === 'empty') +
+

결제 완료 후 이 페이지에서 핀 확인/발급/매입을 진행할 수 있습니다.

+

결제 완료 페이지에서 자동으로 이동되며, attempt_id가 없으면 상세 정보를 표시할 수 없습니다.

+
+ @else + + {{-- 상태 스텝 --}} +
+ @foreach(($steps ?? []) as $st) + @php + $active = false; + $done = false; + + // 표시 규칙(간단): 현재 stepKey 기준으로 active 표시 + $active = (($stepKey ?? '') === $st['key']); + + // 완료표시(선택): deposit_wait 이전/이후 같은 세밀한 건 다음 단계에서 고도화 가능 + // 여기서는 "active 이전"을 done으로 찍지 않고, 필요하면 확장 + @endphp +
+
+
{{ $st['label'] }}
+
+ @endforeach +
+ + {{-- 결제/주문 요약 --}} +
+

주문/결제 정보

+ +
+
주문번호 {{ $order['oid'] ?? '-' }}
+
결제수단 {{ $order['pay_method'] ?? '-' }}
+
결제상태 {{ $order['stat_pay'] ?? '-' }}
+
PG TID {{ $order['pg_tid'] ?? ($attempt['pg_tid'] ?? '-') }}
+
결제일시 {{ $order['created_at'] ?? '-' }}
+
+ +
+
상품금액 {{ number_format($order['amounts']['subtotal'] ?? 0) }}원
+
고객수수료 {{ number_format($order['amounts']['fee'] ?? 0) }}원
+
결제금액 {{ number_format($order['amounts']['pay_money'] ?? 0) }}원
+
+ + @if(!empty($vactInfo)) +
+

가상계좌 안내

+
+
은행 {{ $vactInfo['bank'] ?? '-' }}
+
계좌번호 {{ $vactInfo['account'] ?? '-' }}
+
예금주 {{ $vactInfo['holder'] ?? '-' }}
+
입금금액 {{ is_numeric($vactInfo['amount'] ?? null) ? number_format((int)$vactInfo['amount']).'원' : ($vactInfo['amount'] ?? '-') }}
+
만료 {{ $vactInfo['expire_at'] ?? '-' }}
+
+
+ @endif +
+ + {{-- 아이템 --}} +
+

구매 상품

+
+ @foreach(($items ?? []) as $it) +
+
{{ $it['name'] }}
+
+ 수량 {{ $it['qty'] }} + {{ number_format($it['unit'] ?? 0) }}원 + 합계 {{ number_format($it['total'] ?? 0) }}원 +
+
+ @endforeach +
+
+ + {{-- 핀 발급 상태/액션 (결제완료에서만 활성) --}} +
+

핀 발급/전달

+ +
+
필요 핀 수량 {{ (int)($requiredQty ?? 0) }}개
+
할당된 핀 {{ (int)($assignedPinsCount ?? 0) }}개
+ @if(!empty($pinsSummary)) +
+ @foreach($pinsSummary as $st => $cnt) + {{ $st }}: {{ $cnt }} + @endforeach +
+ @endif +
+ + @php + $isPaid = (($order['stat_pay'] ?? '') === 'p') || (($attempt['status'] ?? '') === 'paid'); + $isVactWait = (($order['stat_pay'] ?? '') === 'w') || (($attempt['status'] ?? '') === 'issued'); + $isFailed = in_array(($order['stat_pay'] ?? ''), ['c','f'], true) || in_array(($attempt['status'] ?? ''), ['cancelled','failed'], true); + @endphp + + @if($isVactWait) +
+

가상계좌 입금 확인 후 핀 발급을 진행할 수 있습니다.

+
+ @elseif($isFailed) +
+

결제가 취소/실패 상태입니다. 결제 정보를 확인해 주세요.

+
+ @elseif($isPaid) +
+ + + +
+

※ 사고 방지를 위해 핀 노출/발송/매입은 모두 서버 검증 후 처리됩니다.

+ @else +
+

결제 진행 중이거나 확인 중입니다. 잠시 후 다시 확인해 주세요.

+
+ @endif +
+ + {{-- 디버그(접기) --}} +
+ 결제 상세(디버그) +
{{ json_encode(['attempt'=>$attempt,'order'=>$order], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) }}
+
+ + @endif
+ + + + @endsection diff --git a/routes/web.php b/routes/web.php index bf8ffc1..193fb76 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,7 +15,7 @@ use App\Http\Controllers\Web\Payment\DanalController; use App\Http\Controllers\Web\Payment\DanalDemoController; use App\Http\Controllers\Web\Order\OrderCheckoutController; use App\Http\Controllers\Web\Mypage\MypageQnaController; - +use App\Http\Controllers\Web\Mypage\UsageController; Route::view('/', 'web.home')->name('web.home'); @@ -64,7 +64,8 @@ Route::prefix('mypage')->name('web.mypage.') Route::post('info/marketing/update', [InfoGateController::class, 'marketingUpdate'])->name('info.marketing.update'); Route::post('info/withdraw', [InfoGateController::class, 'withdraw'])->name('info.withdraw'); - Route::view('usage', 'web.mypage.usage.index')->name('usage.index'); + Route::get('usage', [UsageController::class, 'index'])->name('usage.index'); + Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index'); Route::get('qna', [MypageQnaController::class, 'index'])->name('qna.index'); Route::get('qna/{seq}', [MypageQnaController::class, 'show'])->whereNumber('seq')->name('qna.show'); @@ -79,6 +80,8 @@ Route::prefix('pay/danal')->group(function () { Route::match(['GET','POST'], '/phone/return', [DanalController::class, 'phoneReturn'])->name('web.payments.danal.phone.return'); Route::match(['GET','POST'], '/phone/cancel', [DanalController::class, 'phoneCancel'])->name('web.payments.danal.phone.cancel'); + Route::post('wire/return', [DanalController::class, 'wireReturn'])->name('wire.return'); + Route::post('wire/noti', [DanalController::class, 'wireNoti'])->name('wire.noti'); Route::match(['GET','POST'], '/cancel', [DanalController::class, 'cancel'])->name('web.payments.danal.cancel');