diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index c7dc1975..8acfc2f9 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -47,7 +47,7 @@ - 활성 관리자 테마에 최신 시맨틱 CSS가 없어도 플러그인 설정 화면의 여백, 카드 경계, 하단 저장 영역이 깨지지 않도록 보강했습니다. - 회원 마이페이지 주문 상세에서 KG 이니시스로 결제한 주문의 영수증 조회 버튼이 표시되지 않던 문제를 수정했습니다. 주문 상세 주소의 주문 ID를 주문번호로 잘못 사용해 영수증 조회가 항상 실패하던 원인을 바로잡았습니다. - 주문 상세 화면에서 다른 결제수단으로 결제한 주문까지 영수증 조회를 짧은 간격으로 수십 회 반복 요청하던 문제를 수정했습니다. 이제 KG 이니시스 결제가 아닌 주문은 영수증 조회를 호출하지 않으며, 영수증이 없다는 확정 응답을 받으면 재시도를 멈춥니다. -- 카드 결제가 정상 완료된 직후 결제창이 닫히면서 결제완료 주문의 상품이 취소 상태로 바뀌던 문제를 수정했습니다. 결제가 이미 완료된 주문은 결제창 닫힘 보고로 취소 처리되지 않습니다. +- 카드 결제가 정상 완료된 직후 또는 승인 콜백 처리 중 결제창 닫힘 보고가 도착하면서 결제완료 주문의 상품이 취소 상태로 바뀌던 문제를 수정했습니다. 결제가 이미 완료됐거나 완료 처리 중인 주문은 결제창 닫힘 보고로 취소 처리되지 않습니다. - 기본 통화가 원(KRW)이 아닌 통화일 때 결제창에 전달되는 청구 금액이 결제 통화로 환산된 금액과 일치하도록 검증 기준을 맞췄습니다 — 그동안 결제 금액 불일치로 결제가 막히던 문제가 해소됩니다. - CBT 설정 저장 검증 실패 메시지가 번역 키 그대로 노출되지 않도록 백엔드 언어팩과 플러그인 번역 로딩 경로를 보강했습니다. - 운영 모드의 국내 표준/모바일 결제 설정이 미완료된 경우 결제창 호출 전에 명확히 중단하도록 클라이언트 설정 상태를 노출했습니다. diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php index 30553835..b43391c0 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCloseReportController.php @@ -5,17 +5,20 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayKginicis\Controllers; use App\Helpers\ResponseHelper; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\RateLimiter; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection; +use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks; use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext; use Plugins\Sirsoft\PayKginicis\Http\Requests\PaymentCloseReportRequest; class PaymentCloseReportController { use ResolvesEasyPaySelection; + use SerializesPaymentCallbacks; use ValidatesCbtOrderContext; private const FAILURE_CODE = 'USER_CANCEL'; @@ -46,82 +49,97 @@ class PaymentCloseReportController } RateLimiter::hit($rateLimitKey, 60); - $order = $this->orderService->findByOrderNumber($oid); - if (! $order) { - return ResponseHelper::error('messages.failed', 404, [ - 'message' => ['Order not found.'], - ]); - } + $callbackLock = null; - if (strtoupper((string) $order->currency) !== 'KRW') { - return ResponseHelper::error('messages.failed', 422, [ - 'message' => ['Standard KG Inicis close report is only available for KRW orders.'], - ]); - } + try { + // PC close-report 는 PC authCallback 과 경쟁할 수 있으므로 같은 주문 락을 공유한다. + // authCallback 이 먼저 결제완료를 확정했다면 아래 최신 주문 조회에서 close-report 를 무시한다. + $callbackLock = $this->acquireOrderCallbackLock('authCallback', $oid); - if (! $this->requestMatchesOrderBuyer($request, $order)) { - return ResponseHelper::error('messages.failed', 403, [ - 'message' => ['Order buyer verification failed.'], - ]); - } + $order = $this->orderService->findByOrderNumber($oid); + if (! $order) { + return ResponseHelper::error('messages.failed', 404, [ + 'message' => ['Order not found.'], + ]); + } - $expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [ - 'received_amount' => $price, - ]); - if ($expectedPrice === null) { - return ResponseHelper::error('messages.failed', 422, [ - 'message' => ['Payment currency is not chargeable.'], - ]); - } + if (! $order->order_status->isBeforePayment()) { + return ResponseHelper::success('messages.success', [ + 'status' => 'ignored', + 'reason' => 'order_not_payable', + ]); + } - if ($price !== $expectedPrice) { - return ResponseHelper::error('messages.failed', 422, [ - 'message' => ['Payment amount does not match the order amount.'], - ]); - } + // 결제 성공 콜백(authorize)과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지 + // order_status=PENDING_ORDER 라 위 가드를 통과한다. payment_status 가 이미 PAID 면 결제가 + // 성공한 것이므로 실패 처리하지 않는다(failPayment 가 옵션을 취소로 덮어 주문/옵션 상태가 + // 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나, 여기서 차단해 불필요한 + // 마일리지 복원 시도·결제취소 이력 기록까지 미연에 방지한다. + if ($order->payment?->isPaid()) { + return ResponseHelper::success('messages.success', [ + 'status' => 'ignored', + 'reason' => 'payment_already_paid', + ]); + } - if (! $order->order_status->isBeforePayment()) { + if (strtoupper((string) $order->currency) !== 'KRW') { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Standard KG Inicis close report is only available for KRW orders.'], + ]); + } + + if (! $this->requestMatchesOrderBuyer($request, $order)) { + return ResponseHelper::error('messages.failed', 403, [ + 'message' => ['Order buyer verification failed.'], + ]); + } + + $expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [ + 'received_amount' => $price, + ]); + if ($expectedPrice === null) { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Payment currency is not chargeable.'], + ]); + } + + if ($price !== $expectedPrice) { + return ResponseHelper::error('messages.failed', 422, [ + 'message' => ['Payment amount does not match the order amount.'], + ]); + } + + $selectedEasyPayMethod = $this->normalizeEasyPayMethod( + is_string($validated['payment_method'] ?? null) ? $validated['payment_method'] : null, + ); + + $failedOrder = $this->orderService->failPayment( + $order, + self::FAILURE_CODE, + self::FAILURE_MESSAGE, + ); + + $closeReason = trim((string) ($validated['reason'] ?? '')); + + $this->persistEasyPayCloseContext($failedOrder, $selectedEasyPayMethod); + + $this->orderService->recordPaymentCancellation( + $failedOrder, + self::FAILURE_CODE, + $closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE, + ); + + return ResponseHelper::success('messages.success', [ + 'status' => 'recorded', + ]); + } catch (LockTimeoutException) { return ResponseHelper::success('messages.success', [ 'status' => 'ignored', - 'reason' => 'order_not_payable', + 'reason' => 'callback_in_progress', ]); + } finally { + $this->releaseOrderCallbackLock($callbackLock); } - - // 결제 성공 콜백(authorize)과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지 - // order_status=PENDING_ORDER 라 위 가드를 통과한다. payment_status 가 이미 PAID 면 결제가 - // 성공한 것이므로 실패 처리하지 않는다(failPayment 가 옵션을 취소로 덮어 주문/옵션 상태가 - // 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나, 여기서 차단해 불필요한 - // 마일리지 복원 시도·결제취소 이력 기록까지 미연에 방지한다. - if ($order->payment?->isPaid()) { - return ResponseHelper::success('messages.success', [ - 'status' => 'ignored', - 'reason' => 'payment_already_paid', - ]); - } - - $selectedEasyPayMethod = $this->normalizeEasyPayMethod( - is_string($validated['payment_method'] ?? null) ? $validated['payment_method'] : null, - ); - - $failedOrder = $this->orderService->failPayment( - $order, - self::FAILURE_CODE, - self::FAILURE_MESSAGE, - ); - - $closeReason = trim((string) ($validated['reason'] ?? '')); - - $this->persistEasyPayCloseContext($failedOrder, $selectedEasyPayMethod); - - $this->orderService->recordPaymentCancellation( - $failedOrder, - self::FAILURE_CODE, - $closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE, - ); - - return ResponseHelper::success('messages.success', [ - 'status' => 'recorded', - ]); } private function rateLimitKey(PaymentCloseReportRequest $request, string $oid): string diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php index 8e3fbcb4..cac842f7 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCloseReportControllerTest.php @@ -181,8 +181,10 @@ class PaymentCloseReportControllerTest extends PluginTestCase public function test_close_report_ignores_order_when_payment_already_paid(): void { - // race 재현: 승인 콜백이 payment 를 먼저 PAID 로 갱신했으나 order_status 는 아직 PENDING_ORDER. - $order = $this->makeOrder('ORD-CLOSE-PAID-001', 10000); + // race 재현: 승인 콜백이 payment 를 PAID 로 갱신했고 결제 예정액은 이미 0원이 됐으나, + // order_status 관계가 아직 PENDING_ORDER 로 관측되는 순간에도 close-report 는 금액 불일치가 + // 아니라 결제 성공으로 판단해 무시해야 한다. + $order = $this->makeOrder('ORD-CLOSE-PAID-001', 0); $order->setRelation('shippingAddress', new OrderAddress([ 'address_type' => 'shipping', 'orderer_email' => 'buyer@example.com',