Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php
T
HeuJung 55bc9399e6 fix(ecommerce): 배송비 구간 경계 포함 + 무게/부피 단위 환산
구간별 배송비의 종료값이 구간에 포함되지 않아 경계값이 무료배송으로
새고, 상품 옵션의 g/cm³ 저장값을 정책의 kg/L 로 환산하지 않아 금액이
1000배로 청구되던 문제를 수정한다.

- 구간 매칭을 종료값 오름차순 사다리로 교체 — 시작값은 표시 전용이
 되어 기존 저장 데이터의 두 형태 모두에서 정확히 계산된다
- 단위 환산을 배송비 계산 한 지점으로 모으고, 부피무게와 실무게를
 같은 kg 단위로 비교하도록 교정
- 계산이 조용히 0원이 되는 설정(구간 미등록·단위값/기준금액 누락·
 중간 구간 무제한·계산 API 폴백 0원)을 저장 시점에 차단하고,
 런타임 잔여 경로에는 경고 로그를 남긴다
- 연속성 규칙을 정책 타입별로 분리해 소수 구간(2.5kg) 저장을 허용
- 상품 옵션 무게/부피 응답 왕복, 주문 총 무게/부피 실값 기록
2026-08-12 23:43:08 +09:00

7391 lines
300 KiB
PHP
Raw 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
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
use Modules\Sirsoft\Ecommerce\DTO\CancellationAdjustment;
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType;
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
use Modules\Sirsoft\Ecommerce\Models\Category;
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\OrderShipping;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
use Modules\Sirsoft\Ecommerce\Services\OrderAdjustmentService;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 주문 변경(취소) 금액 계산 서비스 테스트
*
* OrderAdjustmentService의 환불금액 계산을 검증합니다.
* - 전체취소/부분취소 기본
* - 14종 배송비 정책별 전체취소/부분취소
* - 쿠폰(상품/주문/배송) + 부분취소 조합
* - 마일리지 + 부분취소
* - 복합 시나리오 (배송비 + 쿠폰 + 마일리지 동시)
* - 안분 정확성
*/
class OrderAdjustmentServiceTest extends ModuleTestCase
{
protected OrderAdjustmentService $adjustmentService;
protected OrderCalculationService $calculationService;
protected function setUp(): void
{
parent::setUp();
$this->setupTestCurrencySettings();
$this->adjustmentService = app(OrderAdjustmentService::class);
$this->calculationService = app(OrderCalculationService::class);
}
/**
* 테스트용 통화 설정을 저장합니다.
*/
protected function setupTestCurrencySettings(): void
{
$settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($settingsPath)) {
mkdir($settingsPath, 0755, true);
}
$settings = [
'default_language' => 'ko',
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'is_default' => true,
],
],
];
file_put_contents(
$settingsPath.'/language_currency.json',
json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
}
protected function tearDown(): void
{
$settingsFile = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/language_currency.json');
if (file_exists($settingsFile)) {
unlink($settingsFile);
}
$mileageFile = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/mileage.json');
if (file_exists($mileageFile)) {
unlink($mileageFile);
}
parent::tearDown();
}
// ========================================
// 헬퍼 메서드
// ========================================
/**
* 테스트용 상품과 옵션을 생성합니다.
*
* @param int $price 상품 판매가
* @param int $priceAdjustment 옵션 추가금액
* @param ShippingPolicy|null $shippingPolicy 배송정책
* @return array [Product, ProductOption]
*/
protected function createProductWithOption(
int $price = 50000,
int $priceAdjustment = 0,
?ShippingPolicy $shippingPolicy = null,
?float $weight = null,
?float $volume = null,
?int $listPrice = null,
): array {
$attrs = [
'tax_status' => 'taxable',
'selling_price' => $price,
'list_price' => $listPrice ?? $price,
];
if ($shippingPolicy) {
$attrs['shipping_policy_id'] = $shippingPolicy->id;
}
$product = ProductFactory::new()->create($attrs);
$optionAttrs = [
'price_adjustment' => $priceAdjustment,
'stock_quantity' => 100,
'is_default' => true,
];
if ($weight !== null) {
$optionAttrs['weight'] = $weight;
}
if ($volume !== null) {
$optionAttrs['volume'] = $volume;
}
$option = ProductOptionFactory::new()->forProduct($product)->create($optionAttrs);
return [$product, $option];
}
/**
* 테스트용 배송정책을 생성합니다.
*
* @param ChargePolicyEnum $chargePolicy 배송비 부과정책
* @param int $baseFee 기본 배송비
* @param int|null $freeThreshold 무료배송 기준금액
* @param array|null $ranges 구간 설정
* @param bool $extraFeeEnabled 도서산간 추가배송비 사용여부
* @param array|null $extraFeeSettings 도서산간 추가배송비 설정
* @param bool $extraFeeMultiply 도서산간 추가배송비 수량비례 적용
*/
protected function createShippingPolicy(
ChargePolicyEnum $chargePolicy = ChargePolicyEnum::FREE,
int $baseFee = 0,
?int $freeThreshold = null,
?array $ranges = null,
bool $extraFeeEnabled = false,
?array $extraFeeSettings = null,
bool $extraFeeMultiply = false,
): ShippingPolicy {
$policy = ShippingPolicy::create([
'name' => ['ko' => '테스트 배송정책', 'en' => 'Test Shipping Policy'],
'is_default' => false,
'is_active' => true,
]);
$policy->countrySettings()->create([
'country_code' => 'KR',
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => $chargePolicy,
'base_fee' => $baseFee,
'free_threshold' => $freeThreshold,
'ranges' => $ranges,
'extra_fee_enabled' => $extraFeeEnabled,
'extra_fee_settings' => $extraFeeSettings,
'extra_fee_multiply' => $extraFeeMultiply,
'is_active' => true,
]);
return $policy->load('countrySettings');
}
/**
* 테스트용 쿠폰과 발급 내역을 생성합니다.
*
* @param CouponTargetType $targetType 적용 대상 타입
* @param CouponDiscountType $discountType 할인 타입
* @param float $discountValue 할인 값
* @param CouponTargetScope $targetScope 적용 범위
* @param float|null $maxDiscount 최대 할인금액
* @param float $minOrderAmount 최소 주문금액
* @return CouponIssue 쿠폰 발급 내역
*/
protected function createCouponWithIssue(
CouponTargetType $targetType = CouponTargetType::PRODUCT_AMOUNT,
CouponDiscountType $discountType = CouponDiscountType::FIXED,
float $discountValue = 1000,
CouponTargetScope $targetScope = CouponTargetScope::ALL,
?float $maxDiscount = null,
float $minOrderAmount = 0,
): CouponIssue {
$coupon = Coupon::create([
'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'],
'description' => ['ko' => '테스트용 쿠폰', 'en' => 'Test coupon'],
'target_type' => $targetType,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'discount_max_amount' => $maxDiscount,
'min_order_amount' => $minOrderAmount,
'target_scope' => $targetScope,
'is_combinable' => true,
'valid_from' => now()->subDay(),
'valid_to' => now()->addDays(30),
]);
$user = User::factory()->create();
$couponIssue = CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'coupon_code' => 'TEST'.uniqid(),
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expired_at' => now()->addDays(30),
]);
return $couponIssue;
}
/**
* 테스트용 카테고리를 생성합니다.
*
* @param string $name 카테고리명
*/
protected function createCategory(string $name = '테스트 카테고리'): Category
{
$category = Category::create([
'name' => ['ko' => $name, 'en' => 'Test Category'],
'slug' => 'test-category-'.uniqid(),
'path' => '1',
'depth' => 0,
'is_active' => true,
]);
$category->update(['path' => (string) $category->id]);
return $category;
}
/**
* OrderCalculationService로 계산 후 Order + OrderOption + OrderShipping + OrderPayment 레코드를 생성합니다.
*
* 이 메서드는 실제 주문 생성 프로세스를 시뮬레이션합니다.
* 계산 결과를 기반으로 정확한 금액이 반영된 주문 레코드를 생성합니다.
*
* @param CalculationInput $input 계산 입력
* @param array $orderOverrides Order 추가/오버라이드 속성
* @return Order 생성된 주문 (옵션, 배송, 결제 관계 로드됨)
*/
protected function createOrderFromCalculation(
CalculationInput $input,
array $orderOverrides = [],
): Order {
$result = $this->calculationService->calculate($input);
$user = User::factory()->create();
// 프로모션 스냅샷 구성 (OrderProcessingService::buildPromotionsAppliedSnapshot 동일 구조)
$promotionsSnapshot = array_merge(
$result->promotions->toArray(),
[
'coupon_issue_ids' => $input->couponIssueIds,
'item_coupons' => $input->itemCoupons,
'discount_code' => $input->discountCode,
]
);
// 배송정책 스냅샷 구성 (OrderProcessingService::buildShippingPolicyAppliedSnapshot 동일 구조)
$shippingPolicySnapshot = [];
if ($input->shippingAddress) {
$shippingPolicySnapshot['address'] = $input->shippingAddress->toArray();
}
// product_option_id를 키로 하는 배송정책 데이터 구성 (스냅샷 모드 재계산 호환)
foreach ($result->items as $item) {
if ($item->appliedShippingPolicy) {
$shippingPolicySnapshot[$item->productOptionId] = $item->appliedShippingPolicy->toArray();
}
}
// Order 생성
$order = Order::factory()->create(array_merge([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'subtotal_amount' => $result->summary->subtotal,
'total_discount_amount' => $result->summary->totalDiscount,
'total_product_coupon_discount_amount' => $result->summary->productCouponDiscount,
'total_order_coupon_discount_amount' => $result->summary->orderCouponDiscount,
'total_coupon_discount_amount' => $result->summary->productCouponDiscount + $result->summary->orderCouponDiscount,
'total_code_discount_amount' => $result->summary->codeDiscount,
'base_shipping_amount' => $result->summary->baseShippingTotal,
'extra_shipping_amount' => $result->summary->extraShippingTotal,
'shipping_discount_amount' => $result->summary->shippingDiscount,
'total_shipping_amount' => $result->summary->totalShipping,
'total_amount' => $result->summary->paymentAmount,
'total_paid_amount' => $result->summary->finalAmount,
'total_due_amount' => 0,
'total_points_used_amount' => $result->summary->pointsUsed,
'total_tax_amount' => $result->summary->taxableAmount,
'total_tax_free_amount' => $result->summary->taxFreeAmount,
'total_cancelled_amount' => 0,
'total_refunded_amount' => 0,
'cancellation_count' => 0,
'currency' => 'KRW',
'promotions_applied_snapshot' => $promotionsSnapshot,
'shipping_policy_applied_snapshot' => $shippingPolicySnapshot,
], $orderOverrides));
// OrderOption 생성
foreach ($result->items as $item) {
// 실제 상품/옵션 데이터로 스냅샷 구성 (스냅샷 모드 재계산과 호환)
$product = Product::find($item->productId);
$productOption = ProductOption::find($item->productOptionId);
$productSnapshot = $product ? $product->toSnapshotArray() : null;
$optionSnapshot = $productOption ? $productOption->toSnapshotArray() : null;
$createData = [
'product_id' => $item->productId,
'product_option_id' => $item->productOptionId,
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => $item->quantity,
'unit_price' => $item->unitPrice,
'subtotal_price' => $item->subtotal,
'subtotal_discount_amount' => $item->productCouponDiscountAmount + $item->codeDiscountAmount + $item->orderCouponDiscountShare,
'coupon_discount_amount' => $item->productCouponDiscountAmount + $item->orderCouponDiscountShare,
'code_discount_amount' => $item->codeDiscountAmount,
'subtotal_paid_amount' => $item->finalAmount,
'subtotal_tax_amount' => $item->taxableAmount,
'subtotal_tax_free_amount' => $item->taxFreeAmount,
'subtotal_points_used_amount' => $item->pointsUsedShare,
'subtotal_earned_points_amount' => $item->pointsEarning,
];
if ($productSnapshot) {
$createData['product_snapshot'] = $productSnapshot;
}
if ($optionSnapshot) {
$createData['option_snapshot'] = $optionSnapshot;
}
OrderOption::factory()->forOrder($order)->create($createData);
}
// OrderShipping 생성
OrderShipping::factory()->forOrder($order)->create([
'shipping_status' => 'pending',
'base_shipping_amount' => $result->summary->baseShippingTotal,
'extra_shipping_amount' => $result->summary->extraShippingTotal,
'total_shipping_amount' => $result->summary->totalShipping,
'shipping_discount_amount' => $result->summary->shippingDiscount,
]);
// OrderPayment 생성
OrderPayment::factory()->forOrder($order)->create([
'payment_status' => PaymentStatusEnum::PAID,
'paid_amount_local' => $result->summary->finalAmount,
'paid_amount_base' => $result->summary->finalAmount,
'paid_at' => now(),
]);
// 쿠폰 사용 처리
foreach ($input->couponIssueIds as $couponIssueId) {
CouponIssue::where('id', $couponIssueId)->update([
'status' => CouponIssueRecordStatus::USED,
'used_at' => now(),
'order_id' => $order->id,
]);
}
return $order->load(['options', 'shippings', 'payment']);
}
/**
* CancellationAdjustment를 생성합니다.
*
* @param array $cancelItems [{order_option_id, cancel_quantity}]
*/
protected function buildCancellation(array $cancelItems): CancellationAdjustment
{
return CancellationAdjustment::fromArray($cancelItems);
}
/**
* 주문의 모든 옵션을 전량 취소하는 CancellationAdjustment를 생성합니다.
*
* @param Order $order 주문
*/
protected function buildFullCancellation(Order $order): CancellationAdjustment
{
$items = [];
foreach ($order->options as $option) {
$items[] = [
'order_option_id' => $option->id,
'cancel_quantity' => $option->quantity,
];
}
return CancellationAdjustment::fromArray($items);
}
// ================================================================
// A-1. 전체취소 기본 (5건)
// ================================================================
/**
* A-1-1: 단일 옵션 할인없음 → 전체취소
*
* 옵션1개(20,000×2) 할인없음 → 전체취소 → refund=40,000
*/
public function test_full_cancel_single_option_no_discount(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertEquals(40000, $result->refundAmount);
$this->assertEquals(40000, $result->originalPaidAmount);
$this->assertEquals(0, $result->recalculatedPaidAmount);
}
/**
* A-1-2: 다중 옵션 할인없음 → 전체취소
*
* 옵션2개(20,000×1 + 10,000×3) → 전체취소 → refund=50,000
*/
public function test_full_cancel_multiple_options_no_discount(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertEquals(50000, $result->refundAmount);
}
/**
* A-1-3: 쿠폰+배송비 적용된 주문 → 전체취소 → 원래 결제금액 전액 환불
*/
public function test_full_cancel_returns_original_paid_amount(): void
{
$shippingPolicy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
[$product, $option] = $this->createProductWithOption(price: 30000, shippingPolicy: $shippingPolicy);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
// 전체취소 = 원래 결제금액 전액 환불
$this->assertEquals($originalPaid, $result->refundAmount);
}
/**
* A-1-4: 마일리지 3,000원 사용 주문 → 전체취소
*
* refund = 결제금액, pointsRefund = 3,000
*/
public function test_full_cancel_with_mileage_used(): void
{
[$product, $option] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
// PG 환불금액 = 결제금액 (30,000 - 3,000 = 27,000)
$this->assertEquals(27000, $result->refundAmount);
// 마일리지 환불
$this->assertEquals(3000, $result->refundPointsAmount);
}
/**
* A-1-5: 전액 쿠폰 결제(paid=0) → 전체취소
*/
public function test_full_cancel_zero_amount_order(): void
{
[$product, $option] = $this->createProductWithOption(price: 10000);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 10000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertEquals(0, $result->refundAmount);
$this->assertContains($couponIssue->id, $result->restoredCouponIssueIds);
}
// ================================================================
// A-2. 부분취소 기본 — 할인/배송비 없음 (6건)
// ================================================================
/**
* A-2-1: 2개 옵션 중 1개 취소
*
* A(20,000)+B(10,000), A 취소 → refund=20,000
*/
public function test_partial_cancel_one_of_two_options(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionToCancel = $order->options->firstWhere('product_option_id', $optionA->id);
$adjustment = $this->buildCancellation([
['order_option_id' => $optionToCancel->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertEquals(20000, $result->refundAmount);
}
/**
* A-2-2: 수량 부분 취소
*
* A(10,000×5), 수량3 취소 → refund=30,000
*/
public function test_partial_cancel_reduces_quantity(): void
{
[$product, $option] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 5),
]);
$order = $this->createOrderFromCalculation($input);
$orderOption = $order->options->first();
$adjustment = $this->buildCancellation([
['order_option_id' => $orderOption->id, 'cancel_quantity' => 3],
]);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertEquals(30000, $result->refundAmount);
$this->assertEquals(50000, $result->originalPaidAmount);
$this->assertEquals(20000, $result->recalculatedPaidAmount);
}
/**
* A-2-3: 모든 옵션 전량 취소 → 전체취소 전환
*/
public function test_partial_cancel_all_quantity_equals_full_cancel(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
// 모든 옵션 전량 지정 → 전체취소와 동일
$items = [];
foreach ($order->options as $opt) {
$items[] = ['order_option_id' => $opt->id, 'cancel_quantity' => $opt->quantity];
}
$adjustment = $this->buildCancellation($items);
$result = $this->adjustmentService->calculate($order, $adjustment);
// 전액 환불
$this->assertEquals(30000, $result->refundAmount);
$this->assertEquals(0, $result->recalculatedPaidAmount);
}
/**
* A-2-4: 3개 옵션 중 1개만 취소
*/
public function test_partial_cancel_single_item_from_three(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 15000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
[$pC, $oC] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$adjustment = $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $adjustment);
// B(20,000) 취소 → refund=20,000
$this->assertEquals(20000, $result->refundAmount);
}
/**
* A-2-5: 3개 옵션 중 2개 동시 취소
*/
public function test_partial_cancel_multiple_items_simultaneously(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 15000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
[$pC, $oC] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$optC = $order->options->firstWhere('product_option_id', $oC->id);
$adjustment = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
['order_option_id' => $optC->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $adjustment);
// A(15,000)+C(10,000) 취소 → refund=25,000
$this->assertEquals(25000, $result->refundAmount);
}
/**
* A-2-6: 다중 옵션 부분 수량 취소
*
* A 5개→2개취소, B 3개→1개취소
*/
public function test_partial_cancel_partial_quantity_from_multiple(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 10000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 5),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$adjustment = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 2],
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $adjustment);
// A 2개취소(20,000) + B 1개취소(20,000) = 40,000
$this->assertEquals(40000, $result->refundAmount);
// 잔여: A 3개(30,000) + B 2개(40,000) = 70,000
$this->assertEquals(70000, $result->recalculatedPaidAmount);
}
// ================================================================
// A-3. 배송비 정책별 전체취소 (주요 6종)
// ================================================================
/**
* A-3-1: FREE 배송비 전체취소
*/
public function test_full_cancel_free_shipping(): void
{
$sp = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FREE);
[$p, $o] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
$this->assertEquals(30000, $result->refundAmount);
}
/**
* A-3-2: FIXED 배송비(3,000원) 전체취소 → 배송비 포함 환불
*/
public function test_full_cancel_fixed_shipping(): void
{
$sp = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FIXED, baseFee: 3000);
[$p, $o] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 30,000 + 배송비 3,000 = 33,000
$this->assertEquals(33000, $result->refundAmount);
}
/**
* A-3-3: CONDITIONAL_FREE 무료배송 충족 → 전체취소
*/
public function test_full_cancel_conditional_free_met(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$p, $o] = $this->createProductWithOption(price: 60000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 무료배송이었으므로 상품금액만 환불
$this->assertEquals(60000, $result->refundAmount);
}
/**
* A-3-4: CONDITIONAL_FREE 미충족 → 전체취소 → 배송비 포함
*/
public function test_full_cancel_conditional_free_not_met(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$p, $o] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 30,000 + 배송비 3,000 = 33,000
$this->assertEquals(33000, $result->refundAmount);
}
/**
* A-3-5: RANGE_AMOUNT 구간 배송비 전체취소
*
* ranges: 0~30,000→3,000, 30,000+→1,000
* 주문 20,000원 → 배송비 3,000원 구간
*/
public function test_full_cancel_range_amount_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 30000, 'fee' => 3000],
['min' => 30000, 'max' => null, 'fee' => 1000],
],
],
);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 20,000 + 배송비 3,000 = 23,000
$this->assertEquals(23000, $result->refundAmount);
}
/**
* A-3-6: PER_QUANTITY 수량당 배송비 전체취소
*
* unit=5, fee=1,000 → 12개 → ceil(12/5)=3 → 배송비 3,000
*/
public function test_full_cancel_per_quantity_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 5],
);
[$p, $o] = $this->createProductWithOption(price: 1000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 12),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
$originalPaid = (float) $order->total_paid_amount;
$this->assertEquals($originalPaid, $result->refundAmount);
}
// ================================================================
// A-4. CONDITIONAL_FREE 부분취소 — 무료↔유료 전환 (8건)
// 핵심 시나리오: 이슈 #29 예시 1번의 일반화
// ================================================================
/**
* A-4-1: 무료배송 유지 (잔여금액 ≥ threshold)
*
* 60,000 주문(무료), 5,000 취소 → 잔여 55,000 ≥ 50,000 → refund=5,000
*/
public function test_conditional_free_stays_free_after_partial(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 55000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 잔여 55,000 ≥ 50,000 → 무료배송 유지 → 배송비 변동 없음
$this->assertEquals(5000, $result->refundAmount);
$this->assertEquals(0, $result->shippingDifference);
}
/**
* A-4-2: 무료→유료 전환 (잔여금액 < threshold)
*
* ⭐ 이슈 #29 예시 1: 60,000 주문(무료), 20,000 취소 → 잔여 40,000 < 50,000
* → 배송비 3,000 발생 → refund = 20,000 - 3,000 = 17,000
*/
public function test_conditional_free_becomes_paid_after_partial(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
// 10,000 × 3 + 10,000 × 3 = 60,000 (무료배송)
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
$this->assertEquals(0, (float) $order->total_shipping_amount);
$this->assertEquals(60000, (float) $order->total_paid_amount);
// B옵션 2개 취소 → 잔여 = 30,000 + 10,000 = 40,000 < 50,000
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 2],
]));
// 원 결제: 60,000, 재계산: 40,000 + 배송비 3,000 = 43,000
// 환불: 60,000 - 43,000 = 17,000
$this->assertEquals(17000, $result->refundAmount);
}
/**
* A-4-3: 경계값 — 정확히 threshold 금액 → 무료배송 유지
*/
public function test_conditional_free_exact_threshold_stays_free(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 50000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 잔여 정확히 50,000 = threshold → 무료배송 유지
$this->assertEquals(10000, $result->refundAmount);
}
/**
* A-4-4: 수량 부분취소로 유료 전환
*
* 10,000×6개=60,000(무료), 3개 취소→30,000 < 50,000
* → refund = 30,000 - 3,000 = 27,000
*/
public function test_conditional_free_partial_qty_triggers_paid(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 6),
]);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 3],
]));
// 원: 60,000, 재계산: 30,000 + 배송비 3,000 = 33,000
// 환불: 60,000 - 33,000 = 27,000
$this->assertEquals(27000, $result->refundAmount);
}
// ================================================================
// A-8. 상품쿠폰(PRODUCT_AMOUNT) + 부분취소 (4건)
// ================================================================
/**
* A-8-1: 상품쿠폰 정액 + 부분취소
*
* A(20,000)+B(10,000), 상품쿠폰 정액 1,000 ALL → A 취소
* 재계산: B(10,000) - 쿠폰1,000 = 9,000
*/
public function test_product_coupon_fixed_partial_cancel(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// 환불 = 원 결제금액 - 재계산 결제금액
// 재계산: B만 남은 상태에서 쿠폰 적용 결과
$this->assertEquals($originalPaid - $result->recalculatedPaidAmount, $result->refundAmount);
$this->assertGreaterThan(0, $result->refundAmount);
$this->assertLessThan($originalPaid, $result->recalculatedPaidAmount);
}
/**
* A-8-2: 상품쿠폰 정률 + 부분취소
*
* A(20,000)+B(10,000), 상품쿠폰 정률 10% ALL → A 취소
*/
public function test_product_coupon_rate_partial_cancel(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// 환불 = 원 결제금액 - 재계산 결제금액
$this->assertEquals($originalPaid - $result->recalculatedPaidAmount, $result->refundAmount);
$this->assertGreaterThan(0, $result->refundAmount);
$this->assertLessThan($originalPaid, $result->recalculatedPaidAmount);
}
/**
* A-8-3: 특정상품 쿠폰, 대상 취소 → 쿠폰 할인 소멸
*
* A(20,000)+B(10,000), 쿠폰 적용 대상=A → A 취소 → 쿠폰 할인 0
*/
public function test_product_coupon_target_cancelled_discount_removed(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 15,
targetScope: CouponTargetScope::PRODUCTS,
);
$coupon->coupon->includedProducts()->attach([$pA->id], ['type' => 'include']);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// 재계산: B(10,000) 쿠폰 대상 아님 → 할인 0 → paid=10,000
$this->assertEquals(10000, $result->recalculatedPaidAmount);
}
// ================================================================
// A-9. 주문쿠폰(ORDER_AMOUNT) + 부분취소 (5건)
// 핵심: 이슈 #29 예시 2번의 일반화
// ================================================================
/**
* A-9-1: 주문쿠폰 min_order 미달 → 쿠폰 복원
*
* ⭐ 이슈 #29 예시 2:
* 40,000 주문, 주문쿠폰 10%(min30,000) → 36,000 결제
* 20,000 취소 → 잔여 20,000 < 30,000 → 쿠폰 복원
* 재계산: 20,000 (할인없음)
* 환불: 36,000 - 20,000 = 16,000
*/
public function test_order_coupon_min_order_not_met_coupon_restored(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 30000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
// 원 결제: 40,000 - 4,000(10%) = 36,000
$this->assertEquals(36000, (float) $order->total_paid_amount);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// 잔여 20,000 < min 30,000 → 쿠폰 복원 → 할인 0
// 재계산: 20,000
// 환불: 36,000 - 20,000 = 16,000
$this->assertEquals(16000, $result->refundAmount);
$this->assertContains($coupon->id, $result->restoredCouponIssueIds);
}
/**
* A-9-2: 주문쿠폰 min_order 충족 유지
*
* 40,000 주문, 주문쿠폰 10%(min30,000) → 36,000 결제
* 5,000 취소 → 잔여 35,000 ≥ 30,000 → 쿠폰 유지
*/
public function test_order_coupon_min_order_still_met(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 35000);
[$pB, $oB] = $this->createProductWithOption(price: 5000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 30000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 잔여 35,000 ≥ 30,000 → 쿠폰 유지
// 재계산: 35,000 - 3,500(10%) = 31,500
$this->assertEquals(31500, $result->recalculatedPaidAmount);
$this->assertNotContains($coupon->id, $result->restoredCouponIssueIds);
}
/**
* A-9-3: 주문쿠폰 정액 + min_order 미달 → 쿠폰 복원
*
* 40,000 주문, 주문쿠폰 정액 5,000원(min30,000) → 35,000 결제
* 15,000 취소 → 잔여 25,000 < 30,000 → 쿠폰 복원
* 환불: 35,000 - 25,000 = 10,000
*/
public function test_order_coupon_fixed_min_not_met(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 25000);
[$pB, $oB] = $this->createProductWithOption(price: 15000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 30000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$this->assertEquals(35000, (float) $order->total_paid_amount);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 잔여 25,000 < min 30,000 → 쿠폰 복원 → 할인 0
// 재계산: 25,000
// 환불: 35,000 - 25,000 = 10,000
$this->assertEquals(10000, $result->refundAmount);
$this->assertContains($coupon->id, $result->restoredCouponIssueIds);
}
/**
* A-9-4: 주문쿠폰 정확히 threshold 경계값
*/
public function test_order_coupon_exact_threshold_maintained(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 30000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 잔여 30,000 = min → 쿠폰 유지
$this->assertEquals(27000, $result->recalculatedPaidAmount);
$this->assertNotContains($coupon->id, $result->restoredCouponIssueIds);
}
/**
* A-9-5: 주문쿠폰 min 없음 (0) → 항상 유지
*/
public function test_order_coupon_no_min_always_maintained(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 0,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// min=0 → 항상 충족 → 쿠폰 유지
// 재계산: 10,000 - 5,000 = 5,000
$this->assertEquals(5000, $result->recalculatedPaidAmount);
$this->assertNotContains($coupon->id, $result->restoredCouponIssueIds);
}
// ================================================================
// A-11. 마일리지 + 부분취소 (3건)
// ================================================================
/**
* A-11-1: 마일리지 사용 + 옵션1개 취소
*
* 마일리지 3,000 사용, A(20,000)+B(10,000) → 27,000 결제
* A 취소 → 잔여 B 10,000 - 마일리지안분
*/
public function test_mileage_partial_cancel_single_option(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// 총 환불 (PG + 마일리지)
$totalRefund = $result->refundAmount + $result->refundPointsAmount;
// A가 전체의 2/3를 차지하므로 대략 환불액은 원결제의 2/3 정도
$this->assertGreaterThan(0, $result->refundAmount);
$this->assertGreaterThanOrEqual(0, $result->refundPointsAmount);
}
/**
* A-11-2: 마일리지 전액 결제(paid=0) + 부분취소
*/
public function test_mileage_full_payment_partial_cancel(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 10000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 20000,
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// PG 환불 0원 (전액 마일리지였으므로)
$this->assertEquals(0, $result->refundAmount);
// 마일리지 환불 = 취소분
$this->assertGreaterThan(0, $result->refundPointsAmount);
}
/**
* A-11-3: 마일리지 + 쿠폰 복합 부분취소
*/
public function test_mileage_with_coupon_partial_cancel(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// 복합: 쿠폰 + 마일리지 재계산
$totalRefund = $result->refundAmount + $result->refundPointsAmount;
$this->assertGreaterThan(0, $totalRefund);
// 원결제 > 재계산 결제
$this->assertGreaterThan($result->recalculatedPaidAmount, $originalPaid);
}
// ================================================================
// A-12. 복합 시나리오 — 배송비 + 쿠폰 + 마일리지 동시 (6건)
// ================================================================
/**
* A-12-1: 이슈 예시 1 정밀 재현
*
* 10,000×3 + 15,000×2 = 60,000, CONDITIONAL_FREE(50,000, 3,000)
* 15,000×2 취소(30,000) → 잔여 30,000 < 50,000
* → refund = 30,000 - 3,000 = 27,000
*/
public function test_issue_example_1_precise(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 15000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 2),
]);
$order = $this->createOrderFromCalculation($input);
$this->assertEquals(60000, (float) $order->total_paid_amount);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 2],
]));
// 잔여: 30,000 < 50,000 → 배송비 3,000 발생
// 재계산: 30,000 + 3,000 = 33,000
// 환불: 60,000 - 33,000 = 27,000
$this->assertEquals(27000, $result->refundAmount);
}
/**
* A-12-2: 이슈 예시 2 정밀 재현
*
* 20,000×2 = 40,000, 주문쿠폰 10%(min30,000) → 36,000 결제
* 20,000×1 취소 → 잔여 20,000 < 30,000 → 쿠폰 복원
* 재계산: 20,000 (할인없음)
* 환불: 36,000 - 20,000 = 16,000
*/
public function test_issue_example_2_precise(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 30000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$this->assertEquals(36000, (float) $order->total_paid_amount);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
$this->assertEquals(16000, $result->refundAmount);
$this->assertContains($coupon->id, $result->restoredCouponIssueIds);
}
/**
* A-12-3: 상품쿠폰 + 배송비전환
*
* A(30,000)+B(20,000), 상품쿠폰 10% ALL, CONDITIONAL_FREE(40,000, 3,000)
* B 취소 → 잔여 A 27,000(할인후) < 40,000 → 배송비 발생
*/
public function test_product_coupon_with_shipping_transition(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 40000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 재계산: A 30,000 - 3,000(10%) = 27,000 + 배송비 3,000 = 30,000
$this->assertEquals(30000, $result->recalculatedPaidAmount);
$this->assertEquals($originalPaid - 30000, $result->refundAmount);
}
/**
* A-12-4: 마일리지 + CONDITIONAL_FREE 배송비 전환
*/
public function test_mileage_with_conditional_free_transition(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 40000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 5000,
);
$order = $this->createOrderFromCalculation($input);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 잔여: 40,000 < 50,000 → 배송비 3,000 발생
// 배송비+마일리지 재계산 → 환불액 계산
$this->assertGreaterThan(0, $result->refundAmount);
}
/**
* A-12-5: 전량 부분취소 → 전체취소 전환 검증
*
* 2옵션 모두 전량 취소 지정 → full cancel 결과와 동일
*/
public function test_all_items_partial_cancel_equals_full_cancel(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 15000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 30000,
);
$sp = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FIXED, baseFee: 3000);
// 배송비 정책 연결을 위해 배송정책이 있는 상품 재생성
[$pA2, $oA2] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB2, $oB2] = $this->createProductWithOption(price: 15000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA2->id, productOptionId: $oA2->id, quantity: 1),
new CalculationItem(productId: $pB2->id, productOptionId: $oB2->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
// 전체취소
$fullResult = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 부분취소 (모든 옵션 전량)
$items = [];
foreach ($order->options as $opt) {
$items[] = ['order_option_id' => $opt->id, 'cancel_quantity' => $opt->quantity];
}
$partialResult = $this->adjustmentService->calculate($order, $this->buildCancellation($items));
// 동일한 환불액
$this->assertEquals($fullResult->refundAmount, $partialResult->refundAmount);
$this->assertEquals($fullResult->refundPointsAmount, $partialResult->refundPointsAmount);
}
/**
* A-12-6: 주문쿠폰 + 배송비쿠폰 + 부분취소
*
* 주문쿠폰 10%OFF + 배송비쿠폰, CONDITIONAL_FREE
*/
public function test_order_coupon_shipping_coupon_partial_cancel(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$orderCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 40000,
);
$shippingCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$orderCoupon->id, $shippingCoupon->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 잔여 30,000 < min 40,000 → 주문쿠폰 복원
// 잔여 30,000 < 50,000 → 배송비 3,000 발생 → 배송비쿠폰으로 상쇄
$this->assertContains($orderCoupon->id, $result->restoredCouponIssueIds);
$this->assertGreaterThan(0, $result->refundAmount);
}
// ================================================================
// A-13. 안분 정확성 검증 (4건)
// ================================================================
/**
* A-13-1: 3개 옵션 안분 합계 = 원래 금액
*/
public function test_apportion_sum_equals_original(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 13000);
[$pB, $oB] = $this->createProductWithOption(price: 17000);
[$pC, $oC] = $this->createProductWithOption(price: 9000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
// 옵션별 paid_amount 합 = total_paid_amount
$optionPaidSum = $order->options->sum('subtotal_paid_amount');
$this->assertEquals(
(float) $order->total_paid_amount,
(float) $optionPaidSum,
'Option paid amounts should sum to order total paid amount (apportion accuracy)'
);
}
/**
* A-13-2: 부분취소 후 잔여 옵션 재계산 합계 일관성
*/
public function test_apportion_consistency_after_partial_cancel(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 15000);
[$pB, $oB] = $this->createProductWithOption(price: 25000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 2),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// optionUpdates의 paid amount 합 = recalculatedPaidAmount
if (! empty($result->optionUpdates)) {
$updatedPaidSum = 0;
foreach ($result->optionUpdates as $update) {
$updatedPaidSum += $update['subtotal_paid_amount'] ?? 0;
}
$this->assertEquals(
$result->recalculatedPaidAmount,
$updatedPaidSum,
'Option updates sum should match recalculated paid amount'
);
}
// 기본 검증: refund > 0
$this->assertGreaterThan(0, $result->refundAmount);
}
/**
* A-13-3: 단일 옵션 → 할인 전액 해당 옵션
*/
public function test_apportion_single_item_gets_all(): void
{
[$p, $o] = $this->createProductWithOption(price: 30000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
// 단일 옵션이므로 할인 전액이 이 옵션에 적용
$option = $order->options->first();
$this->assertEquals(25000, (float) $option->subtotal_paid_amount);
}
/**
* A-13-4: 동일 금액 옵션 균등 안분
*/
public function test_apportion_equal_subtotals_split(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 10000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
[$pC, $oC] = $this->createProductWithOption(price: 10000);
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
// 3개 동일 금액 → 균등 안분 (각 1,000원 할인)
// 합계가 정확해야 함
$paidSum = $order->options->sum('subtotal_paid_amount');
$this->assertEquals(27000, (float) $paidSum);
}
// ================================================================
// A-3 나머지: 배송비 정책별 전체취소 (8건 추가)
// ================================================================
/**
* A-3-7: RANGE_QUANTITY 구간별(수량) 배송비 전체취소
*
* ranges: 1~5→2500, 6+→4000
* 3개×10,000 → 수량3 → 2500원 구간
*/
public function test_full_cancel_range_quantity_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
ranges: [
'tiers' => [
['min' => 0, 'max' => 6, 'fee' => 2500],
['min' => 6, 'max' => null, 'fee' => 4000],
],
],
);
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 30,000 + 배송비 2,500 = 32,500
$this->assertEquals(32500, $result->refundAmount);
}
/**
* A-3-8: RANGE_WEIGHT 구간별(무게) 배송비 전체취소
*
* ranges: 0~500g→2500, 500g+→4000
* 상품 무게 0.3kg × 1개 = 300g → 2500원 구간
*/
public function test_full_cancel_range_weight_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 0.5, 'fee' => 2500],
['min' => 0.5, 'max' => null, 'fee' => 4000],
],
],
);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 300);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 20,000 + 배송비 2,500 = 22,500
$this->assertEquals(22500, $result->refundAmount);
}
/**
* A-3-9: RANGE_VOLUME 구간별(부피) 배송비 전체취소
*
* ranges: 0~1000cm³→3000, 1000+→5000
* 부피 800cm³ → 3000원 구간
*/
public function test_full_cancel_range_volume_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME,
ranges: [
'tiers' => [
['min' => 0, 'max' => 1, 'fee' => 3000],
['min' => 1, 'max' => null, 'fee' => 5000],
],
],
);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, volume: 800);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 20,000 + 배송비 3,000 = 23,000
$this->assertEquals(23000, $result->refundAmount);
}
/**
* A-3-10: RANGE_VOLUME_WEIGHT 구간별(부피+무게) 배송비 전체취소
*
* divisor=6000, 무게=0.5kg, 부피=4200cm³
* 부피무게=4200/6000=0.7kg → max(0.5, 0.7)=0.7kg=700g
* ranges: 0~500g→3000, 500g+→5000 → 5000원
*/
public function test_full_cancel_range_volume_weight_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 0.5, 'fee' => 3000],
['min' => 0.5, 'max' => null, 'fee' => 5000],
],
'volume_weight_divisor' => 6000,
],
);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 500, volume: 4200);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 20,000 + 배송비 5,000 = 25,000
$this->assertEquals(25000, $result->refundAmount);
}
/**
* A-3-11: PER_WEIGHT 무게당 배송비 전체취소
*
* unit=0.5kg, fee=2000. 무게 1.2kg → ceil(1.2/0.5)=3 → 6,000원
*/
public function test_full_cancel_per_weight_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 0.5],
);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 1200);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
$originalPaid = (float) $order->total_paid_amount;
$this->assertEquals($originalPaid, $result->refundAmount);
}
/**
* A-3-12: PER_VOLUME 부피당 배송비 전체취소
*
* unit=1000cm³, fee=1500. 부피 2500cm³ → ceil(2500/1000)=3 → 4,500원
*/
public function test_full_cancel_per_volume_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_VOLUME,
baseFee: 1500,
ranges: ['unit_value' => 1],
);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, volume: 2500);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
$originalPaid = (float) $order->total_paid_amount;
$this->assertEquals($originalPaid, $result->refundAmount);
}
/**
* A-3-13: PER_VOLUME_WEIGHT 부피무게당 배송비 전체취소
*
* divisor=6000, unit=0.5kg, fee=2000
* 무게=0.3kg, 부피=4200cm³ → 부피무게=0.7kg → max(0.3,0.7)=0.7
* ceil(0.7/0.5)=2 → 4,000원
*/
public function test_full_cancel_per_volume_weight_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_VOLUME_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 0.5, 'volume_weight_divisor' => 6000],
);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 300, volume: 4200);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
$originalPaid = (float) $order->total_paid_amount;
$this->assertEquals($originalPaid, $result->refundAmount);
}
/**
* A-3-14: PER_AMOUNT 금액당 배송비 전체취소
*
* unit=10000, fee=500. 금액 35,000 → ceil(35000/10000)=4 → 2,000원
*/
public function test_full_cancel_per_amount_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_AMOUNT,
baseFee: 500,
ranges: ['unit_value' => 10000],
);
[$p, $o] = $this->createProductWithOption(price: 35000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
$originalPaid = (float) $order->total_paid_amount;
$this->assertEquals($originalPaid, $result->refundAmount);
}
// ================================================================
// A-4 나머지: CONDITIONAL_FREE 부분취소 (4건 추가)
// ================================================================
/**
* A-4-5: 연속 2회 부분취소 — 1차 무료유지, 2차 유료전환
*
* 70,000→55,000(1차)→45,000(2차)
* threshold=50,000, fee=3,000
*/
public function test_conditional_free_multiple_partial_cancels(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 15000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pC, $oC] = $this->createProductWithOption(price: 35000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
// 1차: 15,000원 옵션 취소 → 잔여 55,000 ≥ 50,000 → 무료배송 유지
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result1 = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
$this->assertEquals(15000, $result1->refundAmount);
// 2차: 20,000원 옵션 취소 → 잔여 35,000 < 50,000 → 유료전환
$optionB = $order->options->where('product_option_id', $oB->id)->first();
$result2 = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
['order_option_id' => $optionB->id, 'cancel_quantity' => 1],
]));
// 누적 취소: A(15K)+B(20K)=35K, 잔여=35K, 배송비 3K 발생
// refund = 70K - (35K + 3K) = 32,000
$this->assertEquals(32000, $result2->refundAmount);
}
/**
* A-4-7: 높은 threshold (100,000원)
*
* threshold=100,000, fee=3,000
* 120,000 주문, 30,000 취소 → 잔여 90,000 < 100,000
*/
public function test_conditional_free_high_threshold(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 100000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 90000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 잔여 90,000 < 100,000 → 배송비 3,000 발생
// refund = 120,000 - (90,000 + 3,000) = 27,000
$this->assertEquals(27000, $result->refundAmount);
}
/**
* A-4-8: fee=0인 CONDITIONAL_FREE → 전환돼도 배송비 0
*
* threshold=50,000, fee=0
* 60,000→40,000 (유료 전환이지만 fee=0)
*/
public function test_conditional_free_zero_base_fee(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 0,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 40000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// fee=0이므로 전환되어도 배송비 0 → refund = 20,000
$this->assertEquals(20000, $result->refundAmount);
}
// ================================================================
// A-5. RANGE 기반 배송비 부분취소 — 구간 변동 (10건)
// ================================================================
/**
* A-5-1: RANGE_AMOUNT 상위→중간 구간 변동
*
* 0~20K→3000, 20K~50K→2000, 50K+→0
* 60K→40K 부분취소 → 배송비 0→2000
*/
public function test_range_amount_upper_to_middle_tier(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 20000, 'fee' => 3000],
['min' => 20000, 'max' => 50000, 'fee' => 2000],
['min' => 50000, 'max' => null, 'fee' => 0],
],
],
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 40000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 잔여 40K → 20K~50K 구간 → 배송비 2,000
// refund = 60K - (40K + 2K) = 18,000
$this->assertEquals(18000, $result->refundAmount);
}
/**
* A-5-2: RANGE_AMOUNT 중간→하위 구간 변동
*
* 0~20K→3000, 20K~50K→2000
* 25K→15K → 배송비 2000→3000
*/
public function test_range_amount_middle_to_lower_tier(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 20000, 'fee' => 3000],
['min' => 20000, 'max' => 50000, 'fee' => 2000],
],
],
);
[$pA, $oA] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 15000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 잔여 15K → 0~20K 구간 → 배송비 3,000
// 원래: 25K(2K배송)=27K결제, 재계산: 15K+3K=18K
// refund = 27K - 18K = 9,000
$this->assertEquals(9000, $result->refundAmount);
}
/**
* A-5-3: RANGE_AMOUNT 동일 구간 내 부분취소 → 배송비 변동 없음
*/
public function test_range_amount_same_tier_no_shipping_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 20000, 'fee' => 3000],
['min' => 20000, 'max' => 50000, 'fee' => 2000],
],
],
);
[$pA, $oA] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 40000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래 45K(2K배송)=47K, 잔여 40K → 동일 구간(20K~50K) → 배송비 2K 유지
// refund = 47K - (40K + 2K) = 5,000
$this->assertEquals(5000, $result->refundAmount);
}
/**
* A-5-4: RANGE_QUANTITY 구간 변동 (8개→4개)
*
* 1~5→2500, 6~10→4000
*/
public function test_range_quantity_tier_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
ranges: [
'tiers' => [
['min' => 0, 'max' => 6, 'fee' => 2500],
['min' => 6, 'max' => null, 'fee' => 4000],
],
],
);
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 8),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 4],
]));
// 원래: 40K+4K배송=44K, 잔여4개: 20K+2.5K=22.5K
// refund = 44K - 22.5K = 21,500
$this->assertEquals(21500, $result->refundAmount);
}
/**
* A-5-5: RANGE_QUANTITY 구간 내 부분취소 (10→8, 동일 구간)
*/
public function test_range_quantity_same_tier(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
ranges: [
'tiers' => [
['min' => 0, 'max' => 6, 'fee' => 2500],
['min' => 6, 'max' => null, 'fee' => 4000],
],
],
);
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 10),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 50K+4K=54K, 잔여8개(동일구간): 40K+4K=44K
// refund = 54K - 44K = 10,000
$this->assertEquals(10000, $result->refundAmount);
}
/**
* A-5-6: RANGE_WEIGHT 구간 변동 (700g→400g)
*
* 0~500g→2500, 500g+→4000
*/
public function test_range_weight_tier_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 0.5, 'fee' => 2500],
['min' => 0.5, 'max' => null, 'fee' => 4000],
],
],
);
// 0.35kg × 2개 = 0.7kg = 700g → 4000원 구간
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 350);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 2),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
// 1개 취소 → 0.35kg = 350g → 2500원 구간
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 1],
]));
// 원래: 20K+4K=24K, 잔여: 10K+2.5K=12.5K
// refund = 24K - 12.5K = 11,500
$this->assertEquals(11500, $result->refundAmount);
}
/**
* A-5-7: RANGE_VOLUME 구간 변동 (1500→800)
*
* 0~1000→3000, 1000+→5000
*/
public function test_range_volume_tier_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME,
ranges: [
'tiers' => [
['min' => 0, 'max' => 1, 'fee' => 3000],
['min' => 1, 'max' => null, 'fee' => 5000],
],
],
);
// 750cm³ × 2개 = 1500cm³ → 5000원 구간
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, volume: 750);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 2),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
// 1개 취소 → 750cm³ → 3000원 구간
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 1],
]));
// 원래: 20K+5K=25K, 잔여: 10K+3K=13K
// refund = 25K - 13K = 12,000
$this->assertEquals(12000, $result->refundAmount);
}
/**
* A-5-8: RANGE_VOLUME_WEIGHT 부피무게 구간 전환
*
* divisor=6000, 0~500g→3000, 500g+→5000
* 무게0.2kg×3=0.6kg, 부피1500cm³×3=4500cm³→부피무게=0.75kg
* max(0.6,0.75)=0.75kg=750g → 5000원
* 1개 취소 → 무게0.4, 부피3000→부피무게0.5, max(0.4,0.5)=0.5kg=500g
* tier boundary: 500 >= 0 && 500 < 500 → false (상한 미만) → 500g+ 구간 = 5000원 (변동없음)
* → 2개 취소로 확실하게 구간 변동 검증
*/
public function test_range_volume_weight_tier_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_VOLUME_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 0.5, 'fee' => 3000],
['min' => 0.5, 'max' => null, 'fee' => 5000],
],
'volume_weight_divisor' => 6000,
],
);
// 0.2kg, 1500cm³ per item
// 3개: 무게0.6kg, 부피4500→부피무게0.75, max(0.6,0.75)=750g → 5000원
// 1개: 무게0.2kg, 부피1500→부피무게0.25, max(0.2,0.25)=250g → 3000원
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 200, volume: 1500);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 30K+5K=35K, 잔여1개: 10K+3K=13K
// refund = 35K - 13K = 22,000
$this->assertEquals(22000, $result->refundAmount);
}
/**
* A-5-9: RANGE_AMOUNT 최상위→최하위 극단 변동
*
* 0~10K→5000, 10K~30K→3000, 30K+→0
* 50K→5K
*/
public function test_range_amount_extreme_tier_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 10000, 'fee' => 5000],
['min' => 10000, 'max' => 30000, 'fee' => 3000],
['min' => 30000, 'max' => null, 'fee' => 0],
],
],
);
[$pA, $oA] = $this->createProductWithOption(price: 45000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 50K+0배송=50K, 잔여5K+5K배송=10K
// refund = 50K - 10K = 40,000
$this->assertEquals(40000, $result->refundAmount);
}
/**
* A-5-10: RANGE_QUANTITY 1개만 남기기 → 최소 구간
*/
public function test_range_quantity_down_to_one(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_QUANTITY,
ranges: [
'tiers' => [
['min' => 0, 'max' => 3, 'fee' => 5000],
['min' => 3, 'max' => 6, 'fee' => 3000],
['min' => 6, 'max' => null, 'fee' => 1000],
],
],
);
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 7),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 6],
]));
// 원래: 35K+1K=36K, 잔여1개: 5K+5K=10K
// refund = 36K - 10K = 26,000
$this->assertEquals(26000, $result->refundAmount);
}
// ================================================================
// A-6. PER_UNIT 기반 배송비 부분취소 — 단위 변동 (10건)
// ================================================================
/**
* A-6-1: PER_QUANTITY 단위 변동 (12→7개)
*
* unit=5, fee=1000. 12→7 → ceil(12/5)=3→ceil(7/5)=2
*/
public function test_per_quantity_unit_change_12_to_7(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 5],
);
[$p, $o] = $this->createProductWithOption(price: 1000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 12),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 5],
]));
// 원래: 12K+3K=15K, 잔여7개: 7K+ceil(7/5)×1K=7K+2K=9K
// refund = 15K - 9K = 6,000
$this->assertEquals(6000, $result->refundAmount);
}
/**
* A-6-2: PER_QUANTITY 큰 단위 변동 (12→5개)
*
* unit=5, fee=1000. ceil(12/5)=3 → ceil(5/5)=1
*/
public function test_per_quantity_unit_change_12_to_5(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 5],
);
[$p, $o] = $this->createProductWithOption(price: 1000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 12),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 7],
]));
// 원래: 12K+3K=15K, 잔여5개: 5K+1K=6K
// refund = 15K - 6K = 9,000
$this->assertEquals(9000, $result->refundAmount);
}
/**
* A-6-3: PER_QUANTITY 단위 내 부분취소 (5→3, 동일 단위수)
*
* unit=5, fee=1000. ceil(5/5)=1 = ceil(3/5)=1
*/
public function test_per_quantity_same_unit_count(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 5],
);
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 5),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 25K+1K=26K, 잔여3개: 15K+ceil(3/5)×1K=15K+1K=16K
// refund = 26K - 16K = 10,000
$this->assertEquals(10000, $result->refundAmount);
}
/**
* A-6-4: PER_WEIGHT 무게 단위 변동 (1.2kg→0.8kg)
*
* unit=0.5, fee=2000. ceil(1.2/0.5)=3→ceil(0.8/0.5)=2
*/
public function test_per_weight_unit_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 0.5],
);
// 0.4kg × 3개 = 1.2kg
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 400);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
// 1개 취소 → 0.8kg
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 1],
]));
// 원래: 30K+ceil(1.2/0.5)×2K=30K+6K=36K
// 잔여: 20K+ceil(0.8/0.5)×2K=20K+4K=24K
// refund = 36K - 24K = 12,000
$this->assertEquals(12000, $result->refundAmount);
}
/**
* A-6-5: PER_WEIGHT 큰 무게 감소 (1.2kg→0.4kg)
*
* unit=0.5, fee=2000. ceil(1.2/0.5)=3→ceil(0.4/0.5)=1
*/
public function test_per_weight_large_decrease(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 0.5],
);
// 0.4kg × 3개 = 1.2kg
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 400);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
// 2개 취소 → 0.4kg
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 30K+6K=36K, 잔여: 10K+ceil(0.4/0.5)×2K=10K+2K=12K
// refund = 36K - 12K = 24,000
$this->assertEquals(24000, $result->refundAmount);
}
/**
* A-6-6: PER_VOLUME 부피 단위 변동 (2500→1500)
*
* unit=1000, fee=1500. ceil(2500/1000)=3→ceil(1500/1000)=2
*/
public function test_per_volume_unit_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_VOLUME,
baseFee: 1500,
ranges: ['unit_value' => 1],
);
// 500cm³ × 5개 = 2500cm³
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp, volume: 500);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 5),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
// 2개 취소 → 1500cm³
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 25K+ceil(2500/1000)×1.5K=25K+4.5K=29.5K
// 잔여: 15K+ceil(1500/1000)×1.5K=15K+3K=18K
// refund = 29.5K - 18K = 11,500
$this->assertEquals(11500, $result->refundAmount);
}
/**
* A-6-7: PER_AMOUNT 금액당 단위 변동 (35K→20K)
*
* unit=10K, fee=500. ceil(35K/10K)=4→ceil(20K/10K)=2
*/
public function test_per_amount_unit_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_AMOUNT,
baseFee: 500,
ranges: ['unit_value' => 10000],
);
[$pA, $oA] = $this->createProductWithOption(price: 15000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 35K+ceil(35K/10K)×500=35K+2K=37K
// 잔여: 20K+ceil(20K/10K)×500=20K+1K=21K
// refund = 37K - 21K = 16,000
$this->assertEquals(16000, $result->refundAmount);
}
/**
* A-6-8: PER_AMOUNT 동일 단위 (35K→30K)
*
* unit=10K, fee=500. ceil(35K/10K)=4→ceil(30K/10K)=3
*/
public function test_per_amount_unit_change_slight(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_AMOUNT,
baseFee: 500,
ranges: ['unit_value' => 10000],
);
[$pA, $oA] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 35K+ceil(35K/10K)×500=35K+2K=37K
// 잔여: 30K+ceil(30K/10K)×500=30K+1.5K=31.5K
// refund = 37K - 31.5K = 5,500
$this->assertEquals(5500, $result->refundAmount);
}
/**
* A-6-9: PER_VOLUME_WEIGHT 부피무게 단위 변동
*
* divisor=6000, unit=0.5, fee=2000
* 무게0.2×4=0.8kg, 부피1500×4=6000→부피무게1.0kg
* max(0.8,1.0)=1.0 → ceil(1.0/0.5)=2 → 4K
* 2개 취소 → 무게0.4, 부피3000→부피무게0.5, max(0.4,0.5)=0.5
* ceil(0.5/0.5)=1 → 2K
*/
public function test_per_volume_weight_unit_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_VOLUME_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 0.5, 'volume_weight_divisor' => 6000],
);
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 200, volume: 1500);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 4),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 40K+4K=44K, 잔여: 20K+2K=22K
// refund = 44K - 22K = 22,000
$this->assertEquals(22000, $result->refundAmount);
}
/**
* A-6-10: PER_QUANTITY 건당(unit=1) 배송비
*
* unit=1, fee=500. 5→3개 → 배송비 2500→1500
*/
public function test_per_quantity_per_item(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 500,
ranges: ['unit_value' => 1],
);
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 5),
]);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 25K+5×500=27.5K, 잔여: 15K+3×500=16.5K
// refund = 27.5K - 16.5K = 11,000
$this->assertEquals(11000, $result->refundAmount);
}
// ================================================================
// A-7. 도서산간 추가배송비 부분취소 (6건)
// ================================================================
/**
* A-7-1: FIXED + 도서산간 flat extra fee → 전체취소
*
* 기본배송 3000 + 도서산간 2500 = 배송비 5500
*/
public function test_extra_fee_flat_full_cancel(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 2500, 'region' => '제주도'],
],
extraFeeMultiply: false,
);
[$p, $o] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63100');
$input = new CalculationInput(
items: [new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1)],
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
// 전체취소
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 원래: 30K + 3K(기본) + 2.5K(도서산간) = 35.5K → 전액 환불
$this->assertEquals($order->total_paid_amount, $result->refundAmount);
}
/**
* A-7-2: CONDITIONAL_FREE + 도서산간 → 부분취소로 유료 전환 시 기본배송비 + 도서산간 모두 차감
*
* 60K 주문(무료배송), 도서산간 2500. 20K 취소 → 40K < 50K → 유료 전환
* 기본배송비 3000 + 도서산간 2500 = 5500 발생
*/
public function test_extra_fee_conditional_free_partial_cancel(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 2500, 'region' => '제주도'],
],
extraFeeMultiply: false,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 40000, shippingPolicy: $sp);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63200');
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
// 원래: 60K(무료배송) + 2.5K(도서산간) = 62.5K
// 도서산간은 무료배송이어도 부과됨
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(20K) 취소 → 잔여 40K < 50K → 유료배송 전환
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 잔여: 40K + 3K(기본) + 2.5K(도서산간) = 45.5K
// refund = originalPaid - 45500
$this->assertEquals($originalPaid - 45500, $result->refundAmount);
}
/**
* A-7-3: PER_QUANTITY + multiply extra fee → 수량 감소 시 도서산간도 비례 감소
*
* unit=5, fee=1000, extra=1000×multiply.
* 12개: 배송비 ceil(12/5)×1K=3K, 도서산간 ceil(12/5)×1K=3K → 총 6K
* 7개: 배송비 ceil(7/5)×1K=2K, 도서산간 ceil(7/5)×1K=2K → 총 4K
*/
public function test_extra_fee_multiply_per_quantity(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 5],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 1000, 'region' => '제주도'],
],
extraFeeMultiply: true,
);
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63300');
$input = new CalculationInput(
items: [new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 12)],
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
// 5개 취소 → 7개
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 5],
]));
// 원래: 60K + 3K(배송) + 3K(도서산간) = 66K
// 잔여: 35K + 2K(배송) + 2K(도서산간) = 39K
// refund = 66K - 39K = 27,000
$this->assertEquals(27000, $result->refundAmount);
}
/**
* A-7-4: PER_WEIGHT + multiply extra fee → 수량 감소 시 도서산간도 비례 감소
*
* unit=0.5kg, fee=2000, extra=1500×multiply.
* 도서산간 multiply 는 기본 배송비와 같은 배송 단위 수 기준 (수량이 아니다)
* 3개(1.2kg): 배송 ceil(1.2/0.5)=3단위 × 2K=6K, 도서산간 3단위 × 1.5K=4.5K
* 1개(0.4kg): 배송 ceil(0.4/0.5)=1단위 × 2K=2K, 도서산간 1단위 × 1.5K=1.5K
*/
public function test_extra_fee_multiply_per_weight(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_WEIGHT,
baseFee: 2000,
ranges: ['unit_value' => 0.5],
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 1500, 'region' => '제주도'],
],
extraFeeMultiply: true,
);
// 0.4kg × 3개 = 1.2kg
[$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 400);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63400');
$input = new CalculationInput(
items: [new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 3)],
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
// 2개 취소 → 1개 (0.4kg)
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 30K + 6K(배송) + 4.5K(도서산간) = 40.5K
// 잔여: 10K + 2K(배송) + 1.5K(도서산간) = 13.5K
// refund = 40.5K - 13.5K = 27,000
$this->assertEquals(27000, $result->refundAmount);
}
/**
* A-7-5: FIXED + no multiply extra fee → 부분취소 시 도서산간 변동 없음
*
* 기본배송 3000 + 도서산간 2000 (flat, 1회). 부분취소해도 도서산간 그대로.
*/
public function test_extra_fee_no_multiply_fixed(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 2000, 'region' => '제주도'],
],
extraFeeMultiply: false,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63500');
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(20K) 취소
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 30K + 3K(기본) + 2K(도서산간) = 35K
// 잔여: 10K + 3K(기본) + 2K(도서산간) = 15K (배송비 변동 없음)
// refund = 35K - 15K = 20,000
$this->assertEquals(20000, $result->refundAmount);
}
/**
* A-7-6: CONDITIONAL_FREE + extra fee → 무료배송 유지 시 도서산간만 남음
*
* 60K(무료배송) + 도서산간 3000. 5K 취소 → 55K ≥ 50K → 무료 유지. 도서산간 그대로.
*/
public function test_extra_fee_with_conditional_free_stays_free(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 3000, 'region' => '제주도'],
],
extraFeeMultiply: false,
);
[$pA, $oA] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 55000, shippingPolicy: $sp);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63600');
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(5K) 취소 → 잔여 55K ≥ 50K → 무료 유지
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 60K + 0(무료) + 3K(도서산간) = 63K
// 잔여: 55K + 0(무료) + 3K(도서산간) = 58K
// refund = 63K - 58K = 5,000
$this->assertEquals(5000, $result->refundAmount);
}
// ================================================================
// A-8 나머지. 상품쿠폰(PRODUCT_AMOUNT) + 부분취소
// ================================================================
/**
* A-8-3: 정률 10% max=1500, ALL 적용. B(10K) 취소 → A(20K)에만 적용, 10%=2000→cap 1500
*/
public function test_product_coupon_rate_with_max_partial_cancel(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
maxDiscount: 1500,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionB = $order->options->where('product_option_id', $oB->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionB->id, 'cancel_quantity' => 1],
]));
// 원래: 30K - discount(10% of 30K=3K → cap 1.5K) = 28.5K
// 잔여: 20K - discount(10% of 20K=2K → cap 1.5K) = 18.5K
// refund = 28.5K - 18.5K = 10,000
$this->assertEquals($originalPaid - 18500, $result->refundAmount);
}
/**
* A-8-4: 정액 2000, PRODUCTS [A,B] 적용. A,B,C 중 A 취소 → B만 쿠폰 적용 지속
*/
public function test_product_coupon_specific_products_partial_cancel(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
targetScope: CouponTargetScope::PRODUCTS,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 15000);
[$pC, $oC] = $this->createProductWithOption(price: 10000);
// 쿠폰 대상 상품으로 A, B 지정
$couponIssue->coupon->includedProducts()->attach([$pA->id, $pB->id], ['type' => 'include']);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A 취소 → 남은 B, C 중 B에만 쿠폰 적용
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 잔여: B(15K) + C(10K) - discount on B = 25K - 2K = 23K
$this->assertEquals($originalPaid - 23000, $result->refundAmount);
}
/**
* A-8-6: 정액 1000, CATEGORIES [cat1] 적용. cat1 상품 취소 → 잔여 cat1에만 할인
*/
public function test_product_coupon_category_partial_cancel(): void
{
$cat = $this->createCategory('전자기기');
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
targetScope: CouponTargetScope::CATEGORIES,
);
// 쿠폰 대상 카테고리 지정
$couponIssue->coupon->includedCategories()->attach([$cat->id], ['type' => 'include']);
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 15000);
// A, B 모두 cat에 소속
$pA->categories()->attach($cat->id);
$pB->categories()->attach($cat->id);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A 취소 → B만 남음, 할인 1K
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 잔여: 15K - 1K = 14K
$this->assertEquals($originalPaid - 14000, $result->refundAmount);
}
/**
* A-8-7: 정률 20% ALL, 3개 옵션 부분수량 취소 → 잔여 수량 기준 재계산
*/
public function test_product_coupon_rate_partial_quantity(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 20,
);
[$p, $o] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 5)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$option = $order->options->first();
// 2개 취소 → 3개 남음
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 2],
]));
// 원래: 50K - 20% = 40K
// 잔여: 30K - 20% = 24K
// refund = 40K - 24K = 16,000
$this->assertEquals(16000, $result->refundAmount);
}
/**
* A-8-8: 정액 5000, ALL. 1개 옵션(10K)만 남음 → 할인 min(5000,10000) = 5000 적용
*/
public function test_product_coupon_fixed_high_discount_partial(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
[$pA, $oA] = $this->createProductWithOption(price: 40000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(40K) 취소 → B(10K)만 남음
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 잔여: 10K - min(5K,10K)=5K = 5K
$this->assertEquals($originalPaid - 5000, $result->refundAmount);
}
/**
* A-8-9: 정률 50% ALL, 고율 할인. 부분취소 시 큰 할인 차이 검증
*/
public function test_product_coupon_high_rate_partial(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 50,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 50K - 50% = 25K
// 잔여: 20K - 50% = 10K
// refund = 25K - 10K = 15,000
$this->assertEquals(15000, $result->refundAmount);
}
// ================================================================
// A-9 나머지. 주문쿠폰(ORDER_AMOUNT) + 부분취소
// ================================================================
/**
* A-9-4: 정률 20% max=10000, min=50000. 60K→45K(미달) → 쿠폰복원
*/
public function test_order_coupon_rate_with_max_min_not_met(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 20,
maxDiscount: 10000,
minOrderAmount: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 15000);
[$pB, $oB] = $this->createProductWithOption(price: 45000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(15K) 취소 → 잔여 45K < 50K → 쿠폰 복원
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 60K - 20%=12K→cap 10K = 50K
// 잔여: 45K (쿠폰복원, 할인 0)
// refund = 50K - 45K = 5,000
$this->assertEquals($originalPaid - 45000, $result->refundAmount);
}
/**
* A-9-5: 정률 20% max=10000, min=50000. 60K→55K(충족) → 쿠폰 유지, max 재적용
*/
public function test_order_coupon_rate_with_max_min_still_met(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 20,
maxDiscount: 10000,
minOrderAmount: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 5000);
[$pB, $oB] = $this->createProductWithOption(price: 55000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(5K) 취소 → 잔여 55K ≥ 50K → 쿠폰 유지
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 60K - min(20%=12K, max10K) = 50K
// 잔여: 55K - min(20%=11K, max10K) = 45K
// refund = 50K - 45K = 5,000
$this->assertEquals($originalPaid - 45000, $result->refundAmount);
}
/**
* A-9-6: 주문쿠폰 안분 검증. A(30K)+B(10K), 정률10%, B 취소 → A 안분 할인만 남음
*/
public function test_order_coupon_apportion_after_cancel(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionB = $order->options->where('product_option_id', $oB->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionB->id, 'cancel_quantity' => 1],
]));
// 원래: 40K - 10%=4K = 36K
// 잔여: 30K - 10%=3K = 27K
// refund = 36K - 27K = 9,000
$this->assertEquals($originalPaid - 27000, $result->refundAmount);
}
/**
* A-9-7: 정액 3000, min=20000. 3개 옵션 안분, 1개 취소 → 안분 비율 재계산
*/
public function test_order_coupon_fixed_apportion_recalculation(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
minOrderAmount: 20000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 15000);
[$pC, $oC] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionC = $order->options->where('product_option_id', $oC->id)->first();
// C(10K) 취소 → 잔여 35K ≥ 20K → 쿠폰 유지
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionC->id, 'cancel_quantity' => 1],
]));
// 원래: 45K - 3K = 42K
// 잔여: 35K - 3K = 32K
// refund = 42K - 32K = 10,000
$this->assertEquals($originalPaid - 32000, $result->refundAmount);
}
/**
* A-9-8: 연속 2회 부분취소 시뮬레이션. 50K→40K(유지)→25K(복원)
*
* 이 테스트는 1회 부분취소 후 재계산만 검증 (서비스가 단일 계산 단위)
* 실제 연속 취소는 OrderCancellationServiceTest에서 검증
*/
public function test_order_coupon_sequential_cancel_first(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 15,
minOrderAmount: 30000,
);
[$pA, $oA] = $this->createProductWithOption(price: 10000);
[$pB, $oB] = $this->createProductWithOption(price: 40000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(10K) 취소 → 잔여 40K ≥ 30K → 쿠폰 유지
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 50K - 15%=7.5K = 42.5K
// 잔여: 40K - 15%=6K = 34K
// refund = 42.5K - 34K = 8,500
$this->assertEquals($originalPaid - 34000, $result->refundAmount);
}
// ================================================================
// A-10. 배송비쿠폰(SHIPPING_FEE) + 부분취소 (8건)
// ================================================================
/**
* A-10-1: 정액 3000 배송비 쿠폰, FIXED 배송비 3000. 부분취소 후 배송비 변동 없음 → 쿠폰 유지
*/
public function test_shipping_coupon_fixed_no_change(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
$sp = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FIXED, baseFee: 3000);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 30K + 3K(배송) - 3K(쿠폰) = 30K
// 잔여: 10K + 3K(배송) - 3K(쿠폰) = 10K
// refund = 30K - 10K = 20,000
$this->assertEquals(20000, $result->refundAmount);
}
/**
* A-10-2: 정액 3000 배송비 쿠폰, CONDITIONAL_FREE 무료→유료 전환 시 쿠폰으로 배송비 상쇄
*/
public function test_shipping_coupon_conditional_free_to_paid(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 40000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(20K) 취소 → 40K < 50K → 유료 전환 3K. 쿠폰 3K 상쇄
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 60K + 0(무료) - 0(쿠폰 무효, 배송비 0) = 60K
// 잔여: 40K + 3K(유료전환) - 3K(쿠폰) = 40K
// refund = 60K - 40K = 20,000
$this->assertEquals($originalPaid - 40000, $result->refundAmount);
}
/**
* A-10-3: 정률 50% 배송비 쿠폰, FIXED 배송비 4000. 전체취소
*/
public function test_shipping_coupon_rate_full_cancel(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::RATE,
discountValue: 50,
);
$sp = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FIXED, baseFee: 4000);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$result = $this->adjustmentService->calculate($order, $this->buildFullCancellation($order));
// 전체취소 → 원결제금액 전액 환불
$this->assertEquals($order->total_paid_amount, $result->refundAmount);
}
/**
* A-10-4: 정액 2000 배송비 쿠폰, RANGE_AMOUNT 구간 변동 시 배송비↑ + 쿠폰
*/
public function test_shipping_coupon_range_amount_tier_change(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 20000, 'fee' => 4000],
['min' => 20000, 'max' => 50000, 'fee' => 2500],
['min' => 50000, 'max' => null, 'fee' => 0],
],
],
);
[$pA, $oA] = $this->createProductWithOption(price: 15000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionB = $order->options->where('product_option_id', $oB->id)->first();
// B(20K) 취소 → 잔여 15K → 배송비 4000(하위구간)
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionB->id, 'cancel_quantity' => 1],
]));
// 원래: 35K + 2.5K(배송) - 2K(쿠폰) = 35.5K
// 잔여: 15K + 4K(배송) - 2K(쿠폰) = 17K
// refund = 35.5K - 17K = 18,500
$this->assertEquals($originalPaid - 17000, $result->refundAmount);
}
/**
* A-10-5: 정액 5000 배송비 쿠폰 > 배송비 3000 → 초과할인 방지 (할인=min(5K,3K)=3K)
*/
public function test_shipping_coupon_exceeds_shipping_fee(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
$sp = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FIXED, baseFee: 3000);
[$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 배송비 3K - 쿠폰 min(5K,3K) = 0 → 배송비 할인 3K
$this->assertEquals(3000, (int) $order->shipping_discount_amount);
// 실결제 = 20K + 0(배송비 실질) = 20K
$this->assertEquals(20000, (int) $order->total_paid_amount);
}
/**
* A-10-6: 정률 100% 배송비 쿠폰, 부분취소 후 배송비↑ → 증가분도 100% 할인
*/
public function test_shipping_coupon_100_percent_partial_cancel(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::RATE,
discountValue: 100,
);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 5000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 40000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(20K) 취소 → 40K < 50K → 유료 5K 발생. 100% 쿠폰으로 전액 할인
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 60K + 0(무료) - 0(쿠폰) = 60K
// 잔여: 40K + 5K(유료) - 5K(100%쿠폰) = 40K
// refund = 60K - 40K = 20,000
$this->assertEquals($originalPaid - 40000, $result->refundAmount);
}
/**
* A-10-7: 정액 1000 배송비 쿠폰, PER_QUANTITY 배송비 변동 + 쿠폰
*/
public function test_shipping_coupon_per_quantity_change(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 5],
);
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 12)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$option = $order->options->first();
// 5개 취소 → 7개
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 5],
]));
// 원래: 60K + ceil(12/5)×1K=3K - 1K(쿠폰) = 62K
// 잔여: 35K + ceil(7/5)×1K=2K - 1K(쿠폰) = 36K
// refund = 62K - 36K = 26,000
$this->assertEquals($originalPaid - 36000, $result->refundAmount);
}
/**
* A-10-8: 정률 30% 배송비 쿠폰, CONDITIONAL_FREE → 유료전환, 배송비×70% 부과
*/
public function test_shipping_coupon_rate_conditional_free_to_paid(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::RATE,
discountValue: 30,
);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 4000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 40000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(20K) 취소 → 40K < 50K → 배송비 4K 발생. 30% 할인 → 1.2K 할인
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 60K + 0(무료) = 60K
// 잔여: 40K + 4K - 1.2K(30%) = 42.8K
// refund = 60K - 42.8K = 17,200
$this->assertEquals($originalPaid - 42800, $result->refundAmount);
}
// ================================================================
// A-11 나머지. 마일리지 + 부분취소
// ================================================================
/**
* A-11-4: 마일리지+쿠폰 복합, 부분취소 → 쿠폰 복원 시 마일리지 안분도 재계산
*/
public function test_mileage_with_coupon_restore_recalculation(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 30000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$originalPoints = (int) $order->total_points_used_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(20K) 취소 → 잔여 20K < 30K → 쿠폰 복원
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 40K - 4K(쿠폰) - 3K(마일리지) = 33K paid
// 잔여: 20K - 0(쿠폰복원) - 3K(마일리지) = 17K paid
// refund PG = 33K - 17K = 16K, refundPoints = 3K-3K = 0 (마일리지는 잔여에 전액 적용)
$this->assertEquals($originalPaid - 17000, $result->refundAmount);
}
/**
* A-11-5: 마일리지 10K + CONDITIONAL_FREE 배송비 전환
*/
public function test_mileage_with_shipping_transition(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A 취소 → 잔여 30K < 50K → 배송비 3K 발생
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 60K + 0(무료) - 10K(마일리지) = 50K paid
// 잔여: 30K + 3K(유료) - 10K(마일리지) = 23K paid
// refund = 50K - 23K = 27,000
$this->assertEquals($originalPaid - 23000, $result->refundAmount);
}
/**
* A-11-6: 마일리지+주문쿠폰+부분취소 → 쿠폰 복원+마일리지 동시 재계산
*/
public function test_mileage_with_order_coupon_restore(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 40000,
);
[$pA, $oA] = $this->createProductWithOption(price: 25000);
[$pB, $oB] = $this->createProductWithOption(price: 25000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 5000,
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(25K) 취소 → 잔여 25K < 40K → 쿠폰 복원
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 50K - 5K(쿠폰) - 5K(마일리지) = 40K paid
// 잔여: 25K - 0(쿠폰복원) - 5K(마일리지) = 20K paid
// refund = 40K - 20K = 20,000
$this->assertEquals($originalPaid - 20000, $result->refundAmount);
}
// ================================================================
// A-12 나머지. 복합 시나리오
// ================================================================
/**
* A-12-5: 마일리지2K+상품쿠폰정액1K, RANGE_AMOUNT 배송비 구간변동
*/
public function test_complex_mileage_product_coupon_range_shipping(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_AMOUNT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 30000, 'fee' => 3000],
['min' => 30000, 'max' => null, 'fee' => 1000],
],
],
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 2000,
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A 취소 → 잔여 20K → 하위구간 배송비 3K
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 40K - 1K(상품쿠폰) + 1K(배송, 30K+구간) - 2K(마일리지) = 38K
// 잔여: 20K - 1K(상품쿠폰) + 3K(배송, <30K구간) - 2K(마일리지) = 20K
// refund = 38K - 20K = 18,000
$this->assertEquals($originalPaid - 20000, $result->refundAmount);
}
/**
* A-12-6: 3중 할인(상품쿠폰+주문쿠폰+마일리지) + 다중 옵션 부분취소
*/
public function test_complex_triple_discount_partial_cancel(): void
{
$productCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
$orderCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 30000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$productCoupon->id, $orderCoupon->id],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(20K) 취소 → 잔여 30K ≥ 30K → 주문쿠폰 유지
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 재계산 후 잔여 값을 직접 비교 (복합 할인으로 정확한 계산은 계산 엔진에 위임)
$this->assertGreaterThan(0, $result->refundAmount);
$this->assertLessThan($originalPaid, $result->refundAmount);
}
/**
* A-12-7: 카테고리 쿠폰 + 부분취소 대상이 다른 카테고리 → 쿠폰 영향 없음
*/
public function test_complex_category_coupon_different_category_cancel(): void
{
$cat1 = $this->createCategory('전자기기');
$cat2 = $this->createCategory('의류');
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::CATEGORIES,
);
$couponIssue->coupon->includedCategories()->attach([$cat1->id], ['type' => 'include']);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
$pA->categories()->attach($cat1->id);
$pB->categories()->attach($cat2->id);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionB = $order->options->where('product_option_id', $oB->id)->first();
// B(cat2, 20K) 취소 → cat1 상품(A)만 남음 → 쿠폰 영향 없음 (여전히 cat1에 적용)
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionB->id, 'cancel_quantity' => 1],
]));
// 원래: 50K - 3K(cat1 쿠폰) = 47K
// 잔여: 30K - 3K(cat1 쿠폰) = 27K
// refund = 47K - 27K = 20,000
$this->assertEquals($originalPaid - 27000, $result->refundAmount);
}
/**
* A-12-8: 카테고리 쿠폰 + 부분취소 대상이 같은 카테고리 → 할인 재계산
*/
public function test_complex_category_coupon_same_category_cancel(): void
{
$cat1 = $this->createCategory('전자기기');
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::CATEGORIES,
);
$couponIssue->coupon->includedCategories()->attach([$cat1->id], ['type' => 'include']);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
$pA->categories()->attach($cat1->id);
$pB->categories()->attach($cat1->id);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(cat1, 30K) 취소 → B(cat1, 20K)만 남음 → 쿠폰 대상 감소
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 50K - 3K(쿠폰) = 47K
// 잔여: 20K - 3K(쿠폰, B도 cat1) = 17K
// refund = 47K - 17K = 30,000
$this->assertEquals($originalPaid - 17000, $result->refundAmount);
}
/**
* A-12-9: PER_QUANTITY + 상품쿠폰 + 수량 부분취소 → 배송비+할인 동시 변동
*/
public function test_complex_per_quantity_coupon_partial_quantity(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
ranges: ['unit_value' => 5],
);
[$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 12)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$option = $order->options->first();
// 5개 취소 → 7개
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $option->id, 'cancel_quantity' => 5],
]));
// 원래: 60K - 6K(10%) + 3K(배송 ceil(12/5)×1K) = 57K
// 잔여: 35K - 3.5K(10%) + 2K(배송 ceil(7/5)×1K) = 33.5K
// refund = 57K - 33.5K = 23,500
$this->assertEquals($originalPaid - 33500, $result->refundAmount);
}
/**
* A-12-10: RANGE_WEIGHT + 주문쿠폰(min_order) + 마일리지 → 3중 변동
*/
public function test_complex_range_weight_order_coupon_mileage(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
minOrderAmount: 30000,
);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::RANGE_WEIGHT,
ranges: [
'tiers' => [
['min' => 0, 'max' => 0.5, 'fee' => 2500],
['min' => 0.5, 'max' => null, 'fee' => 4000],
],
],
);
// 0.3kg × 2개
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 300);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 300);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 2000,
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (int) $order->total_paid_amount;
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A 취소 → 잔여 20K < 30K → 쿠폰 복원, 무게 0.3kg→<500g → 배송비 2500
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 40K - 4K(쿠폰10%) + 2.5K(배송, 600g→≥500→4000이 아니라... 0.3*2=0.6kg=600g≥500→4K) - 2K(마일리지) = 38K
// 잠깐... 0.3kg×2 = 0.6kg = 600g ≥ 500g → 배송비 4000
// 원래: 40K - 4K(쿠폰) + 4K(배송) - 2K(마일리지) = 38K
// 잔여: 20K - 0(쿠폰복원) + 2.5K(배송, 300g<500g) - 2K(마일리지) = 20.5K
// refund = 38K - 20.5K = 17,500
$this->assertEquals($originalPaid - 20500, $result->refundAmount);
}
// ================================================================
// A-13 나머지. 안분 정확성 검증
// ================================================================
/**
* A-13-5: 배송비 안분이 옵션 소계 비율과 일치하는지 검증
*/
public function test_shipping_apportion_matches_option_ratio(): void
{
$sp = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FIXED, baseFee: 3000);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
// 배송비 3000이 30:10 = 3:1 비율로 안분
$optA = $order->options->where('product_option_id', $oA->id)->first();
$optB = $order->options->where('product_option_id', $oB->id)->first();
// A의 결제금액이 B보다 큼 (배송비 포함 시)
$this->assertGreaterThan((int) $optB->subtotal_paid_amount, (int) $optA->subtotal_paid_amount);
}
/**
* A-13-6: 주문쿠폰 안분이 소계 비율과 일치하는지 검증
*/
public function test_order_coupon_apportion_matches_ratio(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 4000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 쿠폰 4000이 30:10 비율로 안분 → A:3000, B:1000
$optA = $order->options->where('product_option_id', $oA->id)->first();
$optB = $order->options->where('product_option_id', $oB->id)->first();
$totalCouponDiscount = (int) $optA->coupon_discount_amount + (int) $optB->coupon_discount_amount;
$this->assertEquals(4000, $totalCouponDiscount);
// A의 할인이 B보다 큼
$this->assertGreaterThan((int) $optB->coupon_discount_amount, (int) $optA->coupon_discount_amount);
}
/**
* A-13-7: 마일리지 안분이 비율 일치하는지 검증
*/
public function test_mileage_apportion_matches_ratio(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 4000,
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->where('product_option_id', $oA->id)->first();
$optB = $order->options->where('product_option_id', $oB->id)->first();
$totalPoints = (int) $optA->subtotal_points_used_amount + (int) $optB->subtotal_points_used_amount;
$this->assertEquals(4000, $totalPoints);
// 30:10 비율 → A:3000, B:1000
$this->assertGreaterThan((int) $optB->subtotal_points_used_amount, (int) $optA->subtotal_points_used_amount);
}
/**
* A-13-8: 1차 부분취소 후 잔여 옵션 안분 재검증 (누적 정합성)
*/
public function test_multiple_cancel_apportion_reconsistency(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 6000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
[$pC, $oC] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(30K) 취소 → B+C = 30K 남음
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]));
// 원래: 60K - 6K(쿠폰) = 54K
// 잔여: 30K - 6K(쿠폰) = 24K
// 잔여 옵션 업데이트에서 할인 안분 합계 = 6K
$this->assertEquals(24000, $result->recalculatedPaidAmount);
// 잔여 옵션 subtotal_paid_amount 합계 = recalculatedPaidAmount (24K)
// ORDER_AMOUNT 쿠폰은 subtotal_paid_amount에 안분 반영됨
if (! empty($result->optionUpdates)) {
$totalPaid = 0;
foreach ($result->optionUpdates as $update) {
$totalPaid += $update['subtotal_paid_amount'] ?? 0;
}
$this->assertEquals($result->recalculatedPaidAmount, $totalPaid);
}
}
/**
* A-4 #29: 무료배송 기준금액 정확히 1원 미달 시 배송비 발생
*/
public function test_conditional_free_drops_below_threshold_by_1(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
// 50001원 주문 (무료배송)
[$pA, $oA] = $this->createProductWithOption(price: 40001, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$this->assertEquals(0, (float) $order->total_shipping_amount);
$originalPaid = (float) $order->total_paid_amount;
// B(10000) 취소 → 잔여 40001 < 50000 → 유료배송 전환
// BUT: actually 40001 < 50000, shipping becomes 3000
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]));
// 잔여: 40001 + 3000(배송비) = 43001
// 환불: originalPaid - 43001
$this->assertEquals($originalPaid - 43001, $result->refundAmount);
// 배송비 증가 (무료→유료): shippingDifference < 0 (환불 관점에서 음수)
$this->assertLessThan(0, $result->shippingDifference);
}
/**
* A-8 #69: 상품쿠폰 2개 동시 적용 (combinable) 복합 할인 재계산
*/
public function test_product_coupon_two_combinable(): void
{
// 상품쿠폰 1: 정액 1000원
$coupon1 = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 1000,
);
// 상품쿠폰 2: 정률 10%
$coupon2 = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$coupon1->id, $coupon2->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
// A(30K) 취소 → 잔여 B(20K)에 두 쿠폰 적용
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
$this->assertGreaterThan(0, $result->refundAmount);
$this->assertLessThan($originalPaid, $result->refundAmount);
}
/**
* A-8 #70: 상품쿠폰 대상 상품 취소 시 할인 제거
*/
public function test_product_coupon_exclude_target_cancel(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 15,
targetScope: CouponTargetScope::PRODUCTS,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
// 쿠폰 대상: A만
$couponIssue->coupon->includedProducts()->attach([$pA->id], ['type' => 'include']);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$originalDiscount = (float) $order->total_discount_amount;
// A를 취소 → 쿠폰 대상 소멸 → 할인 0
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
// 잔여: B(10K) — 쿠폰 대상이 없으므로 할인 0
// 환불액 = originalPaid - 10000
$this->assertEquals($originalPaid - 10000, $result->refundAmount);
}
/**
* A-12 #106: 도서산간 추가배송비 + 배송비쿠폰 + 부분취소
*/
public function test_complex_extra_fee_shipping_coupon(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63000-63644', 'fee' => 2500, 'region' => '제주도'],
],
extraFeeMultiply: false,
);
// 배송비 쿠폰: 정액 2000원
$shippingCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63200');
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$shippingCoupon->id],
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
// 원래: 60K(무료배송) + 2.5K(도서산간) - 2K(배송쿠폰) = 60.5K
// A(30K) 취소 → 잔여 30K < 50K → 유료배송 전환
// 잔여: 30K + 3K(기본) + 2.5K(도서산간) - 2K(배송쿠폰) = 33.5K
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate($order, $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]));
$this->assertGreaterThan(0, $result->refundAmount);
$this->assertEquals($originalPaid - 33500, $result->refundAmount);
}
// ================================================================
// A-13. 환불 우선순위 (RefundPriorityEnum) 테스트 (4건)
// ================================================================
/**
* A-13-1: PG_FIRST — PG 결제금부터 환불 (기본 동작)
*
* PG 27,000 + 마일리지 3,000 = 30,000원 주문
* A(20,000) 취소 시 → PG 먼저 환불
*/
public function test_refund_priority_pg_first(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]),
RefundPriorityEnum::PG_FIRST,
);
$totalRefund = $result->refundAmount + $result->refundPointsAmount;
$this->assertGreaterThan(0, $totalRefund);
// PG_FIRST: PG 환불이 포인트 환불보다 크거나 같아야 함
$this->assertGreaterThanOrEqual($result->refundPointsAmount, $result->refundAmount);
$this->assertEquals(RefundPriorityEnum::PG_FIRST, $result->refundPriority);
}
/**
* A-13-2: POINTS_FIRST — 포인트부터 환불
*
* 동일 주문에서 POINTS_FIRST 사용 시 포인트 환불이 우선
*/
public function test_refund_priority_points_first(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]),
RefundPriorityEnum::POINTS_FIRST,
);
$totalRefund = $result->refundAmount + $result->refundPointsAmount;
$this->assertGreaterThan(0, $totalRefund);
// POINTS_FIRST: 포인트 잔액만큼 포인트 환불 우선 (3000원이 전부 환불)
$this->assertEquals(3000, $result->refundPointsAmount);
$this->assertEquals(RefundPriorityEnum::POINTS_FIRST, $result->refundPriority);
}
/**
* A-13-3: 동일 주문에서 우선순위 변경 시 총 환불액은 동일
*
* PG_FIRST와 POINTS_FIRST 총합은 같고, 배분만 다름
* A(10,000)+B(10,000), 포인트 10,000 → PG 10,000 결제
* A 취소 → 환불금 ≈ 10,000 (포인트 안분 5,000 + PG 안분 5,000)
* PG_FIRST: PG 5,000 + 포인트 5,000
* POINTS_FIRST: 포인트 5,000 + PG 5,000
*/
public function test_refund_priority_total_same_regardless_of_priority(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 10000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$cancellation = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]);
$pgFirst = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::PG_FIRST);
$pointsFirst = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::POINTS_FIRST);
// 총 환불액 동일
$pgTotal = $pgFirst->refundAmount + $pgFirst->refundPointsAmount;
$pointsTotal = $pointsFirst->refundAmount + $pointsFirst->refundPointsAmount;
$this->assertEquals($pgTotal, $pointsTotal);
// PG_FIRST: PG 환불 >= 포인트 환불
$this->assertGreaterThanOrEqual($pgFirst->refundPointsAmount, $pgFirst->refundAmount);
// POINTS_FIRST: 포인트 환불 >= PG 환불
$this->assertGreaterThanOrEqual($pointsFirst->refundAmount, $pointsFirst->refundPointsAmount);
}
/**
* A-13-4: preview()에서 우선순위 전달 확인
*/
public function test_preview_respects_refund_priority(): void
{
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 5000,
);
$order = $this->createOrderFromCalculation($input);
$optA = $order->options->firstWhere('product_option_id', $oA->id);
$cancellation = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->preview($order, $cancellation, RefundPriorityEnum::POINTS_FIRST);
$this->assertEquals(RefundPriorityEnum::POINTS_FIRST, $result->refundPriority);
// POINTS_FIRST이므로 포인트 잔액(5000원)이 먼저 환불
$this->assertEquals(5000, $result->refundPointsAmount);
}
// ===== mc_* 다중통화 환불 테스트 =====
/**
* 다중통화 스냅샷이 있는 주문의 환불 시 mc_refund_amount가 통화별로 변환됩니다.
*/
public function test_mc_refund_amount_converted_with_currency_snapshot(): void
{
[$product, $option] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'USD' => ['rate' => 0.85, 'rounding_unit' => 0.01, 'rounding_method' => 'round'],
],
];
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$optA = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
// mc_refund_amount가 null이 아니어야 함
$this->assertNotNull($result->mcRefundAmount);
$this->assertIsArray($result->mcRefundAmount);
// KRW 기본 통화는 환불액과 동일
$this->assertArrayHasKey('KRW', $result->mcRefundAmount);
$this->assertEquals($result->refundAmount, $result->mcRefundAmount['KRW']);
// USD 변환값 존재
$this->assertArrayHasKey('USD', $result->mcRefundAmount);
$this->assertGreaterThan(0, $result->mcRefundAmount['USD']);
}
/**
* 다중통화 스냅샷이 없는 주문의 환불 시 mc_* 필드는 모두 null입니다.
*
* 레거시 주문(다통화 도입 이전에 생성되어 currency_snapshot 이 비어 있는 행)의 취소 경로를
* 검증한다. OrderFactory 는 기본 스냅샷을 채우므로 여기서는 명시적으로 null 을 주입한다.
*/
public function test_mc_fields_null_without_currency_snapshot(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
);
// OrderFactory 는 currency_snapshot 을 항상 채운다 (등록된 통화 설정 기준).
// 통화 설정이 비어 있을 때만 우연히 falsy 가 되므로, 앞선 테스트가 통화 설정을 남기면
// 이 테스트가 간헐 실패한다. "스냅샷 없는 주문" 이라는 전제를 명시적으로 못박는다.
$order = $this->createOrderFromCalculation($input, ['currency_snapshot' => null]);
$this->assertNull($order->currency_snapshot, '테스트 전제: 주문에 통화 스냅샷이 없다');
$optA = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
$this->assertNull($result->mcRefundAmount);
$this->assertNull($result->mcRefundPointsAmount);
$this->assertNull($result->mcRefundShippingAmount);
}
/**
* 마일리지 사용 주문의 다중통화 환불 시 mc_refund_points_amount가 변환됩니다.
*/
public function test_mc_refund_points_amount_converted(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 10000,
);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'USD' => ['rate' => 0.85, 'rounding_unit' => 0.01, 'rounding_method' => 'round'],
],
];
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$optA = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]);
// POINTS_FIRST로 계산하여 마일리지 환불이 발생하도록 함
$result = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::POINTS_FIRST);
// 마일리지 환불이 있으므로 mc_refund_points_amount 존재
$this->assertGreaterThan(0, $result->refundPointsAmount);
$this->assertNotNull($result->mcRefundPointsAmount);
$this->assertArrayHasKey('KRW', $result->mcRefundPointsAmount);
$this->assertEquals($result->refundPointsAmount, $result->mcRefundPointsAmount['KRW']);
$this->assertArrayHasKey('USD', $result->mcRefundPointsAmount);
}
/**
* 배송비가 있는 주문 전체취소 시 mc_refund_shipping_amount가 변환됩니다.
*/
public function test_mc_refund_shipping_amount_when_shipping_difference_positive(): void
{
$policy = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $policy);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
],
);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'JPY' => ['rate' => 9.2, 'rounding_unit' => 1, 'rounding_method' => 'floor'],
],
];
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
// 배송비 3000원 유료 → 전체취소 시 배송비 전액 환불
$optA = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
// 전체취소 → 배송비 환불 (shipping_difference > 0)
$this->assertGreaterThan(0, $result->shippingDifference);
$this->assertNotNull($result->mcRefundShippingAmount);
$this->assertArrayHasKey('KRW', $result->mcRefundShippingAmount);
$this->assertEquals($result->shippingDifference, $result->mcRefundShippingAmount['KRW']);
$this->assertArrayHasKey('JPY', $result->mcRefundShippingAmount);
$this->assertGreaterThan(0, $result->mcRefundShippingAmount['JPY']);
}
/**
* toPreviewArray()에 mc_* 필드가 포함됩니다.
*/
public function test_preview_array_includes_mc_fields(): void
{
[$product, $option] = $this->createProductWithOption(price: 40000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 5000,
);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'USD' => ['rate' => 1350, 'rounding_unit' => 0.01, 'rounding_method' => 'round'],
],
];
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$optA = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->preview($order, $cancellation);
$preview = $result->toPreviewArray();
// toPreviewArray에 mc_* 키 존재
$this->assertArrayHasKey('mc_refund_amount', $preview);
$this->assertArrayHasKey('mc_refund_points_amount', $preview);
$this->assertArrayHasKey('mc_refund_shipping_amount', $preview);
// mc_refund_amount는 통화별 값
$this->assertNotNull($preview['mc_refund_amount']);
$this->assertArrayHasKey('KRW', $preview['mc_refund_amount']);
$this->assertArrayHasKey('USD', $preview['mc_refund_amount']);
// mc_refund_points_amount도 변환됨 (마일리지 사용했으므로)
$this->assertNotNull($preview['mc_refund_points_amount']);
}
// ================================================================
// B. 쿠폰 복원 감지 (8건)
// ================================================================
/**
* B-1: 부분취소 후 최소주문금액 미달 시 쿠폰 복원 감지
*
* 50,000×1 + 주문쿠폰(FIXED 5000, minOrder=30000)
* 전체취소 → 잔여 0 < 30000 → 쿠폰 복원
*/
public function test_coupon_restore_detected_when_min_order_not_met_after_partial(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 30000,
);
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertContains($couponIssue->id, $result->restoredCouponIssueIds);
}
/**
* B-2: 부분취소 후 최소주문금액 충족 시 쿠폰 미복원
*
* 50,000×2 + 주문쿠폰(FIXED 5000, minOrder=30000)
* 1개 취소 → 잔여 50,000 >= 30,000 → 쿠폰 유지
*/
public function test_coupon_restore_not_detected_when_min_order_still_met(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 30000,
);
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
$this->assertEmpty($result->restoredCouponIssueIds);
}
/**
* B-3: 주문쿠폰 최소금액 경계값 — 잔여금액이 정확히 미달 시 복원
*
* 30,000×1 + 주문쿠폰(FIXED 3000, minOrder=30001)
* 전체취소 → 쿠폰 복원
*/
public function test_coupon_restore_detected_for_order_coupon_min_amount(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
minOrderAmount: 30001,
);
[$product, $option] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertContains($couponIssue->id, $result->restoredCouponIssueIds);
}
/**
* B-4: 상품쿠폰 대상 전체 취소 시 복원 감지
*
* 20,000×1 + 상품쿠폰(FIXED 2000, targetType=PRODUCT_AMOUNT)
* 전체취소 → 쿠폰 복원
*/
public function test_coupon_restore_detected_for_product_coupon_target_all_cancelled(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
[$product, $option] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertContains($couponIssue->id, $result->restoredCouponIssueIds);
}
/**
* B-5: 복수 쿠폰 부분 복원 — 주문쿠폰만 복원, 상품쿠폰은 유지 가능
*
* 상품A(50,000×1) + 상품B(30,000×1)
* 주문쿠폰(FIXED 5000, minOrder=60000) + 상품쿠폰(FIXED 2000)
* B 취소 → 잔여 50,000 < 60,000 → 주문쿠폰 복원
*/
public function test_coupon_restore_multiple_coupons_partial_restore(): void
{
$orderCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 60000,
);
$productCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
[$pA, $oA] = $this->createProductWithOption(price: 50000);
[$pB, $oB] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$orderCoupon->id, $productCoupon->id],
);
$order = $this->createOrderFromCalculation($input);
$optB = $order->options->firstWhere('product_option_id', $oB->id);
$cancellation = $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
// 주문쿠폰은 최소금액 미달로 복원
$this->assertContains($orderCoupon->id, $result->restoredCouponIssueIds);
}
/**
* B-6: 배송쿠폰 전체취소 시 복원 감지
*
* 20,000×1 + 배송쿠폰(SHIPPING_FEE) → 전체취소 → 배송쿠폰 복원
*/
public function test_coupon_restore_shipping_coupon_when_all_cancelled(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
$shippingCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
[$product, $option] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$shippingCoupon->id],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertContains($shippingCoupon->id, $result->restoredCouponIssueIds);
}
/**
* B-7: restoredCouponIssueIds에 정확한 쿠폰 발급 ID가 포함되는지 검증
*
* 50,000×1 + 주문쿠폰(FIXED 5000, minOrder=30000) → 전체취소
*/
public function test_coupon_restore_ids_populated_in_adjustment_result(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 30000,
);
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertIsArray($result->restoredCouponIssueIds);
$this->assertCount(1, $result->restoredCouponIssueIds);
$this->assertEquals($couponIssue->id, $result->restoredCouponIssueIds[0]);
}
/**
* B-8: toPreviewArray()에 restored_coupons 키와 쿠폰 정보 포함 확인
*
* 50,000×1 + 주문쿠폰(FIXED 5000, minOrder=30000) → 전체취소
*/
public function test_coupon_restore_info_in_preview_array(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 30000,
);
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$preview = $result->toPreviewArray();
$this->assertArrayHasKey('restored_coupons', $preview);
$this->assertNotEmpty($preview['restored_coupons']);
$firstCoupon = $preview['restored_coupons'][0];
$this->assertArrayHasKey('coupon_name', $firstCoupon);
$this->assertArrayHasKey('discount_amount', $firstCoupon);
}
// ================================================================
// C. 환불 우선순위 (RefundPriorityEnum) 상세 (12건)
// ================================================================
/**
* C-1: PG_FIRST 부분취소 — PG만으로 충분 시 포인트 환불 없음
*
* 50,000×2, 포인트 10,000, 1개 취소 → 환불 25,000
* PG_FIRST: refundAmount=25,000, refundPointsAmount=0
*/
public function test_refund_priority_pg_first_partial_cancel_pg_only(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::PG_FIRST);
$totalRefund = $result->refundAmount + $result->refundPointsAmount;
// PG_FIRST: PG 잔액(90,000)이 충분하므로 PG에서 전액 환불
$this->assertEquals($totalRefund, $result->refundAmount + $result->refundPointsAmount);
$this->assertGreaterThanOrEqual($result->refundPointsAmount, $result->refundAmount);
}
/**
* C-2: PG_FIRST 전체취소 — PG + 포인트 모두 환불
*
* 50,000×2, 포인트 10,000, 전체취소 → PG 90,000 + 포인트 10,000 환불
*/
public function test_refund_priority_pg_first_full_cancel_pg_and_points(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::PG_FIRST);
// 전체취소 → PG 결제분 + 포인트 모두 환불
$this->assertEquals(90000, $result->refundAmount);
$this->assertEquals(10000, $result->refundPointsAmount);
}
/**
* C-3: PG_FIRST — PG 잔액 초과 시 포인트로 넘침
*
* 10,000×1, 포인트 8,000 → PG 결제 2,000
* PG_FIRST 전체취소: refundAmount=2,000, refundPointsAmount=8,000
*/
public function test_refund_priority_pg_first_exceeds_pg_balance_spills_to_points(): void
{
[$product, $option] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 8000,
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::PG_FIRST);
// PG 결제 2,000 + 포인트 8,000 = 10,000 전액 환불
$this->assertEquals(2000, $result->refundAmount);
$this->assertEquals(8000, $result->refundPointsAmount);
}
/**
* C-4: POINTS_FIRST 부분취소 — 포인트만으로 충분 시 PG 환불 없음
*
* 50,000×2, 포인트 30,000, 1개 취소 → 환불 25,000
* POINTS_FIRST: refundPointsAmount=25,000, refundAmount=0
*/
public function test_refund_priority_points_first_partial_cancel_points_only(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
usePoints: 30000,
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::POINTS_FIRST);
$totalRefund = $result->refundAmount + $result->refundPointsAmount;
// POINTS_FIRST: 포인트(30,000)가 환불액(25,000) 이상이므로 포인트 우선
$this->assertGreaterThanOrEqual($result->refundAmount, $result->refundPointsAmount);
}
/**
* C-5: POINTS_FIRST 전체취소 — 포인트 + PG 모두 환불
*
* 50,000×2, 포인트 30,000, 전체취소 → refundPointsAmount=30,000, refundAmount=70,000
*/
public function test_refund_priority_points_first_full_cancel_points_and_pg(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
usePoints: 30000,
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::POINTS_FIRST);
// 전체취소 → 포인트 30,000 + PG 70,000 = 100,000 전액 환불
$this->assertEquals(70000, $result->refundAmount);
$this->assertEquals(30000, $result->refundPointsAmount);
}
/**
* C-6: POINTS_FIRST — 포인트 잔액 초과 시 PG로 넘침
*
* 50,000×2, 포인트 10,000, 1개 취소 → 환불 25,000
* POINTS_FIRST: refundPointsAmount=10,000, refundAmount=15,000
*/
public function test_refund_priority_points_first_exceeds_points_balance_spills_to_pg(): void
{
[$product, $option] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::POINTS_FIRST);
// POINTS_FIRST: 포인트 안분(5,000)이 먼저 → 나머지 PG
$totalRefund = $result->refundAmount + $result->refundPointsAmount;
$this->assertGreaterThan(0, $totalRefund);
// 포인트 환불이 발생해야 함
$this->assertGreaterThan(0, $result->refundPointsAmount);
// 포인트만으로 부족하므로 PG 환불도 있어야 함
$this->assertGreaterThan(0, $result->refundAmount);
}
/**
* C-7: 포인트 미사용 시 우선순위 무관 — 동일 결과
*
* 30,000×1, 포인트 없음 → 양 우선순위 모두 refundAmount=30,000, refundPointsAmount=0
*/
public function test_refund_priority_no_points_used_ignores_priority(): void
{
[$product, $option] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$pgFirst = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::PG_FIRST);
$pointsFirst = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::POINTS_FIRST);
$this->assertEquals(30000, $pgFirst->refundAmount);
$this->assertEquals(0, $pgFirst->refundPointsAmount);
$this->assertEquals(30000, $pointsFirst->refundAmount);
$this->assertEquals(0, $pointsFirst->refundPointsAmount);
}
/**
* C-8: 전액 포인트 결제 시 양 우선순위 모두 포인트만 환불
*
* 10,000×1, 포인트 10,000 → PG 0원
* 양 우선순위: refundAmount=0, refundPointsAmount=10,000
*/
public function test_refund_priority_full_points_payment_always_points(): void
{
[$product, $option] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$pgFirst = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::PG_FIRST);
$pointsFirst = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::POINTS_FIRST);
// PG 결제 0원이므로 양 우선순위 모두 포인트만 환불
$this->assertEquals(0, $pgFirst->refundAmount);
$this->assertEquals(10000, $pgFirst->refundPointsAmount);
$this->assertEquals(0, $pointsFirst->refundAmount);
$this->assertEquals(10000, $pointsFirst->refundPointsAmount);
}
/**
* C-9: PG_FIRST 부분취소 후 잔여 잔액 정확성 검증
*
* 20,000×3, 포인트 10,000 → PG 50,000
* 1개 취소(PG_FIRST) → remainingPgBalance, remainingPointsBalance 확인
*/
public function test_refund_priority_sequential_cancel_pg_first_remaining_correct(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::PG_FIRST);
// 잔여 잔액은 원래 잔액에서 환불분 차감
$originalPg = (float) $order->total_paid_amount;
$originalPoints = (float) $order->total_points_used_amount;
$this->assertEquals($originalPg - $result->refundAmount, $result->remainingPgBalance);
$this->assertEquals($originalPoints - $result->refundPointsAmount, $result->remainingPointsBalance);
}
/**
* C-10: POINTS_FIRST 부분취소 후 잔여 잔액 정확성 검증
*
* 20,000×3, 포인트 10,000 → PG 50,000
* 1개 취소(POINTS_FIRST) → remainingPgBalance, remainingPointsBalance 확인
*/
public function test_refund_priority_sequential_cancel_points_first_remaining_correct(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::POINTS_FIRST);
$originalPg = (float) $order->total_paid_amount;
$originalPoints = (float) $order->total_points_used_amount;
$this->assertEquals($originalPg - $result->refundAmount, $result->remainingPgBalance);
$this->assertEquals($originalPoints - $result->refundPointsAmount, $result->remainingPointsBalance);
}
/**
* C-11: toPreviewArray()에 remaining_pg_balance, remaining_points_balance 포함 확인
*/
public function test_refund_priority_remaining_balances_in_preview_array(): void
{
[$product, $option] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
usePoints: 5000,
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
$preview = $result->toPreviewArray();
$this->assertArrayHasKey('remaining_pg_balance', $preview);
$this->assertArrayHasKey('remaining_points_balance', $preview);
$this->assertGreaterThanOrEqual(0, $preview['remaining_pg_balance']);
$this->assertGreaterThanOrEqual(0, $preview['remaining_points_balance']);
}
/**
* C-12: 우선순위 미지정 시 기본값 PG_FIRST 확인
*/
public function test_refund_priority_default_pg_first_when_not_specified(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 5000,
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
// 우선순위 인자 미전달 → 기본값 PG_FIRST
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertEquals(RefundPriorityEnum::PG_FIRST, $result->refundPriority);
}
// ================================================================
// D. 배송비 상세 (shippingDetails) (5건)
// ================================================================
/**
* D-1: 단일 배송그룹 전체취소 시 base_difference 포함
*
* 2개 상품 동일 정책(FIXED 3000), 전체취소 → shippingDetails에 base_difference=3000
*/
public function test_shipping_details_single_group_last_item_includes_base_fee(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertNotEmpty($result->shippingDetails);
$detail = $result->shippingDetails[0];
$this->assertArrayHasKey('base_difference', $detail);
$this->assertEquals(3000, $detail['base_difference']);
}
/**
* D-2: 다중 배송그룹 독립 계산 — 정책별 개별 shippingDetails 항목
*
* 상품A(정책1 FIXED 3000) + 상품B(정책2 FIXED 5000), 전체취소
* shippingDetails에 2개 항목
*/
public function test_shipping_details_multi_group_independent_per_policy(): void
{
$sp1 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
$sp2 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 5000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp1);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp2);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
// 기본 헬퍼가 1개 OrderShipping만 생성하므로, 정책별 2개 레코드로 교체
$order->shippings()->delete();
OrderShipping::factory()->forOrder($order)->create([
'shipping_policy_id' => $sp1->id,
'shipping_status' => 'pending',
'base_shipping_amount' => 3000,
'extra_shipping_amount' => 0,
'total_shipping_amount' => 3000,
'shipping_discount_amount' => 0,
]);
OrderShipping::factory()->forOrder($order)->create([
'shipping_policy_id' => $sp2->id,
'shipping_status' => 'pending',
'base_shipping_amount' => 5000,
'extra_shipping_amount' => 0,
'total_shipping_amount' => 5000,
'shipping_discount_amount' => 0,
]);
$order->load('shippings');
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertCount(2, $result->shippingDetails);
$totalDiff = array_sum(array_column($result->shippingDetails, 'total_difference'));
$this->assertEquals(8000, $totalDiff);
}
/**
* D-3: 도서산간 추가배송비 포함 시 extra_difference 존재
*
* 정책(FIXED 3000, extraFee 도서산간 2000), 전체취소
* shippingDetails에 extra_difference 포함
*/
public function test_shipping_details_extra_fee_included_on_last_item(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
'island' => 2000,
'mountain' => 3000,
],
);
[$product, $option] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63200');
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertNotEmpty($result->shippingDetails);
$detail = $result->shippingDetails[0];
$this->assertArrayHasKey('extra_difference', $detail);
$this->assertGreaterThanOrEqual(0, $detail['extra_difference']);
}
/**
* D-4: 배송비 변동 없으면 shippingDetails 비어있음
*
* 50,000×2 + CONDITIONAL_FREE(freeThreshold=30000), 1개 취소
* 잔여 50,000 >= 30,000 → 배송비 변동 없음 → shippingDetails 비어있음
*/
public function test_shipping_details_no_change_returns_empty_array(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 30000,
);
[$product, $option] = $this->createProductWithOption(price: 50000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
// 잔여 50,000 >= 30,000 → 무료배송 유지 → 배송비 차이 없음
$this->assertEquals(0, $result->shippingDifference);
$this->assertEmpty($result->shippingDetails);
}
/**
* D-5: PER_QUANTITY 정책 부분취소 시 수량 기반 배송비 재계산
*
* PER_QUANTITY(baseFee=1000/개), 3개 → 1개 취소
* shippingDetails에 base_difference=1000
*/
public function test_shipping_details_per_quantity_partial_qty_recalculation(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::PER_QUANTITY,
baseFee: 1000,
);
[$product, $option] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
],
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
// 3개→2개: 배송비 차이 발생 (PER_QUANTITY 기반 재계산)
$this->assertGreaterThan(0, $result->shippingDifference);
if (! empty($result->shippingDetails)) {
$this->assertArrayHasKey('base_difference', $result->shippingDetails[0]);
$this->assertGreaterThan(0, $result->shippingDetails[0]['base_difference']);
}
}
// ================================================================
// E. mc_* 다중통화 환불 상세 (3건)
// ================================================================
/**
* E-1: 환율 변경 시 주문 시점 스냅샷 환율 사용 검증
*
* 스냅샷 환율 USD=0.85로 주문 → mc_refund_amount['USD']는 0.85 기준
*/
public function test_mc_refund_exchange_rate_changed_uses_order_time(): void
{
[$product, $option] = $this->createProductWithOption(price: 26000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'USD' => ['rate' => 0.85, 'rounding_unit' => 0.01, 'rounding_method' => 'round'],
],
];
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
// 변환식: (26,000 / 1000) × 0.85 = 22.10 USD (스냅샷 환율 기준)
$this->assertNotNull($result->mcRefundAmount);
$this->assertArrayHasKey('USD', $result->mcRefundAmount);
$this->assertEquals(22.10, $result->mcRefundAmount['USD']);
}
/**
* E-2: JPY 반올림 규칙(floor) 적용 검증
*
* 스냅샷: JPY rounding_unit=1, method=floor
*/
public function test_mc_refund_rounding_rule_changed_uses_order_time(): void
{
[$product, $option] = $this->createProductWithOption(price: 15000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'JPY' => ['rate' => 9.2, 'rounding_unit' => 1, 'rounding_method' => 'floor'],
],
];
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
// 변환식: (15,000 / 1000) × 9.2 = 138.0 → floor → 138 JPY
$this->assertNotNull($result->mcRefundAmount);
$this->assertArrayHasKey('JPY', $result->mcRefundAmount);
$this->assertEquals(138, $result->mcRefundAmount['JPY']);
}
/**
* E-3: 배송비 + 포인트 포함 주문의 mc 변환 검증
*
* 배송비 3000, 포인트 5000 사용 → 전체취소 시 mc_refund_points_amount, mc_refund_shipping_amount 모두 존재
*/
public function test_mc_refund_points_and_shipping_converted_correctly(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
[$product, $option] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
usePoints: 5000,
);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'USD' => ['rate' => 0.85, 'rounding_unit' => 0.01, 'rounding_method' => 'round'],
],
];
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::POINTS_FIRST);
// 배송비 환불 존재 (전체취소 → 배송비 3000 환불)
$this->assertGreaterThan(0, $result->shippingDifference);
$this->assertNotNull($result->mcRefundShippingAmount);
$this->assertArrayHasKey('USD', $result->mcRefundShippingAmount);
$this->assertGreaterThan(0, $result->mcRefundShippingAmount['USD']);
// 포인트 환불 존재 (POINTS_FIRST → 포인트 5000 전액 환불)
$this->assertGreaterThan(0, $result->refundPointsAmount);
$this->assertNotNull($result->mcRefundPointsAmount);
$this->assertArrayHasKey('USD', $result->mcRefundPointsAmount);
$this->assertGreaterThan(0, $result->mcRefundPointsAmount['USD']);
}
// ================================================================
// F. 복합 시나리오 (5건)
// ================================================================
/**
* F-1: 스냅샷 가격 변경 + 쿠폰 + 배송비 전환 복합
*
* 50,000×2, CONDITIONAL_FREE(threshold=80000), 주문쿠폰 5000(min=60000)
* 1개 취소 → 잔여 50,000 < 80,000 → 배송비 추가, < 60,000 → 쿠폰 복원
*/
public function test_complex_snapshot_price_change_with_coupon_and_shipping_transition(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 80000,
);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 60000,
);
[$product, $option] = $this->createProductWithOption(price: 50000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
// 상품 가격 변경 (스냅샷에는 영향 없음)
$product->update(['selling_price' => 70000]);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
// 쿠폰 복원 (잔여 50,000 < 60,000)
$this->assertContains($couponIssue->id, $result->restoredCouponIssueIds);
// 배송비 전환 (잔여 50,000 < 80,000 → 유료배송 3000 추가)
// 원래: 100,000 - 5,000(쿠폰) + 0(무료배송) = 95,000
// 잔여: 50,000 - 0(쿠폰복원) + 3,000(배송비) = 53,000
$this->assertEquals(53000, $result->recalculatedPaidAmount);
$this->assertEquals($originalPaid - 53000, $result->refundAmount);
}
/**
* F-2: 배송비 + 도서산간 + 상품쿠폰 + 마일리지 전체취소
*
* 30,000×2, FIXED 3000 + extraFee 2000 + 상품쿠폰 3000 + 포인트 5000
* 전체취소 → 전액 환불, 쿠폰 복원
*/
public function test_complex_snapshot_coupon_expired_with_mileage_and_extra_fee(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
'island' => 2000,
'mountain' => 3000,
],
);
$productCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$address = new ShippingAddress(countryCode: 'KR', zipcode: '63200');
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
couponIssueIds: [$productCoupon->id],
usePoints: 5000,
shippingAddress: $address,
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
// 전체취소 → 결제금 전액 환불 (PG + 포인트)
$totalRefund = $result->refundAmount + $result->refundPointsAmount;
$this->assertEquals((float) $order->total_paid_amount + (float) $order->total_points_used_amount, $totalRefund);
// 쿠폰 복원
$this->assertContains($productCoupon->id, $result->restoredCouponIssueIds);
// 재계산 결제금액은 0
$this->assertEquals(0, $result->recalculatedPaidAmount);
}
/**
* F-3: 특정 우선순위로 부분취소 계산 검증
*
* 20,000×3, 포인트 15,000
* 1개 취소(PG_FIRST) → 환불 배분 확인
*/
public function test_complex_snapshot_sequential_cancel_with_priority_change(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
],
usePoints: 15000,
);
$order = $this->createOrderFromCalculation($input);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 1],
]);
$pgResult = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::PG_FIRST);
$ptResult = $this->adjustmentService->calculate($order, $cancellation, RefundPriorityEnum::POINTS_FIRST);
// 총 환불액 동일
$pgTotal = $pgResult->refundAmount + $pgResult->refundPointsAmount;
$ptTotal = $ptResult->refundAmount + $ptResult->refundPointsAmount;
$this->assertEquals($pgTotal, $ptTotal);
// PG_FIRST: PG 환불 >= 포인트 환불
$this->assertGreaterThanOrEqual($pgResult->refundPointsAmount, $pgResult->refundAmount);
// POINTS_FIRST: 포인트 환불 >= PG 환불
$this->assertGreaterThanOrEqual($ptResult->refundAmount, $ptResult->refundPointsAmount);
}
/**
* F-4: 다중 배송정책 그룹 전체취소 시 각 그룹별 shippingDetails 확인
*
* 상품A(정책1 FIXED 3000) + 상품B(정책2 FIXED 5000)
* 전체취소 → shippingDetails 2개, 합계 8000
*/
public function test_complex_snapshot_multi_policy_last_item_each_group(): void
{
$sp1 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
$sp2 = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 5000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp1);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp2);
$input = new CalculationInput(items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
// 정책별 2개 OrderShipping 레코드로 교체
$order->shippings()->delete();
OrderShipping::factory()->forOrder($order)->create([
'shipping_policy_id' => $sp1->id,
'shipping_status' => 'pending',
'base_shipping_amount' => 3000,
'extra_shipping_amount' => 0,
'total_shipping_amount' => 3000,
'shipping_discount_amount' => 0,
]);
OrderShipping::factory()->forOrder($order)->create([
'shipping_policy_id' => $sp2->id,
'shipping_status' => 'pending',
'base_shipping_amount' => 5000,
'extra_shipping_amount' => 0,
'total_shipping_amount' => 5000,
'shipping_discount_amount' => 0,
]);
$order->load('shippings');
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$this->assertCount(2, $result->shippingDetails);
$this->assertEquals(8000, $result->shippingDifference);
$totalDetailDiff = array_sum(array_column($result->shippingDetails, 'total_difference'));
$this->assertEquals(8000, $totalDetailDiff);
}
/**
* F-5: 모든 할인 + 다중통화 복합 — mc_* 필드 전체 검증
*
* 50,000×1, 배송비 3000, 주문쿠폰 5000, 포인트 10,000
* currency_snapshot USD rate=1300
* 전체취소 → mc_refund_amount, mc_refund_points_amount, mc_refund_shipping_amount 모두 존재
*/
public function test_complex_snapshot_mc_refund_with_all_discounts(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
[$product, $option] = $this->createProductWithOption(price: 50000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 10000,
);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'USD' => ['rate' => 0.85, 'rounding_unit' => 0.01, 'rounding_method' => 'round'],
],
];
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::POINTS_FIRST);
// mc_refund_amount 존재 (PG 환불)
$this->assertNotNull($result->mcRefundAmount);
$this->assertArrayHasKey('KRW', $result->mcRefundAmount);
$this->assertArrayHasKey('USD', $result->mcRefundAmount);
// mc_refund_points_amount 존재 (포인트 환불)
$this->assertNotNull($result->mcRefundPointsAmount);
$this->assertArrayHasKey('USD', $result->mcRefundPointsAmount);
$this->assertGreaterThan(0, $result->mcRefundPointsAmount['USD']);
// mc_refund_shipping_amount 존재 (배송비 환불)
$this->assertNotNull($result->mcRefundShippingAmount);
$this->assertArrayHasKey('USD', $result->mcRefundShippingAmount);
$this->assertGreaterThan(0, $result->mcRefundShippingAmount['USD']);
// 전체취소 → 재계산 결제금액 0
$this->assertEquals(0, $result->recalculatedPaidAmount);
// 쿠폰 복원
$this->assertContains($couponIssue->id, $result->restoredCouponIssueIds);
}
// ========================================
// cancel_blocked (재계산 결제금액 > 원 결제금액)
// ========================================
/**
* 부분취소 시 쿠폰 조건 미달로 결제금액 증가 → cancel_blocked = true
*
* 상품A(15,000×1) + 상품B(15,000×1) = 30,000
* 주문쿠폰(FIXED 20,000, minOrder=25,000) → 결제 10,000원
* B 취소 → 잔여 15,000 < 25,000 → 쿠폰 소멸 → 재계산 15,000 > 원결제 10,000
*/
public function test_cancel_blocked_when_coupon_lost_increases_payment(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 20000,
minOrderAmount: 25000,
);
[$productA, $optionA] = $this->createProductWithOption(price: 15000);
[$productB, $optionB] = $this->createProductWithOption(price: 15000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 원 결제금액: 30,000 - 20,000 = 10,000
$this->assertEquals(10000, (float) $order->total_paid_amount);
// B 취소
$optB = $order->options->where('product_option_id', $optionB->id)->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
// 재계산 결제금액 15,000 > 원 결제 10,000 → cancel_blocked
$this->assertTrue($result->isCancelBlocked());
// toPreviewArray에도 반영
$preview = $result->toPreviewArray();
$this->assertTrue($preview['cancel_blocked']);
$this->assertNotNull($preview['cancel_blocked_reason']);
}
/**
* 부분취소 후에도 쿠폰 조건 충족 → cancel_blocked = false
*
* 상품A(30,000×1) + 상품B(30,000×1) = 60,000
* 주문쿠폰(FIXED 5,000, minOrder=25,000) → 결제 55,000원
* B 취소 → 잔여 30,000 >= 25,000 → 쿠폰 유지 → 재계산 25,000 < 원결제 55,000
*/
public function test_cancel_not_blocked_when_coupon_still_valid(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 25000,
);
[$productA, $optionA] = $this->createProductWithOption(price: 30000);
[$productB, $optionB] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$optB = $order->options->where('product_option_id', $optionB->id)->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
$this->assertFalse($result->isCancelBlocked());
$preview = $result->toPreviewArray();
$this->assertFalse($preview['cancel_blocked']);
$this->assertNull($preview['cancel_blocked_reason']);
}
/**
* 전체취소 → cancel_blocked = false (전체취소는 항상 허용)
*/
public function test_cancel_not_blocked_on_full_cancel(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 20000,
minOrderAmount: 25000,
);
[$product, $option] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
// 전체취소: 재계산 결제금액 0 → cancel_blocked = false
$this->assertFalse($result->isCancelBlocked());
}
/**
* 상품쿠폰(대형 할인) 부분취소로 할인 소멸 → cancel_blocked
*
* 상품A(10,000×3) + 상품쿠폰(FIXED 8,000, minOrder=25,000)
* 원 결제: 30,000 - 24,000(8000×3) = 6,000원
* 수량 2개 취소 → 잔여 10,000 < 25,000 → 쿠폰 소멸 → 재계산 10,000 > 원결제 6,000
*/
public function test_cancel_blocked_product_coupon_quantity_loss(): void
{
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 8000,
minOrderAmount: 25000,
);
[$product, $option] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 원 결제: 30,000 - 24,000 = 6,000
$this->assertEquals(6000, (float) $order->total_paid_amount);
$opt = $order->options->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $opt->id, 'cancel_quantity' => 2],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
$this->assertTrue($result->isCancelBlocked());
}
// ================================================================
// G. refund_total 정합성 검증 (배송비 이중 계산 방지)
// ================================================================
/**
* G-1: 부분취소 시 배송비 변동 발생 → refund_total에 배송비 이중 포함 안됨
*
* 상품A(50,000×1) + 상품B(10,000×1), 무료배송 기준 50,000 이상 (배송비 3,000)
* 원 주문: 60,000원 (배송비 0, 무료배송 충족)
* B 취소 → 잔여 50,000 >= 50,000 → 무료 유지 → refund_total = 10,000
*/
public function test_refund_total_equals_refund_plus_points_no_shipping_double_count(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$productA, $optionA] = $this->createProductWithOption(price: 50000, shippingPolicy: $sp);
[$productB, $optionB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
);
$order = $this->createOrderFromCalculation($input);
$optB = $order->options->where('product_option_id', $optionB->id)->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
$preview = $result->toPreviewArray();
// refund_total = refundAmount + refundPointsAmount (배송비 별도 가산 없음)
$expected = max(0, $result->refundAmount) + $result->refundPointsAmount;
$this->assertEquals($expected, $preview['refund_total']);
}
/**
* G-2: 부분취소로 무료배송 → 유료배송 전환 시 refund_total 정합성
*
* 상품A(30,000×1) + 상품B(30,000×1), 무료배송 기준 50,000 (배송비 3,000)
* 원 주문: 60,000 (배송비 0 — 무료배송)
* B 취소 → 잔여 30,000 < 50,000 → 배송비 3,000 발생
* 재계산 결제금액 33,000, 환불 = 60,000 - 33,000 = 27,000 (배송비 차이 포함)
* refund_total = 27,000 (shippingDifference -3,000이 별도 가산되면 안됨)
*/
public function test_refund_total_with_shipping_threshold_change(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 50000,
);
[$productA, $optionA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$productB, $optionB] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
);
$order = $this->createOrderFromCalculation($input);
// 원 주문: 60,000 배송비 0 (무료배송)
$this->assertEquals(0, (float) $order->total_shipping_amount);
$optB = $order->options->where('product_option_id', $optionB->id)->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optB->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
$preview = $result->toPreviewArray();
// 배송비 차이 음수 (무료→유료: 환불 관점에서 음수)
$this->assertLessThan(0, $result->shippingDifference);
// refund_total = refundAmount + refundPointsAmount (shippingDifference 별도 가산 없음)
$expected = max(0, $result->refundAmount) + $result->refundPointsAmount;
$this->assertEquals($expected, $preview['refund_total']);
}
/**
* G-3: 전체취소 시 배송비 있는 주문 → refund_total = 원결제 전액
*
* 상품 10,000 + 배송비 3,000 = 결제 13,000
* 전체취소 → refund_total = 13,000 (배송비 이중 포함 안됨)
*/
public function test_refund_total_full_cancel_with_shipping(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
[$product, $option] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
);
$order = $this->createOrderFromCalculation($input);
// 원 주문: 10,000 + 3,000 = 13,000
$this->assertEquals(13000, (float) $order->total_paid_amount);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$preview = $result->toPreviewArray();
// 전체취소: refund_total = 원결제 전액 (배송비 이중 아님)
$expected = max(0, $result->refundAmount) + $result->refundPointsAmount;
$this->assertEquals($expected, $preview['refund_total']);
$this->assertEquals(13000, $preview['refund_total']);
}
/**
* G-4: 쿠폰 + 배송비 변동 복합 — refund_total 정합성
*
* 상품A(50,000) + 상품B(6,000), 배송비 8,500 (무료배송 50,000 이상)
* 주문쿠폰 30% 할인 + 배송비쿠폰 적용 시나리오
* B만 남기고 A 취소 → 쿠폰 소멸 + 배송비 변동
* refund_total = refundAmount + refundPointsAmount
*/
public function test_refund_total_coupon_and_shipping_combined(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 8500,
freeThreshold: 50000,
);
[$productA, $optionA] = $this->createProductWithOption(price: 50000, shippingPolicy: $sp);
[$productB, $optionB] = $this->createProductWithOption(price: 6000, shippingPolicy: $sp);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 30,
minOrderAmount: 40000,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 원 주문: (50,000 + 6,000) × 0.7 = 39,200 + 배송비(무료 — 56,000 > 50,000)
$this->assertEquals(0, (float) $order->total_shipping_amount);
$optA = $order->options->where('product_option_id', $optionA->id)->first();
$cancellation = $this->buildCancellation([
['order_option_id' => $optA->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $cancellation);
$preview = $result->toPreviewArray();
// refund_total 정합성: refundAmount + refundPointsAmount (shippingDifference 별도 가산 없음)
$expected = max(0, $result->refundAmount) + $result->refundPointsAmount;
$this->assertEquals($expected, $preview['refund_total']);
// refund_total = 원결제액 - 재계산결제액
$this->assertEquals(
(float) $order->total_paid_amount - $result->recalculatedPaidAmount,
$preview['refund_total']
);
}
// ====================================================================
// L. 총 정가금액(total_list_price_amount) 스냅샷 테스트
// ====================================================================
/**
* L-1: 전체취소 시 original_snapshot에 total_list_price_amount 포함
*
* 정가 30,000 × 2개 = 60,000
* 판매가 20,000 × 2개 = 40,000
*/
public function test_full_cancel_snapshot_includes_total_list_price_amount(): void
{
[$product, $option] = $this->createProductWithOption(price: 20000, listPrice: 30000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$preview = $result->toPreviewArray();
// original_snapshot: 정가 합계 = 30,000 × 2 = 60,000
$this->assertEquals(60000, $preview['original_snapshot']['total_list_price_amount']);
// original_snapshot: 판매가 합계 = 20,000 × 2 = 40,000
$this->assertEquals(40000, $preview['original_snapshot']['subtotal_amount']);
// recalculated_snapshot: 전체취소이므로 0
$this->assertEquals(0, $preview['recalculated_snapshot']['total_list_price_amount']);
$this->assertEquals(0, $preview['recalculated_snapshot']['subtotal_amount']);
}
/**
* L-2: 부분취소 시 total_list_price_amount 정확한 계산
*
* 옵션A: 정가 50,000, 판매가 40,000, 2개
* 옵션B: 정가 30,000, 판매가 20,000, 3개
* → 옵션B 2개 취소
* → 취소 후: A(50,000×2) + B(30,000×1) = 130,000 (정가)
* → 취소 후: A(40,000×2) + B(20,000×1) = 100,000 (판매가)
*/
public function test_partial_cancel_snapshot_total_list_price_amount(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 40000, listPrice: 50000);
[$productB, $optionB] = $this->createProductWithOption(price: 20000, listPrice: 30000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 2),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
// 옵션B 2개 취소
$optionBRecord = $order->options->where('product_option_id', $optionB->id)->first();
$adjustment = CancellationAdjustment::fromArray([
['order_option_id' => $optionBRecord->id, 'cancel_quantity' => 2],
]);
$result = $this->adjustmentService->calculate($order, $adjustment);
$preview = $result->toPreviewArray();
// original: A(50,000×2) + B(30,000×3) = 100,000 + 90,000 = 190,000
$this->assertEquals(190000, $preview['original_snapshot']['total_list_price_amount']);
// original: A(40,000×2) + B(20,000×3) = 80,000 + 60,000 = 140,000
$this->assertEquals(140000, $preview['original_snapshot']['subtotal_amount']);
// recalculated: A(50,000×2) + B(30,000×1) = 100,000 + 30,000 = 130,000
$this->assertEquals(130000, $preview['recalculated_snapshot']['total_list_price_amount']);
}
/**
* L-3: 정가 = 판매가일 때 total_list_price_amount == subtotal_amount
*/
public function test_list_price_equals_selling_price_same_amounts(): void
{
[$product, $option] = $this->createProductWithOption(price: 25000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3),
]);
$order = $this->createOrderFromCalculation($input);
$adjustment = $this->buildFullCancellation($order);
$result = $this->adjustmentService->calculate($order, $adjustment);
$preview = $result->toPreviewArray();
// list_price == selling_price이므로 두 값이 동일
$this->assertEquals(
$preview['original_snapshot']['subtotal_amount'],
$preview['original_snapshot']['total_list_price_amount']
);
}
/**
* 마일리지 설정을 활성화합니다 (적립 발생 테스트용).
*
* @param int $earnRate 기본 적립률 (%)
*/
protected function enableMileageSettings(int $earnRate = 10): void
{
$settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($settingsPath)) {
mkdir($settingsPath, 0755, true);
}
file_put_contents(
$settingsPath.'/mileage.json',
json_encode([
'enabled' => true,
'default_earn_rate' => $earnRate,
'earn_trigger' => 'confirmed',
'earn_delay_days' => 0,
'currency_rules' => [
['currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1, 'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0],
],
'expiry_enabled' => true,
'expiry_days' => 365,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
// 설정 파일을 새로 썼으므로 주입된 서비스 인스턴스를 재해석 (설정 캐시 초기화)
$this->calculationService = app(OrderCalculationService::class);
$this->adjustmentService = app(OrderAdjustmentService::class);
}
/**
* §6.3 회귀: 부분취소 후 잔여 옵션의 적립액(subtotal_earned_points_amount)이
* 재안분값으로 옵션 업데이트에 저장되는지 검증 (PG 우선).
*
* 결함: buildOptionUpdates 가 적립액 갱신을 누락하면 취소 상품 몫까지 적립됨.
*/
public function test_partial_cancel_recalculates_earned_points_pg_first(): void
{
$this->enableMileageSettings(earnRate: 10);
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$optionToCancel = $order->options->firstWhere('product_option_id', $optionA->id);
$remainingOption = $order->options->firstWhere('product_option_id', $optionB->id);
$adjustment = $this->buildCancellation([
['order_option_id' => $optionToCancel->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $adjustment);
// 잔여 옵션(B, 10,000)의 적립액이 옵션 업데이트에 저장되어야 함 (10% = 1,000)
$this->assertArrayHasKey($remainingOption->id, $result->optionUpdates);
$this->assertArrayHasKey(
'subtotal_earned_points_amount',
$result->optionUpdates[$remainingOption->id],
'부분취소 재계산 결과에 적립액 갱신이 누락되었습니다.'
);
$this->assertEquals(
1000,
$result->optionUpdates[$remainingOption->id]['subtotal_earned_points_amount'],
'취소 상품 몫이 제외된 잔여 옵션 적립액과 일치해야 합니다.'
);
}
/**
* §6.3 회귀: 마일리지 우선(POINTS_FIRST) 환불 시에도 잔여 옵션 적립액이 재안분 저장됨.
*
* 입력: A(20,000)+B(10,000), rate 10%, usePoints 3,000(A:2,000/B:1,000 안분), A 취소.
* 예상: 재계산 시 B만 남고 usePoints 3,000 전액이 B(결제금액 10,000)에 적용 →
* B earnable = 10,000 − 3,000 = 7,000 → earn = floor(7,000 × 10%) = 700.
* 적립 재안분값은 환불 우선순위와 무관(우선순위는 refund split 만 변경)하므로 pg_first 와 동일.
*/
public function test_partial_cancel_recalculates_earned_points_points_first(): void
{
$this->enableMileageSettings(earnRate: 10);
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$optionToCancel = $order->options->firstWhere('product_option_id', $optionA->id);
$remainingOption = $order->options->firstWhere('product_option_id', $optionB->id);
$adjustment = $this->buildCancellation([
['order_option_id' => $optionToCancel->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $adjustment, RefundPriorityEnum::POINTS_FIRST);
$this->assertArrayHasKey('subtotal_earned_points_amount', $result->optionUpdates[$remainingOption->id]);
// 잔여 B 적립액 = floor((10,000 − 3,000) × 10%) = 700 (마일리지 사용분 제외 정확값)
$this->assertEquals(
700,
$result->optionUpdates[$remainingOption->id]['subtotal_earned_points_amount'],
'POINTS_FIRST 부분취소 시 잔여 옵션 적립액은 사용분(3,000) 제외 기준 정확값이어야 합니다.'
);
}
/**
* §6.3: 마일리지 사용 + 부분취소 재안분 적립값은 환불 우선순위(PG_FIRST/POINTS_FIRST)에 무관하게 동일하다.
*
* 근거: 적립 재안분은 잔여 옵션 재계산(recalcResult)에서 도출되며, 우선순위는 환불 분배(refund split)에만 작용.
* 동일 입력(A 20,000 + B 10,000, rate 10%, usePoints 3,000, A 취소)으로 두 우선순위 각각 계산 →
* 잔여 B 적립액 == 700 으로 동일, 단 refund split(refundAmount/refundPointsAmount)은 상이.
*/
public function test_partial_cancel_earned_points_equal_across_refund_priority(): void
{
$this->enableMileageSettings(earnRate: 10);
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$makeOrder = function () use ($productA, $optionA, $productB, $optionB): Order {
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
usePoints: 3000,
);
return $this->createOrderFromCalculation($input);
};
$orderPg = $makeOrder();
$cancelPg = $orderPg->options->firstWhere('product_option_id', $optionA->id);
$remainPg = $orderPg->options->firstWhere('product_option_id', $optionB->id);
$resultPg = $this->adjustmentService->calculate(
$orderPg,
$this->buildCancellation([['order_option_id' => $cancelPg->id, 'cancel_quantity' => 1]]),
RefundPriorityEnum::PG_FIRST
);
$orderPoints = $makeOrder();
$cancelPoints = $orderPoints->options->firstWhere('product_option_id', $optionA->id);
$remainPoints = $orderPoints->options->firstWhere('product_option_id', $optionB->id);
$resultPoints = $this->adjustmentService->calculate(
$orderPoints,
$this->buildCancellation([['order_option_id' => $cancelPoints->id, 'cancel_quantity' => 1]]),
RefundPriorityEnum::POINTS_FIRST
);
// 적립 재안분값은 두 우선순위에서 동일 (700)
$this->assertEquals(700, $resultPg->optionUpdates[$remainPg->id]['subtotal_earned_points_amount']);
$this->assertEquals(
$resultPg->optionUpdates[$remainPg->id]['subtotal_earned_points_amount'],
$resultPoints->optionUpdates[$remainPoints->id]['subtotal_earned_points_amount'],
'적립 재안분값은 환불 우선순위와 무관하게 동일해야 합니다.'
);
// 단, 환불 분배(refund split)는 우선순위에 따라 달라져야 함 (POINTS_FIRST 는 포인트 환불이 우선)
$this->assertGreaterThanOrEqual($resultPg->refundPointsAmount, $resultPoints->refundPointsAmount);
}
/**
* §6.3: 부분취소 후 잔여 옵션 적립액 합이 취소 상품 몫을 포함하지 않는다 (취소분 미적립).
*
* 입력: A(20,000)+B(10,000), rate 10%, 사용 없음. 원 적립 합 = 2,000 + 1,000 = 3,000.
* A 취소 → 잔여 적립(B) = 1,000 < 원 적립 합 3,000 (취소 A 몫 2,000 제외됨).
*/
public function test_partial_cancel_excludes_cancelled_option_share_from_earned(): void
{
$this->enableMileageSettings(earnRate: 10);
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$originalEarnedSum = (int) $order->options->sum('subtotal_earned_points_amount');
$optionToCancel = $order->options->firstWhere('product_option_id', $optionA->id);
$remainingOption = $order->options->firstWhere('product_option_id', $optionB->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $optionToCancel->id, 'cancel_quantity' => 1]])
);
// 원 적립 합은 3,000 (A 2,000 + B 1,000)
$this->assertEquals(3000, $originalEarnedSum);
// 잔여 B 적립 = 1,000 (취소 A 몫 2,000 미포함)
$this->assertEquals(1000, $result->optionUpdates[$remainingOption->id]['subtotal_earned_points_amount']);
}
/**
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (정책 확정).
*
* 결함: buildOptionUpdates 가 plain subtotal_* 만 갱신하고 mc_subtotal_* 는 부분취소 후 stale.
*/
public function test_partial_cancel_recalculates_option_multi_currency_columns(): void
{
$this->enableMileageSettings(earnRate: 10);
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]);
$currencySnapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => 1, 'rounding_method' => 'round'],
'USD' => ['rate' => 0.85, 'rounding_unit' => 0.01, 'rounding_method' => 'round'],
],
];
$order = $this->createOrderFromCalculation($input, ['currency_snapshot' => $currencySnapshot]);
$optionToCancel = $order->options->firstWhere('product_option_id', $optionA->id);
$remainingOption = $order->options->firstWhere('product_option_id', $optionB->id);
$adjustment = $this->buildCancellation([
['order_option_id' => $optionToCancel->id, 'cancel_quantity' => 1],
]);
$result = $this->adjustmentService->calculate($order, $adjustment);
$updates = $result->optionUpdates[$remainingOption->id];
// mc_ 적립/사용 컬럼이 재산출되어야 함 (KRW 기준값 = plain 값과 동일)
$this->assertArrayHasKey('mc_subtotal_earned_points_amount', $updates, 'mc_ 적립 컬럼이 부분취소 재계산에 포함되어야 합니다.');
$this->assertArrayHasKey('mc_subtotal_points_used_amount', $updates, 'mc_ 사용 컬럼도 함께 갱신되어야 합니다(비대칭 해소).');
$this->assertArrayHasKey('KRW', $updates['mc_subtotal_earned_points_amount']);
$this->assertSame(
(int) $updates['subtotal_earned_points_amount'],
(int) $updates['mc_subtotal_earned_points_amount']['KRW'],
'KRW 기준 mc_ 적립값은 plain 재안분값과 일치해야 합니다.'
);
// USD 변환값 존재
$this->assertArrayHasKey('USD', $updates['mc_subtotal_earned_points_amount']);
}
// ================================================================
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (추가 요구)
// OrderCalculation 테스트 수준의 정확값 검증. 각 케이스는 적립 재안분값이
// 환불 우선순위에 무관(우선순위는 refund split 만 변경)함을 함께 단언한다.
// ================================================================
/**
* 복합 1: 상품쿠폰 + 마일리지 사용 + 부분취소.
*
* A(20,000) + B(10,000), 상품쿠폰 FIXED 3,000(scope ALL), rate 10%, usePoints 3,000, A 취소.
* 잔여 B 적립 base = 상품쿠폰 차감 후 금액 − 재안분 마일리지 사용분. 취소 옵션 쿠폰 복원 동반.
* 정확값은 엔진 재계산 기준으로 고정하고, 취소분 미포함(잔여 적립 < 원 적립 합)을 함께 단언.
*/
public function test_partial_cancel_product_coupon_and_mileage_usage_exact(): void
{
$this->enableMileageSettings(earnRate: 10);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::ALL,
);
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$originalEarnedSum = (int) $order->options->sum('subtotal_earned_points_amount');
$optionToCancel = $order->options->firstWhere('product_option_id', $optionA->id);
$remainingOption = $order->options->firstWhere('product_option_id', $optionB->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $optionToCancel->id, 'cancel_quantity' => 1]])
);
$earned = (int) $result->optionUpdates[$remainingOption->id]['subtotal_earned_points_amount'];
// 상품쿠폰 FIXED 3,000(scope ALL)은 상품별 적용 → B 에 3,000 적용.
// 잔여 B earnable = 10,000 − 3,000(상품쿠폰) − 3,000(사용) = 4,000 → earn = floor(4,000 × 10%) = 400.
$this->assertEquals(400, $earned, '상품쿠폰+마일리지 복합 부분취소 잔여 적립 정확값');
// 취소 A 몫이 잔여에 합산되지 않음
$this->assertLessThan($originalEarnedSum, $earned);
}
/**
* 복합 2: 주문쿠폰 + 마일리지 사용 + 부분취소.
*
* A(20,000) + B(10,000), 주문쿠폰 ORDER_AMOUNT FIXED 3,000, rate 10%, usePoints 3,000, A 취소.
* 주문쿠폰은 잔여 옵션 비례 재안분 → 취소 후 B 의 주문쿠폰 안분이 커짐. earnable 재산출 정확값 고정.
*/
public function test_partial_cancel_order_coupon_and_mileage_usage_exact(): void
{
$this->enableMileageSettings(earnRate: 10);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::ALL,
);
[$productA, $optionA] = $this->createProductWithOption(price: 20000);
[$productB, $optionB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$optionToCancel = $order->options->firstWhere('product_option_id', $optionA->id);
$remainingOption = $order->options->firstWhere('product_option_id', $optionB->id);
$resultPg = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $optionToCancel->id, 'cancel_quantity' => 1]]),
RefundPriorityEnum::PG_FIRST
);
$earnedPg = (int) $resultPg->optionUpdates[$remainingOption->id]['subtotal_earned_points_amount'];
// 재계산: B 만 남으므로 주문쿠폰 3,000 전액 B 에 적용 + usePoints 3,000 전액 B.
// B earnable = 10,000 − 3,000(주문쿠폰) − 3,000(사용) = 4,000 → earn = floor(4,000 × 10%) = 400.
$this->assertEquals(400, $earnedPg, '주문쿠폰+마일리지 복합 부분취소 잔여 적립 정확값');
// 환불 우선순위 무관 동일
$order2 = $this->createOrderFromCalculation($input);
$cancel2 = $order2->options->firstWhere('product_option_id', $optionA->id);
$remain2 = $order2->options->firstWhere('product_option_id', $optionB->id);
$resultPoints = $this->adjustmentService->calculate(
$order2,
$this->buildCancellation([['order_option_id' => $cancel2->id, 'cancel_quantity' => 1]]),
RefundPriorityEnum::POINTS_FIRST
);
$this->assertEquals(
$earnedPg,
(int) $resultPoints->optionUpdates[$remain2->id]['subtotal_earned_points_amount'],
'주문쿠폰 복합에서도 적립 재안분값은 환불 우선순위와 무관해야 합니다.'
);
}
/**
* 복합 3: 조건부 무료배송 임계 붕괴 + 마일리지 사용 + 부분취소.
*
* 정책 CONDITIONAL_FREE(baseFee 3,000, freeThreshold 25,000). A(20,000)+B(10,000)=30,000 → 무료.
* A 취소 → 잔여 10,000 < 25,000 → 배송비 3,000 부활(shippingDifference 음수 방향).
* 배송비 재판정이 상품금액 기준 적립 안분을 오염시키지 않음을 검증.
*/
public function test_partial_cancel_free_shipping_threshold_breaks_with_mileage_usage(): void
{
$this->enableMileageSettings(earnRate: 10);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE,
baseFee: 3000,
freeThreshold: 25000,
);
[$productA, $optionA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$productB, $optionB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
// 원 주문은 무료배송 (합계 30,000 >= 25,000)
$this->assertEquals(0, (int) $order->total_shipping_amount);
$optionToCancel = $order->options->firstWhere('product_option_id', $optionA->id);
$remainingOption = $order->options->firstWhere('product_option_id', $optionB->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $optionToCancel->id, 'cancel_quantity' => 1]])
);
// 잔여 B 적립 = floor((10,000 − 3,000 사용) × 10%) = 700. 배송비 부활과 독립.
$this->assertEquals(
700,
(int) $result->optionUpdates[$remainingOption->id]['subtotal_earned_points_amount'],
'배송비 재판정이 상품금액 기준 적립 안분을 오염시키면 안 됩니다.'
);
// 잔여 주문은 무료배송 문턱 아래라 배송비 3,000 부활 → recalc 배송비 = 3,000
$this->assertEquals(3000, (int) $result->recalculated->summary->totalShipping);
}
/**
* 복합 4: 상품쿠폰 + 조건부 배송정책 + 마일리지 사용 3중 복합, 3옵션 중 1개 부분취소.
*
* 잔여 2옵션 적립 재안분 + 배송비 차이 + 복원 쿠폰 + 환불 split 동시 단언.
* 적립 재안분값은 두 우선순위에서 동일.
*/
public function test_partial_cancel_coupon_and_shipping_and_mileage_usage_full_composite(): void
{
$this->enableMileageSettings(earnRate: 10);
$sp = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FIXED, baseFee: 3000);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pC, $oC] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
// 주문마다 신규 쿠폰 발급 (발급 1건을 두 주문에 재사용하면 첫 주문이 USED 처리 → 둘째 주문 오염)
$makeOrder = function () use ($pA, $oA, $pB, $oB, $pC, $oC): Order {
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::ALL,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 6000,
);
return $this->createOrderFromCalculation($input);
};
$orderPg = $makeOrder();
$cancelA = $orderPg->options->firstWhere('product_option_id', $oA->id);
$remainB = $orderPg->options->firstWhere('product_option_id', $oB->id);
$remainC = $orderPg->options->firstWhere('product_option_id', $oC->id);
$resultPg = $this->adjustmentService->calculate(
$orderPg,
$this->buildCancellation([['order_option_id' => $cancelA->id, 'cancel_quantity' => 1]]),
RefundPriorityEnum::PG_FIRST
);
$earnedB = (int) $resultPg->optionUpdates[$remainB->id]['subtotal_earned_points_amount'];
$earnedC = (int) $resultPg->optionUpdates[$remainC->id]['subtotal_earned_points_amount'];
// 잔여 B/C earnable (상품쿠폰 3,000 각 적용 + usePoints 6,000 안분 B:4,000/C:2,000):
// B = 20,000 − 3,000 − 4,000 = 13,000 → 1,300 / C = 10,000 − 3,000 − 2,000 = 5,000 → 500.
$this->assertEquals(1300, $earnedB, '복합 잔여 B 적립 정확값');
$this->assertEquals(500, $earnedC, '복합 잔여 C 적립 정확값');
// 두 우선순위 적립값 동일
$orderPoints = $makeOrder();
$cancelA2 = $orderPoints->options->firstWhere('product_option_id', $oA->id);
$remainB2 = $orderPoints->options->firstWhere('product_option_id', $oB->id);
$remainC2 = $orderPoints->options->firstWhere('product_option_id', $oC->id);
$resultPoints = $this->adjustmentService->calculate(
$orderPoints,
$this->buildCancellation([['order_option_id' => $cancelA2->id, 'cancel_quantity' => 1]]),
RefundPriorityEnum::POINTS_FIRST
);
$this->assertEquals($earnedB, (int) $resultPoints->optionUpdates[$remainB2->id]['subtotal_earned_points_amount']);
$this->assertEquals($earnedC, (int) $resultPoints->optionUpdates[$remainC2->id]['subtotal_earned_points_amount']);
// 환불 총액은 두 우선순위에서 동일 (분배만 상이)
$this->assertEquals(
$resultPg->refundAmount + $resultPg->refundPointsAmount,
$resultPoints->refundAmount + $resultPoints->refundPointsAmount,
'환불 총액은 우선순위와 무관하게 동일해야 합니다.'
);
}
/**
* 복합 5: 복합 상황에서 옵션 전량취소가 아닌 수량 일부 축소.
*
* A(10,000)×3 + B(10,000)×1, rate 10%, usePoints 3,000. A 수량 2개만 취소(1개 잔존).
* 잔여 A(1개)+B 기준 적립 재산출 정확값.
*/
public function test_partial_cancel_composite_reduce_quantity_not_full_option(): void
{
$this->enableMileageSettings(earnRate: 10);
[$pA, $oA] = $this->createProductWithOption(price: 10000);
[$pB, $oB] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->firstWhere('product_option_id', $oA->id);
$optionB = $order->options->firstWhere('product_option_id', $oB->id);
// A 3개 중 2개만 취소 → A 1개 잔존
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $optionA->id, 'cancel_quantity' => 2]])
);
// 잔여: A(1×10,000)=10,000, B(10,000)=10,000, 총 20,000. usePoints 3,000 → A:1,500/B:1,500.
// A earnable = 10,000 − 1,500 = 8,500 → 850. B 동일 850.
$this->assertEquals(850, (int) $result->optionUpdates[$optionA->id]['subtotal_earned_points_amount']);
$this->assertEquals(850, (int) $result->optionUpdates[$optionB->id]['subtotal_earned_points_amount']);
}
/**
* 복합 6: 도서산간 추가배송비 + 마일리지 사용 + 부분취소.
*
* 정책 FIXED 3,000 + 도서산간(zipcode 63* → +3,000). 배송지 제주(63123).
* A(20,000)+B(10,000), rate 10%, usePoints 3,000, A 취소.
* 도서산간 추가배송비는 상품금액 기준 적립 base 와 독립 — 적립값이 추가배송비에 오염되지 않음.
*/
public function test_partial_cancel_island_extra_shipping_fee_with_mileage_usage(): void
{
$this->enableMileageSettings(earnRate: 10);
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [
['zipcode' => '63*', 'fee' => 3000],
],
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
[$pB, $oB] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 3000,
shippingAddress: new ShippingAddress(zipcode: '63123'),
);
$order = $this->createOrderFromCalculation($input);
// 도서산간 추가배송비가 원 주문에 반영됨
$this->assertGreaterThan(0, (int) $order->extra_shipping_amount);
$optionA = $order->options->firstWhere('product_option_id', $oA->id);
$optionB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $optionA->id, 'cancel_quantity' => 1]])
);
// 잔여 B 적립 = floor((10,000 − 3,000 사용) × 10%) = 700. 도서산간 추가배송비와 독립.
$this->assertEquals(
700,
(int) $result->optionUpdates[$optionB->id]['subtotal_earned_points_amount'],
'도서산간 추가배송비가 상품금액 기준 적립 안분을 오염시키면 안 됩니다.'
);
}
/**
* 복합 7: 상품별 배송정책 상이 + 마일리지 사용 + 부분취소.
*
* 옵션 A=정책α(FIXED 3,000), 옵션 B=정책β(CONDITIONAL_FREE freeThreshold 5,000).
* A(20,000)+B(10,000), rate 10%, usePoints 3,000, A 취소.
* 잔여 B 는 정책β 기준 배송비 재판정(10,000 >= 5,000 → 무료), B 적립 재안분 정확값.
*/
public function test_partial_cancel_per_product_distinct_shipping_policies_with_mileage(): void
{
$this->enableMileageSettings(earnRate: 10);
$spA = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::FIXED, baseFee: 3000);
$spB = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE, baseFee: 2500, freeThreshold: 5000);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $spA);
[$pB, $oB] = $this->createProductWithOption(price: 10000, shippingPolicy: $spB);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
],
usePoints: 3000,
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->firstWhere('product_option_id', $oA->id);
$optionB = $order->options->firstWhere('product_option_id', $oB->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $optionA->id, 'cancel_quantity' => 1]])
);
// 잔여 B 적립 = floor((10,000 − 3,000) × 10%) = 700. 정책β 기준 배송비는 무료(10,000 >= 5,000).
$this->assertEquals(
700,
(int) $result->optionUpdates[$optionB->id]['subtotal_earned_points_amount'],
'옵션별 배송정책이 달라도 적립 재안분은 상품금액 기준으로 정확해야 합니다.'
);
$this->assertEquals(0, (int) $result->recalculated->summary->totalShipping, '잔여 B 는 정책β 무료배송 문턱 충족');
}
/**
* 복합 8: 상품별 상이 배송정책 + 도서산간 + 쿠폰 + 마일리지 최대 복합, 3옵션 중 1개 부분취소.
*
* 잔여 2옵션 적립 재안분 + 옵션별 배송비 + 도서산간 + 쿠폰 복원 + 환불 split 전 항목 동시 단언.
* 적립 재안분값은 두 우선순위에서 동일.
*/
public function test_partial_cancel_full_composite_per_product_policy_island_coupon_mileage(): void
{
$this->enableMileageSettings(earnRate: 10);
$spA = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
extraFeeEnabled: true,
extraFeeSettings: [['zipcode' => '63*', 'fee' => 3000]],
);
$spB = $this->createShippingPolicy(chargePolicy: ChargePolicyEnum::CONDITIONAL_FREE, baseFee: 2500, freeThreshold: 5000);
[$pA, $oA] = $this->createProductWithOption(price: 30000, shippingPolicy: $spA);
[$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $spB);
[$pC, $oC] = $this->createProductWithOption(price: 10000, shippingPolicy: $spB);
// 주문마다 신규 쿠폰 발급 (발급 1건 재사용 시 첫 주문 USED → 둘째 주문 오염)
$makeOrder = function () use ($pA, $oA, $pB, $oB, $pC, $oC): Order {
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
targetScope: CouponTargetScope::ALL,
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
new CalculationItem(productId: $pC->id, productOptionId: $oC->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
usePoints: 6000,
shippingAddress: new ShippingAddress(zipcode: '63123'),
);
return $this->createOrderFromCalculation($input);
};
$orderPg = $makeOrder();
$cancelA = $orderPg->options->firstWhere('product_option_id', $oA->id);
$remainB = $orderPg->options->firstWhere('product_option_id', $oB->id);
$remainC = $orderPg->options->firstWhere('product_option_id', $oC->id);
$resultPg = $this->adjustmentService->calculate(
$orderPg,
$this->buildCancellation([['order_option_id' => $cancelA->id, 'cancel_quantity' => 1]]),
RefundPriorityEnum::PG_FIRST
);
$earnedB = (int) $resultPg->optionUpdates[$remainB->id]['subtotal_earned_points_amount'];
$earnedC = (int) $resultPg->optionUpdates[$remainC->id]['subtotal_earned_points_amount'];
$this->assertGreaterThan(0, $earnedB);
$this->assertGreaterThan(0, $earnedC);
// 적립 재안분값은 두 우선순위에서 동일
$orderPoints = $makeOrder();
$cancelA2 = $orderPoints->options->firstWhere('product_option_id', $oA->id);
$remainB2 = $orderPoints->options->firstWhere('product_option_id', $oB->id);
$remainC2 = $orderPoints->options->firstWhere('product_option_id', $oC->id);
$resultPoints = $this->adjustmentService->calculate(
$orderPoints,
$this->buildCancellation([['order_option_id' => $cancelA2->id, 'cancel_quantity' => 1]]),
RefundPriorityEnum::POINTS_FIRST
);
$this->assertEquals($earnedB, (int) $resultPoints->optionUpdates[$remainB2->id]['subtotal_earned_points_amount']);
$this->assertEquals($earnedC, (int) $resultPoints->optionUpdates[$remainC2->id]['subtotal_earned_points_amount']);
// 환불 총액 동일
$this->assertEquals(
$resultPg->refundAmount + $resultPg->refundPointsAmount,
$resultPoints->refundAmount + $resultPoints->refundPointsAmount,
);
}
// ================================================================
// 부분취소 부가세 재산출 (#493 E5)
// ================================================================
/**
* 부분취소 재계산이 잔여 금액 기준 부가세를 다시 계산하는지 테스트합니다.
*
* 부가세를 재산출하지 않으면 취소 후에도 취소 전 부가세가 남아 세금 표기가 실제 결제
* 금액과 어긋난다.
*/
public function test_partial_cancel_recalculates_vat(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 11000);
[$productB, $optionB] = $this->createProductWithOption(price: 22000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
$cancelTarget = $order->options->firstWhere('product_option_id', $optionB->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $cancelTarget->id, 'cancel_quantity' => 1]])
);
// 잔여 11,000 (10%) → 1,000. 취소 전 전체(33,000)의 3,000 이 남으면 안 된다.
$this->assertSame(1000, (int) $result->orderUpdates['total_vat_amount']);
}
/**
* 부분취소 재계산이 주문 당시 스냅샷 세율을 쓰는지 테스트합니다.
*
* 상품의 현재 세율을 읽으면, 주문 이후 세율이 바뀐 상품에서 과거 주문의 세금 표기가
* 소급해 바뀐다.
*/
public function test_partial_cancel_uses_snapshot_tax_rate_not_current_product_rate(): void
{
[$productA, $optionA] = $this->createProductWithOption(price: 11000);
[$productB, $optionB] = $this->createProductWithOption(price: 22000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]);
$order = $this->createOrderFromCalculation($input);
// 주문 이후 상품 세율 변경 — 과거 주문의 재계산에 영향을 주면 안 된다
$productA->update(['tax_rate' => 25.0]);
$cancelTarget = $order->options->firstWhere('product_option_id', $optionB->id);
$result = $this->adjustmentService->calculate(
$order,
$this->buildCancellation([['order_option_id' => $cancelTarget->id, 'cancel_quantity' => 1]])
);
// 스냅샷 세율 10% 기준 1,000 (현재 세율 25% 였다면 2,200)
$this->assertSame(1000, (int) $result->orderUpdates['total_vat_amount']);
}
}