fix(nicepayments): 부분취소 후 잔액 취소 판정 보정

이미 부분취소된 거래의 남은 잔액을 취소할 때도 나이스페이에 부분취소 코드가 전달되도록 보정합니다.

회귀 테스트와 나이스페이 플러그인 CHANGELOG를 함께 추가합니다.
This commit is contained in:
thisgun
2026-07-08 23:24:20 +09:00
parent 79eb091351
commit 2493bf424a
3 changed files with 62 additions and 3 deletions
@@ -18,6 +18,7 @@
### Fixed
- 이미 부분취소된 나이스페이먼츠 거래의 남은 잔액을 다시 취소할 때 PG 취소 요청이 전액취소로 잘못 전달되어 실패하던 문제를 수정했습니다.
- 비회원 나이스페이먼츠 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다.
- 나이스페이먼츠 인증 단계 실패(`AuthResultCode != 0000`)는 실제 PG 승인 전이므로 주문/결제/주문옵션을 취소 상태로 바꾸지 않고 체크아웃 재시도가 가능하도록 수정했습니다.
- 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 SignData, 결제 승인 콜백, 결제창 닫힘 보고에서 예외가 일반 승인 오류로 뭉개지지 않도록 결제 통화 설정 오류로 명시 처리했습니다.
@@ -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;
}
}
@@ -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 를 호출하지 않아야 한다.
*/