Windows 는 하위 트리에 열린 핸들이 하나라도 있으면 디렉토리 rename 을 막는다. 잠금 프로세스를 찾아 종료하던 기존 대응은 디렉토리 핸들을 감지하지 못했고 사용 중인 편집기를 예고 없이 죽였다. rename 이 막히면 파일 단위 연산으로 폴백해 어떤 프로세스도 건드리지 않고 교체를 끝낸다. 커밋 전 점검에서 pint.json 이 코어 업데이트 대상에 빠져 있던 것도 함께 등재했다. 이커머스는 기본 제공 통화 삭제가 저장 응답에서 즉시 부활했다. 항목 단위 보충 병합이 "소실" 과 "의도적 삭제" 를 구분하지 못한 탓이라, 삭제 의도를 서버가 도출해 저장본에 기록하고 병합이 그 기록을 존중하게 했다. 그 통화로 결제된 과거 주문의 표기가 흔들리지 않도록 소수 자릿수 해석도 스냅샷 우선으로 바꿨다 — 금액은 원래 스냅샷 환율을 써 안전했으나 자릿수만 현재 설정을 봤다.
227 lines
9.4 KiB
PHP
227 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\EcommerceUserProfileRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
|
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
|
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
|
|
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
|
use ReflectionMethod;
|
|
|
|
/**
|
|
* base 통화 스냅샷 정책 테스트 (A2)
|
|
*
|
|
* - order_currency = resolveActiveCurrency(영속>헤더>base) — 표시 헤더 단독 아님
|
|
* - base_currency = default_currency (모든 금액 SSoT)
|
|
* - 환불 불변식: 부분환불이 원주문 스냅샷 환율 사용(현재 환율 재조회 금지 → 환율 변경해도 불변)
|
|
*/
|
|
class OrderProcessingCurrencySnapshotTest extends ModuleTestCase
|
|
{
|
|
private function invokeBuildSnapshot(): array
|
|
{
|
|
$service = app(OrderProcessingService::class);
|
|
$method = new ReflectionMethod($service, 'buildCurrencySnapshot');
|
|
$method->setAccessible(true);
|
|
|
|
return $method->invoke($service);
|
|
}
|
|
|
|
public function test_snapshot_base_currency_is_default(): void
|
|
{
|
|
$snapshot = $this->invokeBuildSnapshot();
|
|
|
|
$this->assertArrayHasKey('base_currency', $snapshot);
|
|
// 기본통화 = default_currency (KRW). base 는 금액 SSoT.
|
|
$this->assertSame('KRW', $snapshot['base_currency']);
|
|
}
|
|
|
|
public function test_order_currency_resolves_from_header_when_no_persisted(): void
|
|
{
|
|
// 비로그인/헤더 USD → order_currency = USD (resolveActiveCurrency 2순위)
|
|
request()->headers->set('X-Currency', 'USD');
|
|
|
|
$snapshot = $this->invokeBuildSnapshot();
|
|
|
|
$this->assertSame('USD', $snapshot['order_currency']);
|
|
// base 는 여전히 KRW (SSoT 보존, order_currency 와 분리)
|
|
$this->assertSame('KRW', $snapshot['base_currency']);
|
|
}
|
|
|
|
public function test_order_currency_falls_back_to_base_without_header(): void
|
|
{
|
|
request()->headers->remove('X-Currency');
|
|
|
|
$snapshot = $this->invokeBuildSnapshot();
|
|
|
|
$this->assertSame('KRW', $snapshot['order_currency']);
|
|
}
|
|
|
|
public function test_persisted_user_currency_takes_priority_over_header(): void
|
|
{
|
|
// 로그인 유저의 영속 통화(EUR)가 헤더(USD)보다 1순위 (§A3-b)
|
|
$user = User::factory()->create();
|
|
Auth::login($user);
|
|
app(EcommerceUserProfileRepositoryInterface::class)->setPreferredCurrency($user->id, 'EUR');
|
|
request()->headers->set('X-Currency', 'USD');
|
|
|
|
$snapshot = $this->invokeBuildSnapshot();
|
|
|
|
$this->assertSame('EUR', $snapshot['order_currency']);
|
|
Auth::logout();
|
|
}
|
|
|
|
public function test_snapshot_includes_all_currency_exchange_rates(): void
|
|
{
|
|
$snapshot = $this->invokeBuildSnapshot();
|
|
|
|
$this->assertArrayHasKey('exchange_rates', $snapshot);
|
|
// 전 통화 환율 동봉(환불/표시 재사용)
|
|
$this->assertArrayHasKey('KRW', $snapshot['exchange_rates']);
|
|
$this->assertArrayHasKey('USD', $snapshot['exchange_rates']);
|
|
}
|
|
|
|
public function test_snapshot_embeds_base_unit_for_formula_reproduction(): void
|
|
{
|
|
// 공식 변경 후에도 주문 시점 환산 재현을 위해 base_unit 을 박제한다(환차손 0).
|
|
$snapshot = $this->invokeBuildSnapshot();
|
|
|
|
// 최상위 base_unit = 기본 통화(KRW)의 base_unit = 1000 (폴백)
|
|
$this->assertArrayHasKey('base_unit', $snapshot);
|
|
$this->assertSame(1000, $snapshot['base_unit']);
|
|
|
|
// 통화별 base_unit 도 동봉: KRW=1000, JPY=100, USD/EUR=1 (폴백)
|
|
$this->assertSame(1000, $snapshot['exchange_rates']['KRW']['base_unit']);
|
|
$this->assertSame(1, $snapshot['exchange_rates']['USD']['base_unit']);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 미등록 통화 방어 (공개 #91) — 삭제 영속화로 처음 열리는 경로
|
|
// ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* 등록 통화를 KRW 단독으로 축소합니다. (관리자가 나머지를 삭제한 상태)
|
|
*/
|
|
private function registerOnlyBaseCurrency(): void
|
|
{
|
|
Config::set('g7_settings.modules.sirsoft-ecommerce.language_currency', [
|
|
'default_currency' => 'KRW',
|
|
'currencies' => [
|
|
[
|
|
'code' => 'KRW',
|
|
'name' => ['ko' => 'KRW', 'en' => 'KRW'],
|
|
'exchange_rate' => null,
|
|
'base_unit' => 1000,
|
|
'rounding_unit' => '1',
|
|
'rounding_method' => 'floor',
|
|
'decimal_places' => 0,
|
|
'is_default' => true,
|
|
],
|
|
],
|
|
]);
|
|
|
|
CurrencySettingsCache::clear();
|
|
$this->app->forgetInstance(CurrencyConversionService::class);
|
|
$this->app->forgetInstance(OrderProcessingService::class);
|
|
}
|
|
|
|
/**
|
|
* 사용 중지된 통화를 선호 통화로 가진 유저는 기본 통화로 폴백한다.
|
|
*
|
|
* 폴백이 없으면 스냅샷 exchange_rates 에 그 코드가 없어 체크아웃이 차단된다.
|
|
*
|
|
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
|
|
*
|
|
* @effects unregistered_active_currency_falls_back_to_base
|
|
*/
|
|
public function test_unregistered_persisted_currency_falls_back_to_base(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
Auth::login($user);
|
|
app(EcommerceUserProfileRepositoryInterface::class)->setPreferredCurrency($user->id, 'EUR');
|
|
request()->headers->remove('X-Currency');
|
|
|
|
$this->registerOnlyBaseCurrency();
|
|
|
|
$snapshot = $this->invokeBuildSnapshot();
|
|
|
|
$this->assertSame('KRW', $snapshot['order_currency'], '미등록 선호 통화가 결제 통화로 그대로 채택되었습니다.');
|
|
$this->assertArrayHasKey('KRW', $snapshot['exchange_rates']);
|
|
Auth::logout();
|
|
}
|
|
|
|
/**
|
|
* 사용 중지된 통화가 X-Currency 헤더로 들어와도 기본 통화로 폴백한다.
|
|
*
|
|
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
|
|
*
|
|
* @effects unregistered_active_currency_falls_back_to_base
|
|
*/
|
|
public function test_unregistered_x_currency_header_falls_back_to_base(): void
|
|
{
|
|
request()->headers->set('X-Currency', 'JPY');
|
|
|
|
$this->registerOnlyBaseCurrency();
|
|
|
|
$snapshot = $this->invokeBuildSnapshot();
|
|
|
|
$this->assertSame('KRW', $snapshot['order_currency'], '미등록 헤더 통화가 결제 통화로 그대로 채택되었습니다.');
|
|
request()->headers->remove('X-Currency');
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 환불 불변식 (A2 D-BASE-3) — 핵심
|
|
// ──────────────────────────────────────────────
|
|
|
|
public function test_refund_uses_snapshot_rate_not_current_rate(): void
|
|
{
|
|
/** @var CurrencyConversionService $svc */
|
|
$svc = app(CurrencyConversionService::class);
|
|
|
|
// 주문 시점 스냅샷: USD 환율 0.85 로 고정
|
|
$snapshot = [
|
|
'base_currency' => 'KRW',
|
|
'order_currency' => 'USD',
|
|
'exchange_rate' => 0.85,
|
|
'exchange_rates' => [
|
|
'KRW' => ['rate' => 1.0, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0],
|
|
'USD' => ['rate' => 0.85, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
|
|
],
|
|
];
|
|
|
|
// 환불액 10,000(base KRW) 을 스냅샷 환율로 변환
|
|
$result = $svc->convertMultipleAmountsWithSnapshot(['refund' => 10000], $snapshot);
|
|
|
|
// USD 환산 = (10000/1000) * 0.85 = 8.5 (스냅샷 환율 기준)
|
|
$this->assertEquals(8.5, $result['USD']['refund']);
|
|
// KRW(base) = 정수 그대로 보존(SSoT)
|
|
$this->assertEquals(10000, $result['KRW']['refund']);
|
|
}
|
|
|
|
public function test_refund_unchanged_when_current_rate_differs_from_snapshot(): void
|
|
{
|
|
/** @var CurrencyConversionService $svc */
|
|
$svc = app(CurrencyConversionService::class);
|
|
|
|
// 스냅샷 환율(주문 시점) = 0.85, 현재 환율이 1.50 으로 바뀌었다고 가정해도
|
|
// convertMultipleAmountsWithSnapshot 는 스냅샷 환율(0.85)만 사용해야 한다(환차손 0).
|
|
$snapshot = [
|
|
'base_currency' => 'KRW',
|
|
'order_currency' => 'USD',
|
|
'exchange_rate' => 0.85,
|
|
'exchange_rates' => [
|
|
'USD' => ['rate' => 0.85, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
|
|
],
|
|
];
|
|
|
|
$result = $svc->convertMultipleAmountsWithSnapshot(['refund' => 20000], $snapshot);
|
|
|
|
// (20000/1000)*0.85 = 17.0 (스냅샷 0.85, 현재 환율 1.50 이 아님)
|
|
$this->assertEquals(17.0, $result['USD']['refund']);
|
|
$this->assertNotEquals(30.0, $result['USD']['refund']); // 현재 환율 1.50 이었다면 30.0
|
|
}
|
|
}
|