Files
HeuJung 494e581973 fix(security): KVE-2026 신고 4건 조치 — 관리자 API 권한 게이트 + 금전 처리 경쟁 조건
KISA 신고 4건(KVE-2026-1886/1887/1893/1894)을 검증하고, 미수정 2건과
동종 결함 전수 조사에서 나온 결함을 함께 조치했다.

권한 게이트 (KVE-2026-1893)

- NHN KCP 관리자 주문 연동 7경로가 admin 보유 여부만 보고 세부 권한을 보지 않아,
 업무 권한 없는 관리자가 주문번호·결제정보·수령인 연락처를 조회하고 에스크로
 배송등록까지 할 수 있었다. 조회는 orders.read, 등록은 orders.update, 설정성
 경로는 settings.read 로 게이트해 다른 PG 연동과 강도를 맞췄다.
- 마케팅 채널 저장은 코어 플러그인 설정과 같은 저장소를 덮어쓰는 우회 경로라
 core.plugins.update 를 부착했다.

첨부 해시 노출 (KVE-2026-1894 잔재)

- 비밀글의 썸네일 URL 이 목록·상세 응답에 그대로 실려 첨부 해시가 노출됐다.
 이미지 서빙은 이미 차단돼 있었으나 식별값 자체가 나갔다. 첨부 목록과 같은
 게이트를 써서 값만 가리고 필드는 유지한다.

경쟁 조건 (KVE-2026-1886 + 전수 조사)

- 쿠폰 차감을 조회 후 갱신에서 조건부 UPDATE 로 바꿔 1회 제한 쿠폰의 동시
 사용을 막고, 선점당한 주문은 409 로 되돌린다.
- 라이브 병렬 재현에서 그 롤백이 동작하지 않는 것을 확인했다. Action 훅 기본값이
 큐 래핑 + afterCommit 이라 금전 처리가 호출자 커밋 뒤에 실행되고 있었다.
 쿠폰·적립금 차감/복원 5개 구독에 sync 를 선언했고, 회귀 테스트는 손 등록이
 아니라 실제 등록 경로를 태워 고정했다.
- 동시 부분취소의 취소 누적 컬럼 lost update, 적립 lot 의 read-modify-write,
 주문옵션당 적립 lot 중복 생성을 각각 행 잠금·컬럼 연산·유니크 제약으로 막았다.
 기설치본의 중복 lot 은 인덱스 생성 전에 금액을 합산해 한 줄로 통합한다.

규정·문서

- sync 판정 기준을 hooks.md 에 명문화하고 /AGENTS.md Listener 표와
 coverage manifest 에 반영했다. 같은 증상을 다시 만났을 때의 진단 경로를
 트러블슈팅 사례로 남겼다.
2026-08-22 01:19:36 +09:00

2294 lines
94 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
use Modules\Sirsoft\Ecommerce\Enums\CancelStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\CancelTypeEnum;
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\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderCancel;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\OrderRefund;
use Modules\Sirsoft\Ecommerce\Models\OrderShipping;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\Sequence;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Services\OrderCancellationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 주문 취소 실행/상태변경/DB기록 테스트
*
* OrderCancellationService의 전체취소/부분취소 실행을 검증합니다.
* - 전체취소: cancel 레코드, 주문 상태, refund 레코드, payment 갱신, 쿠폰 복원
* - 부분취소: cancel 레코드, 옵션 분할, 금액 재계산, 주문 합계
* - PG 환불 훅, 에러 처리
* - 환경설정 기반 취소 가능 상태 검증
*/
class OrderCancellationServiceTest extends ModuleTestCase
{
protected OrderCancellationService $cancellationService;
protected OrderCalculationService $calculationService;
protected function setUp(): void
{
parent::setUp();
$this->setupTestCurrencySettings();
$this->setupSequences();
$this->cancellationService = app(OrderCancellationService::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 setupSequences(): void
{
foreach ([SequenceType::CANCEL, SequenceType::REFUND] as $type) {
$config = $type->getDefaultConfig();
Sequence::firstOrCreate(
['type' => $type->value],
[
'algorithm' => $config['algorithm']->value,
'prefix' => $config['prefix'],
'current_value' => 0,
'increment' => 1,
'min_value' => 1,
'max_value' => $config['max_value'],
'cycle' => false,
'pad_length' => $config['pad_length'],
]
);
}
}
protected function tearDown(): void
{
$settingsDir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
foreach (['language_currency.json', 'order_settings.json'] as $file) {
$path = $settingsDir.'/'.$file;
if (file_exists($path)) {
unlink($path);
}
}
parent::tearDown();
}
// ================================================================
// 헬퍼 메서드
// ================================================================
/**
* 상품+옵션을 생성합니다.
*/
protected function createProductWithOption(
int $price = 10000,
int $stock = 100,
?ShippingPolicy $shippingPolicy = null,
): array {
$attrs = [
'selling_price' => $price,
'list_price' => $price,
'tax_status' => 'taxable',
];
if ($shippingPolicy) {
$attrs['shipping_policy_id'] = $shippingPolicy->id;
}
$product = ProductFactory::new()->create($attrs);
$option = ProductOption::factory()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => $stock,
]);
return [$product, $option];
}
/**
* 배송정책을 생성합니다.
*/
protected function createShippingPolicy(
ChargePolicyEnum $chargePolicy = ChargePolicyEnum::FREE,
int $baseFee = 0,
?int $freeThreshold = null,
?array $ranges = null,
): 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' => false,
'extra_fee_settings' => null,
'extra_fee_multiply' => false,
'is_active' => true,
]);
return $policy->load('countrySettings');
}
/**
* OrderCalculationService로 계산 후 주문 레코드를 생성합니다.
*/
protected function createOrderFromCalculation(
CalculationInput $input,
array $orderOverrides = [],
): Order {
$result = $this->calculationService->calculate($input);
$user = User::factory()->create();
$promotionsSnapshot = [
'coupon_issue_ids' => $input->couponIssueIds,
'item_coupons' => $input->itemCoupons,
'discount_code' => $input->discountCode,
];
$shippingPolicySnapshot = [];
if ($input->shippingAddress) {
$shippingPolicySnapshot['address'] = $input->shippingAddress->toArray();
}
// 배송정책 스냅샷 구성 (재계산 시 스냅샷 기반 배송비 계산용)
foreach ($result->items as $item) {
if ($item->appliedShippingPolicy !== null) {
$shippingPolicySnapshot[] = [
'product_option_id' => $item->productOptionId,
'policy' => array_merge(
$item->appliedShippingPolicy->policySnapshot,
['policy_id' => $item->appliedShippingPolicy->policyId, 'policy_name' => $item->appliedShippingPolicy->policyName]
),
];
}
}
$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));
foreach ($result->items as $item) {
$product = Product::find($item->productId);
$productOption = ProductOption::find($item->productOptionId);
OrderOption::factory()->forOrder($order)->create([
'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,
'product_snapshot' => $product?->toSnapshotArray() ?? [],
'option_snapshot' => $productOption?->toSnapshotArray() ?? [],
]);
}
$shippingPolicy = $result->shippings[0] ?? null;
OrderShipping::factory()->forOrder($order)->create([
'shipping_status' => 'pending',
'shipping_policy_id' => $shippingPolicy?->policyId,
'base_shipping_amount' => $result->summary->baseShippingTotal,
'extra_shipping_amount' => $result->summary->extraShippingTotal,
'total_shipping_amount' => $result->summary->totalShipping,
'shipping_discount_amount' => $result->summary->shippingDiscount,
'delivery_policy_snapshot' => $shippingPolicy?->policySnapshot,
]);
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']);
}
/**
* 쿠폰 + 발급 내역을 생성합니다.
*/
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();
return 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),
]);
}
// ================================================================
// B-1. 전체취소 실행 (5건)
// ================================================================
/**
* B-1-1: 전체취소 시 OrderCancel 레코드가 type=full, status=completed로 생성되는지 검증
*/
public function test_full_cancel_creates_cancel_record(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 2)],
);
$order = $this->createOrderFromCalculation($input);
$result = $this->cancellationService->cancelOrder(
order: $order,
reason: 'changed_mind',
cancelledBy: $order->user_id,
cancelPg: false,
);
$cancel = $result->orderCancel;
$this->assertEquals(CancelTypeEnum::FULL, $cancel->cancel_type);
$this->assertEquals(CancelStatusEnum::COMPLETED, $cancel->cancel_status);
$this->assertNotNull($cancel->cancelled_at);
$this->assertNotNull($cancel->cancel_number);
}
/**
* B-1-2: 전체취소 후 주문 상태가 CANCELLED로 변경되는지 검증
*/
public function test_full_cancel_updates_order_status(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 2)],
);
$order = $this->createOrderFromCalculation($input);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order->order_status);
}
/**
* B-1-3: 전체취소 시 OrderRefund 레코드의 refund_amount가 원 결제금액과 일치하는지 검증
*/
public function test_full_cancel_creates_refund_record(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 2)],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
$this->assertNotNull($result->orderRefund);
$this->assertEquals($originalPaid, (float) $result->orderRefund->refund_amount);
$this->assertEquals(RefundStatusEnum::COMPLETED, $result->orderRefund->refund_status);
}
/**
* B-1-4: 전체취소 시 payment.cancelled_amount에 환불금액이 누적되는지 검증
*/
public function test_full_cancel_updates_payment_cancelled_amount(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 2)],
);
$order = $this->createOrderFromCalculation($input);
$originalPaid = (float) $order->total_paid_amount;
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
$payment = $result->order->payment;
$this->assertEquals($originalPaid, (float) $payment->cancelled_amount);
$this->assertEquals(PaymentStatusEnum::CANCELLED, $payment->payment_status);
}
/**
* B-1-5: 전체취소 시 사용된 쿠폰이 복원 대상에 포함되는지 검증
*/
public function test_full_cancel_restores_coupons(): void
{
$this->createShippingPolicy();
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 쿠폰이 USED 상태인지 확인
$this->assertEquals(CouponIssueRecordStatus::USED, $couponIssue->fresh()->status);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
// 쿠폰 복원 훅이 호출되어야 함 (restoredCouponIssueIds 포함)
$this->assertNotEmpty($result->adjustmentResult->restoredCouponIssueIds);
$this->assertContains($couponIssue->id, $result->adjustmentResult->restoredCouponIssueIds);
}
// ================================================================
// B-2. 부분취소 실행 (8건)
// ================================================================
/**
* B-2-1: 부분취소 시 cancel 레코드가 type=partial로 생성되는지 검증
*/
public function test_partial_cancel_creates_cancel_record(): void
{
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$cancel = $result->orderCancel;
$this->assertEquals(CancelTypeEnum::PARTIAL, $cancel->cancel_type);
$this->assertEquals(CancelStatusEnum::COMPLETED, $cancel->cancel_status);
// cancel_options 수 확인
$this->assertEquals(1, $cancel->cancelOptions()->count());
}
/**
* B-2-2: 부분취소 후 주문 상태가 잔여 옵션 기준으로 유지되고, 부분취소 파생 플래그가 켜지는지 검증.
*
* 부분취소는 별도 order_status 가 아니다(partial_cancelled 제거). 잔여 활성 옵션이
* PAYMENT_COMPLETE 이므로 주문 상태도 PAYMENT_COMPLETE 로 유지되고, isPartiallyCancelled()=true.
*/
public function test_partial_cancel_keeps_status_and_sets_partial_flag(): void
{
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order->order_status);
$this->assertTrue($result->order->fresh()->isPartiallyCancelled());
}
/**
* B-2-3: 부분취소 시 옵션 수량 분할이 정상 동작하는지 검증
* (전량 취소 → CANCELLED, 부분 수량 취소 → 분할)
*/
public function test_partial_cancel_splits_option_quantity(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 5)],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// 5개 중 3개 취소
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 3]],
cancelPg: false,
);
// 원본 옵션 + 분할 옵션으로 2개 존재
$options = $result->order->options;
$cancelledOptions = $options->where('option_status', OrderStatusEnum::CANCELLED);
$activeOptions = $options->where('option_status', '!=', OrderStatusEnum::CANCELLED);
// 취소된 옵션(3개) + 잔여 옵션(2개) 존재 확인
$this->assertEquals(3, $cancelledOptions->sum('quantity'));
$this->assertEquals(2, $activeOptions->sum('quantity'));
}
/**
* B-2-4: 부분취소 후 잔여 옵션의 subtotal_paid_amount가 재계산되는지 검증
*/
public function test_partial_cancel_updates_option_amounts(): void
{
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
// 잔여 옵션 B의 paid amount = 10,000
$remainingOption = $result->order->options
->where('product_option_id', $oB->id)
->where('option_status', '!=', OrderStatusEnum::CANCELLED)
->first();
$this->assertEquals(10000, (int) $remainingOption->subtotal_paid_amount);
}
/**
* B-2-5: 부분취소로 배송비 변동 시 shipping 레코드가 갱신되는지 검증
*/
public function test_partial_cancel_updates_shipping_amounts(): void
{
$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),
],
);
$order = $this->createOrderFromCalculation($input);
// 60K → 무료배송
$this->assertEquals(0, (int) $order->total_shipping_amount);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
// A(20K) 취소 → 잔여 40K < 50K → 배송비 3000 발생
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$shipping = $result->order->shippings->first();
$this->assertEquals(3000, (int) $shipping->total_shipping_amount);
}
/**
* B-2-6: 부분취소 후 주문 합계 컬럼이 모두 재계산되는지 검증
*/
public function test_partial_cancel_updates_order_totals(): void
{
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$updatedOrder = $result->order;
// 잔여 B(10K)만 남음
$this->assertEquals(10000, (int) $updatedOrder->total_paid_amount);
$this->assertGreaterThan(0, (float) $updatedOrder->total_cancelled_amount);
}
/**
* B-2-7: 부분취소 시 cancellation_count가 1 증가하는지 검증
*/
public function test_partial_cancel_increments_cancellation_count(): void
{
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$this->assertEquals(1, $result->order->cancellation_count);
}
/**
* B-2-8: 모든 옵션을 전량 지정하면 전체취소(full)로 전환되는지 검증
*/
public function test_partial_cancel_converts_to_full_when_all_cancelled(): void
{
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$optionB = $order->options->where('product_option_id', $oB->id)->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
['order_option_id' => $optionB->id, 'cancel_quantity' => 1],
],
cancelPg: false,
);
// 전체취소로 전환됨
$this->assertEquals(CancelTypeEnum::FULL, $result->orderCancel->cancel_type);
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order->order_status);
}
// ================================================================
// B-3. PG 환불 훅 + 에러 처리 (4건)
// ================================================================
/**
* B-3-1: 취소 시 PG 환불 훅이 발행되는지 검증
*/
public function test_cancel_fires_payment_refund_hook(): void
{
$hookCalled = false;
HookManager::addFilter('sirsoft-ecommerce.payment.refund', function ($default) use (&$hookCalled) {
$hookCalled = true;
return [
'success' => true,
'transaction_id' => 'TXN_'.uniqid(),
'error_code' => null,
'error_message' => null,
];
});
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: true,
);
$this->assertTrue($hookCalled, 'PG 환불 훅이 호출되어야 합니다');
$this->assertEquals(RefundStatusEnum::COMPLETED, $result->orderRefund->refund_status);
}
/**
* B-3-1-a: PG 환불 훅이 OrderRefund 레코드를 6번째 인자로 전달하는지 검증
*
* PG 리스너는 이 레코드의 id 로 멱등키(Idempotency-Key)를 조립한다. 네트워크 재시도로
* 같은 취소가 두 번 도달해도 PG 가 중복 청구를 차단하려면 환불 시도마다 고유한 값이 필요하다.
*
* @effects refund_hook_passes_order_refund_record
*/
public function test_payment_refund_hook_passes_order_refund_record(): void
{
$received = null;
HookManager::addFilter(
'sirsoft-ecommerce.payment.refund',
function ($default, $order, $payment, $refundAmount, $reason, $refund = null) use (&$received) {
$received = $refund;
return ['success' => true, 'transaction_id' => 'TXN', 'error_code' => null, 'error_message' => null];
}
);
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input);
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertInstanceOf(OrderRefund::class, $received, 'PG 환불 훅은 OrderRefund 레코드를 전달해야 합니다');
$this->assertSame($result->orderRefund->id, $received->id, '전달된 환불 레코드는 이번 환불 시도의 것이어야 합니다');
}
/**
* B-3-1-b: 인자를 5개만 선언한 기존(레거시) 리스너가 계속 동작하는지 검증
*
* OrderRefund 인자 추가는 하위호환이어야 한다 — 초과 인자는 무시되고 예외가 나지 않는다.
*
* @effects refund_hook_backward_compatible_with_five_arg_listener
*/
public function test_payment_refund_hook_is_backward_compatible_with_five_arg_listener(): void
{
$hookCalled = false;
HookManager::addFilter(
'sirsoft-ecommerce.payment.refund',
function ($default, $order, $payment, $refundAmount, $reason) use (&$hookCalled) {
$hookCalled = true;
return ['success' => true, 'transaction_id' => 'TXN', 'error_code' => null, 'error_message' => null];
}
);
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input);
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertTrue($hookCalled, '5-파라미터 리스너도 호출되어야 합니다');
$this->assertEquals(RefundStatusEnum::COMPLETED, $result->orderRefund->refund_status);
}
/**
* B-3-2: PG 환불 실패 시 전체 트랜잭션이 롤백되는지 검증
*/
public function test_cancel_pg_failure_rolls_back(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', function ($default) {
return [
'success' => false,
'error_code' => 'PG_ERROR',
'error_message' => 'PG 결제 취소 실패',
'transaction_id' => null,
];
});
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input);
$originalStatus = $order->order_status;
$this->expectException(\Exception::class);
$this->cancellationService->cancelOrder(
order: $order,
cancelPg: true,
);
// 트랜잭션 롤백 확인: 주문 상태 변경 없음
$this->assertEquals($originalStatus, $order->fresh()->order_status);
// 취소 레코드 미생성
$this->assertEquals(0, OrderCancel::where('order_id', $order->id)->count());
}
/**
* B-3-3: cancelPg=false 시 PG 환불 훅이 미발행되는지 검증
*/
public function test_cancel_without_pg_skips_refund_hook(): void
{
$hookCalled = false;
HookManager::addFilter('sirsoft-ecommerce.payment.refund', function ($default) use (&$hookCalled) {
$hookCalled = true;
return ['success' => true, 'transaction_id' => 'TXN'];
});
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
$this->assertFalse($hookCalled, 'cancelPg=false 시 PG 훅이 호출되면 안됩니다');
}
/**
* B-3-4: 미결제 주문 취소 시 refund 레코드가 미생성되는지 검증
*/
public function test_unpaid_order_cancel_skips_refund(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
// 미결제(PENDING_PAYMENT) 주문 생성
$order = $this->createOrderFromCalculation($input, [
'order_status' => OrderStatusEnum::PENDING_PAYMENT,
]);
// payment를 미결제 상태로 변경
$order->payment->update(['payment_status' => PaymentStatusEnum::READY]);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
// cancel은 생성, refund는 미생성
$this->assertNotNull($result->orderCancel);
$this->assertNull($result->orderRefund);
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order->order_status);
}
// ================================================================
// B-4. 환경설정 + 검증 (3건)
// ================================================================
/**
* B-4-1: cancellable_statuses에 없는 상태에서 취소 시 예외 발생
*/
public function test_cancel_rejected_when_status_not_cancellable(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input, [
'order_status' => OrderStatusEnum::SHIPPING,
]);
$this->expectException(\Exception::class);
$this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
}
/**
* B-4-2: 설정된 상태에서 취소가 허용되는지 검증
*/
public function test_cancel_allowed_for_configured_status(): void
{
// 기본 cancellable_statuses에 payment_complete이 포함됨
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input);
// PAYMENT_COMPLETE 상태는 취소 가능
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
$this->assertEquals(CancelStatusEnum::COMPLETED, $result->orderCancel->cancel_status);
}
/**
* B-4-3: previewRefund 호출 후 DB가 변경되지 않는지 검증
*/
public function test_preview_refund_does_not_modify_db(): void
{
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$cancelCountBefore = OrderCancel::count();
$refundCountBefore = OrderRefund::count();
$orderStatusBefore = $order->order_status;
$paidAmountBefore = (float) $order->total_paid_amount;
// 미리보기 실행
$result = $this->cancellationService->previewRefund($order, [
['order_option_id' => $optionA->id, 'cancel_quantity' => 1],
]);
// DB 변경 없음 확인
$this->assertEquals($cancelCountBefore, OrderCancel::count());
$this->assertEquals($refundCountBefore, OrderRefund::count());
$this->assertEquals($orderStatusBefore, $order->fresh()->order_status);
$this->assertEquals($paidAmountBefore, (float) $order->fresh()->total_paid_amount);
// 결과는 존재
$this->assertGreaterThan(0, $result->refundAmount);
}
// ================================================================
// C-1. 쿠폰 복원 실행 검증 (5건)
// ================================================================
/**
* C-1-1: 전체취소 시 모든 쿠폰이 복원 대상으로 포함되고, 훅을 통한 상태 복원이 실행되는지 검증
*/
public function test_full_cancel_restores_all_coupons_status_and_used_at(): void
{
$this->createShippingPolicy();
// 주문쿠폰 (FIXED 5000)
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 쿠폰이 USED 상태인지 확인
$this->assertEquals(CouponIssueRecordStatus::USED, $couponIssue->fresh()->status);
$this->assertNotNull($couponIssue->fresh()->used_at);
// 훅 리스너 등록: 쿠폰 복원 시 실제 DB 업데이트 수행
HookManager::addAction('sirsoft-ecommerce.coupon.restore', function ($order, $couponIssueIds) {
CouponIssue::whereIn('id', $couponIssueIds)->update([
'status' => CouponIssueRecordStatus::AVAILABLE,
'used_at' => null,
'order_id' => null,
]);
});
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
// 복원 대상에 포함
$this->assertContains($couponIssue->id, $result->adjustmentResult->restoredCouponIssueIds);
// 실제 DB에서 쿠폰이 복원됨
$restored = $couponIssue->fresh();
$this->assertEquals(CouponIssueRecordStatus::AVAILABLE, $restored->status);
$this->assertNull($restored->used_at);
$this->assertNull($restored->order_id);
}
/**
* C-1-2: 부분취소 시 최소주문금액 미달로 쿠폰이 복원 대상에 포함되는지 검증
*/
public function test_partial_cancel_restores_coupon_when_min_amount_not_met(): void
{
$this->createShippingPolicy();
// 주문쿠폰: FIXED 5000, 최소주문금액 60000
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 60000,
);
[$pA, $oA] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 2)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 100,000원 → 쿠폰 적용 가능 (60,000 이상)
$optionA = $order->options->first();
// 1개 취소 → 잔여 50,000 < 최소주문금액 60,000 → 쿠폰 복원
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$this->assertContains($couponIssue->id, $result->adjustmentResult->restoredCouponIssueIds);
}
/**
* C-1-3: 부분취소 후 최소주문금액을 여전히 충족하면 쿠폰이 복원되지 않는지 검증
*/
public function test_partial_cancel_does_not_restore_coupon_when_still_met(): void
{
$this->createShippingPolicy();
// 주문쿠폰: FIXED 5000, 최소주문금액 30000
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 30000,
);
[$pA, $oA] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 2)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 100,000원 → 쿠폰 적용
$optionA = $order->options->first();
// 1개 취소 → 잔여 50,000 >= 30,000 → 쿠폰 유지
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
// 쿠폰이 복원 대상에 포함되지 않아야 함
$this->assertNotContains($couponIssue->id, $result->adjustmentResult->restoredCouponIssueIds);
// DB에서도 여전히 USED 상태
$this->assertEquals(CouponIssueRecordStatus::USED, $couponIssue->fresh()->status);
}
/**
* C-1-4: 다른 주문의 쿠폰이 현재 주문 취소에 영향받지 않는지 검증
*/
public function test_cancel_does_not_affect_other_order_coupons(): void
{
$this->createShippingPolicy();
// 주문1용 쿠폰
$couponIssue1 = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
// 주문2용 쿠폰
$couponIssue2 = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 3000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000);
[$pB, $oB] = $this->createProductWithOption(price: 20000);
// 주문 1
$input1 = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
couponIssueIds: [$couponIssue1->id],
);
$order1 = $this->createOrderFromCalculation($input1);
// 주문 2
$input2 = new CalculationInput(
items: [new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1)],
couponIssueIds: [$couponIssue2->id],
);
$order2 = $this->createOrderFromCalculation($input2);
// 주문1 전체취소
$this->cancellationService->cancelOrder(
order: $order1,
cancelPg: false,
);
// 주문2의 쿠폰은 여전히 USED 상태
$this->assertEquals(CouponIssueRecordStatus::USED, $couponIssue2->fresh()->status);
$this->assertNotNull($couponIssue2->fresh()->used_at);
}
/**
* C-1-5: previewRefund 호출 시 쿠폰/포인트 DB가 변경되지 않는지 검증
*/
public function test_cancel_preview_does_not_modify_coupon_or_points(): void
{
$this->createShippingPolicy();
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
[$pA, $oA] = $this->createProductWithOption(price: 30000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
$couponStatusBefore = $couponIssue->fresh()->status;
$couponUsedAtBefore = $couponIssue->fresh()->used_at;
// 미리보기 실행
$result = $this->cancellationService->previewRefund($order, [
['order_option_id' => $order->options->first()->id, 'cancel_quantity' => 1],
]);
// 쿠폰 상태 변경 없음
$this->assertEquals($couponStatusBefore, $couponIssue->fresh()->status);
$this->assertEquals($couponUsedAtBefore?->toDateTimeString(), $couponIssue->fresh()->used_at?->toDateTimeString());
// OrderCancel/OrderRefund 미생성
$this->assertEquals(0, OrderCancel::where('order_id', $order->id)->count());
$this->assertEquals(0, OrderRefund::where('order_id', $order->id)->count());
}
/**
* C-1-6: 부분취소 미리보기가 쿠폰 발급 행을 일절 변이하지 않는지 검증 (#108)
*
* C-1-5 는 단일 옵션 전량 취소라 buildFullCancelResult 조기 반환 경로를 타서
* 재계산 자체가 일어나지 않는다. 잔여 옵션이 남는 부분취소여야 재계산 경로에
* 진입하므로, 여기서는 옵션 2개 중 1개만 취소한다.
*
* 전후 status/used_at 비교만으로는 "변경했다 되돌리는" 왕복을 잡지 못하므로
* (1) 실행 중 발생한 UPDATE 쿼리 자체를 세고 (2) updated_at 불변을 함께 단언한다.
*
* @effects preview_refund_issues_zero_write_queries_on_coupon_issues, preview_refund_keeps_coupon_issue_updated_at_unchanged, preview_refund_keeps_coupon_status_used_at_order_id_unchanged, partial_cancel_preview_reaches_recalculation_path_not_full_cancel_shortcut, recalculation_never_reads_coupon_usage_state_in_snapshot_mode
*/
public function test_partial_cancel_preview_issues_no_write_query_on_coupon_issues(): void
{
$this->createShippingPolicy();
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
[$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);
$couponTable = (new CouponIssue)->getTable();
$before = CouponIssue::find($couponIssue->id);
// 미리보기 실행 중 쿠폰 발급 테이블에 발생한 쓰기 쿼리를 수집
$writeQueries = [];
DB::listen(function ($query) use ($couponTable, &$writeQueries) {
$sql = strtolower($query->sql);
if (str_contains($sql, $couponTable) && ! str_starts_with(ltrim($sql), 'select')) {
$writeQueries[] = $query->sql;
}
});
// 옵션 2개 중 1개만 취소 → 잔여 옵션이 있으므로 재계산 경로 진입
$cancelOption = $order->options->firstWhere('product_option_id', $oA->id);
$this->cancellationService->previewRefund($order, [
['order_option_id' => $cancelOption->id, 'cancel_quantity' => 1],
]);
$this->assertSame(
[],
$writeQueries,
'환불 예상 미리보기는 조회만 수행해야 하는데 쿠폰 발급 테이블에 쓰기 쿼리가 발생했습니다: '
.implode(' | ', $writeQueries)
);
// 왕복 UPDATE 는 값을 되돌려도 updated_at 을 오염시킨다
$after = CouponIssue::find($couponIssue->id);
$this->assertSame(
$before->updated_at?->toDateTimeString(),
$after->updated_at?->toDateTimeString(),
'미리보기 후 쿠폰 발급 행의 updated_at 이 변경되었습니다.'
);
$this->assertSame($before->status, $after->status);
$this->assertSame($before->used_at?->toDateTimeString(), $after->used_at?->toDateTimeString());
$this->assertSame($before->order_id, $after->order_id);
}
// ================================================================
// C-2. 환불 처리 검증 (5건)
// ================================================================
/**
* C-2-1: PG 우선 환불 시 refunded_amount가 정확히 갱신되는지 검증
*/
public function test_cancel_with_pg_first_updates_refunded_amount_correctly(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
// total_paid_amount = 40000 (PG), points_used = 10000
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
refundPriority: RefundPriorityEnum::PG_FIRST,
);
// PG 환불금액 = 40000 (결제금 전액)
$this->assertEquals(40000, (float) $result->orderRefund->refund_amount);
// 마일리지 환불액 = 10000
$this->assertEquals(10000, (float) $result->orderRefund->refund_points_amount);
// 취소 레코드 생성 확인
$this->assertNotNull($result->orderCancel);
$this->assertNotNull($result->orderRefund);
}
/**
* C-2-2: 포인트 우선 환불 시 환불 레코드에 적절한 분배가 기록되는지 검증
*/
public function test_cancel_with_points_first_updates_refunded_points_correctly(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
refundPriority: RefundPriorityEnum::POINTS_FIRST,
);
// POINTS_FIRST: 마일리지 환불이 포인트 사용분부터 차감
$this->assertNotNull($result->orderRefund);
$this->assertGreaterThan(0, (float) $result->orderRefund->refund_points_amount);
// AdjustmentResult에도 올바른 우선순위 저장
$this->assertEquals(RefundPriorityEnum::POINTS_FIRST, $result->adjustmentResult->refundPriority);
}
/**
* C-2-3: 순차 부분취소 시 total_cancelled_amount가 누적되는지 검증
*/
public function test_sequential_cancel_accumulates_refunded_amounts(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3)],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->first();
// 1차 취소: 1개
$result1 = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$cancelledAfterFirst = (float) $result1->order->total_cancelled_amount;
$this->assertGreaterThan(0, $cancelledAfterFirst);
// 2차 취소: 잔여 옵션에서 1개 추가 취소
$updatedOrder = $result1->order->fresh(['options', 'payment', 'shippings']);
// 부분취소 후 주문 상태가 변경되므로 취소 가능 상태로 복원
$updatedOrder->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
$updatedOrder->refresh();
$remainingOption = $updatedOrder->options
->where('option_status', '!=', OrderStatusEnum::CANCELLED)
->first();
$result2 = $this->cancellationService->cancelOrderOptions(
order: $updatedOrder,
cancelItems: [['order_option_id' => $remainingOption->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
// 2차 취소 후 누적 금액이 1차보다 큰지 확인
$cancelledAfterSecond = (float) $result2->order->fresh()->total_cancelled_amount;
$this->assertGreaterThan($cancelledAfterFirst, $cancelledAfterSecond);
}
/**
* 낡은 주문 스냅샷으로 두 번째 취소를 실행해도 취소 누산이 유실되지 않아야 합니다.
*
* 취소 총액·취소 횟수·결제 취소액은 현재 값을 읽어 더하는 read-modify-write 다.
* 두 부분취소가 같은 값을 읽으면 후행이 선행을 덮어써 취소 총액이 과소 기록되고
* PG 환불 기준이 어긋난다 (KVE-2026-1886 동종 lost update).
*
* @return void
*/
public function test_concurrent_partial_cancel_does_not_lose_accumulated_totals(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3)],
);
$order = $this->createOrderFromCalculation($input);
$optionA = $order->options->first();
// 두 번째 취소 요청이 손에 쥔, 1차 취소 이전에 읽은 주문 스냅샷
$staleOrder = Order::with(['options', 'payment', 'shippings'])->find($order->id);
$this->assertEquals(0.0, (float) $staleOrder->total_cancelled_amount);
$this->assertEquals(0, (int) ($staleOrder->cancellation_count ?? 0));
// 1차 취소 (선행 요청)
$result1 = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$cancelledAfterFirst = (float) $result1->order->fresh()->total_cancelled_amount;
$paymentCancelledAfterFirst = (float) $result1->order->fresh()->payment->cancelled_amount;
$this->assertGreaterThan(0, $cancelledAfterFirst);
// 2차 취소 (낡은 스냅샷을 든 후행 요청) — 취소 가능 상태만 되돌려 실행 조건을 맞춘다
Order::query()->where('id', $order->id)
->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
$remainingOption = $staleOrder->options
->where('id', '!=', $optionA->id)
->first() ?? OrderOption::query()
->where('order_id', $order->id)
->where('option_status', '!=', OrderStatusEnum::CANCELLED)
->first();
$result2 = $this->cancellationService->cancelOrderOptions(
order: $staleOrder,
cancelItems: [['order_option_id' => $remainingOption->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$finalOrder = $result2->order->fresh(['payment']);
$this->assertGreaterThan(
$cancelledAfterFirst,
(float) $finalOrder->total_cancelled_amount,
'후행 취소가 선행 취소의 누적 취소 총액을 덮어써서는 안 됩니다.'
);
$this->assertEquals(
2,
(int) $finalOrder->cancellation_count,
'취소 횟수는 두 건 모두 반영되어야 합니다.'
);
$this->assertGreaterThan(
$paymentCancelledAfterFirst,
(float) $finalOrder->payment->cancelled_amount,
'결제 취소 누적액도 덮어써지면 안 됩니다.'
);
$this->assertCount(
2,
$finalOrder->payment->cancel_history ?? [],
'취소 이력은 두 건 모두 남아야 합니다.'
);
}
/**
* C-2-4: 취소 시 AdjustmentResult에 환불 우선순위가 저장되는지 검증
*/
public function test_cancel_stores_refund_priority_in_adjustment_result(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
refundPriority: RefundPriorityEnum::PG_FIRST,
);
$this->assertEquals(RefundPriorityEnum::PG_FIRST, $result->adjustmentResult->refundPriority);
}
/**
* C-2-5: previewRefund 호출 시 OrderRefund 레코드가 생성되지 않는지 검증
*/
public function test_cancel_preview_does_not_create_refund_record(): void
{
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input);
$refundCountBefore = OrderRefund::count();
// 미리보기 실행
$this->cancellationService->previewRefund($order, [
['order_option_id' => $order->options->first()->id, 'cancel_quantity' => 1],
]);
$this->assertEquals($refundCountBefore, OrderRefund::count());
}
// ================================================================
// C-3. mc_* 다중통화 환불 레코드 검증 (4건)
// ================================================================
/**
* C-3-1: 다중통화 스냅샷이 있는 주문 취소 시 OrderRefund에 mc_refund_amount가 저장되는지 검증
*/
public function test_refund_record_includes_mc_refund_amount(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 30000);
$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'],
],
];
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
// OrderRefund 레코드에 mc_refund_amount 저장 확인
$refund = $result->orderRefund;
$this->assertNotNull($refund->mc_refund_amount);
$this->assertArrayHasKey('KRW', $refund->mc_refund_amount);
$this->assertArrayHasKey('USD', $refund->mc_refund_amount);
$this->assertGreaterThan(0, $refund->mc_refund_amount['USD']);
}
/**
* C-3-2: 포인트 사용 + 다중통화 주문 취소 시 mc_refund_points_amount가 저장되는지 검증
*/
public function test_refund_record_includes_mc_refund_points_amount(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 30000);
$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'],
],
];
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
usePoints: 10000,
);
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
refundPriority: RefundPriorityEnum::POINTS_FIRST,
);
$refund = $result->orderRefund;
// 포인트 환불이 발생하면 mc_refund_points_amount 존재
if ((float) $refund->refund_points_amount > 0) {
$this->assertNotNull($refund->mc_refund_points_amount);
$this->assertArrayHasKey('KRW', $refund->mc_refund_points_amount);
}
}
/**
* C-3-3: 배송비 + 다중통화 주문 취소 시 mc_refund_shipping_amount가 저장되는지 검증
*/
public function test_refund_record_includes_mc_refund_shipping_amount(): void
{
$sp = $this->createShippingPolicy(
chargePolicy: ChargePolicyEnum::FIXED,
baseFee: 3000,
);
[$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp);
$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'],
],
];
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
$order = $this->createOrderFromCalculation($input, [
'currency_snapshot' => $currencySnapshot,
]);
// 배송비 3000원 존재 확인
$this->assertEquals(3000, (int) $order->total_shipping_amount);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
$refund = $result->orderRefund;
// 전체취소 시 배송비 환불 → mc_refund_shipping_amount 존재
if ((float) $refund->refund_shipping_amount > 0) {
$this->assertNotNull($refund->mc_refund_shipping_amount);
$this->assertArrayHasKey('USD', $refund->mc_refund_shipping_amount);
$this->assertGreaterThan(0, $refund->mc_refund_shipping_amount['USD']);
}
}
/**
* C-3-4: 다중통화 스냅샷이 없는 주문 취소 시 AdjustmentResult의 mc_* 필드가 null인지 검증
*/
public function test_refund_record_mc_null_when_no_currency_snapshot(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
);
// currency_snapshot 미설정 — 팩토리 기본 스냅샷을 명시적으로 null 로 덮어야 "스냅샷 없음" 경로를 검증한다.
// (Order::factory 가 기본 currency_snapshot 을 제공하므로 override 없이는 mc_* 가 채워진다.)
$order = $this->createOrderFromCalculation($input, ['currency_snapshot' => null]);
$result = $this->cancellationService->cancelOrder(
order: $order,
cancelPg: false,
);
// AdjustmentResult의 mc_* 필드가 모두 null
$this->assertNull($result->adjustmentResult->mcRefundAmount);
$this->assertNull($result->adjustmentResult->mcRefundPointsAmount);
$this->assertNull($result->adjustmentResult->mcRefundShippingAmount);
// OrderRefund의 mc_* 필드도 null
$this->assertNull($result->orderRefund->mc_refund_amount);
$this->assertNull($result->orderRefund->mc_refund_points_amount);
$this->assertNull($result->orderRefund->mc_refund_shipping_amount);
}
// ================================================================
// C-4. 스냅샷 업데이트 검증 (3건)
// ================================================================
/**
* C-4-1: 부분취소 후 잔여 옵션의 promotions_applied_snapshot이 갱신되는지 검증
*/
public function test_partial_cancel_updates_remaining_option_promotion_snapshot(): void
{
$this->createShippingPolicy();
// 상품쿠폰
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
[$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],
itemCoupons: [['product_id' => $pA->id, 'coupon_issue_id' => $couponIssue->id]],
);
$order = $this->createOrderFromCalculation($input);
$optionB = $order->options->where('product_option_id', $oB->id)->first();
$snapshotBefore = $optionB->promotions_applied_snapshot;
// A 옵션 취소
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
// 잔여 옵션 B의 스냅샷이 업데이트되었는지 확인 (재계산 반영)
$remainingB = $result->order->options
->where('product_option_id', $oB->id)
->where('option_status', '!=', OrderStatusEnum::CANCELLED)
->first();
// 스냅샷이 존재하면 재계산 결과가 반영되어 있어야 함
// (부분취소 후 재계산이 실행되므로 스냅샷이 갱신됨)
$this->assertNotNull($remainingB);
}
/**
* C-4-2: 부분취소 후 주문의 promotions_applied_snapshot이 갱신되는지 검증
*/
public function test_partial_cancel_updates_order_promotion_snapshot(): void
{
$this->createShippingPolicy();
// 주문쿠폰
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
minOrderAmount: 60000,
);
[$pA, $oA] = $this->createProductWithOption(price: 50000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 2)],
couponIssueIds: [$couponIssue->id],
);
$order = $this->createOrderFromCalculation($input);
// 100,000원 → 쿠폰 적용
$snapshotBefore = $order->promotions_applied_snapshot;
$optionA = $order->options->first();
// 1개 취소 → 잔여 50,000 < 60,000 → 쿠폰 복원
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$updatedSnapshot = $result->order->promotions_applied_snapshot;
// 쿠폰이 복원되었으므로 스냅샷에서 쿠폰 정보가 제거/갱신되어야 함
// 또는 스냅샷 자체가 갱신되었는지 확인
$this->assertNotEquals($snapshotBefore, $updatedSnapshot);
}
/**
* C-4-3: 순차 부분취소 시 2차 취소가 1차 갱신된 스냅샷 기반으로 계산되는지 검증
*/
public function test_sequential_cancel_uses_updated_snapshot_for_second_cancel(): void
{
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3)],
);
$order = $this->createOrderFromCalculation($input);
// 60,000원 (20,000 x 3)
$optionA = $order->options->first();
// 1차 취소: 1개 (20,000원 환불)
$result1 = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$refund1Amount = $result1->adjustmentResult->refundAmount;
$this->assertGreaterThan(0, $refund1Amount);
// 2차 취소를 위해 갱신된 주문 로드
$updatedOrder = $result1->order->fresh(['options', 'payment', 'shippings']);
// 부분취소 후 주문 상태가 변경되므로 취소 가능 상태로 복원
$updatedOrder->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
$updatedOrder->refresh();
$remainingOption = $updatedOrder->options
->where('option_status', '!=', OrderStatusEnum::CANCELLED)
->first();
// 2차 취소: 1개 추가
$result2 = $this->cancellationService->cancelOrderOptions(
order: $updatedOrder,
cancelItems: [['order_option_id' => $remainingOption->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$refund2Amount = $result2->adjustmentResult->refundAmount;
$this->assertGreaterThan(0, $refund2Amount);
// 각 취소의 환불금액이 단위가격(20,000)과 일치하는지 검증
// (스냅샷이 올바르게 갱신되었다면 2차 취소도 정확한 금액)
$this->assertEquals(20000, (int) $refund1Amount);
$this->assertEquals(20000, (int) $refund2Amount);
// 최종 주문 잔여 금액: 20,000 (1개 남음)
$finalOrder = $result2->order;
$this->assertEquals(20000, (int) $finalOrder->total_paid_amount);
}
// ================================================================
// D. 재고 복원 (A4/P0-C — 이중복구 제거 / ON·OFF / 플래그 정리)
// ================================================================
/**
* order_settings.stock_restore_on_cancel 설정 값을 테스트 경로에 기록합니다.
*/
protected function setStockRestoreSetting(bool $enabled): void
{
$settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($settingsPath)) {
mkdir($settingsPath, 0755, true);
}
file_put_contents(
$settingsPath.'/order_settings.json',
json_encode(['stock_restore_on_cancel' => $enabled], JSON_PRETTY_PRINT)
);
}
/**
* 주문 옵션들을 재고 차감 상태(is_stock_deducted=true)로 표시합니다.
*/
protected function markOptionsDeducted(Order $order): void
{
$order->options()->update(['is_stock_deducted' => true]);
$order->load('options');
}
/**
* D-1: 전체취소 + 복원 ON → 재고가 정확히 +quantity 1회만 복원 (이중복구 회귀 가드)
*/
public function test_full_cancel_with_restore_on_restores_quantity_once(): void
{
$this->setStockRestoreSetting(true);
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000, stock: 10);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3)],
);
$order = $this->createOrderFromCalculation($input);
$this->markOptionsDeducted($order);
$stockBefore = ProductOption::find($oA->id)->stock_quantity;
$this->cancellationService->cancelOrder(order: $order, cancelPg: false);
// 정확히 +3 (이중복구 시 +6 이 되어 실패)
$this->assertEquals($stockBefore + 3, ProductOption::find($oA->id)->stock_quantity);
}
/**
* D-2: 전체취소 + 복원 OFF → 재고 불변 (설정 무시 경로 회귀 가드)
*/
public function test_full_cancel_with_restore_off_keeps_stock(): void
{
$this->setStockRestoreSetting(false);
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000, stock: 10);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 3)],
);
$order = $this->createOrderFromCalculation($input);
$this->markOptionsDeducted($order);
$stockBefore = ProductOption::find($oA->id)->stock_quantity;
$this->cancellationService->cancelOrder(order: $order, cancelPg: false);
// 복원 0건 (리스너 경로 제거로 OFF 가 정상 적용)
$this->assertEquals($stockBefore, ProductOption::find($oA->id)->stock_quantity);
}
/**
* D-3: 부분취소 + 복원 ON → +cancel_quantity 만 복원, CANCELLED 옵션 플래그만 false
*/
public function test_partial_cancel_restores_cancel_quantity_and_resets_cancelled_flag(): void
{
$this->setStockRestoreSetting(true);
$this->createShippingPolicy();
[$pA, $oA] = $this->createProductWithOption(price: 20000, stock: 10);
$input = new CalculationInput(
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 5)],
);
$order = $this->createOrderFromCalculation($input);
$this->markOptionsDeducted($order);
$optionA = $order->options->where('product_option_id', $oA->id)->first();
$stockBefore = ProductOption::find($oA->id)->stock_quantity;
// 5개 중 3개 취소
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 3]],
cancelPg: false,
);
// +3 만 복원
$this->assertEquals($stockBefore + 3, ProductOption::find($oA->id)->stock_quantity);
// CANCELLED 행은 플래그 false, 잔여 active 행은 true 유지
$options = $result->order->options()->where('product_option_id', $oA->id)->get();
$cancelled = $options->where('option_status', OrderStatusEnum::CANCELLED);
$active = $options->where('option_status', '!=', OrderStatusEnum::CANCELLED);
$this->assertEquals(3, $cancelled->sum('quantity'));
$this->assertEquals(2, $active->sum('quantity'));
$this->assertTrue($cancelled->every(fn ($o) => $o->is_stock_deducted === false));
$this->assertTrue($active->every(fn ($o) => $o->is_stock_deducted === true));
}
// ================================================================
// MP02-U1: 무통장(dbank) 취소 PG 환불 스킵 + 환불상태 정상화
// ================================================================
/**
* 두 옵션짜리 카드 결제완료 주문을 생성하고, payment 를 지정 결제수단으로 보정합니다.
*
* @param string $paymentMethodValue PaymentMethodEnum 값(dbank/card/vbank/point)
* @param PaymentStatusEnum $paymentStatus 결제 상태
* @param array $orderOverrides 주문 오버라이드
* @return Order 보정된 주문
*/
private function createOrderWithPaymentMethod(
string $paymentMethodValue,
PaymentStatusEnum $paymentStatus = PaymentStatusEnum::PAID,
array $orderOverrides = [],
): Order {
$this->createShippingPolicy();
[$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),
],
);
$order = $this->createOrderFromCalculation($input, $orderOverrides);
$order->payment->update([
'payment_method' => $paymentMethodValue,
'payment_status' => $paymentStatus,
'pg_provider' => in_array($paymentMethodValue, ['dbank', 'point'], true) ? '' : 'tosspayments',
]);
$order->load('payment');
return $order;
}
/**
* 무통장(dbank) paid 주문 전체취소 시 PG 환불을 건너뛰고 성공한다 (RED→GREEN).
*/
public function test_dbank_paid_order_cancel_skips_pg_and_succeeds(): void
{
// PG 훅은 기본 success:false — 무통장이 PG 분기를 타면 pg_refund_failed 로 롤백된다.
$hookCalled = false;
HookManager::addFilter('sirsoft-ecommerce.payment.refund', function ($default) use (&$hookCalled) {
$hookCalled = true;
return ['success' => false, 'error_code' => 'PG_ERROR', 'error_message' => 'PG 미지원', 'transaction_id' => null];
});
$order = $this->createOrderWithPaymentMethod('dbank');
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertFalse($hookCalled, '무통장 취소는 PG 환불 훅을 발화하면 안 됩니다');
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order->order_status);
$this->assertNotNull($result->orderRefund);
}
/**
* 무통장 취소 환불 수단은 BANK 다.
*/
public function test_dbank_cancel_refund_method_is_bank(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => false]);
$order = $this->createOrderWithPaymentMethod('dbank');
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertEquals(RefundMethodEnum::BANK, $result->orderRefund->refund_method);
}
/**
* 무통장 취소 환불 상태는 APPROVED(수동 송금 대기)이고 refunded_at 은 null 이다.
*/
public function test_dbank_cancel_refund_status_is_approved(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => false]);
$order = $this->createOrderWithPaymentMethod('dbank');
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertEquals(RefundStatusEnum::APPROVED, $result->orderRefund->refund_status);
$this->assertNull($result->orderRefund->refunded_at, '수동 송금 전이므로 refunded_at 은 기록되면 안 됩니다');
}
/**
* 무통장 취소 시 주문/결제 상태가 갱신된다.
*/
public function test_dbank_cancel_updates_order_and_payment(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => false]);
$order = $this->createOrderWithPaymentMethod('dbank');
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order->order_status);
$this->assertEquals(PaymentStatusEnum::CANCELLED, $result->order->payment->payment_status);
$this->assertGreaterThan(0, (float) $result->order->payment->cancelled_amount);
}
/**
* 카드(PG 대상) 취소는 기존대로 PG 훅을 발화하고 COMPLETED 로 확정한다(비회귀).
*/
public function test_card_cancel_still_fires_pg_hook_and_completes(): void
{
$hookCalled = false;
HookManager::addFilter('sirsoft-ecommerce.payment.refund', function ($default) use (&$hookCalled) {
$hookCalled = true;
return ['success' => true, 'transaction_id' => 'TXN_'.uniqid(), 'error_code' => null, 'error_message' => null];
});
$order = $this->createOrderWithPaymentMethod('card');
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertTrue($hookCalled, '카드 취소는 PG 환불 훅을 발화해야 합니다');
$this->assertEquals(RefundMethodEnum::PG, $result->orderRefund->refund_method);
$this->assertEquals(RefundStatusEnum::COMPLETED, $result->orderRefund->refund_status);
}
/**
* 가상계좌(vbank, PG 대상) 취소도 PG 가드를 통과해 PG 훅을 발화한다(비회귀).
*/
public function test_vbank_cancel_still_fires_pg_hook(): void
{
$hookCalled = false;
HookManager::addFilter('sirsoft-ecommerce.payment.refund', function ($default) use (&$hookCalled) {
$hookCalled = true;
return ['success' => true, 'transaction_id' => 'TXN', 'error_code' => null, 'error_message' => null];
});
$order = $this->createOrderWithPaymentMethod('vbank');
$this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertTrue($hookCalled, '가상계좌 취소는 PG 환불 훅을 발화해야 합니다');
}
// ================================================================
// MP02-U2/U20②: 0원·미입금 부분취소 허용
// ================================================================
/**
* 미입금(dbank, payment_status≠PAID, paid=0) 주문의 부분취소가 허용된다 (RED→GREEN).
*/
public function test_partial_cancel_allowed_for_unpaid_order(): void
{
$order = $this->createOrderWithPaymentMethod(
'dbank',
PaymentStatusEnum::WAITING_DEPOSIT,
['order_status' => OrderStatusEnum::PAYMENT_COMPLETE, 'total_paid_amount' => 0],
);
$optionA = $order->options->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
// 핵심: cancel_refund_negative 예외 없이 부분취소가 허용되어야 한다.
// 부분취소는 잔여 옵션 기준 진행 상태 유지(PAYMENT_COMPLETE) + 파생 플래그로 표시.
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order->order_status);
$this->assertTrue($result->order->fresh()->isPartiallyCancelled());
// 실결제 0원 → 환불할 금액이 없으므로 PG 환불액은 0이다(미입금 주문).
if ($result->orderRefund !== null) {
$this->assertEquals(0.0, (float) $result->orderRefund->refund_amount, '미입금 주문의 PG 환불액은 0이어야 합니다');
}
}
/**
* 운영자 0원 결제완료(payment_status≠PAID, paid=0) 주문도 부분취소가 허용된다 (무조건 허용).
*/
public function test_partial_cancel_allowed_for_operator_zero_paid_order(): void
{
$order = $this->createOrderWithPaymentMethod(
'card',
PaymentStatusEnum::READY,
['order_status' => OrderStatusEnum::PAYMENT_COMPLETE, 'total_paid_amount' => 0],
);
$optionA = $order->options->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: false,
);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order->order_status);
$this->assertTrue($result->order->fresh()->isPartiallyCancelled());
}
/**
* 정상 결제(PAID, paid>0) 무할인 주문의 부분취소는 통과하고 환불이 생성된다.
*/
public function test_partial_cancel_passes_for_normal_paid_order(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => true, 'transaction_id' => 'TXN']);
$order = $this->createOrderWithPaymentMethod('card');
$optionA = $order->options->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: true,
);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order->order_status);
$this->assertTrue($result->order->fresh()->isPartiallyCancelled());
$this->assertNotNull($result->orderRefund);
$this->assertGreaterThan(0, (float) $result->orderRefund->refund_amount);
}
// ================================================================
// MP02-U20④: 취소일시 native 컬럼 기록 (전체+부분)
// ================================================================
/**
* 전체취소 시 cancelled_at 이 기록된다.
*/
public function test_full_cancel_records_cancelled_at(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => true, 'transaction_id' => 'TXN']);
$order = $this->createOrderWithPaymentMethod('card');
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
$this->assertNotNull($result->order->cancelled_at, '전체취소 시 cancelled_at 이 기록되어야 합니다');
}
/**
* 부분취소 시에도 cancelled_at 이 기록된다 (종전 전체취소만 기록되던 결함 수정).
*/
public function test_partial_cancel_records_cancelled_at(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => true, 'transaction_id' => 'TXN']);
$order = $this->createOrderWithPaymentMethod('card');
$optionA = $order->options->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $optionA->id, 'cancel_quantity' => 1]],
cancelPg: true,
);
$this->assertNotNull($result->order->cancelled_at, '부분취소 시에도 cancelled_at 이 기록되어야 합니다');
}
// ================================================================
// MP02-U20①: 다중항목 일부 부분취소가 전체취소로 오처리되지 않는다
// ================================================================
/**
* 2항목 중 1항목 전량 부분취소 시 주문 상태는 잔여 옵션 기준(PAYMENT_COMPLETE)으로 유지되고,
* 부분취소 파생 플래그가 켜지며 나머지 항목은 활성 유지된다 (partial_cancelled 제거).
*/
public function test_partial_subset_cancel_keeps_remaining_active(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => true, 'transaction_id' => 'TXN']);
$order = $this->createOrderWithPaymentMethod('card');
$options = $order->options;
$first = $options->first();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $first->id, 'cancel_quantity' => $first->quantity]],
cancelPg: true,
);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order->order_status);
$this->assertTrue($result->order->fresh()->isPartiallyCancelled());
$active = $result->order->options->where('option_status', '!=', OrderStatusEnum::CANCELLED);
$this->assertGreaterThan(0, $active->count(), '취소하지 않은 항목은 활성 상태로 남아야 합니다');
}
/**
* 모든 활성 항목 전량 취소 요청은 백엔드에서 FULL 로 승격된다.
*/
public function test_all_items_full_quantity_promotes_to_full_cancel(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => true, 'transaction_id' => 'TXN']);
$order = $this->createOrderWithPaymentMethod('card');
$cancelItems = $order->options
->map(fn ($o) => ['order_option_id' => $o->id, 'cancel_quantity' => $o->quantity])
->values()
->all();
$result = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: $cancelItems,
cancelPg: true,
);
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order->order_status, '전량 취소는 FULL 로 승격되어야 합니다');
}
// ================================================================
// MP02-U18②: partial_cancelled 잔여 항목 재취소 허용
// ================================================================
/**
* 부분취소된 주문(PARTIAL_CANCELLED)의 잔여 항목을 다시 취소할 수 있다.
*/
public function test_partial_cancelled_order_can_cancel_remaining(): void
{
HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn ($d) => ['success' => true, 'transaction_id' => 'TXN']);
$order = $this->createOrderWithPaymentMethod('card');
$options = $order->options->values();
// 1차: 첫 항목 취소 → 잔여 옵션 기준 진행 상태 유지(PAYMENT_COMPLETE), 부분취소 파생 플래그 ON.
// 별도 partial_cancelled 상태가 없으므로, 잔여 항목은 취소 가능 상태(PAYMENT_COMPLETE)에 그대로 있어 재취소 가능.
$first = $this->cancellationService->cancelOrderOptions(
order: $order,
cancelItems: [['order_option_id' => $options[0]->id, 'cancel_quantity' => $options[0]->quantity]],
cancelPg: true,
);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $first->order->order_status);
$this->assertTrue($first->order->fresh()->isPartiallyCancelled());
// 2차: 잔여 항목 재취소 → 예외 없이 처리, 전량 소진으로 CANCELLED 전이
$reloaded = Order::find($order->id)->load(['options', 'payment', 'shippings']);
$remaining = $reloaded->options->where('option_status', '!=', OrderStatusEnum::CANCELLED)->first();
$second = $this->cancellationService->cancelOrderOptions(
order: $reloaded,
cancelItems: [['order_option_id' => $remaining->id, 'cancel_quantity' => $remaining->quantity]],
cancelPg: true,
);
$this->assertEquals(OrderStatusEnum::CANCELLED, $second->order->order_status, '잔여 전량 취소 시 CANCELLED 로 전이되어야 합니다');
}
}