diff --git a/app/Console/Commands/PaymentsArchiveDispatch.php b/app/Console/Commands/PaymentsArchiveDispatch.php new file mode 100644 index 0000000..dcc63d4 --- /dev/null +++ b/app/Console/Commands/PaymentsArchiveDispatch.php @@ -0,0 +1,30 @@ +option('days'), + (int)$this->option('timeout'), + (int)$this->option('timeout_archive'), + (int)$this->option('batch'), + ); + + $this->info('ArchivePaymentsJob dispatched.'); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Web/Order/OrderCheckoutController.php b/app/Http/Controllers/Web/Order/OrderCheckoutController.php new file mode 100644 index 0000000..308b3e5 --- /dev/null +++ b/app/Http/Controllers/Web/Order/OrderCheckoutController.php @@ -0,0 +1,84 @@ + false, + 'message' => '로그인 후 이용해 주세요.', + 'redirect' => route('web.auth.login'), + ]); + } + + $memNo = (int)($sess['_mno'] ?? 0); + $stat3 = (int)($sess['_mstat_3'] ?? 0); + + if ($memNo <= 0) { + return view('web.payments.danal_finish_top', [ + 'ok' => false, + 'message' => '로그인 정보가 올바르지 않습니다.', + 'redirect' => route('web.auth.login'), + ]); + } + + if ($stat3 !== 1) { + return view('web.payments.danal_finish_top', [ + 'ok' => false, + 'message' => '관리자 확인이 필요합니다.', + 'redirect' => url('/'), + ]); + } + + // 2) 파라미터 + $skuId = (int)$request->query('sku_id', 0); + $qty = (int)$request->query('qty', 0); + $payId = (int)$request->query('pay_id', 0); + + if ($skuId <= 0 || $qty <= 0 || $payId <= 0) { + return view('web.payments.danal_finish_top', [ + 'ok' => false, + 'message' => '요청 값이 올바르지 않습니다.', + 'redirect' => url('/'), + ]); + } + + // 3) 주문 생성 + 결제 시작 + $out = $this->service->checkoutAndStart($memNo, $skuId, $qty, $payId, $request); + + if (($out['ok'] ?? false) && ($out['view'] ?? '') === 'autosubmit') { + return view('web.payments.danal_autosubmit', [ + 'action' => $out['action'], + 'fields' => $out['fields'], + 'acceptCharset' => $out['acceptCharset'] ?? 'EUC-KR', + + 'attemptToken' => $out['token'] ?? ($out['meta']['token'] ?? ''), + 'oid' => $out['oid'] ?? ($out['meta']['oid'] ?? ''), + 'method' => $out['method'] ?? ($out['meta']['method'] ?? ''), + 'phoneMode' => $out['meta']['phone_mode'] ?? '', + ]); + } + + + return view('web.payments.danal_finish_top', [ + 'ok' => false, + 'message' => $out['message'] ?? '처리 실패', + 'redirect' => $out['redirect'] ?? url('/'), + ]); + } +} diff --git a/app/Http/Controllers/Web/Payment/DanalController.php b/app/Http/Controllers/Web/Payment/DanalController.php new file mode 100644 index 0000000..cae2037 --- /dev/null +++ b/app/Http/Controllers/Web/Payment/DanalController.php @@ -0,0 +1,181 @@ +validate([ + 'oid' => ['required','string','max:64'], + 'method' => ['required','in:card,vact,phone'], + 'card_kind' => ['nullable','in:general,exchange'], + 'phone_mode' => ['nullable','in:prod,dev'], + 'is_mobile' => ['nullable','boolean'], + ]); + + $memNo = $this->currentMemNo($request); + if ($memNo <= 0) abort(403); + + $out = $this->service->start( + $data['oid'], + $memNo, + $data['method'], + [ + 'card_kind' => $data['card_kind'] ?? null, + 'phone_mode' => $data['phone_mode'] ?? null, + 'is_mobile' => (bool)($data['is_mobile'] ?? false), + ] + ); + + if (($out['type'] ?? '') === 'redirect') { + return view('web.payments.danal.redirect', [ + 'actionUrl' => $out['start']['actionUrl'], + 'params' => $out['start']['params'], + 'acceptCharset' => $out['start']['acceptCharset'] ?? 'EUC-KR', + ]); + } + + return view('web.payments.danal.result', $out); + } + + // 카드 RETURNURL + public function cardReturn(Request $request) + { + $token = (string)$request->query('a', ''); + if ($token === '') abort(404); + + 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' => '결제에 실패했습니다.', + ]); + } + + // 가상계좌 RETURNURL + public function vactReturn(Request $request) + { + $token = (string)$request->query('a', ''); + if ($token === '') abort(404); + + $out = $this->service->handleVactReturn($token, $request->all()); + + if (($out['ok'] ?? false) && ($out['status'] ?? '') === 'issued') { + $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' => '가상계좌 처리에 실패했습니다.', + ]); + } + + + // 가상계좌 NOTIURL (반드시 OK) + public function vactNoti(Request $request) + { + $this->service->handleVactNoti($request->all()); + return response('OK', 200)->header('Content-Type', 'text/plain'); + } + + // 휴대폰 TargetURL + public function phoneReturn(Request $request) + { + $out = $this->service->handlePhoneReturn($request->all()); + + 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' => '결제에 실패했습니다.', + ]); + } + + // 휴대폰 BackURL(취소) + public function phoneCancel(Request $request) + { + $out = $this->service->handlePhoneCancel($request->all()); + + return view('web.payments.danal.finish_top_action', [ + 'action' => 'close_modal', + 'message' => '결제가 취소되었습니다.', + 'title' => '결제취소', + ]); + } + + // 카드/가상계좌 CancelURL + public function cancel(Request $request) + { + $token = (string)$request->query('a', ''); + if ($token === '') abort(404); + + $out = $this->service->handleCancel($token); + + // ✅ 취소면: iframe 닫고 showMsg 실행 + if (($out['meta']['code'] ?? '') === 'CANCEL') { + return view('web.payments.danal.finish_top_action', [ + 'action' => 'close_modal', + 'message' => '결제가 취소되었습니다.', + 'title' => '결제취소', + ]); + } + + return view('web.payments.danal.result', $out); + } + + private function currentMemNo(Request $request): int + { + // 프로젝트에 맞게 연결해라: + // 1) Auth::user()->mem_no 가 있으면 그걸 쓰고, + // 2) local 테스트용은 request('mem_no') 허용 + $u = $request->user(); + if ($u && isset($u->mem_no)) return (int)$u->mem_no; + + if (app()->environment('local')) { + return (int)$request->input('mem_no', 0); + } + + return 0; + } +} diff --git a/app/Http/Controllers/Web/Payment/DanalDemoController.php b/app/Http/Controllers/Web/Payment/DanalDemoController.php new file mode 100644 index 0000000..8c0a1c6 --- /dev/null +++ b/app/Http/Controllers/Web/Payment/DanalDemoController.php @@ -0,0 +1,41 @@ +environment('local'), 404); + return view('web.payments.danal.demo'); + } + + public function submit(Request $request) + { + abort_unless(app()->environment('local'), 404); + + $data = $request->validate([ + 'mem_no' => ['required','integer','min:1'], + 'amount' => ['required','integer','min:100'], + 'method' => ['required','in:card,vact,phone'], + 'card_kind' => ['nullable','in:general,exchange'], + 'phone_mode' => ['nullable','in:prod,dev'], + 'is_mobile' => ['nullable','boolean'], + ]); + + $order = $this->checkout->createDemoOrder( + (int)$data['mem_no'], + (int)$data['amount'] + ); + + return redirect()->route('web.payments.danal.demo.form')->with('oid', $order->oid)->with('data', $data); + } +} diff --git a/app/Jobs/Payments/ArchivePaymentsJob.php b/app/Jobs/Payments/ArchivePaymentsJob.php new file mode 100644 index 0000000..b516f3f --- /dev/null +++ b/app/Jobs/Payments/ArchivePaymentsJob.php @@ -0,0 +1,52 @@ + $batchId, + 'days' => $this->days, + 'timeoutMin' => $this->timeoutMin, + 'timeoutArchiveMin' => $this->timeoutArchiveMin, + 'batchSize' => $this->batch, + ]); + + $result = $svc->run($this->days, $this->timeoutMin, $this->timeoutArchiveMin, $this->batch, $batchId); + + Log::info('[ArchivePaymentsJob] end', ['batch' => $batchId] + $result); + } +} diff --git a/app/Models/Payments/GcPaymentAttempt.php b/app/Models/Payments/GcPaymentAttempt.php new file mode 100644 index 0000000..9756fe9 --- /dev/null +++ b/app/Models/Payments/GcPaymentAttempt.php @@ -0,0 +1,21 @@ + 'array', + 'response_payload' => 'array', + 'return_payload' => 'array', + 'noti_payload' => 'array', + 'ready_at' => 'datetime', + 'redirected_at' => 'datetime', + 'returned_at' => 'datetime', + 'noti_at' => 'datetime', + ]; +} diff --git a/app/Models/Payments/GcPinOrder.php b/app/Models/Payments/GcPinOrder.php new file mode 100644 index 0000000..35f94b2 --- /dev/null +++ b/app/Models/Payments/GcPinOrder.php @@ -0,0 +1,31 @@ + 'array', + 'ret_data' => 'array', + 'ordered_at' => 'datetime', + 'paid_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + + public function items(): HasMany + { + return $this->hasMany(GcPinOrderItem::class, 'order_id'); + } +} diff --git a/app/Models/Payments/GcPinOrderItem.php b/app/Models/Payments/GcPinOrderItem.php new file mode 100644 index 0000000..3405772 --- /dev/null +++ b/app/Models/Payments/GcPinOrderItem.php @@ -0,0 +1,21 @@ + 'array', + ]; +} diff --git a/app/Providers/Danal/Clients/DanalCpcgiClient.php b/app/Providers/Danal/Clients/DanalCpcgiClient.php new file mode 100644 index 0000000..b87390b --- /dev/null +++ b/app/Providers/Danal/Clients/DanalCpcgiClient.php @@ -0,0 +1,67 @@ + NVP + */ + public function call(string $url, string $outerCpid, array $reqData, string $hexKey, string $hexIv): array + { + $sendData = []; + foreach ($reqData as $k => $v) { + $sendData[$k] = is_string($v) ? $this->euc->toEuc($v) : $v; + } + + $plain = $this->nvp->build($sendData); + $enc = $this->aes->encrypt($plain, $hexKey, $hexIv); + + $payload = 'CPID=' . $outerCpid . '&DATA=' . urlencode($enc); + + $httpOpt = config('danal.http'); + $res = Http::withOptions([ + 'verify' => (bool)($httpOpt['verify_ssl'] ?? true), + 'connect_timeout' => (float)($httpOpt['connect_timeout'] ?? 5), + ]) + ->withHeaders([ + 'Content-Type' => 'application/x-www-form-urlencoded; charset=euc-kr', + ]) + ->timeout((int)($httpOpt['timeout'] ?? 30)) + ->withBody($payload, 'application/x-www-form-urlencoded; charset=euc-kr') + ->post($url); + + $body = (string)$res->body(); + + $map = $this->nvp->parse($body); + + // 응답이 DATA를 포함하면 decrypt 후 재파싱 + if (isset($map['DATA']) && is_string($map['DATA']) && $map['DATA'] !== '') { + $dec = $this->aes->decrypt($map['DATA'], $hexKey, $hexIv); + $map = $this->nvp->parse($dec); + } + + // 메시지는 EUC-KR일 수 있으니 UTF-8 변환 + return $this->euc->mapToUtf8($map); + } + + public function decryptReturnParams(string $returnParams, string $hexKey, string $hexIv): array + { + $dec = $this->aes->decrypt($returnParams, $hexKey, $hexIv); + $map = $this->nvp->parse($dec); + return $this->euc->mapToUtf8($map); + } +} diff --git a/app/Providers/Danal/DanalConfig.php b/app/Providers/Danal/DanalConfig.php new file mode 100644 index 0000000..3359bd3 --- /dev/null +++ b/app/Providers/Danal/DanalConfig.php @@ -0,0 +1,53 @@ + $base['bin_path'], + 'item_code' => $base['item_code'], + 'start_url_web' => $base['start_url_web'], + 'start_url_mobile' => $base['start_url_mobile'], + 'cpid' => $m['cpid'], + 'pwd' => $m['pwd'], + 'iv' => $m['iv'] ?? '', + 'mode' => $mode, + ]; + } + + public function http(): array + { + return config('danal.http'); + } +} diff --git a/app/Providers/Danal/Gateways/CardGateway.php b/app/Providers/Danal/Gateways/CardGateway.php new file mode 100644 index 0000000..0a7bfe0 --- /dev/null +++ b/app/Providers/Danal/Gateways/CardGateway.php @@ -0,0 +1,100 @@ +cfg->card($cardKind); + + $userAgent = $isMobile ? 'WM' : 'PC'; + + $returnUrl = route('web.payments.danal.card.return', ['a' => $attemptToken], true); + $cancelUrl = route('web.payments.danal.cancel', ['a' => $attemptToken], true); + + // BYPASSVALUE: '&' 금지. 토큰만 넣고 나머지는 서버에서 검증. + $req = [ + 'SUBCPID' => '', + 'AMOUNT' => (string)$order->pay_money, + 'CURRENCY' => '410', + 'ITEMNAME' => $this->safeItemName($this->orderTitle($order)), + 'USERAGENT' => $userAgent, + 'ORDERID' => $order->oid, + 'OFFERPERIOD' => '', + + 'USERNAME' => '', + 'USERID' => (string)$order->mem_no, + 'USEREMAIL' => '', + + 'CANCELURL' => $cancelUrl, + 'RETURNURL' => $returnUrl, + + 'TXTYPE' => 'AUTH', + 'SERVICETYPE' => 'DANALCARD', + 'ISNOTI' => 'N', + 'BYPASSVALUE' => 'AT=' . $attemptToken, + ]; + + $res = $this->client->call($c['url'], $c['cpid'], $req, $c['key'], $c['iv']); + + return [ + 'req' => $req, + 'res' => $res, + 'start' => [ + 'actionUrl' => (string)($res['STARTURL'] ?? ''), + 'params' => ['STARTPARAMS' => (string)($res['STARTPARAMS'] ?? '')], + 'acceptCharset' => 'EUC-KR', + ], + ]; + } + + public function bill(GcPinOrder $order, string $cardKind, string $tid): array + { + $c = $this->cfg->card($cardKind); + + $req = [ + 'TID' => $tid, + 'AMOUNT' => (string)$order->pay_money, + 'TXTYPE' => 'BILL', + 'SERVICETYPE' => 'DANALCARD', + ]; + + $res = $this->client->call($c['url'], $c['cpid'], $req, $c['key'], $c['iv']); + + return ['req' => $req, 'res' => $res]; + } + + public function decryptReturn(string $cardKind, string $returnParams): array + { + $c = $this->cfg->card($cardKind); + return $this->client->decryptReturnParams($returnParams, $c['key'], $c['iv']); + } + + private function orderTitle(GcPinOrder $order): string + { + // 아이템 1개면 그 이름, 아니면 "상품권 외 N건" + $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/Providers/Danal/Gateways/PhoneGateway.php b/app/Providers/Danal/Gateways/PhoneGateway.php new file mode 100644 index 0000000..b059875 --- /dev/null +++ b/app/Providers/Danal/Gateways/PhoneGateway.php @@ -0,0 +1,261 @@ +cfg->phone($mode); + + $binPath = rtrim($c['bin_path'], '/'); + $itemCode = (string)$c['item_code']; + + // ITEMSEND2 + $trans = [ + 'Command' => 'ITEMSEND2', + 'SERVICE' => 'TELEDIT', + 'ItemCount' => '1', + 'OUTPUTOPTION' => 'DEFAULT', + + 'ID' => $c['cpid'], + 'PWD' => $c['pwd'], + + 'SUBCP' => '', + 'USERID' => (string)$order->mem_no, + 'ORDERID' => (string)$order->oid, + 'IsPreOtbill' => 'N', + 'IsSubscript' => 'N', + 'ItemInfo' => $this->makeItemInfo((int)$order->pay_money, $itemCode, $this->orderTitle($order)), + ]; + + $res = $this->runSClient($binPath, $trans); + + if (($res['Result'] ?? '') !== '0') { + $msg = (string)($res['ErrMsg'] ?? '휴대폰 결제 모듈 실패'); + return [ + 'req' => $trans, + 'res' => $res, + 'start' => null, + 'error' => ['code' => (string)($res['Result'] ?? 'E_TELEDIT'), 'msg' => $msg], + ]; + } + + // RETURN 시 검증용(oid/amount/mem_no/token/mode) + $orderPayload = Crypt::encryptString($order->oid . '/' . $order->pay_money . '/' . $order->mem_no . '/' . $attemptToken . '/' . $mode); + + $byPass = [ + 'BgColor' => '00', + 'TargetURL' => route('web.payments.danal.phone.return', [], true), + 'BackURL' => route('web.payments.danal.phone.cancel', [], true), + 'IsUseCI' => 'N', + 'CIURL' => $extra['ci_url'] ?? url('/img/main/top_logo.png'), + 'Email' => $extra['email'] ?? '', + 'IsCharSet' => '', + 'Order' => $orderPayload, + ]; + + $actionUrl = $isMobile ? $c['start_url_mobile'] : $c['start_url_web']; + + $params = []; + foreach ($res as $k => $v) { + if ($k === 'Result' || $k === 'ErrMsg') continue; + $params[$k] = $v; + } + foreach ($byPass as $k => $v) $params[$k] = $v; + + // 폼 히든 추가(필요시) + $params['CPName'] = $extra['cp_name'] ?? '핀포유'; + $params['ItemName'] = $this->orderTitle($order); + $params['ItemAmt'] = (string)$order->pay_money; + $params['IsCarrier'] = (string)$extra['member_cell_corp']; + $params['IsDstAddr'] = (string)$extra['member_phone_enc']; + + logger()->info('danal_phone_mode_config', [ + 'params' => $extra, + 'default_mode' => config('danal.phone.default_mode'), + ]); + + return [ + 'req' => $trans, + 'res' => $res, + 'start' => [ + 'actionUrl' => $actionUrl, + 'params' => $params, + 'acceptCharset' => 'EUC-KR', + ], + ]; + } + + public function confirmAndBill(array $post, callable $onPaid): array + { + $serverInfo = (string)($post['ServerInfo'] ?? ''); + $encOrder = (string)($post['Order'] ?? ''); + + if ($serverInfo === '' || $encOrder === '') { + return ['ok' => false, 'code' => 'E_PHONE_PARAM', 'msg' => 'ServerInfo/Order 누락', 'payload' => ['post'=>$post]]; + } + + [$oid, $amount, $memNo, $token, $mode] = $this->decryptOrder($encOrder); + $c = $this->cfg->phone($mode); + $binPath = rtrim($c['bin_path'], '/'); + + // NCONFIRM + $nConfirm = [ + 'Command' => 'NCONFIRM', + 'OUTPUTOPTION' => 'DEFAULT', + 'ServerInfo' => $serverInfo, + 'IFVERSION' => 'V1.1.2', + 'ConfirmOption' => '0', + ]; + $res1 = $this->runSClient($binPath, $nConfirm); + if (($res1['Result'] ?? '') !== '0') { + return ['ok' => false, 'code' => (string)($res1['Result'] ?? 'E_NCONFIRM'), 'msg' => (string)($res1['ErrMsg'] ?? 'NCONFIRM 실패'), 'payload'=>['post'=>$post,'res1'=>$res1]]; + } + + // NBILL + $nBill = [ + 'Command' => 'NBILL', + 'OUTPUTOPTION' => 'DEFAULT', + 'ServerInfo' => $serverInfo, + 'IFVERSION' => 'V1.1.2', + 'BillOption' => '0', + ]; + $res2 = $this->runSClient($binPath, $nBill); + if (($res2['Result'] ?? '') !== '0') { + return ['ok' => false, 'code' => (string)($res2['Result'] ?? 'E_NBILL'), 'msg' => (string)($res2['ErrMsg'] ?? 'NBILL 실패'), 'payload'=>['post'=>$post,'res1'=>$res1,'res2'=>$res2]]; + } + + $tid = (string)($res1['TID'] ?? ''); + $paidAmount = (int)($res1['AMOUNT'] ?? $amount); + + $payload = ['post'=>$post,'nconfirm'=>$res1,'nbill'=>$res2,'meta'=>['oid'=>$oid,'mem_no'=>$memNo,'token'=>$token,'mode'=>$mode]]; + + $onPaid($oid, $token, $tid, $paidAmount, $payload); + + return ['ok'=>true, 'oid'=>$oid, 'token'=>$token, 'tid'=>$tid, 'amount'=>$paidAmount, 'payload'=>$payload]; + } + + 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] ?? ''); + $mode = (string)($parts[4] ?? 'prod'); + + if ($oid === '' || $amount <= 0 || $memNo === '' || $token === '') { + throw new \RuntimeException('Invalid phone Order payload'); + } + if ($mode !== 'prod' && $mode !== 'dev') $mode = 'prod'; + + return [$oid, $amount, $memNo, $token, $mode]; + } + + public function cancel(array $post, callable $onCancel): array + { + $encOrder = (string)($post['Order'] ?? ''); + if ($encOrder === '') return ['ok'=>false, 'code'=>'E_CANCEL', 'msg'=>'Order 누락', 'payload'=>['post'=>$post]]; + + [$oid, $amount, $memNo, $token, $mode] = $this->decryptOrder($encOrder); + $payload = ['post'=>$post,'meta'=>compact('oid','amount','memNo','token','mode')]; + + $onCancel($oid, $token, $payload); + + return ['ok'=>true, 'oid'=>$oid, 'token'=>$token, 'payload'=>$payload]; + } + + private function runSClient(string $binPath, array $params): array + { + $arg = $this->makeParam($params); + $proc = new Process([$binPath . '/SClient', $arg]); + $proc->setTimeout((int)config('danal.http.timeout', 30)); + $proc->run(); + + $out = $proc->getOutput(); + if ($out === '' && !$proc->isSuccessful()) { + $err = $proc->getErrorOutput(); + throw new \RuntimeException("Teledit SClient failed: {$err}"); + } + + return $this->parseOutput($out); + } + + private function parseOutput(string $out): array + { + $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] ?? ''); + if ($name === '') continue; + + $value = trim($tmp[1] ?? ''); + $value = urldecode($value); + + // 이미 UTF-8이면 그대로 두고, 아니면 EUC-KR -> UTF-8 변환 + if (!function_exists('mb_check_encoding') || !mb_check_encoding($value, 'UTF-8')) { + $value = @iconv('EUC-KR', 'UTF-8//IGNORE', $value) ?: $value; + // 마지막 안전망(깨진 UTF-8 제거) + if (function_exists('mb_check_encoding') && !mb_check_encoding($value, 'UTF-8')) { + $value = @iconv('UTF-8', 'UTF-8//IGNORE', $value) ?: ''; + } + } + + $map[$name] = $value; + } + + return $map; + } + + + private function makeParam(array $arr): string + { + $parts = []; + foreach ($arr as $k => $v) { + $sv = (string)$v; + // SClient 전송용만 EUC-KR + $sv = @iconv('UTF-8', 'EUC-KR//IGNORE', $sv) ?: $sv; + $parts[] = $k . '=' . $sv; + } + return implode(';', $parts); + } + private function makeItemInfo(int $amt, string $code, string $name): string + { + $name = $this->safeItemName($name); + return substr($code, 0, 1) . '|' . $amt . '|1|' . $code . '|' . $name; + } + + 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); // teledit 금칙 최소 + $s = str_replace(["&","\"","\\","<",">","," , "+"], " ", $s); + return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권'; + } +} diff --git a/app/Providers/Danal/Gateways/VactGateway.php b/app/Providers/Danal/Gateways/VactGateway.php new file mode 100644 index 0000000..4ad8c26 --- /dev/null +++ b/app/Providers/Danal/Gateways/VactGateway.php @@ -0,0 +1,102 @@ +cfg->vact(); + + $userAgent = $isMobile ? 'MW' : 'PC'; + + $returnUrl = route('web.payments.danal.vact.return', ['a' => $attemptToken], true); + $cancelUrl = route('web.payments.danal.cancel', ['a' => $attemptToken], true); + $notiUrl = route('web.payments.danal.vact.noti', [], true); + + $req = [ + 'CPID' => (string)$c['cpid'], + 'SUBCPID' => '', + 'ACCOUNTHOLDER' => (string)$c['holder'], + 'EXPIREDATE' => now()->addHours(23)->format('Ymd'), + + 'ORDERID' => $order->oid, + 'ITEMNAME' => $this->safeItemName($this->orderTitle($order)), + 'AMOUNT' => (string)$order->pay_money, + 'ISCASHRECEIPTUI' => 'N', + + 'USERNAME' => '', + 'USERID' => (string)$order->mem_no, + 'USEREMAIL' => '', + 'USERPHONE' => '', + 'USERAGENT' => $userAgent, + + 'TXTYPE' => 'AUTH', + 'SERVICETYPE' => 'DANALVACCOUNT', + + 'RETURNURL' => $returnUrl, + 'NOTIURL' => $notiUrl, + 'CANCELURL' => $cancelUrl, + 'BYPASSVALUE' => 'AT=' . $attemptToken, + ]; + + $res = $this->client->call($c['url'], $c['cpid'], $req, $c['key'], $c['iv']); + + return [ + 'req' => $req, + 'res' => $res, + 'start' => [ + 'actionUrl' => (string)($res['STARTURL'] ?? ''), + 'params' => ['STARTPARAMS' => (string)($res['STARTPARAMS'] ?? '')], + 'acceptCharset' => 'EUC-KR', + ], + ]; + } + + public function decryptReturn(string $returnParams): array + { + $c = $this->cfg->vact(); + return $this->client->decryptReturnParams($returnParams, $c['key'], $c['iv']); + } + + public function issue(GcPinOrder $order, string $tid): array + { + $c = $this->cfg->vact(); + + $req = [ + 'CPID' => (string)$c['cpid'], + 'TID' => $tid, + 'AMOUNT' => (string)$order->pay_money, + 'TXTYPE' => 'ISSUEVACCOUNT', + 'SERVICETYPE' => 'DANALVACCOUNT', + ]; + + $res = $this->client->call($c['url'], $c['cpid'], $req, $c['key'], $c['iv']); + return ['req' => $req, 'res' => $res]; + } + + 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 ee47a03..a70b223 100644 --- a/app/Repositories/Member/MemInfoRepository.php +++ b/app/Repositories/Member/MemInfoRepository.php @@ -18,4 +18,12 @@ class MemInfoRepository return strcasecmp((string)$mem->email, $email) === 0; } + + public function findForPhonePay(int $memNo): ?MemInfo + { + return MemInfo::query() + ->select(['mem_no', 'cell_phone', 'cell_corp']) // 컬럼명은 너 DB에 맞춰 + ->where('mem_no', $memNo) + ->first(); + } } diff --git a/app/Repositories/Payments/GcPaymentAttemptRepository.php b/app/Repositories/Payments/GcPaymentAttemptRepository.php new file mode 100644 index 0000000..3baafbb --- /dev/null +++ b/app/Repositories/Payments/GcPaymentAttemptRepository.php @@ -0,0 +1,145 @@ +where('provider', $data['provider']) + ->where('oid', $data['oid']) + ->where('pay_method', $data['pay_method']) + ->lockForUpdate(); + + $row = $q->first(); + + if (!$row) { + $row = new GcPaymentAttempt(); + $row->provider = $data['provider']; + $row->oid = $data['oid']; + $row->mem_no = $data['mem_no']; + $row->order_id = $data['order_id'] ?? null; + $row->pay_method = $data['pay_method']; + $row->amount = $data['amount']; + $row->currency = $data['currency'] ?? 'KRW'; + $row->ready_at = now(); + } + + $row->status = 'ready'; + $row->token_hash = $data['token_hash']; + $row->card_kind = $data['card_kind'] ?? null; + $row->vact_kind = $data['vact_kind'] ?? null; + + $row->user_agent = $data['user_agent'] ?? null; + $row->user_ip = $data['user_ip'] ?? null; + + $row->save(); + return $row; + } + + public function findByTokenForUpdate(string $method, string $token): ?GcPaymentAttempt + { + $hash = hash('sha256', $token); + + return GcPaymentAttempt::query() + ->where('provider', 'danal') + ->where('pay_method', $method) + ->where('token_hash', $hash) + ->lockForUpdate() + ->first(); + } + + public function markRedirected(GcPaymentAttempt $a, array $req, array $res): void + { + $a->status = 'redirected'; + $a->redirected_at = now(); + $a->request_payload = $this->jsonSafe($req); + $a->response_payload = $this->jsonSafe($res); + $a->save(); + } + + public function markReturned(GcPaymentAttempt $a, array $payload, ?string $tid, string $code, string $msg, string $status): void + { + // status: auth_ok/issued/paid/failed/cancelled + $a->status = $status; + $a->returned_at = now(); + $a->pg_tid = $tid ?: $a->pg_tid; + $a->return_code = $code ?: $a->return_code; + $a->return_msg = $msg ?: $a->return_msg; + $a->return_payload = $this->jsonSafe($payload); + $a->save(); + } + + public function markNotiPaid(GcPaymentAttempt $a, array $payload, string $tid, int $amount): void + { + if ($a->status === 'paid') return; + $a->status = 'paid'; + $a->pg_tid = $tid ?: $a->pg_tid; + $a->amount = $amount ?: $a->amount; + $a->noti_payload = $this->jsonSafe($payload); + $a->noti_at = now(); + $a->save(); + } + + public function markCancelled(GcPaymentAttempt $a, array $payload = []): void + { + if ($a->status === 'paid') return; + $a->status = 'cancelled'; + $a->returned_at = now(); + if ($payload) $a->return_payload = $this->jsonSafe($payload); + $a->save(); + } + + public function markFailed(GcPaymentAttempt $a, string $code, string $msg, array $payload = []): void + { + if ($a->status === 'paid') return; + $a->status = 'failed'; + $a->returned_at = now(); + $a->return_code = $code; + $a->return_msg = $msg; + if ($payload) $a->return_payload = $this->jsonSafe($payload); + $a->save(); + } + + private function jsonSafe(mixed $v): mixed + { + if (is_array($v)) { + foreach ($v as $k => $vv) $v[$k] = $this->jsonSafe($vv); + return $v; + } + + if (is_object($v)) { + return $this->jsonSafe((array)$v); + } + + if (is_string($v)) { + // 이미 UTF-8이면 그대로 + if (function_exists('mb_check_encoding') && mb_check_encoding($v, 'UTF-8')) return $v; + + // 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) ?: ''; + } + return $out; + } + + return $v; + } + + public function findAnyByTokenForUpdate(string $token): ?GcPaymentAttempt + { + $hash = hash('sha256', $token); + + return GcPaymentAttempt::query() + ->where('provider', 'danal') + ->where('token_hash', $hash) + ->lockForUpdate() + ->first(); + } +} diff --git a/app/Repositories/Payments/GcPaymentMethodRepository.php b/app/Repositories/Payments/GcPaymentMethodRepository.php new file mode 100644 index 0000000..bacea3e --- /dev/null +++ b/app/Repositories/Payments/GcPaymentMethodRepository.php @@ -0,0 +1,18 @@ +where('id', $id) + ->where('is_active', 1) + ->first(); + + return $row ?: null; + } +} diff --git a/app/Repositories/Payments/GcPinOrderRepository.php b/app/Repositories/Payments/GcPinOrderRepository.php new file mode 100644 index 0000000..b2c8cf7 --- /dev/null +++ b/app/Repositories/Payments/GcPinOrderRepository.php @@ -0,0 +1,101 @@ +where('oid', $oid) + ->lockForUpdate() + ->first(); + } + + public function findByOid(string $oid): ?GcPinOrder + { + return GcPinOrder::query()->where('oid', $oid)->first(); + } + + public function markMethod(GcPinOrder $order, string $method): void + { + $order->pay_method = $method; + $order->save(); + } + + public function markVactIssued(GcPinOrder $order, string $tid, array $issued): void + { + if ($order->stat_pay === 'p') return; // 이미 paid면 무시(멱등) + $order->stat_pay = 'w'; + $order->pg_tid = $tid ?: $order->pg_tid; + $order->ret_code = (string)($issued['RETURNCODE'] ?? '0000'); + $order->ret_msg = (string)($issued['RETURNMSG'] ?? ''); + $order->pay_data = $this->jsonSafe(array_merge($order->pay_data ?? [], ['vact'=>$issued])); + $order->ret_data = $this->jsonSafe(array_merge($order->ret_data ?? [], ['vact_issue'=>$issued])); + $order->save(); + } + + public function markPaid(GcPinOrder $order, string $tid, string $code, string $msg, array $payload): void + { + if ($order->stat_pay === 'p') return; // 멱등 + + $order->stat_pay = 'p'; + $order->paid_at = now(); + $order->pg_tid = $tid ?: $order->pg_tid; + $order->ret_code = $code; + $order->ret_msg = $msg; + + $payload = $this->jsonSafe($payload); + $merged = array_merge($order->ret_data ?? [], $payload); + $order->ret_data = $this->jsonSafe($merged); + + $order->save(); + } + + public function markCancelled(GcPinOrder $order, string $code, string $msg, array $payload = []): void + { + if ($order->stat_pay === 'p') return; + $order->stat_pay = 'c'; + $order->cancelled_at = now(); + $order->ret_code = $code; + $order->ret_msg = $msg; + $payload = $this->jsonSafe($payload); + $order->ret_data = $this->jsonSafe(array_merge($order->ret_data ?? [], $payload)); + + $order->save(); + } + + public function markFailed(GcPinOrder $order, string $code, string $msg, array $payload = []): void + { + if ($order->stat_pay === 'p') return; + $order->stat_pay = 'f'; + $order->ret_code = $code; + $order->ret_msg = $msg; + $payload = $this->jsonSafe($payload); + $order->ret_data = $this->jsonSafe(array_merge($order->ret_data ?? [], $payload)); + $order->save(); + } + + private function jsonSafe(mixed $v): mixed + { + if (is_array($v)) { + foreach ($v as $k => $vv) $v[$k] = $this->jsonSafe($vv); + return $v; + } + if (is_object($v)) return $this->jsonSafe((array)$v); + + if (is_string($v)) { + if (function_exists('mb_check_encoding') && mb_check_encoding($v, 'UTF-8')) return $v; + $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) ?: ''; + } + return $out; + } + return $v; + } +} diff --git a/app/Repositories/Product/ProductRepository.php b/app/Repositories/Product/ProductRepository.php index b1a47a3..f6bbf15 100644 --- a/app/Repositories/Product/ProductRepository.php +++ b/app/Repositories/Product/ProductRepository.php @@ -91,7 +91,8 @@ class ProductRepository return DB::table('gc_product_skus') ->where('product_id', $productId) ->where('is_active', 1) - ->orderBy('sort_order', 'asc') + //->orderBy('sort_order', 'asc') + ->orderBy('face_value', 'asc') ->get(); } @@ -108,4 +109,25 @@ class ProductRepository ->orderBy('sort_order', 'asc') ->get(); } + + public function getActiveSkuWithProduct(int $skuId) + { + return DB::table('gc_product_skus as s') + ->join('gc_products as p', 'p.id', '=', 's.product_id') + ->select( + 's.*', + 'p.id as product_id', + 'p.name as product_name', + 'p.allowed_payments', + 'p.min_buy_qty', + 'p.max_buy_qty', + 'p.max_buy_amount', + 'p.status as product_status' + ) + ->where('s.id', $skuId) + ->where('s.is_active', 1) + ->where('p.status', 'ACTIVE') + ->first(); + } + } diff --git a/app/Services/Order/OrderCheckoutService.php b/app/Services/Order/OrderCheckoutService.php new file mode 100644 index 0000000..08a87b5 --- /dev/null +++ b/app/Services/Order/OrderCheckoutService.php @@ -0,0 +1,247 @@ +products->getActiveSkuWithProduct($skuId); + if (!$sku) return $this->fail('SKU_NOT_FOUND', '권종 정보를 찾을 수 없습니다.'); + + // allowed_payments 체크 + $allowed = json_decode($sku->allowed_payments ?? '[]', true); + $allowed = is_array($allowed) ? array_map('intval', $allowed) : []; + if (!in_array((int)$payId, $allowed, true)) { + return $this->fail('PAY_NOT_ALLOWED', '선택한 결제수단은 사용 불가합니다.'); + } + + // qty 정책 + $minQty = (int)($sku->min_buy_qty ?? 1); + $maxQty = (int)($sku->max_buy_qty ?? 0); + $maxAmount = (int)($sku->max_buy_amount ?? 0); + + if ($qty < $minQty) $qty = $minQty; + if ($maxQty > 0 && $qty > $maxQty) { + return $this->fail('QTY_MAX', '최대 구매 수량을 초과했습니다.'); + } + + $unit = (int)($sku->final_price ?? 0); + if ($unit <= 0) return $this->fail('PRICE_INVALID', '판매가 정보가 올바르지 않습니다.'); + + $subtotal = $unit * $qty; + if ($maxAmount > 0 && $subtotal > $maxAmount) { + return $this->fail('AMOUNT_MAX', '1회 최대 결제 금액을 초과했습니다.'); + } + + $pm = $this->payMethods->findActiveById($payId); + if (!$pm) return $this->fail('PAY_NOT_FOUND', '결제수단 정보를 찾을 수 없습니다.'); + + // 수수료(정수연산, 2자리 소수율) + $customerFee = $this->calcFee($subtotal, (string)$pm->customer_fee_rate); + $payMoney = $subtotal + $customerFee; + $pgFee = $this->calcFee($payMoney, (string)$pm->pg_fee_rate); + + // 주문 생성 + $oid = 'GC' . now()->format('YmdHis') . Str::upper(Str::random(6)); + + $order = GcPinOrder::create([ + 'oid' => $oid, + 'mem_no' => $memNo, + 'order_type' => 'self', + 'stat_pay' => 'ready', + 'stat_tax' => 'taxfree', + + 'subtotal_amount' => $subtotal, + 'fee_amount' => $customerFee, + 'pg_fee_amount' => $pgFee, + 'discount_amount' => 0, + 'pay_money' => $payMoney, + + 'provider' => 'danal', + 'ordered_at' => now(), + + 'pay_data' => [ + 'payment_method' => [ + 'id' => (int)$pm->id, + 'code' => (string)$pm->code, + 'name' => (string)$pm->name, + 'display_name' => (string)$pm->display_name, + 'customer_fee_rate' => (string)$pm->customer_fee_rate, + 'pg_fee_rate' => (string)$pm->pg_fee_rate, + ], + 'calc' => [ + 'unit_price' => $unit, + 'qty' => $qty, + 'subtotal' => $subtotal, + 'customer_fee' => $customerFee, + 'pay_money' => $payMoney, + 'pg_fee' => $pgFee, + ], + ], + ]); + + GcPinOrderItem::create([ + 'order_id' => $order->id, + 'tbl_pointer' => null, + 'pin_seq' => null, + 'item_name' => (string)$sku->name, + 'item_code' => (string)($sku->sku_code ?? $sku->id), + 'qty' => $qty, + 'unit_price' => (int)($sku->face_value ?? $unit), + 'unit_pay_price' => $unit, + 'line_subtotal' => $subtotal, + 'line_fee' => $customerFee, + 'line_total' => $payMoney, + 'meta' => [ + 'product_id' => (int)$sku->product_id, + 'product_name' => (string)$sku->product_name, + 'sku_id' => (int)$sku->id, + ], + ]); + + // pay_id -> 결제모듈 옵션 매핑 + [$method, $opt] = $this->mapPayMethod((string)$pm->code, $request); + + + if ($method === 'phone') { + $m = $this->members->findForPhonePay($memNo) ?? $this->members->findByMemNo($memNo); + if (!$m) { + return [ + 'ok' => false, + 'message' => '회원 정보를 찾을 수 없습니다.', + 'redirect' => url('/'), + ]; + } + + $phoneEnc = (string)($m->cell_phone ?? ''); // 복호화 가능한 암호문 컬럼 + $corpCode = (string)($m->cell_corp ?? ''); // 01~06 + + if ($phoneEnc === '') { + return [ + 'ok' => false, + 'message' => '휴대폰 번호가 등록되어 있지 않습니다. 휴대폰 번호 등록 후 이용해 주세요.', + 'redirect' => url('/mypage/info'), + ]; + } + + $seed = app(CiSeedCrypto::class); + $phonePlain = (string)$seed->decrypt($phoneEnc); + $digits = preg_replace('/\D+/', '', $phonePlain); + + if ($digits === '' || strlen($digits) < 10 || strlen($digits) > 11) { + return [ + 'ok' => false, + 'message' => '회원 휴대폰 번호 정보가 올바르지 않습니다. 고객센터에 문의해 주세요.', + 'redirect' => url('/'), + ]; + } + + $map = [ + '01' => 'SKT', '02' => 'KTF', '03' => 'LGT', + '04' => 'MVNO','05' => 'MVNO','06' => 'MVNO', + ]; + + $corpCode = str_pad(preg_replace('/\D+/', '', $corpCode), 2, '0', STR_PAD_LEFT); + $carrier = $map[$corpCode] ?? ''; + + if ($carrier === '') { + return [ + 'ok' => false, + 'message' => '회원 통신사 정보가 올바르지 않습니다. 고객센터에 문의해 주세요.', + 'redirect' => url('/'), + ]; + } + + + + $opt['member_phone_digits'] = $digits; + $opt['member_carrier'] = $carrier; + } + + + // 결제 시작 (PG redirect 정보 생성) + $out = $this->payment->start($order->oid, $memNo, $method, $opt); + + if (($out['type'] ?? '') === 'redirect') { + return [ + 'ok' => true, + 'view' => 'autosubmit', + 'action' => $out['start']['actionUrl'], + 'fields' => $out['start']['params'], + 'acceptCharset' => $out['start']['acceptCharset'] ?? 'EUC-KR', + + 'meta' => $out['meta'] ?? [], + 'token' => $out['meta']['token'] ?? '', + 'oid' => $out['meta']['oid'] ?? $order->oid, + 'method' => $out['meta']['method'] ?? $method, + ]; + } + + // 실패 + return [ + 'ok' => false, + 'code' => $out['meta']['code'] ?? 'PG_FAIL', + 'message' => $out['message'] ?? '결제 시작 실패', + 'redirect' => url("/product/detail/" . (int)$sku->product_id), + ]; + }); + } + + private function mapPayMethod(string $code, Request $request): array + { + $ua = strtolower((string)$request->userAgent()); + $isMobile = str_contains($ua, 'mobile') || str_contains($ua, 'android') || str_contains($ua, 'iphone'); + + // 휴대폰 dev/prod 분리: 운영=prod, 그 외=dev 기본 + $phoneMode = (string)config('danal.phone.default_mode', 'prod'); + + // 개발환경에서만 ?phone_mode=dev|prod 로 override 허용(원하면) + if (!app()->environment('production')) { + $q = (string)$request->query('phone_mode', ''); + if ($q === 'prod' || $q === 'dev') $phoneMode = $q; + } + + return match ($code) { + 'MOBILE' => ['phone', ['phone_mode'=>$phoneMode, 'is_mobile'=>$isMobile]], + 'CREDIT_CARD' => ['card', ['card_kind'=>'general', 'is_mobile'=>$isMobile]], + 'CREDIT_CARD_REFUND' => ['card', ['card_kind'=>'exchange', 'is_mobile'=>$isMobile]], + 'VACT' => ['vact', ['is_mobile'=>$isMobile]], + default => ['card', ['card_kind'=>'general', 'is_mobile'=>$isMobile]], + }; + } + + // rateStr: "5.00" (%), 2자리 소수 -> 정수 계산 + private function calcFee(int $amount, string $rateStr): int + { + $rate = (float)$rateStr; // 5.00 + $bp = (int) round($rate * 100); // 500 (basis points of percent) + // amount * bp / 10000 + return (int) ceil(($amount * $bp) / 10000); + } + + private function fail(string $code, string $message): array + { + return ['ok'=>false, 'code'=>$code, 'message'=>$message]; + } +} diff --git a/app/Services/Payments/CheckoutService.php b/app/Services/Payments/CheckoutService.php new file mode 100644 index 0000000..b6e013a --- /dev/null +++ b/app/Services/Payments/CheckoutService.php @@ -0,0 +1,47 @@ +format('YmdHis') . Str::upper(Str::random(6)); + + $order = GcPinOrder::create([ + 'oid' => $oid, + 'mem_no' => $memNo, + 'stat_pay' => 'ready', + 'stat_tax' => 'taxfree', + 'subtotal_amount' => $amount, + 'fee_amount' => 0, + 'pg_fee_amount' => 0, + 'discount_amount' => 0, + 'pay_money' => $amount, + 'provider' => 'danal', + 'ordered_at' => now(), + ]); + + GcPinOrderItem::create([ + 'order_id' => $order->id, + 'item_name' => '테스트 상품권', + 'item_code' => 'TEST', + 'qty' => 1, + 'unit_price' => $amount, + 'unit_pay_price' => $amount, + 'line_subtotal' => $amount, + 'line_fee' => 0, + 'line_total' => $amount, + ]); + + return $order; + }); + } +} diff --git a/app/Services/Payments/Danal/Clients/DanalTeleditClient.php b/app/Services/Payments/Danal/Clients/DanalTeleditClient.php new file mode 100644 index 0000000..ef5b759 --- /dev/null +++ b/app/Services/Payments/Danal/Clients/DanalTeleditClient.php @@ -0,0 +1,308 @@ + 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 new file mode 100644 index 0000000..64a9082 --- /dev/null +++ b/app/Services/Payments/Danal/DanalPaymentService.php @@ -0,0 +1,278 @@ +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 new file mode 100644 index 0000000..bd935f0 --- /dev/null +++ b/app/Services/Payments/Danal/Gateways/DanalCardGateway.php @@ -0,0 +1,243 @@ + 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 new file mode 100644 index 0000000..5f61ebf --- /dev/null +++ b/app/Services/Payments/Danal/Gateways/DanalVactGateway.php @@ -0,0 +1,277 @@ + 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/PaymentArchiveService.php b/app/Services/Payments/PaymentArchiveService.php new file mode 100644 index 0000000..96e59db --- /dev/null +++ b/app/Services/Payments/PaymentArchiveService.php @@ -0,0 +1,141 @@ +subMinutes(30)->toDateTimeString(); + + $affectedTimeout = DB::update(" + UPDATE gc_payment_attempts a + JOIN gc_pin_order o ON o.oid = a.oid + SET + a.status='cancelled', + a.return_code='TIMEOUT', + a.return_msg='결제시간초과', + a.returned_at=NOW(), + a.updated_at=NOW(), + o.stat_pay='c', + o.ret_code='TIMEOUT', + o.ret_msg='결제시간초과', + o.cancelled_at=NOW(), + o.updated_at=NOW() + WHERE + a.status='redirected' + AND a.redirected_at IS NOT NULL + AND a.redirected_at < ? + AND o.stat_pay='ready' + ", [$cutoffRedirect]); + + // 2) 아카이브 대상 attempt 뽑기 + $rows = DB::select(" + SELECT a.id, a.oid + FROM gc_payment_attempts a + WHERE + a.status IN ('cancelled','failed') + OR ( + a.status = 'redirected' + AND a.redirected_at IS NOT NULL + AND a.redirected_at < ? + ) + ORDER BY a.id + LIMIT {$batch} + ", [$cutoffRedirect]); + + if (!$rows) { + return [ + 'timeout_updated' => $affectedTimeout, + 'archived_attempts' => 0, + 'archived_orders' => 0, + 'deleted_orders' => 0, + ]; + } + + $ids = array_map(fn($r) => (int)$r->id, $rows); + $oids = array_values(array_unique(array_map(fn($r) => (string)$r->oid, $rows))); + + $placeIds = implode(',', array_fill(0, count($ids), '?')); + $placeOids = implode(',', array_fill(0, count($oids), '?')); + + // 3) attempts → attempts_bak + DB::insert(" + INSERT IGNORE INTO gc_payment_attempts_bak + SELECT + a.*, + NOW(), + ?, + CASE WHEN a.return_code='TIMEOUT' THEN 'timeout' ELSE 'old' END + FROM gc_payment_attempts a + WHERE a.id IN ($placeIds) + ", array_merge([$batchId], $ids)); + + // 4) orders → orders_bak (oid 기준) + DB::insert(" + INSERT IGNORE INTO gc_pin_order_bak + SELECT + o.*, + NOW(), + ?, + 'linked' + FROM gc_pin_order o + WHERE o.oid IN ($placeOids) + ", array_merge([$batchId], $oids)); + + // 5) items → items_bak (order_id 기준, oid로 join) + DB::insert(" + INSERT IGNORE INTO gc_pin_order_items_bak + SELECT + i.*, + NOW(), + ?, + 'linked' + FROM gc_pin_order_items i + JOIN gc_pin_order o ON o.id = i.order_id + WHERE o.oid IN ($placeOids) + ", array_merge([$batchId], $oids)); + + // 6) 원본 attempts 삭제 + DB::delete("DELETE FROM gc_payment_attempts WHERE id IN ($placeIds)", $ids); + + // 7) 원본 order/items 삭제는 “해당 oid에 남은 attempt가 없을 때만” 삭제(중요) + $orderRows = DB::select(" + SELECT o.id + FROM gc_pin_order o + WHERE o.oid IN ($placeOids) + AND NOT EXISTS (SELECT 1 FROM gc_payment_attempts a WHERE a.oid=o.oid) + ", $oids); + + $orderIds = array_map(fn($r) => (int)$r->id, $orderRows); + + $deletedOrders = 0; + if ($orderIds) { + $placeOrderIds = implode(',', array_fill(0, count($orderIds), '?')); + DB::delete("DELETE FROM gc_pin_order_items WHERE order_id IN ($placeOrderIds)", $orderIds); + $deletedOrders = DB::delete("DELETE FROM gc_pin_order WHERE id IN ($placeOrderIds)", $orderIds); + } + + Log::info('[payments:archive] done', [ + 'batch' => $batchId, + 'timeout_updated' => $affectedTimeout, + 'attempts' => count($ids), + 'oids' => count($oids), + 'deleted_orders' => $deletedOrders, + ]); + + return [ + 'timeout_updated' => $affectedTimeout, + 'archived_attempts' => count($ids), + 'archived_orders' => count($oids), + 'deleted_orders' => $deletedOrders, + ]; + }); + } +} diff --git a/app/Services/Payments/PaymentService.php b/app/Services/Payments/PaymentService.php new file mode 100644 index 0000000..f3c9c9b --- /dev/null +++ b/app/Services/Payments/PaymentService.php @@ -0,0 +1,408 @@ +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 ''; + } +} diff --git a/app/Support/Danal/DanalAes256CbcHex.php b/app/Support/Danal/DanalAes256CbcHex.php new file mode 100644 index 0000000..a0ae22b --- /dev/null +++ b/app/Support/Danal/DanalAes256CbcHex.php @@ -0,0 +1,26 @@ + UTF-8 변환(실패해도 빈문자/대체로 처리) + $out = @iconv('EUC-KR', 'UTF-8//IGNORE', $s); + + // iconv가 false면 mb_convert로 한번 더(가능할 때) + if ($out === false) { + $out = function_exists('mb_convert_encoding') + ? @mb_convert_encoding($s, 'UTF-8', 'EUC-KR') + : ''; + } + + // 그래도 UTF-8이 아니면 마지막으로 invalid byte 제거 + if ($out === false || $out === '') $out = ''; + if (function_exists('mb_check_encoding') && !mb_check_encoding($out, 'UTF-8')) { + $out = @iconv('UTF-8', 'UTF-8//IGNORE', $out) ?: ''; + } + + return $out; + } + + + public function mapToUtf8(array $arr): array + { + foreach ($arr as $k => $v) { + if (is_string($v) && $v !== '') { + $arr[$k] = $this->toUtf8($v); + } + } + return $arr; + } +} diff --git a/app/Support/Danal/Nvp.php b/app/Support/Danal/Nvp.php new file mode 100644 index 0000000..67d4624 --- /dev/null +++ b/app/Support/Danal/Nvp.php @@ -0,0 +1,25 @@ + $v) { + $pairs[] = $k . '=' . urlencode((string)$v); + } + return implode('&', $pairs); + } + + public function parse(string $str): array + { + $out = []; + foreach (explode('&', $str) as $tok) { + $kv = explode('=', $tok, 2); + if (count($kv) === 2) $out[$kv[0]] = urldecode($kv[1]); + } + return $out; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 75e950b..d16d9e2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -27,8 +27,14 @@ return Application::configure(basePath: dirname(__DIR__)) // ✅ CSRF 예외 처리 $middleware->validateCsrfTokens(except: [ - 'auth/register/danal/result', - 'mypage/info/danal/result', + 'auth/register/danal/result', #다날인증 + 'mypage/info/danal/result', #다날인증 + 'pay/danal/card/return', #다날카드 결제 + 'pay/danal/vact/return', #다날가상계좌 + 'pay/danal/vact/noti', #다날가상계좌 + 'pay/danal/phone/return', #다날휴대폰 결제 + 'pay/danal/phone/cancel', #다날휴대폰 결제취소 + 'pay/danal/cancel', ]); // ✅ alias 등록 diff --git a/config/danal.php b/config/danal.php index 208abe5..fb812cb 100644 --- a/config/danal.php +++ b/config/danal.php @@ -12,4 +12,57 @@ return [ 'cpid' => env('DANAL_AUTHTEL_CPID', ''), 'cppwd' => env('DANAL_AUTHTEL_CPPWD', ''), ], + // Card CPCGI + 'card' => [ + 'general' => [ + 'cpid' => env('DANAL_CARD_GENERAL_CPID', ''), + 'key' => env('DANAL_CARD_GENERAL_KEY', ''), + 'iv' => env('DANAL_CARD_GENERAL_IV', ''), + 'url' => env('DANAL_CARD_GENERAL_URL', 'https://tx-creditcard.danalpay.com/credit/'), + ], + 'exchange' => [ + 'cpid' => env('DANAL_CARD_EXCHANGE_CPID', ''), + 'key' => env('DANAL_CARD_EXCHANGE_KEY', ''), + 'iv' => env('DANAL_CARD_EXCHANGE_IV', ''), + 'url' => env('DANAL_CARD_EXCHANGE_URL', 'https://tx-creditcard.danalpay.com/credit/'), + ], + ], + + // VAccount CPCGI + 'vact' => [ + 'cpid' => env('DANAL_VACT_CPID', ''), + 'key' => env('DANAL_VACT_KEY', ''), + 'iv' => env('DANAL_VACT_IV', ''), + 'url' => env('DANAL_VACT_URL', 'https://tx-vaccount.danalpay.com/vaccount/'), + 'holder' => env('DANAL_VACT_ACCOUNT_HOLDER_NAME', '핀포유'), + ], + + // Phone(Teledit) - SClient + 'phone' => [ + 'default_mode' => env('DANAL_PHONE_DEFAULT_MODE', 'prod'), + + 'bin_path' => env('DANAL_PHONE_BIN_PATH', ''), + 'item_code' => env('DANAL_PHONE_ITEMCODE', ''), + 'start_url_web' => env('DANAL_PHONE_WEB_START_URL', 'https://ui.teledit.com/Danal/Teledit/Web/Start.php'), + 'start_url_mobile' => env('DANAL_PHONE_MOBILE_START_URL', 'https://ui.teledit.com/Danal/Teledit/Mobile/Start.php'), + + // 리얼/테스트 분리 + 'prod' => [ + 'cpid' => env('DANAL_PHONE_PROD_CPID', ''), + 'pwd' => env('DANAL_PHONE_PROD_PWD', ''), + 'iv' => env('DANAL_PHONE_PROD_IV', ''), + ], + 'dev' => [ + 'cpid' => env('DANAL_PHONE_DEV_CPID', ''), + 'pwd' => env('DANAL_PHONE_DEV_PWD', ''), + 'iv' => env('DANAL_PHONE_DEV_IV', ''), + ], + ], + + // 공통 네트워크 옵션 + 'http' => [ + 'connect_timeout' => (int)env('DANAL_CONNECT_TIMEOUT', 5), + 'timeout' => (int)env('DANAL_TIMEOUT', 30), + 'verify_ssl' => (bool)env('DANAL_VERIFY_SSL', true), + ], ]; diff --git a/resources/views/web/payments/danal/demo.blade.php b/resources/views/web/payments/danal/demo.blade.php new file mode 100644 index 0000000..00a34fc --- /dev/null +++ b/resources/views/web/payments/danal/demo.blade.php @@ -0,0 +1,65 @@ + + +
+ +{{ $errors }}
+@endif
+
+
diff --git a/resources/views/web/payments/danal/finish_top_action.blade.php b/resources/views/web/payments/danal/finish_top_action.blade.php
new file mode 100644
index 0000000..a3b4db9
--- /dev/null
+++ b/resources/views/web/payments/danal/finish_top_action.blade.php
@@ -0,0 +1,43 @@
+
+
+
+
+
+ 결제 페이지로 이동 중입니다…
+ + + + + + diff --git a/resources/views/web/payments/danal/result.blade.php b/resources/views/web/payments/danal/result.blade.php new file mode 100644 index 0000000..aa87b50 --- /dev/null +++ b/resources/views/web/payments/danal/result.blade.php @@ -0,0 +1,26 @@ + + + + +{{ $message }}
+ +@if(!empty($meta)) +{{ json_encode($meta, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT) }}
+@endif
+
+{{ json_encode($meta, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_INVALID_UTF8_SUBSTITUTE) }}
+
+홈으로
+
+
diff --git a/resources/views/web/payments/danal_autosubmit.blade.php b/resources/views/web/payments/danal_autosubmit.blade.php
new file mode 100644
index 0000000..60cc3ce
--- /dev/null
+++ b/resources/views/web/payments/danal_autosubmit.blade.php
@@ -0,0 +1,36 @@
+
+
+
+
+
+