diff --git a/app/Http/Controllers/Web/Mypage/UsageController.php b/app/Http/Controllers/Web/Mypage/UsageController.php index f17e9a5..a665b51 100644 --- a/app/Http/Controllers/Web/Mypage/UsageController.php +++ b/app/Http/Controllers/Web/Mypage/UsageController.php @@ -4,14 +4,65 @@ namespace App\Http\Controllers\Web\Mypage; use App\Http\Controllers\Controller; use App\Services\Mypage\UsageService; +use App\Services\Payments\OwnPinIssueService; use Illuminate\Http\Request; final class UsageController extends Controller { public function __construct( private readonly UsageService $service, + private readonly OwnPinIssueService $ownPinIssueService, ) {} + public function issuePinInstant(Request $request, int $attemptId) + { + if ((bool)session('_sess._login_') !== true) { + return redirect()->route('web.auth.login'); + } + + $memNo = (int)session('_sess._mno', 0); + if ($memNo <= 0) abort(403); + + $out = $this->ownPinIssueService->issuePinInstant($attemptId, $memNo); + + if (!($out['ok'] ?? false)) { + return redirect()->back()->with('error', (string)($out['message'] ?? '발행 실패')); + } + + return redirect()->route('web.mypage.usage.show', ['attemptId' => $attemptId]) + ->with('success', (string)($out['message'] ?? '발행 완료')); + } + + public function revealPins(Request $request, int $attemptId) + { + if ((bool)session('_sess._login_') !== true) { + return redirect()->route('web.auth.login'); + } + + $memNo = (int)session('_sess._mno', 0); + if ($memNo <= 0) abort(403); + + $data = $request->validate([ + 'pin2' => ['required', 'string', 'min:4', 'max:20'], + ]); + + $out = $this->service->revealPins( + $attemptId, + $memNo, + (string)$data['pin2'] + ); + + if (!($out['ok'] ?? false)) { + return redirect() + ->route('web.mypage.usage.show', ['attemptId' => $attemptId]) + ->with('error', (string)($out['message'] ?? '핀번호 확인에 실패했습니다.')); + } + + return redirect() + ->route('web.mypage.usage.show', ['attemptId' => $attemptId, 'revealed' => 1]) + ->with('success', '핀번호 확인이 완료되었습니다.'); + } + /** * GET /mypage/usage * - 리스트 + 검색 + 페이징 diff --git a/app/Repositories/Mypage/UsageRepository.php b/app/Repositories/Mypage/UsageRepository.php index abad402..2af8701 100644 --- a/app/Repositories/Mypage/UsageRepository.php +++ b/app/Repositories/Mypage/UsageRepository.php @@ -37,13 +37,14 @@ final class UsageRepository return $out; } - private function decodeJsonArray($v): array + private function decodeJsonArray($json): array { - if ($v === null) return []; - if (is_array($v)) return $v; - $s = trim((string)$v); - if ($s === '') return []; - $decoded = json_decode($s, true); + if (is_array($json)) return $json; + + $json = trim((string)$json); + if ($json === '') return []; + + $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } @@ -119,17 +120,23 @@ final class UsageRepository $from = trim((string)($filters['from'] ?? '')); $to = trim((string)($filters['to'] ?? '')); - // order_items 집계 서브쿼리 (group by를 메인 쿼리에서 피해서 paginate 안정) + // 주문아이템 집계 $oiAgg = DB::table('gc_pin_order_items') ->selectRaw('order_id, SUM(qty) as total_qty, MIN(item_name) as first_item_name') ->groupBy('order_id'); + // 발행 집계 + $issueAgg = DB::table('gc_pin_issues') + ->selectRaw('order_id, COUNT(*) as issued_count') + ->groupBy('order_id'); + $qb = DB::table('gc_payment_attempts as a') ->leftJoin('gc_pin_order as o', 'o.id', '=', 'a.order_id') ->leftJoinSub($oiAgg, 'oi', 'oi.order_id', '=', 'o.id') + ->leftJoinSub($issueAgg, 'gi', 'gi.order_id', '=', 'o.id') ->where('a.mem_no', $memNo) - // ✅ 중요: OR 조건 전체를 반드시 하나의 where 그룹으로 묶어야 함 + // 리스트 기본 노출 대상 ->where(function ($s) { // 1) 취소완료 $s->where(function ($x) { @@ -152,6 +159,7 @@ final class UsageRepository ->select([ 'a.id as attempt_id', + 'o.id as order_id', 'o.oid as order_oid', DB::raw("COALESCE(o.products_name, oi.first_item_name) as product_name"), 'oi.first_item_name as item_name', @@ -162,11 +170,12 @@ final class UsageRepository 'o.stat_pay as order_stat_pay', 'a.cancel_status as attempt_cancel_status', 'o.cancel_status as order_cancel_status', + DB::raw("COALESCE(gi.issued_count, 0) as issued_count"), 'a.created_at as created_at', ]) ->orderByDesc('a.id'); - // ✅ q 검색: 거래번호(o.oid / a.oid) 정확히 일치 + // 주문번호 검색 if ($q !== '') { $qb->where(function ($w) use ($q) { $w->where('o.oid', $q) @@ -179,31 +188,63 @@ final class UsageRepository $qb->where('a.pay_method', $method); } - // ✅ 상태 필터도 화면 의미에 맞게 묶어서 처리 - // (현재 화면 기준: paid / issued / cancel를 의미 상태로 쓰는 경우) + /** + * 상태 필터는 화면 의미 기준 + * + * pay : 입금/결제 진행 상태 + * issue_wait : 결제완료 + 발행대기 + * issue_done : 결제완료 + 발행완료 + * cancelled : 결제취소 + * failed : 결제실패 + * + * 하위호환: + * paid : issue_wait + issue_done + * issued : pay + * cancel : cancelled + * canceled : cancelled + */ if ($status !== '') { - if ($status === 'paid') { - $qb->where(function ($x) { - $x->where('a.status', 'paid') - ->orWhere('o.stat_pay', 'p'); - }); - } elseif ($status === 'issued') { - $qb->where(function ($x) { - $x->where('a.status', 'issued') - ->orWhere('o.stat_pay', 'w'); - }); - } elseif (in_array($status, ['cancel', 'cancelled', 'canceled'], true)) { + if (in_array($status, ['cancel', 'cancelled', 'canceled'], true)) { $qb->where(function ($x) { $x->where('a.cancel_status', 'success') ->orWhere('o.cancel_status', 'success'); }); + } elseif ($status === 'issue_done') { + $qb->where(function ($x) { + $x->where('a.status', 'paid') + ->orWhere('o.stat_pay', 'p'); + }); + $qb->whereRaw('COALESCE(gi.issued_count, 0) > 0'); + } elseif ($status === 'issue_wait') { + $qb->where(function ($x) { + $x->where('a.status', 'paid') + ->orWhere('o.stat_pay', 'p'); + }); + $qb->whereRaw('COALESCE(gi.issued_count, 0) = 0'); + } elseif ($status === 'pay' || $status === 'issued') { + $qb->where(function ($x) { + $x->where('a.status', 'issued') + ->orWhere('o.stat_pay', 'w') + ->orWhere('a.status', 'ready') + ->orWhere('a.status', 'redirected'); + }); + } elseif ($status === 'paid') { + // 하위호환: 결제완료 전체(발행대기+발행완료) + $qb->where(function ($x) { + $x->where('a.status', 'paid') + ->orWhere('o.stat_pay', 'p'); + }); + } elseif ($status === 'failed') { + $qb->where(function ($x) { + $x->where('a.status', 'failed') + ->orWhere('o.stat_pay', 'f'); + }); } else { - // 기타 상태는 attempts 기준으로 그대로 $qb->where('a.status', $status); } } - // 날짜 필터 (date 기준) + // 날짜 필터 if ($from !== '') { $qb->whereDate('a.created_at', '>=', $from); } @@ -268,4 +309,81 @@ final class UsageRepository return array_map(fn($r) => (array)$r, $rows->all()); } + + public function getIssuesForOrder(int $orderId): array + { + $rows = DB::table('gc_pin_issues') + ->where('order_id', $orderId) + ->orderBy('id', 'asc') + ->get(); + + return array_map(function ($r) { + $arr = (array)$r; + $arr['pins_json_decoded'] = $this->decodeJsonArray($arr['pins_json'] ?? null); + $arr['issue_logs_json_decoded'] = $this->decodeJsonArray($arr['issue_logs_json'] ?? null); + return $arr; + }, $rows->all()); + } + + public function getIssuesForOrderForUpdate(int $orderId): array + { + $rows = DB::table('gc_pin_issues') + ->where('order_id', $orderId) + ->orderBy('id', 'asc') + ->lockForUpdate() + ->get(); + + return array_map(fn ($r) => (array)$r, $rows->all()); + } + + /** + * 핀번호 확인 / SMS / 출금완료 등 강한 잠금 + */ + public function hasLockedOrOpenedIssues(int $orderId): bool + { + return DB::table('gc_pin_issues') + ->where('order_id', $orderId) + ->where(function ($q) { + $q->whereNotNull('opened_at') + ->orWhereNotNull('sms_sent_at') + ->orWhereNotNull('payout_done_at') + ->orWhere('cancel_status', 'LOCKED'); + }) + ->exists(); + } + + + public function hasAnyIssuedIssues(int $orderId): bool + { + return DB::table('gc_pin_issues') + ->where('order_id', $orderId) + ->where(function ($q) { + $q->whereIn('issue_status', ['PROCESSING', 'ISSUED']) + ->orWhere('pin_count', '>', 0) + ->orWhereNotNull('issued_at'); + }) + ->exists(); + } + + public function markIssueOpened(int $issueId, string $openedAt, array $logs, ?string $reason = null): void + { + DB::table('gc_pin_issues') + ->where('id', $issueId) + ->update([ + 'opened_at' => $openedAt, + 'cancel_status' => 'LOCKED', + 'cancel_locked_reason' => $reason ?? '핀번호 확인 완료', + 'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE), + 'updated_at' => $openedAt, + ]); + } + + public function getSkuForOrderItem(int $productId, int $skuId): ?object + { + return DB::table('gc_product_skus') + ->where('product_id', $productId) + ->where('id', $skuId) + ->where('is_active', 1) + ->first(); + } } diff --git a/app/Repositories/Payments/GcPinIssueRepository.php b/app/Repositories/Payments/GcPinIssueRepository.php new file mode 100644 index 0000000..97a58f5 --- /dev/null +++ b/app/Repositories/Payments/GcPinIssueRepository.php @@ -0,0 +1,43 @@ +where('order_item_id', $orderItemId) + ->first(); + } + + public function findByOrderId(int $orderId): array + { + $rows = DB::table('gc_pin_issues') + ->where('order_id', $orderId) + ->orderBy('id', 'asc') + ->get(); + + return array_map(fn ($r) => (array)$r, $rows->all()); + } + + public function insert(array $data): int + { + return (int) DB::table('gc_pin_issues')->insertGetId($data); + } + + public function markOpened(int $id, string $now, array $logs, ?string $reason = null): void + { + DB::table('gc_pin_issues') + ->where('id', $id) + ->update([ + 'opened_at' => $now, + 'cancel_status' => 'LOCKED', + 'cancel_locked_reason' => $reason ?? '핀 오픈 완료', + 'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE), + 'updated_at' => $now, + ]); + } +} diff --git a/app/Repositories/Payments/GcPinsRepository.php b/app/Repositories/Payments/GcPinsRepository.php new file mode 100644 index 0000000..e880dbc --- /dev/null +++ b/app/Repositories/Payments/GcPinsRepository.php @@ -0,0 +1,36 @@ +where('product_id', $productId) + ->where('sku_id', $skuId) + ->where('status', 'AVAILABLE') + ->orderBy('id', 'asc') + ->lockForUpdate() + ->limit($qty) + ->get(); + + return array_map(fn ($r) => (array)$r, $rows->all()); + } + + public function updateStatusByIds(array $ids, string $status, array $extra = []): void + { + if (empty($ids)) return; + + $payload = array_merge([ + 'status' => $status, + 'updated_at' => now(), + ], $extra); + + DB::table('gc_pins') + ->whereIn('id', $ids) + ->update($payload); + } +} diff --git a/app/Services/Mypage/UsageService.php b/app/Services/Mypage/UsageService.php index 46d7114..1ceabab 100644 --- a/app/Services/Mypage/UsageService.php +++ b/app/Services/Mypage/UsageService.php @@ -4,7 +4,9 @@ namespace App\Services\Mypage; use App\Repositories\Mypage\UsageRepository; use App\Repositories\Payments\GcPinOrderRepository; +use App\Repositories\Member\MemberAuthRepository; use App\Services\Payments\PaymentCancelService; +use Illuminate\Support\Facades\DB; final class UsageService { @@ -12,6 +14,7 @@ final class UsageService private readonly UsageRepository $repo, private readonly GcPinOrderRepository $orders, private readonly PaymentCancelService $cancelSvc, + private readonly MemberAuthRepository $memberAuthRepo, ) {} /** @@ -19,7 +22,7 @@ final class UsageService */ public function buildListPageData(int $sessionMemNo, array $filters): array { - $rows = $this->repo->paginateAttemptsWithOrder($sessionMemNo, $filters, 5); + $rows = $this->repo->paginateAttemptsWithOrder($sessionMemNo, $filters, 15); return [ 'pageTitle' => '구매내역', @@ -43,81 +46,215 @@ final class UsageService $order = (array)($data['order'] ?? []); $orderId = (int)($order['id'] ?? 0); - $pins = $this->repo->getPinsForOrder($orderId); + $issues = $this->repo->getIssuesForOrder($orderId); $cancelLogs = $this->repo->getCancelLogsForAttempt($attemptId, 20); - $retData = $order['ret_data'] ?? null; - $retArr = is_array($retData) ? $retData : []; - $pinsOpened = !empty($retArr['pin_opened_at']); + $pins = []; + foreach ($issues as $issue) { + $issue = (array)$issue; + + $orderItemId = (int)($issue['order_item_id'] ?? 0); + $pinsJson = $issue['pins_json_decoded'] ?? null; + + if (!is_array($pinsJson)) { + $pinsJson = $this->decodeJsonArray($issue['pins_json'] ?? null); + } + + foreach ($pinsJson as $pin) { + $pin = (array)$pin; + + $pins[] = [ + 'id' => (int)($pin['gc_pin_id'] ?? 0), + 'issue_id' => (int)($issue['id'] ?? 0), + 'order_item_id' => $orderItemId, + 'status' => (string)($issue['issue_status'] ?? ''), + 'pin_mask' => (string)($pin['pin_mask'] ?? ''), + 'pin' => $this->decryptIssuedPin( + (string)($pin['pin_enc'] ?? ''), + (int)($order['mem_no'] ?? 0), + (string)($order['oid'] ?? ''), + $orderItemId, + (int)($pin['seq'] ?? 0) + ), + 'face_value' => (int)($pin['face_value'] ?? 0), + 'issued_at' => (string)($pin['issued_at'] ?? ''), + ]; + } + } + + // 이번 요청에서만 실핀 표시 + $pinsRevealed = ( + (int)request()->query('revealed', 0) === 1 + || (int)session('pin_revealed_attempt_id', 0) === $attemptId + ); // 결제완료 조건 $attempt = (array)($data['attempt'] ?? []); $isPaid = (($order['stat_pay'] ?? '') === 'p') || (($attempt['status'] ?? '') === 'paid'); - // cancel_status 기반 버튼 제어 - $aCancel = (string)($attempt['cancel_status'] ?? 'none'); - $canCancel = $isPaid && !$pinsOpened && in_array($aCancel, ['none','failed'], true); + // 핀 발행 완료되면 회원 취소 불가 + $hasIssuedIssues = !empty($issues); + $aCancel = (string)($attempt['cancel_status'] ?? 'none'); + $canCancel = $isPaid + && !$hasIssuedIssues + && in_array($aCancel, ['none', 'failed'], true); + + $data['issues'] = $issues; $data['pins'] = $pins; - $data['pinsOpened'] = $pinsOpened; + $data['pinsRevealed'] = $pinsRevealed; + $data['hasIssuedIssues'] = $hasIssuedIssues; $data['canCancel'] = $canCancel; $data['cancelLogs'] = $cancelLogs; return $data; } + private function decodeJsonArray($json): array + { + if (is_array($json)) { + return $json; + } + + $json = trim((string)$json); + if ($json === '') { + return []; + } + + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; + } + + private function decryptIssuedPin(string $enc, int $memNo, string $oid, int $orderItemId, int $seq): string + { + if (trim($enc) === '') { + return ''; + } + + $plain = \Illuminate\Support\Facades\Crypt::decryptString($enc); + + $suffix = '|M:' . $memNo . '|O:' . $oid . '|I:' . $orderItemId . '|S:' . $seq; + + if (str_ends_with($plain, $suffix)) { + return substr($plain, 0, -strlen($suffix)); + } + + return $plain; + } + /** * 핀 오픈(확인): ret_data에 pin_opened_at 기록 */ public function openPins(int $attemptId, int $sessionMemNo): array { - $row = $this->repo->findAttemptWithOrder($attemptId); - if (!$row) return ['ok'=>false, 'message'=>'결제내역을 찾을 수 없습니다.']; + return \Illuminate\Support\Facades\DB::transaction(function () use ($attemptId, $sessionMemNo) { + $row = $this->repo->findAttemptWithOrder($attemptId); + if (!$row) { + return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.']; + } - $attemptMem = (int)($row->attempt_mem_no ?? 0); - $orderMem = (int)($row->order_mem_no ?? 0); - if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) { - return ['ok'=>false, 'message'=>'권한이 없습니다.']; - } + $attemptMem = (int)($row->attempt_mem_no ?? 0); + $orderMem = (int)($row->order_mem_no ?? 0); - $attemptStatus = (string)($row->attempt_status ?? ''); - $orderStatPay = (string)($row->order_stat_pay ?? ''); - if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) { - return ['ok'=>false, 'message'=>'결제완료 상태에서만 핀 확인이 가능합니다.']; - } + if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) { + return ['ok' => false, 'message' => '권한이 없습니다.']; + } - $oid = (string)($row->order_oid ?? ''); - if ($oid === '') return ['ok'=>false, 'message'=>'주문정보가 올바르지 않습니다.']; + $attemptStatus = (string)($row->attempt_status ?? ''); + $orderStatPay = (string)($row->order_stat_pay ?? ''); - $order = $this->orders->findByOidForUpdate($oid); - if (!$order) return ['ok'=>false, 'message'=>'주문을 찾을 수 없습니다.']; + if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) { + return ['ok' => false, 'message' => '결제완료 상태에서만 핀 확인이 가능합니다.']; + } - $ret = (array)($order->ret_data ?? []); - if (!empty($ret['pin_opened_at'])) { - return ['ok'=>true]; - } + $orderId = (int)($row->order_id ?? 0); + if ($orderId <= 0) { + return ['ok' => false, 'message' => '주문정보가 올바르지 않습니다.']; + } - $ret['pin_opened_at'] = now()->toDateTimeString(); - $order->ret_data = $ret; - $order->save(); + // 발행건 잠금 조회 + $issues = $this->repo->getIssuesForOrderForUpdate($orderId); + if (empty($issues)) { + return ['ok' => false, 'message' => '발행된 핀이 없습니다.']; + } - return ['ok'=>true]; + // 실제 발행된 건만 대상 + $issuedIssues = array_values(array_filter($issues, function ($issue) { + $issue = (array)$issue; + + $status = (string)($issue['issue_status'] ?? ''); + $pinCnt = (int)($issue['pin_count'] ?? 0); + + return in_array($status, ['ISSUED', 'PROCESSING'], true) || $pinCnt > 0; + })); + + if (empty($issuedIssues)) { + return ['ok' => false, 'message' => '확인 가능한 핀이 없습니다.']; + } + + // 이미 모두 오픈된 상태면 성공 처리 + $allOpened = collect($issuedIssues)->every(function ($issue) { + $issue = (array)$issue; + return !empty($issue['opened_at']); + }); + + if ($allOpened) { + return ['ok' => true]; + } + + $now = now()->format('Y-m-d H:i:s'); + + foreach ($issuedIssues as $issue) { + $issue = (array)$issue; + + if (!empty($issue['opened_at'])) { + continue; + } + + $logs = $this->decodeJsonArray($issue['issue_logs_json'] ?? null); + $logs[] = [ + 'at' => $now, + 'type' => 'OPEN', + 'code' => 'PIN_OPENED', + 'msg' => '회원 웹페이지에서 핀 오픈', + ]; + + $this->repo->markIssueOpened( + (int)$issue['id'], + $now, + $logs, + '핀 오픈 완료' + ); + } + + return ['ok' => true]; + }); } + + /** * 결제완료 후 취소(핀 오픈 전만) */ public function cancelPaidAttempt(int $attemptId, int $sessionMemNo, string $reason): array { $data = $this->buildDetailPageData($attemptId, $sessionMemNo); + $order = (array)($data['order'] ?? []); + $orderId = (int)($order['id'] ?? 0); - $pinsOpened = (bool)($data['pinsOpened'] ?? false); + // 핀 발행이 완료되면 회원 취소 금지 + if ($orderId > 0 && $this->repo->hasAnyIssuedIssues($orderId)) { + return [ + 'ok' => false, + 'message' => '핀 발행이 완료된 주문은 회원이 직접 취소할 수 없습니다. 관리자에게 문의해 주세요.', + ]; + } return $this->cancelSvc->cancelByAttempt( $attemptId, ['type' => 'user', 'mem_no' => $sessionMemNo, 'id' => $sessionMemNo], $reason, - $pinsOpened + false ); } @@ -445,4 +582,47 @@ final class UsageService return $s; } + + public function revealPins(int $attemptId, int $sessionMemNo, string $pin2): array + { + $row = $this->repo->findAttemptWithOrder($attemptId); + if (!$row) { + return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.']; + } + + $attemptMem = (int)($row->attempt_mem_no ?? 0); + $orderMem = (int)($row->order_mem_no ?? 0); + + if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) { + return ['ok' => false, 'message' => '권한이 없습니다.']; + } + + $attemptStatus = (string)($row->attempt_status ?? ''); + $orderStatPay = (string)($row->order_stat_pay ?? ''); + + if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) { + return ['ok' => false, 'message' => '결제완료 상태에서만 핀번호 확인이 가능합니다.']; + } + + $pin2Ok = $this->memberAuthRepo->verifyPin2($sessionMemNo, $pin2); + if (!$pin2Ok) { + return ['ok' => false, 'message' => '2차 비밀번호가 올바르지 않습니다.']; + } + + $orderId = (int)($row->order_id ?? 0); + if ($orderId <= 0) { + return ['ok' => false, 'message' => '주문정보가 올바르지 않습니다.']; + } + + $issues = $this->repo->getIssuesForOrder($orderId); + if (empty($issues)) { + return ['ok' => false, 'message' => '발행된 핀이 없습니다.']; + } + + // 상태값 변경 없이 이번 요청에서만 실핀 표시 + session()->flash('pin_revealed_attempt_id', $attemptId); + + return ['ok' => true]; + } + } diff --git a/app/Services/Payments/OwnPinIssueService.php b/app/Services/Payments/OwnPinIssueService.php new file mode 100644 index 0000000..dfb6f17 --- /dev/null +++ b/app/Services/Payments/OwnPinIssueService.php @@ -0,0 +1,437 @@ +usageRepo->findAttemptWithOrder($attemptId); + if (!$row) { + return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.']; + } + + $attemptMem = (int)($row->attempt_mem_no ?? 0); + $orderMem = (int)($row->order_mem_no ?? 0); + + if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) { + return ['ok' => false, 'message' => '권한이 없습니다.']; + } + + $orderId = (int)($row->order_id ?? 0); + if ($orderId <= 0) { + return ['ok' => false, 'message' => '주문 정보가 없습니다.']; + } + + $isPaid = (($row->order_stat_pay ?? '') === 'p') || (($row->attempt_status ?? '') === 'paid'); + if (!$isPaid) { + return ['ok' => false, 'message' => '결제 완료 주문만 발행할 수 있습니다.']; + } + + $items = $this->usageRepo->getOrderItems($orderId); + if ($items->isEmpty()) { + return ['ok' => false, 'message' => '주문 아이템이 없습니다.']; + } + + $now = now()->format('Y-m-d H:i:s'); + $created = 0; + + foreach ($items as $item) { + $orderItemId = (int)($item->id ?? 0); + if ($orderItemId <= 0) { + return ['ok' => false, 'message' => '주문 아이템 정보가 올바르지 않습니다.']; + } + + // 이미 발행된 주문아이템이면 중복 방지 + $exists = $this->issueRepo->findByOrderItemId($orderItemId); + if ($exists) { + continue; + } + + $meta = $this->decodeJson((string)($item->meta ?? '')); + $productId = (int)($meta['product_id'] ?? 0); + + // 정책상 item_code = gc_product_skus.id + $skuId = (int)($item->item_code ?? ($meta['sku_id'] ?? 0)); + + if ($productId <= 0 || $skuId <= 0) { + return ['ok' => false, 'message' => '상품 정보(product_id/sku_id)가 올바르지 않습니다.']; + } + + $qty = (int)($item->qty ?? 0); + if ($qty <= 0) { + return ['ok' => false, 'message' => '주문 수량이 올바르지 않습니다.']; + } + + // 상품의 회원 발행 방식 허용 여부 확인 + $issueOptions = $this->usageRepo->getProductsIssueOptions([$productId]); + $methods = $issueOptions[$productId]['pin_check_methods'] ?? []; + if (!in_array('PIN_INSTANT', $methods, true)) { + return ['ok' => false, 'message' => '웹페이지 직접발행이 허용되지 않은 상품입니다.']; + } + + // SKU 조회 -> sales_method 분기 + $sku = $this->usageRepo->getSkuForOrderItem($productId, $skuId); + if (!$sku) { + return ['ok' => false, 'message' => 'SKU 정보를 찾을 수 없습니다.']; + } + + $salesMethod = (string)($sku->sales_method ?? 'OWN_PIN'); + $apiProviderId = (int)($sku->api_provider_id ?? 0); + $apiProductCode = trim((string)($sku->api_product_code ?? '')); + + if ($salesMethod === 'OWN_PIN') { + $this->issueOwnPinItem( + row: $row, + item: (array)$item, + attemptId: $attemptId, + orderId: $orderId, + orderItemId: $orderItemId, + productId: $productId, + skuId: $skuId, + qty: $qty, + now: $now + ); + + $created++; + continue; + } + + if ($salesMethod === 'API_LINK') { + $this->issueApiLinkPlaceholder( + row: $row, + item: (array)$item, + attemptId: $attemptId, + orderId: $orderId, + orderItemId: $orderItemId, + productId: $productId, + skuId: $skuId, + qty: $qty, + apiProviderId: $apiProviderId, + apiProductCode: $apiProductCode, + now: $now + ); + + return [ + 'ok' => false, + 'message' => '연동발행 상품입니다. 연동발행 구현 후 처리됩니다.', + ]; + } + + return ['ok' => false, 'message' => '알 수 없는 발행 방식입니다.']; + } + + if ($created <= 0) { + return ['ok' => true, 'message' => '이미 발행된 주문입니다.']; + } + + return ['ok' => true, 'message' => '자사핀 발행이 완료되었습니다.']; + }); + } catch (\Throwable $e) { + return [ + 'ok' => false, + 'message' => $e->getMessage() !== '' ? $e->getMessage() : '핀 발행 중 오류가 발생했습니다.', + ]; + } + } + + /** + * OWN_PIN 실제 발행 + */ + private function issueOwnPinItem( + object|array $row, + array $item, + int $attemptId, + int $orderId, + int $orderItemId, + int $productId, + int $skuId, + int $qty, + string $now, + ): void { + $row = (object)$row; + + $pins = $this->pinsRepo->lockAvailablePins($productId, $skuId, $qty); + if (count($pins) < $qty) { + throw new \Exception('죄송합니다 재고가 부족합니다. 관리자에게 문의해 주시면 바로 처리해 드리겠습니다.'); + } + + $pinIds = array_map(fn ($p) => (int)$p['id'], $pins); + + // 1차 선점 + $this->pinsRepo->updateStatusByIds($pinIds, 'HOLD', [ + 'order_id' => $orderId, + ]); + + $memNo = (int)($row->order_mem_no ?? 0); + $oid = (string)($row->order_oid ?? ''); + + $pinsJson = []; + foreach ($pins as $idx => $pin) { + $rawPin = $this->decryptSourcePin((string)($pin['pin_code'] ?? '')); + + $memberEnc = $this->encryptForIssue( + $rawPin, + $memNo, + $oid, + $orderItemId, + $idx + 1 + ); + + $pinsJson[] = [ + 'seq' => $idx + 1, + 'gc_pin_id' => (int)($pin['id'] ?? 0), + 'pin_enc' => $memberEnc, + 'pin_mask' => $this->maskPin($rawPin), + 'pin_hash' => hash('sha256', $rawPin), + 'face_value' => (int)($pin['face_value'] ?? 0), + 'issued_at' => $now, + ]; + } + + $logs = [[ + 'at' => $now, + 'type' => 'ISSUE', + 'code' => 'OWN_PIN_ISSUED', + 'msg' => '자사핀 발행 완료', + 'count' => count($pinsJson), + ]]; + + $this->issueRepo->insert([ + 'issue_no' => $this->makeIssueNo(), + 'order_id' => $orderId, + 'order_item_id' => $orderItemId, + 'oid' => $oid, + 'mem_no' => $memNo, + + 'product_id' => $productId, + 'sku_id' => $skuId, + 'item_name' => (string)($item['item_name'] ?? ''), + 'item_code' => (string)($item['item_code'] ?? ''), + 'qty' => $qty, + + 'unit_price' => (int)($item['unit_price'] ?? 0), + 'unit_pay_price' => (int)($item['unit_pay_price'] ?? 0), + 'line_subtotal' => (int)($item['line_subtotal'] ?? 0), + 'line_fee' => (int)($item['line_fee'] ?? 0), + 'line_total' => (int)($item['line_total'] ?? 0), + + 'supply_type' => 'OWN_PIN', + 'provider_code' => 'OWN', + 'provider_id' => null, + 'provider_product_code' => null, + + 'member_delivery_type' => 'PIN_INSTANT', + 'issue_status' => 'ISSUED', + + 'pin_count' => count($pinsJson), + 'pins_json' => json_encode($pinsJson, JSON_UNESCAPED_UNICODE), + 'pin_key_ver' => 1, + + 'issued_at' => $now, + 'opened_at' => null, + + 'sms_phone_enc' => null, + 'sms_phone_hash' => null, + 'sms_sent_at' => null, + 'sms_result_code' => null, + 'sms_result_msg' => null, + + 'payout_amount' => 0, + 'payout_fee_amount' => 0, + 'payout_status' => 'NONE', + 'payout_done_at' => null, + + 'cancel_status' => 'AVAILABLE', + 'cancel_locked_reason' => null, + 'cancelled_at' => null, + + 'provider_payload_json' => null, + 'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE), + 'meta' => json_encode([ + 'source' => 'OWN_PIN', + 'attempt_id' => $attemptId, + 'sales_method' => 'OWN_PIN', + ], JSON_UNESCAPED_UNICODE), + + 'created_at' => $now, + 'updated_at' => $now, + ]); + + // 최종 판매 처리 + $this->pinsRepo->updateStatusByIds($pinIds, 'SOLD', [ + 'sold_at' => $now, + ]); + } + + /** + * API_LINK 분기만 먼저 저장 + * 실제 연동 통신은 다음 단계에서 구현 + */ + private function issueApiLinkPlaceholder( + object|array $row, + array $item, + int $attemptId, + int $orderId, + int $orderItemId, + int $productId, + int $skuId, + int $qty, + int $apiProviderId, + string $apiProductCode, + string $now, + ): void { + $row = (object)$row; + + $logs = [[ + 'at' => $now, + 'type' => 'ISSUE', + 'code' => 'API_LINK_PENDING', + 'msg' => '연동발행 대상 상품 - 실제 연동 구현 전', + 'api_provider_id' => $apiProviderId, + 'api_product_code' => $apiProductCode, + ]]; + + $this->issueRepo->insert([ + 'issue_no' => $this->makeIssueNo(), + 'order_id' => $orderId, + 'order_item_id' => $orderItemId, + 'oid' => (string)($row->order_oid ?? ''), + 'mem_no' => (int)($row->order_mem_no ?? 0), + + 'product_id' => $productId, + 'sku_id' => $skuId, + 'item_name' => (string)($item['item_name'] ?? ''), + 'item_code' => (string)($item['item_code'] ?? ''), + 'qty' => $qty, + + 'unit_price' => (int)($item['unit_price'] ?? 0), + 'unit_pay_price' => (int)($item['unit_pay_price'] ?? 0), + 'line_subtotal' => (int)($item['line_subtotal'] ?? 0), + 'line_fee' => (int)($item['line_fee'] ?? 0), + 'line_total' => (int)($item['line_total'] ?? 0), + + 'supply_type' => 'API_LINK', + 'provider_code' => 'API_LINK', + 'provider_id' => $apiProviderId > 0 ? $apiProviderId : null, + 'provider_product_code' => $apiProductCode !== '' ? $apiProductCode : null, + + 'member_delivery_type' => 'PIN_INSTANT', + 'issue_status' => 'PROCESSING', + + 'pin_count' => 0, + 'pins_json' => null, + 'pin_key_ver' => 1, + + 'issued_at' => null, + 'opened_at' => null, + + 'sms_phone_enc' => null, + 'sms_phone_hash' => null, + 'sms_sent_at' => null, + 'sms_result_code' => null, + 'sms_result_msg' => null, + + 'payout_amount' => 0, + 'payout_fee_amount' => 0, + 'payout_status' => 'NONE', + 'payout_done_at' => null, + + 'cancel_status' => 'AVAILABLE', + 'cancel_locked_reason' => null, + 'cancelled_at' => null, + + 'provider_payload_json' => json_encode([ + 'api_provider_id' => $apiProviderId, + 'api_product_code' => $apiProductCode, + ], JSON_UNESCAPED_UNICODE), + 'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE), + 'meta' => json_encode([ + 'source' => 'API_LINK', + 'attempt_id' => $attemptId, + 'sales_method' => 'API_LINK', + ], JSON_UNESCAPED_UNICODE), + + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function decodeJson(string $json): array + { + $json = trim($json); + if ($json === '') { + return []; + } + + $arr = json_decode($json, true); + return is_array($arr) ? $arr : []; + } + + private function decryptSourcePin(string $enc): string + { + $enc = trim($enc); + if ($enc === '') { + throw new \RuntimeException('원본 핀 데이터가 비어 있습니다.'); + } + + return Crypt::decryptString($enc); + } + + private function maskPin(string $raw): string + { + $v = preg_replace('/\s+/', '', $raw); + $len = mb_strlen($v); + + if ($len <= 4) { + return str_repeat('*', $len); + } + + if ($len <= 8) { + return mb_substr($v, 0, 2) + . str_repeat('*', max(1, $len - 4)) + . mb_substr($v, -2); + } + + return mb_substr($v, 0, 4) + . str_repeat('*', max(4, $len - 6)) + . mb_substr($v, -2); + } + + /** + * 회원 발행용 암호화 + * 현재는 Crypt 기반으로 suffix를 붙이는 방식 + * 추후 HKDF 기반 파생키 구조로 교체 가능 + */ + private function encryptForIssue(string $rawPin, int $memNo, string $oid, int $orderItemId, int $seq): string + { + return Crypt::encryptString( + $rawPin . '|M:' . $memNo . '|O:' . $oid . '|I:' . $orderItemId . '|S:' . $seq + ); + } + + private function makeIssueNo(): string + { + return 'GI' . now()->format('YmdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 8)); + } +} diff --git a/config/cs_faq.php b/config/cs_faq.php index a728569..c81b0de 100644 --- a/config/cs_faq.php +++ b/config/cs_faq.php @@ -107,7 +107,7 @@ return [ [ 'category' => 'code', 'q' => '상품 코드는 어디에서 확인하나요?', - 'a' => "마이페이지 > 이용내역에서 확인 가능합니다.\n단, 일부 상품은 휴대폰으로 즉시 전송되어 이용내역에서 확인이 어려울 수 있습니다.\n미수신/확인 불가 시 1:1 문의로 접수해 주세요.", + 'a' => "마이페이지 > 구매내역에서 확인 가능합니다.\n단, 일부 상품은 휴대폰으로 즉시 전송되어 구매내역에서 확인이 어려울 수 있습니다.\n미수신/확인 불가 시 1:1 문의로 접수해 주세요.", ], [ 'category' => 'code', diff --git a/config/web.php b/config/web.php index 3b67e7b..c97d263 100644 --- a/config/web.php +++ b/config/web.php @@ -86,7 +86,7 @@ return [ ], 'mypage_tabs' => [ ['label' => '나의정보', 'route' => 'web.mypage.info.index', 'key' => 'info'], - ['label' => '이용내역', 'route' => 'web.mypage.usage.index', 'key' => 'usage'], + ['label' => '구매내역', 'route' => 'web.mypage.usage.index', 'key' => 'usage'], ['label' => '교환내역', 'route' => 'web.mypage.exchange.index', 'key' => 'exchange'], ['label' => '1:1문의내역', 'route' => 'web.mypage.qna.index', 'key' => 'qna'], ], diff --git a/resources/views/admin/product/products/edit.blade.php b/resources/views/admin/product/products/edit.blade.php index d7295bc..2fc42bc 100644 --- a/resources/views/admin/product/products/edit.blade.php +++ b/resources/views/admin/product/products/edit.blade.php @@ -39,7 +39,7 @@ @endif diff --git a/resources/views/web/company/header.blade.php b/resources/views/web/company/header.blade.php index fc5e7e2..2c3706e 100644 --- a/resources/views/web/company/header.blade.php +++ b/resources/views/web/company/header.blade.php @@ -69,7 +69,7 @@
내 정보 - 이용내역 + 구매내역 1:1 문의
diff --git a/resources/views/web/cs/guide/index.blade.php b/resources/views/web/cs/guide/index.blade.php index 165336c..b00758a 100644 --- a/resources/views/web/cs/guide/index.blade.php +++ b/resources/views/web/cs/guide/index.blade.php @@ -115,7 +115,7 @@
확인 - 결제 완료 후 안내가 진행되며, 필요한 경우 마이페이지 이용내역에서 확인할 수 있어요. + 결제 완료 후 안내가 진행되며, 필요한 경우 마이페이지 구매내역에서 확인할 수 있어요.
diff --git a/resources/views/web/mypage/usage/index.blade.php b/resources/views/web/mypage/usage/index.blade.php index 3d272fc..6e0e792 100644 --- a/resources/views/web/mypage/usage/index.blade.php +++ b/resources/views/web/mypage/usage/index.blade.php @@ -19,26 +19,34 @@ }; // 상태는 "결제완료/결제취소" 중심 + 화면 깨짐 방지 최소 처리 - $statusLabel = function ($r) { + $payStatusLabel = function ($r) { $aCancel = (string)($r->attempt_cancel_status ?? 'none'); $oCancel = (string)($r->order_cancel_status ?? 'none'); - // 결제 후 취소 인식: status/stat_pay 유지 + cancel_status=success if ($aCancel === 'success' || $oCancel === 'success') return '결제취소'; $aStatus = (string)($r->attempt_status ?? ''); $oPay = (string)($r->order_stat_pay ?? ''); if ($aStatus === 'paid' || $oPay === 'p') return '결제완료'; - - // 아래는 운영/테스트 중 섞여도 UI가 깨지지 않게 최소 표시 if ($aStatus === 'failed' || $oPay === 'f') return '결제실패'; if ($aStatus === 'issued' || $oPay === 'w') return '입금대기'; return '진행중'; }; - $statusClass = function ($label) { + $issueStatusLabel = function ($r) use ($payStatusLabel) { + $pay = $payStatusLabel($r); + + // 결제취소면 발행 상태는 의미 없음 + if ($pay === '결제취소') return '-'; + + $issuedCount = (int)($r->issued_count ?? 0); + + return $issuedCount > 0 ? '발행완료' : '발행대기'; + }; + + $payStatusClass = function ($label) { return match ($label) { '결제취소' => 'pill--danger', '결제완료' => 'pill--ok', @@ -48,6 +56,14 @@ }; }; + $issueStatusClass = function ($label) { + return match ($label) { + '발행완료' => 'pill--ok', + '발행대기' => 'pill--wait', + default => 'pill--muted', + }; + }; + $formatDate = function ($v) { $s = (string)$v; if ($s === '') return '-'; @@ -120,8 +136,10 @@ $qty = (int)($r->total_qty ?? 0); $money = (int)($r->pay_money ?? 0); $method = (string)($r->pay_method ?? ''); - $st = $statusLabel($r); - $stCls = $statusClass($st); + $paySt = $payStatusLabel($r); + $payStCls = $payStatusClass($paySt); + $issueSt = $issueStatusLabel($r); + $issueStCls = $issueStatusClass($issueSt); $dt = $formatDate($r->created_at ?? ''); $href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery)); @endphp @@ -129,7 +147,10 @@
No. {{ $no }}
- {{ $st }} +
+ {{ $paySt }} + {{ $issueSt }} +
@@ -170,6 +191,7 @@ 결제수단 수량 금액 + 결제 상태 일시 @@ -186,8 +208,10 @@ $qty = (int)($r->total_qty ?? 0); $money = (int)($r->pay_money ?? 0); $method = (string)($r->pay_method ?? ''); - $st = $statusLabel($r); - $stCls = $statusClass($st); + $paySt = $payStatusLabel($r); + $payStCls = $payStatusClass($paySt); + $issueSt = $issueStatusLabel($r); + $issueStCls = $issueStatusClass($issueSt); $dt = $formatDate($r->created_at ?? ''); $href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery)); @endphp @@ -197,11 +221,12 @@ {{ $methodLabel($method) }} {{ $qty }} {{ number_format($money) }} 원 - {{ $st }} + {{ $paySt }} + {{ $issueSt }} {{ $dt }} @empty - 구매내역이 없습니다. + 구매내역이 없습니다. @endforelse @@ -305,5 +330,6 @@ .list-mobile{display:none;} .list-desktop{display:block;} } + .mcard__badges{display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end;} @endsection diff --git a/resources/views/web/mypage/usage/show.blade.php b/resources/views/web/mypage/usage/show.blade.php index a1c35fb..b6ce7b8 100644 --- a/resources/views/web/mypage/usage/show.blade.php +++ b/resources/views/web/mypage/usage/show.blade.php @@ -6,13 +6,14 @@ $order = $order ?? []; $items = $items ?? []; $pins = $pins ?? []; - $pinsOpened = (bool)($pinsOpened ?? false); + $pinsOpened = (bool)($pinsOpened ?? false); // 기존 핀 오픈 상태 + $pinsRevealed = (bool)($pinsRevealed ?? false); // 이번 요청에서 실핀 표시 여부 + $pinsRevealLocked = (bool)($pinsRevealLocked ?? false); // 실핀 확인 이력(취소 잠금) $canCancel = (bool)($canCancel ?? false); $backToListQuery = request()->only(['q', 'method', 'status', 'from', 'to', 'page']); $backToListQuery = array_filter($backToListQuery, fn($v) => $v !== null && $v !== ''); - // 결제수단 한글 매핑 $methodLabel = function ($m) { $m = (string)$m; return match ($m) { @@ -24,12 +25,10 @@ }; }; - // 리스트와 동일한 상태 라벨 $statusLabel = function () use ($attempt, $order) { $aCancel = (string)($attempt['cancel_status'] ?? 'none'); $oCancel = (string)($order['cancel_status'] ?? 'none'); - // 결제 후 취소는 cancel_status=success로만 인식 if ($aCancel === 'success' || $oCancel === 'success') return '결제취소'; $aStatus = (string)($attempt['status'] ?? ''); @@ -37,14 +36,12 @@ if ($aStatus === 'paid' || $oPay === 'p') return '결제완료'; if ($aStatus === 'issued' || $oPay === 'w') return '입금대기'; - - // 화면 깨짐 방지용(원하면 숨겨도 됨) if ($aStatus === 'failed' || $oPay === 'f') return '결제실패'; return '진행중'; }; $st = $statusLabel(); - $isCancelledAfterPaid = ($st === '결제취소'); // 취소 완료면 전표만 남김 + $isCancelledAfterPaid = ($st === '결제취소'); $statusClass = function ($label) { return match ($label) { @@ -56,7 +53,6 @@ }; }; - // 전표용 값 $attemptId = (int)($attempt['id'] ?? 0); $oid = (string)($order['oid'] ?? ''); $method = (string)($order['pay_method'] ?? ($attempt['pay_method'] ?? '')); @@ -67,32 +63,22 @@ $fee = (int)($amounts['fee'] ?? 0); $payMoney = (int)($amounts['pay_money'] ?? 0); - // 상품명: 현재 전달받는 변수 기준 유지 (사용자 확인 완료) $productName = (string)($productname ?? ''); if ($productName === '') $productName = '-'; $itemName = (string)($items[0]['name'] ?? ''); if ($itemName === '') $itemName = '-'; - // 수량 합계 $totalQty = 0; foreach ($items as $it) $totalQty += (int)($it['qty'] ?? 0); - // 일시 (분까지) $createdAt = (string)($order['created_at'] ?? ($attempt['created_at'] ?? '')); $dateStr = $createdAt ? \Carbon\Carbon::parse($createdAt)->format('Y-m-d H:i') : '-'; - // 핀 목록 "추후 조건" 대비: 지금은 보여줌 $showPinsNow = true; - - // 핀발행 완료 여부 (우선 pins 존재 기준) - // 추후 서버에서 bool($pinsIssuedCompleted) 내려주면 그 값 우선 사용 권장 $isPinIssuedCompleted = (bool)($pinsIssuedCompleted ?? !empty($pins)); - - // 오른쪽 영역 배너 모드 조건 $useRightBannerMode = $isCancelledAfterPaid || $isPinIssuedCompleted; - // 핀 발행(확인) 방식: gc_products.pin_check_methods 기반 (서비스에서 내려줌) $issueMethods = $issueMethods ?? ['PIN_INSTANT','SMS','BUYBACK']; if (is_string($issueMethods)) $issueMethods = json_decode($issueMethods, true) ?: []; if (!is_array($issueMethods) || empty($issueMethods)) $issueMethods = ['PIN_INSTANT','SMS','BUYBACK']; @@ -112,7 +98,6 @@ if (isset($issueMap[$k])) $issueMissingLabels[] = $issueMap[$k]; } - // 1개만 가능하면 그 옵션을 기본으로 열어둠 $issueOpenKey = (count($issueMethods) === 1) ? ($issueMethods[0] ?? null) : null; @endphp @@ -128,9 +113,7 @@ ← 목록
- {{-- 상단: 전표 + 우측 영역(핀발행/배너) --}}
- {{-- 좌측: 영수증형 전표 --}}
@@ -186,16 +169,8 @@ {{ number_format($payMoney) }}원
- - @if(!empty($order['cancel_last_msg'])) -
- 취소 처리 결과
- {{ $order['cancel_last_msg'] }} -
- @endif
- {{-- 우측: 핀발행 인터랙션 또는 안내 배너 --}}
-
-
취소 제한 안내
+
+
+ {{ $pinsRevealLocked ? '핀번호 확인 완료' : '핀번호 확인 안내' }} +
- 핀 확인/발행 이후에는 결제 취소가 제한될 수 있습니다. - 실제 취소 가능 여부는 하단 결제 취소 영역에서 확인됩니다. + @if($pinsRevealLocked) + 핀번호를 확인한 주문은 회원이 직접 취소할 수 없습니다. + 취소가 꼭 필요한 경우 관리자에게 문의해 주세요. + @else + 핀 오픈 후에도 기본은 마스킹 상태로 유지됩니다. + “핀번호 확인” 버튼에서 2차 비밀번호 인증 후 전체 핀번호를 확인할 수 있습니다. + @endif
@endif @@ -262,7 +244,6 @@
- {{-- 옵션 1 --}} @if(isset($issueAllowed['PIN_INSTANT']))
+
+ @csrf + +
@endif - {{-- 옵션 2 --}} @if(isset($issueAllowed['SMS']))
@endif - {{-- 옵션 3 --}} @if(isset($issueAllowed['BUYBACK']))
@if(!$isCancelledAfterPaid) - - {{-- 핀 목록 --}} - @if($showPinsNow) -
-
-

핀 목록

-
- 핀 발행이 완료되면 이 영역에서 핀 정보를 확인할 수 있습니다. (현재는 UI 확인을 위해 표시 중) + @if($showPinsNow && $hasIssuedIssues) +
+
+
+

핀번호

+
+ 기본은 마스킹 상태로 표시됩니다. + 핀번호 확인 버튼에서 2차 비밀번호를 입력하면 이번 화면에서만 전체 핀번호를 확인할 수 있습니다. +
+ + @if($pinsRevealed) + 핀번호 확인됨 + @else + 마스킹 표시중 + @endif
@if(empty($pins)) -

표시할 핀이 없습니다.

+
+

표시할 핀이 없습니다.

+
@else -
    - @foreach($pins as $p) +
    + @foreach($pins as $idx => $p) @php - $id = (int)($p['id'] ?? 0); - $status = (string)($p['status'] ?? ''); - $raw = (string)($p['pin'] ?? $p['pin_code'] ?? $p['pin_no'] ?? ''); - $masked = (string)($p['pin_masked'] ?? $p['pin_mask'] ?? ''); - if ($masked === '' && $raw !== '') { - $digits = preg_replace('/\s+/', '', $raw); - $masked = (mb_strlen($digits) >= 8) - ? (mb_substr($digits,0,4).str_repeat('*',4).mb_substr($digits,-2)) - : '****'; - } - // 오픈 전에는 마스킹 우선 - $display = $pinsOpened ? ($raw ?: $masked) : ($masked ?: '****'); + $raw = (string)($p['pin'] ?? ''); + $masked = (string)($p['pin_mask'] ?? '****'); + $display = $pinsRevealed ? ($raw ?: $masked) : $masked; + $amount = number_format((int)($p['face_value'] ?? 0)); @endphp -
  • - #{{ $id }} - @if($status !== '') {{ $status }} @endif - {{ $display }} -
  • + +
    +
    +
    MOBILE GIFT
    +
    No. {{ $idx + 1 }}
    +
    + +
    +
    {{ $productName }}
    +
    {{ $amount }}원
    + +
    +
    PIN NUMBER
    +
    + {{ $display }} +
    +
    + +
    + 핀번호는 본인만 확인해 주세요.
    + 핀 발행이 완료된 주문은 회원이 직접 취소할 수 없습니다. +
    +
    +
    @endforeach -
+
+ + @if(!$pinsRevealed) +
+ +
+ @else +
+ +
+ @endif @endif
@endif - {{-- 취소 버튼은 맨 아래, 작게 --}} -
-

결제 취소

-
- 핀을 확인/발행한 이후에는 취소가 제한될 수 있습니다.
- 결제 후 취소는 처리 시간이 소요될 수 있으며, 취소 결과는 본 페이지에 반영됩니다. -
- @if($canCancel) -
- @csrf - - - - - - - - -
- @else -
- 현재 상태에서는 결제 취소가 불가능합니다. +
+

결제 취소

+ +
+ 취소 사유를 선택한 뒤 결제를 취소할 수 있습니다. +
+ +
+ @csrf + + + + +
@endif + @endif +
+ +