giftcon_dev/app/Services/Payments/PaymentCancelService.php
2026-03-03 15:13:16 +09:00

246 lines
10 KiB
PHP

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