fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화

Windows 는 하위 트리에 열린 핸들이 하나라도 있으면 디렉토리 rename 을 막는다.
잠금 프로세스를 찾아 종료하던 기존 대응은 디렉토리 핸들을 감지하지 못했고
사용 중인 편집기를 예고 없이 죽였다. rename 이 막히면 파일 단위 연산으로
폴백해 어떤 프로세스도 건드리지 않고 교체를 끝낸다. 커밋 전 점검에서
pint.json 이 코어 업데이트 대상에 빠져 있던 것도 함께 등재했다.

이커머스는 기본 제공 통화 삭제가 저장 응답에서 즉시 부활했다. 항목 단위
보충 병합이 "소실" 과 "의도적 삭제" 를 구분하지 못한 탓이라, 삭제 의도를
서버가 도출해 저장본에 기록하고 병합이 그 기록을 존중하게 했다. 그 통화로
결제된 과거 주문의 표기가 흔들리지 않도록 소수 자릿수 해석도 스냅샷 우선으로
바꿨다 — 금액은 원래 스냅샷 환율을 써 안전했으나 자릿수만 현재 설정을 봤다.
This commit is contained in:
HeuJung committed 2026-08-12 10:10:01 +09:00
1 parent 5ae34002f3
commit c77ddee03b
39 files changed
+1886 -138

No files matched your search

@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.1] - 2026-08-11
### Fixed
- 언어/통화 설정에서 기본 제공 통화(달러·엔·위안·유로)를 삭제해도 저장 직후 다시 나타나던 문제를 수정했습니다. 삭제한 통화는 설정 화면과 쇼핑몰 화면 양쪽에서 유지되며, 통화 추가로 다시 등록하면 복원됩니다. 사용 중지된 통화를 표시 통화로 갖고 있던 구매자의 주문은 기본 통화로 안전하게 진행됩니다. (#91 @koojunho 님께서 제보해주셨습니다.)
- 통화를 삭제한 뒤 그 통화로 결제된 과거 주문의 금액 표기가 달라지던 문제를 수정했습니다. 주문·환불 금액의 소수 자릿수는 주문 시점 기준을 유지하므로, 엔화처럼 소수점을 쓰지 않는 통화가 `¥14,835.00` 으로 바뀌거나 소수점 이하 자릿수가 많은 통화의 표시 금액이 잘리지 않습니다. (#91 @koojunho 님께서 제보해주셨습니다.)
## [1.1.0] - 2026-08-10
### Security
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -43,7 +43,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙). `removed_default_currencies` 는 관리자가 삭제한 기본 제공 통화 코드 목록으로, 서버가 저장 시점에 도출해 기록한다 (관리자 응답 전용 — 공개 설정에는 노출되지 않음) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
@@ -452,6 +452,7 @@ HTTP/1.1 200
| language_currency | body | array | 아니오 | — | 통화 설정 섹션 (기본 통화·통화 목록: 코드·다국어명·환율·반올림 규칙·통화별 로케일) |
| language_currency.default_currency | body | string | 아니오 | max 10 | 쇼핑몰 기본(base) 통화 코드. 상품/주문이 1건이라도 생성된 뒤에는 변경 불가 |
| language_currency.currencies | body | array | 아니오 | — | 등록 통화 목록. 항목별 `code`(ISO 4217 3자리 대문자, 필수)·`name`(다국어 배열, 필수)·`symbol`·`exchange_rate`·`base_unit`·`rounding_unit`·`rounding_method`(`floor`\|`round`\|`ceil`)·`decimal_places`·`is_default`·`locales` |
| language_currency.removed_default_currencies | body | array | — | — | 서버 관리 필드. 요청에 실어 보내도 무시되며, 제출된 `currencies` 와 기본 제공 통화 목록의 차집합으로 서버가 재계산한다. `currencies` 를 보내지 않은 저장은 기존 값을 그대로 이월한다 |
| seo | body | array | 아니오 | — | SEO 메타 설정 섹션 (페이지 유형별 메타 타이틀/설명·SEO 활성 토글) |
| seo.meta_category_title | body | string | 아니오 | max 500 | 카테고리 페이지 메타 Title (`{commerce_name}`·`{category_name}` 등 변수 사용 가능) |
| seo.meta_category_description | body | string | 아니오 | max 1000 | 카테고리 페이지 메타 Description |
@@ -655,7 +656,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙). `removed_default_currencies` 는 관리자가 삭제한 기본 제공 통화 코드 목록으로, 서버가 저장 시점에 도출해 기록한다 (관리자 응답 전용 — 공개 설정에는 노출되지 않음) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
@@ -1071,7 +1072,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙). `removed_default_currencies` 는 관리자가 삭제한 기본 제공 통화 코드 목록으로, 서버가 저장 시점에 도출해 기록한다 (관리자 응답 전용 — 공개 설정에는 노출되지 않음) |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
@@ -5,7 +5,7 @@
"ko": "이커머스",
"en": "Ecommerce"
},
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
"version": "1.1.0",
"version": "1.1.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.1.0",
"version": "1.1.1",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -39,6 +39,9 @@ class GuestOrderResource extends BaseApiResource
{
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
// 주문 시점 통화 스냅샷 — 소수 자릿수를 현재 설정이 아닌 주문 시점 값으로 고정 (공개 #91 후속).
$currencySnapshot = $this->resource->currency_snapshot ?? null;
$this->withCurrencySnapshot($currencySnapshot);
// 결제 통화(order_currency) — 현금영수증 금액 표기 기준(구매자가 실제 낸 통화).
$paymentCurrency = $this->currency
@@ -118,14 +121,14 @@ class GuestOrderResource extends BaseApiResource
// 주문 옵션 (품목) — 주문 시점 통화를 자식에 전파
'options' => $this->whenLoaded('options', fn () => OrderOptionResource::collection($this->options)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 배송지/결제/배송 정보
'shipping_address' => new OrderAddressResource($this->whenLoaded('shippingAddress')),
'payment' => $this->whenLoaded('payment', fn () => (new OrderPaymentResource($this->payment))->withOrderCurrency($orderCurrency)),
'payment' => $this->whenLoaded('payment', fn () => (new OrderPaymentResource($this->payment))->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)),
'shippings' => $this->whenLoaded('shippings', fn () => OrderShippingResource::collection($this->shippings)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 취소 이력 (취소 사유·상세 사유·취소일시 표시용) — 최근 취소가 먼저 오도록 정렬
@@ -141,13 +144,13 @@ class GuestOrderResource extends BaseApiResource
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
return $active
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency)
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)->withReceiptCurrency($paymentCurrency)
: null;
}),
// 발급/취소 전체 이력 — 카드의 "직전 발급이 실패했는가" 분기가 cash_receipts[0] 를 본다.
'cash_receipts' => $this->whenLoaded('cashReceipts', fn () => CashReceiptResource::collection(
$this->cashReceipts->sortByDesc('id')->values()
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency))),
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)->withReceiptCurrency($paymentCurrency))),
// 배송정책 — **표시용 필드만**. 비회원 주문 상세 화면은 회원 마이페이지와 같은
// partial 을 써서 상품별 정책명·개별 배송비를 그리는데, 이 필드를 통째로 빼면
@@ -30,6 +30,9 @@ class OrderListResource extends BaseApiResource
{
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
// 주문 시점 통화 스냅샷 — 소수 자릿수를 현재 설정이 아닌 주문 시점 값으로 고정 (공개 #91 후속).
$currencySnapshot = $this->resource->currency_snapshot ?? null;
$this->withCurrencySnapshot($currencySnapshot);
// 결제 통화(order_currency) — 유저가 선택·결제한 통화. base 통화와 다를 때 함께 표기.
$paymentCurrency = $this->currency
@@ -27,6 +27,9 @@ class OrderResource extends BaseApiResource
{
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
// 주문 시점 통화 스냅샷 — 소수 자릿수를 현재 설정이 아닌 주문 시점 값으로 고정 (공개 #91 후속).
$currencySnapshot = $this->resource->currency_snapshot ?? null;
$this->withCurrencySnapshot($currencySnapshot);
// 결제 통화(order_currency) — 유저가 선택·결제한 통화. base 통화와 다를 때 화면에 함께 표기한다.
$paymentCurrency = $this->currency
@@ -177,7 +180,7 @@ class OrderResource extends BaseApiResource
// 주문 옵션 (품목) — 주문 시점 통화를 자식에 전파
'options' => $this->whenLoaded('options', fn () => OrderOptionResource::collection($this->options)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 배송지 정보
@@ -185,9 +188,9 @@ class OrderResource extends BaseApiResource
'billing_address' => new OrderAddressResource($this->whenLoaded('billingAddress')),
// 결제 정보
'payment' => $this->whenLoaded('payment', fn () => (new OrderPaymentResource($this->payment))->withOrderCurrency($orderCurrency)),
'payment' => $this->whenLoaded('payment', fn () => (new OrderPaymentResource($this->payment))->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)),
'payments' => $this->whenLoaded('payments', fn () => OrderPaymentResource::collection($this->payments)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 현금영수증 — 현재 활성 영수증 1건(없으면 null). 발급 카드의 "발급완료" 상태 근거.
@@ -195,17 +198,17 @@ class OrderResource extends BaseApiResource
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
return $active
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency)
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)->withReceiptCurrency($paymentCurrency)
: null;
}),
// 발급/취소 전체 이력 — 관리자 화면의 "취소 성공 + 재발급 실패" 경고 배지 판정 근거.
'cash_receipts' => $this->whenLoaded('cashReceipts', fn () => CashReceiptResource::collection(
$this->cashReceipts->sortByDesc('id')->values()
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency))),
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)->withReceiptCurrency($paymentCurrency))),
// 배송 정보
'shippings' => $this->whenLoaded('shippings', fn () => OrderShippingResource::collection($this->shippings)->each(
fn ($r) => $r->withOrderCurrency($orderCurrency)
fn ($r) => $r->withOrderCurrency($orderCurrency)->withCurrencySnapshot($currencySnapshot)
)),
// 취소 이력 (취소 사유·상세 사유·취소일시 표시용) — 최근 취소가 먼저 오도록 정렬
@@ -20,6 +20,14 @@ trait HasMultiCurrencyPrices
*/
protected ?string $orderCurrencyCode = null;
/**
* 부모 주문 리소스가 주입한 주문 시점 통화 스냅샷 (공개 #91 후속).
*
* 소수 자릿수를 현재 설정이 아니라 주문 시점 값으로 고정하기 위해 전파한다.
* null 이면 현재 설정으로 폴백 — 상품 등 카탈로그 경로는 그것이 정답이다.
*/
protected ?array $currencySnapshot = null;
/**
* 주문 시점 기준 통화 코드를 주입합니다 (부모 → 자식 리소스 전파).
*
@@ -33,6 +41,23 @@ trait HasMultiCurrencyPrices
return $this;
}
/**
* 주문 시점 통화 스냅샷을 주입합니다 (부모 → 자식 리소스 전파).
*
* 관리자가 통화를 삭제하면 현재 설정에서 사라져 자릿수가 폴백 2자리로 바뀐다.
* 그러면 0자리 통화로 결제된 과거 주문의 표기가 `¥14,835` → `¥14,835.00` 이 되고,
* 3자리 이상 통화는 표시 금액이 절사된다. 스냅샷이 있으면 그것을 SSoT 로 삼는다.
*
* @param array|null $snapshot 주문의 `currency_snapshot`
* @return $this 메서드 체이닝을 위한 자기 자신
*/
public function withCurrencySnapshot(?array $snapshot): static
{
$this->currencySnapshot = $snapshot;
return $this;
}
/**
* 다중 통화 가격 정보를 생성합니다.
*
@@ -302,11 +327,20 @@ trait HasMultiCurrencyPrices
/**
* 통화의 소수 자릿수를 반환합니다.
*
* 주문 시점 스냅샷이 주입돼 있고 그 통화의 자릿수를 박제해 두었으면 그것이 SSoT 다
* (공개 #91 후속 — 삭제된 통화가 현재 설정에 없어 폴백 2자리로 바뀌는 것을 막는다).
* 스냅샷이 없으면(상품 등 카탈로그 경로) 현재 설정을 그대로 따른다.
*
* @param string $code 통화 코드
* @return int 소수 자릿수 (기본값: 2)
*/
protected function getDecimalPlacesForCurrency(string $code): int
{
$snapshotPlaces = $this->currencySnapshot['exchange_rates'][$code]['decimal_places'] ?? null;
if ($snapshotPlaces !== null) {
return (int) $snapshotPlaces;
}
$currencies = $this->getCurrencySettings();
foreach ($currencies as $currency) {
@@ -5,9 +5,11 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources;
use App\Helpers\PermissionHelper;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\MissingValue;
use Modules\Sirsoft\Ecommerce\Http\Resources\Concerns\LocalizesCountryName;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\SummarizesAdditionalOptions;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
/**
* 사용자 주문 목록 리소스
@@ -31,6 +33,9 @@ class UserOrderListResource extends BaseApiResource
{
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
// 주문 시점 통화 스냅샷 — 소수 자릿수를 현재 설정이 아닌 주문 시점 값으로 고정 (공개 #91 후속).
$currencySnapshot = $this->resource->currency_snapshot ?? null;
$this->withCurrencySnapshot($currencySnapshot);
// 컬렉션(UserOrderCollection)이 이 배열을 그대로 응답에 실어 Laravel 의 MissingValue
// 제거 단계를 거치지 않는다 — 미충족 조건부 필드를 직접 걸러낸다. 걸러내지 않으면
@@ -98,7 +103,7 @@ class UserOrderListResource extends BaseApiResource
* 주문" 이라는 사실 아닌 단언이 된다.
*
* @param string|null $orderCurrency 주문 시점 기준 통화
* @return array<int, array<string, mixed>>|\Illuminate\Http\Resources\MissingValue 아이템 배열
* @return array<int, array<string, mixed>>|MissingValue 아이템 배열
*/
private function resolveItems(?string $orderCurrency)
{
@@ -117,13 +122,13 @@ class UserOrderListResource extends BaseApiResource
: [];
}
return new \Illuminate\Http\Resources\MissingValue;
return new MissingValue;
}
/**
* 주문 아이템 1건을 표시용 배열로 변환합니다.
*
* @param \Modules\Sirsoft\Ecommerce\Models\OrderOption $option 주문 옵션
* @param OrderOption $option 주문 옵션
* @param string|null $orderCurrency 주문 시점 기준 통화
* @return array<string, mixed> 표시용 아이템 배열
*/
@@ -157,7 +162,7 @@ class UserOrderListResource extends BaseApiResource
* 유무는 값이 아니라 속성 키로 판정한다 — 0건의 `0` 과 "집계 안 함" 은 값으로 구분되지
* 않는다.
*
* @return int|\Illuminate\Http\Resources\MissingValue 아이템 수
* @return int|MissingValue 아이템 수
*/
private function resolveItemCount()
{
@@ -169,7 +174,7 @@ class UserOrderListResource extends BaseApiResource
return $this->resource->options->count();
}
return new \Illuminate\Http\Resources\MissingValue;
return new MissingValue;
}
/**
@@ -82,11 +82,23 @@ class CurrencyConversionService
/**
* 통화의 소수 자릿수를 반환합니다.
*
* 주문 시점 스냅샷이 주어지고 그 통화의 자릿수가 박제돼 있으면 그것을 우선합니다.
*
* @param string $code 통화 코드
* @param array|null $currencySnapshot 주문 시점 통화 스냅샷 (없으면 현재 설정 사용)
* @return int 소수 자릿수 (기본값: 2)
*/
public function getDecimalPlaces(string $code): int
public function getDecimalPlaces(string $code, ?array $currencySnapshot = null): int
{
// 주문 시점 스냅샷이 자릿수를 박제해 두었으면 그것이 SSoT (공개 #91 후속).
// 관리자가 그 통화를 삭제하면 현재 설정에서 사라져 폴백 2자리가 적용되는데,
// 그러면 0자리 통화의 과거 주문 표기가 `¥18,860` → `¥18,860.00` 으로 바뀌고
// 3자리 이상 통화는 표시 금액이 절사된다.
$snapshotPlaces = $this->snapshotDecimalPlaces($currencySnapshot, $code);
if ($snapshotPlaces !== null) {
return $snapshotPlaces;
}
$currencies = $this->getCurrencySettings();
foreach ($currencies as $currency) {
@@ -99,6 +111,27 @@ class CurrencyConversionService
return 2;
}
/**
* 통화 스냅샷에 박제된 소수 자릿수를 반환합니다. (공개 #91 후속)
*
* 자릿수를 박제하지 않은 구형 스냅샷(환율이 단순 숫자)은 null 을 돌려주어
* 호출부가 현재 설정 폴백을 그대로 타게 합니다.
*
* @param array|null $currencySnapshot 주문 시점 통화 스냅샷
* @param string $code 통화 코드
* @return int|null 박제된 자릿수 (없으면 null)
*/
private function snapshotDecimalPlaces(?array $currencySnapshot, string $code): ?int
{
$rateData = $currencySnapshot['exchange_rates'][$code] ?? null;
if (! is_array($rateData) || ! isset($rateData['decimal_places'])) {
return null;
}
return (int) $rateData['decimal_places'];
}
/**
* 통화의 base_unit(기본 통화일 때 환율 분모가 되는 1단위 금액)을 반환합니다.
*
@@ -342,12 +375,12 @@ class CurrencyConversionService
$snapshotRate = (float) $rateData;
$roundingUnit = '0.01';
$roundingMethod = 'round';
$decimalPlaces = $this->getDecimalPlaces($orderCurrency);
$decimalPlaces = $this->getDecimalPlaces($orderCurrency, $currencySnapshot);
} else {
$snapshotRate = (float) ($rateData['rate'] ?? 0);
$roundingUnit = $rateData['rounding_unit'] ?? '0.01';
$roundingMethod = $rateData['rounding_method'] ?? 'round';
$decimalPlaces = (int) ($rateData['decimal_places'] ?? $this->getDecimalPlaces($orderCurrency));
$decimalPlaces = (int) ($rateData['decimal_places'] ?? $this->getDecimalPlaces($orderCurrency, $currencySnapshot));
}
$isBase = ($orderCurrency === $baseCurrency);
@@ -355,7 +388,7 @@ class CurrencyConversionService
if ($isBase) {
// base 통화 결제: 환산 없이 그대로. base 의 decimal_places 로 정수화.
$convertedAmount = (float) $baseAmount;
$decimalPlaces = (int) ($rateData['decimal_places'] ?? $this->getDecimalPlaces($orderCurrency));
$decimalPlaces = (int) ($rateData['decimal_places'] ?? $this->getDecimalPlaces($orderCurrency, $currencySnapshot));
$snapshotRate = 1.0;
} else {
if ($snapshotRate <= 0) {
@@ -433,11 +466,15 @@ class CurrencyConversionService
/**
* 통화별 가격을 포맷팅합니다.
*
* 주문 시점 스냅샷을 넘기면 소수 자릿수를 그 시점 값으로 고정합니다 — 통화가 삭제되어도
* 과거 주문의 표기가 바뀌지 않습니다. 상품 등 카탈로그 표시는 스냅샷 없이 호출합니다.
*
* @param float|int $price 가격
* @param string $code 통화 코드
* @param array|null $currencySnapshot 주문 시점 통화 스냅샷 (없으면 현재 설정 사용)
* @return string 포맷팅된 가격
*/
public function formatPrice(float|int $price, string $code): string
public function formatPrice(float|int $price, string $code, ?array $currencySnapshot = null): string
{
$prefix = __('sirsoft-ecommerce::messages.currency.prefix.'.$code, [], app()->getLocale());
$suffix = __('sirsoft-ecommerce::messages.currency.suffix.'.$code, [], app()->getLocale());
@@ -450,7 +487,7 @@ class CurrencyConversionService
$suffix = '';
}
$decimalPlaces = $this->getDecimalPlaces($code);
$decimalPlaces = $this->getDecimalPlaces($code, $currencySnapshot);
$formattedNumber = number_format($price, $decimalPlaces);
// prefix나 suffix가 없으면 기본 포맷
@@ -507,7 +544,7 @@ class CurrencyConversionService
foreach ($amounts as $field => $baseAmount) {
if ($isDefault) {
$currencyAmounts[$field] = $baseAmount;
$currencyAmounts[$field.'_formatted'] = $this->formatPrice($baseAmount, $code);
$currencyAmounts[$field.'_formatted'] = $this->formatPrice($baseAmount, $code, $currencySnapshot);
} else {
if ($snapshotRate > 0) {
$convertedPrice = ($baseAmount / $baseUnit) * $snapshotRate;
@@ -517,7 +554,7 @@ class CurrencyConversionService
$roundingMethod
);
$currencyAmounts[$field] = $convertedAmount;
$currencyAmounts[$field.'_formatted'] = $this->formatPrice($convertedAmount, $code);
$currencyAmounts[$field.'_formatted'] = $this->formatPrice($convertedAmount, $code, $currencySnapshot);
}
}
}
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
/**
* 이커머스 모듈 환경설정 서비스
@@ -28,6 +29,11 @@ class EcommerceSettingsService implements ModuleSettingsInterface
*/
private const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
/**
* 관리자가 삭제한 기본 제공 통화 코드 목록의 저장 키 (공개 #91)
*/
private const REMOVED_CURRENCIES_KEY = 'removed_default_currencies';
/**
* 설정 기본값 (캐시)
*/
@@ -119,10 +125,14 @@ class EcommerceSettingsService implements ModuleSettingsInterface
// language_currency.currencies 는 정수키 리스트라 array_merge 가 통째 교체 →
// defaults 에 있고 저장본에 없는 통화는 code 기준으로 보충(환율은 저장본 우선 보존).
// 관리자가 의도적으로 삭제한 게 아니라 array_merge 부작용으로 소실되던 영속성 공백 수정(U11-A).
// 단, 관리자가 실제로 삭제한 통화는 저장 시점에 기록되므로 보충 대상에서 뺀다(공개 #91).
if (isset($defaultValues['language_currency']['currencies'])) {
$removedCodes = $settings['language_currency'][self::REMOVED_CURRENCIES_KEY] ?? [];
$settings['language_currency']['currencies'] = $this->mergeCurrenciesByCode(
$defaultValues['language_currency']['currencies'],
$settings['language_currency']['currencies'] ?? []
$settings['language_currency']['currencies'] ?? [],
is_array($removedCodes) ? $removedCodes : []
);
}
@@ -150,7 +160,8 @@ class EcommerceSettingsService implements ModuleSettingsInterface
);
}
// 셀렉터(_currency_selector.json)가 참조하는 symbol/flag 보강
// (settings 스키마에 없는 표시 메타 — 저장 시 normalize 가 떨궈 round-trip 오염 없음)
// (flag 는 저장 규칙에 없는 표시 메타 — 폼이 되돌려 보내도 FormRequest 검증에서
// 탈락해 round-trip 오염이 없다)
if (! empty($currency['code'])) {
$meta = $this->currencyDisplayMeta($currency['code']);
// 관리자가 직접 지정한 기호는 보존, 없을 때만 표준 매핑으로 보충
@@ -263,6 +274,11 @@ class EcommerceSettingsService implements ModuleSettingsInterface
);
}
// language_currency: 관리자가 삭제한 기본 제공 통화를 서버가 도출해 기록 (공개 #91)
if ($category === 'language_currency') {
$processedSettings = $this->applyRemovedDefaultCurrencies($processedSettings);
}
if (! $this->saveCategorySettings($category, $processedSettings)) {
$success = false;
}
@@ -271,9 +287,84 @@ class EcommerceSettingsService implements ModuleSettingsInterface
// 캐시 초기화
$this->settings = null;
// 통화 목록이 바뀌었을 수 있으므로 요청 단위 통화 캐시도 함께 비운다.
// (리소스 계층이 이 캐시로 다통화 금액을 만들기 때문에, 비우지 않으면 같은 요청 안에서
// 저장 전 통화 구성으로 금액이 계산된다)
CurrencySettingsCache::clear();
return $success;
}
/**
* 관리자가 삭제한 기본 제공 통화를 저장본에 기록합니다. (공개 #91)
*
* 조회 병합(mergeCurrenciesByCode)은 저장본에 없는 defaults 통화를 무조건 보충하는데,
* 그 보충은 "array_merge 통째 교체로 인한 의도치 않은 소실"(U11-A)을 되돌리기 위한 것이다.
* 관리자의 의도적 삭제와 구분할 장치가 없으면 삭제까지 되돌아가므로, 저장 시점에
* 삭제 의도를 도출해 남긴다.
*
* - 클라이언트가 보낸 같은 키는 신뢰하지 않고 항상 서버가 재계산한다.
* - `currencies` 미제출 저장(기본 통화만 변경 등)은 기존 통화 목록과 기록을 함께 이월한다.
* 저장이 파일 통째 교체이므로 이월하지 않으면 저장본에서 두 키가 모두 사라지고, 조회
* 병합이 defaults 를 그대로 들여와 삭제가 전부 부활한다.
* - 기본 통화 코드는 기록 대상에서 제외한다 (기본 통화는 항상 생존해야 한다).
*
* @param array $processed 정규화까지 끝난 language_currency 저장값
* @return array 삭제 기록이 반영된 저장값
*/
private function applyRemovedDefaultCurrencies(array $processed): array
{
// 클라이언트 주입 방어 — 검증에서 떨어지지만 프로그램 직접 호출 경로도 막는다
unset($processed[self::REMOVED_CURRENCIES_KEY]);
$saved = $this->loadCategorySettings('language_currency');
$defaultValues = $this->getDefaults()['defaults']['language_currency'] ?? [];
// currencies 미제출 → 기존 통화 목록과 삭제 기록을 함께 이월
if (! isset($processed['currencies']) || ! is_array($processed['currencies'])) {
$carried = $saved[self::REMOVED_CURRENCIES_KEY] ?? [];
$processed[self::REMOVED_CURRENCIES_KEY] = array_values(array_filter(
is_array($carried) ? $carried : [],
'is_string'
));
if (isset($saved['currencies']) && is_array($saved['currencies'])) {
$processed['currencies'] = array_values($saved['currencies']);
// 같은 저장에서 기본 통화가 바뀌었을 수 있으므로 is_default 를 다시 맞춘다
$processed = $this->syncCurrencyDefaults($processed);
}
return $processed;
}
// 제출된 통화 코드 집합
$submitted = [];
foreach ($processed['currencies'] as $currency) {
$code = is_array($currency) ? ($currency['code'] ?? null) : null;
if (is_string($code) && $code !== '') {
$submitted[$code] = true;
}
}
// 유효 기본 통화: 제출값 → 기존 저장본 → defaults 순
$baseCode = $processed['default_currency']
?? ($saved['default_currency'] ?? ($defaultValues['default_currency'] ?? null));
$removed = [];
foreach ($defaultValues['currencies'] ?? [] as $defaultCurrency) {
$code = $defaultCurrency['code'] ?? null;
if (! is_string($code) || $code === '' || isset($submitted[$code]) || $code === $baseCode) {
continue;
}
$removed[] = $code;
}
// 비연속 키가 JSON 객체로 직렬화되지 않도록 재정렬
$processed[self::REMOVED_CURRENCIES_KEY] = array_values($removed);
return $processed;
}
/**
* 은행 목록만 저장합니다.
*
@@ -1105,12 +1196,17 @@ class EcommerceSettingsService implements ModuleSettingsInterface
* - 저장본에 있는 통화: 저장본(환율 포함) 채택 (관리자 편집 보존)
* - 저장본에 없는 defaults 통화: defaults 항목 보충 (소실 방지)
* - 저장본에만 있는(관리자 신규 추가) 통화: 그대로 보존
* - 관리자가 삭제한 defaults 통화($removedCodes): 보충하지 않음 (공개 #91)
*
* 삭제 기록에 있어도 저장본에 그 통화가 다시 들어 있으면 저장본을 우선해 되살린다 —
* 재추가 저장의 결과가 즉시 반영되어야 하기 때문이다(기록은 다음 저장에서 재계산된다).
*
* @param array $defaults defaults.json 의 통화 목록
* @param array $saved 저장본 통화 목록
* @param array $removedCodes 관리자가 삭제한 기본 제공 통화 코드 목록
* @return array code 기준으로 병합된 통화 목록
*/
private function mergeCurrenciesByCode(array $defaults, array $saved): array
private function mergeCurrenciesByCode(array $defaults, array $saved, array $removedCodes = []): array
{
// defaults 를 code 인덱스로 매핑 (필드 보충용)
$defaultsByCode = [];
@@ -1139,6 +1235,14 @@ class EcommerceSettingsService implements ModuleSettingsInterface
if ($code === null) {
continue;
}
// 관리자가 삭제한 통화는 보충하지 않는다 (저장본에 있으면 재추가된 것이므로 채택)
if (! isset($savedByCode[$code]) && in_array($code, $removedCodes, true)) {
$usedCodes[$code] = true;
continue;
}
$merged[] = $this->backfillBaseUnit($savedByCode[$code] ?? $defaultCurrency, $defaultsByCode[$code] ?? []);
$usedCodes[$code] = true;
}
@@ -524,15 +524,15 @@ class OrderAdjustmentService
if ($mcOriginalSnapshot) {
$zeroFormattedSubtotal = [];
foreach ($mcOriginalSnapshot['mc_subtotal_amount'] ?? [] as $code => $amount) {
$zeroFormattedSubtotal[$code] = $this->currencyService->formatPrice(0, $code);
$zeroFormattedSubtotal[$code] = $this->currencyService->formatPrice(0, $code, $currencySnapshot);
}
$zeroFormattedTotalPaid = [];
foreach ($mcOriginalSnapshot['mc_total_paid_amount'] ?? [] as $code => $amount) {
$zeroFormattedTotalPaid[$code] = $this->currencyService->formatPrice(0, $code);
$zeroFormattedTotalPaid[$code] = $this->currencyService->formatPrice(0, $code, $currencySnapshot);
}
$zeroFormattedListPrice = [];
foreach ($mcOriginalSnapshot['mc_total_list_price_amount'] ?? [] as $code => $amount) {
$zeroFormattedListPrice[$code] = $this->currencyService->formatPrice(0, $code);
$zeroFormattedListPrice[$code] = $this->currencyService->formatPrice(0, $code, $currencySnapshot);
}
$zeroMcSnapshot = [
@@ -1014,7 +1014,7 @@ class OrderAdjustmentService
$restoredCoupons[] = [
'coupon_name' => $issue->coupon?->getLocalizedName() ?? '',
'discount_amount' => $discountAmount,
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency),
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency, $currencySnapshot),
];
}
@@ -1093,7 +1093,7 @@ class OrderAdjustmentService
'name' => $coupon['name'] ?? '',
'target_type' => $coupon['target_type'] ?? '',
'discount_amount' => $discountAmount,
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency),
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency, $currencySnapshot),
];
}
}
@@ -1119,7 +1119,7 @@ class OrderAdjustmentService
$formatted = [];
foreach ($snapshot as $key => $value) {
if (is_numeric($value)) {
$formatted[$key] = $this->currencyService->formatPrice((float) $value, $baseCurrency);
$formatted[$key] = $this->currencyService->formatPrice((float) $value, $baseCurrency, $currencySnapshot);
}
}
@@ -1145,7 +1145,7 @@ class OrderAdjustmentService
$base = [];
foreach ($amounts as $field => $value) {
$base[$field] = $this->currencyService->formatPrice((float) $value, $baseCurrency);
$base[$field] = $this->currencyService->formatPrice((float) $value, $baseCurrency, $currencySnapshot);
}
$mc = [];
@@ -1184,12 +1184,12 @@ class OrderAdjustmentService
$formattedSubtotal = [];
foreach ($order->mc_subtotal_amount ?? [] as $code => $amount) {
$formattedSubtotal[$code] = $this->currencyService->formatPrice($amount, $code);
$formattedSubtotal[$code] = $this->currencyService->formatPrice($amount, $code, $order->currency_snapshot);
}
$formattedTotalPaid = [];
foreach ($order->mc_total_paid_amount ?? [] as $code => $amount) {
$formattedTotalPaid[$code] = $this->currencyService->formatPrice($amount, $code);
$formattedTotalPaid[$code] = $this->currencyService->formatPrice($amount, $code, $order->currency_snapshot);
}
// 정가 합계 다통화 변환
@@ -386,6 +386,11 @@ class OrderProcessingService
*
* 게스트 체크아웃(비로그인)은 유저 컨텍스트 부재 → 헤더/base 로 안전 폴백한다.
*
* 해석 결과가 현재 등록된 통화가 아니면 base 로 폴백한다 (공개 #91). 관리자가 통화를
* 삭제해도 유저의 영속 선호 통화와 클라이언트 헤더에는 그 코드가 남아 있는데, 그대로
* 채택하면 스냅샷 환율에 코드가 없어 체크아웃이 차단된다. 저장된 선호 값 자체는 지우지
* 않는다 — 그 통화가 다시 등록되면 자동으로 복원되는 것이 올바른 동작이다.
*
* @param string $baseCurrency 기본 통화(폴백)
* @return string 결정된 통화 코드
*/
@@ -393,12 +398,15 @@ class OrderProcessingService
{
// 1순위: 로그인 유저의 영속 통화 (§A3 user-profile)
$persisted = $this->resolvePersistedUserCurrency();
if ($persisted !== null && $persisted !== '') {
return $persisted;
}
// 2순위: X-Currency 헤더 (비로그인/세션 표시) → 3순위: base 폴백
return request()->header('X-Currency', $baseCurrency) ?: $baseCurrency;
$resolved = ($persisted !== null && $persisted !== '')
? $persisted
: (request()->header('X-Currency', $baseCurrency) ?: $baseCurrency);
return $this->currencyConversionService->isSupportedCurrency($resolved)
? $resolved
: $baseCurrency;
}
/**
@@ -0,0 +1,81 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 통화 삭제 저장 왕복 테스트 (공개 #91)
*
* 제보된 증상은 "저장 응답에 이미 삭제한 통화가 되살아나 있다" 였다. 서비스 단위 테스트만으로는
* 저장 응답이 재조회 결과를 그대로 싣는 이 층을 잡지 못하므로, PUT 응답과 재-GET 양쪽을 검증한다.
*/
class EcommerceSettingsCurrencyDeletionTest extends ModuleTestCase
{
private string $apiBase = '/api/modules/sirsoft-ecommerce/admin/settings';
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-ecommerce.settings.read',
'sirsoft-ecommerce.settings.update',
]);
}
/**
* 응답 payload 에서 통화 코드 목록을 추출합니다.
*
* @param array|null $currencies 통화 배열
* @return array<int, string> 통화 코드 목록
*/
private function codes(?array $currencies): array
{
return array_values(array_map(fn ($c) => $c['code'] ?? null, $currencies ?? []));
}
/**
* 기본 제공 통화를 삭제해 저장하면 저장 응답과 재조회 양쪽에서 사라진다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects intentionally_removed_default_currency_stays_deleted
*/
public function test_put_response_reflects_currency_deletion(): void
{
$response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'language_currency',
'language_currency' => [
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'base_unit' => 1000,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'decimal_places' => 0,
'is_default' => true,
],
],
],
]);
$response->assertOk();
// 저장 응답 자체가 삭제를 반영해야 한다 (제보된 증상: 응답에 5종 부활)
$saved = $this->codes($response->json('data.language_currency.currencies'));
$this->assertSame(['KRW'], $saved, '저장 응답에 삭제한 통화가 되살아났습니다.');
// 재조회에서도 유지 (새로고침 시 부활 여부)
$fetched = $this->actingAs($this->adminUser)->getJson($this->apiBase);
$fetched->assertOk();
$this->assertSame(['KRW'], $this->codes($fetched->json('data.language_currency.currencies')), '재조회에서 삭제한 통화가 되살아났습니다.');
}
}
@@ -4,13 +4,12 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Seo;
use App\Jobs\GenerateSitemapJob;
use App\Models\SitemapUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
use Tests\TestCase;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* Ecommerce 리스너 사이트맵 증분 색인 테스트 (S4 ⑲)
@@ -20,10 +19,8 @@ use Tests\TestCase;
* - 활성 카테고리 변경 → 색인, 비활성/삭제 → 색인 제거
* - 리스너가 사이트맵 재생성 잡을 디스패치
*/
class EcommerceSitemapIndexTest extends TestCase
class EcommerceSitemapIndexTest extends ModuleTestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
@@ -0,0 +1,287 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 통화 삭제 후 기존 주문의 금액·표기 불변성 테스트 (공개 #91 후속)
*
* 관리자가 통화를 삭제해도 그 통화로 결제된 과거 주문은 환불 재계산·표기 모두 주문 시점
* 스냅샷을 따라야 한다. 환산 금액은 스냅샷 환율·절사규칙을 쓰므로 이미 불변이지만,
* 소수 자릿수만은 현재 설정에서 조회해 왔다 — 삭제된 통화는 설정에서 사라지므로
* 폴백 2자리가 적용되어 0자리 통화(JPY 등)의 표기가 `¥18,860` → `¥18,860.00` 으로 바뀌고,
* 3자리 이상으로 설정된 통화는 표시 금액이 절사된다.
*/
class DeletedCurrencyOrderIntegrityTest extends ModuleTestCase
{
private string $storagePath;
/**
* 주문 시점 스냅샷 — JPY(0자리)·XAU(3자리)가 살아 있던 시점.
*/
private const SNAPSHOT = [
'base_currency' => 'KRW',
'order_currency' => 'JPY',
'exchange_rate' => 115.0,
'base_unit' => 1000,
'exchange_rates' => [
'KRW' => ['rate' => 1.0, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 1000],
'JPY' => ['rate' => 115.0, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 100],
'XAU' => ['rate' => 0.002, 'rounding_unit' => '0.001', 'rounding_method' => 'round', 'decimal_places' => 3, 'base_unit' => 1],
],
];
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
// 현재 설정 = 관리자가 JPY·XAU 를 삭제한 뒤 (KRW 만 남음)
File::ensureDirectoryExists($this->storagePath);
File::put(
$this->storagePath.'/language_currency.json',
json_encode([
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'base_unit' => 1000,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'decimal_places' => 0,
'is_default' => true,
],
],
'removed_default_currencies' => ['USD', 'JPY', 'CNY', 'EUR'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
config(['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();
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
CurrencySettingsCache::clear();
parent::tearDown();
}
/**
* 리소스 계층(트레이트)을 단독으로 쓰기 위한 익명 클래스를 만듭니다.
*/
private function resourceStub(): object
{
return new class
{
use HasMultiCurrencyPrices;
public function formatStored(?array $amounts): array
{
return $this->formatStoredMultiCurrency($amounts);
}
public function roundTo(float|int|null $price, string $code): float|int
{
return $this->roundToCurrency($price, $code);
}
};
}
// ──────────────────────────────────────────────
// 환불 재계산 금액 (스냅샷 고정 — 회귀 방지)
// ──────────────────────────────────────────────
/**
* 삭제된 통화로 결제된 주문의 부분환불 금액이 스냅샷 환율로 계산된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_order_refund_uses_snapshot_rate
*/
public function test_partial_refund_of_deleted_currency_order_uses_snapshot_rate(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
// 부분환불 10,000원 → JPY 환산 = (10000/1000) * 115 = 1,150
$result = $svc->convertMultipleAmountsWithSnapshot(['refund_amount' => 10000], self::SNAPSHOT);
$this->assertArrayHasKey('JPY', $result, '삭제된 통화가 스냅샷 환산 결과에서 사라졌습니다.');
$this->assertEquals(1150, $result['JPY']['refund_amount']);
$this->assertEquals(10000, $result['KRW']['refund_amount']);
}
/**
* 삭제된 통화의 결제 청구액도 스냅샷 환율·자릿수로 산출된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_order_refund_uses_snapshot_rate
*/
public function test_payment_charge_of_deleted_currency_uses_snapshot(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
$charge = $svc->resolveSnapshotPaymentCharge(10000, self::SNAPSHOT);
$this->assertSame('JPY', $charge['currency']);
$this->assertEquals(1150, $charge['amount']);
}
// ──────────────────────────────────────────────
// 자릿수 — 스냅샷 우선 (이번 수정 대상)
// ──────────────────────────────────────────────
/**
* 삭제된 0자리 통화의 환산 표기가 스냅샷 자릿수(0)를 유지한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_converted_amount_formatting_keeps_snapshot_decimal_places(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
$result = $svc->convertMultipleAmountsWithSnapshot(['refund_amount' => 10000], self::SNAPSHOT);
$this->assertStringNotContainsString(
'.00',
$result['JPY']['refund_amount_formatted'],
'삭제된 0자리 통화가 폴백 2자리로 표기되었습니다.'
);
}
/**
* 삭제된 3자리 통화의 표기가 절사되지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_high_precision_deleted_currency_is_not_truncated(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
// (10000/1000) * 0.002 = 0.02 → 절사단위 0.001 → 0.02, 3자리 표기 '0.020'
$result = $svc->convertMultipleAmountsWithSnapshot(['refund_amount' => 10000], self::SNAPSHOT);
$this->assertStringContainsString(
'0.020',
$result['XAU']['refund_amount_formatted'],
'삭제된 3자리 통화가 폴백 2자리로 절사되었습니다.'
);
}
/**
* 서비스의 단일 금액 포맷도 스냅샷 자릿수를 따른다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_format_price_accepts_snapshot(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
$this->assertSame('¥18,860', $svc->formatPrice(18860, 'JPY', self::SNAPSHOT));
// 스냅샷 없이 호출하면 현행대로 현재 설정 폴백(2자리)
$this->assertSame('¥18,860.00', $svc->formatPrice(18860, 'JPY'));
}
/**
* 주문의 저장된 mc_* 금액 표기가 스냅샷 자릿수를 따른다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_stored_multi_currency_formatting_keeps_snapshot_decimal_places(): void
{
$resource = $this->resourceStub()->withCurrencySnapshot(self::SNAPSHOT);
$formatted = $resource->formatStored(['KRW' => 129000, 'JPY' => 14835]);
$this->assertSame('129,000원', $formatted['KRW']['formatted']);
$this->assertSame('¥14,835', $formatted['JPY']['formatted'], '삭제된 0자리 통화가 폴백 2자리로 표기되었습니다.');
}
/**
* raw 금액 라운딩도 스냅샷 자릿수를 따른다 (0자리 통화는 int 유지).
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects deleted_currency_display_keeps_snapshot_decimal_places
*/
public function test_round_to_currency_keeps_snapshot_decimal_places(): void
{
$resource = $this->resourceStub()->withCurrencySnapshot(self::SNAPSHOT);
$this->assertSame(14835, $resource->roundTo(14835.0, 'JPY'), '삭제된 0자리 통화가 float 로 반환되었습니다.');
}
// ──────────────────────────────────────────────
// 폴백 (레거시 스냅샷 / 스냅샷 부재)
// ──────────────────────────────────────────────
/**
* 자릿수가 없는 구형 스냅샷은 현재 설정 폴백을 그대로 유지한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=none_legacy
*
* @effects legacy_snapshot_without_decimal_places_falls_back_to_settings
*/
public function test_legacy_snapshot_without_decimal_places_falls_back(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
// 구형 스냅샷: 환율이 단순 숫자 (절사규칙·자릿수 미박제)
$legacy = [
'base_currency' => 'KRW',
'order_currency' => 'JPY',
'exchange_rates' => ['JPY' => 115.0],
];
$this->assertSame('¥18,860.00', $svc->formatPrice(18860, 'JPY', $legacy));
}
/**
* 스냅샷을 주지 않은 리소스는 현재 설정으로 동작한다 (상품 등 카탈로그 경로 회귀 방지).
*
* @scenario saved_currency_set=all_five, deletion_tombstone=none_legacy
*
* @effects legacy_snapshot_without_decimal_places_falls_back_to_settings
*/
public function test_resource_without_snapshot_uses_live_settings(): void
{
$resource = $this->resourceStub();
// KRW 는 현재 설정에 살아 있고 0자리
$formatted = $resource->formatStored(['KRW' => 129000]);
$this->assertSame('129,000원', $formatted['KRW']['formatted']);
}
}
@@ -0,0 +1,365 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 기본 제공 통화의 의도적 삭제 영속화 테스트 (공개 #91)
*
* U11-A 는 array_merge 통째 교체로 defaults 통화가 소실되던 문제를 code 기준 보충으로 막았으나,
* "부작용 소실" 과 "관리자 의도 삭제" 를 구분할 장치가 없어 의도 삭제까지 되돌렸다.
* 저장 시점에 삭제 의도를 서버가 도출해 `removed_default_currencies` 로 기록하고,
* 조회 병합이 그 기록을 존중하는지 검증한다.
*/
class EcommerceSettingsServiceCurrencyTombstoneTest extends ModuleTestCase
{
private EcommerceSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
$this->service = new EcommerceSettingsService;
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
// ──────────────────────────────────────────────
// 헬퍼
// ──────────────────────────────────────────────
/**
* 통화 코드 목록으로 저장 payload 의 currencies 배열을 만듭니다.
*
* @param array<int, string> $codes 통화 코드 목록
* @param string $defaultCode 기본 통화 코드
* @return array 통화 항목 배열
*/
private function currencies(array $codes, string $defaultCode = 'KRW'): array
{
$meta = [
'KRW' => ['rate' => null, 'unit' => '1', 'method' => 'floor', 'dp' => 0, 'base_unit' => 1000],
'USD' => ['rate' => 0.85, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'JPY' => ['rate' => 115, 'unit' => '1', 'method' => 'floor', 'dp' => 0, 'base_unit' => 100],
'CNY' => ['rate' => 5.8, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'EUR' => ['rate' => 0.78, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'GBP' => ['rate' => 0.6, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
];
return array_map(function (string $code) use ($meta, $defaultCode) {
$m = $meta[$code] ?? ['rate' => 1.0, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1];
return [
'code' => $code,
'name' => ['ko' => $code, 'en' => $code],
'exchange_rate' => $code === $defaultCode ? null : $m['rate'],
'base_unit' => $m['base_unit'],
'rounding_unit' => $m['unit'],
'rounding_method' => $m['method'],
'decimal_places' => $m['dp'],
'is_default' => $code === $defaultCode,
];
}, $codes);
}
/**
* 관리자 저장 경로(saveSettings)로 language_currency 를 저장합니다.
*
* @param array $languageCurrency language_currency 카테고리 payload
*/
private function save(array $languageCurrency): void
{
$this->service->saveSettings(['language_currency' => $languageCurrency]);
$this->service->clearCache();
}
/**
* 저장된 language_currency.json 을 그대로 읽습니다.
*
* @return array 저장 파일의 디코드 결과
*/
private function savedFile(): array
{
$path = $this->storagePath.'/language_currency.json';
if (! File::exists($path)) {
return [];
}
return json_decode(File::get($path), true) ?? [];
}
/**
* 현재 조회 결과의 통화 코드 목록을 반환합니다.
*
* @return array<int, string> 통화 코드 목록
*/
private function resolvedCodes(): array
{
$lc = $this->service->getSettings('language_currency');
return array_values(array_map(fn ($c) => $c['code'] ?? null, $lc['currencies'] ?? []));
}
// ──────────────────────────────────────────────
// 삭제 영속화 (핵심)
// ──────────────────────────────────────────────
/**
* 관리자가 삭제한 기본 제공 통화가 저장 후에도 되살아나지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects intentionally_removed_default_currency_stays_deleted
*/
public function test_deleted_default_currency_stays_deleted_after_save(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes);
$this->assertContains('USD', $codes);
$this->assertNotContains('JPY', $codes, '관리자가 삭제한 JPY 가 defaults 에서 되살아났습니다.');
$this->assertNotContains('CNY', $codes, '관리자가 삭제한 CNY 가 defaults 에서 되살아났습니다.');
$this->assertNotContains('EUR', $codes, '관리자가 삭제한 EUR 가 defaults 에서 되살아났습니다.');
}
/**
* 삭제 의도가 저장 파일에 tombstone 으로 기록된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_tombstone_written_to_saved_file(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$saved = $this->savedFile();
$this->assertArrayHasKey('removed_default_currencies', $saved, '삭제 기록이 저장되지 않았습니다.');
$this->assertSame(['JPY', 'CNY', 'EUR'], $saved['removed_default_currencies']);
// 비연속 키가 JSON 객체로 직렬화되지 않도록 array_values 재정렬 (CLAUDE.md 규칙)
$this->assertSame([0, 1, 2], array_keys($saved['removed_default_currencies']));
}
/**
* 기본 통화 코드는 제출에서 빠져도 tombstone 되지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects base_currency_never_tombstoned
*/
public function test_default_currency_code_never_tombstoned(): void
{
// API 직접 제출로 기본 통화(KRW)가 빠진 목록을 보낸 상황
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['USD'], 'USD'),
]);
$saved = $this->savedFile();
$this->assertNotContains('KRW', $saved['removed_default_currencies'] ?? [], '기본 통화가 tombstone 되었습니다.');
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes, '기본 통화가 조회 결과에서 사라졌습니다.');
$lc = $this->service->getSettings('language_currency');
$krw = collect($lc['currencies'])->firstWhere('code', 'KRW');
$this->assertTrue($krw['is_default'], '기본 통화의 is_default 가 유지되어야 합니다.');
}
/**
* 삭제한 통화를 다시 추가하면 tombstone 이 해제되고 복원된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=readded
*
* @effects intentionally_removed_default_currency_stays_deleted, tombstone_recomputed_server_side_on_save
*/
public function test_readding_currency_clears_tombstone_and_resurrects(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$this->assertSame(['USD', 'JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
// USD 재등록
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$codes = $this->resolvedCodes();
$this->assertContains('USD', $codes, '재추가한 통화가 복원되지 않았습니다.');
$this->assertSame(['JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
}
/**
* 관리자가 추가한 통화(defaults 밖)의 삭제는 종전대로 유지된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects intentionally_removed_default_currency_stays_deleted
*/
public function test_admin_added_currency_still_deletable(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'GBP']),
]);
$this->assertContains('GBP', $this->resolvedCodes());
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$this->assertNotContains('GBP', $this->resolvedCodes(), '관리자 추가 통화의 삭제가 유지되지 않았습니다.');
// defaults 밖 통화는 tombstone 대상이 아니다 (보충 병합이 되살리지 않으므로 기록 불필요)
$this->assertNotContains('GBP', $this->savedFile()['removed_default_currencies'] ?? []);
}
/**
* 다른 탭 저장은 language_currency 의 삭제 상태를 되돌리지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects other_category_save_preserves_tombstone
*/
public function test_other_category_save_preserves_tombstone(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$before = $this->savedFile();
// 기본정보 탭만 저장
$this->service->saveSettings(['basic_info' => ['shop_name' => ['ko' => '테스트샵', 'en' => 'Test Shop']]]);
$this->service->clearCache();
$this->assertSame($before, $this->savedFile(), '타 카테고리 저장이 language_currency 파일을 변경했습니다.');
$this->assertSame(['KRW'], $this->resolvedCodes());
}
/**
* currencies 키 없이 language_currency 를 저장해도 tombstone 이 이월된다.
*
* saveCategorySettings 는 파일 통째 교체라 이월하지 않으면 삭제가 전부 부활한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects other_category_save_preserves_tombstone
*/
public function test_language_currency_save_without_currencies_preserves_tombstone(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
// default_currency 만 담긴 저장 (currencies 키 부재)
$this->save(['default_currency' => 'KRW']);
$this->assertSame(['USD', 'JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
$this->assertSame(['KRW'], $this->resolvedCodes(), 'currencies 미제출 저장이 삭제 통화를 부활시켰습니다.');
}
/**
* tombstone 키가 없는 구 저장본은 U11-A 보충 동작을 그대로 유지한다.
*
* @scenario saved_currency_set=jpy_missing, deletion_tombstone=none_legacy
*
* @effects legacy_file_without_tombstone_keeps_supplement
*/
public function test_legacy_saved_file_without_tombstone_supplements_defaults(): void
{
File::ensureDirectoryExists($this->storagePath);
File::put(
$this->storagePath.'/language_currency.json',
json_encode([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$this->service->clearCache();
$codes = $this->resolvedCodes();
$this->assertContains('JPY', $codes, '구 저장본의 defaults 보충(U11-A)이 깨졌습니다.');
$this->assertContains('CNY', $codes);
$this->assertContains('EUR', $codes);
}
/**
* 클라이언트가 보낸 tombstone 값은 무시되고 서버가 재계산한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_client_supplied_tombstone_is_recomputed(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD', 'JPY', 'CNY', 'EUR']),
'removed_default_currencies' => ['KRW', 'USD'],
]);
$this->assertSame([], $this->savedFile()['removed_default_currencies'] ?? null, '클라이언트 값이 그대로 저장되었습니다.');
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes);
$this->assertContains('USD', $codes);
}
/**
* tombstone 은 프론트엔드 공개 설정에 노출되지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_tombstone_not_exposed_in_frontend_settings(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$frontend = $this->service->getFrontendSettings();
$this->assertArrayHasKey('language_currency', $frontend);
$this->assertArrayNotHasKey(
'removed_default_currencies',
$frontend['language_currency'],
'내부 삭제 기록이 공개 설정에 노출되었습니다.'
);
}
}
@@ -4,9 +4,11 @@ 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;
@@ -96,6 +98,80 @@ class OrderProcessingCurrencySnapshotTest extends ModuleTestCase
$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) — 핵심
// ──────────────────────────────────────────────
@@ -0,0 +1,152 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 목록형 설정의 삭제 영속성 실측 (공개 #91 전수 조사)
*
* defaults.json 이 항목을 제공하고 관리자 화면이 그 항목의 삭제를 제공하는 목록 설정 전부에 대해,
* 삭제 저장 후 재조회에서 항목이 되살아나지 않는지 확인한다. 통화(currencies)만 code 기준
* 보충 병합을 거치고 나머지는 통째 교체이므로 삭제가 그대로 유지되어야 하는데, 이 구분은
* 코드를 읽어야만 드러나므로 실측으로 고정한다.
*/
class SettingsListDeletionPersistenceTest extends ModuleTestCase
{
private EcommerceSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
$this->service = new EcommerceSettingsService;
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
/**
* 카테고리의 목록 값을 저장하고 재조회 결과를 돌려줍니다.
*
* @param string $category 설정 카테고리
* @param string $key 목록 키
* @param array $list 저장할 목록
* @return array 재조회된 목록
*/
private function saveAndReload(string $category, string $key, array $list): array
{
$current = $this->service->getSettings($category);
$current[$key] = $list;
$this->service->saveSettings([$category => $current]);
$this->service->clearCache();
return $this->service->getSettings($category)[$key] ?? [];
}
/**
* 은행 목록에서 항목을 삭제하면 유지된다. (통째 교체)
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects non_supplemented_list_settings_keep_deletion
*/
public function test_deleted_bank_stays_deleted(): void
{
$banks = $this->service->getSettings('order_settings')['banks'] ?? [];
$this->assertGreaterThan(1, count($banks), '기본 은행 목록이 비어 있어 삭제를 측정할 수 없습니다.');
$kept = array_values(array_slice($banks, 0, 2));
$reloaded = $this->saveAndReload('order_settings', 'banks', $kept);
$this->assertCount(2, $reloaded, '삭제한 은행이 defaults 에서 되살아났습니다.');
}
/**
* 입금 계좌 목록에서 항목을 삭제하면 유지된다. (통째 교체)
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects non_supplemented_list_settings_keep_deletion
*/
public function test_deleted_bank_account_stays_deleted(): void
{
$accounts = $this->service->getSettings('order_settings')['bank_accounts'] ?? [];
$this->assertNotEmpty($accounts, '기본 입금 계좌가 없어 삭제를 측정할 수 없습니다.');
$reloaded = $this->saveAndReload('order_settings', 'bank_accounts', []);
$this->assertSame([], $reloaded, '삭제한 입금 계좌가 defaults 에서 되살아났습니다.');
}
/**
* 배송 가능 국가에서 항목을 삭제하면 유지된다. (통째 교체 + 라벨 보강만)
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects non_supplemented_list_settings_keep_deletion
*/
public function test_deleted_shipping_country_stays_deleted(): void
{
$countries = $this->service->getSettings('shipping')['available_countries'] ?? [];
$this->assertGreaterThan(1, count($countries), '기본 배송 국가 목록이 비어 있어 삭제를 측정할 수 없습니다.');
$kept = array_values(array_slice($countries, 0, 1));
$reloaded = $this->saveAndReload('shipping', 'available_countries', $kept);
$this->assertCount(1, $reloaded, '삭제한 배송 국가가 defaults 에서 되살아났습니다.');
}
/**
* 마일리지 통화 규칙에서 항목을 삭제하면 유지된다. (통째 교체)
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects non_supplemented_list_settings_keep_deletion
*/
public function test_deleted_mileage_currency_rule_stays_deleted(): void
{
$rules = $this->service->getSettings('mileage')['currency_rules'] ?? [];
$this->assertNotEmpty($rules, '기본 마일리지 통화 규칙이 없어 삭제를 측정할 수 없습니다.');
$reloaded = $this->saveAndReload('mileage', 'currency_rules', []);
$this->assertSame([], $reloaded, '삭제한 마일리지 통화 규칙이 defaults 에서 되살아났습니다.');
}
/**
* 결제수단은 카탈로그 병합 모델이라 저장본에서 빠져도 카탈로그에서 다시 채워진다.
*
* 삭제 UX 가 아니라 is_active 토글 모델이므로 #91 과 다른 구조다 — 그 사실을 고정한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects catalog_merged_list_settings_refill_by_design
*/
public function test_payment_methods_are_catalog_merged_not_deletable(): void
{
$methods = $this->service->getSettings('order_settings')['payment_methods'] ?? [];
$this->assertNotEmpty($methods, '기본 결제수단 카탈로그가 비어 있습니다.');
$reloaded = $this->saveAndReload('order_settings', 'payment_methods', []);
$this->assertNotEmpty($reloaded, '결제수단은 카탈로그에서 다시 채워져야 합니다(토글 모델).');
}
}
@@ -1,23 +1,34 @@
# audit:allow test-scenario-coverage reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
# 환율 영속성 axis 는 PHPUnit(EcommerceSettingsServiceTest) 가 전수 커버하며 green 이다.
feature: 통화 환율 영속성 — code 기준 병합 (U11-A)
feature: 통화 목록 영속성 — code 기준 병합(U11-A) + 의도적 삭제 기록(공개 #91)
description: |
language_currency.currencies 는 정수키 리스트라 array_merge 가 통째 교체되어,
관리자가 일부 통화를 빼고 저장하면 defaults 통화가 영구 소실되던 영속성 공백을 수정한다.
getAllSettings() 에서 code 기준 병합(mergeCurrenciesByCode)으로 defaults 보충(환율은 저장본 우선).
그 보충은 "array_merge 부작용으로 인한 소실" 을 되돌리기 위한 것이었으나 관리자의 의도적
삭제와 구분할 장치가 없어, 기본 제공 통화를 삭제하면 저장 응답에서 이미 되살아났다(공개 #91).
저장 시점에 삭제 의도를 서버가 도출해 저장본에 기록하고, 조회 병합이 그 기록을 존중한다.
삭제가 실제로 영속되면 처음 도달 가능해지는 후속 경로(사용 중지된 통화를 표시 통화로 가진
구매자의 체크아웃 차단)도 같은 축에서 방어한다.
axes:
saved_currency_set:
- all_five # 전 5종 저장
- jpy_missing # JPY 빼고 저장
- usd_missing # USD 빼고 저장
- new_added # 관리자 신규 통화 추가
- all_five # 전 5종 저장
- jpy_missing # JPY 빼고 저장
- usd_missing # USD 빼고 저장
- new_added # 관리자 신규 통화 추가
- default_removed_by_admin # 관리자 의도 삭제 (공개 #91)
get_settings_result:
- defaults_supplemented # 누락 통화 defaults 보충됨
- defaults_supplemented # 누락 통화 defaults 보충됨
exchange_rate_preservation:
- saved_value_kept # 저장본 exchange_rate 보존
- saved_value_kept # 저장본 exchange_rate 보존
deletion_tombstone: # 공개 #91 — 의도적 삭제 영속화
- none_legacy # 구 저장본(키 없음) → U11-A 보충 유지
- has_removed_codes # 삭제 기록 보유 → defaults 보충 스킵
- readded # 재추가 저장 → 기록 해제·부활
effects:
- missing_currency_supplemented_from_defaults # array_merge 통째교체로 통화 소실 안 됨
@@ -25,6 +36,64 @@ effects:
- new_added_currency_preserved # 저장본에만 있는 신규 통화 보존
- default_currency_exchange_rate_null # 기본통화 exchange_rate null 유지(syncCurrencyDefaults)
- currency_conversion_uses_persisted_rate # 환율 109 설정 시 JPY 정확 환산, null 시 제외
# 공개 #91
- intentionally_removed_default_currency_stays_deleted # 삭제한 기본 제공 통화 미부활
- tombstone_recomputed_server_side_on_save # 삭제 기록은 서버가 재계산(클라이언트 값 불신)
- base_currency_never_tombstoned # 기본 통화는 기록 대상 제외 → 항상 생존
- other_category_save_preserves_tombstone # 타 탭/통화 미제출 저장이 삭제를 되돌리지 않음
- legacy_file_without_tombstone_keeps_supplement # 구 저장본은 U11-A 보충 그대로
- unregistered_active_currency_falls_back_to_base # 미등록 표시/선호 통화 → 기본 통화 폴백
- non_supplemented_list_settings_keep_deletion # 보충 병합을 거치지 않는 목록 설정은 삭제 유지
- catalog_merged_list_settings_refill_by_design # 카탈로그 병합 목록(결제수단)은 토글 모델이라 재충전
# 공개 #91 후속 — 삭제된 통화로 결제된 과거 주문
- deleted_currency_order_refund_uses_snapshot_rate # 환불 재계산이 스냅샷 환율을 사용
- deleted_currency_display_keeps_snapshot_decimal_places # 표기 자릿수도 주문 시점 값 유지
- legacy_snapshot_without_decimal_places_falls_back_to_settings # 구형 스냅샷은 현재 설정 폴백
sub_flows:
- id: intentional_deletion_round_trip
description: |
삭제 → 저장 → 재조회 왕복. 서비스 계층뿐 아니라 저장 응답(PUT) 자체가 삭제를
반영해야 한다 — 제보된 증상이 "저장 응답에 이미 5종이 부활" 이었기 때문이다.
effects:
- intentionally_removed_default_currency_stays_deleted
- tombstone_recomputed_server_side_on_save
- base_currency_never_tombstoned
- id: deletion_side_effects
description: 삭제가 영속되면 처음 열리는 후속 경로(미등록 통화가 결제 통화로 채택되는 경우) 방어
effects:
- unregistered_active_currency_falls_back_to_base
- id: deleted_currency_existing_orders
description: |
통화를 삭제해도 그 통화로 결제된 과거 주문은 환불 재계산·표기가 불변이어야 한다.
금액은 스냅샷 환율·절사규칙을 쓰므로 원래 불변이었으나, 소수 자릿수만 현재 설정에서
조회해 삭제 시 폴백 2자리가 적용됐다(0자리 통화 표기 변화 / 3자리 이상 통화 절사).
effects:
- deleted_currency_order_refund_uses_snapshot_rate
- deleted_currency_display_keeps_snapshot_decimal_places
- legacy_snapshot_without_decimal_places_falls_back_to_settings
- id: list_settings_deletion_sweep
description: |
defaults 가 항목을 제공하고 화면이 삭제를 제공하는 다른 목록 설정(은행·입금계좌·배송 국가·
마일리지 통화 규칙)의 삭제 영속성 실측. 통화만 항목 단위 보충을 거치고 나머지는 통째
교체라 삭제가 유지되는데, 이 구분은 코드를 읽어야만 드러나므로 실측으로 고정한다.
effects:
- non_supplemented_list_settings_keep_deletion
- catalog_merged_list_settings_refill_by_design
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsServiceCurrencyMergeTest.php
# 공개 #91 — 삭제 영속화 (서비스 계층)
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsServiceCurrencyTombstoneTest.php
# 공개 #91 — 저장 응답 왕복 (제보된 증상 층)
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsCurrencyDeletionTest.php
# 공개 #91 — 미등록 통화 폴백 (후속 경로 방어)
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingCurrencySnapshotTest.php
# 공개 #91 전수 조사 — 다른 목록형 설정의 삭제 영속성 실측
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/SettingsListDeletionPersistenceTest.php
# 공개 #91 후속 — 통화 삭제 후 기존 주문의 금액·표기 불변성
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/DeletedCurrencyOrderIntegrityTest.php
notes: |
E2E 미해당: 변경 범위가 백엔드 전용(Service)이며 레이아웃 JSON·컴포넌트·엔진 코드 변경이 없다.
브라우저 검증은 Chrome MCP 매트릭스가 담당한다.
@@ -97,7 +97,7 @@ test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ConfirmDepositControllerTest.php
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminOrderConfirmDeposit.test.tsx
- templates/_bundled/sirsoft-basic/__tests__/layouts/orderReviewButton.test.tsx
- tests/Playwright/specs/order-option-status-review.spec.ts
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/order-option-status-review.spec.ts
validation:
audit_rule: frontend-change-requires-e2e
@@ -90,7 +90,7 @@ test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderServiceTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Notifications/OrderStatusNotificationTest.php
- tests/Playwright/specs/admin/order-status-notification.spec.ts
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-status-notification.spec.ts
validation:
audit_rule: test-scenario-coverage
@@ -1,11 +1,11 @@
{
"schema_version": "1.0",
"generated_at": "2026-08-06T03:32:02+00:00",
"generated_at": "2026-08-11T07:27:43+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
"composer_json_sha256": "cf5460aed4b90364c33889e3bd479eeb87c05b8f4b1ee94886575e14dcf441d1",
"composer_json_sha256": "c1a7e6cb8fcf68b62af5b7817509e2a2df6cb19348b9951ec8ea30634f3030d3",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
"zip_sha256": "a475756e4c92ad685d324cacb83beab2c8f94e5d29d8b33bf64dd63b12180f67",
"zip_sha256": "9c9bbaa646fdc7500234699362177f825bd421a2981afac5168a4fee554f5d5e",
"zip_size": 435548,
"package_count": 1,
"php_requirement": "^8.2",
Binary file not shown.