Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/OrderResourceFieldsTest.php
T
HeuJung e21bff678f fix(core,ecommerce,board,basic,pay_kginicis,pay_nhnkcp,pay_nicepayments): 7.0.6 회귀 3건·잠복 결함 3건 해소 + 통화 하드코딩 전수 제거
7.0.6 사이클에서 유입된 회귀 3건을 출시 전에 닫는다. 추가옵션 저장소의
반환 타입 선언이 실제 반환(중첩 맵)과 어긋나 추가옵션 있는 상품의 담기·
바로구매가 죽었고, 검색 엔진이 결과 소비 시점에 SELECT 를 재작성해 정렬이
참조하던 스코어 별칭을 지웠으며, 미들웨어 targets 가 늘어난 라우트를
따라가지 못해 비회원 현금영수증이 전면 불능이었다. 셋 다 정적 검사의
사각지대였으므로 룰 2건과 계약 테스트를 함께 세워 재발을 막는다.

같은 유형의 잠복 결함 3건(빈 반환의 타입 불일치 2건, 없는 메서드 호출 1건)도
호출 경로가 생기는 순간 fatal 이므로 함께 닫았다.

브라우저 검수 중 드러난 선행 결함 5건을 이어서 수정한다. 엔진이 event 키로
선언한 액션을 React prop 으로 연결하지 않아 오류 없이 조용히 무반응이었고,
연결한 뒤에는 커스텀 컴포넌트의 이벤트 payload 가 유실됐다. 비회원 주문상세
응답에 현금영수증 키가 없어 발급 결과에 도달할 수 없었으며, 입금확인과
현금영수증 발급이 base 통화 금액을 써서 결제 통화가 다른 주문에서 각각
저장 실패와 잘못된 증빙을 냈다.

마지막으로 통화 단위를 코드·문구에서 걷어내 설정으로 이관한다. 다국어
문구의 원/円, 프론트의 KRW 고정표와 리터럴 폴백 173건, 서버 포맷의 통화
코드 리터럴이 대상이며, 기본 통화가 원이 아닌 상점에서 값은 맞고 단위만
틀린 금액이 예외 없이 나가던 문제였다. 통화 판정을 설정 단일 출처로 모으고
규칙 3건을 정적 검사로 고정했다.
2026-08-10 09:07:46 +09:00

589 lines
27 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Resources;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\MileageTransactionTypeEnum;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Http\Resources\OrderOptionResource;
use Modules\Sirsoft\Ecommerce\Http\Resources\OrderPaymentResource;
use Modules\Sirsoft\Ecommerce\Http\Resources\OrderResource;
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* OrderResource / OrderOptionResource 필드 테스트
*
* 주문상세 합계행 수정 및 다통화 표시 추가 관련 필드 검증
*/
class OrderResourceFieldsTest extends ModuleTestCase
{
/**
* OrderOptionResource에 final_amount, final_amount_formatted 필드가 포함되는지 확인
*/
public function test_order_option_resource_includes_final_amount_fields(): void
{
// Given: 주문 옵션 생성 (subtotal_price=30000, discount=1000, points=0, deposit=0)
$order = OrderFactory::new()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'unit_price' => 10000,
'quantity' => 3,
'subtotal_price' => 30000,
'subtotal_discount_amount' => 1000,
'subtotal_points_used_amount' => 0,
'subtotal_deposit_used_amount' => 0,
]);
// When: 리소스 변환
$resource = (new OrderOptionResource($option))->resolve();
// Then: final_amount = 30000 - 1000 - 0 - 0 = 29000
$this->assertEquals(29000, $resource['final_amount']);
$this->assertEquals('29,000원', $resource['final_amount_formatted']);
}
/**
* OrderOptionResource에 list_price 필드가 option_snapshot에서 추출되는지 확인
*/
public function test_order_option_resource_includes_list_price_from_snapshot(): void
{
// Given: option_snapshot에 list_price=15000, unit_price=10000
$order = OrderFactory::new()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'unit_price' => 10000,
'option_snapshot' => [
'list_price' => 15000,
'selling_price' => 10000,
],
]);
// When: 리소스 변환
$resource = (new OrderOptionResource($option))->resolve();
// Then: list_price가 스냅샷에서 추출
$this->assertEquals(15000, $resource['list_price']);
$this->assertEquals('15,000원', $resource['list_price_formatted']);
}
/**
* OrderOptionResource에서 mc_subtotal_discount_amount 필드가 제거되었는지 확인
*/
public function test_order_option_resource_does_not_include_mc_subtotal_discount_amount(): void
{
// Given: 주문 옵션 생성
$order = OrderFactory::new()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create();
// When: 리소스 변환
$resource = (new OrderOptionResource($option))->resolve();
// Then: mc_subtotal_discount_amount 필드 미존재 (버그 수정)
$this->assertArrayNotHasKey('mc_subtotal_discount_amount', $resource);
}
/**
* OrderResource에 total_quantity 필드가 옵션 수량 합계를 반환하는지 확인
*/
public function test_order_resource_includes_total_quantity(): void
{
// Given: 주문에 옵션 3개 (수량 2, 3, 5)
$order = OrderFactory::new()->create();
OrderOptionFactory::new()->forOrder($order)->create(['quantity' => 2, 'unit_price' => 10000, 'subtotal_price' => 20000]);
OrderOptionFactory::new()->forOrder($order)->create(['quantity' => 3, 'unit_price' => 10000, 'subtotal_price' => 30000]);
OrderOptionFactory::new()->forOrder($order)->create(['quantity' => 5, 'unit_price' => 10000, 'subtotal_price' => 50000]);
// When: 리소스 변환 (options 관계 로드 필요)
$order->load('options');
$resource = (new OrderResource($order))->resolve();
// Then: total_quantity = 2 + 3 + 5 = 10
$this->assertEquals(10, $resource['total_quantity']);
}
/**
* OrderResource에 total_list_price 필드가 스냅샷의 list_price * quantity 합계를 반환하는지 확인
*/
public function test_order_resource_includes_total_list_price(): void
{
// Given: 주문에 옵션 2개
$order = OrderFactory::new()->create();
// 옵션1: list_price=15000, quantity=2 → 30000
OrderOptionFactory::new()->forOrder($order)->create([
'quantity' => 2,
'unit_price' => 10000,
'subtotal_price' => 20000,
'option_snapshot' => ['list_price' => 15000, 'selling_price' => 10000],
]);
// 옵션2: list_price=20000, quantity=1 → 20000
OrderOptionFactory::new()->forOrder($order)->create([
'quantity' => 1,
'unit_price' => 18000,
'subtotal_price' => 18000,
'option_snapshot' => ['list_price' => 20000, 'selling_price' => 18000],
]);
// When: 리소스 변환
$order->load('options');
$resource = (new OrderResource($order))->resolve();
// Then: total_list_price = 30000 + 20000 = 50000
$this->assertEquals(50000, $resource['total_list_price']);
$this->assertEquals('50,000원', $resource['total_list_price_formatted']);
}
/**
* OrderResource의 total_list_price가 list_price 없을 때 unit_price로 폴백하는지 확인
*/
public function test_order_resource_total_list_price_falls_back_to_unit_price(): void
{
// Given: option_snapshot에 list_price가 없는 경우
$order = OrderFactory::new()->create();
OrderOptionFactory::new()->forOrder($order)->create([
'quantity' => 2,
'unit_price' => 10000,
'subtotal_price' => 20000,
'option_snapshot' => ['selling_price' => 10000],
'product_snapshot' => ['selling_price' => 10000],
]);
// When: 리소스 변환
$order->load('options');
$resource = (new OrderResource($order))->resolve();
// Then: unit_price 폴백 → 10000 * 2 = 20000
$this->assertEquals(20000, $resource['total_list_price']);
}
/**
* OrderResource가 마일리지 사용/적립/환불 마일리지 합계 필드를 노출하는지 확인
*
* 회귀 차단: 마일리지 시스템 도입 전 완성된 주문상세/목록 화면이 이 필드를 기대하므로
* Resource 가 누락하면 화면에 마일리지가 표시되지 않는다.
*/
public function test_order_resource_exposes_mileage_and_refund_points_fields(): void
{
// Given: 마일리지 사용 5000, 적립 2400, 마일리지 환불 1500 인 주문
$order = OrderFactory::new()->create([
'total_points_used_amount' => 5000,
'total_earned_points_amount' => 2400,
'total_refunded_points_amount' => 1500,
]);
// When: 리소스 변환
$resource = (new OrderResource($order))->resolve();
// Then: 사용/적립/환불 마일리지 값 + 포맷이 모두 노출
$this->assertEquals(5000, $resource['total_points_used_amount']);
$this->assertEquals('5,000원', $resource['total_points_used_amount_formatted']);
$this->assertEquals(2400, $resource['total_earned_points_amount']);
$this->assertEquals('2,400원', $resource['total_earned_points_amount_formatted']);
$this->assertEquals(1500, $resource['total_refunded_points_amount']);
$this->assertEquals('1,500원', $resource['total_refunded_points_amount_formatted']);
}
/**
* OrderOptionResource의 final_amount가 할인+마일리지+예치금 모두 차감하는지 확인
*/
public function test_order_option_resource_final_amount_deducts_all(): void
{
// Given: 할인+마일리지+예치금 모두 있는 경우
$order = OrderFactory::new()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'unit_price' => 50000,
'quantity' => 1,
'subtotal_price' => 50000,
'subtotal_discount_amount' => 5000,
'subtotal_points_used_amount' => 2000,
'subtotal_deposit_used_amount' => 1000,
]);
// When: 리소스 변환
$resource = (new OrderOptionResource($option))->resolve();
// Then: final_amount = 50000 - 5000 - 2000 - 1000 = 42000
$this->assertEquals(42000, $resource['final_amount']);
$this->assertEquals('42,000원', $resource['final_amount_formatted']);
}
/**
* OrderOptionResource가 재고 적용 여부(is_stock_deducted)를 boolean 으로 노출하는지 확인
*
* 주문상세 '구매수량' 컬럼의 재고 차감/미차감 배지가 이 필드를 사용한다.
*/
public function test_order_option_resource_exposes_is_stock_deducted(): void
{
// Given: 재고 차감 완료 옵션 / 미차감 옵션
$order = OrderFactory::new()->create();
$deducted = OrderOptionFactory::new()->forOrder($order)->create(['is_stock_deducted' => true]);
$notDeducted = OrderOptionFactory::new()->forOrder($order)->create(['is_stock_deducted' => false]);
// When: 리소스 변환
$deductedResource = (new OrderOptionResource($deducted))->resolve();
$notDeductedResource = (new OrderOptionResource($notDeducted))->resolve();
// Then: boolean 타입으로 차감 여부 노출
$this->assertArrayHasKey('is_stock_deducted', $deductedResource);
$this->assertTrue($deductedResource['is_stock_deducted']);
$this->assertFalse($notDeductedResource['is_stock_deducted']);
}
/**
* is_points_earned 가 구매 적립 거래 미발행 시 false 로 노출되는지 확인
*
* 적립예정액은 있으나 실제 적립이 아직 발생하지 않은 상태 → '적립예정' 배지.
*/
public function test_order_option_resource_is_points_earned_false_without_earn_transaction(): void
{
// Given: 적립예정액은 있으나 적립 거래는 없는 주문옵션
$order = OrderFactory::new()->create(['user_id' => $this->createUser()->id]);
OrderOptionFactory::new()->forOrder($order)->create([
'subtotal_earned_points_amount' => 520,
]);
// When: Repository 의 findWithRelations 로 로드 (withExists 집계 경로 검증)
$loaded = app(OrderRepositoryInterface::class)->findWithRelations($order->id);
$resource = (new OrderOptionResource($loaded->options->first()))->resolve();
// Then: 적립 거래 미발행 → false
$this->assertArrayHasKey('is_points_earned', $resource);
$this->assertFalse($resource['is_points_earned']);
}
/**
* is_points_earned 가 구매 적립 거래 발행 시 true 로 노출되는지 확인
*
* 실제 PURCHASE_EARN 거래가 해당 옵션에 발행된 상태 → '적립완료' 배지.
*/
public function test_order_option_resource_is_points_earned_true_with_purchase_earn_transaction(): void
{
// Given: 적립예정액이 있는 주문옵션 + 해당 옵션에 PURCHASE_EARN 거래 발행
$user = $this->createUser();
$order = OrderFactory::new()->create(['user_id' => $user->id]);
$option = OrderOptionFactory::new()->forOrder($order)->create([
'subtotal_earned_points_amount' => 520,
]);
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => MileageTransactionTypeEnum::PURCHASE_EARN->value,
'amount' => 520,
'remaining_amount' => 520,
'balance_after' => 520,
'order_id' => $order->id,
'order_option_id' => $option->id,
]);
// When: Repository 의 findWithRelations 로 로드
$loaded = app(OrderRepositoryInterface::class)->findWithRelations($order->id);
$resource = (new OrderOptionResource($loaded->options->first()))->resolve();
// Then: 구매 적립 발행됨 → true
$this->assertTrue($resource['is_points_earned']);
}
/**
* is_points_earned 가 적립 외 거래(사용/취소복원)에는 반응하지 않는지 확인
*
* existsEarnForOption 의 EARN_TYPES 와 달리, 화면 '적립완료' 판정은
* 구매 적립(PURCHASE_EARN)만 대상으로 한다. 사용/복원 거래로 오탐되면 안 된다.
*/
public function test_order_option_resource_is_points_earned_ignores_non_purchase_earn_transactions(): void
{
// Given: 적립예정액이 있는 주문옵션 + 해당 옵션에 ORDER_USE(사용) 거래만 존재
$user = $this->createUser();
$order = OrderFactory::new()->create(['user_id' => $user->id]);
$option = OrderOptionFactory::new()->forOrder($order)->create([
'subtotal_earned_points_amount' => 520,
]);
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => MileageTransactionTypeEnum::ORDER_USE->value,
'amount' => -1000,
'remaining_amount' => 0,
'balance_after' => 0,
'order_id' => $order->id,
'order_option_id' => $option->id,
]);
// When: Repository 의 findWithRelations 로 로드
$loaded = app(OrderRepositoryInterface::class)->findWithRelations($order->id);
$resource = (new OrderOptionResource($loaded->options->first()))->resolve();
// Then: 구매 적립이 아니므로 false
$this->assertFalse($resource['is_points_earned']);
}
/**
* OrderResource 가 cancelled_at native 컬럼과 cancelled_at_formatted 를 노출하는지 확인.
*/
public function test_order_resource_exposes_cancelled_at_and_formatted(): void
{
$order = OrderFactory::new()->create(['cancelled_at' => now()]);
$resource = (new OrderResource($order))->resolve();
$this->assertArrayHasKey('cancelled_at', $resource);
$this->assertArrayHasKey('cancelled_at_formatted', $resource);
$this->assertNotNull($resource['cancelled_at']);
$this->assertNotEmpty($resource['cancelled_at_formatted']);
}
/**
* cancelled_at 이 null 이면 cancelled_at_formatted 도 null 이다.
*/
public function test_order_resource_cancelled_at_formatted_null_when_not_cancelled(): void
{
$order = OrderFactory::new()->create(['cancelled_at' => null]);
$resource = (new OrderResource($order))->resolve();
$this->assertNull($resource['cancelled_at']);
$this->assertNull($resource['cancelled_at_formatted']);
}
/**
* OrderPaymentResource 가 결제수단별 requires_pg_cancellation 플래그를 노출하는지 확인 (A28).
*/
public function test_order_payment_resource_exposes_requires_pg_cancellation(): void
{
$order = OrderFactory::new()->create();
$card = OrderPaymentFactory::new()
->forOrder($order)->create(['payment_method' => PaymentMethodEnum::CARD]);
$cardResource = (new OrderPaymentResource($card))->resolve();
$this->assertTrue($cardResource['requires_pg_cancellation'], '카드는 PG 취소 대상');
$dbankOrder = OrderFactory::new()->create();
$dbank = OrderPaymentFactory::new()
->forOrder($dbankOrder)->create(['payment_method' => PaymentMethodEnum::DBANK]);
$dbankResource = (new OrderPaymentResource($dbank))->resolve();
$this->assertFalse($dbankResource['requires_pg_cancellation'], '무통장은 PG 취소 대상 아님');
}
/**
* OrderResource 의 is_partially_cancelled 파생 플래그가 "취소 옵션 ∧ 잔여 활성 옵션" 일 때만 true 인지 확인.
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — 2026-06-22.)
*/
public function test_order_resource_exposes_is_partially_cancelled_flag(): void
{
// 일부만 취소: 활성 1 + 취소 1 → true
$partial = OrderFactory::new()->create(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]);
OrderOptionFactory::new()->forOrder($partial)->create(['option_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]);
OrderOptionFactory::new()->forOrder($partial)->create(['option_status' => OrderStatusEnum::CANCELLED->value]);
$partial->load('options');
$this->assertTrue((new OrderResource($partial))->resolve()['is_partially_cancelled']);
// 취소 없음: 활성 2 → false
$normal = OrderFactory::new()->create(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]);
OrderOptionFactory::new()->forOrder($normal)->create(['option_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]);
OrderOptionFactory::new()->forOrder($normal)->create(['option_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]);
$normal->load('options');
$this->assertFalse((new OrderResource($normal))->resolve()['is_partially_cancelled']);
// 전부 취소(전체취소): 활성 0 → false (부분취소 아님)
$full = OrderFactory::new()->create(['order_status' => OrderStatusEnum::CANCELLED->value]);
OrderOptionFactory::new()->forOrder($full)->create(['option_status' => OrderStatusEnum::CANCELLED->value]);
OrderOptionFactory::new()->forOrder($full)->create(['option_status' => OrderStatusEnum::CANCELLED->value]);
$full->load('options');
$this->assertFalse((new OrderResource($full))->resolve()['is_partially_cancelled']);
}
/**
* 구매확정 후 작성 기한(기본 90일)이 지나면 review_deadline_passed=true
*
* 버튼 노출 판정(Resource)이 제출 검증(ProductReviewService::canWrite)과
* 동일한 경계(confirmed_at + N일)를 사용함을 회귀로 고정한다.
*/
public function test_order_option_resource_review_deadline_passed_true_when_expired(): void
{
// Given: 구매확정 + 미작성 + 확정 100일 경과 (기본 기한 90일 초과)
$order = OrderFactory::new()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::CONFIRMED,
'confirmed_at' => now()->subDays(100),
]);
// When: 리소스 변환
$resource = (new OrderOptionResource($option))->resolve();
// Then: 기한 만료 안내 노출, 작성 버튼은 미노출
$this->assertTrue($resource['review_deadline_passed']);
$this->assertFalse($resource['can_write_review']);
}
/**
* 구매확정 후 작성 기한 이내(확정 직후)면 review_deadline_passed=false
*/
public function test_order_option_resource_review_deadline_passed_false_within_period(): void
{
// Given: 구매확정 + 미작성 + 확정 1일 경과 (기한 이내)
$order = OrderFactory::new()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::CONFIRMED,
'confirmed_at' => now()->subDays(1),
]);
// When: 리소스 변환
$resource = (new OrderOptionResource($option))->resolve();
// Then: 기한 이내이므로 만료 안내 미노출
$this->assertFalse($resource['review_deadline_passed']);
}
/**
* 구매확정 전(미확정) 옵션은 기한 만료 안내를 노출하지 않는다
*/
public function test_order_option_resource_review_deadline_passed_false_when_not_confirmed(): void
{
// Given: 배송완료(미확정) + confirmed_at 없음
$order = OrderFactory::new()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::DELIVERED,
'confirmed_at' => null,
]);
// When: 리소스 변환
$resource = (new OrderOptionResource($option))->resolve();
// Then: 미확정 상태에서는 만료 안내 미노출
$this->assertFalse($resource['review_deadline_passed']);
}
/**
* 과거 주문의 *_formatted 는 주문 시점 스냅샷 통화로 표기되며, 현재 기본 통화 설정 변경에 영향받지 않는다.
*
* 주문이 USD 기준으로 기록됐다면, 운영자가 이후 기본 통화를 KRW 로 바꿔도 그 주문의 합계 표기는
* 계속 "$..." 로 고정되어야 한다 (소급 통화 변경 방지).
*/
public function test_order_amount_formatted_uses_snapshot_currency_not_current_default(): void
{
// Given: USD 기준으로 기록된 주문 (currency_snapshot.base_currency = USD)
$order = OrderFactory::new()->create([
'currency' => 'USD',
'currency_snapshot' => ['base_currency' => 'USD', 'order_currency' => 'USD'],
'total_amount' => 50,
'subtotal_amount' => 50,
]);
// 현재 시스템 기본 통화는 KRW (기본값) 이지만, 주문 표기는 스냅샷 USD 를 따라야 한다.
$resource = (new OrderResource($order))->resolve();
// Then: 합계가 USD 기호($)로 표기되고, 원화(원)로 표기되지 않는다.
$this->assertStringContainsString('$', $resource['total_amount_formatted'], '주문 표기는 스냅샷 통화(USD) 기호여야 합니다.');
$this->assertStringNotContainsString('원', $resource['total_amount_formatted'], '기본 통화가 KRW 라도 USD 주문이 원화로 표기되면 안 됩니다.');
$this->assertSame('$50.00', $resource['total_amount_formatted']);
}
/**
* 자식 리소스(OrderPayment)의 base 통화 환산 필드는 주입된 주문 시점 통화로 포맷되고,
* 미주입 시 현재 기본 통화로 폴백한다.
*
* 반면 `paid_amount_local` 은 PG 가 **실제 청구한 결제 통화** 금액이므로 주입값과 무관하게
* 결제 행의 통화(`payment.currency`)로 포맷된다. 주입 통화로 표기하면 base≠결제 통화 주문에서
* 숫자와 기호의 단위가 어긋난다 (#421 기본통화≠결제통화 정합).
*/
public function test_child_resource_uses_injected_order_currency(): void
{
// Given: 결제 (paid_amount_local = 50, 결제 통화는 팩토리 기본 = KRW)
$order = OrderFactory::new()->create();
$payment = OrderPaymentFactory::new()->forOrder($order)->create([
'paid_amount_local' => 50,
'vat_amount' => 0,
]);
// When: USD 를 주입해 직렬화 (부모 → 자식 전파 경로)
$injected = (new OrderPaymentResource($payment))->withOrderCurrency('USD')->resolve();
// Then: base 환산 필드는 주입 통화(USD) 기호로 표기
$this->assertStringContainsString('$', $injected['vat_amount_formatted']);
$this->assertStringNotContainsString('원', $injected['vat_amount_formatted']);
// 실청구액은 결제 행 통화(KRW)를 그대로 따른다 — 주입에 흔들리지 않는다.
$this->assertSame('50원', $injected['paid_amount_formatted']);
$this->assertSame('KRW', $injected['payment_currency']);
// 미주입 시: base 환산 필드도 현재 기본 통화(KRW)로 폴백
$fallback = (new OrderPaymentResource($payment))->resolve();
$this->assertStringContainsString('원', $fallback['vat_amount_formatted']);
$this->assertSame('50원', $fallback['paid_amount_formatted']);
}
/**
* base 통화와 결제 통화가 다를 때, 결제 통화 기준 실청구액을 함께 내려야 한다.
*
* 서버의 입금·PG 금액 검증 SSoT 는 `resolveOrderPaymentChargeAmount()`(결제 통화 최소단위)다.
* 화면이 base 금액(`total_due_amount`)을 입금액으로 보내면 base≠결제 통화인 주문에서
* **항상** 금액 불일치 422 가 되어 운영자가 입금확인을 할 수 없다.
* 화면이 서버와 같은 값을 들 수 있도록 응답에 그 값을 노출한다.
*/
public function test_order_resource_exposes_payment_currency_charge_amount(): void
{
// base JPY 31,000 / 결제 통화 KRW (base_unit 100, rate 1) → 청구액 310 KRW
$order = OrderFactory::new()->create([
'total_amount' => 31000,
'total_due_amount' => 31000,
'currency' => 'KRW',
'currency_snapshot' => [
'base_currency' => 'JPY',
'order_currency' => 'KRW',
'exchange_rate' => 1,
'base_unit' => 100,
'exchange_rates' => [
'JPY' => ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'round', 'decimal_places' => 0, 'base_unit' => 100],
'KRW' => ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 1000],
],
],
]);
$expected = app(CurrencyConversionService::class)
->resolveOrderPaymentChargeAmount($order);
$resource = (new OrderResource($order))->resolve();
$this->assertSame('KRW', $resource['payment_currency']);
$this->assertTrue($resource['is_cross_currency']);
// 서버 검증 기준과 정확히 같은 값
$this->assertSame($expected, $resource['total_due_charge_amount']);
$this->assertSame(310, $resource['total_due_charge_amount']);
// 표시는 결제 통화 기호로
$this->assertStringContainsString('원', $resource['total_due_charge_amount_formatted']);
// base 금액은 종전대로 유지 (기존 소비처 무영향)
$this->assertSame(31000, (int) $resource['total_due_amount']);
}
/**
* base == 결제 통화면 청구액이 base 금액과 같아야 한다 (단일통화 상점 회귀 방지).
*/
public function test_order_resource_charge_amount_equals_base_when_single_currency(): void
{
$order = OrderFactory::new()->create([
'total_amount' => 12000,
'total_due_amount' => 12000,
'currency' => 'KRW',
'currency_snapshot' => [
'base_currency' => 'KRW',
'order_currency' => 'KRW',
'exchange_rate' => 1,
'base_unit' => 1,
'exchange_rates' => [
'KRW' => ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 1],
],
],
]);
$resource = (new OrderResource($order))->resolve();
$this->assertFalse($resource['is_cross_currency']);
$this->assertSame(12000, $resource['total_due_charge_amount']);
}
}