246 lines
10 KiB
PHP
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');
|
|
}
|
|
}
|