diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 22b60ff7..93c60a6f 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -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 diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/cash_receipt.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/cash_receipt.php new file mode 100644 index 00000000..1ee7294a --- /dev/null +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/cash_receipt.php @@ -0,0 +1,33 @@ + [ + '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' => '再発行に必要な識別番号を復号化できません。管理者が識別番号を再入力して発行する必要があります。', + ], +]; diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index feecaa32..6bf41278 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -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", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index f8f105b8..74bfe386 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -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 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index 542c3464..4ce24561 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -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": { diff --git a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json index fb974294..f2b209eb 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json @@ -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", diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php new file mode 100644 index 00000000..c7befc31 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php @@ -0,0 +1,62 @@ +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'); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php new file mode 100644 index 00000000..bdff75a4 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php @@ -0,0 +1,69 @@ +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); + } + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php new file mode 100644 index 00000000..388a9a62 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php @@ -0,0 +1,43 @@ +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'); + } + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php new file mode 100644 index 00000000..bacd1d9a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php @@ -0,0 +1,45 @@ +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'); + } + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 0ac4ee0a..e7b68a12 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,7 +5,7 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.0.5", + "version": "1.1.0", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index 0b9e70b0..bb1e54fb 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -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", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 9e058613..7da219b5 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.5", + "version": "1.1.0", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIdentifierType.php b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIdentifierType.php new file mode 100644 index 00000000..e13646a0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIdentifierType.php @@ -0,0 +1,139 @@ + 값 배열 + */ + 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; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIssueStatus.php b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIssueStatus.php new file mode 100644 index 00000000..8ac0120c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIssueStatus.php @@ -0,0 +1,92 @@ + 값 배열 + */ + 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', + }; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptTransactionType.php b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptTransactionType.php new file mode 100644 index 00000000..15f726b3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptTransactionType.php @@ -0,0 +1,63 @@ + 값 배열 + */ + 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; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptType.php b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptType.php new file mode 100644 index 00000000..d0bc880d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptType.php @@ -0,0 +1,106 @@ + 값 배열 + */ + 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 허용되는 식별번호 종류 + */ + public function allowedIdentifierTypes(): array + { + return match ($this) { + self::INCOME => [ + CashReceiptIdentifierType::PHONE, + CashReceiptIdentifierType::CARD, + ], + self::EXPENSE => [ + CashReceiptIdentifierType::BUSINESS, + CashReceiptIdentifierType::PHONE, + CashReceiptIdentifierType::CARD, + ], + }; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Enums/ShippingFeeTaxPolicy.php b/modules/_bundled/sirsoft-ecommerce/src/Enums/ShippingFeeTaxPolicy.php new file mode 100644 index 00000000..b7f535f5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Enums/ShippingFeeTaxPolicy.php @@ -0,0 +1,85 @@ + 값 배열 + */ + 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; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php index 9b598bbb..db26e74b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php @@ -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'); + } + /** * 배송 관계 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/OrderCashReceipt.php b/modules/_bundled/sirsoft-ecommerce/src/Models/OrderCashReceipt.php new file mode 100644 index 00000000..48865d90 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/OrderCashReceipt.php @@ -0,0 +1,106 @@ + '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; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/OrderOption.php b/modules/_bundled/sirsoft-ecommerce/src/Models/OrderOption.php index 13e62ccd..5a095d7d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/OrderOption.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/OrderOption.php @@ -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', ]; /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/OrderPayment.php b/modules/_bundled/sirsoft-ecommerce/src/Models/OrderPayment.php index bb7ec522..69a0cb60 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/OrderPayment.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/OrderPayment.php @@ -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; + } + /** * 카드 결제 여부 확인 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php index 7999c461..6bcc6f11 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php @@ -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, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderCashReceiptRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderCashReceiptRepositoryInterface.php new file mode 100644 index 00000000..4c480c76 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderCashReceiptRepositoryInterface.php @@ -0,0 +1,56 @@ + $data 이력 데이터 + * @return OrderCashReceipt 생성된 이력 + */ + public function create(array $data): OrderCashReceipt; + + /** + * 주문의 모든 이력을 최신순으로 조회합니다. + * + * @param Order $order 주문 모델 + * @return Collection 이력 컬렉션 + */ + 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 활성 영수증 컬렉션 + */ + public function findActiveReceipts(Order $order): Collection; + + /** + * 영수증 키로 이력을 조회합니다. + * + * @param string $receiptKey 프로바이더 영수증 키 + * @return OrderCashReceipt|null 이력 (없으면 null) + */ + public function findByReceiptKey(string $receiptKey): ?OrderCashReceipt; +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php new file mode 100644 index 00000000..5b315841 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php @@ -0,0 +1,72 @@ +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(); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php new file mode 100644 index 00000000..dc8bca12 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php @@ -0,0 +1,94 @@ + 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; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php new file mode 100644 index 00000000..f46cf88b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php @@ -0,0 +1,614 @@ + + */ + 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|null 마스킹된 응답 (배열이 아니면 null) + */ + private function sanitizeResponse(mixed $response): ?array + { + if (! is_array($response)) { + return null; + } + + return $this->maskSensitiveKeys($response); + } + + /** + * 배열을 재귀 순회하며 민감 키의 값을 마스킹합니다. + * + * @param array $data 원본 배열 + * @return array 마스킹된 배열 + */ + 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 기본 반환값 + */ + 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 기본 반환값 + */ + 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, + ]; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php index 12680ee1..e2af87b4 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php @@ -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); + } + /** * 특정 결제수단의 설정을 조회합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php index 2338de1e..5cd5fc2d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php @@ -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'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php index c411f16f..279d9f53 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php @@ -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 매핑 저장 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderService.php index 0b4b8471..d9db6303 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderService.php @@ -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(); diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/cash_receipt.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/cash_receipt.php new file mode 100644 index 00000000..8010c664 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/cash_receipt.php @@ -0,0 +1,56 @@ + [ + '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.', + ], +]; diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/cash_receipt.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/cash_receipt.php new file mode 100644 index 00000000..c53d7d08 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/cash_receipt.php @@ -0,0 +1,56 @@ + [ + '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' => '재발급에 필요한 식별번호를 복호화할 수 없습니다. 관리자가 식별번호를 다시 입력해 발급해야 합니다.', + ], +]; diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Migrations/CashReceiptMigrationRoundTripTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Migrations/CashReceiptMigrationRoundTripTest.php new file mode 100644 index 00000000..3915eb56 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Migrations/CashReceiptMigrationRoundTripTest.php @@ -0,0 +1,160 @@ + + */ + 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')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/CashReceiptIdentifierTypeTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/CashReceiptIdentifierTypeTest.php new file mode 100644 index 00000000..09e63b70 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/CashReceiptIdentifierTypeTest.php @@ -0,0 +1,136 @@ +assertTrue(CashReceiptIdentifierType::isValidBusinessNumber($number)); + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + 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], + ]; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashEquivalentAmountTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashEquivalentAmountTest.php new file mode 100644 index 00000000..6bcbdb63 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashEquivalentAmountTest.php @@ -0,0 +1,104 @@ +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 + */ + 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)); + } + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php new file mode 100644 index 00000000..81cfc8e2 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php @@ -0,0 +1,608 @@ + 발급 훅이 받은 호출 기록 */ + private array $issueCalls = []; + + /** @var array 취소 훅이 받은 호출 기록 */ + 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()); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ShippingFeeTaxIntegrationTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ShippingFeeTaxIntegrationTest.php new file mode 100644 index 00000000..28e7c7d2 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ShippingFeeTaxIntegrationTest.php @@ -0,0 +1,224 @@ +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 에만 배송비 합산'); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ShippingFeeTaxPolicyTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ShippingFeeTaxPolicyTest.php new file mode 100644 index 00000000..b9ff4c2b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ShippingFeeTaxPolicyTest.php @@ -0,0 +1,192 @@ +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 + */ + 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 + */ + 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')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/NormalizeCashReceiptTypeTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/NormalizeCashReceiptTypeTest.php new file mode 100644 index 00000000..38cc92e3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/NormalizeCashReceiptTypeTest.php @@ -0,0 +1,152 @@ +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()); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/cash-receipt-infrastructure.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/cash-receipt-infrastructure.yaml new file mode 100644 index 00000000..79c0570c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/cash-receipt-infrastructure.yaml @@ -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 diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_0.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_0.php new file mode 100644 index 00000000..5f195225 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_0.php @@ -0,0 +1,18 @@ + + */ + 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 정규화 완료'); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json index e8f1c775..47d41c5e 100644 --- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json +++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json @@ -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", diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip index a8a0cc92..c14b57d9 100644 Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ