Files
HeuJung af1da5fe0b feat(ecommerce): 현금영수증 프로바이더 훅 축 + 이력 테이블 + 현금성 금액 + 배송비 과세 정책
현금영수증 발급 프로바이더를 PG 와 독립적으로 선택할 수 있도록 훅 축을 신설한다.
KG이니시스로 결제받으면서 현금영수증만 토스페이먼츠로 발급하는 구성이 가능하다.

발급/취소 원장(ecommerce_order_cash_receipts)이 국세청 신고 근거를 보관한다.
금액이 바뀌면 syncFromOrder 가 주문의 현재 상태에서 발급액을 재계산해
전체취소 → 전액 재발급한다. 부분취소 API 를 쓰지 않으므로 전액취소만 지원하는
벤더도 동일 인터페이스로 수용되고, 증가/감소 방향을 구분하지 않아 향후 반품·교환
배송비 청구가 도입되어도 수정 없이 동작한다.

배송비는 그동안 과세/면세 분류에서 아예 빠져 있었다. 배송비는 단계 3 에서 계산되고
할인은 단계 4 에서 적용되므로 상품 분류 시점(단계 2-b)에는 존재하지 않는다.
따라서 할인 후 배송비가 확정된 Summary 집계 직전에 3가지 정책 중 하나로 분류해
합산하고, 옵션별 과세액에는 배송비를 섞지 않는다.

재발급용 식별번호는 APP_KEY 기반으로 암호화 보관하고 구매확정 시 폐기한다.
이력에는 마스킹 값만 남기며 프로바이더 원응답의 민감 키도 가린다.
주민등록번호는 수집하지 않는다.

cash_receipt_type 은 Enum 캐스트하지 않는다 — 레거시 값(income_deduction)이 남아
있는 동안 Laravel 의 ::from 이 ValueError 를 던져 정규화해야 할 업그레이드 스텝
자신이 그 행을 읽지 못하게 되기 때문이다.
2026-08-06 11:48:12 +09:00

193 lines
7.6 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use ReflectionMethod;
/**
* 배송비 과세 정책 테스트 (A-4)
*
* 배송비 과세 분류는 상품 분류(단계 2-b)와 달리 배송비 할인이 확정된 뒤에 계산된다.
* 정책 3종 × 과세/면세/복합 = 9케이스 + 안분 반올림 잔차의 합계 보존을 검증한다.
*/
class ShippingFeeTaxPolicyTest extends ModuleTestCase
{
/**
* classifyShippingFeeTaxStatus 를 지정 정책으로 호출합니다.
*
* @return array{taxable_amount: int, tax_free_amount: int}
*/
private function classify(
ShippingFeeTaxPolicy $policy,
int $netShippingFee,
int $productTaxable,
int $productTaxFree,
): array {
$service = $this->getMockBuilder(OrderCalculationService::class)
->disableOriginalConstructor()
->onlyMethods(['resolveShippingFeeTaxPolicy'])
->getMock();
$service->method('resolveShippingFeeTaxPolicy')->willReturn($policy);
$method = new ReflectionMethod(OrderCalculationService::class, 'classifyShippingFeeTaxStatus');
$method->setAccessible(true);
return $method->invoke($service, $netShippingFee, $productTaxable, $productTaxFree);
}
#[Test]
#[DataProvider('policyMatrixProvider')]
public function 배송비_과세_정책_9케이스(
ShippingFeeTaxPolicy $policy,
int $shipping,
int $taxable,
int $taxFree,
int $expectedTaxable,
int $expectedTaxFree,
): void {
$result = $this->classify($policy, $shipping, $taxable, $taxFree);
$this->assertSame($expectedTaxable, $result['taxable_amount'], '배송비 과세분');
$this->assertSame($expectedTaxFree, $result['tax_free_amount'], '배송비 면세분');
$this->assertSame(
$shipping,
$result['taxable_amount'] + $result['tax_free_amount'],
'배송비 합계가 보존되어야 한다',
);
}
/**
* @return array<string, array{ShippingFeeTaxPolicy, int, int, int, int, int}>
*/
public static function policyMatrixProvider(): array
{
return [
// 안분 (proportional)
'안분 × 전액과세' => [ShippingFeeTaxPolicy::PROPORTIONAL, 3000, 10000, 0, 3000, 0],
'안분 × 전액면세' => [ShippingFeeTaxPolicy::PROPORTIONAL, 3000, 0, 10000, 0, 3000],
'안분 × 복합(과세 6:면세 4)' => [ShippingFeeTaxPolicy::PROPORTIONAL, 3000, 6000, 4000, 1800, 1200],
// 전액과세 (taxable)
'전액과세 × 전액과세' => [ShippingFeeTaxPolicy::TAXABLE, 3000, 10000, 0, 3000, 0],
'전액과세 × 전액면세' => [ShippingFeeTaxPolicy::TAXABLE, 3000, 0, 10000, 3000, 0],
'전액과세 × 복합' => [ShippingFeeTaxPolicy::TAXABLE, 3000, 6000, 4000, 3000, 0],
// 주된재화 (follow_main_item)
'주된재화 × 전액과세' => [ShippingFeeTaxPolicy::FOLLOW_MAIN_ITEM, 3000, 10000, 0, 3000, 0],
'주된재화 × 전액면세' => [ShippingFeeTaxPolicy::FOLLOW_MAIN_ITEM, 3000, 0, 10000, 0, 3000],
'주된재화 × 복합(과세 우세)' => [ShippingFeeTaxPolicy::FOLLOW_MAIN_ITEM, 3000, 6000, 4000, 3000, 0],
];
}
#[Test]
public function 주된재화_정책은_면세가_우세하면_배송비를_면세로_분류한다(): void
{
$result = $this->classify(ShippingFeeTaxPolicy::FOLLOW_MAIN_ITEM, 3000, 4000, 6000);
$this->assertSame(0, $result['taxable_amount']);
$this->assertSame(3000, $result['tax_free_amount']);
}
#[Test]
public function 주된재화_정책은_동률이면_과세로_분류한다(): void
{
$result = $this->classify(ShippingFeeTaxPolicy::FOLLOW_MAIN_ITEM, 3000, 5000, 5000);
$this->assertSame(3000, $result['taxable_amount']);
$this->assertSame(0, $result['tax_free_amount']);
}
#[Test]
#[DataProvider('roundingProvider')]
public function 안분_반올림_잔차가_합계를_보존한다(int $shipping, int $taxable, int $taxFree): void
{
$result = $this->classify(ShippingFeeTaxPolicy::PROPORTIONAL, $shipping, $taxable, $taxFree);
$this->assertSame(
$shipping,
$result['taxable_amount'] + $result['tax_free_amount'],
"배송비 {$shipping} 이 과세 {$result['taxable_amount']} + 면세 {$result['tax_free_amount']} 로 보존되어야 한다",
);
$this->assertGreaterThanOrEqual(0, $result['taxable_amount']);
$this->assertGreaterThanOrEqual(0, $result['tax_free_amount']);
}
/**
* 나누어떨어지지 않아 반올림 잔차가 발생하는 조합들.
*
* @return array<string, array{int, int, int}>
*/
public static function roundingProvider(): array
{
return [
'3000 × 1:2' => [3000, 1000, 2000],
'2500 × 1:3' => [2500, 1000, 3000],
'3000 × 7:3 (1/3 반복소수)' => [3000, 7000, 3000],
'1 원 배송비' => [1, 1, 2],
'2 원 배송비 홀수 비율' => [2, 1, 2],
'3333 × 1:1' => [3333, 5000, 5000],
'999 × 1:6' => [999, 1000, 6000],
];
}
#[Test]
public function 배송비가_0원이면_과세_면세_모두_0이다(): void
{
foreach (ShippingFeeTaxPolicy::cases() as $policy) {
$result = $this->classify($policy, 0, 6000, 4000);
$this->assertSame(0, $result['taxable_amount'], $policy->value);
$this->assertSame(0, $result['tax_free_amount'], $policy->value);
}
}
#[Test]
public function 상품금액이_0이면_안분_기준이_없어_전액_과세한다(): void
{
foreach (ShippingFeeTaxPolicy::cases() as $policy) {
$result = $this->classify($policy, 3000, 0, 0);
$this->assertSame(3000, $result['taxable_amount'], $policy->value);
$this->assertSame(0, $result['tax_free_amount'], $policy->value);
}
}
#[Test]
public function 배송비_할인이_반영된_실질_배송비를_사용한다(): void
{
$method = new ReflectionMethod(OrderCalculationService::class, 'resolveNetShippingFee');
$method->setAccessible(true);
$service = $this->getMockBuilder(OrderCalculationService::class)
->disableOriginalConstructor()
->onlyMethods([])
->getMock();
$this->assertSame(1000, $method->invoke($service, [
'total_shipping' => 3000,
'shipping_discount' => 2000,
]));
// 할인이 배송비를 초과해도 음수가 되지 않는다.
$this->assertSame(0, $method->invoke($service, [
'total_shipping' => 3000,
'shipping_discount' => 5000,
]));
}
#[Test]
public function 미설정_정책값은_안분으로_폴백한다(): void
{
$this->assertSame(ShippingFeeTaxPolicy::PROPORTIONAL, ShippingFeeTaxPolicy::fromValueOrDefault(null));
$this->assertSame(ShippingFeeTaxPolicy::PROPORTIONAL, ShippingFeeTaxPolicy::fromValueOrDefault(''));
$this->assertSame(ShippingFeeTaxPolicy::PROPORTIONAL, ShippingFeeTaxPolicy::fromValueOrDefault('bogus'));
$this->assertSame(ShippingFeeTaxPolicy::TAXABLE, ShippingFeeTaxPolicy::fromValueOrDefault('taxable'));
}
}