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 를 던져 정규화해야 할 업그레이드 스텝
자신이 그 행을 읽지 못하게 되기 때문이다.
This commit is contained in:
HeuJung
2026-08-06 11:48:12 +09:00
parent 5b587d7119
commit af1da5fe0b
46 changed files with 3986 additions and 16 deletions
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.0] - 2026-08-06
### Added
- 현금영수증 관련 문구(`cash_receipt.*` — 발급 용도, 식별번호 종류, 발급/취소 이력 상태, 배송비 과세 방식, 오류 안내) 일본어 번역 추가 — 현금영수증 발급 화면과 이커머스 환경설정이 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.8] - 2026-08-01
### Added
@@ -0,0 +1,33 @@
<?php
return [
'type' => [
'income' => '所得控除用',
'expense' => '支出証明用',
],
'identifier_type' => [
'phone' => '携帯電話番号',
'card' => '現金領収書カード番号',
'business' => '事業者登録番号',
],
'transaction_type' => [
'issue' => '発行',
'cancel' => 'キャンセル',
],
'issue_status' => [
'in_progress' => '処理中',
'completed' => '発行完了',
'failed' => '発行失敗',
],
'shipping_fee_tax_policy' => [
'proportional' => '按分 (課税商品の比率に応じて課税)',
'taxable' => '全額課税',
'follow_main_item' => '主要な財を従う',
],
'errors' => [
'provider_not_configured' => '現金領収書発行プロバイダーが設定されていません。',
'no_provider_handled' => '現金領収書発行リクエストを処理したプロバイダーがありません。',
'no_issuable_amount' => '発行可能な現金性金額がありません。',
'identifier_unavailable' => '再発行に必要な識別番号を復号化できません。管理者が識別番号を再入力して発行する必要があります。',
],
];
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.0.8",
"version": "1.1.0",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
@@ -4,7 +4,7 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.5] - 2026-07-16
## [1.1.0] - 2026-08-06
### Security
@@ -18,6 +18,11 @@
### Added
- 현금영수증 발급 업체를 결제대행사와 따로 고를 수 있습니다 — KG이니시스로 결제받으면서 현금영수증만 토스페이먼츠로 발급하는 구성이 가능합니다. 발급 업체는 이커머스 환경설정에서 선택합니다.
- 현금영수증 발급·취소 이력이 남습니다. 부분 환불 등으로 결제금액이 바뀌면 기존 영수증을 취소하고 바뀐 금액으로 다시 발급하며, 그 과정이 모두 기록되어 국세청 신고 근거로 쓸 수 있습니다. 재발급이 실패한 경우에도 이력이 남아 관리자가 확인하고 다시 발급할 수 있습니다.
- 주문에 현금성 금액이 기록됩니다. 무통장입금 주문에서 마일리지를 사용한 경우, 실제로 계좌에 입금된 금액만 현금영수증 발급 대상이 됩니다.
- 배송비 과세 방식을 이커머스 환경설정에서 고를 수 있습니다 — 안분(기본, 과세상품 비율만큼 배송비를 과세로 계산) / 전액 과세 / 주된 재화를 따름 중 상점의 세무 판단에 맞춰 선택합니다.
- 현금영수증 식별번호(휴대폰번호·사업자등록번호 등)는 재발급에 쓸 수 있도록 암호화해 보관하고 구매확정 시점에 폐기합니다. 이력에는 뒤 4자리를 제외한 마스킹 값만 남으며, 발급 업체가 보내온 응답에 섞여 오는 개인정보도 가려서 저장합니다. 주민등록번호는 수집하지 않습니다.
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
@@ -25,6 +30,8 @@
### Changed
- 과세·면세 금액 합계에 배송비가 포함됩니다. 그동안 배송비는 과세/면세 어느 쪽에도 집계되지 않아 상품 금액만 합산되었습니다.
- 현금영수증 발급 용도 값을 소득공제(income) / 지출증빙(expense)으로 통일했습니다. 기존에 다른 값으로 저장된 주문은 모듈 업데이트 시 자동으로 정리됩니다.
- 관리자 상품 목록이 상품마다 옵션 전체를 함께 내려주지 않습니다. 목록에는 옵션 개수와 재고 합계만 실리고, 옵션 상세는 행을 펼칠 때 필요한 상품의 것만 한 번에 가져옵니다. 옵션을 많이 쓰는 쇼핑몰일수록 목록을 여는 속도와 전송량이 크게 개선됩니다(상품 100건에 상품당 옵션 20건 기준, 목록 응답 크기 약 94% 감소). 화면에서 보이는 내용과 일괄 변경 적용 범위는 이전과 동일하며, 옵션을 목록 응답에 함께 받으려면 주소 뒤에 `?with_options=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 관리자 주문 목록이 주문마다 주문상품 전체를 함께 내려주지 않습니다. 목록은 대표 상품 1건과 "외 N건" 표기만 사용하므로, 이제 그 1건과 개수만 읽습니다. 한 주문에 상품을 많이 담는 쇼핑몰일수록 목록을 여는 속도와 전송량이 개선됩니다. 화면에 보이는 내용(대표 상품·추가옵션 요약·"외 N건"·부분취소 표시)은 이전과 동일하며, 마이페이지 주문내역은 종전대로 주문상품 전체를 보여줍니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 상품 옵션의 표시 순서를 상품 등록/수정 화면과 동일하게 정렬 순서 기준으로 통일했습니다. 이전에는 관리자 상품 목록의 펼친 옵션만 정해진 순서 없이 표시돼 화면마다 순서가 달라 보일 수 있었습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
"version": "1.0.5",
"version": "1.1.0",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -107,6 +107,9 @@
},
"order_settings": {
"default_pg_provider": null,
"cash_receipt_provider": "",
"cash_receipt_self_issue": false,
"shipping_fee_tax_policy": "proportional",
"payment_methods": [
{
"id": "card",
@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/*
* comment 문자열에 모듈 Enum 클래스를 참조하지 않는다.
* `php artisan migrate` 는 모듈 오토로더가 등록되지 않은 상태에서도 실행될 수 있어
* (신규 설치, 모듈 비활성 상태 등) Enum 클래스 해석에 실패한다.
*/
return new class extends Migration
{
/**
* 마이그레이션 실행
*/
public function up(): void
{
if (Schema::hasTable('ecommerce_order_cash_receipts')) {
return;
}
Schema::create('ecommerce_order_cash_receipts', function (Blueprint $table) {
$table->id()->comment('현금영수증 이력 ID');
$table->foreignId('order_id')
->comment('주문 ID')
->constrained('ecommerce_orders')
->onDelete('cascade');
$table->unsignedBigInteger('order_payment_id')->nullable()->comment('결제 ID');
$table->string('provider', 50)->comment('발급 프로바이더 (tosspayments, kginicis 등)');
$table->string('receipt_key', 200)->nullable()->comment('프로바이더 영수증 키');
$table->string('transaction_type', 20)
->comment('거래 유형 (issue: 발급, cancel: 취소)');
$table->string('receipt_type', 20)
->comment('영수증 유형 (income: 소득공제, expense: 지출증빙)');
$table->decimal('amount', 12, 2)->comment('발급/취소 금액');
$table->decimal('tax_free_amount', 12, 2)->default(0)->comment('면세 금액');
$table->string('identifier_masked', 50)->nullable()->comment('식별번호 (뒤 4자리 외 마스킹)');
$table->text('receipt_url')->nullable()->comment('영수증 조회 URL');
$table->string('issue_number', 20)->nullable()->comment('발급번호');
$table->string('issue_status', 20)
->comment('발급 상태 (IN_PROGRESS: 처리중, COMPLETED: 완료, FAILED: 실패)');
$table->string('error_code', 100)->nullable()->comment('실패 시 프로바이더 에러코드');
$table->text('error_message')->nullable()->comment('실패 시 에러 메시지');
$table->timestamp('issued_at')->nullable()->comment('발급일시');
$table->json('raw_response')->nullable()->comment('프로바이더 원응답 (민감키 마스킹 후 저장)');
$table->timestamps();
$table->index(['order_id', 'transaction_type'], 'ecommerce_order_cash_receipts_order_type_index');
$table->index('receipt_key', 'ecommerce_order_cash_receipts_receipt_key_index');
$table->index('order_payment_id', 'ecommerce_order_cash_receipts_payment_id_index');
});
}
/**
* 마이그레이션 롤백
*/
public function down(): void
{
Schema::dropIfExists('ecommerce_order_cash_receipts');
}
};
@@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 마이그레이션 실행
*/
public function up(): void
{
Schema::table('ecommerce_orders', function (Blueprint $table) {
if (! Schema::hasColumn('ecommerce_orders', 'total_cash_equivalent_amount')) {
$table->decimal('total_cash_equivalent_amount', 12, 2)
->default(0)
->after('total_due_amount')
->comment('주문 전체 현금성 결제 금액 (현금영수증 발급 대상)');
}
if (! Schema::hasColumn('ecommerce_orders', 'mc_total_cash_equivalent_amount')) {
$table->text('mc_total_cash_equivalent_amount')
->nullable()
->comment('현금성 결제 금액 다중 통화');
}
});
Schema::table('ecommerce_order_options', function (Blueprint $table) {
if (! Schema::hasColumn('ecommerce_order_options', 'subtotal_cash_equivalent_amount')) {
$table->decimal('subtotal_cash_equivalent_amount', 12, 2)
->default(0)
->comment('옵션별 현금성 금액 안분액');
}
if (! Schema::hasColumn('ecommerce_order_options', 'mc_subtotal_cash_equivalent_amount')) {
$table->text('mc_subtotal_cash_equivalent_amount')
->nullable()
->comment('옵션별 현금성 금액 다중 통화');
}
});
}
/**
* 마이그레이션 롤백
*/
public function down(): void
{
if (Schema::hasTable('ecommerce_orders')) {
Schema::table('ecommerce_orders', function (Blueprint $table) {
foreach (['total_cash_equivalent_amount', 'mc_total_cash_equivalent_amount'] as $column) {
if (Schema::hasColumn('ecommerce_orders', $column)) {
$table->dropColumn($column);
}
}
});
}
if (Schema::hasTable('ecommerce_order_options')) {
Schema::table('ecommerce_order_options', function (Blueprint $table) {
foreach (['subtotal_cash_equivalent_amount', 'mc_subtotal_cash_equivalent_amount'] as $column) {
if (Schema::hasColumn('ecommerce_order_options', $column)) {
$table->dropColumn($column);
}
}
});
}
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 마이그레이션 실행
*
* 재발급(전체취소 → 전액 재발급)에는 최초 발급 시의 원본 식별번호가 필요하다.
* 이력 테이블에는 마스킹 값만 남기고, 원본은 이 컬럼에 APP_KEY 기반 AES-256 으로만 보관한다.
* 구매확정 시점에 폐기된다(개인정보 최소보유).
*/
public function up(): void
{
Schema::table('ecommerce_order_payments', function (Blueprint $table) {
if (! Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_encrypted')) {
$table->text('cash_receipt_identifier_encrypted')
->nullable()
->after('cash_receipt_identifier')
->comment('현금영수증 식별번호 암호문 (재발급용, 구매확정 시 폐기)');
}
});
}
/**
* 마이그레이션 롤백
*/
public function down(): void
{
if (! Schema::hasTable('ecommerce_order_payments')) {
return;
}
Schema::table('ecommerce_order_payments', function (Blueprint $table) {
if (Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_encrypted')) {
$table->dropColumn('cash_receipt_identifier_encrypted');
}
});
}
};
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/*
* comment 문자열에 모듈 Enum 클래스를 참조하지 않는다 (모듈 오토로더 미등록 상태에서도 실행 가능해야 함).
*/
return new class extends Migration
{
/**
* 마이그레이션 실행
*
* 용도(cash_receipt_type)와 식별번호 종류는 서로 독립이다.
* 휴대폰번호는 소득공제/지출증빙 양쪽에 사용 가능하므로 종류를 별도 컬럼으로 보관한다.
*/
public function up(): void
{
Schema::table('ecommerce_order_payments', function (Blueprint $table) {
if (! Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_type')) {
$table->string('cash_receipt_identifier_type', 20)
->nullable()
->after('cash_receipt_type')
->comment('현금영수증 식별번호 종류 (phone: 휴대폰번호, card: 현금영수증카드번호, business: 사업자등록번호)');
}
});
}
/**
* 마이그레이션 롤백
*/
public function down(): void
{
if (! Schema::hasTable('ecommerce_order_payments')) {
return;
}
Schema::table('ecommerce_order_payments', function (Blueprint $table) {
if (Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_type')) {
$table->dropColumn('cash_receipt_identifier_type');
}
});
}
};
@@ -5,7 +5,7 @@
"ko": "이커머스",
"en": "Ecommerce"
},
"version": "1.0.5",
"version": "1.1.0",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.5",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.5",
"version": "1.1.0",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
"version": "1.0.5",
"version": "1.1.0",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -0,0 +1,139 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Enums;
/**
* 현금영수증 식별번호 종류 Enum
*
* 주민등록번호는 수집하지 않는다. 개인정보 보호법 제24조의2 는 법령상 구체적 근거가 없는
* 주민등록번호 처리를 원칙 금지하며, 아래 3종으로 소득공제/지출증빙 양쪽 용도를 대체할 수 있다.
*/
enum CashReceiptIdentifierType: string
{
/**
* 휴대폰번호 (소득공제/지출증빙 공통)
*/
case PHONE = 'phone';
/**
* 현금영수증 카드번호 (소득공제/지출증빙 공통)
*/
case CARD = 'card';
/**
* 사업자등록번호 (지출증빙 전용)
*/
case BUSINESS = 'business';
/**
* 국세청 자진발급 지정번호 (소득공제용 전용)
*
* 구매자가 현금영수증을 신청하지 않은 건에 대해 사업자가 자진발급할 때 사용한다.
*/
public const SELF_ISSUE_NUMBER = '0100001234';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 값 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨
*/
public function label(): string
{
return __($this->labelKey());
}
/**
* 번역 키를 반환합니다.
*
* @return string 번역 키
*/
public function labelKey(): string
{
return 'sirsoft-ecommerce::cash_receipt.identifier_type.'.$this->value;
}
/**
* 식별번호 형식이 유효한지 확인합니다.
*
* 하이픈·공백은 호출부에서 제거한 뒤 전달한다.
* 현금영수증 카드번호는 공개된 체크섬 규격을 확인하지 못해 자릿수만 검증한다.
*
* @param string $identifier 하이픈 제거된 식별번호
* @return bool 형식 유효 여부
*/
public function matches(string $identifier): bool
{
return match ($this) {
self::PHONE => (bool) preg_match('/^01[016789]\d{7,8}$/', $identifier),
self::CARD => (bool) preg_match('/^\d{13,19}$/', $identifier),
self::BUSINESS => self::isValidBusinessNumber($identifier),
};
}
/**
* 사업자등록번호 체크섬을 검증합니다.
*
* 가중치 [1,3,7,1,3,7,1,3,5] 를 앞 9자리에 곱하고, 9번째 자리 곱 결과의 십의 자리를 가산한 뒤
* (10 - 합 % 10) % 10 이 마지막 자리와 일치해야 한다.
*
* 앞 3자리(세무서 코드)가 000 인 번호는 체크섬을 수학적으로 항상 만족(0000000000)하지만
* 발급될 수 없는 번호이므로 별도로 배제한다.
*
* @param string $number 하이픈 제거된 10자리 사업자등록번호
* @return bool 체크섬 유효 여부
*/
public static function isValidBusinessNumber(string $number): bool
{
if (! preg_match('/^\d{10}$/', $number)) {
return false;
}
if (substr($number, 0, 3) === '000') {
return false;
}
$weights = [1, 3, 7, 1, 3, 7, 1, 3, 5];
$sum = 0;
foreach ($weights as $index => $weight) {
$sum += ((int) $number[$index]) * $weight;
}
// 9번째 자리(index 8) 곱 결과의 십의 자리를 가산
$sum += intdiv(((int) $number[8]) * 5, 10);
return ((10 - ($sum % 10)) % 10) === (int) $number[9];
}
/**
* 자진발급 지정번호인지 확인합니다.
*
* @param string $identifier 하이픈 제거된 식별번호
* @return bool 자진발급 지정번호 여부
*/
public static function isSelfIssueNumber(string $identifier): bool
{
return $identifier === self::SELF_ISSUE_NUMBER;
}
}
@@ -0,0 +1,92 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Enums;
/**
* 현금영수증 발급 상태 Enum
*
* FAILED 이력은 "취소는 성공했는데 재발급만 실패한" 중간 상태를 원장에 남기기 위해 존재한다.
* 이 상태를 조용히 넘기면 국세청 신고 누락으로 이어지므로 관리자 화면에 노출된다.
*/
enum CashReceiptIssueStatus: string
{
/**
* 처리 중 (프로바이더 응답 대기)
*/
case IN_PROGRESS = 'IN_PROGRESS';
/**
* 완료
*/
case COMPLETED = 'COMPLETED';
/**
* 실패
*/
case FAILED = 'FAILED';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 값 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 성공적으로 완료된 상태인지 확인합니다.
*
* @return bool 완료 여부
*/
public function isCompleted(): bool
{
return $this === self::COMPLETED;
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨
*/
public function label(): string
{
return __($this->labelKey());
}
/**
* 번역 키를 반환합니다.
*
* @return string 번역 키
*/
public function labelKey(): string
{
return 'sirsoft-ecommerce::cash_receipt.issue_status.'.strtolower($this->value);
}
/**
* UI Badge variant 를 반환합니다.
*
* @return string variant 명
*/
public function variant(): string
{
return match ($this) {
self::IN_PROGRESS => 'warning',
self::COMPLETED => 'success',
self::FAILED => 'danger',
};
}
}
@@ -0,0 +1,63 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Enums;
/**
* 현금영수증 이력 거래 유형 Enum
*
* 이력 테이블(ecommerce_order_cash_receipts)의 원장 성격을 규정한다.
* 부분취소는 사용하지 않으므로(항상 전체취소 → 전액 재발급) 유형은 2종으로 충분하다.
*/
enum CashReceiptTransactionType: string
{
/**
* 발급
*/
case ISSUE = 'issue';
/**
* 취소 (전액취소)
*/
case CANCEL = 'cancel';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 값 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨
*/
public function label(): string
{
return __($this->labelKey());
}
/**
* 번역 키를 반환합니다.
*
* @return string 번역 키
*/
public function labelKey(): string
{
return 'sirsoft-ecommerce::cash_receipt.transaction_type.'.$this->value;
}
}
@@ -0,0 +1,106 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Enums;
/**
* 현금영수증 발급 용도 Enum
*
* 기존 ecommerce_order_payments.cash_receipt_type 컬럼의 comment 는 income/expense 인 반면
* KG이니시스 플러그인은 income_deduction/expenditure_proof 를 기록해 왔다. 본 Enum 이 SSoT 이며
* 레거시 값은 Upgrade_1_1_0 업그레이드 스텝에서 정규화된다.
*/
enum CashReceiptType: string
{
/**
* 소득공제용 (개인)
*/
case INCOME = 'income';
/**
* 지출증빙용 (사업자)
*/
case EXPENSE = 'expense';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 값 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 레거시 값(income_deduction/expenditure_proof)을 포함해 안전하게 변환합니다.
*
* @param string|null $value 원본 값
* @return self|null 변환된 Enum (해석 불가 시 null)
*/
public static function fromLegacy(?string $value): ?self
{
if ($value === null || $value === '') {
return null;
}
return match ($value) {
'income', 'income_deduction' => self::INCOME,
'expense', 'expenditure_proof' => self::EXPENSE,
default => self::tryFrom($value),
};
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨
*/
public function label(): string
{
return __($this->labelKey());
}
/**
* 번역 키를 반환합니다.
*
* @return string 번역 키
*/
public function labelKey(): string
{
return 'sirsoft-ecommerce::cash_receipt.type.'.$this->value;
}
/**
* 해당 용도로 사용 가능한 식별번호 종류를 반환합니다.
*
* 휴대폰번호는 소득공제/지출증빙 양쪽에 사용 가능하다.
* (근거: 팝빌 발행유형 가이드 — 두 용도 모두에 휴대폰번호를 명시)
*
* @return array<int, CashReceiptIdentifierType> 허용되는 식별번호 종류
*/
public function allowedIdentifierTypes(): array
{
return match ($this) {
self::INCOME => [
CashReceiptIdentifierType::PHONE,
CashReceiptIdentifierType::CARD,
],
self::EXPENSE => [
CashReceiptIdentifierType::BUSINESS,
CashReceiptIdentifierType::PHONE,
CashReceiptIdentifierType::CARD,
],
};
}
}
@@ -0,0 +1,85 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Enums;
/**
* 배송비 과세 정책 Enum
*
* 부가가치세법 제14조는 주된 재화에 부수되는 용역이 주된 재화의 과세/면세를 따른다고 정하지만,
* 과세·면세 상품이 섞인 장바구니의 배송비 안분에 관한 명문 규정은 존재하지 않는다.
* (이슈 본문의 "과세금액 50% 이상이면 과세" 규정은 현행 법령·예규에서 확인되지 않았다.)
* 따라서 상점의 세무 판단에 따라 아래 3가지 중 선택하게 한다.
*/
enum ShippingFeeTaxPolicy: string
{
/**
* 안분 — 과세상품금액 비율만큼 배송비를 과세로 분류 (기본값)
*/
case PROPORTIONAL = 'proportional';
/**
* 전액과세 — 배송비 전액을 과세로 분류
*/
case TAXABLE = 'taxable';
/**
* 주된재화 — 상품금액이 큰 쪽(과세 또는 면세)의 성격을 따름
*/
case FOLLOW_MAIN_ITEM = 'follow_main_item';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 값 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 값을 안전하게 변환합니다. 해석 불가 시 기본값(안분)을 반환합니다.
*
* @param string|null $value 원본 값
* @return self 변환된 Enum
*/
public static function fromValueOrDefault(?string $value): self
{
if ($value === null || $value === '') {
return self::PROPORTIONAL;
}
return self::tryFrom($value) ?? self::PROPORTIONAL;
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨
*/
public function label(): string
{
return __($this->labelKey());
}
/**
* 번역 키를 반환합니다.
*
* @return string 번역 키
*/
public function labelKey(): string
{
return 'sirsoft-ecommerce::cash_receipt.shipping_fee_tax_policy.'.$this->value;
}
}
@@ -96,6 +96,7 @@ class Order extends Model
'total_deposit_used_amount',
'total_paid_amount',
'total_due_amount',
'total_cash_equivalent_amount',
'total_cancelled_amount',
'total_refunded_amount',
'total_refunded_points_amount',
@@ -132,6 +133,7 @@ class Order extends Model
'mc_total_tax_free_amount',
'mc_total_amount',
'mc_total_paid_amount',
'mc_total_cash_equivalent_amount',
];
/**
@@ -165,6 +167,7 @@ class Order extends Model
'total_deposit_used_amount' => 'decimal:2',
'total_paid_amount' => 'decimal:2',
'total_due_amount' => 'decimal:2',
'total_cash_equivalent_amount' => 'decimal:2',
'total_cancelled_amount' => 'decimal:2',
'total_refunded_amount' => 'decimal:2',
'total_refunded_points_amount' => 'decimal:2',
@@ -202,6 +205,7 @@ class Order extends Model
'mc_total_tax_free_amount' => 'array',
'mc_total_amount' => 'array',
'mc_total_paid_amount' => 'array',
'mc_total_cash_equivalent_amount' => 'array',
];
/**
@@ -291,6 +295,16 @@ class Order extends Model
return $this->hasMany(OrderPayment::class, 'order_id');
}
/**
* 현금영수증 이력 관계
*
* @return HasMany 현금영수증 이력 모델과의 관계
*/
public function cashReceipts(): HasMany
{
return $this->hasMany(OrderCashReceipt::class, 'order_id');
}
/**
* 배송 관계
*
@@ -0,0 +1,106 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
/**
* 주문 현금영수증 이력 모델
*
* 발급/취소 원장(ledger)이다. ecommerce_order_payments 의 cash_receipt_* 컬럼은
* "현재 상태 요약"이고, 이 테이블이 국세청 신고 근거가 되는 전체 이력을 보관한다.
*
* 식별번호 원본(평문/암호문)은 절대 이 테이블에 저장하지 않는다 — 마스킹 값만 남긴다.
*/
class OrderCashReceipt extends Model
{
protected $table = 'ecommerce_order_cash_receipts';
protected $fillable = [
'order_id',
'order_payment_id',
'provider',
'receipt_key',
'transaction_type',
'receipt_type',
'amount',
'tax_free_amount',
'identifier_masked',
'receipt_url',
'issue_number',
'issue_status',
'error_code',
'error_message',
'issued_at',
'raw_response',
];
protected $casts = [
'amount' => 'decimal:2',
'tax_free_amount' => 'decimal:2',
'issued_at' => 'datetime',
'raw_response' => 'array',
'transaction_type' => CashReceiptTransactionType::class,
'receipt_type' => CashReceiptType::class,
'issue_status' => CashReceiptIssueStatus::class,
];
/**
* 주문 관계
*
* @return BelongsTo 주문 모델과의 관계
*/
public function order(): BelongsTo
{
return $this->belongsTo(Order::class, 'order_id');
}
/**
* 결제 관계
*
* @return BelongsTo 결제 모델과의 관계
*/
public function payment(): BelongsTo
{
return $this->belongsTo(OrderPayment::class, 'order_payment_id');
}
/**
* 발급 이력인지 확인합니다.
*
* @return bool 발급 이력 여부
*/
public function isIssue(): bool
{
return $this->transaction_type === CashReceiptTransactionType::ISSUE;
}
/**
* 성공적으로 발급 완료된 이력인지 확인합니다.
*
* @return bool 발급 완료 여부
*/
public function isCompletedIssue(): bool
{
return $this->isIssue() && $this->issue_status === CashReceiptIssueStatus::COMPLETED;
}
/**
* 발급 금액과 면세 금액이 주어진 목표치와 동일한지 확인합니다.
*
* decimal cast 는 문자열을 반환하므로 정수 비교로 정규화한다.
*
* @param int $amount 목표 발급 금액
* @param int $taxFreeAmount 목표 면세 금액
* @return bool 동일 여부
*/
public function matchesAmounts(int $amount, int $taxFreeAmount): bool
{
return (int) $this->amount === $amount
&& (int) $this->tax_free_amount === $taxFreeAmount;
}
}
@@ -69,6 +69,7 @@ class OrderOption extends Model
'subtotal_points_used_amount',
'subtotal_deposit_used_amount',
'subtotal_paid_amount',
'subtotal_cash_equivalent_amount',
'subtotal_tax_amount',
'subtotal_tax_free_amount',
'subtotal_earned_points_amount',
@@ -94,6 +95,7 @@ class OrderOption extends Model
'mc_subtotal_tax_amount',
'mc_subtotal_tax_free_amount',
'mc_final_amount',
'mc_subtotal_cash_equivalent_amount',
];
protected $casts = [
@@ -114,6 +116,7 @@ class OrderOption extends Model
'subtotal_points_used_amount' => 'decimal:2',
'subtotal_deposit_used_amount' => 'decimal:2',
'subtotal_paid_amount' => 'decimal:2',
'subtotal_cash_equivalent_amount' => 'decimal:2',
'subtotal_tax_amount' => 'decimal:2',
'subtotal_tax_free_amount' => 'decimal:2',
'subtotal_earned_points_amount' => 'decimal:2',
@@ -145,6 +148,7 @@ class OrderOption extends Model
'mc_subtotal_tax_amount' => 'array',
'mc_subtotal_tax_free_amount' => 'array',
'mc_final_amount' => 'array',
'mc_subtotal_cash_equivalent_amount' => 'array',
];
/**
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
@@ -64,7 +66,9 @@ class OrderPayment extends Model
'is_cash_receipt_requested',
'is_cash_receipt_issued',
'cash_receipt_type',
'cash_receipt_identifier_type',
'cash_receipt_identifier',
'cash_receipt_identifier_encrypted',
'cash_receipt_issued_at',
'cancelled_amount',
'cancelled_vat_amount',
@@ -99,6 +103,13 @@ class OrderPayment extends Model
'is_escrow' => 'boolean',
'is_cash_receipt_requested' => 'boolean',
'is_cash_receipt_issued' => 'boolean',
// cash_receipt_type 은 Enum 캐스트하지 않는다 — 레거시 행이 income_deduction/expenditure_proof 를
// 담고 있어 Laravel 의 Enum 캐스트(::from)가 ValueError 를 던진다. Upgrade_1_1_0 이 정규화하기
// 전까지 해당 행을 읽을 수 없게 되고, 업그레이드 스텝 자신도 그 행을 읽어야 한다.
// 읽기 경계에서는 CashReceiptType::fromLegacy() 를 사용한다.
'cash_receipt_identifier_type' => CashReceiptIdentifierType::class,
// APP_KEY 기반 AES-256. 이력 테이블·로그에는 절대 노출하지 않는다.
'cash_receipt_identifier_encrypted' => 'encrypted',
'cash_receipt_issued_at' => 'datetime',
'cancelled_amount' => 'decimal:2',
'cancelled_vat_amount' => 'decimal:2',
@@ -150,6 +161,38 @@ class OrderPayment extends Model
return $this->hasMany(OrderTaxInvoice::class, 'payment_id');
}
/**
* 현금영수증 이력 관계
*
* @return HasMany 현금영수증 이력 모델과의 관계
*/
public function cashReceipts(): HasMany
{
return $this->hasMany(OrderCashReceipt::class, 'order_payment_id');
}
/**
* 현금영수증 발급 용도를 Enum 으로 반환합니다.
*
* 레거시 값(income_deduction/expenditure_proof)도 안전하게 해석한다.
*
* @return CashReceiptType|null 발급 용도 (미설정/해석불가 시 null)
*/
public function getCashReceiptType(): ?CashReceiptType
{
return CashReceiptType::fromLegacy($this->cash_receipt_type);
}
/**
* 무통장입금 결제 여부 확인
*
* @return bool 무통장입금 결제 여부
*/
public function isBankDeposit(): bool
{
return $this->payment_method === PaymentMethodEnum::DBANK;
}
/**
* 카드 결제 여부 확인
*
@@ -31,7 +31,9 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInt
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCancelOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCancelRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCashReceiptRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
@@ -64,7 +66,9 @@ use Modules\Sirsoft\Ecommerce\Repositories\MileageBalanceRepository;
use Modules\Sirsoft\Ecommerce\Repositories\MileageTransactionRepository;
use Modules\Sirsoft\Ecommerce\Repositories\OrderCancelOptionRepository;
use Modules\Sirsoft\Ecommerce\Repositories\OrderCancelRepository;
use Modules\Sirsoft\Ecommerce\Repositories\OrderCashReceiptRepository;
use Modules\Sirsoft\Ecommerce\Repositories\OrderOptionRepository;
use Modules\Sirsoft\Ecommerce\Repositories\OrderPaymentRepository;
use Modules\Sirsoft\Ecommerce\Repositories\OrderRefundOptionRepository;
use Modules\Sirsoft\Ecommerce\Repositories\OrderRefundRepository;
use Modules\Sirsoft\Ecommerce\Repositories\OrderRepository;
@@ -137,7 +141,9 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider
ExtraFeeTemplateRepositoryInterface::class => ExtraFeeTemplateRepository::class,
OrderCancelRepositoryInterface::class => OrderCancelRepository::class,
OrderCancelOptionRepositoryInterface::class => OrderCancelOptionRepository::class,
OrderCashReceiptRepositoryInterface::class => OrderCashReceiptRepository::class,
OrderOptionRepositoryInterface::class => OrderOptionRepository::class,
OrderPaymentRepositoryInterface::class => OrderPaymentRepository::class,
OrderRefundRepositoryInterface::class => OrderRefundRepository::class,
OrderRefundOptionRepositoryInterface::class => OrderRefundOptionRepository::class,
OrderRepositoryInterface::class => OrderRepository::class,
@@ -0,0 +1,56 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
/**
* 주문 현금영수증 이력 Repository 인터페이스
*/
interface OrderCashReceiptRepositoryInterface
{
/**
* 이력을 생성합니다.
*
* @param array<string, mixed> $data 이력 데이터
* @return OrderCashReceipt 생성된 이력
*/
public function create(array $data): OrderCashReceipt;
/**
* 주문의 모든 이력을 최신순으로 조회합니다.
*
* @param Order $order 주문 모델
* @return Collection<int, OrderCashReceipt> 이력 컬렉션
*/
public function findByOrder(Order $order): Collection;
/**
* 주문의 활성 영수증(가장 최근 발급 완료 건 중 이후 취소되지 않은 것)을 조회합니다.
*
* @param Order $order 주문 모델
* @return OrderCashReceipt|null 활성 영수증 (없으면 null)
*/
public function findActiveReceipt(Order $order): ?OrderCashReceipt;
/**
* 주문의 활성 영수증 전체를 조회합니다.
*
* 정상 흐름에서는 항상 0~1건이지만, 발급 실패·중복 발급 등 이상 상태에서 2건 이상이
* 남을 수 있으므로 전액취소 시에는 이 목록 전체를 대상으로 한다.
*
* @param Order $order 주문 모델
* @return Collection<int, OrderCashReceipt> 활성 영수증 컬렉션
*/
public function findActiveReceipts(Order $order): Collection;
/**
* 영수증 키로 이력을 조회합니다.
*
* @param string $receiptKey 프로바이더 영수증 키
* @return OrderCashReceipt|null 이력 (없으면 null)
*/
public function findByReceiptKey(string $receiptKey): ?OrderCashReceipt;
}
@@ -0,0 +1,72 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
/**
* 주문 결제 Repository 인터페이스
*/
interface OrderPaymentRepositoryInterface
{
/**
* 현금영수증 발급 성공 시 결제의 요약 컬럼을 갱신합니다.
*
* 원본 식별번호는 encrypted cast 로 암호화되어 저장되고, 표시용으로는 마스킹 값만 남는다.
*
* @param OrderPayment $payment 결제 모델
* @param OrderCashReceipt $receipt 발급 이력
* @param CashReceiptType $type 발급 용도
* @param string $identifier 식별번호 원본
* @param string $identifierMasked 마스킹된 식별번호
* @param CashReceiptIdentifierType|null $identifierType 식별번호 종류
* @return OrderPayment 갱신된 결제 모델
*/
public function markCashReceiptIssued(
OrderPayment $payment,
OrderCashReceipt $receipt,
CashReceiptType $type,
string $identifier,
string $identifierMasked,
?CashReceiptIdentifierType $identifierType,
): OrderPayment;
/**
* 현금영수증 전액취소 성공 시 결제의 요약 컬럼을 초기화합니다.
*
* @param OrderPayment $payment 결제 모델
* @return OrderPayment 갱신된 결제 모델
*/
public function markCashReceiptCancelled(OrderPayment $payment): OrderPayment;
/**
* 재발급용 식별번호 암호문을 폐기합니다. (구매확정 시점)
*
* 이력·영수증 URL·마스킹 값은 유지된다.
*
* @param OrderPayment $payment 결제 모델
* @return OrderPayment 갱신된 결제 모델
*/
public function purgeCashReceiptIdentifier(OrderPayment $payment): OrderPayment;
/**
* 암호화된 식별번호 원본을 복호화해 반환합니다.
*
* APP_KEY 로테이션 등으로 복호화가 실패하면 null 을 반환한다.
*
* @param OrderPayment $payment 결제 모델
* @return string|null 식별번호 원본 (부재/복호화 실패 시 null)
*/
public function resolveCashReceiptIdentifier(OrderPayment $payment): ?string;
/**
* 식별번호 암호문이 저장되어 있는지 확인합니다.
*
* @param OrderPayment $payment 결제 모델
* @return bool 암호문 존재 여부
*/
public function hasCashReceiptIdentifier(OrderPayment $payment): bool;
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Repositories;
use Illuminate\Database\Eloquent\Collection;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCashReceiptRepositoryInterface;
/**
* 주문 현금영수증 이력 Repository 구현체
*/
class OrderCashReceiptRepository implements OrderCashReceiptRepositoryInterface
{
public function __construct(
protected OrderCashReceipt $model
) {}
/**
* {@inheritDoc}
*/
public function create(array $data): OrderCashReceipt
{
return $this->model->newQuery()->create($data);
}
/**
* {@inheritDoc}
*/
public function findByOrder(Order $order): Collection
{
return $this->model->newQuery()
->where('order_id', $order->id)
->orderByDesc('id')
->get();
}
/**
* {@inheritDoc}
*/
public function findActiveReceipt(Order $order): ?OrderCashReceipt
{
return $this->findActiveReceipts($order)->first();
}
/**
* {@inheritDoc}
*/
public function findActiveReceipts(Order $order): Collection
{
$rows = $this->model->newQuery()
->where('order_id', $order->id)
->whereIn('transaction_type', [
CashReceiptTransactionType::ISSUE->value,
CashReceiptTransactionType::CANCEL->value,
])
->where('issue_status', CashReceiptIssueStatus::COMPLETED->value)
->orderByDesc('id')
->get();
// 취소 완료된 receipt_key 집합 — 같은 키의 발급 이력은 더 이상 활성이 아니다.
$cancelledKeys = $rows
->filter(fn (OrderCashReceipt $row) => $row->transaction_type === CashReceiptTransactionType::CANCEL)
->pluck('receipt_key')
->filter()
->unique()
->all();
$active = $rows->filter(function (OrderCashReceipt $row) use ($cancelledKeys) {
if ($row->transaction_type !== CashReceiptTransactionType::ISSUE) {
return false;
}
// receipt_key 가 없는 발급 완료 건은 취소 대상을 특정할 수 없다 — 활성으로 보지 않는다.
if ($row->receipt_key === null || $row->receipt_key === '') {
return false;
}
return ! in_array($row->receipt_key, $cancelledKeys, true);
});
return $this->model->newCollection($active->values()->all());
}
/**
* {@inheritDoc}
*/
public function findByReceiptKey(string $receiptKey): ?OrderCashReceipt
{
return $this->model->newQuery()
->where('receipt_key', $receiptKey)
->orderByDesc('id')
->first();
}
}
@@ -0,0 +1,94 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Repositories;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
/**
* 주문 결제 Repository 구현체
*/
class OrderPaymentRepository implements OrderPaymentRepositoryInterface
{
/**
* {@inheritDoc}
*/
public function markCashReceiptIssued(
OrderPayment $payment,
OrderCashReceipt $receipt,
CashReceiptType $type,
string $identifier,
string $identifierMasked,
?CashReceiptIdentifierType $identifierType,
): OrderPayment {
$attributes = [
'is_cash_receipt_issued' => true,
'cash_receipt_type' => $type->value,
'cash_receipt_identifier' => $identifierMasked,
'cash_receipt_identifier_type' => $identifierType,
'cash_receipt_identifier_encrypted' => $identifier,
'cash_receipt_issued_at' => $receipt->issued_at,
];
if ($receipt->receipt_url !== null) {
$attributes['receipt_url'] = $receipt->receipt_url;
}
$payment->update($attributes);
return $payment;
}
/**
* {@inheritDoc}
*/
public function markCashReceiptCancelled(OrderPayment $payment): OrderPayment
{
$payment->update([
'is_cash_receipt_issued' => false,
'cash_receipt_issued_at' => null,
]);
return $payment;
}
/**
* {@inheritDoc}
*/
public function purgeCashReceiptIdentifier(OrderPayment $payment): OrderPayment
{
if (! $this->hasCashReceiptIdentifier($payment)) {
return $payment;
}
$payment->update(['cash_receipt_identifier_encrypted' => null]);
return $payment;
}
/**
* {@inheritDoc}
*/
public function resolveCashReceiptIdentifier(OrderPayment $payment): ?string
{
try {
$identifier = $payment->cash_receipt_identifier_encrypted;
} catch (\Throwable $e) {
// APP_KEY 로테이션 등으로 복호화 실패
return null;
}
return is_string($identifier) && $identifier !== '' ? $identifier : null;
}
/**
* {@inheritDoc}
*/
public function hasCashReceiptIdentifier(OrderPayment $payment): bool
{
return $payment->getRawOriginal('cash_receipt_identifier_encrypted') !== null;
}
}
@@ -0,0 +1,614 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Services;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCashReceiptRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
/**
* 현금영수증 발급/취소 오케스트레이션 서비스
*
* 이 서비스는 "왜" 금액이 바뀌었는지 알지 못한다. 부분환불이든 반품배송비 청구든 추가상품이든,
* 호출부는 syncFromOrder($order) 하나만 부르고 서비스는 주문의 현재 상태에서 발급액을 다시 계산한다.
* 이 무상태 설계 덕분에 향후 금액 증가 흐름이 도입되어도 본 코드는 수정 없이 동작한다.
*
* 재발급은 항상 "전체취소 → 전액 재발급" 단일 경로다. 부분취소 API 를 호출하지 않으므로
* 전액취소만 지원하는 벤더(NHN KCP·페이레터)도 동일 인터페이스로 수용된다.
*/
class CashReceiptService
{
/**
* 원응답에서 마스킹할 민감 키 목록 (대소문자 무시, 부분 일치)
*
* @var array<int, string>
*/
private const SENSITIVE_RESPONSE_KEYS = [
'customeridentitynumber',
'identitynumber',
'identifier',
'customermobilephone',
'mobilephone',
'phone',
'businessnumber',
'customername',
'customeremail',
'email',
'secret',
'secretkey',
'password',
];
public function __construct(
private OrderCashReceiptRepositoryInterface $repository,
private OrderPaymentRepositoryInterface $paymentRepository,
private EcommerceSettingsService $settingsService,
) {}
/**
* 현금영수증을 발급합니다.
*
* @param Order $order 주문 모델
* @param CashReceiptType $type 발급 용도
* @param string $identifier 식별번호 원본 (하이픈 제거)
* @param CashReceiptIdentifierType|null $identifierType 식별번호 종류 (미지정 시 추론)
* @return OrderCashReceipt 생성된 발급 이력 (실패 시 issue_status=FAILED)
*/
public function issue(
Order $order,
CashReceiptType $type,
string $identifier,
?CashReceiptIdentifierType $identifierType = null,
): OrderCashReceipt {
$provider = $this->resolveProvider();
$payment = $order->payment;
if ($provider === null) {
return $this->recordFailure(
$order,
$payment,
CashReceiptTransactionType::ISSUE,
$type,
'PROVIDER_NOT_CONFIGURED',
__('sirsoft-ecommerce::cash_receipt.errors.provider_not_configured'),
$identifier,
);
}
$target = $this->calculateIssuableAmount($order);
if ($target['amount'] <= 0) {
return $this->recordFailure(
$order,
$payment,
CashReceiptTransactionType::ISSUE,
$type,
'NO_ISSUABLE_AMOUNT',
__('sirsoft-ecommerce::cash_receipt.errors.no_issuable_amount'),
$identifier,
$provider,
);
}
$payload = [
'type' => $type->value,
'identifier' => $identifier,
'identifier_type' => ($identifierType ?? $this->inferIdentifierType($identifier))?->value,
'amount' => $target['amount'],
'tax_free_amount' => $target['tax_free_amount'],
'order_name' => $this->buildOrderName($order),
'order_number' => $order->order_number,
];
$result = HookManager::applyFilters(
'sirsoft-ecommerce.cash_receipt.issue',
$this->defaultIssueResult(),
$order,
$provider,
$payload,
);
$succeeded = (bool) ($result['success'] ?? false);
$receipt = $this->repository->create([
'order_id' => $order->id,
'order_payment_id' => $payment?->id,
'provider' => $provider,
'receipt_key' => $result['receipt_key'] ?? null,
'transaction_type' => CashReceiptTransactionType::ISSUE->value,
'receipt_type' => $type->value,
'amount' => $target['amount'],
'tax_free_amount' => $target['tax_free_amount'],
'identifier_masked' => $this->maskIdentifier($identifier),
'receipt_url' => $result['receipt_url'] ?? null,
'issue_number' => $result['issue_number'] ?? null,
'issue_status' => $succeeded
? CashReceiptIssueStatus::COMPLETED->value
: CashReceiptIssueStatus::FAILED->value,
'error_code' => $succeeded ? null : ($result['error_code'] ?? null),
'error_message' => $succeeded ? null : ($result['error_message'] ?? null),
'issued_at' => $succeeded ? now() : null,
'raw_response' => $this->sanitizeResponse($result['raw_response'] ?? null),
]);
if ($succeeded) {
$this->syncPaymentSummary($payment, $receipt, $type, $identifier, $identifierType);
} else {
Log::warning('[cash_receipt] 발급 실패', [
'order_id' => $order->id,
'provider' => $provider,
'error_code' => $result['error_code'] ?? null,
]);
}
return $receipt;
}
/**
* 주문의 활성 현금영수증을 모두 전액취소합니다.
*
* 부분취소 API 는 사용하지 않는다 (취소 금액 파라미터 없음).
*
* @param Order $order 주문 모델
* @param string|null $reason 취소 사유
* @return bool 활성 영수증 전부가 취소 성공했는지 여부 (취소 대상 없으면 true)
*/
public function cancelAll(Order $order, ?string $reason = null): bool
{
$activeReceipts = $this->repository->findActiveReceipts($order);
if ($activeReceipts->isEmpty()) {
return true;
}
$payment = $order->payment;
$allSucceeded = true;
foreach ($activeReceipts as $active) {
$provider = $active->provider;
$receiptKey = (string) $active->receipt_key;
$result = HookManager::applyFilters(
'sirsoft-ecommerce.cash_receipt.cancel',
$this->defaultCancelResult(),
$order,
$provider,
$receiptKey,
);
$succeeded = (bool) ($result['success'] ?? false);
$allSucceeded = $allSucceeded && $succeeded;
$this->repository->create([
'order_id' => $order->id,
'order_payment_id' => $payment?->id,
'provider' => $provider,
'receipt_key' => $result['receipt_key'] ?? $receiptKey,
'transaction_type' => CashReceiptTransactionType::CANCEL->value,
'receipt_type' => $active->receipt_type->value,
'amount' => (int) $active->amount,
'tax_free_amount' => (int) $active->tax_free_amount,
'identifier_masked' => $active->identifier_masked,
'receipt_url' => null,
'issue_number' => null,
'issue_status' => $succeeded
? CashReceiptIssueStatus::COMPLETED->value
: CashReceiptIssueStatus::FAILED->value,
'error_code' => $succeeded ? null : ($result['error_code'] ?? null),
'error_message' => $succeeded ? null : ($result['error_message'] ?? $reason),
'issued_at' => null,
'raw_response' => $this->sanitizeResponse($result['raw_response'] ?? null),
]);
if (! $succeeded) {
Log::warning('[cash_receipt] 취소 실패', [
'order_id' => $order->id,
'provider' => $provider,
'error_code' => $result['error_code'] ?? null,
]);
}
}
if ($allSucceeded) {
$this->clearPaymentSummary($payment);
}
return $allSucceeded;
}
/**
* 주문 금액 변동 시 현금영수증을 현재 상태에 맞춰 동기화합니다.
*
* 금액을 바꾸는 모든 지점(부분환불, 추가입금, 향후 반품/교환배송비 청구)이 호출하는
* 유일한 진입점이다. 증가/감소를 구분하지 않는다.
*
* 실패해도 예외를 던지지 않는다 — 환불 트랜잭션을 롤백시킬 사유가 아니기 때문이다.
* 실패는 이력 테이블에 FAILED 로 남아 관리자 화면에 노출되고 수동 재발급으로 복구된다.
*
* @param Order $order 주문 모델
* @param string|null $reason 변동 사유 (취소 이력에 기록)
*/
public function syncFromOrder(Order $order, ?string $reason = null): void
{
$active = $this->repository->findActiveReceipt($order);
// 발급 이력이 없으면 아무것도 하지 않는다 (신청하지 않은 주문).
if ($active === null) {
return;
}
$target = $this->calculateIssuableAmount($order);
// 멱등: 변동이 없으면 no-op
if ($active->matchesAmounts($target['amount'], $target['tax_free_amount'])) {
return;
}
// 전액 취소/전액 마일리지 등으로 발급 대상이 사라진 경우
if ($target['amount'] <= 0) {
$this->cancelAll($order, $reason);
return;
}
$type = $active->receipt_type;
$identifier = $this->resolveStoredIdentifier($order);
// 암호문이 없거나 복호화에 실패하면 재발급을 시도하지 않는다 (조용한 실패 금지).
if ($identifier === null) {
$this->recordFailure(
$order,
$order->payment,
CashReceiptTransactionType::ISSUE,
$type,
'IDENTIFIER_UNAVAILABLE',
__('sirsoft-ecommerce::cash_receipt.errors.identifier_unavailable'),
null,
$active->provider,
);
Log::warning('[cash_receipt] 재발급용 식별번호 복호화 불가 — 관리자 수동 재발급 필요', [
'order_id' => $order->id,
]);
return;
}
if (! $this->cancelAll($order, $reason)) {
// 취소가 실패하면 재발급하지 않는다 — 중복 영수증 발행 방지.
return;
}
$this->issue($order, $type, $identifier, $order->payment?->cash_receipt_identifier_type);
}
/**
* 현재 상태 기준 발급 가능 금액을 계산합니다.
*
* 주문 테이블의 재계산된 금액을 그대로 사용한다. 부분취소 시 OrderAdjustmentService 가
* total_amount / total_tax_free_amount 를 잔여분 기준으로 갱신하므로, 본 메서드는
* "지금 이 주문이 얼마짜리인가"만 읽으면 된다.
*
* 현금성 금액(total_cash_equivalent_amount)은 무통장 주문의 실입금액이며,
* 마일리지 사용분은 현금이 아니므로 제외되어 있다.
*
* @param Order $order 주문 모델
* @return array{amount: int, tax_free_amount: int} 발급액과 면세액
*/
public function calculateIssuableAmount(Order $order): array
{
$cashEquivalent = (int) round((float) $order->total_cash_equivalent_amount);
if ($cashEquivalent <= 0) {
return ['amount' => 0, 'tax_free_amount' => 0];
}
$taxFree = (int) round((float) $order->total_tax_free_amount);
$taxable = (int) round((float) $order->total_tax_amount);
$classified = $taxFree + $taxable;
// 과세/면세 분류 합이 현금성 금액과 다르면(마일리지 사용, 할인 등) 면세 비율로 안분한다.
// 반올림 잔차는 과세 쪽에 귀속시켜 합계를 보존한다.
if ($classified <= 0) {
return ['amount' => $cashEquivalent, 'tax_free_amount' => 0];
}
$taxFreeShare = $classified === $cashEquivalent
? $taxFree
: (int) round($cashEquivalent * $taxFree / $classified);
$taxFreeShare = max(0, min($taxFreeShare, $cashEquivalent));
return [
'amount' => $cashEquivalent,
'tax_free_amount' => $taxFreeShare,
];
}
/**
* 설정된 현금영수증 발급 프로바이더 ID를 반환합니다.
*
* @return string|null 프로바이더 ID (미설정 시 null)
*/
public function resolveProvider(): ?string
{
return $this->settingsService->getCashReceiptProvider();
}
/**
* 재발급용 식별번호 암호문을 폐기합니다. (구매확정 시점)
*
* 이력·receipt_url·마스킹 값은 유지된다 (국세청 신고 근거).
* 이미 폐기된 경우 아무것도 하지 않는다 (멱등).
*
* @param Order $order 주문 모델
*/
public function purgeIdentifier(Order $order): void
{
$payment = $order->payment;
if ($payment === null) {
return;
}
$this->paymentRepository->purgeCashReceiptIdentifier($payment);
}
/**
* 저장된 암호문에서 식별번호 원본을 복호화합니다.
*
* @param Order $order 주문 모델
* @return string|null 식별번호 원본 (부재/복호화 실패 시 null)
*/
private function resolveStoredIdentifier(Order $order): ?string
{
$payment = $order->payment;
if ($payment === null) {
return null;
}
return $this->paymentRepository->resolveCashReceiptIdentifier($payment);
}
/**
* 실패 이력을 기록합니다.
*
* @param Order $order 주문 모델
* @param OrderPayment|null $payment 결제 모델
* @param CashReceiptTransactionType $transactionType 거래 유형
* @param CashReceiptType $type 발급 용도
* @param string $errorCode 에러코드
* @param string $errorMessage 에러 메시지
* @param string|null $identifier 식별번호 원본 (마스킹해 저장)
* @param string|null $provider 프로바이더 ID
* @return OrderCashReceipt 생성된 실패 이력
*/
private function recordFailure(
Order $order,
?OrderPayment $payment,
CashReceiptTransactionType $transactionType,
CashReceiptType $type,
string $errorCode,
string $errorMessage,
?string $identifier,
?string $provider = null,
): OrderCashReceipt {
return $this->repository->create([
'order_id' => $order->id,
'order_payment_id' => $payment?->id,
'provider' => $provider ?? '',
'receipt_key' => null,
'transaction_type' => $transactionType->value,
'receipt_type' => $type->value,
'amount' => 0,
'tax_free_amount' => 0,
'identifier_masked' => $identifier !== null ? $this->maskIdentifier($identifier) : null,
'receipt_url' => null,
'issue_number' => null,
'issue_status' => CashReceiptIssueStatus::FAILED->value,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'issued_at' => null,
'raw_response' => null,
]);
}
/**
* 발급 성공 시 결제 테이블의 요약 컬럼을 갱신합니다.
*
* @param OrderPayment|null $payment 결제 모델
* @param OrderCashReceipt $receipt 발급 이력
* @param CashReceiptType $type 발급 용도
* @param string $identifier 식별번호 원본
* @param CashReceiptIdentifierType|null $identifierType 식별번호 종류
*/
private function syncPaymentSummary(
?OrderPayment $payment,
OrderCashReceipt $receipt,
CashReceiptType $type,
string $identifier,
?CashReceiptIdentifierType $identifierType,
): void {
if ($payment === null) {
return;
}
$this->paymentRepository->markCashReceiptIssued(
$payment,
$receipt,
$type,
$identifier,
$this->maskIdentifier($identifier),
$identifierType ?? $this->inferIdentifierType($identifier),
);
}
/**
* 전액취소 성공 시 결제 테이블의 요약 컬럼을 초기화합니다.
*
* 암호문은 유지한다 — 전액취소 후 관리자가 같은 식별번호로 재발급할 수 있어야 하고,
* 구매확정(purgeIdentifier) 시점에 일괄 폐기된다.
*
* @param OrderPayment|null $payment 결제 모델
*/
private function clearPaymentSummary(?OrderPayment $payment): void
{
if ($payment === null) {
return;
}
$this->paymentRepository->markCashReceiptCancelled($payment);
}
/**
* 식별번호를 마스킹합니다 (뒤 4자리만 노출).
*
* @param string $identifier 식별번호 원본
* @return string 마스킹된 식별번호
*/
private function maskIdentifier(string $identifier): string
{
$length = strlen($identifier);
if ($length <= 4) {
return str_repeat('*', $length);
}
return str_repeat('*', $length - 4).substr($identifier, -4);
}
/**
* 식별번호 형식으로 종류를 추론합니다.
*
* @param string $identifier 식별번호 원본
* @return CashReceiptIdentifierType|null 추론된 종류 (판별 불가 시 null)
*/
private function inferIdentifierType(string $identifier): ?CashReceiptIdentifierType
{
foreach ([
CashReceiptIdentifierType::PHONE,
CashReceiptIdentifierType::BUSINESS,
CashReceiptIdentifierType::CARD,
] as $candidate) {
if ($candidate->matches($identifier)) {
return $candidate;
}
}
return null;
}
/**
* 발급 요청에 사용할 주문명을 생성합니다.
*
* @param Order $order 주문 모델
* @return string 주문명
*/
private function buildOrderName(Order $order): string
{
return $order->payment?->payment_name
?? (string) $order->order_number;
}
/**
* 프로바이더 원응답에서 민감 키를 마스킹합니다.
*
* PO 결정: 전체 응답을 보관하되 알려진 민감 키를 재귀적으로 가린다.
*
* @param mixed $response 원응답
* @return array<string, mixed>|null 마스킹된 응답 (배열이 아니면 null)
*/
private function sanitizeResponse(mixed $response): ?array
{
if (! is_array($response)) {
return null;
}
return $this->maskSensitiveKeys($response);
}
/**
* 배열을 재귀 순회하며 민감 키의 값을 마스킹합니다.
*
* @param array<mixed, mixed> $data 원본 배열
* @return array<mixed, mixed> 마스킹된 배열
*/
private function maskSensitiveKeys(array $data): array
{
$masked = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$masked[$key] = $this->maskSensitiveKeys($value);
continue;
}
$masked[$key] = $this->isSensitiveKey((string) $key) ? '***' : $value;
}
return $masked;
}
/**
* 민감 키인지 확인합니다.
*
* @param string $key 키 이름
* @return bool 민감 키 여부
*/
private function isSensitiveKey(string $key): bool
{
$normalized = strtolower(str_replace(['_', '-'], '', $key));
foreach (self::SENSITIVE_RESPONSE_KEYS as $sensitive) {
if (str_contains($normalized, $sensitive)) {
return true;
}
}
return false;
}
/**
* 발급 필터 훅의 기본 반환값을 생성합니다.
*
* @return array<string, mixed> 기본 반환값
*/
private function defaultIssueResult(): array
{
return [
'success' => false,
'error_code' => 'NO_PROVIDER_HANDLED',
'error_message' => __('sirsoft-ecommerce::cash_receipt.errors.no_provider_handled'),
'receipt_key' => null,
'receipt_url' => null,
'issue_number' => null,
'raw_response' => null,
];
}
/**
* 취소 필터 훅의 기본 반환값을 생성합니다.
*
* @return array<string, mixed> 기본 반환값
*/
private function defaultCancelResult(): array
{
return [
'success' => false,
'error_code' => 'NO_PROVIDER_HANDLED',
'error_message' => __('sirsoft-ecommerce::cash_receipt.errors.no_provider_handled'),
'receipt_key' => null,
'raw_response' => null,
];
}
}
@@ -9,6 +9,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
/**
* 이커머스 모듈 환경설정 서비스
@@ -687,6 +688,59 @@ class EcommerceSettingsService implements ModuleSettingsInterface
);
}
/**
* 등록된 현금영수증 발급 프로바이더 목록을 조회합니다. (플러그인 훅 기반)
*
* PG 프로바이더와 독립적으로 선택 가능하다 — KG이니시스로 결제하면서
* 현금영수증만 토스페이먼츠로 발급하는 구성을 허용한다.
*
* @return array 프로바이더 배열 [{id, name, name_key, icon}, ...]
*/
public function getRegisteredCashReceiptProviders(): array
{
return HookManager::applyFilters(
'sirsoft-ecommerce.cash_receipt.registered_providers',
[]
);
}
/**
* 현재 선택된 현금영수증 발급 프로바이더 ID를 반환합니다.
*
* @return string|null 프로바이더 ID (미설정 시 null)
*/
public function getCashReceiptProvider(): ?string
{
$provider = $this->getSetting('order_settings.cash_receipt_provider');
return is_string($provider) && $provider !== '' ? $provider : null;
}
/**
* 자진발급 사용 여부를 반환합니다.
*
* 구매자가 현금영수증을 신청하지 않은 무통장 입금완료 건에 대해 사업자가
* 국세청 지정번호로 자진발급할지 여부. 기본값은 사용 안 함.
*
* @return bool 자진발급 사용 여부
*/
public function isCashReceiptSelfIssueEnabled(): bool
{
return (bool) $this->getSetting('order_settings.cash_receipt_self_issue', false);
}
/**
* 배송비 과세 정책을 반환합니다.
*
* @return ShippingFeeTaxPolicy 배송비 과세 정책 (미설정 시 안분)
*/
public function getShippingFeeTaxPolicy(): ShippingFeeTaxPolicy
{
$value = $this->getSetting('order_settings.shipping_fee_tax_policy');
return ShippingFeeTaxPolicy::fromValueOrDefault(is_string($value) ? $value : null);
}
/**
* 특정 결제수단의 설정을 조회합니다.
*
@@ -29,6 +29,7 @@ use Modules\Sirsoft\Ecommerce\Enums\ProductTaxStatus;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiAuthType;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiHttpMethod;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiResponseType;
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
@@ -984,6 +985,98 @@ class OrderCalculationService
return $classification;
}
/**
* 배송비 할인을 반영한 실질 배송비를 반환합니다.
*
* @param array $paymentCalculation 결제금액 계산 결과
* @return int 할인 후 배송비 (0 이상)
*/
protected function resolveNetShippingFee(array $paymentCalculation): int
{
$totalShipping = (int) ($paymentCalculation['total_shipping'] ?? 0);
$shippingDiscount = (int) ($paymentCalculation['shipping_discount'] ?? 0);
return max(0, $totalShipping - $shippingDiscount);
}
/**
* 단계 2-c: 배송비를 과세/면세로 분류합니다.
*
* 부가가치세법 제14조는 주된 재화에 부수되는 용역이 주된 재화의 과세/면세를 따른다고 정할 뿐,
* 과세·면세 상품이 혼합된 장바구니의 배송비 안분에 관한 명문 규정은 없다.
* 상점의 세무 판단에 따라 3가지 정책 중 선택한다.
*
* 상품 분류(classifyTaxStatus)와 달리 이 메서드는 배송비 할인이 확정된 뒤에 호출되어야 한다.
*
* @param int $netShippingFee 할인 후 배송비
* @param int $productTaxable 상품 과세금액 합계
* @param int $productTaxFree 상품 면세금액 합계
* @return array{taxable_amount: int, tax_free_amount: int} 배송비 과세/면세 분류
*/
protected function classifyShippingFeeTaxStatus(
int $netShippingFee,
int $productTaxable,
int $productTaxFree,
): array {
if ($netShippingFee <= 0) {
return ['taxable_amount' => 0, 'tax_free_amount' => 0];
}
$policy = $this->resolveShippingFeeTaxPolicy();
$productTotal = $productTaxable + $productTaxFree;
// 상품 금액이 전부 0 이면 안분 기준이 없다 — 전액 과세로 처리한다.
if ($productTotal <= 0) {
return ['taxable_amount' => $netShippingFee, 'tax_free_amount' => 0];
}
return match ($policy) {
ShippingFeeTaxPolicy::TAXABLE => [
'taxable_amount' => $netShippingFee,
'tax_free_amount' => 0,
],
ShippingFeeTaxPolicy::FOLLOW_MAIN_ITEM => $productTaxable >= $productTaxFree
? ['taxable_amount' => $netShippingFee, 'tax_free_amount' => 0]
: ['taxable_amount' => 0, 'tax_free_amount' => $netShippingFee],
ShippingFeeTaxPolicy::PROPORTIONAL => $this->apportionShippingFeeTax(
$netShippingFee,
$productTaxable,
$productTotal,
),
};
}
/**
* 배송비를 과세상품 비율만큼 안분합니다.
*
* 면세분을 반올림해 산출한 뒤 잔차를 과세 쪽에 귀속시켜 합계를 보존한다.
*
* @param int $netShippingFee 할인 후 배송비
* @param int $productTaxable 상품 과세금액 합계
* @param int $productTotal 상품 총액 (과세 + 면세)
* @return array{taxable_amount: int, tax_free_amount: int} 안분 결과
*/
protected function apportionShippingFeeTax(int $netShippingFee, int $productTaxable, int $productTotal): array
{
$taxableShipping = (int) round($netShippingFee * $productTaxable / $productTotal);
$taxableShipping = max(0, min($taxableShipping, $netShippingFee));
return [
'taxable_amount' => $taxableShipping,
'tax_free_amount' => $netShippingFee - $taxableShipping,
];
}
/**
* 배송비 과세 정책을 조회합니다.
*
* @return ShippingFeeTaxPolicy 배송비 과세 정책
*/
protected function resolveShippingFeeTaxPolicy(): ShippingFeeTaxPolicy
{
return app(EcommerceSettingsService::class)->getShippingFeeTaxPolicy();
}
/**
* 단계 3: 배송비를 계산합니다 (정책별 그룹).
*
@@ -1499,6 +1592,20 @@ class OrderCalculationService
// (전체 과세표준에 단일 세율을 적용하면 면세·영세·다른 세율 상품이 섞였을 때 어긋난다)
$totalVat = VatCalculator::sumFromClassification($taxClassification);
// 배송비 과세 분류 — 상품 분류(단계 2-b)와 달리 할인 후 최종 배송비가 확정된 이 시점에서만
// 계산할 수 있다. 옵션별 taxable_amount 는 상품분만 담으므로 배송비는 Summary 에만 합산한다.
$shippingTax = $this->classifyShippingFeeTaxStatus(
$this->resolveNetShippingFee($paymentCalculation),
$totalTaxable,
$totalTaxFree,
);
$totalTaxable += $shippingTax['taxable_amount'];
$totalTaxFree += $shippingTax['tax_free_amount'];
// 배송비 과세분에 내재된 부가세도 합산 — 배송비는 옵션별 세율이 없으므로 기본 세율을 적용한다
// (과세표준(taxableAmount)에 배송비가 포함되므로 부가세도 같은 기준을 따라야 정합)
$totalVat += VatCalculator::fromTaxableAmount($shippingTax['taxable_amount']);
$summary = new Summary(
subtotal: $paymentCalculation['subtotal'],
productCouponDiscount: $paymentCalculation['coupon_discount'],
@@ -172,10 +172,10 @@ class OrderProcessingService
$isZeroPayable
) {
// 주문 생성
$order = $this->createOrder($tempOrder, $calculationResult, $initialStatus, $currencySnapshot, $guestLookupPassword, $shippingInfo);
$order = $this->createOrder($tempOrder, $calculationResult, $initialStatus, $currencySnapshot, $guestLookupPassword, $shippingInfo, $paymentMethod);
// 주문 옵션 생성 (배송 정보 연결을 위해 옵션 ID 매핑 반환)
$createdOptions = $this->createOrderOptions($order, $tempOrder, $calculationResult, $currencySnapshot);
$createdOptions = $this->createOrderOptions($order, $tempOrder, $calculationResult, $currencySnapshot, $paymentMethod);
// 주문 주소 생성 (주문자 + 배송지)
$this->createOrderAddresses($order, $ordererInfo, $shippingInfo, $shippingMemo);
@@ -505,6 +505,9 @@ class OrderProcessingService
* @param OrderCalculationResult $calculationResult 계산 결과
* @param OrderStatusEnum $initialStatus 초기 상태
* @param array $currencySnapshot 통화 스냅샷
* @param string|null $guestLookupPassword 비회원 조회 비밀번호
* @param array $shippingInfo 배송 정보
* @param string|null $paymentMethod 결제수단 (현금성 금액 산정용)
*/
protected function createOrder(
TempOrder $tempOrder,
@@ -512,9 +515,11 @@ class OrderProcessingService
OrderStatusEnum $initialStatus,
array $currencySnapshot,
?string $guestLookupPassword = null,
array $shippingInfo = []
array $shippingInfo = [],
?string $paymentMethod = null
): Order {
$summary = $calculationResult->summary;
$cashEquivalent = $this->resolveCashEquivalentAmount($paymentMethod, (int) ($summary->finalAmount ?? 0));
// 다중 통화 변환
$mcAmounts = $this->buildOrderMultiCurrency($summary, $currencySnapshot);
@@ -555,6 +560,10 @@ class OrderProcessingService
// PG(KG 이니시스 등) 결제 요청 금액·무통장 입금 안내액의 SSoT 이므로 차감 전 paymentAmount 를 쓰면
// 마일리지 사용분만큼 과다 청구된다. total_amount 와 동일한 차감 후 금액으로 둔다.
'total_due_amount' => $summary->finalAmount ?? 0,
// 현금성 금액 = 현금영수증 발급 대상액. 무통장(dbank)만 실입금액 전액이 현금이며,
// 마일리지 사용분은 현금이 아니므로 차감 후 금액(finalAmount)을 쓴다.
// 가상계좌는 PG 가 자동 발급하므로 0, 카드/계좌이체/휴대폰도 0.
'total_cash_equivalent_amount' => $cashEquivalent,
'total_cancelled_amount' => 0,
'total_refunded_amount' => 0,
'total_refunded_points_amount' => 0,
@@ -588,6 +597,7 @@ class OrderProcessingService
'mc_total_tax_free_amount' => $mcAmounts['mc_total_tax_free_amount'],
'mc_total_amount' => $mcAmounts['mc_total_amount'],
'mc_total_paid_amount' => $mcAmounts['mc_total_paid_amount'],
'mc_total_cash_equivalent_amount' => $this->buildAllCurrencyConverter($currencySnapshot)($cashEquivalent),
];
// 훅을 통한 데이터 가공
@@ -646,6 +656,31 @@ class OrderProcessingService
];
}
/**
* 결제수단별 현금성 금액을 산정합니다.
*
* 현금성 금액 = 현금영수증 발급 대상이 되는 실제 현금 입금액.
*
* | 결제수단 | 현금성 금액 |
* |------------------|--------------------------------------------|
* | dbank (무통장) | 실입금액 전액 (마일리지 차감 후 finalAmount) |
* | vbank (가상계좌) | 0 — PG 가 자동 발급 |
* | card/bank/phone | 0 |
* | point/deposit | 0 (현금이 아님) |
*
* @param string|null $paymentMethod 결제수단 식별자
* @param int $finalAmount 마일리지/예치금 차감 후 실결제액
* @return int 현금성 금액
*/
protected function resolveCashEquivalentAmount(?string $paymentMethod, int $finalAmount): int
{
if ($paymentMethod !== PaymentMethodEnum::DBANK->value) {
return 0;
}
return max(0, $finalAmount);
}
/**
* 주문 옵션 생성
*
@@ -668,7 +703,8 @@ class OrderProcessingService
Order $order,
TempOrder $tempOrder,
OrderCalculationResult $calculationResult,
array $currencySnapshot
array $currencySnapshot,
?string $paymentMethod = null
): array {
$createdOptions = [];
@@ -715,6 +751,12 @@ class OrderProcessingService
'subtotal_points_used_amount' => $item->pointsUsedShare ?? 0,
'subtotal_deposit_used_amount' => $item->depositUsedShare ?? 0,
'subtotal_paid_amount' => $item->finalAmount ?? 0,
// 옵션별 현금성 안분액. 옵션 finalAmount 합 = 주문 finalAmount 이므로
// 안분 잔차 없이 합계가 그대로 보존된다.
'subtotal_cash_equivalent_amount' => $this->resolveCashEquivalentAmount(
$paymentMethod,
(int) ($item->finalAmount ?? 0),
),
'subtotal_tax_amount' => $item->taxableAmount ?? 0,
'subtotal_tax_free_amount' => $item->taxFreeAmount ?? 0,
'subtotal_earned_points_amount' => $item->pointsEarning ?? 0,
@@ -736,6 +778,9 @@ class OrderProcessingService
'mc_subtotal_tax_amount' => $mcAmounts['mc_subtotal_tax_amount'],
'mc_subtotal_tax_free_amount' => $mcAmounts['mc_subtotal_tax_free_amount'],
'mc_final_amount' => $mcAmounts['mc_final_amount'],
'mc_subtotal_cash_equivalent_amount' => $this->buildAllCurrencyConverter($currencySnapshot)(
$this->resolveCashEquivalentAmount($paymentMethod, (int) ($item->finalAmount ?? 0)),
),
]);
// productOptionId → OrderOption 매핑 저장
@@ -357,6 +357,8 @@ class OrderService
$order->taxInvoices()->delete();
$order->shippings()->delete();
$order->addresses()->delete();
// 현금영수증 이력은 결제(order_payment_id)를 참조하므로 결제보다 먼저 삭제한다.
$order->cashReceipts()->delete();
$order->payment()->delete();
$order->options()->delete();
@@ -0,0 +1,56 @@
<?php
return [
/*
* 발급 용도
*/
'type' => [
'income' => 'Income Deduction',
'expense' => 'Expenditure Proof',
],
/*
* 식별번호 종류
*/
'identifier_type' => [
'phone' => 'Mobile Number',
'card' => 'Cash Receipt Card Number',
'business' => 'Business Registration Number',
],
/*
* 이력 거래 유형
*/
'transaction_type' => [
'issue' => 'Issue',
'cancel' => 'Cancel',
],
/*
* 발급 상태
*/
'issue_status' => [
'in_progress' => 'In Progress',
'completed' => 'Completed',
'failed' => 'Failed',
],
/*
* 배송비 과세 정책
*/
'shipping_fee_tax_policy' => [
'proportional' => 'Proportional (by taxable item ratio)',
'taxable' => 'Fully Taxable',
'follow_main_item' => 'Follow Main Item',
],
/*
* 오류 메시지
*/
'errors' => [
'provider_not_configured' => 'No cash receipt provider is configured.',
'no_provider_handled' => 'No provider handled the cash receipt request.',
'no_issuable_amount' => 'There is no issuable cash-equivalent amount.',
'identifier_unavailable' => 'The identifier required for re-issuance could not be decrypted. An administrator must re-enter the identifier to issue the receipt.',
],
];
@@ -0,0 +1,56 @@
<?php
return [
/*
* 발급 용도
*/
'type' => [
'income' => '소득공제용',
'expense' => '지출증빙용',
],
/*
* 식별번호 종류
*/
'identifier_type' => [
'phone' => '휴대폰번호',
'card' => '현금영수증카드번호',
'business' => '사업자등록번호',
],
/*
* 이력 거래 유형
*/
'transaction_type' => [
'issue' => '발급',
'cancel' => '취소',
],
/*
* 발급 상태
*/
'issue_status' => [
'in_progress' => '처리 중',
'completed' => '발급 완료',
'failed' => '발급 실패',
],
/*
* 배송비 과세 정책
*/
'shipping_fee_tax_policy' => [
'proportional' => '안분 (과세상품 비율만큼 과세)',
'taxable' => '전액 과세',
'follow_main_item' => '주된 재화를 따름',
],
/*
* 오류 메시지
*/
'errors' => [
'provider_not_configured' => '현금영수증 발급 프로바이더가 설정되지 않았습니다.',
'no_provider_handled' => '현금영수증 발급 요청을 처리한 프로바이더가 없습니다.',
'no_issuable_amount' => '발급 가능한 현금성 금액이 없습니다.',
'identifier_unavailable' => '재발급에 필요한 식별번호를 복호화할 수 없습니다. 관리자가 식별번호를 다시 입력해 발급해야 합니다.',
],
];
@@ -0,0 +1,160 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Migrations;
use Illuminate\Support\Facades\Schema;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 현금영수증 관련 마이그레이션 왕복(up → down → up) 안전성 테스트
*
* 모듈 삭제 시 delete_data 옵션으로 롤백이 실행되므로 down() 이 방어적이어야 한다.
* 또한 코어 업그레이드 중 부분 적용된 상태에서 재실행되어도 SQL error 가 없어야 한다(멱등).
*/
class CashReceiptMigrationRoundTripTest extends ModuleTestCase
{
private const MIGRATION_DIR = 'modules/_bundled/sirsoft-ecommerce/database/migrations';
/**
* 마이그레이션 파일 경로 목록
*
* @return array<string, string>
*/
private function migrations(): array
{
return [
'cash_receipts' => self::MIGRATION_DIR.'/2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php',
'cash_equivalent' => self::MIGRATION_DIR.'/2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php',
'identifier_encrypted' => self::MIGRATION_DIR.'/2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php',
'identifier_type' => self::MIGRATION_DIR.'/2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php',
];
}
/**
* 마이그레이션 파일을 로드해 인스턴스를 반환합니다.
*/
private function load(string $relativePath): object
{
return require base_path($relativePath);
}
#[Test]
public function 마이그레이션은_모듈_enum_클래스를_참조하지_않는다(): void
{
// `php artisan migrate` 는 모듈 오토로더 없이 실행될 수 있다 (신규 설치/모듈 비활성).
foreach ($this->migrations() as $name => $path) {
$source = file_get_contents(base_path($path));
$this->assertStringNotContainsString(
'Modules\\Sirsoft',
$source,
"{$name} 마이그레이션이 모듈 클래스를 참조하면 오토로더 미등록 시 실패한다",
);
}
}
#[Test]
public function 이력_테이블은_왕복_후에도_동일하게_생성된다(): void
{
$migration = $this->load($this->migrations()['cash_receipts']);
$this->assertTrue(Schema::hasTable('ecommerce_order_cash_receipts'));
$migration->down();
$this->assertFalse(Schema::hasTable('ecommerce_order_cash_receipts'));
$migration->up();
$this->assertTrue(Schema::hasTable('ecommerce_order_cash_receipts'));
foreach ([
'order_id', 'order_payment_id', 'provider', 'receipt_key', 'transaction_type',
'receipt_type', 'amount', 'tax_free_amount', 'identifier_masked', 'receipt_url',
'issue_number', 'issue_status', 'error_code', 'error_message', 'issued_at', 'raw_response',
] as $column) {
$this->assertTrue(
Schema::hasColumn('ecommerce_order_cash_receipts', $column),
"컬럼 {$column} 이 존재해야 한다",
);
}
}
#[Test]
public function 이력_테이블_생성은_두_번_실행해도_안전하다(): void
{
$migration = $this->load($this->migrations()['cash_receipts']);
// 이미 존재하는 상태에서 재실행 — 멱등 가드가 없으면 "table already exists" fatal
$migration->up();
$this->assertTrue(Schema::hasTable('ecommerce_order_cash_receipts'));
}
#[Test]
public function 현금성_금액_컬럼은_왕복_후에도_동일하게_생성된다(): void
{
$migration = $this->load($this->migrations()['cash_equivalent']);
$migration->down();
$this->assertFalse(Schema::hasColumn('ecommerce_orders', 'total_cash_equivalent_amount'));
$this->assertFalse(Schema::hasColumn('ecommerce_order_options', 'subtotal_cash_equivalent_amount'));
$migration->up();
$this->assertTrue(Schema::hasColumn('ecommerce_orders', 'total_cash_equivalent_amount'));
$this->assertTrue(Schema::hasColumn('ecommerce_orders', 'mc_total_cash_equivalent_amount'));
$this->assertTrue(Schema::hasColumn('ecommerce_order_options', 'subtotal_cash_equivalent_amount'));
$this->assertTrue(Schema::hasColumn('ecommerce_order_options', 'mc_subtotal_cash_equivalent_amount'));
// 멱등: 재실행해도 SQL error 없음
$migration->up();
$this->assertTrue(Schema::hasColumn('ecommerce_orders', 'total_cash_equivalent_amount'));
}
#[Test]
public function 암호문_컬럼은_왕복_후에도_동일하게_생성된다(): void
{
$migration = $this->load($this->migrations()['identifier_encrypted']);
$migration->down();
$this->assertFalse(Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_encrypted'));
$migration->up();
$this->assertTrue(Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_encrypted'));
$migration->up();
$this->assertTrue(Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_encrypted'));
}
#[Test]
public function 식별번호_종류_컬럼은_왕복_후에도_동일하게_생성된다(): void
{
$migration = $this->load($this->migrations()['identifier_type']);
$migration->down();
$this->assertFalse(Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_type'));
$migration->up();
$this->assertTrue(Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_type'));
$migration->up();
$this->assertTrue(Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_type'));
}
#[Test]
public function down_은_테이블이_없어도_예외를_던지지_않는다(): void
{
// 모듈 삭제 시 부분 롤백 상태에서 재롤백되는 경우
$cashReceipts = $this->load($this->migrations()['cash_receipts']);
$cashReceipts->down();
$cashReceipts->down();
$this->assertFalse(Schema::hasTable('ecommerce_order_cash_receipts'));
// 컬럼 추가 마이그레이션도 동일
$encrypted = $this->load($this->migrations()['identifier_encrypted']);
$encrypted->down();
$encrypted->down();
$this->assertFalse(Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_encrypted'));
}
}
@@ -0,0 +1,136 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Enums;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
/**
* 현금영수증 식별번호 종류 Enum 테스트
*/
class CashReceiptIdentifierTypeTest extends ModuleTestCase
{
#[Test]
#[DataProvider('validBusinessNumberProvider')]
public function 유효한_사업자등록번호는_체크섬을_통과한다(string $number): void
{
$this->assertTrue(CashReceiptIdentifierType::isValidBusinessNumber($number));
}
/**
* @return array<string, array{string}>
*/
public static function validBusinessNumberProvider(): array
{
return [
'체크섬 유효 #1' => ['1248100998'],
'체크섬 유효 #2' => ['1018116293'],
];
}
#[Test]
#[DataProvider('invalidBusinessNumberProvider')]
public function 무효한_사업자등록번호는_거부된다(string $number): void
{
$this->assertFalse(CashReceiptIdentifierType::isValidBusinessNumber($number));
}
/**
* @return array<string, array{string}>
*/
public static function invalidBusinessNumberProvider(): array
{
return [
'체크digit 불일치' => ['1248100997'],
'임의 순번' => ['1234567890'],
// 앞 9자리가 0 이면 체크섬을 수학적으로 항상 만족하지만 발급될 수 없는 번호다.
'전부 0 (체크섬은 통과하나 세무서 코드 000)' => ['0000000000'],
'자릿수 부족' => ['124810099'],
'자릿수 초과' => ['12481009980'],
'숫자 아님' => ['124-81-0099'],
];
}
#[Test]
#[DataProvider('identifierFormatProvider')]
public function 식별번호_형식을_종류별로_검증한다(
CashReceiptIdentifierType $type,
string $identifier,
bool $expected,
): void {
$this->assertSame($expected, $type->matches($identifier));
}
/**
* @return array<string, array{CashReceiptIdentifierType, string, bool}>
*/
public static function identifierFormatProvider(): array
{
return [
'휴대폰 10자리' => [CashReceiptIdentifierType::PHONE, '0101234567', true],
'휴대폰 11자리' => [CashReceiptIdentifierType::PHONE, '01012345678', true],
'휴대폰 접두어 오류' => [CashReceiptIdentifierType::PHONE, '02012345678', false],
'휴대폰 하이픈 포함' => [CashReceiptIdentifierType::PHONE, '010-1234-5678', false],
'카드 13자리' => [CashReceiptIdentifierType::CARD, '1234567890123', true],
'카드 19자리' => [CashReceiptIdentifierType::CARD, '1234567890123456789', true],
'카드 12자리(부족)' => [CashReceiptIdentifierType::CARD, '123456789012', false],
'카드 20자리(초과)' => [CashReceiptIdentifierType::CARD, '12345678901234567890', false],
'사업자번호 유효' => [CashReceiptIdentifierType::BUSINESS, '1248100998', true],
'사업자번호 무효' => [CashReceiptIdentifierType::BUSINESS, '1248100997', false],
];
}
#[Test]
public function 자진발급_지정번호를_식별한다(): void
{
$this->assertTrue(CashReceiptIdentifierType::isSelfIssueNumber('0100001234'));
$this->assertFalse(CashReceiptIdentifierType::isSelfIssueNumber('01012345678'));
}
#[Test]
public function 소득공제용은_휴대폰과_카드번호만_허용한다(): void
{
$allowed = CashReceiptType::INCOME->allowedIdentifierTypes();
$this->assertContains(CashReceiptIdentifierType::PHONE, $allowed);
$this->assertContains(CashReceiptIdentifierType::CARD, $allowed);
$this->assertNotContains(CashReceiptIdentifierType::BUSINESS, $allowed);
}
#[Test]
public function 지출증빙용은_휴대폰번호도_허용한다(): void
{
// 팝빌 발행유형 가이드가 소득공제/지출증빙 양쪽에 휴대폰번호를 명시한다.
$allowed = CashReceiptType::EXPENSE->allowedIdentifierTypes();
$this->assertContains(CashReceiptIdentifierType::BUSINESS, $allowed);
$this->assertContains(CashReceiptIdentifierType::PHONE, $allowed);
$this->assertContains(CashReceiptIdentifierType::CARD, $allowed);
}
#[Test]
#[DataProvider('legacyTypeProvider')]
public function 레거시_발급용도_값을_정규화한다(?string $legacy, ?CashReceiptType $expected): void
{
$this->assertSame($expected, CashReceiptType::fromLegacy($legacy));
}
/**
* @return array<string, array{?string, ?CashReceiptType}>
*/
public static function legacyTypeProvider(): array
{
return [
'KG 레거시 소득공제' => ['income_deduction', CashReceiptType::INCOME],
'KG 레거시 지출증빙' => ['expenditure_proof', CashReceiptType::EXPENSE],
'정규 소득공제' => ['income', CashReceiptType::INCOME],
'정규 지출증빙' => ['expense', CashReceiptType::EXPENSE],
'null' => [null, null],
'빈 문자열' => ['', null],
'해석 불가' => ['unknown', null],
];
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use ReflectionMethod;
/**
* 현금성 금액 산정 테스트 (A-3)
*
* 현금성 금액 = 현금영수증 발급 대상이 되는 실제 현금 입금액.
* 무통장만 실입금액(마일리지 차감 후) 전액이고 나머지는 0 이다.
*/
class CashEquivalentAmountTest extends ModuleTestCase
{
/**
* resolveCashEquivalentAmount 를 호출합니다.
*/
private function resolve(?string $paymentMethod, int $finalAmount): int
{
$service = $this->getMockBuilder(OrderProcessingService::class)
->disableOriginalConstructor()
->onlyMethods([])
->getMock();
$method = new ReflectionMethod(OrderProcessingService::class, 'resolveCashEquivalentAmount');
$method->setAccessible(true);
return $method->invoke($service, $paymentMethod, $finalAmount);
}
#[Test]
#[DataProvider('paymentMethodProvider')]
public function 결제수단별_현금성_금액(?string $method, int $finalAmount, int $expected): void
{
$this->assertSame($expected, $this->resolve($method, $finalAmount));
}
/**
* @return array<string, array{?string, int, int}>
*/
public static function paymentMethodProvider(): array
{
return [
'무통장 = 실입금액 전액' => [PaymentMethodEnum::DBANK->value, 11000, 11000],
'가상계좌 = 0 (PG 자동발급)' => [PaymentMethodEnum::VBANK->value, 11000, 0],
'카드 = 0' => [PaymentMethodEnum::CARD->value, 11000, 0],
'계좌이체 = 0' => [PaymentMethodEnum::BANK->value, 11000, 0],
'휴대폰 = 0' => [PaymentMethodEnum::PHONE->value, 11000, 0],
'결제수단 미지정 = 0' => [null, 11000, 0],
];
}
#[Test]
public function 무통장에서_마일리지를_사용하면_차감된_실입금액이_현금성이다(): void
{
// 주문 20000, 마일리지 8000 사용 → finalAmount 12000 이 실제 계좌 입금액
$this->assertSame(12000, $this->resolve(PaymentMethodEnum::DBANK->value, 12000));
}
#[Test]
public function 전액_마일리지_결제면_현금성이_0이다(): void
{
$this->assertSame(0, $this->resolve(PaymentMethodEnum::DBANK->value, 0));
}
#[Test]
public function 음수_실입금액은_0으로_보정된다(): void
{
$this->assertSame(0, $this->resolve(PaymentMethodEnum::DBANK->value, -500));
}
#[Test]
public function 옵션별_현금성_안분합이_주문_현금성과_일치한다(): void
{
// 옵션 finalAmount 합 = 주문 finalAmount 이므로 안분 잔차가 발생하지 않는다.
$optionFinalAmounts = [3333, 3333, 3334];
$orderFinalAmount = array_sum($optionFinalAmounts);
$optionCashSum = 0;
foreach ($optionFinalAmounts as $amount) {
$optionCashSum += $this->resolve(PaymentMethodEnum::DBANK->value, $amount);
}
$this->assertSame(
$this->resolve(PaymentMethodEnum::DBANK->value, $orderFinalAmount),
$optionCashSum,
'옵션별 현금성 안분 합 = 주문 현금성 금액',
);
}
#[Test]
public function 무통장이_아니면_옵션별_현금성도_모두_0이다(): void
{
foreach ([3333, 3333, 3334] as $amount) {
$this->assertSame(0, $this->resolve(PaymentMethodEnum::VBANK->value, $amount));
$this->assertSame(0, $this->resolve(PaymentMethodEnum::CARD->value, $amount));
}
}
}
@@ -0,0 +1,608 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\HookManager;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCashReceiptRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 현금영수증 서비스 테스트 (A-1 / A-7 / D7 / D15)
*
* syncFromOrder() 는 "왜" 금액이 바뀌었는지 모르는 무상태 재계산기다.
* 감소/증가/무변동/전액취소/이력없음/과세비율변동 6케이스를 검증한다.
*/
class CashReceiptServiceTest extends ModuleTestCase
{
private const PROVIDER = 'tosspayments';
private const IDENTIFIER = '01012345678';
/** @var array<int, array{provider: string, payload: array}> 발급 훅이 받은 호출 기록 */
private array $issueCalls = [];
/** @var array<int, array{provider: string, receipt_key: string}> 취소 훅이 받은 호출 기록 */
private array $cancelCalls = [];
private int $receiptSequence = 0;
/** 발급 훅이 실패를 반환할지 여부 (테스트 도중 전환 가능) */
private bool $issueShouldFail = false;
protected function setUp(): void
{
parent::setUp();
$this->issueCalls = [];
$this->cancelCalls = [];
$this->receiptSequence = 0;
$this->issueShouldFail = false;
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
}
/**
* 프로바이더 리스너를 등록합니다.
*
* ModuleTestCase 가 HookManager static 상태를 테스트마다 스냅샷/복원하므로 별도 정리 불필요.
*/
private function registerProvider(): void
{
HookManager::addFilter(
'sirsoft-ecommerce.cash_receipt.issue',
function (array $result, Order $order, string $provider, array $payload) {
if ($provider !== self::PROVIDER) {
return $result;
}
$this->issueCalls[] = ['provider' => $provider, 'payload' => $payload];
if ($this->issueShouldFail) {
return [
'success' => false,
'error_code' => 'PROVIDER_ERROR',
'error_message' => '발급 실패',
'receipt_key' => null,
'receipt_url' => null,
'issue_number' => null,
'raw_response' => null,
];
}
$key = 'receipt-'.(++$this->receiptSequence);
return [
'success' => true,
'error_code' => null,
'error_message' => null,
'receipt_key' => $key,
'receipt_url' => "https://example.test/receipt/{$key}",
'issue_number' => '12345678',
'raw_response' => [
'receiptKey' => $key,
'customerIdentityNumber' => self::IDENTIFIER,
'amount' => $payload['amount'],
],
];
},
);
HookManager::addFilter(
'sirsoft-ecommerce.cash_receipt.cancel',
function (array $result, Order $order, string $provider, string $receiptKey) {
if ($provider !== self::PROVIDER) {
return $result;
}
$this->cancelCalls[] = ['provider' => $provider, 'receipt_key' => $receiptKey];
return [
'success' => true,
'error_code' => null,
'error_message' => null,
'receipt_key' => $receiptKey,
'raw_response' => ['receiptKey' => $receiptKey, 'status' => 'CANCELED'],
];
},
);
}
/**
* 무통장 주문을 생성합니다.
*/
private function makeOrder(int $cashEquivalent, int $taxable, int $taxFree): Order
{
$order = Order::factory()->create([
'total_amount' => $cashEquivalent,
'total_cash_equivalent_amount' => $cashEquivalent,
'total_tax_amount' => $taxable,
'total_tax_free_amount' => $taxFree,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_method' => PaymentMethodEnum::DBANK,
'payment_status' => PaymentStatusEnum::PAID,
]);
return $order->fresh();
}
private function service(): CashReceiptService
{
return app(CashReceiptService::class);
}
/**
* 주문의 현재 활성 영수증을 반환합니다.
*/
private function activeReceipt(Order $order): ?OrderCashReceipt
{
return app(OrderCashReceiptRepositoryInterface::class)->findActiveReceipt($order->fresh());
}
#[Test]
public function 발급하면_이력과_결제요약이_함께_갱신된다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$receipt = $this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(CashReceiptIssueStatus::COMPLETED, $receipt->issue_status);
$this->assertSame(CashReceiptTransactionType::ISSUE, $receipt->transaction_type);
$this->assertSame(11000, (int) $receipt->amount);
$this->assertSame(0, (int) $receipt->tax_free_amount);
$this->assertNotNull($receipt->receipt_url);
$payment = $order->fresh()->payment;
$this->assertTrue($payment->is_cash_receipt_issued);
$this->assertSame('income', $payment->cash_receipt_type);
$this->assertSame(CashReceiptIdentifierType::PHONE, $payment->cash_receipt_identifier_type);
$this->assertNotNull($payment->cash_receipt_issued_at);
}
#[Test]
public function 식별번호는_마스킹되어_저장되고_원본은_암호화된다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$receipt = $this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
// 이력에는 마스킹 값만
$this->assertSame('*******5678', $receipt->identifier_masked);
$this->assertStringNotContainsString(self::IDENTIFIER, (string) $receipt->identifier_masked);
// 암호문 왕복
$payment = $order->fresh()->payment;
$this->assertSame(self::IDENTIFIER, $payment->cash_receipt_identifier_encrypted);
// DB 원시값에 평문이 없어야 한다
$raw = DB::table('ecommerce_order_payments')->where('id', $payment->id)->first();
$this->assertNotSame(self::IDENTIFIER, $raw->cash_receipt_identifier_encrypted);
$this->assertStringNotContainsString(self::IDENTIFIER, (string) $raw->cash_receipt_identifier_encrypted);
$this->assertStringNotContainsString(self::IDENTIFIER, (string) $raw->cash_receipt_identifier);
// 이력 테이블 어느 컬럼에도 평문·암호문이 없어야 한다
$rawReceipt = DB::table('ecommerce_order_cash_receipts')->where('id', $receipt->id)->first();
foreach ((array) $rawReceipt as $value) {
$this->assertStringNotContainsString(self::IDENTIFIER, (string) $value);
}
}
#[Test]
public function 원응답의_민감키는_마스킹되어_보관된다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$receipt = $this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame('***', $receipt->raw_response['customerIdentityNumber']);
$this->assertSame(11000, $receipt->raw_response['amount']);
$this->assertSame('receipt-1', $receipt->raw_response['receiptKey']);
}
#[Test]
public function sync_금액감소시_전체취소_후_전액_재발급한다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
// 부분환불로 금액 감소
$order->update([
'total_cash_equivalent_amount' => 5500,
'total_tax_amount' => 5500,
]);
$this->service()->syncFromOrder($order->fresh(), '부분환불');
$this->assertCount(1, $this->cancelCalls, '전체취소가 1회 호출되어야 한다');
$this->assertCount(2, $this->issueCalls, '재발급이 수행되어야 한다');
$this->assertSame(5500, $this->issueCalls[1]['payload']['amount']);
$active = $this->activeReceipt($order);
$this->assertSame(5500, (int) $active->amount);
$this->assertSame(CashReceiptType::INCOME, $active->receipt_type, '발급 용도가 유지되어야 한다');
}
#[Test]
public function sync_금액증가시에도_전체취소_후_전액_재발급한다(): void
{
// 금액 증가 흐름은 현행 코드에 없지만, syncFromOrder 는 방향과 무관해야 한다 (D17).
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$order->update([
'total_cash_equivalent_amount' => 14000,
'total_tax_amount' => 14000,
]);
$this->service()->syncFromOrder($order->fresh(), '반품배송비 청구');
$this->assertCount(1, $this->cancelCalls);
$this->assertSame(14000, $this->issueCalls[1]['payload']['amount']);
$this->assertSame(14000, (int) $this->activeReceipt($order)->amount);
}
#[Test]
public function sync_변동이_없으면_아무것도_하지_않는다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->service()->syncFromOrder($order->fresh());
$this->service()->syncFromOrder($order->fresh());
$this->assertCount(0, $this->cancelCalls, '멱등: 취소가 호출되지 않아야 한다');
$this->assertCount(1, $this->issueCalls, '멱등: 재발급이 호출되지 않아야 한다');
$this->assertSame(1, OrderCashReceipt::where('order_id', $order->id)->count());
}
#[Test]
public function sync_발급액이_0이하이면_전체취소만_수행한다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$order->update([
'total_cash_equivalent_amount' => 0,
'total_tax_amount' => 0,
]);
$this->service()->syncFromOrder($order->fresh(), '전액취소');
$this->assertCount(1, $this->cancelCalls);
$this->assertCount(1, $this->issueCalls, '재발급을 시도하지 않아야 한다');
$this->assertNull($this->activeReceipt($order));
$this->assertFalse($order->fresh()->payment->is_cash_receipt_issued);
}
#[Test]
public function sync_발급_이력이_없으면_아무것도_하지_않는다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->syncFromOrder($order, '부분환불');
$this->assertCount(0, $this->issueCalls);
$this->assertCount(0, $this->cancelCalls);
$this->assertSame(0, OrderCashReceipt::where('order_id', $order->id)->count());
}
#[Test]
public function sync_과세면세_비율이_바뀌면_면세액이_재계산된다(): void
{
$this->registerProvider();
// 과세 6000 + 면세 4000 = 10000
$order = $this->makeOrder(10000, 6000, 4000);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(4000, $this->issueCalls[0]['payload']['tax_free_amount']);
// 면세 상품만 부분환불 → 과세 6000 만 잔존
$order->update([
'total_cash_equivalent_amount' => 6000,
'total_tax_amount' => 6000,
'total_tax_free_amount' => 0,
]);
$this->service()->syncFromOrder($order->fresh(), '면세상품 환불');
$this->assertSame(6000, $this->issueCalls[1]['payload']['amount']);
$this->assertSame(0, $this->issueCalls[1]['payload']['tax_free_amount']);
$this->assertSame(0, (int) $this->activeReceipt($order)->tax_free_amount);
}
#[Test]
public function 취소는_성공했는데_재발급이_실패하면_faile_d_이력이_남는다(): void
{
// 첫 발급은 성공시키고, 이후 재발급만 실패시킨다.
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
// 발급 훅을 실패 버전으로 교체
$this->issueShouldFail = true;
$order->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
$this->service()->syncFromOrder($order->fresh(), '부분환불');
$receipts = OrderCashReceipt::where('order_id', $order->id)->orderBy('id')->get();
$this->assertCount(3, $receipts, 'issue(성공) + cancel(성공) + issue(실패)');
$this->assertSame(CashReceiptTransactionType::CANCEL, $receipts[1]->transaction_type);
$this->assertSame(CashReceiptIssueStatus::COMPLETED, $receipts[1]->issue_status);
$this->assertSame(CashReceiptTransactionType::ISSUE, $receipts[2]->transaction_type);
$this->assertSame(CashReceiptIssueStatus::FAILED, $receipts[2]->issue_status);
$this->assertSame('PROVIDER_ERROR', $receipts[2]->error_code);
// 활성 영수증이 없는 상태 — 관리자 수동 재발급 대상
$this->assertNull($this->activeReceipt($order));
}
#[Test]
public function sync_는_예외를_던지지_않아_환불_트랜잭션을_롤백시키지_않는다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->issueShouldFail = true;
$order->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
// 예외가 전파되지 않아야 한다
$this->service()->syncFromOrder($order->fresh(), '부분환불');
$this->assertTrue(true, 'syncFromOrder 가 예외를 던지지 않았다');
}
#[Test]
public function 암호문이_없으면_재발급을_시도하지_않고_faile_d_이력을_남긴다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
// 구매확정으로 암호문 폐기
$this->service()->purgeIdentifier($order->fresh());
$order->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
$this->service()->syncFromOrder($order->fresh(), '부분환불');
$this->assertCount(0, $this->cancelCalls, '취소도 시도하지 않아야 한다');
$this->assertCount(1, $this->issueCalls, '재발급을 시도하지 않아야 한다');
$failed = OrderCashReceipt::where('order_id', $order->id)
->where('issue_status', CashReceiptIssueStatus::FAILED->value)
->first();
$this->assertNotNull($failed);
$this->assertSame('IDENTIFIER_UNAVAILABLE', $failed->error_code);
}
#[Test]
public function 복호화가_실패하면_재발급을_시도하지_않고_실패_이력을_남긴다(): void
{
// APP_KEY 로테이션 등으로 기존 암호문을 복호화할 수 없게 된 경우.
// "암호문 부재"(null) 와는 다른 분기다 — encrypted cast 가 DecryptException 을 던진다.
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
// 다른 키로 암호화된 것처럼 보이는 암호문을 심는다 (복호화 불가).
DB::table('ecommerce_order_payments')
->where('id', $order->fresh()->payment->id)
->update(['cash_receipt_identifier_encrypted' => 'eyJpdiI6ImJvZ3VzIiwidmFsdWUiOiJib2d1cyIsIm1hYyI6ImJvZ3VzIn0=']);
// 복호화 시도 자체가 예외를 던지는지 먼저 확인 (테스트 전제 고정)
$this->expectDecryptionToFail($order->fresh()->payment);
$order->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
// syncFromOrder 는 예외를 전파하지 않는다
$this->service()->syncFromOrder($order->fresh(), '부분환불');
$this->assertCount(0, $this->cancelCalls, '취소를 시도하지 않아야 한다');
$this->assertCount(1, $this->issueCalls, '재발급을 시도하지 않아야 한다');
$failed = OrderCashReceipt::where('order_id', $order->id)
->where('issue_status', CashReceiptIssueStatus::FAILED->value)
->first();
$this->assertNotNull($failed, '복호화 실패도 FAILED 이력으로 남아야 한다');
$this->assertSame('IDENTIFIER_UNAVAILABLE', $failed->error_code);
}
/**
* 결제의 암호문이 실제로 복호화 불가 상태인지 확인합니다 (테스트 전제 고정).
*/
private function expectDecryptionToFail(OrderPayment $payment): void
{
try {
$payment->cash_receipt_identifier_encrypted;
$this->fail('복호화가 실패해야 하는데 성공했다 — 테스트 전제가 깨졌다');
} catch (\Throwable $e) {
$this->assertInstanceOf(DecryptException::class, $e);
}
}
#[Test]
public function 식별번호는_어떤_로그에도_기록되지_않는다(): void
{
// 발급 실패 / 취소 실패 / 복호화 실패 3경로 모두에서 Log 인자를 캡처해 검사한다.
Log::spy();
$this->registerProvider();
$this->issueShouldFail = true;
$order = $this->makeOrder(11000, 11000, 0);
// ① 발급 실패 경로
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
// ② 복호화 실패 경로 (암호문 폐기 후 sync)
$this->issueShouldFail = false;
$this->service()->issue($order->fresh(), CashReceiptType::INCOME, self::IDENTIFIER);
$this->service()->purgeIdentifier($order->fresh());
$order->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
$this->service()->syncFromOrder($order->fresh(), '부분환불');
$logged = [];
Log::shouldHaveReceived('warning')
->withArgs(function (string $message, array $context = []) use (&$logged) {
$logged[] = $message.' '.json_encode($context, JSON_UNESCAPED_UNICODE);
return true;
});
$this->assertNotEmpty($logged, 'Log::warning 이 최소 1회는 호출되어야 한다');
foreach ($logged as $entry) {
$this->assertStringNotContainsString(self::IDENTIFIER, $entry, "식별번호 평문이 로그에 노출됨: {$entry}");
$this->assertStringNotContainsString('5678', $entry, "식별번호 뒤 4자리가 로그에 노출됨: {$entry}");
$this->assertStringNotContainsString('cash_receipt_identifier', $entry, "식별번호 필드가 로그에 노출됨: {$entry}");
}
}
#[Test]
public function 구매확정_암호문_폐기는_이력과_영수증_ur_l을_보존한다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$receipt = $this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->service()->purgeIdentifier($order->fresh());
$payment = $order->fresh()->payment;
$this->assertNull($payment->cash_receipt_identifier_encrypted, '암호문은 폐기되어야 한다');
$this->assertSame('*******5678', $payment->cash_receipt_identifier, '마스킹은 유지');
$this->assertNotNull($payment->receipt_url, '영수증 URL 은 유지');
$this->assertNotNull($receipt->fresh()->receipt_url, '이력은 유지');
$this->assertSame(1, OrderCashReceipt::where('order_id', $order->id)->count());
}
#[Test]
public function 구매확정_암호문_폐기는_멱등이다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->service()->purgeIdentifier($order->fresh());
$this->service()->purgeIdentifier($order->fresh());
$this->assertNull($order->fresh()->payment->cash_receipt_identifier_encrypted);
}
#[Test]
public function 프로바이더가_미설정이면_발급하지_않고_실패_이력을_남긴다(): void
{
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', '');
$order = $this->makeOrder(11000, 11000, 0);
$receipt = $this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(CashReceiptIssueStatus::FAILED, $receipt->issue_status);
$this->assertSame('PROVIDER_NOT_CONFIGURED', $receipt->error_code);
$this->assertFalse($order->fresh()->payment->is_cash_receipt_issued);
}
#[Test]
public function 어떤_리스너도_처리하지_않으면_실패_이력을_남긴다(): void
{
$order = $this->makeOrder(11000, 11000, 0);
$receipt = $this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(CashReceiptIssueStatus::FAILED, $receipt->issue_status);
$this->assertSame('NO_PROVIDER_HANDLED', $receipt->error_code);
}
#[Test]
public function 발급_대상액이_0이면_발급을_시도하지_않는다(): void
{
$this->registerProvider();
$order = $this->makeOrder(0, 0, 0);
$receipt = $this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(CashReceiptIssueStatus::FAILED, $receipt->issue_status);
$this->assertSame('NO_ISSUABLE_AMOUNT', $receipt->error_code);
$this->assertCount(0, $this->issueCalls);
}
#[Test]
public function cancel_all_은_활성_영수증이_없으면_no_op이다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->assertTrue($this->service()->cancelAll($order));
$this->assertCount(0, $this->cancelCalls);
}
#[Test]
public function 발급액_계산은_현금성_금액을_기준으로_면세를_안분한다(): void
{
// 현금성 10000, 과세 6000 + 면세 4000 → 분류합 10000 == 현금성 → 그대로
$order = $this->makeOrder(10000, 6000, 4000);
$this->assertSame(
['amount' => 10000, 'tax_free_amount' => 4000],
$this->service()->calculateIssuableAmount($order),
);
// 마일리지 사용으로 현금성(5000)이 분류합(10000)보다 작음 → 면세 비율 40% 안분
$order->update(['total_cash_equivalent_amount' => 5000]);
$this->assertSame(
['amount' => 5000, 'tax_free_amount' => 2000],
$this->service()->calculateIssuableAmount($order->fresh()),
);
}
#[Test]
public function 현금성_금액이_0이면_발급액도_0이다(): void
{
$order = $this->makeOrder(0, 11000, 0);
$this->assertSame(
['amount' => 0, 'tax_free_amount' => 0],
$this->service()->calculateIssuableAmount($order),
);
}
#[Test]
public function 주문_삭제_시_현금영수증_이력도_명시적으로_삭제된다(): void
{
// DB CASCADE 에 의존하지 않고 Service 가 명시적으로 삭제해야 한다.
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(1, OrderCashReceipt::where('order_id', $order->id)->count());
app(OrderService::class)->delete($order->fresh());
$this->assertSame(0, OrderCashReceipt::where('order_id', $order->id)->count());
}
}
@@ -0,0 +1,224 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
use Modules\Sirsoft\Ecommerce\Enums\ProductTaxStatus;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 배송비 과세 정책 통합 테스트 (A-4)
*
* 단위 테스트(ShippingFeeTaxPolicyTest)가 분류 로직을 검증한다면, 본 테스트는
* calculate() 전체 파이프라인을 통과한 Summary 에 배송비 과세액이 실제로 합산되는지 확인한다.
*
* 배송비는 단계 3 에서 계산되므로 단계 2-b 의 classifyTaxStatus 로는 분류할 수 없다.
* 회귀 pin: 배송비가 Summary 의 taxableAmount/taxFreeAmount 에서 누락되면 실패한다.
*/
class ShippingFeeTaxIntegrationTest extends ModuleTestCase
{
private OrderCalculationService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(OrderCalculationService::class);
}
private function setPolicy(string $policy): void
{
app(EcommerceSettingsService::class)->setSetting('order_settings.shipping_fee_tax_policy', $policy);
}
/**
* 고정 배송비 정책을 생성합니다.
*/
private function makeShippingPolicy(int $baseFee): ShippingPolicy
{
$policy = ShippingPolicy::create([
'name' => ['ko' => '테스트 배송정책', 'en' => 'Test Shipping Policy'],
'is_default' => false,
'is_active' => true,
]);
$policy->countrySettings()->create([
'country_code' => 'KR',
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => $baseFee,
'is_active' => true,
]);
return $policy->load('countrySettings');
}
/**
* 과세 상태와 배송정책을 지정한 상품을 생성합니다.
*
* @return array{0: Product, 1: ProductOption}
*/
private function makeProduct(int $price, ProductTaxStatus $taxStatus, ?ShippingPolicy $policy = null): array
{
$product = ProductFactory::new()->create([
'tax_status' => $taxStatus,
'selling_price' => $price,
'list_price' => $price,
'shipping_policy_id' => $policy?->id,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
'is_default' => true,
]);
return [$product, $option];
}
/**
* 주어진 상품 구성으로 계산을 수행합니다.
*/
private function calculate(array ...$productOptionPairs): OrderCalculationResult
{
$items = [];
foreach ($productOptionPairs as [$product, $option]) {
$items[] = new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
);
}
return $this->service->calculate(new CalculationInput(items: $items));
}
#[Test]
public function 기본_안분_정책에서_전액과세_주문의_배송비는_과세로_합산된다(): void
{
$policy = $this->makeShippingPolicy(3000);
$taxable = $this->makeProduct(30000, ProductTaxStatus::TAXABLE, $policy);
$result = $this->calculate($taxable);
$this->assertEquals(3000, $result->summary->totalShipping);
// 상품 30000 + 배송비 3000 = 33000 이 모두 과세
$this->assertEquals(33000, $result->summary->taxableAmount, '배송비가 과세액에 합산되어야 한다');
$this->assertEquals(0, $result->summary->taxFreeAmount);
}
#[Test]
public function 기본_안분_정책에서_전액면세_주문의_배송비는_면세로_합산된다(): void
{
$policy = $this->makeShippingPolicy(3000);
$taxFree = $this->makeProduct(30000, ProductTaxStatus::TAX_FREE, $policy);
$result = $this->calculate($taxFree);
$this->assertEquals(3000, $result->summary->totalShipping);
$this->assertEquals(0, $result->summary->taxableAmount);
$this->assertEquals(33000, $result->summary->taxFreeAmount, '배송비가 면세액에 합산되어야 한다');
}
#[Test]
public function 안분_정책에서_복합_주문의_배송비는_과세비율만큼_나뉜다(): void
{
$this->setPolicy('proportional');
$policy = $this->makeShippingPolicy(3000);
$taxable = $this->makeProduct(6000, ProductTaxStatus::TAXABLE, $policy);
$taxFree = $this->makeProduct(4000, ProductTaxStatus::TAX_FREE, $policy);
$result = $this->calculate($taxable, $taxFree);
// 상품 과세 6000 : 면세 4000 → 배송비 3000 을 1800 : 1200 로 안분
$this->assertEquals(6000 + 1800, $result->summary->taxableAmount);
$this->assertEquals(4000 + 1200, $result->summary->taxFreeAmount);
// 합계 보존: 상품 10000 + 배송비 3000
$this->assertEquals(
13000,
$result->summary->taxableAmount + $result->summary->taxFreeAmount,
);
}
#[Test]
public function 전액과세_정책은_면세_주문의_배송비도_과세로_분류한다(): void
{
$this->setPolicy('taxable');
$policy = $this->makeShippingPolicy(3000);
$taxFree = $this->makeProduct(30000, ProductTaxStatus::TAX_FREE, $policy);
$result = $this->calculate($taxFree);
$this->assertEquals(3000, $result->summary->taxableAmount, '배송비 전액이 과세');
$this->assertEquals(30000, $result->summary->taxFreeAmount, '상품은 면세 유지');
}
#[Test]
public function 주된재화_정책은_면세가_우세하면_배송비를_면세로_분류한다(): void
{
$this->setPolicy('follow_main_item');
$policy = $this->makeShippingPolicy(3000);
$taxable = $this->makeProduct(4000, ProductTaxStatus::TAXABLE, $policy);
$taxFree = $this->makeProduct(6000, ProductTaxStatus::TAX_FREE, $policy);
$result = $this->calculate($taxable, $taxFree);
$this->assertEquals(4000, $result->summary->taxableAmount);
$this->assertEquals(6000 + 3000, $result->summary->taxFreeAmount, '배송비가 주된재화(면세)를 따른다');
}
#[Test]
public function 주된재화_정책은_과세가_우세하면_배송비를_과세로_분류한다(): void
{
$this->setPolicy('follow_main_item');
$policy = $this->makeShippingPolicy(3000);
$taxable = $this->makeProduct(6000, ProductTaxStatus::TAXABLE, $policy);
$taxFree = $this->makeProduct(4000, ProductTaxStatus::TAX_FREE, $policy);
$result = $this->calculate($taxable, $taxFree);
$this->assertEquals(6000 + 3000, $result->summary->taxableAmount);
$this->assertEquals(4000, $result->summary->taxFreeAmount);
}
#[Test]
public function 배송비가_없으면_과세_면세액은_상품금액과_동일하다(): void
{
// 배송정책 미지정 → 배송비 0 (기존 회귀 pin)
$taxable = $this->makeProduct(30000, ProductTaxStatus::TAXABLE);
$taxFree = $this->makeProduct(20000, ProductTaxStatus::TAX_FREE);
$result = $this->calculate($taxable, $taxFree);
$this->assertEquals(0, $result->summary->totalShipping);
$this->assertEquals(30000, $result->summary->taxableAmount);
$this->assertEquals(20000, $result->summary->taxFreeAmount);
}
#[Test]
public function 옵션별_과세액에는_배송비가_섞이지_않는다(): void
{
// 배송비는 Summary 에만 합산된다 — 옵션 단위 taxableAmount 오염 방지 pin
$policy = $this->makeShippingPolicy(3000);
$taxable = $this->makeProduct(30000, ProductTaxStatus::TAXABLE, $policy);
$result = $this->calculate($taxable);
$this->assertEquals(30000, $result->items[0]->taxableAmount, '옵션 과세액은 상품분만');
$this->assertEquals(0, $result->items[0]->taxFreeAmount);
$this->assertEquals(33000, $result->summary->taxableAmount, 'Summary 에만 배송비 합산');
}
}
@@ -0,0 +1,192 @@
<?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'));
}
}
@@ -0,0 +1,152 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Upgrades;
use App\Extension\AbstractUpgradeStep;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use Modules\Sirsoft\Ecommerce\Upgrades\Upgrade_1_1_0;
use PHPUnit\Framework\Attributes\Test;
/**
* Upgrade_1_1_0 — cash_receipt_type 레거시 값 정규화 테스트
*
* KG이니시스가 기록해 온 income_deduction/expenditure_proof 를 CashReceiptType Enum
* SSoT 값(income/expense)으로 정규화한다.
*/
class NormalizeCashReceiptTypeTest extends ModuleTestCase
{
/**
* 지정한 cash_receipt_type 을 가진 결제 행을 생성합니다.
*/
private function makePaymentWithType(?string $type): OrderPayment
{
$order = Order::factory()->create();
$payment = OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_method' => PaymentMethodEnum::DBANK,
]);
// Enum 캐스트를 우회해 레거시 원시값을 직접 심는다.
DB::table('ecommerce_order_payments')
->where('id', $payment->id)
->update(['cash_receipt_type' => $type]);
return $payment;
}
/**
* 결제 행의 cash_receipt_type 원시값을 조회합니다.
*/
private function rawType(OrderPayment $payment): ?string
{
return DB::table('ecommerce_order_payments')
->where('id', $payment->id)
->value('cash_receipt_type');
}
private function runUpgrade(): void
{
$step = new Upgrade_1_1_0;
$context = (new UpgradeContext(
fromVersion: '1.0.1',
toVersion: '1.1.0',
logChannel: 'extension-upgrade',
))->withCurrentStep('1.1.0');
$step->run($context);
}
#[Test]
public function 업그레이드_스텝은_abstract_upgrade_step_을_상속한다(): void
{
// g7_version(">=7.0.0") 이 7.0.0-beta.5 이상이므로 상속 의무가 발동한다.
$this->assertInstanceOf(AbstractUpgradeStep::class, new Upgrade_1_1_0);
}
#[Test]
public function income_deduction_을_income_으로_정규화한다(): void
{
$payment = $this->makePaymentWithType('income_deduction');
$this->runUpgrade();
$this->assertSame('income', $this->rawType($payment));
}
#[Test]
public function expenditure_proof_를_expense_로_정규화한다(): void
{
$payment = $this->makePaymentWithType('expenditure_proof');
$this->runUpgrade();
$this->assertSame('expense', $this->rawType($payment));
}
#[Test]
public function 이미_정규화된_값은_변경하지_않는다(): void
{
$income = $this->makePaymentWithType('income');
$expense = $this->makePaymentWithType('expense');
$this->runUpgrade();
$this->assertSame('income', $this->rawType($income));
$this->assertSame('expense', $this->rawType($expense));
}
#[Test]
public function null_값은_그대로_둔다(): void
{
$payment = $this->makePaymentWithType(null);
$this->runUpgrade();
$this->assertNull($this->rawType($payment));
}
#[Test]
public function 두_번_실행해도_동일한_결과다(): void
{
$legacy = $this->makePaymentWithType('income_deduction');
$this->runUpgrade();
$this->runUpgrade();
$this->assertSame('income', $this->rawType($legacy));
}
#[Test]
public function 레거시_값이_남아있어도_결제_모델을_읽을_수_있다(): void
{
// KG이니시스 플러그인은 S5 에서 제거되기 전까지 income_deduction 을 계속 기록한다.
// cash_receipt_type 을 Enum 캐스트하면 Laravel 이 ::from() 으로 ValueError 를 던져
// 해당 행을 읽을 수 없게 되고, 업그레이드 스텝 자신도 그 행을 읽어야 한다.
$payment = $this->makePaymentWithType('income_deduction');
$reloaded = OrderPayment::find($payment->id);
$this->assertSame('income_deduction', $reloaded->cash_receipt_type, '캐스트 없이 원시값으로 읽힌다');
$this->assertSame(CashReceiptType::INCOME, $reloaded->getCashReceiptType(), 'fromLegacy 로 안전하게 해석');
}
#[Test]
public function 정규화_후에도_동일한_해석_결과를_유지한다(): void
{
$payment = $this->makePaymentWithType('expenditure_proof');
$this->runUpgrade();
$reloaded = OrderPayment::find($payment->id);
$this->assertSame('expense', $reloaded->cash_receipt_type);
$this->assertSame(CashReceiptType::EXPENSE, $reloaded->getCashReceiptType());
}
}
@@ -0,0 +1,135 @@
# audit:allow test-scenario-coverage reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는
# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경(S1)은
# 서버 전용 인프라(훅 축 · 이력 테이블 · 현금성 금액 · 배송비 과세 정책)이며 UI 산출물이 없다.
# syncFromOrder 6케이스 × 배송비 3정책 × 과세 3구성 + 암호화 왕복 + 로그 미노출 + 복호화 실패
# + 레거시 정규화를 PHPUnit 이 커버하며 green (CashReceiptService 23건, 배송비 단위 20건 + 통합 8건,
# 현금성 11건, Enum 체크섬 21건, 업그레이드 8건, 마이그레이션 왕복 7건).
# 신청/발급 UI 와 자동발급 리스너는 S2·S3 범위 — 그 시점에 E2E axis 를 추가한다.
feature: 현금영수증 인프라 (프로바이더 훅 축 · 발급/취소 이력 · 현금성 금액 · 배송비 과세 정책)
description: |
현금영수증 발급 프로바이더를 PG 와 독립적으로 선택할 수 있도록 이커머스 모듈에 훅 축을 신설한다
(KG이니시스로 결제하면서 현금영수증만 토스페이먼츠로 발급하는 구성 허용).
발급/취소 원장(ecommerce_order_cash_receipts)이 국세청 신고 근거를 보관한다. 금액이 바뀌면
syncFromOrder() 가 현재 상태에서 발급액을 재계산해 전체취소 → 전액 재발급한다(부분취소 API 미사용).
증가/감소 방향을 구분하지 않으므로 향후 반품/교환배송비 청구가 도입되어도 수정 없이 동작한다.
재발급용 식별번호는 APP_KEY 기반으로 암호화 보관하고 구매확정 시 폐기한다. 이력에는 마스킹 값만
남으며, 프로바이더 원응답의 민감 키도 마스킹한다. 주민등록번호는 수집하지 않는다.
배송비는 그동안 과세/면세 분류에서 누락되어 있었다. 배송비 할인이 확정된 뒤(Summary 집계 시점)
3가지 정책 중 하나로 분류해 합산한다. 옵션별 과세액에는 배송비를 섞지 않는다.
axes:
amount_change:
- decrease # 부분환불 → 전체취소 + 전액 재발급
- increase # 금액 증가(향후 반품/교환배송비) → 동일 경로
- unchanged # 변동 없음 → no-op(멱등)
- zero # 발급액 0 이하 → cancelAll 만
- no_history # 발급 이력 없음 → no-op
- tax_ratio_changed # 과세/면세 비율 변동 → 면세액 재계산
shipping_fee_tax_policy:
- proportional # 과세상품 비율만큼 안분 (기본값)
- taxable # 배송비 전액 과세
- follow_main_item # 상품금액이 큰 쪽의 성격을 따름
product_tax_mix:
- all_taxable # 전액 과세
- all_tax_free # 전액 면세
- mixed # 복합 (과세 + 면세)
payment_method:
- dbank # 무통장 → 현금성 = 실입금액 전액
- vbank # 가상계좌 → 0 (PG 자동 발급)
- card # 카드 → 0
- bank # 계좌이체 → 0
- phone # 휴대폰 → 0
identifier_type:
- phone # 휴대폰번호 (소득공제/지출증빙 공통)
- card # 현금영수증 카드번호 (소득공제/지출증빙 공통)
- business # 사업자등록번호 (지출증빙 전용, 체크섬 검증)
receipt_type:
- income # 소득공제용
- expense # 지출증빙용
provider_outcome:
- issue_success # 발급 성공
- issue_failed # 발급 실패 → FAILED 이력
- not_configured # 프로바이더 미설정 → 발급 시도 안 함
- no_listener # 어떤 리스너도 처리 안 함 → FAILED 이력
identifier_state: # 재발급 시 암호문 상태 (syncFromOrder 분기)
- decryptable # 정상 복호화 → 재발급 진행
- purged # 구매확정으로 폐기(null) → IDENTIFIER_UNAVAILABLE
- undecryptable # APP_KEY 로테이션 등 복호화 실패 → IDENTIFIER_UNAVAILABLE
legacy_receipt_type:
- income_deduction # KG 레거시 → income 정규화
- expenditure_proof # KG 레거시 → expense 정규화
- already_normalized # 이미 정규화됨 → 변경 없음
- null_value # 미설정 → 그대로
effects:
# 훅 축 · 발급/취소
- registered_providers_filter_hook_exposed # cash_receipt.registered_providers 필터 훅 노출
- issue_delegates_to_provider_filter # 발급을 cash_receipt.issue 필터로 위임
- cancel_delegates_without_amount # 취소 필터에 금액을 넘기지 않음(항상 전액취소)
- issue_records_ledger_and_payment_summary # 이력 + 결제 요약 컬럼 동시 갱신
# syncFromOrder — 무상태 재계산
- sync_reissues_on_amount_change # 감소·증가 모두 전체취소 후 전액 재발급
- sync_is_idempotent_when_unchanged # 변동 없으면 취소/재발급 미호출
- sync_cancels_only_when_amount_zero # 발급액 0 이하 → cancelAll 만
- sync_noop_without_active_receipt # 발급 이력 없으면 아무것도 안 함
- sync_recomputes_tax_free_share # 과세/면세 비율 변동 시 면세액 재계산
- sync_never_throws_to_caller # 실패해도 환불 트랜잭션을 롤백시키지 않음
- cancel_success_with_reissue_failure_logged # 취소 성공 + 재발급 실패 = 이력 2행(FAILED 노출)
- reissue_skipped_when_identifier_purged # 암호문 부재 시 재발급 미시도 + FAILED 이력
# 개인정보 보호
- identifier_encrypted_at_rest # 원본은 암호문으로만 저장(DB 원시값에 평문 부재)
- identifier_masked_in_ledger # 이력에는 뒤 4자리 외 마스킹만
- raw_response_sensitive_keys_masked # 프로바이더 원응답 민감 키 마스킹
- identifier_purged_on_purchase_confirm # 구매확정 시 암호문 폐기(멱등), 이력·URL·마스킹 유지
- identifier_never_logged # 발급실패·취소실패·복호화실패 3경로 로그에 식별번호 미노출
- decryption_failure_skips_reissue # APP_KEY 로테이션 등 복호화 실패 → 재발급 미시도 + FAILED 이력
# 현금성 금액
- cash_equivalent_dbank_is_final_amount # 무통장 = 마일리지 차감 후 실입금액
- cash_equivalent_zero_for_non_dbank # 그 외 결제수단 = 0
- option_cash_equivalent_sum_equals_order # 옵션 안분 합 = 주문 현금성 금액
# 배송비 과세 정책
- shipping_fee_included_in_tax_totals # Summary 과세/면세 합계에 배송비 포함
- shipping_apportioned_by_taxable_ratio # 안분 정책: 과세비율만큼 분배
- shipping_rounding_remainder_preserves_sum # 반올림 잔차가 배송비 합계를 보존
- shipping_fully_taxable_under_taxable # 전액과세 정책
- shipping_follows_dominant_item # 주된재화 정책 (동률 시 과세)
- option_tax_amount_excludes_shipping # 옵션별 과세액에는 배송비 미포함
# 검증 규칙
- business_number_checksum_validated # 사업자번호 체크섬 (000 접두 배제 포함)
- phone_and_card_allowed_for_both_types # 휴대폰/카드번호는 소득공제·지출증빙 공통
- business_number_only_for_expense # 사업자번호는 지출증빙 전용
# 데이터 정합성
- legacy_receipt_type_normalized # income_deduction/expenditure_proof 정규화
- upgrade_step_is_idempotent # 두 번 실행해도 동일 결과
- legacy_row_readable_without_enum_cast # 정규화 전 레거시 행도 읽기 가능
- cash_receipts_deleted_explicitly_on_order_delete # 주문 삭제 시 CASCADE 의존 없이 명시 삭제
- migrations_have_no_module_class_reference # 마이그레이션이 모듈 오토로더에 의존하지 않음
- migration_up_down_up_round_trip_safe # 왕복 + 재실행 안전(멱등)
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ShippingFeeTaxPolicyTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ShippingFeeTaxIntegrationTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashEquivalentAmountTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/CashReceiptIdentifierTypeTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/NormalizeCashReceiptTypeTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Migrations/CashReceiptMigrationRoundTripTest.php
@@ -0,0 +1,18 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* Ecommerce 모듈 1.1.0 업그레이드 스텝
*
* 현금영수증 발급 용도(`ecommerce_order_payments.cash_receipt_type`)의 레거시 값을 정규화한다.
* 마이그레이션 comment 는 income/expense 였으나 KG이니시스 플러그인이
* income_deduction/expenditure_proof 를 기록해 왔다 — CashReceiptType Enum 을 SSoT 로 통일한다.
*
* 모든 비즈니스 로직은 data/1.1.0/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path B
*/
class Upgrade_1_1_0 extends AbstractUpgradeStep {}
@@ -0,0 +1,62 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_1_0\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 현금영수증 발급 용도(cash_receipt_type)의 레거시 값을 정규화합니다.
*
* `ecommerce_order_payments` 마이그레이션의 comment 는 income/expense 였으나,
* KG이니시스 플러그인의 AdminCashReceiptController 는 income_deduction/expenditure_proof 를
* 기록해 왔다. CashReceiptType Enum 을 SSoT 로 삼아 두 값을 정규화한다.
*
* idempotent: 이미 정규화된 값은 매칭되지 않아 no-op.
* V-1 안전: Illuminate\Support\Facades\* 만 사용 — 모듈 Enum 클래스를 참조하지 않는다
* (그 시점의 Enum 정의가 바뀌면 이 스텝의 동작이 달라지므로).
*/
class NormalizeCashReceiptType implements DataMigration
{
private const TABLE = 'ecommerce_order_payments';
private const COLUMN = 'cash_receipt_type';
/**
* 레거시 값 → 정규화 값 매핑
*
* @var array<string, string>
*/
private const LEGACY_MAP = [
'income_deduction' => 'income',
'expenditure_proof' => 'expense',
];
public function name(): string
{
return 'NormalizeCashReceiptType';
}
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::TABLE) || ! Schema::hasColumn(self::TABLE, self::COLUMN)) {
$context->logger->warning('[ecommerce:1.1.0] '.self::TABLE.'.'.self::COLUMN.' 미존재 — 스킵');
return;
}
foreach (self::LEGACY_MAP as $legacy => $normalized) {
$updated = DB::table(self::TABLE)
->where(self::COLUMN, $legacy)
->update([self::COLUMN => $normalized]);
if ($updated > 0) {
$context->logger->info("[ecommerce:1.1.0] {$legacy} → {$normalized}: {$updated} 건 정규화");
}
}
$context->logger->info('[ecommerce:1.1.0] cash_receipt_type 정규화 완료');
}
}
@@ -1,15 +1,15 @@
{
"schema_version": "1.0",
"generated_at": "2026-07-16T15:36:56+00:00",
"generated_at": "2026-08-06T02:43:43+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
"composer_json_sha256": "892a78a4c180c410eeb2cafbdec3ee1ff87ea066db94dea7e1340678e91759f6",
"composer_json_sha256": "cf5460aed4b90364c33889e3bd479eeb87c05b8f4b1ee94886575e14dcf441d1",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
"zip_sha256": "e222da279ba452bd155fb68f06d6bcdd0c2f29041f231eeea3ef6229912e9dcc",
"zip_size": 435549,
"zip_sha256": "3c84d3216050b9ecdb49ebb8b457275d221b2b0c8369a608a20377b611fecafb",
"zip_size": 435548,
"package_count": 1,
"php_requirement": "^8.2",
"g7_version": "7.0.5",
"g7_version": "7.0.6",
"packages": [
{
"name": "ezyang/htmlpurifier",
Binary file not shown.