Files
2026-07-14 17:46:37 +09:00

835 lines
37 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\Tests\Feature\Controllers;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\OrderRefund;
use Modules\Sirsoft\Ecommerce\Models\OrderShipping;
use Modules\Sirsoft\Ecommerce\Models\Sequence;
use Plugins\Sirsoft\PayNicepayments\Listeners\PaymentRefundListener;
use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService;
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
/**
* 나이스페이먼츠 결제 플러그인 — 배송비·쿠폰 적용 취소 통합 테스트
*
* ShippingAndPromotionCancellationTest.php 가 mockPgRefundSuccess() 로 훅을 가짜로 대체한 것과 달리,
* 이 테스트는 실제 PaymentRefundListener 를 등록하고 NicePaymentsApiService 만 mock 으로 교체한다.
* 경로 전체를 검증한다:
* admin cancel request → OrderCancellationService → PaymentRefundListener → NicePaymentsApiService
*
* Mock 규칙:
* - 호출 횟수/인자 검증이 필요한 테스트: $mock->expects($this->once())->method(...)->willReturn(...)
* - 성공만 필요한 테스트: $this->stubNicePayCancelSuccess()
* - 호출되면 안 되는 테스트: $mock->expects($this->never())->method(...)
* ※ 같은 메서드에 method()와 expects()를 동시에 걸면 PHPUnit이 이중 호출로 인식하므로 혼용 금지.
*/
class ShippingAndCouponCancellationTest extends PluginTestCase
{
private const TID = 'TID_NICEPAY_TEST_001';
private const CANCEL_SUCCESS = [
'ResultCode' => '2001',
'ResultMsg' => '취소 성공',
'TID' => 'TID_CANCEL_NICEPAY',
];
protected User $adminUser;
protected function setUp(): void
{
parent::setUp();
// HookArgumentSerializer PHP 8.1 enum 직렬화 버그 우회
Queue::fake();
$this->adminUser = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
$this->createCancelSequences();
// 테스트 DB 시더가 plugin 레코드를 등록하지 않아
// PluginManager::registerPluginHookListeners() 의 active 가드에서 skip 되므로
// 테스트에서 직접 listener 를 등록한다. HookListenerRegistrar 가 idempotent 이라
// 운영 환경에서 listener 가 이미 등록되어 있으면 중복 호출되지 않음.
HookListenerRegistrar::register(PaymentRefundListener::class, 'sirsoft-pay_nicepayments');
}
protected function tearDown(): void
{
HookManager::resetAll();
// HookListenerRegistrar 의 static $registered 캐시도 비워야
// 다음 테스트의 setUp 에서 register() 가 실제 등록을 수행한다.
HookListenerRegistrar::clear();
parent::tearDown();
}
// ────────────────────────────────────────────
// Mock 헬퍼
// ────────────────────────────────────────────
/**
* cancelPayment 를 성공 stub 으로만 설정합니다 (호출 횟수 검증 없음).
* 호출 횟수·인자를 검증해야 하는 테스트에서는 이 메서드 대신 인라인 mock 을 사용하세요.
*/
private function stubNicePayCancelSuccess(): void
{
$mock = $this->createMock(NicePaymentsApiService::class);
$mock->method('cancelPayment')->willReturn(self::CANCEL_SUCCESS);
$this->app->instance(NicePaymentsApiService::class, $mock);
}
/**
* cancelPayment 가 정확히 1회 호출되는 mock 을 생성합니다.
* 반환된 mock 에 with() 제약을 추가할 수 있습니다.
*/
private function expectNicePayCancelOnce(): \PHPUnit\Framework\MockObject\MockObject
{
$mock = $this->createMock(NicePaymentsApiService::class);
$mock->expects($this->once())
->method('cancelPayment')
->willReturn(self::CANCEL_SUCCESS);
$this->app->instance(NicePaymentsApiService::class, $mock);
return $mock;
}
/**
* cancelPayment 가 호출되지 않아야 하는 mock 을 생성합니다.
*/
private function expectNicePayCancelNever(): void
{
$mock = $this->createMock(NicePaymentsApiService::class);
$mock->expects($this->never())->method('cancelPayment');
$this->app->instance(NicePaymentsApiService::class, $mock);
}
/**
* cancelPayment 에서 예외를 던지는 mock 을 생성합니다.
*/
private function stubNicePayCancelFailure(string $errorMsg = 'NicePayments 취소 실패'): void
{
$mock = $this->createMock(NicePaymentsApiService::class);
$mock->method('cancelPayment')->willThrowException(new \Exception($errorMsg));
$this->app->instance(NicePaymentsApiService::class, $mock);
}
/**
* 쿠폰 복원 훅을 등록합니다.
*/
private function mockCouponRestore(): void
{
HookManager::addAction(
'sirsoft-ecommerce.coupon.restore',
function (Order $order, array $couponIssueIds) {
CouponIssue::whereIn('id', $couponIssueIds)->update([
'status' => CouponIssueRecordStatus::AVAILABLE->value,
'used_at' => null,
'order_id' => null,
]);
},
10
);
}
// ────────────────────────────────────────────
// 주문 생성 헬퍼
// ────────────────────────────────────────────
private function createCancelSequences(): void
{
foreach ([SequenceType::CANCEL, SequenceType::REFUND] as $type) {
$cfg = $type->getDefaultConfig();
Sequence::firstOrCreate(
['type' => $type->value],
[
'algorithm' => $cfg['algorithm']->value,
'prefix' => $cfg['prefix'],
'current_value' => 0,
'increment' => 1,
'min_value' => 1,
'max_value' => $cfg['max_value'],
'cycle' => false,
'pad_length' => $cfg['pad_length'],
]
);
}
}
/**
* 나이스페이먼츠 결제가 완료된 배송비 포함 주문을 생성합니다.
*
* @return array{order: Order, options: OrderOption[], payment: OrderPayment, shipping: OrderShipping}
*/
private function createNicepayOrderWithShipping(
int $optionCount = 1,
int $unitPrice = 20000,
int $shippingFee = 3000,
string $tid = self::TID,
): array {
$user = User::factory()->create();
$subtotal = $unitPrice * $optionCount;
$total = $subtotal + $shippingFee;
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'subtotal_amount' => $subtotal,
'total_shipping_amount' => $shippingFee,
'total_amount' => $total,
'total_paid_amount' => $total,
'total_due_amount' => 0,
'total_cancelled_amount' => 0,
'cancellation_count' => 0,
'paid_at' => now(),
'promotions_applied_snapshot' => [],
'shipping_policy_applied_snapshot' => [],
]);
$options = [];
for ($i = 0; $i < $optionCount; $i++) {
$options[] = OrderOption::factory()->forOrder($order)->create([
'quantity' => 1,
'unit_price' => $unitPrice,
'subtotal_price' => $unitPrice,
'subtotal_paid_amount' => $unitPrice,
'subtotal_discount_amount' => 0,
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'product_snapshot' => [
'id' => null, 'name' => ['ko' => '테스트상품', 'en' => 'Test Product'],
'product_code' => null, 'sku' => null, 'brand_id' => null,
'list_price' => $unitPrice, 'selling_price' => $unitPrice,
'currency_code' => 'KRW', 'stock_quantity' => 100,
'tax_status' => 'taxable', 'tax_rate' => 10,
'has_options' => false, 'option_groups' => null, 'thumbnail_url' => null,
],
'option_snapshot' => [
'id' => null, 'option_code' => null, 'option_values' => null,
'option_name' => '기본', 'price_adjustment' => 0,
'list_price' => $unitPrice, 'selling_price' => $unitPrice,
'currency_code' => 'KRW', 'stock_quantity' => 100,
'weight' => 0.5, 'volume' => 0.01, 'sku' => null, 'is_default' => true,
],
]);
}
$payment = OrderPaymentFactory::new()->forOrder($order)->create([
'payment_status' => PaymentStatusEnum::PAID,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'nicepayments',
'paid_amount_local' => $total,
'paid_amount_base' => $total,
'cancelled_amount' => 0,
'paid_at' => now(),
'transaction_id' => $tid,
]);
$shipping = OrderShipping::factory()->forOrder($order)->create([
'order_option_id' => $options[0]->id,
'base_shipping_amount' => $shippingFee,
'total_shipping_amount' => $shippingFee,
]);
return compact('order', 'options', 'payment', 'shipping');
}
/**
* 나이스페이먼츠 결제가 완료된 쿠폰 할인 주문을 생성합니다.
*
* @return array{order: Order, options: OrderOption[], payment: OrderPayment, couponIssue: CouponIssue}
*/
private function createNicepayOrderWithCoupon(
int $optionCount = 1,
int $unitPrice = 30000,
int $couponDiscount = 3000,
int $minOrderAmount = 0,
string $tid = self::TID,
): array {
$user = User::factory()->create();
$subtotal = $unitPrice * $optionCount;
$paid = $subtotal - $couponDiscount;
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'subtotal_amount' => $subtotal,
'total_shipping_amount' => 0,
'total_amount' => $subtotal,
'total_coupon_discount_amount' => $couponDiscount,
'total_order_coupon_discount_amount' => $couponDiscount,
'total_paid_amount' => $paid,
'total_due_amount' => 0,
'total_cancelled_amount' => 0,
'cancellation_count' => 0,
'paid_at' => now(),
'shipping_policy_applied_snapshot' => [],
]);
// 쿠폰 할인을 옵션별로 균등 분배 (단가보다 할인이 큰 경우에도 음수 방지)
$options = [];
$perBase = (int) round($couponDiscount / $optionCount);
for ($i = 0; $i < $optionCount; $i++) {
$perDisc = $i < ($optionCount - 1)
? $perBase
: $couponDiscount - $perBase * ($optionCount - 1);
$options[] = OrderOption::factory()->forOrder($order)->create([
'quantity' => 1,
'unit_price' => $unitPrice,
'subtotal_price' => $unitPrice,
'subtotal_paid_amount' => $unitPrice - $perDisc,
'subtotal_discount_amount' => $perDisc,
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'product_snapshot' => [
'id' => null, 'name' => ['ko' => '테스트상품', 'en' => 'Test Product'],
'product_code' => null, 'sku' => null, 'brand_id' => null,
'list_price' => $unitPrice, 'selling_price' => $unitPrice,
'currency_code' => 'KRW', 'stock_quantity' => 100,
'tax_status' => 'taxable', 'tax_rate' => 10,
'has_options' => false, 'option_groups' => null, 'thumbnail_url' => null,
],
'option_snapshot' => [
'id' => null, 'option_code' => null, 'option_values' => null,
'option_name' => '기본', 'price_adjustment' => 0,
'list_price' => $unitPrice, 'selling_price' => $unitPrice,
'currency_code' => 'KRW', 'stock_quantity' => 100,
'weight' => 0.5, 'volume' => 0.01, 'sku' => null, 'is_default' => true,
],
]);
}
$payment = OrderPaymentFactory::new()->forOrder($order)->create([
'payment_status' => PaymentStatusEnum::PAID,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'nicepayments',
'paid_amount_local' => $paid,
'paid_amount_base' => $paid,
'cancelled_amount' => 0,
'paid_at' => now(),
'transaction_id' => $tid,
]);
$coupon = Coupon::create([
'name' => '검수용 테스트 쿠폰',
'target_type' => 'order_amount',
'discount_type' => 'fixed',
'discount_value' => $couponDiscount,
'issue_method' => 'direct',
'issue_condition' => 'manual',
'issue_status' => 'issuing',
'total_quantity' => 100,
'issued_count' => 1,
'per_user_limit' => 1,
'valid_type' => 'period',
'is_combinable' => false,
]);
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $order->user_id,
'coupon_code' => 'TEST-' . strtoupper(uniqid()),
'status' => CouponIssueRecordStatus::USED->value,
'issued_at' => now(),
'used_at' => now(),
'order_id' => $order->id,
'discount_amount' => $couponDiscount,
]);
$order->update([
'promotions_applied_snapshot' => [
'coupon_issue_ids' => [$couponIssue->id],
'order_promotions' => [
'coupons' => [[
'coupon_issue_id' => $couponIssue->id,
'discount_type' => 'fixed',
'discount_value' => $couponDiscount,
'min_order_amount' => $minOrderAmount,
'applied_amount' => $couponDiscount,
]],
],
],
]);
return compact('order', 'options', 'payment', 'couponIssue');
}
// ====================================================
// 1. 배송정책 + 전체취소
// ====================================================
/**
* 배송비 있는 주문 전체취소 시 NicePayments cancelPayment 가 정확히 1회 호출되고
* 배송비가 환불 레코드에 포함되어야 한다.
*/
public function test_full_cancel_with_shipping_calls_nicepayments_once_and_refunds_shipping(): void
{
// Given: 20,000원 상품 + 3,000원 배송비 = 23,000원 결제
$this->expectNicePayCancelOnce(); // 정확히 1회만 호출되어야 함
$data = $this->createNicepayOrderWithShipping(optionCount: 1, unitPrice: 20000, shippingFee: 3000);
$order = $data['order'];
// When: 전체취소 (PG 연동 on)
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
]);
// Then: 성공 + 배송비 환불 포함
$response->assertOk()->assertJsonPath('success', true);
$order->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$refund = OrderRefund::where('order_id', $order->id)->first();
$this->assertNotNull($refund);
$this->assertEquals(3000, (float) $refund->refund_shipping_amount, '배송비 3,000원이 환불되어야 합니다');
$this->assertEquals(23000, (float) $refund->refund_amount, '상품(20,000) + 배송비(3,000) = 23,000원 환불');
}
/**
* 전체취소 시 NicePayments cancelPayment 에 isPartial=0 (전액취소) 이 전달되어야 한다.
*/
public function test_full_cancel_passes_partial_code_zero_to_nicepayments(): void
{
$mock = $this->createMock(NicePaymentsApiService::class);
$mock->expects($this->once())
->method('cancelPayment')
->with(
self::TID, // tid
$this->anything(), // moid
23000, // cancelAmt
$this->anything(), // cancelMsg
0, // isPartial = 0 (전액취소)
null, null, null,
)
->willReturn(self::CANCEL_SUCCESS);
$this->app->instance(NicePaymentsApiService::class, $mock);
$data = $this->createNicepayOrderWithShipping(optionCount: 1, unitPrice: 20000, shippingFee: 3000);
$order = $data['order'];
$this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
])->assertOk()->assertJsonPath('success', true);
}
/**
* 무료배송 주문 전체취소 시 환불 레코드에 배송비가 0원이어야 한다.
*/
public function test_full_cancel_free_shipping_refunds_only_product_amount(): void
{
$this->stubNicePayCancelSuccess();
$data = $this->createNicepayOrderWithShipping(optionCount: 1, unitPrice: 30000, shippingFee: 0);
$order = $data['order'];
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
]);
$response->assertOk()->assertJsonPath('success', true);
$refund = OrderRefund::where('order_id', $order->id)->first();
$this->assertNotNull($refund);
$this->assertEquals(0, (float) $refund->refund_shipping_amount);
$this->assertEquals(30000, (float) $refund->refund_amount);
}
// ====================================================
// 2. 배송정책 + 부분취소
// ====================================================
/**
* 배송비 있는 주문 부분취소 시 NicePayments cancelPayment 에 isPartial=1 이 전달되어야 한다.
*/
public function test_partial_cancel_with_shipping_calls_nicepayments_with_partial_flag(): void
{
// Given: 20,000원 × 2개 + 배송비 3,000원 = 43,000원
$mock = $this->createMock(NicePaymentsApiService::class);
$mock->expects($this->once())
->method('cancelPayment')
->with(
self::TID,
$this->anything(),
23000,
$this->anything(),
1, // isPartial = 1 (부분취소)
null, null, null,
)
->willReturn(self::CANCEL_SUCCESS);
$this->app->instance(NicePaymentsApiService::class, $mock);
$data = $this->createNicepayOrderWithShipping(optionCount: 2, unitPrice: 20000, shippingFee: 3000);
$order = $data['order'];
$options = $data['options'];
$response = $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,
]);
$response->assertOk()->assertJsonPath('success', true);
$order->refresh();
// 부분취소는 주문 상태(결제완료)를 유지하고 취소된 옵션만 CANCELLED 로 전이된다(PARTIAL_CANCELLED 폐기).
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
$this->assertEquals(OrderStatusEnum::CANCELLED, $options[0]->refresh()->option_status);
$refund = OrderRefund::where('order_id', $order->id)->first();
$this->assertNotNull($refund, '부분취소 후 환불 레코드가 생성되어야 합니다');
$this->assertEquals(3000, (float) $refund->refund_shipping_amount, '부분취소 시 배송비 차액 3,000원이 포함되어야 합니다');
$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 를 호출하지 않아야 한다.
*/
public function test_partial_cancel_without_pg_never_calls_nicepayments_api(): void
{
$this->expectNicePayCancelNever(); // cancel_pg=false 이므로 API 미호출
$data = $this->createNicepayOrderWithShipping(optionCount: 2, unitPrice: 20000, shippingFee: 3000);
$order = $data['order'];
$options = $data['options'];
$originalPaid = (float) $order->total_paid_amount;
$response = $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' => false,
]);
$response->assertOk()->assertJsonPath('success', true);
$refund = OrderRefund::where('order_id', $order->id)->first();
$this->assertNotNull($refund);
$this->assertGreaterThan(0, (float) $refund->refund_amount);
$this->assertLessThanOrEqual($originalPaid, (float) $refund->refund_amount);
}
// ====================================================
// 3. 쿠폰 적용 + 전체취소
// ====================================================
/**
* 쿠폰 적용 주문 전체취소 시 실결제금액(할인 후)이 환불되고 쿠폰이 복원되어야 한다.
*/
public function test_full_cancel_with_coupon_refunds_paid_amount_and_restores_coupon(): void
{
// Given: 30,000원 - 쿠폰 3,000원 = 27,000원 실결제 (나이스페이먼츠)
$this->stubNicePayCancelSuccess();
$this->mockCouponRestore();
$data = $this->createNicepayOrderWithCoupon(optionCount: 1, unitPrice: 30000, couponDiscount: 3000);
$order = $data['order'];
$couponIssue = $data['couponIssue'];
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
]);
$response->assertOk()->assertJsonPath('success', true);
// 실결제금액(27,000원) 환불 검증
$refund = OrderRefund::where('order_id', $order->id)->first();
$this->assertNotNull($refund);
$this->assertEquals(27000, (float) $refund->refund_amount, '쿠폰 할인 후 실결제금액(27,000원)이 환불되어야 합니다');
// 쿠폰 복원 검증
$couponIssue->refresh();
$this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $couponIssue->status, '쿠폰이 AVAILABLE 로 복원되어야 합니다');
$this->assertNull($couponIssue->order_id, '복원된 쿠폰의 order_id 가 null 이어야 합니다');
}
/**
* 쿠폰 적용 주문 전체취소 시 NicePayments cancelPayment 에 실결제금액이 전달되어야 한다.
*/
public function test_full_cancel_with_coupon_calls_nicepayments_with_paid_amount(): void
{
// 쿠폰 할인 후 실결제금액(27,000원)이 PG 취소 금액으로 전달되어야 함
$mock = $this->createMock(NicePaymentsApiService::class);
$mock->expects($this->once())
->method('cancelPayment')
->with(self::TID, $this->anything(), 27000, $this->anything(), 0, null, null, null)
->willReturn(self::CANCEL_SUCCESS);
$this->app->instance(NicePaymentsApiService::class, $mock);
$this->mockCouponRestore();
$data = $this->createNicepayOrderWithCoupon(optionCount: 1, unitPrice: 30000, couponDiscount: 3000);
$order = $data['order'];
$this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
])->assertOk()->assertJsonPath('success', true);
}
// ====================================================
// 4. 쿠폰 적용 + 부분취소
// ====================================================
/**
* 부분취소 후 잔여금액이 쿠폰 최소 조건을 충족하는 경우 성공해야 한다.
*/
public function test_partial_cancel_with_coupon_succeeds_when_min_amount_still_met(): void
{
// 30,000원 × 2개, 쿠폰 3,000원 (최소 20,000원) → 부분취소 후 잔여 30,000원 > 최소
$this->stubNicePayCancelSuccess();
$this->mockCouponRestore();
$data = $this->createNicepayOrderWithCoupon(
optionCount: 2, unitPrice: 30000, couponDiscount: 3000, minOrderAmount: 20000
);
$order = $data['order'];
$options = $data['options'];
$response = $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,
]);
$response->assertOk()->assertJsonPath('success', true);
$order->refresh();
// 부분취소는 주문 상태(결제완료)를 유지하고 취소된 옵션만 CANCELLED 로 전이된다(PARTIAL_CANCELLED 폐기).
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
$this->assertEquals(OrderStatusEnum::CANCELLED, $options[0]->refresh()->option_status);
}
/**
* 쿠폰 최소 주문금액 조건 미달 부분취소 시 422 를 반환하고 NicePayments API 를 호출하지 않아야 한다.
*
* 20,000원 × 2개, 쿠폰 22,000원 할인 (최소 30,000원) → 결제 18,000원
* 옵션 1개 취소 후 잔여 20,000 < 최소 30,000 → 쿠폰 소멸
* 재계산: 20,000 > 원 결제 18,000 → 422 (추가 결제 필요, 취소 거부)
*/
public function test_partial_cancel_rejected_when_coupon_condition_no_longer_met(): void
{
$this->expectNicePayCancelNever(); // 취소 거부되므로 PG API 미호출
$data = $this->createNicepayOrderWithCoupon(
optionCount: 2, unitPrice: 20000, couponDiscount: 22000, minOrderAmount: 30000
);
$order = $data['order'];
$options = $data['options'];
$response = $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,
]);
$response->assertStatus(422)->assertJsonPath('success', false);
}
// ====================================================
// 5. 가상계좌 결제 + 취소
// ====================================================
/**
* 입금 완료된 가상계좌 주문 취소 시 VBANK_REQUIRES_BANK_INFO 오류로 취소 실패해야 한다.
*
* vbank_number 가 있고 payment_status = PAID 이면 PaymentRefundListener 가
* VBANK_REQUIRES_BANK_INFO 를 반환 → OrderCancellationService 가 취소를 거부.
* 환불 계좌 정보는 AdminVbankRefundController 를 통해 별도 처리한다.
*/
public function test_full_cancel_deposited_vbank_fails_with_pg_refund_error(): void
{
$this->expectNicePayCancelNever(); // vbank 는 일반 API 로 처리 불가 → 미호출
$data = $this->createNicepayOrderWithShipping(optionCount: 1, unitPrice: 20000, shippingFee: 0);
$order = $data['order'];
$payment = $data['payment'];
// 가상계좌 입금 완료 상태로 업데이트
$payment->update([
'vbank_number' => '1234567890',
'vbank_name' => '국민은행',
'payment_status' => PaymentStatusEnum::PAID,
]);
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
]);
// 가상계좌 입금 완료 건은 환불 계좌 없이 PG 환불 불가 → 취소 실패
$response->assertStatus(422)->assertJsonPath('success', false);
// 주문 상태가 CANCELLED 로 변경되지 않아야 함 (롤백)
$order->refresh();
$this->assertNotEquals(OrderStatusEnum::CANCELLED, $order->order_status);
}
// ====================================================
// 6. NicePayments API 오류
// ====================================================
/**
* NicePayments cancelPayment API 오류 시 주문 취소가 실패하고 롤백되어야 한다.
*/
public function test_cancel_fails_and_rolls_back_when_nicepayments_api_throws(): void
{
$this->stubNicePayCancelFailure('NicePayments 취소 실패: 결제가 만료되었습니다');
$data = $this->createNicepayOrderWithShipping(optionCount: 1, unitPrice: 20000, shippingFee: 3000);
$order = $data['order'];
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
]);
// PG 취소 실패 → 422
$response->assertStatus(422)->assertJsonPath('success', false);
// 주문이 CANCELLED 로 변경되지 않아야 함 (트랜잭션 롤백)
$order->refresh();
$this->assertNotEquals(OrderStatusEnum::CANCELLED, $order->order_status, 'PG 환불 실패 시 주문 상태 변경이 롤백되어야 합니다');
}
/**
* NicePayments API 오류 시 refund_failed 훅이 발동되어야 한다.
*/
public function test_nicepayments_api_error_fires_refund_failed_hook(): void
{
$this->stubNicePayCancelFailure('API 호출 실패');
$data = $this->createNicepayOrderWithShipping(optionCount: 1, unitPrice: 20000, shippingFee: 3000);
$order = $data['order'];
$hookFired = false;
HookManager::addAction('sirsoft-pay_nicepayments.payment.refund_failed', function () use (&$hookFired) {
$hookFired = true;
}, 20);
$this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => true,
]);
$this->assertTrue($hookFired, 'PG 환불 실패 시 refund_failed 훅이 발동되어야 합니다');
}
// ====================================================
// 7. non-nicepayments PG 주문에 대한 무간섭 검증
// ====================================================
/**
* pg_provider 가 nicepayments 가 아닌 주문에는 PaymentRefundListener 가 개입하지 않아야 한다.
*/
public function test_listener_skips_non_nicepayments_payment(): void
{
$this->expectNicePayCancelNever(); // 다른 PG 이므로 NicePayments API 미호출
$data = $this->createNicepayOrderWithShipping(optionCount: 1, unitPrice: 20000, shippingFee: 3000);
$order = $data['order'];
$payment = $data['payment'];
// pg_provider 를 다른 PG 로 변경
$payment->update(['pg_provider' => 'other_pg']);
// cancel_pg=false 로 PG 없이 취소 (다른 PG 는 환불 핸들러 없음)
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel", [
'type' => 'full',
'reason' => 'changed_mind',
'cancel_pg' => false,
]);
$response->assertOk()->assertJsonPath('success', true);
$order->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
}
}