KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고, 그 과정에서 드러난 두 결함군을 함께 닫는다. - 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다 - 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다 - 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고, 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는 결제창 미완료 주문은 만료 자동취소가 거둔다 - 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다 - 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
738 lines
28 KiB
PHP
738 lines
28 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Plugins\Sirsoft\PayKginicis\Controllers;
|
|
|
|
use App\Services\PluginSettingsService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Models\Order;
|
|
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
|
use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
|
|
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
|
|
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
|
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
|
|
use Plugins\Sirsoft\PayKginicis\Http\Requests\CbtCallbackRequest;
|
|
use Plugins\Sirsoft\PayKginicis\Services\CbtReconciliationService;
|
|
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
|
use Plugins\Sirsoft\PayKginicis\Support\ShopRedirectUrl;
|
|
|
|
/**
|
|
* KG 이니시스 CBT (Cross Border Trade) 일본 결제 콜백 컨트롤러
|
|
*
|
|
* CBT 결제 흐름:
|
|
* 1. 브라우저가 /cbtauth 로 POST 폼 전송
|
|
* 2. KG 이니시스가 returnUrl 로 sid 를 붙여 리다이렉트 → 이 컨트롤러
|
|
* 3. 서버가 /cbtapprove 로 mid + sid 전송하여 최종 승인
|
|
* 4. 성공 시 주문 완료 처리 후 결제 완료 페이지로 리다이렉트
|
|
*/
|
|
class CbtCallbackController
|
|
{
|
|
use IssuesReceiptCookie;
|
|
use PreventsReplayCallback;
|
|
use SerializesPaymentCallbacks;
|
|
use ValidatesCbtOrderContext;
|
|
|
|
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
|
|
|
|
private const CBT_USER_CANCEL_RESULT_CODES = ['2001', '0021', '0022'];
|
|
|
|
private const CBT_USER_CANCEL_MESSAGE_PATTERNS = [
|
|
'ユーザーキャンセル',
|
|
'キャンセル',
|
|
'사용자가 결제를 취소',
|
|
'결제를 취소',
|
|
'사용자가 취소',
|
|
'사용자 취소',
|
|
'취소',
|
|
];
|
|
|
|
private const CBT_PAYPAY_PROCESSING_FAILURE_MESSAGE = 'PayPay 결제를 완료하지 못했습니다. 다시 시도하거나 다른 결제수단을 선택해 주세요.';
|
|
|
|
private const CBT_AUTH_RESPONSE_KEYS = [
|
|
'resultCode',
|
|
'resultMsg',
|
|
'orderID',
|
|
'orderId',
|
|
'oid',
|
|
'mid',
|
|
'sid',
|
|
'paymethod',
|
|
'selectedPaymentMethod',
|
|
];
|
|
|
|
private const CBT_APPROVE_RESPONSE_KEYS = [
|
|
'resultCode',
|
|
'resultMsg',
|
|
'code',
|
|
'message',
|
|
'tid',
|
|
'transactionId',
|
|
'paymethod',
|
|
'payMethod',
|
|
'amount',
|
|
'price',
|
|
'currency',
|
|
'approve',
|
|
'applDate',
|
|
'applTime',
|
|
'installMonth',
|
|
'cardCode',
|
|
'cardName',
|
|
'applNo',
|
|
'convenience',
|
|
'confNo',
|
|
'receiptNo',
|
|
'paymentTerm',
|
|
'currencyCd',
|
|
'currencyCode',
|
|
'mid',
|
|
'MID',
|
|
'oid',
|
|
'orderId',
|
|
'orderID',
|
|
];
|
|
|
|
private const CBT_REFUND_RESPONSE_KEYS = [
|
|
'resultCode',
|
|
'resultMsg',
|
|
'tid',
|
|
'cancelDate',
|
|
'cancelTime',
|
|
'prtcCode',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly OrderProcessingService $orderService,
|
|
private readonly PluginSettingsService $pluginSettingsService,
|
|
private readonly KgInicisApiService $apiService,
|
|
private readonly CbtReconciliationService $reconciliationService,
|
|
) {}
|
|
|
|
/**
|
|
* CBT(일본 엔화) 결제 인증 콜백을 받아 승인 처리 후 결과 페이지로 리다이렉트합니다.
|
|
*
|
|
* @param CbtCallbackRequest $request CBT 인증 콜백 요청
|
|
* @return RedirectResponse 성공/실패 결과 페이지 리다이렉트
|
|
*/
|
|
public function handle(CbtCallbackRequest $request): RedirectResponse
|
|
{
|
|
$sid = (string) $request->input('sid', '');
|
|
$oid = $this->resolveOrderId($request);
|
|
$authResultCode = (string) $request->input('resultCode', '');
|
|
$authResultMsg = (string) $request->input('resultMsg', '');
|
|
$authMid = (string) $request->input('mid', '');
|
|
|
|
if ($authResultCode !== '' && $authResultCode !== 'OK' && $this->isCbtAuthUserCancel($authResultCode, $authResultMsg)) {
|
|
Log::info('KG Inicis CBT: auth cancelled by user', [
|
|
'oid' => $oid,
|
|
'result_code' => $authResultCode,
|
|
'result_msg' => $authResultMsg,
|
|
]);
|
|
|
|
return redirect($this->resolveFailUrl());
|
|
}
|
|
|
|
if ($sid === '' || $oid === '') {
|
|
Log::warning('KG Inicis CBT: missing sid or oid', ['oid' => $oid, 'sid' => $sid]);
|
|
|
|
return redirect($this->resolveFailUrl(['error' => 'invalid_params', 'orderId' => $oid]));
|
|
}
|
|
|
|
if ($authResultCode !== '' && $authResultCode !== 'OK') {
|
|
// 브라우저가 전달한 CBT 인증 실패값은 무인증 콜백이므로 주문 상태 변경에는 사용하지 않는다.
|
|
Log::warning('KG Inicis CBT: auth failed', [
|
|
'oid' => $oid,
|
|
'result_code' => $authResultCode,
|
|
'result_msg' => $authResultMsg,
|
|
]);
|
|
|
|
return redirect($this->resolveFailUrl($this->buildCbtFailureRedirectParams(
|
|
$authResultCode !== '' ? $authResultCode : 'cbt_auth_failed',
|
|
$authResultMsg,
|
|
(string) $request->input('paymethod', ''),
|
|
$oid,
|
|
)));
|
|
}
|
|
|
|
if ($authMid !== '' && $authMid !== $this->apiService->getJapanMid()) {
|
|
Log::warning('KG Inicis CBT: callback MID mismatch', [
|
|
'oid' => $oid,
|
|
'received_mid' => $authMid,
|
|
'expected_mid' => $this->apiService->getJapanMid(),
|
|
]);
|
|
|
|
return redirect($this->resolveFailUrl(['error' => 'mid_mismatch', 'orderId' => $oid]));
|
|
}
|
|
|
|
// Approve 성공 후 후속 처리 실패 시 PG 자동 cancel 추적 변수.
|
|
$approvedTid = null;
|
|
$approvedAmount = 0;
|
|
$callbackLock = null;
|
|
|
|
try {
|
|
$order = $this->orderService->findByOrderNumber($oid);
|
|
|
|
if (! $order) {
|
|
Log::error('KG Inicis CBT: order not found', ['oid' => $oid]);
|
|
|
|
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $oid]));
|
|
}
|
|
|
|
$callbackLock = $this->acquireOrderCallbackLock('cbtAuthCallback', $oid);
|
|
|
|
$this->assertPayableCbtOrder($order);
|
|
|
|
$pgResponse = $this->apiService->approveCbtPayment($sid);
|
|
|
|
$resultCode = $pgResponse['resultCode'] ?? ($pgResponse['code'] ?? '');
|
|
|
|
if (! $this->isCbtSuccessCode((string) $resultCode)) {
|
|
$resultMsg = $pgResponse['resultMsg'] ?? ($pgResponse['message'] ?? 'CBT approve failed');
|
|
Log::warning('KG Inicis CBT: approve failed', [
|
|
'oid' => $oid,
|
|
'result_code' => $resultCode,
|
|
'result_msg' => $resultMsg,
|
|
]);
|
|
|
|
// 승인이 성립하지 않았다 — 실제로 결제된 것이 없으므로 주문 상태를 바꾸지 않는다.
|
|
// 브라우저 실패(authResultCode)는 이미 무변경으로 하드닝되어 있으나, 서버 승인
|
|
// 실패 분기는 여전히 비인증 입력(sid + oid)으로 도달할 수 있어 같은 통로가 열려 있었다.
|
|
return redirect($this->resolveFailUrl($this->buildCbtFailureRedirectParams(
|
|
(string) $resultCode,
|
|
(string) $resultMsg,
|
|
(string) ($pgResponse['paymethod'] ?? $request->input('paymethod', '')),
|
|
$oid,
|
|
)));
|
|
}
|
|
|
|
$tid = $pgResponse['tid'] ?? ($pgResponse['transactionId'] ?? '');
|
|
if ($tid === '') {
|
|
throw new \RuntimeException('KG Inicis CBT approve response missing tid.');
|
|
}
|
|
|
|
// Replay 가드: 동일 tid 가 이미 paid 상태면 중복 처리하지 않고 성공 페이지로 복귀
|
|
if ($this->wasAlreadyPaid($tid)) {
|
|
$this->logReplayDetected($tid, $oid, 'CBT authCallback');
|
|
$this->queueReceiptCookie($oid);
|
|
|
|
return redirect($this->resolveSuccessUrl($oid));
|
|
}
|
|
|
|
$payMethod = (string) ($pgResponse['paymethod'] ?? $request->input('paymethod', 'CBT'));
|
|
if (! $this->isCbtCvsPayMethod($payMethod)) {
|
|
// PG 승인이 확정된 직후부터는 어떤 후속 예외라도 자동 취소 대상이다.
|
|
$approvedTid = $tid;
|
|
}
|
|
|
|
$this->assertCbtApproveResponseMatchesOrder($order, $pgResponse, $request, $payMethod);
|
|
$approvedAmount = $this->resolveApprovedAmount($pgResponse, $order);
|
|
$authResponse = $this->sanitizePgResponse($request->except(['_token']), self::CBT_AUTH_RESPONSE_KEYS);
|
|
$approveResponse = $this->sanitizePgResponse($pgResponse, self::CBT_APPROVE_RESPONSE_KEYS);
|
|
$selectedPaymentMethod = $this->resolveSelectedCbtPaymentMethod($request, $payMethod);
|
|
$selectionMeta = $this->buildCbtSelectionPaymentMeta($selectedPaymentMethod);
|
|
|
|
if ($this->isCbtCvsPayMethod($payMethod)) {
|
|
$this->handleCbtCvsIssued($order, $pgResponse, $tid, $sid, $approvedAmount, $authResponse, $approveResponse, $selectedPaymentMethod);
|
|
$this->queueReceiptCookie($oid);
|
|
|
|
Log::info('KG Inicis CBT: CVS payment issued', ['oid' => $oid, 'tid' => $tid]);
|
|
|
|
return redirect($this->resolveSuccessUrl($oid));
|
|
}
|
|
|
|
$this->orderService->completePayment($order, [
|
|
'transaction_id' => $tid,
|
|
'card_approval_number' => $pgResponse['approve'] ?? null,
|
|
'card_installment_months' => $this->normalizeInstallmentMonths($pgResponse['installMonth'] ?? null),
|
|
'payment_meta' => array_merge([
|
|
'result_code' => $resultCode,
|
|
'pay_method' => $payMethod,
|
|
'cbt_type' => 'JPPG',
|
|
'cbt_mid' => $this->apiService->getJapanMid(),
|
|
'cbt_sid' => $sid,
|
|
'mid' => $this->apiService->getJapanMid(),
|
|
'currency' => 'JPY',
|
|
'is_cbt' => true,
|
|
'is_test_mode' => $this->apiService->isTestMode(),
|
|
'pg_response_sanitized' => true,
|
|
'pg_auth_response' => $authResponse,
|
|
'pg_approve_response' => $approveResponse,
|
|
'pg_raw_response' => $approveResponse,
|
|
], $selectionMeta),
|
|
], $approvedAmount);
|
|
|
|
$this->queueReceiptCookie($oid);
|
|
|
|
Log::info('KG Inicis CBT: payment completed', ['oid' => $oid, 'tid' => $tid]);
|
|
|
|
return redirect($this->resolveSuccessUrl($oid));
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('KG Inicis CBT: callback exception', [
|
|
'oid' => $oid,
|
|
'tid' => $approvedTid,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
if ($this->wasAlreadyPaid($approvedTid)) {
|
|
Log::warning('KG Inicis CBT: local payment already completed, auto-refund skipped after exception', [
|
|
'oid' => $oid,
|
|
'tid' => $approvedTid,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
$this->queueReceiptCookie($oid);
|
|
|
|
return redirect($this->resolveSuccessUrl($oid));
|
|
}
|
|
|
|
$this->refundApprovedCbtPaymentOrFlagManualReconciliation(
|
|
$approvedTid,
|
|
$oid,
|
|
$approvedAmount,
|
|
$e->getMessage(),
|
|
);
|
|
|
|
return redirect($this->resolveFailUrl(['error' => 'cbt_failed', 'orderId' => $oid]));
|
|
} finally {
|
|
$this->releaseOrderCallbackLock($callbackLock);
|
|
}
|
|
}
|
|
|
|
private function resolveOrderId(CbtCallbackRequest $request): string
|
|
{
|
|
return (string) (
|
|
$request->input('orderID')
|
|
?: $request->input('orderId')
|
|
?: $request->input('oid')
|
|
?: ''
|
|
);
|
|
}
|
|
|
|
private function isCbtSuccessCode(string $resultCode): bool
|
|
{
|
|
return in_array($resultCode, ['OK', '00', '0000'], true);
|
|
}
|
|
|
|
private function isCbtAuthUserCancel(string $resultCode, string $resultMsg): bool
|
|
{
|
|
if (in_array($resultCode, self::CBT_USER_CANCEL_RESULT_CODES, true)) {
|
|
return true;
|
|
}
|
|
|
|
foreach (self::CBT_USER_CANCEL_MESSAGE_PATTERNS as $pattern) {
|
|
if ($resultMsg !== '' && str_contains($resultMsg, $pattern)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function buildCbtFailureRedirectParams(
|
|
string $resultCode,
|
|
string $resultMsg,
|
|
string $payMethod,
|
|
string $orderId,
|
|
): array {
|
|
if ($this->isPayPayProcessingFailure($resultCode, $resultMsg, $payMethod)) {
|
|
return [
|
|
'error' => 'paypay_processing_failed',
|
|
'message' => self::CBT_PAYPAY_PROCESSING_FAILURE_MESSAGE,
|
|
'orderId' => $orderId,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'error' => $resultCode,
|
|
'message' => $resultMsg,
|
|
'orderId' => $orderId,
|
|
];
|
|
}
|
|
|
|
private function isPayPayProcessingFailure(string $resultCode, string $resultMsg, string $payMethod): bool
|
|
{
|
|
$normalizedPayMethod = strtoupper($payMethod);
|
|
$normalizedResultCode = strtolower($resultCode);
|
|
$normalizedResultMsg = strtolower($resultMsg);
|
|
$isPayPay = str_contains($normalizedPayMethod, 'PAYPAY');
|
|
|
|
return $isPayPay
|
|
&& (
|
|
$normalizedResultCode === 'processing_failure'
|
|
|| str_contains($normalizedResultMsg, 'processing failed upstream')
|
|
);
|
|
}
|
|
|
|
private function assertPayableCbtOrder(Order $order): void
|
|
{
|
|
if (! $order->order_status->isBeforePayment()) {
|
|
throw new \RuntimeException('Order is not payable.');
|
|
}
|
|
|
|
if ((string) $order->currency !== 'JPY') {
|
|
throw new \RuntimeException('CBT payment is only available for JPY orders.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CBT 승인액을 확정합니다.
|
|
*
|
|
* KG 이니시스 일본 CBT 승인 응답(/cbtapprove)에는 신뢰할 금액 필드가 없다.
|
|
* 공식 매뉴얼(manual.inicis.com/jppay) 기준 승인 응답 파라미터는 결제수단별로
|
|
* - 신용카드: cardCode/approve/payType/installMonth (금액 없음)
|
|
* - 편의점(CVS): convenience/confNo/receiptNo/paymentTerm (금액 없음)
|
|
* - PayPay(BOKU): bokuApplPrice/bokuApplCurrency (모두 "일본결제 미사용" → 항상 공란)
|
|
* 이며 amount/price 키 자체가 존재하지 않는다. 금액의 권위는 인증요청(cbtauth)
|
|
* 시 가맹점이 보낸 금액(= 주문 결제예정액)과 NOTI(입금통보)의 amount 뿐이다.
|
|
*
|
|
* 따라서 승인 응답에 금액 필드가 채워져 오면 그 값으로 위변조를 검증하고,
|
|
* 비어 있으면(일본 CBT 정상 동작) 주문 결제예정액(order_currency=JPY 환산액)을
|
|
* 승인액으로 신뢰한다.
|
|
*
|
|
* @param array<string,mixed> $pgResponse CBT 승인 응답
|
|
* @param Order $order 결제 대상 주문
|
|
* @return int 확정된 승인 금액 (결제 통화 단위)
|
|
*/
|
|
private function resolveApprovedAmount(array $pgResponse, Order $order): int
|
|
{
|
|
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준).
|
|
// CBT 는 order_currency=JPY 강제이나 base 통화는 다를 수 있어(예: base KRW, 결제 JPY)
|
|
// total_due_amount(base) 직접 비교 시 PG 승인액(JPY)과 단위가 어긋난다.
|
|
$pgAmount = $this->firstNonEmptyString($pgResponse, ['amount', 'price', 'bokuApplPrice', 'bokuLocalApplPrice']);
|
|
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'cbt_approve', [
|
|
'tid' => $pgResponse['tid'] ?? ($pgResponse['transactionId'] ?? null),
|
|
'received_amount' => $pgAmount,
|
|
]);
|
|
if ($expectedAmount === null) {
|
|
throw new \RuntimeException('KG Inicis CBT payment currency is not chargeable.');
|
|
}
|
|
|
|
// 일본 CBT 승인 응답은 금액 필드가 비어 온다(매뉴얼상 미사용). 금액이 없으면
|
|
// 주문 결제예정액을 승인액으로 신뢰한다(승인 권위 = resultCode OK + tid).
|
|
if ($pgAmount === null) {
|
|
return $expectedAmount;
|
|
}
|
|
|
|
// 라이브/타 결제수단에서 금액이 채워져 오면 위변조 검증을 유지한다.
|
|
$approvedAmount = (int) $pgAmount;
|
|
if ($approvedAmount !== $expectedAmount) {
|
|
throw new \RuntimeException('KG Inicis CBT approved amount mismatch.');
|
|
}
|
|
|
|
return $approvedAmount;
|
|
}
|
|
|
|
private function assertCbtApproveResponseMatchesOrder(
|
|
Order $order,
|
|
array $pgResponse,
|
|
CbtCallbackRequest $request,
|
|
string $approvedPayMethod,
|
|
): void {
|
|
$receivedOrderId = $this->firstNonEmptyString($pgResponse, ['orderId', 'orderID', 'oid']);
|
|
if ($receivedOrderId !== null && $receivedOrderId !== (string) $order->order_number) {
|
|
throw new \RuntimeException('KG Inicis CBT approved order id mismatch.');
|
|
}
|
|
|
|
$receivedMid = $this->firstNonEmptyString($pgResponse, ['mid', 'MID']);
|
|
$expectedMid = $this->apiService->getJapanMid();
|
|
if ($receivedMid !== null && $receivedMid !== $expectedMid) {
|
|
throw new \RuntimeException('KG Inicis CBT approved MID mismatch.');
|
|
}
|
|
|
|
$receivedCurrency = strtoupper((string) $this->firstNonEmptyString($pgResponse, ['currencyCd', 'currencyCode', 'currency']));
|
|
if ($receivedCurrency !== '' && $receivedCurrency !== 'JPY') {
|
|
throw new \RuntimeException('KG Inicis CBT approved currency mismatch.');
|
|
}
|
|
|
|
$expectedPayMethod = $this->resolveExpectedCbtPayMethod($request);
|
|
$receivedPayMethod = $this->normalizeCbtPayMethod($approvedPayMethod);
|
|
if ($expectedPayMethod !== null && $receivedPayMethod !== '' && $receivedPayMethod !== $expectedPayMethod) {
|
|
throw new \RuntimeException('KG Inicis CBT approved paymethod mismatch.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $source
|
|
* @param array<int,string> $keys
|
|
*/
|
|
private function firstNonEmptyString(array $source, array $keys): ?string
|
|
{
|
|
foreach ($keys as $key) {
|
|
if (! array_key_exists($key, $source)) {
|
|
continue;
|
|
}
|
|
|
|
$value = trim((string) $source[$key]);
|
|
if ($value !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolveExpectedCbtPayMethod(CbtCallbackRequest $request): ?string
|
|
{
|
|
$selectedPaymentMethod = (string) $request->input('selectedPaymentMethod', '');
|
|
$expectedBySelectedMethod = [
|
|
'card' => 'CARD',
|
|
'kginicis_japan_paypay' => 'PAYPAY',
|
|
'kginicis_japan_cvs' => 'CVS',
|
|
];
|
|
|
|
if ($selectedPaymentMethod !== '' && isset($expectedBySelectedMethod[$selectedPaymentMethod])) {
|
|
return $expectedBySelectedMethod[$selectedPaymentMethod];
|
|
}
|
|
|
|
$authPayMethod = $this->normalizeCbtPayMethod((string) $request->input('paymethod', ''));
|
|
|
|
return $authPayMethod !== '' ? $authPayMethod : null;
|
|
}
|
|
|
|
private function resolveSelectedCbtPaymentMethod(CbtCallbackRequest $request, string $payMethod): ?string
|
|
{
|
|
$selectedPaymentMethod = (string) $request->input('selectedPaymentMethod', '');
|
|
$allowedSelectedMethods = [
|
|
'card',
|
|
'kginicis_japan_paypay',
|
|
'kginicis_japan_cvs',
|
|
];
|
|
|
|
if (in_array($selectedPaymentMethod, $allowedSelectedMethods, true)) {
|
|
return $selectedPaymentMethod;
|
|
}
|
|
|
|
return match ($this->normalizeCbtPayMethod($payMethod)) {
|
|
'CARD' => 'card',
|
|
'PAYPAY' => 'kginicis_japan_paypay',
|
|
'CVS' => 'kginicis_japan_cvs',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function buildCbtSelectionPaymentMeta(?string $selectedPaymentMethod): array
|
|
{
|
|
if ($selectedPaymentMethod === null || $selectedPaymentMethod === '') {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
'selected_payment_method' => $selectedPaymentMethod,
|
|
];
|
|
}
|
|
|
|
private function normalizeCbtPayMethod(string $payMethod): string
|
|
{
|
|
return strtoupper(trim($payMethod));
|
|
}
|
|
|
|
private function normalizeInstallmentMonths(mixed $value): ?int
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
return (int) $value;
|
|
}
|
|
|
|
private function isCbtCvsPayMethod(string $payMethod): bool
|
|
{
|
|
return strtoupper($payMethod) === 'CVS';
|
|
}
|
|
|
|
private function handleCbtCvsIssued(
|
|
Order $order,
|
|
array $pgResponse,
|
|
string $tid,
|
|
string $sid,
|
|
int $amount,
|
|
array $authResponse,
|
|
array $approveResponse,
|
|
?string $selectedPaymentMethod,
|
|
): void {
|
|
$paymentTerm = (string) ($pgResponse['paymentTerm'] ?? '');
|
|
$dueAt = null;
|
|
|
|
if (preg_match('/^\d{14}$/', $paymentTerm) === 1) {
|
|
try {
|
|
$dueAt = Carbon::createFromFormat('YmdHis', $paymentTerm, 'Asia/Tokyo')
|
|
->setTimezone(config('app.timezone', 'UTC'));
|
|
} catch (\Throwable) {
|
|
$dueAt = null;
|
|
}
|
|
}
|
|
|
|
$order->payment()->update(array_filter([
|
|
'pg_provider' => 'kginicis',
|
|
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
|
|
'transaction_id' => $tid,
|
|
'vbank_code' => $pgResponse['convenience'] ?? null,
|
|
'vbank_name' => 'CVS',
|
|
'vbank_number' => $pgResponse['confNo'] ?? ($pgResponse['receiptNo'] ?? null),
|
|
'vbank_due_at' => $dueAt,
|
|
'vbank_issued_at' => now(),
|
|
'payment_meta' => array_merge([
|
|
'result_code' => $pgResponse['resultCode'] ?? 'OK',
|
|
'pay_method' => 'CVS',
|
|
'cbt_type' => 'JPPG',
|
|
'cbt_mid' => $this->apiService->getJapanMid(),
|
|
'cbt_sid' => $sid,
|
|
'mid' => $this->apiService->getJapanMid(),
|
|
'currency' => 'JPY',
|
|
'is_cbt' => true,
|
|
'is_test_mode' => $this->apiService->isTestMode(),
|
|
'pg_response_sanitized' => true,
|
|
'pg_auth_response' => $authResponse,
|
|
'pg_approve_response' => $approveResponse,
|
|
'pg_raw_response' => $approveResponse,
|
|
'cvs_status' => 'waiting_deposit',
|
|
'cvs_amount' => $amount,
|
|
'cvs_convenience' => $pgResponse['convenience'] ?? null,
|
|
'cvs_conf_no' => $pgResponse['confNo'] ?? null,
|
|
'cvs_receipt_no' => $pgResponse['receiptNo'] ?? null,
|
|
'cvs_payment_term' => $paymentTerm !== '' ? $paymentTerm : null,
|
|
], $this->buildCbtSelectionPaymentMeta($selectedPaymentMethod)),
|
|
], fn ($value) => $value !== null));
|
|
}
|
|
|
|
private function refundApprovedCbtPaymentOrFlagManualReconciliation(
|
|
?string $tid,
|
|
string $oid,
|
|
int $amount,
|
|
string $reason,
|
|
): void {
|
|
if ($tid === null || $tid === '') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$refundResult = $this->apiService->refundCbtPayment(
|
|
$tid,
|
|
null,
|
|
'CBT approved but local payment completion failed: '.mb_substr($reason, 0, 80),
|
|
);
|
|
|
|
$this->recordCbtReconciliationStatus($oid, [
|
|
'status' => CbtReconciliationService::STATUS_AUTO_REFUNDED,
|
|
'manual_action_required' => false,
|
|
'tid' => $tid,
|
|
'amount' => $amount,
|
|
'reason' => $reason,
|
|
'refund_error' => null,
|
|
'refund_result' => $this->sanitizePgResponse($refundResult, self::CBT_REFUND_RESPONSE_KEYS),
|
|
'is_test_mode' => $this->apiService->isTestMode(),
|
|
'cbt_mid' => $this->apiService->getJapanMid(),
|
|
'retry_count' => 0,
|
|
]);
|
|
|
|
Log::warning('KG Inicis CBT: approved payment auto-refunded after local failure', [
|
|
'tid' => $tid,
|
|
'oid' => $oid,
|
|
'amount' => $amount,
|
|
'reason' => $reason,
|
|
]);
|
|
|
|
return;
|
|
} catch (\Throwable $refundException) {
|
|
Log::error('KG Inicis CBT: auto refund after local failure failed', [
|
|
'tid' => $tid,
|
|
'oid' => $oid,
|
|
'amount' => $amount,
|
|
'reason' => $reason,
|
|
'refund_error' => $refundException->getMessage(),
|
|
]);
|
|
|
|
$this->recordCbtReconciliationStatus($oid, [
|
|
'status' => CbtReconciliationService::STATUS_MANUAL_REFUND_REQUIRED,
|
|
'manual_action_required' => true,
|
|
'tid' => $tid,
|
|
'amount' => $amount,
|
|
'reason' => $reason,
|
|
'refund_error' => $refundException->getMessage(),
|
|
'refund_result' => null,
|
|
'is_test_mode' => $this->apiService->isTestMode(),
|
|
'cbt_mid' => $this->apiService->getJapanMid(),
|
|
'retry_count' => 0,
|
|
]);
|
|
}
|
|
|
|
Log::error('KG Inicis CBT: post-approve failure — MANUAL CANCEL REQUIRED on KG Inicis JP merchant admin', [
|
|
'tid' => $tid,
|
|
'oid' => $oid,
|
|
'amount' => $amount,
|
|
'reason' => $reason,
|
|
]);
|
|
}
|
|
|
|
private function recordCbtReconciliationStatus(string $oid, array $attributes): void
|
|
{
|
|
$this->reconciliationService->record($oid, $attributes);
|
|
}
|
|
|
|
private function sanitizePgResponse(array $response, array $allowedKeys): array
|
|
{
|
|
$allowed = array_flip($allowedKeys);
|
|
$sanitized = [];
|
|
|
|
foreach ($response as $key => $value) {
|
|
if (! isset($allowed[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$sanitized[$key] = is_scalar($value) || $value === null
|
|
? $value
|
|
: json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
return $sanitized;
|
|
}
|
|
|
|
private function resolveSuccessUrl(string $orderId): string
|
|
{
|
|
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
|
$urlTemplate = $settings['redirect_success_url'] ?? ShopRedirectUrl::DEFAULT_SUCCESS_URL;
|
|
|
|
return $this->absolutize(ShopRedirectUrl::resolve($urlTemplate, ['{orderId}' => $orderId]));
|
|
}
|
|
|
|
private function resolveFailUrl(array $queryParams = []): string
|
|
{
|
|
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
|
$baseUrl = $this->absolutize(ShopRedirectUrl::resolve($settings['redirect_fail_url'] ?? ShopRedirectUrl::DEFAULT_FAIL_URL));
|
|
|
|
if (empty($queryParams)) {
|
|
return $baseUrl;
|
|
}
|
|
|
|
$query = http_build_query(array_filter($queryParams));
|
|
$separator = str_contains($baseUrl, '?') ? '&' : '?';
|
|
|
|
return $baseUrl.$separator.$query;
|
|
}
|
|
|
|
/**
|
|
* 상대 경로면 APP_URL 기준으로 절대 URL 화.
|
|
*
|
|
* PG가 브라우저 POST 로 콜백을 보내는 동안 Apache 가 ProxyPreserveHost Off 등
|
|
* 으로 Host 헤더를 localhost 로 바꿔서 PHP 에 전달하는 경우, Laravel 의
|
|
* redirect('/path') 가 http://localhost/path 를 생성해버린다. config('app.url')
|
|
* (.env 의 APP_URL)을 명시적 base 로 사용하여 도메인을 보존한다.
|
|
*/
|
|
private function absolutize(string $url): string
|
|
{
|
|
if (preg_match('#^https?://#i', $url) === 1) {
|
|
return $url;
|
|
}
|
|
|
|
$base = rtrim((string) config('app.url'), '/');
|
|
$path = $url === '' ? '/' : ($url[0] === '/' ? $url : '/'.$url);
|
|
|
|
return $base.$path;
|
|
}
|
|
}
|