From 2493bf424af8c5086ec406e4a47ffb2e56876d37 Mon Sep 17 00:00:00 2001 From: thisgun Date: Wed, 8 Jul 2026 23:24:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(nicepayments):=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=ED=9B=84=20=EC=9E=94=EC=95=A1=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=ED=8C=90=EC=A0=95=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미 부분취소된 거래의 남은 잔액을 취소할 때도 나이스페이에 부분취소 코드가 전달되도록 보정합니다. 회귀 테스트와 나이스페이 플러그인 CHANGELOG를 함께 추가합니다. --- .../sirsoft-pay_nicepayments/CHANGELOG.md | 1 + .../src/Listeners/PaymentRefundListener.php | 18 ++++++-- .../ShippingAndCouponCancellationTest.php | 46 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md index 296e25dc..75d8d77c 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixed +- 이미 부분취소된 나이스페이먼츠 거래의 남은 잔액을 다시 취소할 때 PG 취소 요청이 전액취소로 잘못 전달되어 실패하던 문제를 수정했습니다. - 비회원 나이스페이먼츠 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다. - 나이스페이먼츠 인증 단계 실패(`AuthResultCode != 0000`)는 실제 PG 승인 전이므로 주문/결제/주문옵션을 취소 상태로 바꾸지 않고 체크아웃 재시도가 가능하도록 수정했습니다. - 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 SignData, 결제 승인 콜백, 결제창 닫힘 보고에서 예외가 일반 승인 오류로 뭉개지지 않도록 결제 통화 설정 오류로 명시 처리했습니다. diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/PaymentRefundListener.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/PaymentRefundListener.php index a10a0b4e..88037be0 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/PaymentRefundListener.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/PaymentRefundListener.php @@ -14,6 +14,7 @@ use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService; class PaymentRefundListener implements HookListenerInterface { private const PG_PROVIDER_ID = 'nicepayments'; + private const MONEY_EPSILON = 0.0001; /** * 구독할 훅 매핑 반환 @@ -104,9 +105,7 @@ class PaymentRefundListener implements HookListenerInterface $moid = (string) $order->order_number; - // 코어가 cancelled_amount를 훅 호출 전에 먼저 업데이트하므로 - // getCancellableAmount() > 0이면 부분취소, 0이면 전액취소 - $isPartial = $payment->getCancellableAmount() > 0; + $isPartial = $this->shouldUsePartialCancelCode($payment, $cancelAmt); $response = $apiService->cancelPayment($tid, $moid, $cancelAmt, $cancelMsg, $isPartial ? 1 : 0); Log::info('NicePayments: refund success', [ @@ -154,4 +153,17 @@ class PaymentRefundListener implements HookListenerInterface $apiService->useStoredCredentials((bool) $meta['is_test_mode'], $mid); } + + /** + * 코어는 PG 환불 훅 호출 전에 누적 취소액을 먼저 반영한다. + * 따라서 남은 잔액 취소 시 getCancellableAmount() 만 보면 전액취소처럼 보일 수 있으므로, + * 이번 환불 이전에 이미 취소된 결제 통화 금액이 있었는지도 함께 확인한다. + */ + private function shouldUsePartialCancelCode(OrderPayment $payment, int $cancelAmt): bool + { + $cancelledBeforeThisRefund = max(0.0, $payment->cancelledLocalAmount() - $cancelAmt); + + return $cancelledBeforeThisRefund > self::MONEY_EPSILON + || $payment->getCancellableAmount() > self::MONEY_EPSILON; + } } diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/ShippingAndCouponCancellationTest.php b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/ShippingAndCouponCancellationTest.php index f4a5bd8d..efa508d4 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/ShippingAndCouponCancellationTest.php +++ b/plugins/_bundled/sirsoft-pay_nicepayments/tests/Feature/Controllers/ShippingAndCouponCancellationTest.php @@ -506,6 +506,52 @@ class ShippingAndCouponCancellationTest extends PluginTestCase $this->assertEquals(23000, (float) $refund->refund_amount, '상품(20,000) + 배송비 차액(3,000) = 23,000원 환불'); } + /** + * 이미 부분취소된 거래의 남은 잔액을 취소할 때도 NicePayments 에는 부분취소 코드가 전달되어야 한다. + */ + public function test_remaining_cancel_after_prior_partial_cancel_keeps_partial_flag(): void + { + $calls = []; + $mock = $this->createMock(NicePaymentsApiService::class); + $mock->expects($this->exactly(2)) + ->method('cancelPayment') + ->willReturnCallback(function ( + string $tid, + string $moid, + int $cancelAmt, + string $cancelMsg, + int $partialCancelCode, + ) use (&$calls) { + $calls[] = compact('tid', 'moid', 'cancelAmt', 'cancelMsg', 'partialCancelCode'); + + return self::CANCEL_SUCCESS; + }); + $this->app->instance(NicePaymentsApiService::class, $mock); + + $data = $this->createNicepayOrderWithShipping(optionCount: 2, unitPrice: 1000, shippingFee: 0); + $order = $data['order']; + $options = $data['options']; + + $this->actingAs($this->adminUser) + ->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [ + 'type' => 'partial', + 'items' => [['order_option_id' => $options[0]->id, 'cancel_quantity' => 1]], + 'reason' => 'changed_mind', + 'cancel_pg' => true, + ])->assertOk()->assertJsonPath('success', true); + + $this->actingAs($this->adminUser) + ->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [ + 'type' => 'partial', + 'items' => [['order_option_id' => $options[1]->id, 'cancel_quantity' => 1]], + 'reason' => 'changed_mind', + 'cancel_pg' => true, + ])->assertOk()->assertJsonPath('success', true); + + $this->assertSame(1, $calls[0]['partialCancelCode']); + $this->assertSame(1, $calls[1]['partialCancelCode']); + } + /** * cancel_pg=false 이면 NicePayments API 를 호출하지 않아야 한다. */