결제 신용카드, 핸드폰, 가상계좌 결제 완료

This commit is contained in:
sungro815 2026-02-23 12:14:17 +09:00
parent b0545ab5b9
commit 20d6ea2732
41 changed files with 3827 additions and 13 deletions

View File

@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Payments\ArchivePaymentsJob;
use Illuminate\Console\Command;
final class PaymentsArchiveDispatch extends Command
{
protected $signature = 'payments:archive-dispatch
{--days=7}
{--timeout=15}
{--timeout_archive=60}
{--batch=500}';
protected $description = 'Dispatch archive job to queue (gc_payment_attempts/gc_pin_order/gc_pin_order_items).';
public function handle(): int
{
ArchivePaymentsJob::dispatch(
(int)$this->option('days'),
(int)$this->option('timeout'),
(int)$this->option('timeout_archive'),
(int)$this->option('batch'),
);
$this->info('ArchivePaymentsJob dispatched.');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Web\Order;
use App\Http\Controllers\Controller;
use App\Services\Order\OrderCheckoutService;
use Illuminate\Http\Request;
final class OrderCheckoutController extends Controller
{
public function __construct(
private readonly OrderCheckoutService $service,
) {}
public function __invoke(Request $request)
{
// 1) 로그인 체크 (legacy session)
$sess = (array) session('_sess', []);
$logged = ($sess['_login_'] ?? false) === true;
if (!$logged) {
return view('web.payments.danal_finish_top', [
'ok' => 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('/'),
]);
}
}

View File

@ -0,0 +1,181 @@
<?php
namespace App\Http\Controllers\Web\Payment;
use App\Http\Controllers\Controller;
use App\Services\Payments\PaymentService;
use Illuminate\Http\Request;
final class DanalController extends Controller
{
public function __construct(
private readonly PaymentService $service,
) {}
// 결제 시작(POST 권장)
public function start(Request $request)
{
$data = $request->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;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Web\Payment;
use App\Http\Controllers\Controller;
use App\Services\Payments\CheckoutService;
use Illuminate\Http\Request;
final class DanalDemoController extends Controller
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
public function form()
{
abort_unless(app()->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);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Jobs\Payments;
use App\Services\Payments\PaymentArchiveService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
final class ArchivePaymentsJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 120;
public int $tries = 1;
// 5분 동안 중복 실행 방지(스케줄러가 여러번 던져도 1개만)
public int $uniqueFor = 300;
public function __construct(
public int $days = 7,
public int $timeoutMin = 15,
public int $timeoutArchiveMin = 60,
public int $batch = 500,
) {}
public function uniqueId(): string
{
return 'payments-archive';
}
public function handle(PaymentArchiveService $svc): void
{
$batchId = substr(bin2hex(random_bytes(8)), 0, 16);
Log::info('[ArchivePaymentsJob] start', [
'batch' => $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);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models\Payments;
use Illuminate\Database\Eloquent\Model;
final class GcPaymentAttempt extends Model
{
protected $table = 'gc_payment_attempts';
protected $casts = [
'request_payload' => 'array',
'response_payload' => 'array',
'return_payload' => 'array',
'noti_payload' => 'array',
'ready_at' => 'datetime',
'redirected_at' => 'datetime',
'returned_at' => 'datetime',
'noti_at' => 'datetime',
];
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models\Payments;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class GcPinOrder extends Model
{
protected $table = 'gc_pin_order';
protected $fillable = [
'oid','mem_no','order_type','stat_pay','stat_tax',
'subtotal_amount','fee_amount','pg_fee_amount','discount_amount','pay_money',
'provider','pay_method','pg_tid','ret_code','ret_msg',
'pay_data','ret_data','ordered_at','paid_at','cancelled_at',
];
protected $casts = [
'pay_data' => 'array',
'ret_data' => 'array',
'ordered_at' => 'datetime',
'paid_at' => 'datetime',
'cancelled_at' => 'datetime',
];
public function items(): HasMany
{
return $this->hasMany(GcPinOrderItem::class, 'order_id');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models\Payments;
use Illuminate\Database\Eloquent\Model;
final class GcPinOrderItem extends Model
{
protected $table = 'gc_pin_order_items';
protected $fillable = [
'order_id','tbl_pointer','pin_seq',
'item_name','item_code','qty',
'unit_price','unit_pay_price','line_subtotal','line_fee','line_total',
'meta',
];
protected $casts = [
'meta' => 'array',
];
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Providers\Danal\Clients;
use App\Support\Danal\DanalAes256CbcHex;
use App\Support\Danal\Nvp;
use App\Support\Danal\EucKr;
use Illuminate\Support\Facades\Http;
final class DanalCpcgiClient
{
public function __construct(
private readonly DanalAes256CbcHex $aes,
private readonly Nvp $nvp,
private readonly EucKr $euc,
) {}
/**
* CPCGI POST 규칙:
* CPID=...&DATA=urlencode(base64(AES256CBC( key=urlencode(value)&... )))
* 응답은 NVP, DATA가 있으면 다시 decrypt -> 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);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Providers\Danal;
final class DanalConfig
{
public function card(string $kind): array
{
$cfg = config("danal.card.$kind");
if (!$cfg || !$cfg['cpid'] || !$cfg['key'] || !$cfg['iv'] || !$cfg['url']) {
throw new \RuntimeException("Danal card config missing: {$kind}");
}
return $cfg;
}
public function vact(): array
{
$cfg = config('danal.vact');
if (!$cfg || !$cfg['cpid'] || !$cfg['key'] || !$cfg['iv'] || !$cfg['url']) {
throw new \RuntimeException('Danal vact config missing');
}
return $cfg;
}
public function phone(string $mode): array
{
$base = config('danal.phone');
if (!$base || empty($base['bin_path']) || empty($base['item_code'])) {
throw new \RuntimeException('Danal phone base config missing (BIN_PATH/ITEMCODE)');
}
$m = $base[$mode] ?? null;
if (!$m || empty($m['cpid']) || empty($m['pwd'])) {
throw new \RuntimeException("Danal phone config missing: {$mode}");
}
return [
'bin_path' => $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');
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Providers\Danal\Gateways;
use App\Models\Payments\GcPinOrder;
use App\Providers\Danal\Clients\DanalCpcgiClient;
use App\Providers\Danal\DanalConfig;
use App\Support\Danal\EucKr;
final class CardGateway
{
public function __construct(
private readonly DanalConfig $cfg,
private readonly DanalCpcgiClient $client,
private readonly EucKr $euc,
) {}
public function auth(GcPinOrder $order, string $attemptToken, string $cardKind, bool $isMobile): array
{
$c = $this->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)) ?: '상품권';
}
}

View File

@ -0,0 +1,261 @@
<?php
namespace App\Providers\Danal\Gateways;
use App\Models\Payments\GcPinOrder;
use App\Providers\Danal\DanalConfig;
use Illuminate\Support\Facades\Crypt;
use Symfony\Component\Process\Process;
final class PhoneGateway
{
public function __construct(
private readonly DanalConfig $cfg,
) {}
public function ready(GcPinOrder $order, string $attemptToken, string $mode, bool $isMobile, array $extra = []): array
{
$c = $this->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)) ?: '상품권';
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Providers\Danal\Gateways;
use App\Models\Payments\GcPinOrder;
use App\Providers\Danal\Clients\DanalCpcgiClient;
use App\Providers\Danal\DanalConfig;
use App\Support\Danal\EucKr;
final class VactGateway
{
public function __construct(
private readonly DanalConfig $cfg,
private readonly DanalCpcgiClient $client,
private readonly EucKr $euc,
) {}
public function auth(GcPinOrder $order, string $attemptToken, bool $isMobile): array
{
$c = $this->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)) ?: '상품권';
}
}

View File

@ -18,4 +18,12 @@ class MemInfoRepository
return strcasecmp((string)$mem->email, $email) === 0; 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();
}
} }

View File

@ -0,0 +1,145 @@
<?php
namespace App\Repositories\Payments;
use App\Models\Payments\GcPaymentAttempt;
final class GcPaymentAttemptRepository
{
public function upsertForUpdate(array $data): GcPaymentAttempt
{
// uq_provider_oid_method 때문에 1개만 존재(동일 method 재시도는 정책적으로 “새 주문 생성” 권장)
$q = GcPaymentAttempt::query()
->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();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repositories\Payments;
use Illuminate\Support\Facades\DB;
final class GcPaymentMethodRepository
{
public function findActiveById(int $id): ?object
{
$row = DB::table('gc_payment_methods')
->where('id', $id)
->where('is_active', 1)
->first();
return $row ?: null;
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Repositories\Payments;
use App\Models\Payments\GcPinOrder;
use Illuminate\Support\Facades\DB;
final class GcPinOrderRepository
{
public function findByOidForUpdate(string $oid): ?GcPinOrder
{
return GcPinOrder::query()
->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;
}
}

View File

@ -91,7 +91,8 @@ class ProductRepository
return DB::table('gc_product_skus') return DB::table('gc_product_skus')
->where('product_id', $productId) ->where('product_id', $productId)
->where('is_active', 1) ->where('is_active', 1)
->orderBy('sort_order', 'asc') //->orderBy('sort_order', 'asc')
->orderBy('face_value', 'asc')
->get(); ->get();
} }
@ -108,4 +109,25 @@ class ProductRepository
->orderBy('sort_order', 'asc') ->orderBy('sort_order', 'asc')
->get(); ->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();
}
} }

View File

@ -0,0 +1,247 @@
<?php
namespace App\Services\Order;
use App\Models\Payments\GcPinOrder;
use App\Models\Payments\GcPinOrderItem;
use App\Repositories\Product\ProductRepository;
use App\Repositories\Payments\GcPaymentMethodRepository;
use App\Services\Payments\PaymentService;
use App\Repositories\Member\MemInfoRepository;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class OrderCheckoutService
{
public function __construct(
private readonly MemInfoRepository $members,
private readonly ProductRepository $products,
private readonly GcPaymentMethodRepository $payMethods,
private readonly PaymentService $payment,
) {}
public function checkoutAndStart(int $memNo, int $skuId, int $qty, int $payId, Request $request): array
{
return DB::transaction(function () use ($memNo, $skuId, $qty, $payId, $request) {
$sku = $this->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];
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Services\Payments;
use App\Models\Payments\GcPinOrder;
use App\Models\Payments\GcPinOrderItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class CheckoutService
{
public function createDemoOrder(int $memNo, int $amount): GcPinOrder
{
return DB::transaction(function () use ($memNo, $amount) {
$oid = 'GC' . now()->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;
});
}
}

View File

@ -0,0 +1,308 @@
<?php
namespace App\Services\Payments\Danal\Clients;
use App\Models\GcPaymentAttempt;
use Illuminate\Support\Facades\Crypt;
use Symfony\Component\Process\Process;
final class DanalTeleditClient
{
/**
* CI3: payment_danal_cell() -> 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,
],
];
}
}

View File

@ -0,0 +1,278 @@
<?php
namespace App\Services\Payments\Danal;
use App\Models\GcPaymentAttempt;
use App\Services\Payments\Danal\Gateways\DanalCardGateway;
use App\Services\Payments\Danal\Gateways\DanalVactGateway;
use App\Services\Payments\Danal\Clients\DanalTeleditClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class DanalPaymentService
{
public function __construct(
private readonly DanalCardGateway $card,
private readonly DanalVactGateway $vact,
private readonly DanalTeleditClient $teledit,
) {}
/**
* 결제 시작: card/vact/phone 분기
*/
public function start(int $memNo, array $data): array
{
$oid = (string)$data['oid'];
$payMethod = (string)$data['pay_method'];
// TODO: 여기서 CI3처럼 주문정보를 DB에서 조회해서 변조 방지해야 함
// - 필요한 값: amount, itemName, stat_pay(w), mid(환금성 판단), mem_no 일치 등
// - 현재는 최소값만 받되, 반드시 네 주문 테이블에 맞게 연결할 것.
$order = $this->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', ''),
];
}
}

View File

@ -0,0 +1,243 @@
<?php
namespace App\Services\Payments\Danal\Gateways;
use App\Models\GcPaymentAttempt;
use App\Support\DanalAes256CbcHex;
final class DanalCardGateway
{
public function __construct(
private readonly DanalAes256CbcHex $aes,
) {}
/**
* CI3: payment_danal_card() -> 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,
],
];
}
}

View File

@ -0,0 +1,277 @@
<?php
namespace App\Services\Payments\Danal\Gateways;
use App\Models\GcPaymentAttempt;
use App\Support\DanalAes256CbcHex;
final class DanalVactGateway
{
public function __construct(
private readonly DanalAes256CbcHex $aes,
) {}
/**
* CI3: payment_danal_vact() -> 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,
],
];
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace App\Services\Payments;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
final class PaymentArchiveService
{
public function run(int $days, int $timeoutMin, int $timeoutArchiveMin, int $batch, string $batchId): array
{
return DB::transaction(function () use ($days, $timeoutMin, $timeoutArchiveMin, $batch, $batchId) {
// 1) redirected 타임아웃 → cancelled + 주문 stat_pay=c 로 정리
$cutoffRedirect = now()->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,
];
});
}
}

View File

@ -0,0 +1,408 @@
<?php
namespace App\Services\Payments;
use App\Models\Payments\GcPinOrder;
use App\Repositories\Payments\GcPinOrderRepository;
use App\Repositories\Payments\GcPaymentAttemptRepository;
use App\Providers\Danal\Gateways\CardGateway;
use App\Providers\Danal\Gateways\VactGateway;
use App\Providers\Danal\Gateways\PhoneGateway;
use Illuminate\Support\Facades\DB;
final class PaymentService
{
public function __construct(
private readonly GcPinOrderRepository $orders,
private readonly GcPaymentAttemptRepository $attempts,
private readonly CardGateway $card,
private readonly VactGateway $vact,
private readonly PhoneGateway $phone,
) {}
/** 결제 시작: 카드/가상계좌/휴대폰 */
public function start(string $oid, int $memNo, string $method, array $opt): array
{
return DB::transaction(function () use ($oid, $memNo, $method, $opt) {
$order = $this->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 '';
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Support\Danal;
final class DanalAes256CbcHex
{
public function encrypt(string $plain, string $hexKey, string $hexIv): string
{
$iv = hex2bin($hexIv) ?: '';
$key = hex2bin($hexKey) ?: '';
$raw = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
if ($raw === false) throw new \RuntimeException('openssl_encrypt failed');
return base64_encode($raw);
}
public function decrypt(string $base64Cipher, string $hexKey, string $hexIv): string
{
$iv = hex2bin($hexIv) ?: '';
$key = hex2bin($hexKey) ?: '';
$raw = base64_decode($base64Cipher, true);
if ($raw === false) throw new \RuntimeException('base64_decode failed');
$plain = openssl_decrypt($raw, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
if ($plain === false) throw new \RuntimeException('openssl_decrypt failed');
return $plain;
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Support\Danal;
final class EucKr
{
public function toEuc(string $s): string
{
if ($s === '') return '';
$out = @iconv('UTF-8', 'EUC-KR//IGNORE', $s);
return $out === false ? $s : $out;
}
public function toUtf8(string $s): string
{
if ($s === '') return '';
// 이미 UTF-8이면 그대로
if (function_exists('mb_check_encoding') && mb_check_encoding($s, 'UTF-8')) {
return $s;
}
// EUC-KR -> 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;
}
}

25
app/Support/Danal/Nvp.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Danal;
final class Nvp
{
public function build(array $data): string
{
$pairs = [];
foreach ($data as $k => $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;
}
}

View File

@ -27,8 +27,14 @@ return Application::configure(basePath: dirname(__DIR__))
// ✅ CSRF 예외 처리 // ✅ CSRF 예외 처리
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'auth/register/danal/result', 'auth/register/danal/result', #다날인증
'mypage/info/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 등록 // ✅ alias 등록

View File

@ -12,4 +12,57 @@ return [
'cpid' => env('DANAL_AUTHTEL_CPID', ''), 'cpid' => env('DANAL_AUTHTEL_CPID', ''),
'cppwd' => env('DANAL_AUTHTEL_CPPWD', ''), '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),
],
]; ];

View File

@ -0,0 +1,65 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Danal 결제 데모</title>
</head>
<body style="font-family:system-ui;max-width:720px;margin:24px auto">
<h1>Danal 결제 데모(local)</h1>
<form method="post" action="{{ route('web.payments.danal.demo.submit') }}">
@csrf
<div>
<label>mem_no</label>
<input name="mem_no" value="1">
</div>
<div>
<label>amount</label>
<input name="amount" value="1004">
</div>
<div>
<label>method</label>
<select name="method">
<option value="card">card</option>
<option value="vact">vact</option>
<option value="phone">phone</option>
</select>
</div>
<div>
<label>card_kind</label>
<select name="card_kind">
<option value="general">general</option>
<option value="exchange">exchange</option>
</select>
</div>
<div>
<label>phone_mode</label>
<select name="phone_mode">
<option value="prod">prod</option>
<option value="dev">dev</option>
</select>
</div>
<button type="submit">주문 생성</button>
</form>
@if(session('oid'))
<hr>
<h3>생성된 OID: {{ session('oid') }}</h3>
<form method="post" action="{{ route('web.payments.danal.start') }}">
@csrf
<input type="hidden" name="oid" value="{{ session('oid') }}">
<input type="hidden" name="mem_no" value="{{ session('data.mem_no') }}">
<input type="hidden" name="method" value="{{ session('data.method') }}">
<input type="hidden" name="card_kind" value="{{ session('data.card_kind') }}">
<input type="hidden" name="phone_mode" value="{{ session('data.phone_mode') }}">
<input type="hidden" name="is_mobile" value="0">
<button type="submit">결제 시작(결제창 이동)</button>
</form>
@endif
@if($errors->any())
<pre>{{ $errors }}</pre>
@endif
</body>
</html>

View File

@ -0,0 +1,43 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>결제 처리</title>
</head>
<body>
<script>
(async function () {
const action = @json($action ?? 'close_modal', JSON_INVALID_UTF8_SUBSTITUTE);
const message = @json($message ?? '', JSON_INVALID_UTF8_SUBSTITUTE);
const title = @json($title ?? '알림', JSON_INVALID_UTF8_SUBSTITUTE);
const redirect = @json($redirect ?? '', JSON_INVALID_UTF8_SUBSTITUTE);
const topWin = window.top || window;
try {
// 1) 모달 닫기
if (action === 'close_modal' && topWin.closePayModal) {
topWin.closePayModal({ keepFrame: true });
}
// 2) 공통 알림(showMsg 우선)
if (topWin.showMsg) {
await topWin.showMsg(message, { type:'alert', title:title });
} else if (message) {
alert(message);
}
console.log('[finish] redirect=', redirect);
if (redirect) topWin.location.href = redirect;
} finally {
if (redirect) {
try { topWin.location.assign(redirect); }
catch (e) { window.location.href = redirect; }
}
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>결제 진행</title>
</head>
<body>
<p style="font-family:system-ui">결제 페이지로 이동 중입니다…</p>
<form id="pgForm" method="post" action="{{ $actionUrl }}" accept-charset="{{ $acceptCharset ?? 'EUC-KR' }}">
@foreach($params as $k => $v)
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endforeach
<noscript>
<button type="submit">계속</button>
</noscript>
</form>
<script>
document.getElementById('pgForm').submit();
</script>
</body>
</html>

View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>결제 결과</title>
</head>
<body style="font-family:system-ui">
@php
$ok = $ok ?? false;
$status = $status ?? ($ok ? 'success' : 'fail');
$message = $message ?? '';
$meta = $meta ?? [];
@endphp
<h2>결제 결과: {{ $status }}</h2>
<p>{{ $message }}</p>
@if(!empty($meta))
<pre style="background:#f5f5f5;padding:12px;border-radius:8px;max-width:900px;white-space:pre-wrap">{{ json_encode($meta, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT) }}</pre>
@endif
<pre>{{ json_encode($meta, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_INVALID_UTF8_SUBSTITUTE) }}</pre>
<a href="/">홈으로</a>
</body>
</html>

View File

@ -0,0 +1,36 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>결제 이동</title>
</head>
<body>
<div style="padding:16px;font-family:sans-serif;">
결제 페이지로 이동 중입니다. 잠시만 기다려 주세요…
</div>
<form id="danalAutoSubmitForm" method="post" action="{{ $action }}" accept-charset="{{ $acceptCharset ?? 'EUC-KR' }}">
@foreach(($fields ?? []) as $k => $v)
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endforeach
<noscript><button type="submit">계속</button></noscript>
</form>
<script>
(function(){
try {
if (window.top && window.top.setPayCtx) {
window.top.setPayCtx({
token: @json($attemptToken ?? ''),
oid: @json($oid ?? ''),
method: @json($method ?? ''),
phone_mode: @json($phoneMode ?? ''),
});
}
} catch(e) {}
})();
document.getElementById('danalAutoSubmitForm').submit();
</script>
</body>
</html>

View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>처리 결과</title>
</head>
<body>
<script>
(function () {
var message = @json($message ?? '', JSON_INVALID_UTF8_SUBSTITUTE);
var redirect = @json($redirect ?? '/', JSON_INVALID_UTF8_SUBSTITUTE);
if (message) alert(message);
try {
if (window.top) window.top.location.href = redirect;
else window.location.href = redirect;
} catch (e) {
window.location.href = redirect;
}
})();
</script>
<noscript><a href="{{ $redirect }}">계속</a></noscript>
</body>
</html>

View File

@ -153,10 +153,119 @@
</div> </div>
</div> </div>
</div> </div>
<div id="payModal" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,.6);">
<div id="payModalBox" style="
position:absolute;
left:50%; top:50%;
transform:translate(-50%,-50%);
width:min(520px, 92vw);
height:min(860px, 92vh);
background:#fff;
border-radius:14px;
overflow:hidden;">
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 12px; border-bottom:1px solid #eee;">
<strong>
결제 진행
<span id="payModeBadge" style="display:none; margin-left:8px; padding:2px 8px; border-radius:999px; font-size:12px; color:#fff; background:#d32f2f;">
DEV(자동취소)
</span>
</strong>
<button type="button" onclick="window.onPayModalCloseClick()" style="border:0;background:#fff;font-size:18px;cursor:pointer;"></button>
</div>
<iframe id="payFrame" style="width:100%; height:calc(100% - 44px); border:0;"></iframe>
</div>
</div>
@endsection @endsection
@push('scripts') @push('scripts')
<script> <script>
const PAY_MODAL_SIZE = {
card: { w: 750, h: 550 },
vact: { w: 550, h: 550 },
phone: { w: 420, h: 760 },
};
// pay_id -> type 매핑(네 DB 기준)
function payTypeFromPayId(payId) {
const id = parseInt(payId, 10);
if (id === 1) return 'phone'; // MOBILE
if (id === 2) return 'card'; // CREDIT_CARD(일반)
if (id === 7) return 'card'; // CREDIT_CARD_REFUND(환금) - 사이즈는 카드와 동일로 시작
if (id === 3) return 'vact'; // VACT
return 'card';
}
function setPayModalSize(type) {
const box = document.getElementById('payModalBox');
const s = PAY_MODAL_SIZE[type] || PAY_MODAL_SIZE.card;
// 화면에 맞게 제한
box.style.width = `min(${s.w}px, 92vw)`;
box.style.height = `min(${s.h}px, 92vh)`;
}
window.__payCtx = null;
window.setPayCtx = function(ctx){
window.__payCtx = ctx || null;
updatePayBadge(window.__payCtx);
};
window.openPayModal = function(url, payId){
updatePayBadge(null);
const m = document.getElementById('payModal');
const f = document.getElementById('payFrame');
const type = payTypeFromPayId(payId);
setPayModalSize(type);
f.src = url;
m.style.display = 'block';
};
window.closePayModal = function(opts = {}){
const m = document.getElementById('payModal');
const f = document.getElementById('payFrame');
m.style.display = 'none';
if (!opts.keepFrame) {
f.src = 'about:blank';
}
};
window.onPayModalCloseClick = async function(){
const ctx = window.__payCtx;
try{
if (ctx && ctx.token) {
const r = await fetch('/pay/danal/cancel?a=' + encodeURIComponent(ctx.token), {
method:'POST',
credentials:'same-origin',
});
//console.log('cancel status', r.status);
}
} catch(e){}
window.closePayModal();
await window.showMsg('결제가 취소되었습니다.', { type:'alert', title:'결제취소' });
};
function updatePayBadge(ctx){
const badge = document.getElementById('payModeBadge');
if (!badge) return;
// 기본 숨김
badge.style.display = 'none';
// 휴대폰 + dev 일 때만 표시
if (ctx && ctx.method === 'phone' && ctx.phone_mode === 'dev') {
badge.textContent = 'DEV';
badge.style.display = 'inline-block';
}
}
(function() { (function() {
const POLICY = { const POLICY = {
minQty: parseInt("{{ $product->min_buy_qty }}") || 1, minQty: parseInt("{{ $product->min_buy_qty }}") || 1,
@ -196,11 +305,18 @@
if (nextQty < POLICY.minQty) return; if (nextQty < POLICY.minQty) return;
if (POLICY.maxQty > 0 && nextQty > POLICY.maxQty) { if (POLICY.maxQty > 0 && nextQty > POLICY.maxQty) {
await showMsg('최대 구매 수량(' + POLICY.maxQty + '개)을 초과할 수 없습니다.', { type:'alert', title:'수량초과' }); await showMsg(
`선택하신 수량이 1회 최대 구매 수량(${POLICY.maxQty}개)을 초과합니다.\n수량을 줄여 다시 시도해 주세요.`,
{ type:'alert', title:'구매 수량 안내' }
);
return; return;
} }
const money = (n) => Number(n || 0).toLocaleString('ko-KR');
if (POLICY.maxAmount > 0 && (currentUnitPrice * nextQty) > POLICY.maxAmount) { if (POLICY.maxAmount > 0 && (currentUnitPrice * nextQty) > POLICY.maxAmount) {
await showMsg('1회 최대 결제 금액 초과할 수 없습니다.', { type:'alert', title:'금액초과' }); await showMsg(
`선택하신 수량으로 결제하면 1회 결제 한도(${money(POLICY.maxAmount)}원)를 초과합니다.\n수량을 줄여 다시 시도해 주세요.`,
{ type:'alert', title:'결제 한도 안내' }
);
return; return;
} }
@ -222,7 +338,7 @@
function calculateTotal() { function calculateTotal() {
const qty = parseInt(document.getElementById('pOrderQty').value); const qty = parseInt(document.getElementById('pOrderQty').value);
const baseTotal = currentUnitPrice * qty; const baseTotal = currentUnitPrice * qty;
const feeAmount = Math.floor(baseTotal * (currentFeeRate / 100)); const feeAmount = Math.ceil(baseTotal * (currentFeeRate / 100));
document.getElementById('pBaseTotal').innerText = baseTotal.toLocaleString(); document.getElementById('pBaseTotal').innerText = baseTotal.toLocaleString();
document.getElementById('pFeeRateDisplay').innerText = currentFeeRate; document.getElementById('pFeeRateDisplay').innerText = currentFeeRate;
@ -235,7 +351,9 @@
const activePay = document.querySelector('.p-pay-btn.is-active'); const activePay = document.querySelector('.p-pay-btn.is-active');
const qty = document.getElementById('pOrderQty').value; const qty = document.getElementById('pOrderQty').value;
if (!activeSku) return await showMsg('권종을 선택해주세요.', { type:'alert', title:'권종선택' }); if (!activeSku) return await showMsg('권종을 선택해주세요.', { type:'alert', title:'권종선택' });
location.href = "/order/checkout?sku_id=" + activeSku.dataset.skuId + "&qty=" + qty + "&pay_id=" + activePay.dataset.payId;
const url = "/order/checkout?sku_id=" + activeSku.dataset.skuId + "&qty=" + qty + "&pay_id=" + activePay.dataset.payId;
window.openPayModal(url);
}; };
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

View File

@ -21,10 +21,6 @@
<div class="status-dot"></div> <div class="status-dot"></div>
</div> </div>
<h3 class="prod-title">{{ $product->name }}</h3> <h3 class="prod-title">{{ $product->name }}</h3>
<div class="price-box">
<div class="discount-rate">적립가능</div>
<div class="main-price">상세보기 <i class="bi bi-chevron-right"></i></div>
</div>
</div> </div>
</a> </a>
</div> </div>

View File

@ -121,6 +121,15 @@ registerScheduleCron('marketing_members_stats_daily', '10 3 * * *', 'marketing:m
'run_in_background' => true, 'run_in_background' => true,
]); ]);
/*결제정보 백업 */
registerScheduleCron('payments_archive_dispatch', '0 4 * * *', 'payments:archive-dispatch --days=7 --timeout=15 --timeout_archive=60 --batch=500', [
'without_overlapping' => true,
'on_one_server' => true,
'run_in_background' => true,
]);

View File

@ -11,6 +11,9 @@ use App\Http\Controllers\Web\Cs\NoticeController;
use App\Http\Controllers\Web\Auth\EmailVerificationController; use App\Http\Controllers\Web\Auth\EmailVerificationController;
use App\Http\Controllers\Web\Cs\CsQnaController; use App\Http\Controllers\Web\Cs\CsQnaController;
use App\Http\Controllers\Web\Product\ProductController; use App\Http\Controllers\Web\Product\ProductController;
use App\Http\Controllers\Web\Payment\DanalController;
use App\Http\Controllers\Web\Payment\DanalDemoController;
use App\Http\Controllers\Web\Order\OrderCheckoutController;
use App\Http\Controllers\Web\Mypage\MypageQnaController; use App\Http\Controllers\Web\Mypage\MypageQnaController;
@ -67,6 +70,26 @@ Route::prefix('mypage')->name('web.mypage.')
Route::get('qna/{seq}', [MypageQnaController::class, 'show'])->whereNumber('seq')->name('qna.show'); Route::get('qna/{seq}', [MypageQnaController::class, 'show'])->whereNumber('seq')->name('qna.show');
}); });
Route::prefix('pay/danal')->group(function () {
Route::post('/start', [DanalController::class, 'start'])->name('web.payments.danal.start');
Route::match(['GET','POST'], '/card/return', [DanalController::class, 'cardReturn'])->name('web.payments.danal.card.return');
Route::match(['GET','POST'], '/vact/return', [DanalController::class, 'vactReturn'])->name('web.payments.danal.vact.return');
Route::match(['GET','POST'], '/vact/noti', [DanalController::class, 'vactNoti'])->name('web.payments.danal.vact.noti');
Route::match(['GET','POST'], '/phone/return', [DanalController::class, 'phoneReturn'])->name('web.payments.danal.phone.return');
Route::match(['GET','POST'], '/phone/cancel', [DanalController::class, 'phoneCancel'])->name('web.payments.danal.phone.cancel');
Route::match(['GET','POST'], '/cancel', [DanalController::class, 'cancel'])->name('web.payments.danal.cancel');
// local 테스트 페이지(원하면 제거)
Route::get('/demo', [DanalDemoController::class, 'form'])->name('web.payments.danal.demo.form');
Route::post('/demo', [DanalDemoController::class, 'submit'])->name('web.payments.danal.demo.submit');
});
Route::get('/order/checkout', OrderCheckoutController::class)->name('web.order.checkout');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Policy | Policy
@ -146,8 +169,8 @@ Route::prefix('auth')->name('web.auth.')->group(function () {
}); });
Route::group(['prefix' => 'product', 'as' => 'web.product.'], function () { Route::group(['prefix' => 'product', 'as' => 'web.product.'], function () {
Route::get('/list/{category?}', [App\Http\Controllers\Web\Product\ProductController::class, 'index'])->name('index'); Route::get('/list/{category?}', [ProductController::class, 'index'])->name('index');
Route::get('/detail/{id}', [App\Http\Controllers\Web\Product\ProductController::class, 'show'])->name('show'); Route::get('/detail/{id}', [ProductController::class, 'show'])->name('show');
}); });
/* /*