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); } }