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 에 반영했다. 같은 증상을 다시 만났을 때의 진단 경로를 트러블슈팅 사례로 남겼다.
3169 lines
133 KiB
PHP
3169 lines
133 KiB
PHP
<?php
|
||
|
||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||
|
||
use App\Extension\HookManager;
|
||
use App\Models\Role;
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\App;
|
||
use Modules\Sirsoft\Ecommerce\Database\Factories\TempOrderFactory;
|
||
use Modules\Sirsoft\Ecommerce\DTO\AppliedPromotions;
|
||
use Modules\Sirsoft\Ecommerce\DTO\AppliedShippingPolicy;
|
||
use Modules\Sirsoft\Ecommerce\DTO\CouponApplication;
|
||
use Modules\Sirsoft\Ecommerce\DTO\ItemCalculation;
|
||
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
|
||
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
|
||
use Modules\Sirsoft\Ecommerce\DTO\Summary;
|
||
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\Exceptions\CartUnavailableException;
|
||
use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException;
|
||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderAmountChangedException;
|
||
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
|
||
use Modules\Sirsoft\Ecommerce\Exceptions\UnsupportedPaymentCurrencyException;
|
||
use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener;
|
||
use Modules\Sirsoft\Ecommerce\Models\Cart;
|
||
use Modules\Sirsoft\Ecommerce\Models\Coupon;
|
||
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
|
||
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
|
||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
|
||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||
use Modules\Sirsoft\Ecommerce\Models\UserAddress;
|
||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
|
||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||
use Modules\Sirsoft\Ecommerce\Services\StockService;
|
||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||
use PHPUnit\Framework\Attributes\DataProvider;
|
||
use PHPUnit\Framework\MockObject\MockObject;
|
||
|
||
/**
|
||
* 주문 생성 서비스 테스트
|
||
*/
|
||
class OrderProcessingServiceTest extends ModuleTestCase
|
||
{
|
||
protected OrderProcessingService $service;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->cleanupMileageSettingsFile();
|
||
$this->service = app(OrderProcessingService::class);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
$this->cleanupMileageSettingsFile();
|
||
parent::tearDown();
|
||
}
|
||
|
||
/**
|
||
* 테스트 간 격리를 위해 testing 격리 경로의 마일리지 설정 파일을 제거합니다.
|
||
* (setSetting 으로 deduction_timing 등을 변경한 테스트가 다음 테스트에 누설되지 않도록)
|
||
*/
|
||
private function cleanupMileageSettingsFile(): void
|
||
{
|
||
foreach (['mileage.json', 'order_settings.json'] as $name) {
|
||
$file = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/'.$name);
|
||
if (file_exists($file)) {
|
||
unlink($file);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 마일리지 사용 정책을 무제한으로 완화합니다.
|
||
*
|
||
* 주문 확정 시점에 사용 정책(min_use_amount / use_unit / max_use_*)이 강제되므로(#493 E1),
|
||
* 마일리지가 주제가 아닌 테스트는 배포 기본값(최소 1,000 / 상한 50,000)에 걸린다.
|
||
* 이 테스트들의 관심사는 주문 메타·결제 상태이므로 정책을 열어 두고 본래 대상만 검증한다.
|
||
* 정책 강제 자체는 MileageUsageLimitTest 가 다룬다.
|
||
*/
|
||
private function allowAnyMileageUsage(): void
|
||
{
|
||
$dir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||
if (! is_dir($dir)) {
|
||
mkdir($dir, 0777, true);
|
||
}
|
||
|
||
file_put_contents($dir.'/mileage.json', json_encode([
|
||
'enabled' => true,
|
||
'default_earn_rate' => 0,
|
||
'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' => false,
|
||
'expiry_days' => 365,
|
||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
/**
|
||
* 카드(card) 결제수단의 마일리지 차감 시점을 설정합니다. (마일리지/MP06)
|
||
*
|
||
* 차감 시점은 결제수단별(order_settings.payment_methods.*.mileage_deduction_timing)로 관리되므로,
|
||
* 단일 키 대신 카드 결제수단 설정을 저장한다.
|
||
*
|
||
* @param string $timing order_placed | payment_complete
|
||
*/
|
||
private function setCardMileageTiming(string $timing): void
|
||
{
|
||
app(EcommerceSettingsService::class)->saveSettings([
|
||
'order_settings' => [
|
||
'payment_methods' => [
|
||
['id' => 'card', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'mileage_deduction_timing' => $timing],
|
||
],
|
||
],
|
||
]);
|
||
app(EcommerceSettingsService::class)->clearCache();
|
||
}
|
||
|
||
// ===== 기존 테스트 (determineInitialStatus, isFirstOrder, generateOrderNumber) =====
|
||
|
||
public function test_determine_initial_status_returns_pending_payment_for_vbank(): void
|
||
{
|
||
// Protected 메서드 테스트를 위한 리플렉션
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('determineInitialStatus');
|
||
$method->setAccessible(true);
|
||
|
||
// 결제할 금액이 있는 일반 주문(finalAmount > 0)
|
||
$status = $method->invoke($this->service, 'vbank', 50000);
|
||
|
||
$this->assertEquals(OrderStatusEnum::PENDING_PAYMENT, $status);
|
||
}
|
||
|
||
public function test_determine_initial_status_returns_pending_order_for_card(): void
|
||
{
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('determineInitialStatus');
|
||
$method->setAccessible(true);
|
||
|
||
$status = $method->invoke($this->service, 'card', 50000);
|
||
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $status);
|
||
}
|
||
|
||
public function test_determine_initial_status_returns_pending_order_for_pg(): void
|
||
{
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('determineInitialStatus');
|
||
$method->setAccessible(true);
|
||
|
||
$status = $method->invoke($this->service, 'pg', 50000);
|
||
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $status);
|
||
}
|
||
|
||
public function test_is_first_order_returns_true_for_new_user(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('isFirstOrder');
|
||
$method->setAccessible(true);
|
||
|
||
$isFirstOrder = $method->invoke($this->service, $user->id);
|
||
|
||
$this->assertTrue($isFirstOrder);
|
||
}
|
||
|
||
public function test_is_first_order_returns_false_for_user_with_orders(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
// 기존 주문 생성
|
||
Order::factory()->create(['user_id' => $user->id]);
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('isFirstOrder');
|
||
$method->setAccessible(true);
|
||
|
||
$isFirstOrder = $method->invoke($this->service, $user->id);
|
||
|
||
$this->assertFalse($isFirstOrder);
|
||
}
|
||
|
||
public function test_is_first_order_returns_false_for_guest(): void
|
||
{
|
||
// 비회원 주문(user_id = null)은 회원 식별이 불가능해 첫 주문 혜택 대상이 아니다.
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('isFirstOrder');
|
||
$method->setAccessible(true);
|
||
|
||
$isFirstOrder = $method->invoke($this->service, null);
|
||
|
||
$this->assertFalse($isFirstOrder);
|
||
}
|
||
|
||
public function test_generate_order_number_returns_unique_string(): void
|
||
{
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('generateOrderNumber');
|
||
$method->setAccessible(true);
|
||
|
||
$orderNumber1 = $method->invoke($this->service);
|
||
$orderNumber2 = $method->invoke($this->service);
|
||
|
||
$this->assertIsString($orderNumber1);
|
||
$this->assertIsString($orderNumber2);
|
||
$this->assertNotEquals($orderNumber1, $orderNumber2);
|
||
}
|
||
|
||
public function test_generate_order_number_has_expected_format(): void
|
||
{
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('generateOrderNumber');
|
||
$method->setAccessible(true);
|
||
|
||
$orderNumber = $method->invoke($this->service);
|
||
|
||
// SequenceService TIMESTAMP 알고리즘: Ymd-His + 밀리초3자리 + 랜덤1자리 (예: 20260208-1435226549)
|
||
$this->assertMatchesRegularExpression('/^\d{8}-\d{10}$/', $orderNumber);
|
||
}
|
||
|
||
public function test_generate_order_number_is_recorded_in_sequence_codes(): void
|
||
{
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('generateOrderNumber');
|
||
$method->setAccessible(true);
|
||
|
||
$orderNumber = $method->invoke($this->service);
|
||
|
||
// 채번된 주문번호가 ecommerce_sequence_codes 테이블에 기록되는지 확인
|
||
$this->assertDatabaseHas('ecommerce_sequence_codes', [
|
||
'type' => 'order',
|
||
'code' => $orderNumber,
|
||
]);
|
||
}
|
||
|
||
public function test_generate_order_number_produces_unique_values(): void
|
||
{
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('generateOrderNumber');
|
||
$method->setAccessible(true);
|
||
|
||
$orderNumbers = [];
|
||
for ($i = 0; $i < 10; $i++) {
|
||
$orderNumbers[] = $method->invoke($this->service);
|
||
}
|
||
|
||
// 모든 주문번호가 유일해야 함
|
||
$uniqueNumbers = array_unique($orderNumbers);
|
||
$this->assertCount(10, $uniqueNumbers);
|
||
|
||
// 모든 주문번호가 타임스탬프 형식이어야 함
|
||
foreach ($orderNumbers as $orderNumber) {
|
||
$this->assertMatchesRegularExpression('/^\d{8}-\d{10}$/', $orderNumber);
|
||
}
|
||
}
|
||
|
||
// ===== createFromTempOrder 재계산 통합 테스트 =====
|
||
|
||
/**
|
||
* OrderCalculationService를 mock하여 재계산 결과를 주입하고, 서비스를 재생성합니다.
|
||
*
|
||
* @param OrderCalculationResult $result 재계산 결과
|
||
*/
|
||
protected function mockCalculationService(OrderCalculationResult $result): void
|
||
{
|
||
$mock = $this->createMock(OrderCalculationService::class);
|
||
$mock->method('calculate')->willReturn($result);
|
||
|
||
$this->app->instance(OrderCalculationService::class, $mock);
|
||
$this->service = app(OrderProcessingService::class);
|
||
}
|
||
|
||
/**
|
||
* 기본 계산 결과 DTO를 생성합니다.
|
||
*
|
||
* @param int $finalAmount 최종 금액
|
||
* @param array $overrides 오버라이드 옵션
|
||
*/
|
||
protected function makeCalculationResult(int $finalAmount = 103000, array $overrides = []): OrderCalculationResult
|
||
{
|
||
$summary = new Summary(
|
||
subtotal: $overrides['subtotal'] ?? 100000,
|
||
totalDiscount: $overrides['total_discount'] ?? 0,
|
||
productCouponDiscount: $overrides['product_coupon_discount'] ?? 0,
|
||
codeDiscount: $overrides['code_discount'] ?? 0,
|
||
totalShipping: $overrides['total_shipping'] ?? 3000,
|
||
taxableAmount: $overrides['taxable_amount'] ?? 93636,
|
||
taxFreeAmount: $overrides['tax_free_amount'] ?? 0,
|
||
pointsUsed: $overrides['points_used'] ?? 0,
|
||
pointsEarning: $overrides['points_earning'] ?? 0,
|
||
paymentAmount: $overrides['payment_amount'] ?? $finalAmount,
|
||
finalAmount: $finalAmount,
|
||
);
|
||
|
||
$items = $overrides['items'] ?? [];
|
||
$promotions = $overrides['promotions'] ?? new PromotionsSummary;
|
||
|
||
return new OrderCalculationResult(
|
||
items: $items,
|
||
summary: $summary,
|
||
promotions: $promotions,
|
||
validationErrors: $overrides['validation_errors'] ?? [],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 테스트용 기본 TempOrder를 생성합니다.
|
||
*
|
||
* @param User $user 사용자
|
||
* @param int $finalAmount 최종 금액 (calculation_result.summary.final_amount)
|
||
* @return TempOrder
|
||
*/
|
||
protected function createTestTempOrder(User $user, int $finalAmount = 103000)
|
||
{
|
||
return TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withCalculationResult([
|
||
'summary' => [
|
||
'subtotal' => 100000,
|
||
'total_discount' => 0,
|
||
'product_coupon_discount' => 0,
|
||
'code_discount' => 0,
|
||
'total_shipping' => 3000,
|
||
'final_amount' => $finalAmount,
|
||
'taxable_amount' => 93636,
|
||
'tax_free_amount' => 0,
|
||
'points_used' => 0,
|
||
],
|
||
'items' => [],
|
||
'shippings' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
}
|
||
|
||
public function test_create_from_temp_order_creates_order(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
|
||
// mock: 재계산 결과가 저장된 금액과 동일
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
|
||
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구 테헤란로', 'address_detail' => '123동 456호'],
|
||
'card',
|
||
103000,
|
||
'문앞에 놓아주세요'
|
||
);
|
||
|
||
$this->assertInstanceOf(Order::class, $order);
|
||
$this->assertEquals($user->id, $order->user_id);
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
|
||
$this->assertEquals(103000, $order->total_amount);
|
||
$this->assertMatchesRegularExpression('/^\d{8}-\d{10}$/', $order->order_number);
|
||
}
|
||
|
||
/**
|
||
* 주문 생성 시 현재 화면 언어가 배송지 orderer_locale 에 저장된다.
|
||
*
|
||
* 비회원 알림(이메일)이 주문 화면 언어로 발송되도록 하는 근거 데이터.
|
||
*/
|
||
public function test_create_from_temp_order_stores_current_locale_on_address(): void
|
||
{
|
||
App::setLocale('en');
|
||
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
|
||
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구 테헤란로', 'address_detail' => '123동 456호'],
|
||
'card',
|
||
103000,
|
||
null
|
||
);
|
||
|
||
$this->assertSame('en', $order->fresh()->shippingAddress?->orderer_locale);
|
||
}
|
||
|
||
/**
|
||
* 주문 생성 시 결제 레코드에 결제요청일시·구매자 정보·부가세·결제명·디바이스가
|
||
* 모두 채워지는지 검증합니다 (PG 콜백을 거치지 않는 수단에서도 NULL 로 남지 않아야 함).
|
||
*
|
||
* 회귀 배경: createOrderPayment 가 결제요청일시·구매자정보·결제명·부가세·디바이스 컬럼을
|
||
* 설정하지 않아, 무통장 등 PG 콜백 없는 주문에서 영구 NULL 로 남던 결함.
|
||
*/
|
||
public function test_create_from_temp_order_populates_payment_record_fields(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
// 실제 상품/옵션 생성 (FK 제약조건 충족 + payment_name 산출용 상품명)
|
||
$product = Product::factory()->create();
|
||
$productOption = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
['cart_id' => 1, 'product_id' => $product->id, 'product_option_id' => $productOption->id, 'quantity' => 1],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 103000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$item = new ItemCalculation(
|
||
productId: $product->id,
|
||
productOptionId: $productOption->id,
|
||
quantity: 1,
|
||
unitPrice: 100000,
|
||
subtotal: 100000,
|
||
finalAmount: 100000,
|
||
productName: '테스트 상품 A',
|
||
);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000, [
|
||
'taxable_amount' => 93636,
|
||
'items' => [$item],
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'buyer@example.com'],
|
||
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구 테헤란로', 'address_detail' => '123동 456호'],
|
||
'dbank',
|
||
103000,
|
||
null,
|
||
'홍길동',
|
||
['bank_code' => '004', 'bank_name' => '국민은행', 'account_number' => '123-456', 'account_holder' => '판매자']
|
||
);
|
||
|
||
$payment = $order->payment;
|
||
$this->assertNotNull($payment, '결제 레코드가 생성되어야 함');
|
||
|
||
// 결제요청일시 — 전 결제수단 공통, 생성 시점에 기록
|
||
$this->assertNotNull($payment->payment_started_at, 'payment_started_at 은 생성 시점에 기록되어야 함');
|
||
|
||
// 구매자 정보 — 주문자 정보가 결제 레코드에 기록
|
||
$this->assertEquals('홍길동', $payment->buyer_name);
|
||
$this->assertEquals('buyer@example.com', $payment->buyer_email);
|
||
$this->assertEquals('010-1234-5678', $payment->buyer_phone);
|
||
|
||
// 부가세 — 과세표준(93636)의 1/11
|
||
$this->assertEquals((int) round(93636 / 11), (int) $payment->vat_amount);
|
||
|
||
// 결제명 — 상품명 (단일 항목이므로 상품명 그대로)
|
||
$this->assertEquals('테스트 상품 A', $payment->payment_name);
|
||
|
||
// 결제 디바이스 — User-Agent 기반 (테스트 환경은 비어 'pc')
|
||
$this->assertContains($payment->payment_device, ['pc', 'mobile']);
|
||
}
|
||
|
||
public function test_create_from_temp_order_with_vbank_sets_pending_payment(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user, 50000);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(50000, [
|
||
'subtotal' => 50000,
|
||
'total_shipping' => 0,
|
||
'taxable_amount' => 45454,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => '김철수', 'phone' => '010-9876-5432', 'email' => 'kim@example.com'],
|
||
['recipient_name' => '김철수', 'recipient_phone' => '010-9876-5432', 'zipcode' => '54321', 'address' => '부산시 해운대구', 'address_detail' => '마린시티 1동'],
|
||
'vbank',
|
||
50000,
|
||
null,
|
||
'김철수'
|
||
);
|
||
|
||
$this->assertInstanceOf(Order::class, $order);
|
||
$this->assertEquals(OrderStatusEnum::PENDING_PAYMENT, $order->order_status);
|
||
}
|
||
|
||
public function test_create_from_temp_order_preserves_temp_order(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user, 30000);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(30000, [
|
||
'subtotal' => 30000,
|
||
'total_shipping' => 0,
|
||
'taxable_amount' => 27273,
|
||
]));
|
||
|
||
$tempOrderId = $tempOrder->id;
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test Address', 'address_detail' => 'Test Detail'],
|
||
'card',
|
||
30000
|
||
);
|
||
|
||
// PG 결제 취소 → 재결제를 위해 임시주문 유지
|
||
$this->assertDatabaseHas('ecommerce_temp_orders', ['id' => $tempOrderId]);
|
||
}
|
||
|
||
// ===== 신규 테스트: 프로모션/배송비/마일리지 데이터 누락 수정 검증 =====
|
||
|
||
public function test_create_from_temp_order_populates_promotions_applied_snapshot(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
|
||
// 쿠폰이 적용된 프로모션 결과 구성
|
||
$coupon = new CouponApplication(
|
||
couponId: 1,
|
||
couponIssueId: 101,
|
||
name: '10% 할인 쿠폰',
|
||
targetType: 'product_amount',
|
||
discountType: 'rate',
|
||
discountValue: 10,
|
||
totalDiscount: 10000,
|
||
);
|
||
$productPromotions = new AppliedPromotions(coupons: [$coupon]);
|
||
$promotions = new PromotionsSummary(productPromotions: $productPromotions);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000, [
|
||
'promotions' => $promotions,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
|
||
// promotions_applied_snapshot이 비어있지 않아야 함
|
||
$snapshot = $order->promotions_applied_snapshot;
|
||
$this->assertNotEmpty($snapshot);
|
||
$this->assertArrayHasKey('product_promotions', $snapshot);
|
||
$this->assertNotEmpty($snapshot['product_promotions']['coupons']);
|
||
$this->assertEquals(101, $snapshot['product_promotions']['coupons'][0]['coupon_issue_id']);
|
||
}
|
||
|
||
public function test_create_from_temp_order_populates_shipping_policy_snapshot(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
// 실제 상품/옵션 생성 (FK 제약조건 충족)
|
||
$product = Product::factory()->create();
|
||
$productOption = ProductOption::factory()->create([
|
||
'product_id' => $product->id,
|
||
]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
[
|
||
'cart_id' => 1,
|
||
'product_id' => $product->id,
|
||
'product_option_id' => $productOption->id,
|
||
'quantity' => 1,
|
||
],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 103000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
// 배송정책이 적용된 아이템 구성
|
||
$shippingPolicy = new AppliedShippingPolicy(
|
||
policyId: 1,
|
||
policyName: '기본 배송',
|
||
chargePolicy: 'paid',
|
||
shippingAmount: 3000,
|
||
extraShippingAmount: 0,
|
||
totalShippingAmount: 3000,
|
||
policySnapshot: ['id' => 1, 'name' => '기본 배송', 'base_fee' => 3000],
|
||
);
|
||
|
||
$item = new ItemCalculation(
|
||
productId: $product->id,
|
||
productOptionId: $productOption->id,
|
||
quantity: 1,
|
||
unitPrice: 100000,
|
||
subtotal: 100000,
|
||
finalAmount: 100000,
|
||
appliedShippingPolicy: $shippingPolicy,
|
||
);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000, [
|
||
'items' => [$item],
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
|
||
// shipping_policy_applied_snapshot 은 `{items: [...], address: {...}}` 구조여야 한다.
|
||
// 항목 목록과 배송지 메타를 한 배열에 섞으면 PHP 배열이 non-sequential 이 되어
|
||
// json_encode 가 객체로 직렬화하고, 배열을 전제하는 화면에서 표시가 사라진다.
|
||
$snapshot = $order->shipping_policy_applied_snapshot;
|
||
$this->assertNotEmpty($snapshot);
|
||
$this->assertArrayHasKey('items', $snapshot);
|
||
$this->assertArrayHasKey('address', $snapshot);
|
||
$this->assertEquals($productOption->id, $snapshot['items'][0]['product_option_id']);
|
||
$this->assertEquals(1, $snapshot['items'][0]['policy']['policy_id']);
|
||
|
||
// items 는 JSON 에서 반드시 배열 리터럴로 나가야 한다 (객체면 프론트 .find 가 죽는다)
|
||
$this->assertStringContainsString('"items":[', json_encode($snapshot));
|
||
}
|
||
|
||
/**
|
||
* 주문의 총 무게/부피는 주문 옵션 소계의 합으로 기록된다 (공개 #94 / N7).
|
||
*
|
||
* 기존에는 0 으로 고정 기록되어, 배송사 연동·운임 정산이 이 값을 읽으면
|
||
* 무게 없는 주문으로 취급됐다. 값의 단위는 상품 옵션과 같은 g / cm³ 다.
|
||
*/
|
||
public function test_create_from_temp_order_records_total_weight_and_volume(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
$product = Product::factory()->create();
|
||
$productOption = ProductOption::factory()->create([
|
||
'product_id' => $product->id,
|
||
'weight' => 500,
|
||
'volume' => 1200,
|
||
]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
[
|
||
'cart_id' => 1,
|
||
'product_id' => $product->id,
|
||
'product_option_id' => $productOption->id,
|
||
'quantity' => 3,
|
||
],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 103000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$item = new ItemCalculation(
|
||
productId: $product->id,
|
||
productOptionId: $productOption->id,
|
||
quantity: 3,
|
||
unitPrice: 100000,
|
||
subtotal: 300000,
|
||
finalAmount: 300000,
|
||
);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000, [
|
||
'items' => [$item],
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
|
||
$order->refresh();
|
||
|
||
// 단위 무게는 상품 옵션의 g 값을 그대로 복사한다 (kg 환산은 배송비 계산 시점에만)
|
||
$orderOption = $order->options()->first();
|
||
$this->assertEquals(500.0, (float) $orderOption->unit_weight);
|
||
$this->assertEquals(1200.0, (float) $orderOption->unit_volume);
|
||
$this->assertEquals(1500.0, (float) $orderOption->subtotal_weight);
|
||
$this->assertEquals(3600.0, (float) $orderOption->subtotal_volume);
|
||
|
||
// 주문 합계는 옵션 소계의 합
|
||
$this->assertEquals(1500.0, (float) $order->total_weight);
|
||
$this->assertEquals(3600.0, (float) $order->total_volume);
|
||
}
|
||
|
||
public function test_create_from_temp_order_saves_order_meta_with_calculation_input(): void
|
||
{
|
||
$this->allowAnyMileageUsage();
|
||
|
||
$user = User::factory()->create();
|
||
|
||
// 마일리지 사용 훅이 실제 FIFO 차감을 수행하므로 사용액(500) 이상 잔액 시드
|
||
MileageTransaction::create([
|
||
'user_id' => $user->id,
|
||
'currency' => 'KRW',
|
||
'type' => 'purchase_earn',
|
||
'amount' => 2000,
|
||
'remaining_amount' => 2000,
|
||
'balance_after' => 2000,
|
||
]);
|
||
|
||
$calculationInput = [
|
||
'promotions' => [
|
||
'item_coupons' => [1 => [101]],
|
||
'order_coupon_issue_id' => null,
|
||
'shipping_coupon_issue_id' => null,
|
||
],
|
||
'use_points' => 500,
|
||
'shipping_address' => null,
|
||
];
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withCalculationInput($calculationInput)
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 103000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
|
||
// order_meta에 calculation_input이 포함되어야 함
|
||
$meta = $order->order_meta;
|
||
$this->assertArrayHasKey('temp_order_id', $meta);
|
||
$this->assertArrayHasKey('calculation_input', $meta);
|
||
$this->assertEquals(500, $meta['calculation_input']['use_points']);
|
||
}
|
||
|
||
public function test_create_order_shippings_uses_correct_dto_properties(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
// 실제 상품/옵션 생성
|
||
$product = Product::factory()->create();
|
||
$productOption = ProductOption::factory()->create([
|
||
'product_id' => $product->id,
|
||
]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
[
|
||
'cart_id' => 1,
|
||
'product_id' => $product->id,
|
||
'product_option_id' => $productOption->id,
|
||
'quantity' => 2,
|
||
],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 55000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
// 배송정책이 설정된 아이템
|
||
$shippingPolicy = new AppliedShippingPolicy(
|
||
policyId: 5,
|
||
policyName: '도서산간 배송',
|
||
chargePolicy: 'paid',
|
||
shippingAmount: 3000,
|
||
extraShippingAmount: 2000,
|
||
totalShippingAmount: 5000,
|
||
shippingDiscountAmount: 1000,
|
||
policySnapshot: ['id' => 5, 'name' => '도서산간 배송'],
|
||
);
|
||
|
||
$item = new ItemCalculation(
|
||
productId: $product->id,
|
||
productOptionId: $productOption->id,
|
||
quantity: 2,
|
||
unitPrice: 25000,
|
||
subtotal: 50000,
|
||
finalAmount: 50000,
|
||
appliedShippingPolicy: $shippingPolicy,
|
||
);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(55000, [
|
||
'subtotal' => 50000,
|
||
'total_shipping' => 5000,
|
||
'items' => [$item],
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
55000
|
||
);
|
||
|
||
// 배송 정보가 올바른 DTO 속성값으로 저장되었는지 확인
|
||
$shipping = $order->shippings()->first();
|
||
$this->assertNotNull($shipping);
|
||
$this->assertEquals(3000, $shipping->base_shipping_amount); // shippingAmount (기본 배송비)
|
||
$this->assertEquals(2000, $shipping->extra_shipping_amount); // extraShippingAmount (추가 배송비)
|
||
$this->assertEquals(5000, $shipping->total_shipping_amount); // totalShippingAmount (총 배송비)
|
||
$this->assertEquals(1000, $shipping->shipping_discount_amount); // shippingDiscountAmount (배송비 할인)
|
||
$this->assertTrue((bool) $shipping->is_remote_area); // extraShippingAmount > 0
|
||
}
|
||
|
||
public function test_create_order_shippings_saves_delivery_policy_snapshot(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
$product = Product::factory()->create();
|
||
$productOption = ProductOption::factory()->create([
|
||
'product_id' => $product->id,
|
||
]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
[
|
||
'cart_id' => 1,
|
||
'product_id' => $product->id,
|
||
'product_option_id' => $productOption->id,
|
||
'quantity' => 1,
|
||
],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 33000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$policySnapshotData = [
|
||
'id' => 3,
|
||
'name' => '기본 배송정책',
|
||
'base_fee' => 3000,
|
||
'free_shipping_threshold' => 50000,
|
||
];
|
||
|
||
$shippingPolicy = new AppliedShippingPolicy(
|
||
policyId: 3,
|
||
policyName: '기본 배송정책',
|
||
chargePolicy: 'paid',
|
||
shippingAmount: 3000,
|
||
totalShippingAmount: 3000,
|
||
policySnapshot: $policySnapshotData,
|
||
);
|
||
|
||
$item = new ItemCalculation(
|
||
productId: $product->id,
|
||
productOptionId: $productOption->id,
|
||
quantity: 1,
|
||
unitPrice: 30000,
|
||
subtotal: 30000,
|
||
finalAmount: 30000,
|
||
appliedShippingPolicy: $shippingPolicy,
|
||
);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(33000, [
|
||
'subtotal' => 30000,
|
||
'total_shipping' => 3000,
|
||
'items' => [$item],
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
33000
|
||
);
|
||
|
||
// delivery_policy_snapshot이 저장되었는지 확인
|
||
$shipping = $order->shippings()->first();
|
||
$this->assertNotNull($shipping);
|
||
$this->assertNotNull($shipping->delivery_policy_snapshot);
|
||
$this->assertEquals(3, $shipping->delivery_policy_snapshot['id']);
|
||
$this->assertEquals('기본 배송정책', $shipping->delivery_policy_snapshot['name']);
|
||
}
|
||
|
||
public function test_create_from_temp_order_calls_coupon_use_hook(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
|
||
// 쿠폰이 적용된 계산 결과
|
||
$coupon = new CouponApplication(
|
||
couponId: 1,
|
||
couponIssueId: 201,
|
||
name: '테스트 쿠폰',
|
||
targetType: 'product_amount',
|
||
discountType: 'fixed',
|
||
discountValue: 5000,
|
||
totalDiscount: 5000,
|
||
);
|
||
$productPromotions = new AppliedPromotions(coupons: [$coupon]);
|
||
$promotions = new PromotionsSummary(productPromotions: $productPromotions);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000, [
|
||
'promotions' => $promotions,
|
||
]));
|
||
|
||
// 훅 호출 감지
|
||
$hookCalled = false;
|
||
$capturedCouponIds = [];
|
||
HookManager::addAction('sirsoft-ecommerce.coupon.use', function ($couponIds, $order) use (&$hookCalled, &$capturedCouponIds) {
|
||
$hookCalled = true;
|
||
$capturedCouponIds = $couponIds;
|
||
});
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
|
||
$this->assertTrue($hookCalled, '쿠폰 사용 훅이 호출되어야 합니다');
|
||
$this->assertContains(201, $capturedCouponIds);
|
||
}
|
||
|
||
/**
|
||
* 이미 다른 주문이 선점한 쿠폰으로 주문을 확정하면 트랜잭션 전체가 롤백되어야 합니다.
|
||
*
|
||
* 쿠폰 차감 실패를 삼키고 주문만 생성하면, 1회 제한 쿠폰의 할인이 두 주문에 모두
|
||
* 적용된 채로 확정된다 (KVE-2026-1886).
|
||
*
|
||
* @return void
|
||
*/
|
||
public function test_create_from_temp_order_rolls_back_when_coupon_already_taken(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
|
||
// 선행 주문이 이미 사용한 쿠폰 발급 레코드
|
||
$winningOrder = Order::create([
|
||
'user_id' => $user->id,
|
||
'order_number' => 'ORD-WINNER-'.uniqid(),
|
||
'order_status' => OrderStatusEnum::PENDING_PAYMENT,
|
||
'currency' => 'KRW',
|
||
'item_count' => 1,
|
||
'ordered_at' => now(),
|
||
'subtotal_amount' => 50000,
|
||
'total_amount' => 45000,
|
||
'total_paid_amount' => 45000,
|
||
]);
|
||
|
||
$couponModel = Coupon::create([
|
||
'name' => ['ko' => '선점 쿠폰', 'en' => 'Taken Coupon'],
|
||
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
|
||
'discount_type' => CouponDiscountType::FIXED,
|
||
'discount_value' => 5000,
|
||
'min_order_amount' => 0,
|
||
'target_scope' => CouponTargetScope::ALL,
|
||
'is_combinable' => true,
|
||
'valid_from' => now()->subDay(),
|
||
'valid_to' => now()->addDays(30),
|
||
]);
|
||
|
||
$issue = CouponIssue::create([
|
||
'coupon_id' => $couponModel->id,
|
||
'user_id' => $user->id,
|
||
'coupon_code' => 'TAKEN'.uniqid(),
|
||
'status' => CouponIssueRecordStatus::USED,
|
||
'issued_at' => now(),
|
||
'expired_at' => now()->addDays(30),
|
||
'used_at' => now(),
|
||
'order_id' => $winningOrder->id,
|
||
]);
|
||
|
||
$coupon = new CouponApplication(
|
||
couponId: $couponModel->id,
|
||
couponIssueId: $issue->id,
|
||
name: '선점 쿠폰',
|
||
targetType: 'product_amount',
|
||
discountType: 'fixed',
|
||
discountValue: 5000,
|
||
totalDiscount: 5000,
|
||
);
|
||
$promotions = new PromotionsSummary(
|
||
productPromotions: new AppliedPromotions(coupons: [$coupon])
|
||
);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000, [
|
||
'promotions' => $promotions,
|
||
]));
|
||
|
||
// 실제 리스너를 훅에 연결 (프로덕션과 동일 경로)
|
||
$listener = app(CouponUseListener::class);
|
||
HookManager::addAction(
|
||
'sirsoft-ecommerce.coupon.use',
|
||
fn ($couponIds, $order) => $listener->markCouponsUsed($couponIds, $order)
|
||
);
|
||
|
||
$ordersBefore = Order::query()->count();
|
||
|
||
try {
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
$this->fail('선점된 쿠폰으로 주문이 확정되어서는 안 됩니다.');
|
||
} catch (CouponAlreadyUsedException $e) {
|
||
$this->assertSame($issue->id, $e->getCouponIssueId());
|
||
}
|
||
|
||
// 주문 트랜잭션 전체 롤백 — 주문 행이 늘지 않아야 한다
|
||
$this->assertSame($ordersBefore, Order::query()->count(), '경쟁에서 밀린 주문은 생성되지 않아야 합니다.');
|
||
|
||
// 쿠폰 소유는 선행 주문 그대로
|
||
$issue->refresh();
|
||
$this->assertEquals(CouponIssueRecordStatus::USED, $issue->status);
|
||
$this->assertEquals($winningOrder->id, $issue->order_id);
|
||
}
|
||
|
||
public function test_create_from_temp_order_calls_mileage_use_hook(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
// 카드 결제수단의 마일리지 차감 시점을 order_placed 로 설정 — 주문 생성 시 mileage.use 훅 발화
|
||
// (기본 payment_complete 타이밍은 결제완료 시점에 발화하며, 별도 테스트에서 검증)
|
||
$this->setCardMileageTiming('order_placed');
|
||
|
||
// 마일리지 사용 훅이 실제 FIFO 차감을 수행하므로 사용액(1000) 이상 잔액 시드
|
||
MileageTransaction::create([
|
||
'user_id' => $user->id,
|
||
'currency' => 'KRW',
|
||
'type' => 'purchase_earn',
|
||
'amount' => 2000,
|
||
'remaining_amount' => 2000,
|
||
'balance_after' => 2000,
|
||
]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withUsePoints(1000)
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 102000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(102000, [
|
||
'points_used' => 1000,
|
||
]));
|
||
|
||
// 훅 호출 감지
|
||
$hookCalled = false;
|
||
$capturedPoints = 0;
|
||
HookManager::addAction('sirsoft-ecommerce.mileage.use', function ($points, $order) use (&$hookCalled, &$capturedPoints) {
|
||
$hookCalled = true;
|
||
$capturedPoints = $points;
|
||
});
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
102000
|
||
);
|
||
|
||
$this->assertTrue($hookCalled, '마일리지 사용 훅이 호출되어야 합니다');
|
||
$this->assertEquals(1000, $capturedPoints);
|
||
}
|
||
|
||
/**
|
||
* 마일리지 사용 시 결제예정금액(total_due_amount)은 마일리지 차감 후 금액(finalAmount)이어야 한다.
|
||
*
|
||
* 회귀: 주문 생성 시 total_due_amount 에 차감 전 paymentAmount 가 저장되어
|
||
* PG(KG 이니시스 등)·무통장 입금액이 마일리지 차감 전 금액으로 잡히던 결함.
|
||
* 결제예정금액(total_due_amount)·주문 결제금액(total_amount)은 모두 차감 후 실결제액(finalAmount)이며,
|
||
* 차감 전 paymentAmount 는 어느 컬럼에도 PG 결제 기준값으로 저장되지 않는다.
|
||
*/
|
||
public function test_create_from_temp_order_sets_total_due_amount_to_post_mileage_final_amount(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
// 마일리지 사용 훅의 FIFO 차감을 위한 잔액 시드 (2,000 사용)
|
||
MileageTransaction::create([
|
||
'user_id' => $user->id,
|
||
'currency' => 'KRW',
|
||
'type' => 'purchase_earn',
|
||
'amount' => 2000,
|
||
'remaining_amount' => 2000,
|
||
'balance_after' => 2000,
|
||
]);
|
||
|
||
// 차감 전 결제금액 55,000 / 마일리지 2,000 사용 → 차감 후 53,000
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withUsePoints(2000)
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 53000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(53000, [
|
||
'subtotal' => 55000,
|
||
'total_shipping' => 0,
|
||
'taxable_amount' => 50000,
|
||
'points_used' => 2000,
|
||
'payment_amount' => 55000,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
53000
|
||
);
|
||
|
||
$this->assertEquals(53000, (int) $order->total_amount, '주문 결제금액(total_amount)은 마일리지 차감 후 실결제액이어야 합니다.');
|
||
$this->assertEquals(2000, (int) $order->total_points_used_amount, '사용 마일리지가 기록되어야 합니다.');
|
||
$this->assertEquals(
|
||
53000,
|
||
(int) $order->total_due_amount,
|
||
'결제예정금액(total_due_amount)은 마일리지 차감 후 실결제액이어야 합니다 — PG/무통장 입금액의 SSoT.'
|
||
);
|
||
}
|
||
|
||
public function test_create_from_temp_order_recalculates_from_stored_input(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
|
||
// OrderCalculationService::calculate()가 호출되는지 확인
|
||
$mock = $this->createMock(OrderCalculationService::class);
|
||
$mock->expects($this->once()) // 정확히 1회 호출
|
||
->method('calculate')
|
||
->willReturn($this->makeCalculationResult(103000));
|
||
|
||
$this->app->instance(OrderCalculationService::class, $mock);
|
||
$this->service = app(OrderProcessingService::class);
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
}
|
||
|
||
public function test_create_from_temp_order_blocks_order_on_amount_drift(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
// TempOrder에는 103000으로 저장
|
||
$tempOrder = $this->createTestTempOrder($user, 103000);
|
||
|
||
// 재계산 결과는 105000 (금액 변동)
|
||
$this->mockCalculationService($this->makeCalculationResult(105000));
|
||
|
||
$this->expectException(OrderAmountChangedException::class);
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
}
|
||
|
||
// ===== validatePaymentAmount 테스트 =====
|
||
|
||
/**
|
||
* 테스트용 주문 + 결제 레코드를 생성합니다.
|
||
*
|
||
* @param int $subtotal 소계
|
||
* @param int $shipping 배송비
|
||
* @param int $discount 할인액
|
||
*/
|
||
protected function createOrderWithPayment(
|
||
int $subtotal = 50000,
|
||
int $shipping = 3000,
|
||
int $discount = 0,
|
||
): Order {
|
||
$user = User::factory()->create();
|
||
$totalAmount = $subtotal - $discount + $shipping;
|
||
|
||
$order = Order::factory()->create([
|
||
'user_id' => $user->id,
|
||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||
'subtotal_amount' => $subtotal,
|
||
'total_discount_amount' => $discount,
|
||
'total_product_coupon_discount_amount' => 0,
|
||
'total_order_coupon_discount_amount' => 0,
|
||
'total_code_discount_amount' => $discount,
|
||
'base_shipping_amount' => $shipping,
|
||
'extra_shipping_amount' => 0,
|
||
'shipping_discount_amount' => 0,
|
||
'total_shipping_amount' => $shipping,
|
||
'total_amount' => $totalAmount,
|
||
'total_due_amount' => $totalAmount,
|
||
'total_points_used_amount' => 0,
|
||
'total_deposit_used_amount' => 0,
|
||
'total_paid_amount' => 0,
|
||
]);
|
||
|
||
OrderPayment::factory()->create([
|
||
'order_id' => $order->id,
|
||
'payment_status' => PaymentStatusEnum::READY,
|
||
'payment_method' => PaymentMethodEnum::CARD,
|
||
'pg_provider' => 'tosspayments',
|
||
'paid_amount_local' => 0,
|
||
'paid_at' => null,
|
||
'transaction_id' => null,
|
||
'card_approval_number' => null,
|
||
]);
|
||
|
||
return $order;
|
||
}
|
||
|
||
public function test_validate_payment_amount_passes_when_amounts_match(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// 리플렉션으로 protected 메서드 테스트
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('validatePaymentAmount');
|
||
$method->setAccessible(true);
|
||
|
||
// 53000 = 50000 + 3000 - 0 → total_amount와 일치해야 함
|
||
$method->invoke($this->service, $order, 53000);
|
||
|
||
// 예외가 발생하지 않으면 성공
|
||
$this->assertTrue(true);
|
||
}
|
||
|
||
public function test_validate_payment_amount_throws_on_component_mismatch(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// DB에서 결제예정금액(total_due_amount)을 직접 변조 (컴포넌트 재합산과 불일치)
|
||
// 검증 1단계 기준 컬럼 = total_due_amount.
|
||
Order::withoutEvents(function () use ($order) {
|
||
$order->update(['total_due_amount' => 99999]);
|
||
});
|
||
$order->refresh();
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('validatePaymentAmount');
|
||
$method->setAccessible(true);
|
||
|
||
$this->expectException(PaymentAmountMismatchException::class);
|
||
$method->invoke($this->service, $order, 99999);
|
||
}
|
||
|
||
/**
|
||
* 마일리지 사용 주문: 검증 기준 = total_due_amount(차감 후). 차감 후 금액이면 통과, 차감 전 금액이면 실패.
|
||
*
|
||
* 회귀: PG 검증 기준 컬럼을 total_due_amount 로 통일. total_points_used_amount 가 0 이 아닐 때
|
||
* 컴포넌트 재합산(마일리지 차감 포함) = total_due_amount 와 일치하고, 차감 전 금액(pgAmount)은 거부되어야 한다.
|
||
*/
|
||
public function test_validate_payment_amount_uses_post_mileage_due_amount(): void
|
||
{
|
||
// 상품 55,000 + 배송 0 / 마일리지 2,000 사용 → 차감 후 53,000
|
||
$order = $this->createOrderWithPayment(55000, 0, 0);
|
||
Order::withoutEvents(function () use ($order) {
|
||
$order->update([
|
||
'total_code_discount_amount' => 0,
|
||
'total_points_used_amount' => 2000,
|
||
'total_amount' => 53000,
|
||
'total_due_amount' => 53000,
|
||
]);
|
||
});
|
||
$order->refresh();
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('validatePaymentAmount');
|
||
$method->setAccessible(true);
|
||
|
||
// 차감 후 53,000 으로 PG 콜백 → 통과
|
||
$method->invoke($this->service, $order, 53000);
|
||
$this->assertTrue(true);
|
||
|
||
// 차감 전 55,000 으로 PG 콜백 → 2단계에서 거부
|
||
$this->expectException(PaymentAmountMismatchException::class);
|
||
$method->invoke($this->service, $order, 55000);
|
||
}
|
||
|
||
public function test_validate_payment_amount_throws_on_pg_amount_mismatch(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('validatePaymentAmount');
|
||
$method->setAccessible(true);
|
||
|
||
// PG에서 다른 금액이 전달된 경우
|
||
$this->expectException(PaymentAmountMismatchException::class);
|
||
$method->invoke($this->service, $order, 99999);
|
||
}
|
||
|
||
/**
|
||
* base 통화 ≠ 결제 통화 조합에서 PG 청구액(결제 통화 환산) 검증이 정합해야 한다.
|
||
*
|
||
* 회귀 배경: buildPgPaymentData 가 PG 청구액을 결제 통화로 환산(resolveOrderPaymentChargeAmount)
|
||
* 하도록 바뀌었으나, validatePaymentAmount 2단계가 base(total_due_amount)를 직접 비교해
|
||
* base≠order_currency(예: base JPY 500 → 결제 KRW 4,750) 에서 "결제금액 불일치"로 오차단됐다.
|
||
* 검증 기준을 결제 통화 환산액으로 통일했으므로, 환산 청구액은 통과하고 base 금액은 거부되어야 한다.
|
||
*/
|
||
#[DataProvider('providePaymentCurrencyCombinations')]
|
||
public function test_validate_payment_amount_uses_payment_currency_charge(
|
||
string $baseCurrency,
|
||
string $orderCurrency,
|
||
float $baseDueAmount,
|
||
array $exchangeRates,
|
||
int $expectedPgAmount,
|
||
): void {
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// base≠order 스냅샷 + base 통화 total_due_amount 강제 주입
|
||
// (createOrderWithPayment 의 컴포넌트 검증 1단계를 통과시키려 컴포넌트도 base 기준으로 맞춘다)
|
||
Order::withoutEvents(function () use ($order, $baseCurrency, $orderCurrency, $baseDueAmount, $exchangeRates) {
|
||
$order->update([
|
||
'currency' => $orderCurrency,
|
||
'subtotal_amount' => $baseDueAmount,
|
||
'total_product_coupon_discount_amount' => 0,
|
||
'total_order_coupon_discount_amount' => 0,
|
||
'total_code_discount_amount' => 0,
|
||
'base_shipping_amount' => 0,
|
||
'extra_shipping_amount' => 0,
|
||
'shipping_discount_amount' => 0,
|
||
'total_points_used_amount' => 0,
|
||
'total_amount' => $baseDueAmount,
|
||
'total_due_amount' => $baseDueAmount,
|
||
'currency_snapshot' => [
|
||
'base_currency' => $baseCurrency,
|
||
'order_currency' => $orderCurrency,
|
||
'exchange_rates' => $exchangeRates,
|
||
],
|
||
]);
|
||
});
|
||
$order->refresh();
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('validatePaymentAmount');
|
||
$method->setAccessible(true);
|
||
|
||
// 결제 통화 환산 청구액(PG 가 실제 청구하는 값)은 통과해야 한다.
|
||
$method->invoke($this->service, $order, $expectedPgAmount);
|
||
$this->assertTrue(true);
|
||
|
||
// base 금액(환산 전)이 환산 청구액과 다르면, base 금액을 PG 금액으로 주면 거부되어야 한다.
|
||
// (JPY→USD 처럼 base_unit/소수자리 보정으로 정수값이 우연히 같아지는 조합은 거부 단정 제외.)
|
||
$baseInt = (int) round($baseDueAmount);
|
||
if ($baseInt !== $expectedPgAmount) {
|
||
$this->expectException(PaymentAmountMismatchException::class);
|
||
$method->invoke($this->service, $order, $baseInt);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 검증 4조합: base/order 통화 전 경우의 수.
|
||
*
|
||
* @return array<string, array{0:string,1:string,2:float,3:array,4:int}>
|
||
*/
|
||
public static function providePaymentCurrencyCombinations(): array
|
||
{
|
||
// 환율/단위 규칙(스냅샷): KRW(decimals 0, base_unit 1000), USD(2, 1), JPY(0, base_unit 100)
|
||
$krw = ['rate' => '950', 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 1000];
|
||
$usd = ['rate' => '1', 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2, 'base_unit' => 1];
|
||
$jpy = ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'round', 'decimal_places' => 0, 'base_unit' => 100];
|
||
|
||
return [
|
||
// 기본 KRW + 유저 KRW 결제 (base==order): 53,000원 그대로
|
||
'base KRW / pay KRW' => [
|
||
'KRW', 'KRW', 53000.0,
|
||
['KRW' => $krw, 'USD' => $usd, 'JPY' => $jpy],
|
||
53000,
|
||
],
|
||
// 기본 USD + 유저 KRW 결제: base $6 → (6/1)*950 = 5,700 → floor 단위1 → 5,700원
|
||
'base USD / pay KRW' => [
|
||
'USD', 'KRW', 6.0,
|
||
['USD' => ['rate' => '1', 'decimal_places' => 2, 'base_unit' => 1], 'KRW' => $krw],
|
||
5700,
|
||
],
|
||
// 기본 JPY + 유저 USD 결제: base ¥500 → (500/100)*1 = $5.00 → minor 500
|
||
'base JPY / pay USD' => [
|
||
'JPY', 'USD', 500.0,
|
||
['JPY' => $jpy, 'USD' => $usd],
|
||
500,
|
||
],
|
||
// 기본 JPY + 유저 JPY 결제 (base==order): ¥500 그대로
|
||
'base JPY / pay JPY' => [
|
||
'JPY', 'JPY', 500.0,
|
||
['JPY' => $jpy],
|
||
500,
|
||
],
|
||
];
|
||
}
|
||
|
||
// ===== completePayment 확장 테스트 =====
|
||
|
||
public function test_complete_payment_with_pg_amount_validates_and_updates(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
$result = $this->service->completePayment($order, [
|
||
'transaction_id' => 'pk_test_123',
|
||
'card_approval_number' => '87654321',
|
||
'card_number_masked' => '5432-****-****-1234',
|
||
'card_name' => '삼성카드',
|
||
'card_installment_months' => 3,
|
||
'is_interest_free' => true,
|
||
'receipt_url' => 'https://receipt.example.com',
|
||
'payment_device' => 'pc',
|
||
], 53000);
|
||
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
|
||
|
||
// 결제 정보 확인
|
||
$payment = $result->payment;
|
||
$payment->refresh();
|
||
$this->assertEquals('pk_test_123', $payment->transaction_id);
|
||
$this->assertEquals('87654321', $payment->card_approval_number);
|
||
$this->assertEquals('5432-****-****-1234', $payment->card_number_masked);
|
||
$this->assertEquals('삼성카드', $payment->card_name);
|
||
$this->assertEquals(3, $payment->card_installment_months);
|
||
$this->assertTrue((bool) $payment->is_interest_free);
|
||
$this->assertEquals('https://receipt.example.com', $payment->receipt_url);
|
||
$this->assertEquals('pc', $payment->payment_device);
|
||
}
|
||
|
||
public function test_complete_payment_without_pg_amount_skips_validation(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// $pgAmount = null → 금액 검증 생략 (무통장입금 등)
|
||
$result = $this->service->completePayment($order, [
|
||
'transaction_id' => 'manual_confirm_123',
|
||
]);
|
||
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
|
||
}
|
||
|
||
public function test_complete_payment_calls_hooks(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
$beforeCalled = false;
|
||
$afterCalled = false;
|
||
|
||
HookManager::addAction('sirsoft-ecommerce.order.before_payment_complete', function () use (&$beforeCalled) {
|
||
$beforeCalled = true;
|
||
});
|
||
HookManager::addAction('sirsoft-ecommerce.order.after_payment_complete', function () use (&$afterCalled) {
|
||
$afterCalled = true;
|
||
});
|
||
|
||
$this->service->completePayment($order, [], 53000);
|
||
|
||
$this->assertTrue($beforeCalled, 'before_payment_complete 훅이 호출되어야 합니다');
|
||
$this->assertTrue($afterCalled, 'after_payment_complete 훅이 호출되어야 합니다');
|
||
}
|
||
|
||
// ===== completePayment 옵션 상태 동기화 회귀 테스트 (PG 결제완료 후 옵션이 주문대기에 갇히는 버그) =====
|
||
|
||
public function test_complete_payment_syncs_option_status_to_payment_complete(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
$optionA = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
$optionB = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
|
||
$this->service->completePayment($order, [], 53000);
|
||
|
||
// 주문상품옵션도 결제완료로 동기화되어야 화면에 "주문대기"로 갇히지 않는다.
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $optionA->fresh()->option_status);
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $optionB->fresh()->option_status);
|
||
}
|
||
|
||
public function test_complete_payment_does_not_resurrect_cancelled_option(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
$activeOption = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
$cancelledOption = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::CANCELLED,
|
||
]);
|
||
|
||
$this->service->completePayment($order, [], 53000);
|
||
|
||
// 활성 옵션만 결제완료로 전이, 취소 옵션은 보존되어야 한다.
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $activeOption->fresh()->option_status);
|
||
$this->assertEquals(OrderStatusEnum::CANCELLED, $cancelledOption->fresh()->option_status);
|
||
}
|
||
|
||
// ===== failPayment 테스트 =====
|
||
|
||
public function test_fail_payment_cancels_pending_order(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
|
||
|
||
$result = $this->service->failPayment($order, 'PAY_PROCESS_CANCELED', '결제가 취소되었습니다.');
|
||
|
||
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order_status);
|
||
|
||
$meta = $result->order_meta;
|
||
$this->assertEquals('PAY_PROCESS_CANCELED', $meta['payment_failure_code']);
|
||
$this->assertEquals('결제가 취소되었습니다.', $meta['payment_failure_message']);
|
||
$this->assertArrayHasKey('payment_failed_at', $meta);
|
||
}
|
||
|
||
public function test_fail_payment_ignores_non_pending_order(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// 이미 결제 완료된 상태로 변경
|
||
$order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
|
||
$order->refresh();
|
||
|
||
$result = $this->service->failPayment($order, 'SOME_ERROR', '에러');
|
||
|
||
// 상태 변경 없이 원래 주문이 반환되어야 함
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
|
||
}
|
||
|
||
public function test_fail_payment_calls_payment_failed_hook(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
$hookCalled = false;
|
||
$capturedCode = null;
|
||
|
||
HookManager::addAction('sirsoft-ecommerce.order.payment_failed', function ($o, $code, $message) use (&$hookCalled, &$capturedCode) {
|
||
$hookCalled = true;
|
||
$capturedCode = $code;
|
||
});
|
||
|
||
$this->service->failPayment($order, 'USER_CANCEL', '사용자 취소');
|
||
|
||
$this->assertTrue($hookCalled, 'payment_failed 훅이 호출되어야 합니다');
|
||
$this->assertEquals('USER_CANCEL', $capturedCode);
|
||
}
|
||
|
||
/**
|
||
* 결제 실패 시 옵션 상태도 CANCELLED 로 동기화 (B1 회귀)
|
||
*
|
||
* 기존: order_status 만 CANCELLED 로 바꾸고 option_status 는 PENDING_ORDER 에 잔존 →
|
||
* order_status=CANCELLED ↔ option_status=PENDING_ORDER 불일치.
|
||
*
|
||
* @scenario terminal_path=fail_payment, payment_method=dbank, option_mix=all_active, actor=member
|
||
*
|
||
* @effects fail_payment_syncs_option_status_to_cancelled
|
||
*/
|
||
public function test_fail_payment_syncs_option_status_to_cancelled(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
$optionA = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
$optionB = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
|
||
$this->service->failPayment($order, 'USER_CANCEL', '사용자 취소');
|
||
|
||
// 결제 실패로 주문이 취소되면 옵션도 취소 상태로 동기화되어야 한다.
|
||
$this->assertEquals(OrderStatusEnum::CANCELLED, $optionA->fresh()->option_status);
|
||
$this->assertEquals(OrderStatusEnum::CANCELLED, $optionB->fresh()->option_status);
|
||
}
|
||
|
||
/**
|
||
* 결제 실패 동기화가 이미 부분취소된 옵션을 되살리지 않음 (B1 syncExcludedValues)
|
||
*
|
||
* @scenario terminal_path=fail_payment, payment_method=dbank, option_mix=has_cancelled, actor=member
|
||
*
|
||
* @effects fail_payment_preserves_already_cancelled_options
|
||
*/
|
||
public function test_fail_payment_preserves_already_cancelled_options(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
$activeOption = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
// 이미 취소된 옵션(부분취소로 일부 옵션이 cancelled 된 주문) — failPayment 가 보존해야 한다.
|
||
$cancelledOption = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::CANCELLED,
|
||
]);
|
||
|
||
$this->service->failPayment($order, 'USER_CANCEL', '사용자 취소');
|
||
|
||
// 활성 옵션만 CANCELLED 로 전이, 이미 취소된 옵션은 보존(syncExcludedValues).
|
||
$this->assertEquals(OrderStatusEnum::CANCELLED, $activeOption->fresh()->option_status);
|
||
$this->assertEquals(OrderStatusEnum::CANCELLED, $cancelledOption->fresh()->option_status);
|
||
}
|
||
|
||
/**
|
||
* 이미 결제 완료(payment_status=PAID)된 주문은 failPayment 가 무시한다 (race 회귀).
|
||
*
|
||
* 배경: 카드(PG) 주문은 승인 직전까지 order_status=PENDING_ORDER 이다. PG 승인 콜백
|
||
* (completePayment)과 결제창 닫힘 보고(failPayment)가 거의 동시에 도착하면, failPayment 가
|
||
* order_status 만 보는 기존 가드(isBeforePayment)를 통과해 옵션을 CANCELLED 로 덮을 수 있다.
|
||
* 이후 order_status 는 completePayment 가 PAYMENT_COMPLETE 로 덮지만, 옵션은 syncExcludedValues
|
||
* 보호로 되살아나지 못해 "주문=결제완료 / 옵션=취소" 모순이 고착된다.
|
||
* → payment_status=PAID 면 결제는 이미 성공한 것이므로 failPayment 를 무시해야 한다.
|
||
*
|
||
* @scenario terminal_path=fail_payment, payment_method=card, payment_status=paid, option_mix=all_active, actor=member
|
||
*
|
||
* @effects fail_payment_ignored_when_payment_already_paid
|
||
*/
|
||
public function test_fail_payment_ignores_order_when_payment_already_paid(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
// 승인 콜백이 payment 를 먼저 PAID 로 갱신한 race 상태 재현 (order_status 는 아직 PENDING_ORDER).
|
||
$order->payment->update(['payment_status' => PaymentStatusEnum::PAID]);
|
||
$order->refresh();
|
||
|
||
$optionA = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
$optionB = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
|
||
$result = $this->service->failPayment($order, 'USER_CANCEL', '결제창 닫힘');
|
||
|
||
// 결제가 이미 성공했으므로 주문/옵션 모두 취소되지 않아야 한다.
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $result->order_status);
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $optionA->fresh()->option_status);
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $optionB->fresh()->option_status);
|
||
}
|
||
|
||
/**
|
||
* payment_status=PAID 가드는 결제실패 메타도 기록하지 않는다 (race 회귀).
|
||
*
|
||
* @scenario terminal_path=fail_payment, payment_method=card, payment_status=paid, option_mix=all_active, actor=member
|
||
*
|
||
* @effects fail_payment_ignored_when_payment_already_paid
|
||
*/
|
||
public function test_fail_payment_does_not_record_failure_meta_when_paid(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
$order->payment->update(['payment_status' => PaymentStatusEnum::PAID]);
|
||
$order->refresh();
|
||
|
||
$result = $this->service->failPayment($order, 'USER_CANCEL', '결제창 닫힘');
|
||
|
||
$meta = $result->order_meta ?? [];
|
||
$this->assertArrayNotHasKey('payment_failure_code', $meta);
|
||
$this->assertArrayNotHasKey('payment_failed_at', $meta);
|
||
}
|
||
|
||
// ===== confirmManualDeposit (무통장 입금확인) 테스트 (B2) =====
|
||
|
||
/**
|
||
* 무통장 미결제 주문 생성 (입금확인 테스트용)
|
||
*/
|
||
private function createDbankOrder(int $subtotal = 50000, int $shipping = 3000): Order
|
||
{
|
||
$order = $this->createOrderWithPayment($subtotal, $shipping, 0);
|
||
$order->update(['order_status' => OrderStatusEnum::PENDING_PAYMENT]);
|
||
$order->payment->update(['payment_method' => PaymentMethodEnum::DBANK]);
|
||
|
||
return $order->fresh();
|
||
}
|
||
|
||
/**
|
||
* 무통장 입금확인 → completePayment 위임으로 옵션상태 결제완료 동기화 (B2)
|
||
*
|
||
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, option_mix=all_active, actor=admin
|
||
*
|
||
* @effects manual_deposit_confirm_syncs_option_status_via_complete_payment, manual_deposit_confirm_records_depositor_name
|
||
*/
|
||
public function test_confirm_manual_deposit_syncs_option_status_and_records_depositor(): void
|
||
{
|
||
$order = $this->createDbankOrder(50000, 3000);
|
||
$option = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
|
||
// markOrderComplete=true: 입금 기록 + 결제완료 전이
|
||
$result = $this->service->confirmManualDeposit($order, 53000, '홍길동', true);
|
||
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->fresh()->order_status);
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $option->fresh()->option_status);
|
||
$this->assertEquals('홍길동', $order->fresh()->payment->depositor_name);
|
||
$this->assertEquals(PaymentStatusEnum::PAID, $order->fresh()->payment->payment_status);
|
||
}
|
||
|
||
/**
|
||
* 무통장 입금확인 — markOrderComplete=false: 입금(payment)만 기록, 주문 상태는 전이 안 함
|
||
*
|
||
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, actor=admin
|
||
*
|
||
* @effects manual_deposit_records_payment_only_without_order_transition
|
||
*/
|
||
public function test_confirm_manual_deposit_records_payment_only_when_not_marking_complete(): void
|
||
{
|
||
$order = $this->createDbankOrder(50000, 3000);
|
||
$option = OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
|
||
$result = $this->service->confirmManualDeposit($order, 53000, '홍길동', false);
|
||
|
||
// 결제 레코드는 입금완료로 기록
|
||
$this->assertEquals(PaymentStatusEnum::PAID, $result->payment->payment_status);
|
||
$this->assertNotNull($result->payment->paid_at);
|
||
$this->assertEquals('홍길동', $result->payment->depositor_name);
|
||
|
||
// 주문/옵션 상태는 전이되지 않음 (결제완료 부수효과 미발생)
|
||
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $option->fresh()->option_status);
|
||
}
|
||
|
||
/**
|
||
* 무통장 입금확인 — 주문은 이미 결제완료(order_status)인데 payment 가 미입금(ready)인
|
||
* 불일치 주문: payment 만 PAID 로 정합화하고 결제완료 부수효과는 재실행하지 않는다.
|
||
*
|
||
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, actor=admin
|
||
*
|
||
* @effects manual_deposit_reconciles_payment_only_for_already_completed_order
|
||
*/
|
||
public function test_confirm_manual_deposit_reconciles_payment_for_already_completed_order(): void
|
||
{
|
||
$order = $this->createDbankOrder(50000, 3000);
|
||
// 관리자가 주문 상태만 결제완료로 바꿔 둔 불일치 상황 (payment 는 여전히 ready)
|
||
$order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
|
||
$this->assertEquals(PaymentStatusEnum::READY, $order->fresh()->payment->payment_status);
|
||
|
||
// markOrderComplete=true 여도 이미 결제완료이므로 completePayment 전이는 건너뛰고 payment 만 정합
|
||
$result = $this->service->confirmManualDeposit($order, 53000, '홍길동', true);
|
||
|
||
$this->assertEquals(PaymentStatusEnum::PAID, $result->payment->payment_status);
|
||
$this->assertNotNull($result->payment->paid_at);
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
|
||
}
|
||
|
||
/**
|
||
* 무통장 입금확인: 입금액이 결제예정금액과 불일치하면 422(PaymentAmountMismatchException) (B2)
|
||
*
|
||
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, option_mix=all_active, actor=admin
|
||
*
|
||
* @effects manual_deposit_confirm_rejects_amount_mismatch_422
|
||
*/
|
||
public function test_confirm_manual_deposit_rejects_amount_mismatch(): void
|
||
{
|
||
$order = $this->createDbankOrder(50000, 3000);
|
||
OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
|
||
$this->expectException(PaymentAmountMismatchException::class);
|
||
$this->service->confirmManualDeposit($order, 10000, '홍길동');
|
||
|
||
// 결제완료로 전이되지 않아야 한다 (예외 전 검증 단계에서 차단)
|
||
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->fresh()->order_status);
|
||
}
|
||
|
||
/**
|
||
* 무통장 입금확인 진입부에서 IDV 가드 훅(payment.before_confirm_deposit)이 발화 (A8/B2)
|
||
*
|
||
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, option_mix=all_active, actor=admin
|
||
*
|
||
* @effects manual_deposit_confirm_syncs_option_status_via_complete_payment
|
||
*/
|
||
public function test_confirm_manual_deposit_fires_before_confirm_deposit_hook(): void
|
||
{
|
||
$order = $this->createDbankOrder(50000, 3000);
|
||
OrderOption::factory()->create([
|
||
'order_id' => $order->id,
|
||
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
|
||
$hookFired = false;
|
||
HookManager::addAction('sirsoft-ecommerce.payment.before_confirm_deposit', function () use (&$hookFired) {
|
||
$hookFired = true;
|
||
});
|
||
|
||
$this->service->confirmManualDeposit($order, 53000, '홍길동');
|
||
|
||
$this->assertTrue($hookFired, 'payment.before_confirm_deposit 훅이 발화되어야 합니다');
|
||
}
|
||
|
||
// ===== findByOrderNumber 테스트 =====
|
||
|
||
// ===== recordPaymentCancellation 테스트 =====
|
||
|
||
public function test_record_payment_cancellation_updates_payment_status(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
$result = $this->service->recordPaymentCancellation($order);
|
||
|
||
$payment = $result->payment;
|
||
$payment->refresh();
|
||
|
||
$this->assertEquals(PaymentStatusEnum::CANCELLED->value, $payment->payment_status->value);
|
||
$this->assertNotNull($payment->cancelled_at);
|
||
}
|
||
|
||
public function test_record_payment_cancellation_appends_cancel_history(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
$result = $this->service->recordPaymentCancellation($order, 'USER_CANCEL', '사용자가 결제를 취소했습니다.');
|
||
|
||
$payment = $result->payment;
|
||
$payment->refresh();
|
||
|
||
$cancelHistory = $payment->cancel_history;
|
||
$this->assertIsArray($cancelHistory);
|
||
$this->assertCount(1, $cancelHistory);
|
||
$this->assertEquals('USER_CANCEL', $cancelHistory[0]['cancel_code']);
|
||
$this->assertEquals('사용자가 결제를 취소했습니다.', $cancelHistory[0]['cancel_message']);
|
||
$this->assertArrayHasKey('cancelled_at', $cancelHistory[0]);
|
||
}
|
||
|
||
public function test_record_payment_cancellation_appends_to_existing_history(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// 1차 취소
|
||
$this->service->recordPaymentCancellation($order);
|
||
// 2차 취소
|
||
$result = $this->service->recordPaymentCancellation($order->fresh());
|
||
|
||
$payment = $result->payment;
|
||
$payment->refresh();
|
||
|
||
$cancelHistory = $payment->cancel_history;
|
||
$this->assertCount(2, $cancelHistory);
|
||
}
|
||
|
||
public function test_record_payment_cancellation_returns_order_when_no_payment(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$order = Order::factory()->create([
|
||
'user_id' => $user->id,
|
||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||
]);
|
||
|
||
// payment 없이 호출 → 오류 없이 반환
|
||
$result = $this->service->recordPaymentCancellation($order);
|
||
$this->assertEquals($order->id, $result->id);
|
||
}
|
||
|
||
// ===== completePayment 임시주문 정리 테스트 =====
|
||
|
||
public function test_complete_payment_deletes_temp_order(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// 임시주문 생성
|
||
$user = $order->user;
|
||
$tempOrder = TempOrder::create([
|
||
'user_id' => $user->id,
|
||
'cart_key' => 'test-cart-key',
|
||
'items' => [],
|
||
'calculation_result' => ['summary' => ['final_amount' => 53000]],
|
||
'expires_at' => now()->addMinutes(30),
|
||
]);
|
||
|
||
$this->assertDatabaseHas('ecommerce_temp_orders', ['id' => $tempOrder->id]);
|
||
|
||
$this->service->completePayment($order, [
|
||
'transaction_id' => 'test_tx_123',
|
||
], 53000);
|
||
|
||
// completePayment 후 임시주문 삭제 확인
|
||
$this->assertDatabaseMissing('ecommerce_temp_orders', ['id' => $tempOrder->id]);
|
||
}
|
||
|
||
// ===== findByOrderNumber 테스트 =====
|
||
|
||
public function test_find_by_order_number_returns_order(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
$found = $this->service->findByOrderNumber($order->order_number);
|
||
|
||
$this->assertNotNull($found);
|
||
$this->assertEquals($order->id, $found->id);
|
||
}
|
||
|
||
public function test_find_by_order_number_returns_null_for_non_existent(): void
|
||
{
|
||
$found = $this->service->findByOrderNumber('NON_EXISTENT_ORDER_NUMBER');
|
||
|
||
$this->assertNull($found);
|
||
}
|
||
|
||
// ===== 재고 차감 타이밍 (stock_deduction_timing) 테스트 =====
|
||
|
||
/**
|
||
* StockService와 EcommerceSettingsService를 mock하여 재고 차감 타이밍을 제어합니다.
|
||
*
|
||
* @param string $timing 재고 차감 타이밍 ('order_placed', 'payment_complete', 'none')
|
||
* @return MockObject StockService mock
|
||
*/
|
||
protected function mockStockAndSettingsForTiming(string $timing): MockObject
|
||
{
|
||
$settingsMock = $this->createMock(EcommerceSettingsService::class);
|
||
$settingsMock->method('getStockDeductionTiming')->willReturn($timing);
|
||
$this->app->instance(EcommerceSettingsService::class, $settingsMock);
|
||
|
||
$stockMock = $this->createMock(StockService::class);
|
||
$this->app->instance(StockService::class, $stockMock);
|
||
|
||
return $stockMock;
|
||
}
|
||
|
||
public function test_create_from_temp_order_deducts_stock_for_order_placed_timing(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
|
||
// StockService mock: deductStock이 정확히 1회 호출되어야 함
|
||
$stockMock = $this->mockStockAndSettingsForTiming('order_placed');
|
||
$stockMock->expects($this->once())->method('deductStock');
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'dbank',
|
||
103000
|
||
);
|
||
}
|
||
|
||
public function test_create_from_temp_order_does_not_deduct_stock_for_payment_complete_timing(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
|
||
// StockService mock: deductStock이 호출되지 않아야 함
|
||
$stockMock = $this->mockStockAndSettingsForTiming('payment_complete');
|
||
$stockMock->expects($this->never())->method('deductStock');
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
}
|
||
|
||
public function test_create_from_temp_order_does_not_deduct_stock_for_none_timing(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
|
||
// StockService mock: deductStock이 호출되지 않아야 함
|
||
$stockMock = $this->mockStockAndSettingsForTiming('none');
|
||
$stockMock->expects($this->never())->method('deductStock');
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'dbank',
|
||
103000
|
||
);
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 관리자 신규주문 알림 발송 시점 (order.after_admin_notify)
|
||
// 카드(PG): 결제완료 시점 / 무통장(비-PG): 주문 생성 시점
|
||
// ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* order.after_admin_notify 발화 횟수를 카운트하는 리스너를 등록합니다.
|
||
*
|
||
* @return \Closure 발화 횟수를 반환하는 getter
|
||
*/
|
||
private function countAdminNotify(): \Closure
|
||
{
|
||
$count = 0;
|
||
HookManager::addAction('sirsoft-ecommerce.order.after_admin_notify', function () use (&$count) {
|
||
$count++;
|
||
}, 99);
|
||
|
||
return function () use (&$count) {
|
||
return $count;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 카드 결제수단에 PG 공급자가 설정된 환경을 시뮬레이션합니다.
|
||
*
|
||
* orderRequiresPgPayment 는 determinePgProvider → SettingsService 조회 결과로 PG 필요 여부를
|
||
* 판정한다. 테스트 환경엔 활성 PG 플러그인이 없어 'none' 이 되므로, 설정을 목킹해 'kginicis' 를
|
||
* 반환하게 한다. mockCalculationService 호출 전에 설정 인스턴스를 바인딩해야 한다.
|
||
*/
|
||
private function mockCardPgProvider(): void
|
||
{
|
||
$settingsMock = $this->createMock(EcommerceSettingsService::class);
|
||
$settingsMock->method('getPaymentMethodConfig')->willReturn(['pg_provider' => 'kginicis']);
|
||
$settingsMock->method('getSetting')->willReturnCallback(
|
||
fn ($key, $default = null) => $key === 'order_settings.default_pg_provider' ? 'kginicis' : $default
|
||
);
|
||
$settingsMock->method('getStockDeductionTiming')->willReturn('payment_complete');
|
||
$settingsMock->method('getMileageDeductionTiming')->willReturn('payment_complete');
|
||
$this->app->instance(EcommerceSettingsService::class, $settingsMock);
|
||
}
|
||
|
||
public function test_card_order_does_not_fire_admin_notify_at_creation(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
$this->mockCardPgProvider();
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$getCount = $this->countAdminNotify();
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
103000
|
||
);
|
||
|
||
// 카드(PG) 주문은 생성 시점(pending_order)에 관리자 알림을 발화하지 않는다.
|
||
$this->assertSame(0, $getCount());
|
||
}
|
||
|
||
public function test_dbank_order_fires_admin_notify_at_creation(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->createTestTempOrder($user);
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$getCount = $this->countAdminNotify();
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
|
||
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'dbank',
|
||
103000
|
||
);
|
||
|
||
// 무통장(비-PG) 주문은 생성 시점(입금 전이라도)에 관리자 알림을 발화한다.
|
||
$this->assertSame(1, $getCount());
|
||
}
|
||
|
||
public function test_card_order_fires_admin_notify_on_payment_complete(): void
|
||
{
|
||
// 일관된 카드 주문 fixture(total_due=53000) — completePayment 금액 재검증 통과
|
||
$order = $this->createOrderWithPayment();
|
||
|
||
$getCount = $this->countAdminNotify();
|
||
|
||
// 결제완료 시점에 1회 발화 (PG 콜백 실청구액 = 주문 결제예정액)
|
||
$this->service->completePayment($order, ['transaction_id' => 'TX-TEST'], (int) $order->total_due_amount);
|
||
|
||
$this->assertSame(1, $getCount());
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 미지원 결제 통화 차단 (assertPaymentCurrencyChargeable)
|
||
// ──────────────────────────────────────────────
|
||
|
||
public function test_assert_payment_currency_throws_for_zero_rate_currency(): void
|
||
{
|
||
$method = new \ReflectionMethod($this->service, 'assertPaymentCurrencyChargeable');
|
||
$method->setAccessible(true);
|
||
|
||
$snapshot = [
|
||
'base_currency' => 'USD',
|
||
'order_currency' => 'CNY',
|
||
'exchange_rates' => [
|
||
'USD' => ['rate' => 1, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
|
||
'CNY' => ['rate' => 0, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
|
||
],
|
||
];
|
||
|
||
$this->expectException(UnsupportedPaymentCurrencyException::class);
|
||
$method->invoke($this->service, $snapshot, 6.0);
|
||
}
|
||
|
||
public function test_assert_payment_currency_throws_when_converted_amount_is_zero(): void
|
||
{
|
||
$method = new \ReflectionMethod($this->service, 'assertPaymentCurrencyChargeable');
|
||
$method->setAccessible(true);
|
||
|
||
// base $0.0001 × JPY 환율 → 환산 후 0 (PG 최소금액 미만)
|
||
$snapshot = [
|
||
'base_currency' => 'USD',
|
||
'order_currency' => 'JPY',
|
||
'exchange_rates' => [
|
||
'USD' => ['rate' => 1, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
|
||
'JPY' => ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0],
|
||
],
|
||
];
|
||
|
||
$this->expectException(UnsupportedPaymentCurrencyException::class);
|
||
$method->invoke($this->service, $snapshot, 0.0001);
|
||
}
|
||
|
||
public function test_assert_payment_currency_passes_for_valid_currency(): void
|
||
{
|
||
$method = new \ReflectionMethod($this->service, 'assertPaymentCurrencyChargeable');
|
||
$method->setAccessible(true);
|
||
|
||
$snapshot = [
|
||
'base_currency' => 'USD',
|
||
'order_currency' => 'KRW',
|
||
'exchange_rates' => [
|
||
'KRW' => ['rate' => 1176470, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0],
|
||
'USD' => ['rate' => 1, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
|
||
],
|
||
];
|
||
|
||
// 예외 없이 통과
|
||
$method->invoke($this->service, $snapshot, 6.0);
|
||
$this->assertTrue(true);
|
||
}
|
||
|
||
public function test_complete_payment_deducts_stock_for_payment_complete_timing(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// StockService mock: deductStock이 정확히 1회 호출되어야 함
|
||
$stockMock = $this->mockStockAndSettingsForTiming('payment_complete');
|
||
$stockMock->expects($this->once())->method('deductStock');
|
||
$this->service = app(OrderProcessingService::class);
|
||
|
||
$this->service->completePayment($order, [
|
||
'transaction_id' => 'test_tx_stock',
|
||
], 53000);
|
||
}
|
||
|
||
public function test_complete_payment_does_not_deduct_stock_for_order_placed_timing(): void
|
||
{
|
||
$order = $this->createOrderWithPayment(50000, 3000, 0);
|
||
|
||
// StockService mock: deductStock이 호출되지 않아야 함 (이미 주문 시 차감됨)
|
||
$stockMock = $this->mockStockAndSettingsForTiming('order_placed');
|
||
$stockMock->expects($this->never())->method('deductStock');
|
||
$this->service = app(OrderProcessingService::class);
|
||
|
||
$this->service->completePayment($order, [
|
||
'transaction_id' => 'test_tx_no_stock',
|
||
], 53000);
|
||
}
|
||
|
||
// ===== completePayment 배송지 자동 저장 테스트 =====
|
||
|
||
public function test_complete_payment_saves_shipping_address_when_meta_flag_set(): void
|
||
{
|
||
$order = $this->createOrderWithPayment();
|
||
$order->update([
|
||
'order_meta' => [
|
||
'save_shipping_address' => true,
|
||
'shipping_info_for_save' => [
|
||
'recipient_name' => '김철수',
|
||
'recipient_phone' => '010-9876-5432',
|
||
'country_code' => 'KR',
|
||
'zipcode' => '12345',
|
||
'address' => '서울시 강남구 테헤란로 123',
|
||
'address_detail' => '101동 1001호',
|
||
],
|
||
],
|
||
]);
|
||
|
||
$this->service->completePayment($order, [
|
||
'transaction_id' => 'test_tx_addr_save',
|
||
], 53000);
|
||
|
||
$this->assertDatabaseHas('ecommerce_user_addresses', [
|
||
'user_id' => $order->user_id,
|
||
'recipient_name' => '김철수',
|
||
'zipcode' => '12345',
|
||
]);
|
||
|
||
// order_meta에서 플래그 제거 확인
|
||
$order->refresh();
|
||
$this->assertFalse($order->order_meta['save_shipping_address'] ?? false);
|
||
$this->assertArrayNotHasKey('shipping_info_for_save', $order->order_meta ?? []);
|
||
}
|
||
|
||
public function test_complete_payment_does_not_save_address_when_no_meta_flag(): void
|
||
{
|
||
$order = $this->createOrderWithPayment();
|
||
|
||
$this->service->completePayment($order, [
|
||
'transaction_id' => 'test_tx_no_flag',
|
||
], 53000);
|
||
|
||
$this->assertDatabaseMissing('ecommerce_user_addresses', [
|
||
'user_id' => $order->user_id,
|
||
]);
|
||
}
|
||
|
||
public function test_complete_payment_does_not_save_address_for_guest_order(): void
|
||
{
|
||
$order = $this->createOrderWithPayment();
|
||
// user_id를 null로 변경 (비회원 주문 시뮬레이션)
|
||
Order::withoutEvents(function () use ($order) {
|
||
$order->update([
|
||
'user_id' => null,
|
||
'order_meta' => [
|
||
'save_shipping_address' => true,
|
||
'shipping_info_for_save' => [
|
||
'recipient_name' => '비회원',
|
||
'recipient_phone' => '010-0000-0000',
|
||
'zipcode' => '99999',
|
||
'address' => '서울시',
|
||
],
|
||
],
|
||
]);
|
||
});
|
||
$order->refresh();
|
||
|
||
$this->service->completePayment($order, [
|
||
'transaction_id' => 'test_tx_guest',
|
||
], 53000);
|
||
|
||
$this->assertEquals(0, UserAddress::count());
|
||
}
|
||
|
||
public function test_complete_payment_succeeds_even_if_address_save_fails(): void
|
||
{
|
||
$order = $this->createOrderWithPayment();
|
||
$order->update([
|
||
'order_meta' => [
|
||
'save_shipping_address' => true,
|
||
'shipping_info_for_save' => [], // 빈 데이터 (저장 시 예외 가능)
|
||
],
|
||
]);
|
||
|
||
// 결제 완료는 정상 처리되어야 함 (예외 미전파)
|
||
$result = $this->service->completePayment($order, [
|
||
'transaction_id' => 'test_tx_addr_fail',
|
||
], 53000);
|
||
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
|
||
}
|
||
|
||
// ===== 장바구니 처리 테스트 =====
|
||
|
||
/**
|
||
* 테스트용 Cart 레코드를 생성합니다.
|
||
*
|
||
* @param int $userId 사용자 ID
|
||
* @param int $quantity 수량
|
||
* @param Product|null $product 상품 (null이면 새로 생성)
|
||
* @param ProductOption|null $option 옵션 (null이면 새로 생성)
|
||
*/
|
||
protected function createTestCart(
|
||
int $userId,
|
||
int $quantity = 1,
|
||
?Product $product = null,
|
||
?ProductOption $option = null
|
||
): Cart {
|
||
$product = $product ?? Product::factory()->create();
|
||
$option = $option ?? ProductOption::factory()->create(['product_id' => $product->id]);
|
||
|
||
return Cart::create([
|
||
'user_id' => $userId,
|
||
'product_id' => $product->id,
|
||
'product_option_id' => $option->id,
|
||
'quantity' => $quantity,
|
||
]);
|
||
}
|
||
|
||
public function test_build_order_meta에_cart_items_포함(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$product = Product::factory()->create();
|
||
$option1 = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
$option2 = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
['cart_id' => 10, 'product_id' => $product->id, 'product_option_id' => $option1->id, 'quantity' => 3],
|
||
['cart_id' => 20, 'product_id' => $product->id, 'product_option_id' => $option2->id, 'quantity' => 1],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 50000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('buildOrderMeta');
|
||
$method->setAccessible(true);
|
||
|
||
$meta = $method->invoke($this->service, $tempOrder);
|
||
|
||
$this->assertArrayHasKey('cart_items', $meta);
|
||
$this->assertCount(2, $meta['cart_items']);
|
||
$this->assertEquals(10, $meta['cart_items'][0]['cart_id']);
|
||
$this->assertEquals(3, $meta['cart_items'][0]['quantity']);
|
||
$this->assertEquals(20, $meta['cart_items'][1]['cart_id']);
|
||
$this->assertEquals(1, $meta['cart_items'][1]['quantity']);
|
||
}
|
||
|
||
public function test_build_order_meta_cart_id_없는_아이템은_제외(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$product = Product::factory()->create();
|
||
$option = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
['product_id' => $product->id, 'product_option_id' => $option->id, 'quantity' => 1],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 50000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('buildOrderMeta');
|
||
$method->setAccessible(true);
|
||
|
||
$meta = $method->invoke($this->service, $tempOrder);
|
||
|
||
$this->assertArrayHasKey('cart_items', $meta);
|
||
$this->assertEmpty($meta['cart_items']);
|
||
}
|
||
|
||
public function test_clear_ordered_cart_items_장바구니_수량과_주문_수량_같으면_삭제(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$cart = $this->createTestCart($user->id, 3);
|
||
|
||
$order = Order::factory()->create([
|
||
'user_id' => $user->id,
|
||
'order_meta' => ['cart_items' => [['cart_id' => $cart->id, 'quantity' => 3]]],
|
||
]);
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('clearOrderedCartItems');
|
||
$method->setAccessible(true);
|
||
$method->invoke($this->service, $order);
|
||
|
||
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart->id]);
|
||
}
|
||
|
||
public function test_clear_ordered_cart_items_장바구니_수량이_주문보다_크면_차감(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$cart = $this->createTestCart($user->id, 5);
|
||
|
||
$order = Order::factory()->create([
|
||
'user_id' => $user->id,
|
||
'order_meta' => ['cart_items' => [['cart_id' => $cart->id, 'quantity' => 3]]],
|
||
]);
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('clearOrderedCartItems');
|
||
$method->setAccessible(true);
|
||
$method->invoke($this->service, $order);
|
||
|
||
$this->assertDatabaseHas('ecommerce_carts', ['id' => $cart->id, 'quantity' => 2]);
|
||
}
|
||
|
||
public function test_clear_ordered_cart_items_장바구니_수량이_주문보다_작으면_삭제(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$cart = $this->createTestCart($user->id, 2);
|
||
|
||
$order = Order::factory()->create([
|
||
'user_id' => $user->id,
|
||
'order_meta' => ['cart_items' => [['cart_id' => $cart->id, 'quantity' => 3]]],
|
||
]);
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('clearOrderedCartItems');
|
||
$method->setAccessible(true);
|
||
$method->invoke($this->service, $order);
|
||
|
||
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart->id]);
|
||
}
|
||
|
||
public function test_clear_ordered_cart_items_cart_items_비어있으면_스킵(): void
|
||
{
|
||
$order = Order::factory()->create([
|
||
'order_meta' => ['cart_items' => []],
|
||
]);
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('clearOrderedCartItems');
|
||
$method->setAccessible(true);
|
||
$method->invoke($this->service, $order);
|
||
|
||
// 예외 없이 정상 완료
|
||
$this->assertTrue(true);
|
||
}
|
||
|
||
public function test_clear_ordered_cart_items_이미_삭제된_장바구니는_무시(): void
|
||
{
|
||
$order = Order::factory()->create([
|
||
'order_meta' => ['cart_items' => [['cart_id' => 999999, 'quantity' => 1]]],
|
||
]);
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('clearOrderedCartItems');
|
||
$method->setAccessible(true);
|
||
$method->invoke($this->service, $order);
|
||
|
||
// 존재하지 않는 cart_id여도 예외 없이 정상 완료 (멱등성)
|
||
$this->assertTrue(true);
|
||
}
|
||
|
||
public function test_clear_ordered_cart_items_복수_아이템_혼합_처리(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$product = Product::factory()->create();
|
||
$option1 = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
$option2 = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
$option3 = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
|
||
$cart1 = $this->createTestCart($user->id, 5, $product, $option1);
|
||
$cart2 = $this->createTestCart($user->id, 3, $product, $option2);
|
||
$cart3 = $this->createTestCart($user->id, 1, $product, $option3);
|
||
|
||
$order = Order::factory()->create([
|
||
'user_id' => $user->id,
|
||
'order_meta' => ['cart_items' => [
|
||
['cart_id' => $cart1->id, 'quantity' => 2],
|
||
['cart_id' => $cart2->id, 'quantity' => 3],
|
||
['cart_id' => $cart3->id, 'quantity' => 5],
|
||
]],
|
||
]);
|
||
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('clearOrderedCartItems');
|
||
$method->setAccessible(true);
|
||
$method->invoke($this->service, $order);
|
||
|
||
// cart1: 수량 5, 주문 2 → 잔여 3
|
||
$this->assertDatabaseHas('ecommerce_carts', ['id' => $cart1->id, 'quantity' => 3]);
|
||
// cart2: 수량 3, 주문 3 → 삭제
|
||
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart2->id]);
|
||
// cart3: 수량 1, 주문 5 → 삭제
|
||
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart3->id]);
|
||
}
|
||
|
||
public function test_order_placed_타이밍에서_장바구니_아이템_삭제(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$product = Product::factory()->create();
|
||
$option = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
|
||
$cart = $this->createTestCart($user->id, 2, $product, $option);
|
||
|
||
// order_placed 타이밍 설정
|
||
$settingsMock = $this->createMock(EcommerceSettingsService::class);
|
||
$settingsMock->method('getStockDeductionTiming')->willReturn('order_placed');
|
||
$this->app->instance(EcommerceSettingsService::class, $settingsMock);
|
||
|
||
$stockMock = $this->createMock(StockService::class);
|
||
$stockMock->expects($this->once())->method('deductStock');
|
||
$this->app->instance(StockService::class, $stockMock);
|
||
|
||
$this->service = app(OrderProcessingService::class);
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
['cart_id' => $cart->id, 'product_id' => $product->id, 'product_option_id' => $option->id, 'quantity' => 2],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 103000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
|
||
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '123동'],
|
||
'dbank',
|
||
103000,
|
||
null,
|
||
'홍길동'
|
||
);
|
||
|
||
// order_placed 타이밍 → 재고 차감 + 장바구니 삭제
|
||
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart->id]);
|
||
}
|
||
|
||
public function test_payment_complete_타이밍에서_create_from_temp_order_장바구니_미삭제(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$product = Product::factory()->create();
|
||
$option = ProductOption::factory()->create(['product_id' => $product->id]);
|
||
|
||
$cart = $this->createTestCart($user->id, 2, $product, $option);
|
||
|
||
// payment_complete 타이밍 설정
|
||
$settingsMock = $this->createMock(EcommerceSettingsService::class);
|
||
$settingsMock->method('getStockDeductionTiming')->willReturn('payment_complete');
|
||
$this->app->instance(EcommerceSettingsService::class, $settingsMock);
|
||
|
||
$this->service = app(OrderProcessingService::class);
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
['cart_id' => $cart->id, 'product_id' => $product->id, 'product_option_id' => $option->id, 'quantity' => 2],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 103000],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
|
||
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '123동'],
|
||
'card',
|
||
103000
|
||
);
|
||
|
||
// payment_complete 타이밍 → createFromTempOrder에서는 장바구니 미삭제
|
||
$this->assertDatabaseHas('ecommerce_carts', ['id' => $cart->id]);
|
||
// order_meta에 cart_items 저장 확인 (나중에 completePayment에서 사용)
|
||
$this->assertNotEmpty($order->order_meta['cart_items']);
|
||
}
|
||
|
||
public function test_complete_payment_시_장바구니_아이템_삭제(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$cart = $this->createTestCart($user->id, 2);
|
||
|
||
$order = $this->createOrderWithPayment();
|
||
$order->update([
|
||
'order_meta' => array_merge($order->order_meta ?? [], [
|
||
'cart_items' => [['cart_id' => $cart->id, 'quantity' => 2]],
|
||
]),
|
||
]);
|
||
|
||
$result = $this->service->completePayment($order, [
|
||
'transaction_id' => 'test_tx_cart_clear',
|
||
], $order->total_amount);
|
||
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
|
||
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart->id]);
|
||
}
|
||
|
||
public function test_complete_payment_stores_paid_amount_local_in_order_currency_when_cross_currency(): void
|
||
{
|
||
// base JPY 주문을 KRW 로 결제: paid_amount_local 은 결제 통화(KRW) 환산액이어야 하고,
|
||
// paid_amount_base 는 base(JPY) 진실값이어야 한다. base 값을 그대로 local 에 넣던 회귀를 차단.
|
||
$order = $this->createOrderWithPayment(200, 0, 0); // base JPY 200
|
||
$order->update([
|
||
'currency' => 'KRW',
|
||
'total_due_amount' => 200,
|
||
'currency_snapshot' => [
|
||
'base_currency' => 'JPY',
|
||
'order_currency' => 'KRW',
|
||
'base_unit' => 100,
|
||
'exchange_rates' => [
|
||
'JPY' => ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'round', 'decimal_places' => 0, 'base_unit' => 100],
|
||
'KRW' => ['rate' => 950, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 1000],
|
||
],
|
||
],
|
||
]);
|
||
|
||
// 결제 통화(KRW) 청구액 = (200/100)×950 = 1900
|
||
$result = $this->service->completePayment($order->fresh(), [], 1900);
|
||
|
||
$payment = $result->payment()->first();
|
||
$this->assertSame('KRW', $payment->currency);
|
||
// paid_amount_local = 결제 통화 환산액 1900 (base 200 이 아님)
|
||
$this->assertEqualsWithDelta(1900, (float) $payment->paid_amount_local, 0.001, 'paid_amount_local 은 결제 통화(KRW) 환산액이어야 한다.');
|
||
// paid_amount_base = base 진실값 200
|
||
$this->assertEqualsWithDelta(200, (float) $payment->paid_amount_base, 0.001, 'paid_amount_base 는 base(JPY) 진실값이어야 한다.');
|
||
// mc_total_paid_amount[order_currency] 와 일치
|
||
$this->assertEqualsWithDelta(
|
||
(float) ($result->fresh()->mc_total_paid_amount['KRW'] ?? 0),
|
||
(float) $payment->paid_amount_local,
|
||
0.001,
|
||
'paid_amount_local 은 mc_total_paid_amount[order_currency] 와 정합해야 한다.'
|
||
);
|
||
}
|
||
|
||
// ===== 구매 대상 제한 최종 차단 통합 테스트 =====
|
||
|
||
/**
|
||
* 구매 대상 제한이 걸린 상품을 담은 TempOrder를 생성합니다.
|
||
*
|
||
* @param User $user 사용자
|
||
* @param int $productId 주문 항목의 상품 ID
|
||
* @return TempOrder
|
||
*/
|
||
protected function createRestrictedTempOrder(User $user, int $productId)
|
||
{
|
||
return TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withItems([
|
||
['cart_id' => 1, 'product_id' => $productId, 'product_option_id' => 1, 'quantity' => 1],
|
||
])
|
||
->withCalculationResult([
|
||
'summary' => [
|
||
'subtotal' => 100000, 'total_discount' => 0, 'product_coupon_discount' => 0,
|
||
'code_discount' => 0, 'total_shipping' => 3000, 'final_amount' => 103000,
|
||
'taxable_amount' => 93636, 'tax_free_amount' => 0, 'points_used' => 0,
|
||
],
|
||
'items' => [], 'shippings' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
}
|
||
|
||
/**
|
||
* @return array{0: array, 1: array} 주문자 정보, 배송지 정보
|
||
*/
|
||
protected function ordererAndShipping(): array
|
||
{
|
||
return [
|
||
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
|
||
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구 테헤란로', 'address_detail' => '123동 456호'],
|
||
];
|
||
}
|
||
|
||
public function test_허용_역할_없는_회원은_제한_상품_주문_차단(): void
|
||
{
|
||
$allowedRole = Role::create(['identifier' => 'vip-test', 'name' => ['ko' => 'VIP', 'en' => 'VIP']]);
|
||
|
||
// 사용자는 allowedRole 미보유
|
||
$user = User::factory()->create();
|
||
$this->actingAs($user);
|
||
|
||
$product = Product::factory()->create([
|
||
'purchase_restriction' => 'restricted',
|
||
'allowed_roles' => [$allowedRole->id],
|
||
]);
|
||
|
||
$tempOrder = $this->createRestrictedTempOrder($user, $product->id);
|
||
[$orderer, $shipping] = $this->ordererAndShipping();
|
||
|
||
$this->expectException(CartUnavailableException::class);
|
||
|
||
$this->service->createFromTempOrder($tempOrder, $orderer, $shipping, 'card', 103000);
|
||
}
|
||
|
||
public function test_허용_역할_보유_회원은_제한_상품_주문_가능(): void
|
||
{
|
||
$allowedRole = Role::create(['identifier' => 'vip-test', 'name' => ['ko' => 'VIP', 'en' => 'VIP']]);
|
||
|
||
$user = User::factory()->create();
|
||
$user->roles()->attach($allowedRole->id);
|
||
$this->actingAs($user->fresh());
|
||
|
||
$product = Product::factory()->create([
|
||
'purchase_restriction' => 'restricted',
|
||
'allowed_roles' => [$allowedRole->id],
|
||
]);
|
||
|
||
$tempOrder = $this->createRestrictedTempOrder($user, $product->id);
|
||
[$orderer, $shipping] = $this->ordererAndShipping();
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$order = $this->service->createFromTempOrder($tempOrder, $orderer, $shipping, 'card', 103000);
|
||
|
||
$this->assertInstanceOf(Order::class, $order);
|
||
}
|
||
|
||
public function test_제한_없음_상품은_회원_주문_정상_생성(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$this->actingAs($user);
|
||
|
||
$product = Product::factory()->create([
|
||
'purchase_restriction' => 'none',
|
||
'allowed_roles' => [],
|
||
]);
|
||
|
||
$tempOrder = $this->createRestrictedTempOrder($user, $product->id);
|
||
[$orderer, $shipping] = $this->ordererAndShipping();
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(103000));
|
||
|
||
$order = $this->service->createFromTempOrder($tempOrder, $orderer, $shipping, 'card', 103000);
|
||
|
||
$this->assertInstanceOf(Order::class, $order);
|
||
}
|
||
|
||
/**
|
||
* §3.4 회귀: 옵션 다통화 빌더가 mc_subtotal_earned_points_amount 를 포함해야 한다.
|
||
*
|
||
* 결함: buildOptionMultiCurrency 에 적립값 변환이 빠져 mc_ 적립 컬럼이 항상 NULL.
|
||
*/
|
||
public function test_build_option_multi_currency_includes_earned_points(): void
|
||
{
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('buildOptionMultiCurrency');
|
||
$method->setAccessible(true);
|
||
|
||
$item = (object) [
|
||
'unitPrice' => 10000,
|
||
'subtotal' => 10000,
|
||
'productCouponDiscountAmount' => 0,
|
||
'orderCouponDiscountShare' => 0,
|
||
'codeDiscountAmount' => 0,
|
||
'pointsUsedShare' => 0,
|
||
'pointsEarning' => 500,
|
||
'taxableAmount' => 10000,
|
||
'taxFreeAmount' => 0,
|
||
'finalAmount' => 10000,
|
||
];
|
||
|
||
$snapshot = [
|
||
'base_currency' => 'KRW',
|
||
'exchange_rates' => [
|
||
'KRW' => 1,
|
||
'USD' => ['rate' => 0.75, 'rounding_unit' => '0.01', 'rounding_method' => 'round'],
|
||
],
|
||
];
|
||
|
||
$mc = $method->invoke($this->service, $item, $snapshot);
|
||
|
||
// 적립 다통화 컬럼이 존재하고 적립값(500)이 기준통화에 반영되어야 함
|
||
$this->assertArrayHasKey('mc_subtotal_earned_points_amount', $mc, '적립 다통화 컬럼이 빌더에 포함되어야 합니다.');
|
||
$this->assertSame(500, $mc['mc_subtotal_earned_points_amount']['KRW'] ?? null);
|
||
}
|
||
|
||
// ===== 전액 마일리지 결제 (결제액 0원) — determineInitialStatus / 자동 완료 =====
|
||
|
||
public function test_determine_initial_status_returns_payment_complete_for_zero_amount(): void
|
||
{
|
||
$reflection = new \ReflectionClass($this->service);
|
||
$method = $reflection->getMethod('determineInitialStatus');
|
||
$method->setAccessible(true);
|
||
|
||
// 결제수단 무관(card/vbank/dbank) — finalAmount 0 이면 결제완료
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $method->invoke($this->service, 'card', 0));
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $method->invoke($this->service, 'vbank', 0));
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $method->invoke($this->service, 'dbank', 0));
|
||
|
||
// finalAmount > 0 이면 기존 분기 유지
|
||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $method->invoke($this->service, 'card', 50000));
|
||
$this->assertEquals(OrderStatusEnum::PENDING_PAYMENT, $method->invoke($this->service, 'vbank', 50000));
|
||
}
|
||
|
||
public function test_create_from_temp_order_full_mileage_payment_marks_payment_complete(): void
|
||
{
|
||
$this->allowAnyMileageUsage();
|
||
|
||
$user = User::factory()->create();
|
||
|
||
// 결제액 전액(100,000)을 마일리지로 충당 — FIFO 차감용 잔액 시드
|
||
MileageTransaction::create([
|
||
'user_id' => $user->id,
|
||
'currency' => 'KRW',
|
||
'type' => 'purchase_earn',
|
||
'amount' => 100000,
|
||
'remaining_amount' => 100000,
|
||
'balance_after' => 100000,
|
||
]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withUsePoints(100000)
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 0],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
// 재계산: payment_amount 100,000 전액 마일리지 사용 → final_amount 0
|
||
$this->mockCalculationService($this->makeCalculationResult(0, [
|
||
'subtotal' => 100000,
|
||
'total_shipping' => 0,
|
||
'taxable_amount' => 100000,
|
||
'payment_amount' => 100000,
|
||
'points_used' => 100000,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => '전액마일', 'phone' => '010-0000-0000', 'email' => 'allmileage@test.com'],
|
||
['recipient_name' => '전액마일', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
|
||
'card',
|
||
0
|
||
);
|
||
|
||
$order->refresh()->load('payment');
|
||
|
||
// 결제액 0 → 즉시 결제완료
|
||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status, '전액 마일리지 결제는 결제완료 상태여야 합니다.');
|
||
$this->assertNotNull($order->paid_at, '결제완료 시각이 기록되어야 합니다.');
|
||
$this->assertEquals(0, (int) $order->total_due_amount, '미결제 잔액(입금 필요액)은 0이어야 합니다.');
|
||
$this->assertEquals(0, (int) $order->total_paid_amount, 'PG/현금 결제액은 0이어야 합니다(전액 마일리지).');
|
||
$this->assertEquals(100000, (int) $order->total_points_used_amount, '마일리지 사용액이 기록되어야 합니다.');
|
||
|
||
// 결제 레코드도 PAID
|
||
$this->assertNotNull($order->payment);
|
||
$this->assertEquals(PaymentStatusEnum::PAID, $order->payment->payment_status, '전액 마일리지 결제의 결제 레코드는 PAID 여야 합니다.');
|
||
}
|
||
|
||
public function test_create_from_temp_order_full_mileage_payment_fires_payment_complete_hooks(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
MileageTransaction::create([
|
||
'user_id' => $user->id,
|
||
'currency' => 'KRW',
|
||
'type' => 'purchase_earn',
|
||
'amount' => 50000,
|
||
'remaining_amount' => 50000,
|
||
'balance_after' => 50000,
|
||
]);
|
||
|
||
$tempOrder = TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withUsePoints(50000)
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => 0],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(0, [
|
||
'subtotal' => 50000,
|
||
'total_shipping' => 0,
|
||
'taxable_amount' => 50000,
|
||
'payment_amount' => 50000,
|
||
'points_used' => 50000,
|
||
]));
|
||
|
||
$afterConfirmFired = false;
|
||
$paymentCompleteFired = false;
|
||
HookManager::addAction('sirsoft-ecommerce.order.after_confirm', function ($order) use (&$afterConfirmFired) {
|
||
$afterConfirmFired = true;
|
||
});
|
||
HookManager::addAction('sirsoft-ecommerce.order.after_payment_complete', function ($order) use (&$paymentCompleteFired) {
|
||
$paymentCompleteFired = true;
|
||
});
|
||
|
||
$this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'Hook', 'phone' => '010-0000-0000', 'email' => 'hook@test.com'],
|
||
['recipient_name' => 'Hook', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
|
||
'card',
|
||
0
|
||
);
|
||
|
||
$this->assertTrue($paymentCompleteFired, '전액 마일리지 결제 시 결제완료 훅이 발화해야 합니다.');
|
||
$this->assertTrue($afterConfirmFired, '전액 마일리지 결제 시 주문확인 훅이 발화해야 합니다.');
|
||
}
|
||
|
||
// ===== 마일리지 차감 시점 (mileage.deduction_timing) — 후속 결함 =====
|
||
|
||
/**
|
||
* 부분 마일리지 사용 PG 주문 TempOrder + 잔액 시드를 구성합니다.
|
||
*
|
||
* @param User $user 사용자
|
||
* @param int $usePoints 사용 마일리지
|
||
* @param int $finalAmount 차감 후 결제액
|
||
*/
|
||
protected function makePartialMileageTempOrder(User $user, int $usePoints, int $finalAmount): TempOrder
|
||
{
|
||
MileageTransaction::create([
|
||
'user_id' => $user->id,
|
||
'currency' => 'KRW',
|
||
'type' => 'purchase_earn',
|
||
'amount' => $usePoints,
|
||
'remaining_amount' => $usePoints,
|
||
'balance_after' => $usePoints,
|
||
]);
|
||
|
||
return TempOrderFactory::new()
|
||
->forUser($user)
|
||
->withUsePoints($usePoints)
|
||
->withCalculationResult([
|
||
'summary' => ['final_amount' => $finalAmount],
|
||
'items' => [],
|
||
'promotions' => [
|
||
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
|
||
],
|
||
'validation_errors' => [],
|
||
])
|
||
->create();
|
||
}
|
||
|
||
public function test_payment_complete_timing_does_not_deduct_mileage_at_order_creation(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
|
||
|
||
// 기본(payment_complete) 타이밍 — 실제 설정값(defaults.json) 사용
|
||
$this->mockCalculationService($this->makeCalculationResult(53000, [
|
||
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
|
||
'payment_amount' => 55000, 'points_used' => 2000,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
|
||
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
|
||
'card',
|
||
53000
|
||
);
|
||
|
||
$order->refresh();
|
||
|
||
// 결제완료 전이므로 마일리지가 차감되지 않아야 한다 (선차감 결함 방지)
|
||
$this->assertFalse((bool) $order->is_mileage_deducted, '결제완료 전(payment_complete 타이밍)에는 마일리지가 차감되지 않아야 합니다.');
|
||
$this->assertEquals(0, MileageTransaction::where('user_id', $user->id)->where('type', 'order_use')->count(), '주문 사용 거래가 생성되지 않아야 합니다.');
|
||
// 잔액 lot 은 그대로 남아있어야 함
|
||
$this->assertEquals(2000, (int) MileageTransaction::where('user_id', $user->id)->where('type', 'purchase_earn')->sum('remaining_amount'), '결제 전이므로 적립 lot 잔여가 보존되어야 합니다.');
|
||
}
|
||
|
||
public function test_complete_payment_deducts_mileage_for_payment_complete_timing(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(53000, [
|
||
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
|
||
'payment_amount' => 55000, 'points_used' => 2000,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
|
||
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
|
||
'card',
|
||
53000
|
||
);
|
||
|
||
// 결제완료 처리 → 이 시점에 마일리지 차감
|
||
$this->service->completePayment($order->fresh(), [], 53000);
|
||
|
||
$order->refresh();
|
||
$this->assertTrue((bool) $order->is_mileage_deducted, '결제완료 시 마일리지 차감 플래그가 기록되어야 합니다.');
|
||
$this->assertEquals(1, MileageTransaction::where('user_id', $user->id)->where('type', 'order_use')->count(), '결제완료 시점에 주문 사용 거래가 1건 생성되어야 합니다.');
|
||
$this->assertEquals(0, (int) MileageTransaction::where('user_id', $user->id)->where('type', 'purchase_earn')->sum('remaining_amount'), '사용분만큼 적립 lot 잔여가 소진되어야 합니다.');
|
||
}
|
||
|
||
public function test_order_placed_timing_deducts_mileage_at_order_creation(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
|
||
|
||
// 카드 결제수단의 마일리지 차감 시점을 order_placed 로 설정 (testing 격리 경로에 저장됨)
|
||
$this->setCardMileageTiming('order_placed');
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(53000, [
|
||
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
|
||
'payment_amount' => 55000, 'points_used' => 2000,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
|
||
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
|
||
'card',
|
||
53000
|
||
);
|
||
|
||
$order->refresh();
|
||
$this->assertTrue((bool) $order->is_mileage_deducted, 'order_placed 타이밍은 주문 생성 시 차감되어야 합니다.');
|
||
$this->assertEquals(1, MileageTransaction::where('user_id', $user->id)->where('type', 'order_use')->count(), '주문 생성 시점에 사용 거래가 생성되어야 합니다.');
|
||
}
|
||
|
||
public function test_fail_payment_restores_deducted_mileage(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
|
||
|
||
$this->setCardMileageTiming('order_placed');
|
||
|
||
$this->mockCalculationService($this->makeCalculationResult(53000, [
|
||
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
|
||
'payment_amount' => 55000, 'points_used' => 2000,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
|
||
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
|
||
'card',
|
||
53000
|
||
);
|
||
|
||
$order->refresh();
|
||
$this->assertTrue((bool) $order->is_mileage_deducted, '사전 조건: order_placed 차감이 일어나야 합니다.');
|
||
|
||
// 결제 실패 → 차감된 마일리지가 복원되어야 한다
|
||
$result = $this->service->failPayment($order, 'USER_CANCEL', '결제창 닫음');
|
||
|
||
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order_status);
|
||
$this->assertFalse((bool) $result->is_mileage_deducted, '복원 후 차감 플래그가 해제되어야 합니다.');
|
||
$this->assertEquals(1, MileageTransaction::where('user_id', $user->id)->where('type', 'order_cancel_restore')->count(), '결제 실패 취소 시 복원 거래가 1건 생성되어야 합니다.');
|
||
$this->assertEquals(2000, (int) MileageTransaction::where('user_id', $user->id)->where('type', 'order_cancel_restore')->sum('amount'), '복원액은 사용 마일리지와 같아야 합니다.');
|
||
}
|
||
|
||
public function test_fail_payment_does_not_create_phantom_mileage_when_not_deducted(): void
|
||
{
|
||
$user = User::factory()->create();
|
||
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
|
||
|
||
// 기본(payment_complete) — 생성 시 차감 안 됨
|
||
$this->mockCalculationService($this->makeCalculationResult(53000, [
|
||
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
|
||
'payment_amount' => 55000, 'points_used' => 2000,
|
||
]));
|
||
|
||
$order = $this->service->createFromTempOrder(
|
||
$tempOrder,
|
||
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
|
||
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
|
||
'card',
|
||
53000
|
||
);
|
||
|
||
$order->refresh();
|
||
$this->assertFalse((bool) $order->is_mileage_deducted, '사전 조건: 결제 전이라 차감되지 않아야 합니다.');
|
||
|
||
// 결제 실패 → 차감된 적 없으므로 복원(유령 적립)이 발생하면 안 된다
|
||
$this->service->failPayment($order, 'USER_CANCEL', '결제창 닫음');
|
||
|
||
$this->assertEquals(0, MileageTransaction::where('user_id', $user->id)->where('type', 'order_cancel_restore')->count(), '차감되지 않은 주문은 복원 거래를 생성하지 않아야 합니다 (유령 적립 방지).');
|
||
// 잔액 lot 은 차감/복원 모두 없이 그대로
|
||
$this->assertEquals(2000, (int) MileageTransaction::where('user_id', $user->id)->where('type', 'purchase_earn')->sum('remaining_amount'), '잔액이 변동 없이 보존되어야 합니다.');
|
||
}
|
||
|
||
public function test_order_status_enum_list_hidden_values_contains_only_pending_order(): void
|
||
{
|
||
$this->assertEquals(['pending_order'], OrderStatusEnum::listHiddenValues());
|
||
}
|
||
}
|