무통장입금 주문의 현금영수증 발급을 관리자·회원·비회원 세 경로에서 제공하고,
입금이 확인되면 자동 발급하며, 부분환불로 금액이 바뀌면 전액취소 후 재발급한다.
발급 프로바이더에 종속되지 않도록 발급/취소는 필터 훅으로 위임한다.
자동발급 리스너는 결제완료 전이(after_payment_complete)와 입금만 기록
(after_deposit_recorded) 두 경로를 모두 구독한다 — 후자가 없으면 관리자가
"입금만 기록"했을 때 자동발급이 통째로 누락된다.
금액 동기화(syncFromOrder)는 트랜잭션이 닫힌 뒤에 부른다. 재발급 실패 이력은
국세청 신고 누락을 막는 원장이라 롤백에 휩쓸리면 안 되고, 프로바이더 왕복 2회
동안 DB 잠금을 붙들 수 없기 때문이다. 환불은 이미 확정됐으므로 어떤 실패도
호출부로 전파하지 않고 FAILED 이력 + 로그로 남겨 관리자 수동 복구에 맡긴다.
식별번호는 재발급용으로만 암호화 보관하고 구매확정 시점에 폐기한다.
그 시점을 잡기 위해 order.after_purchase_confirmed 훅을 신설했다.
함께 고친 사전 결함:
- 취소 시 total_cash_equivalent_amount 가 재계산되지 않아 syncFromOrder 가
"금액 변동 없음"으로 판정, 부분환불 후 재발급이 아예 동작하지 않았다.
산정 규칙을 PaymentMethodEnum::resolveCashEquivalentAmount 로 SSoT 화해
주문 생성 시점과 취소 재계산 시점이 같은 규칙을 쓰게 했다.
- 현금영수증 발급이 payment.receipt_url 을 덮어써 카드 매출전표 URL 이
유실됐다. 한 컬럼이 두 의미를 갖던 문제로, 현금영수증 URL 은 이력 테이블이
보관하도록 분리했다.
- OrderService::confirmOption 이 confirmed_at 을 무조건 now 로 덮어썼다
(v0.16.1 부터 존재). 재확정 시 최초 확정 시점이 소실되고 구매확정 훅이
중복 발화한다. OrderOptionService 와 대칭으로 멱등 가드를 넣었다.
- 코어 api:docgen 이 사람이 작성한 API 문서를 재생성 때마다 지웠다.
@generated 블록 안의 표 셀·응답 본문이 대상이었고, extractGeneratedBlock
이 앞의 `## ` 헤딩까지 거슬러 올라가 여러 엔드포인트를 한 덩어리로 반환하던
문제도 함께 고쳤다. 실측 산출 응답 예시에 @probed 출처 마커를 붙여, 마커가
없는 본문은 사람 작성분으로 보고 보존한다 — 실측 1회의 표본은 사람이 코드에서
읽어낸 사실보다 좁으므로 실측은 기본값이지 덮어쓰기 권한이 아니다.
ParameterDescriber 가 {order} 같은 path 파라미터를 "정렬 방향 asc/desc" 로
설명하던 오염도 정정했다.
204 lines
8.9 KiB
PHP
204 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Rules;
|
|
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
|
use Modules\Sirsoft\Ecommerce\Rules\CashReceiptIdentifier;
|
|
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
|
|
/**
|
|
* 현금영수증 식별번호 Custom Rule 테스트 (A-8-1 / D10)
|
|
*
|
|
* 용도 × 식별번호 종류 조합 매트릭스와 형식(정규식·체크섬) 검증을 다룬다.
|
|
* "지출증빙 + 휴대폰번호 = 허용" 이 핵심이다 — 이슈 본문의 1:1 매핑은 부정확했다.
|
|
*/
|
|
class CashReceiptIdentifierTest extends ModuleTestCase
|
|
{
|
|
/**
|
|
* Rule 을 단독 실행해 통과 여부를 반환합니다.
|
|
*/
|
|
private function passes(?CashReceiptType $type, ?CashReceiptIdentifierType $identifierType, string $value): bool
|
|
{
|
|
$validator = Validator::make(
|
|
['identifier' => $value],
|
|
['identifier' => [new CashReceiptIdentifier($type, $identifierType)]],
|
|
);
|
|
|
|
return $validator->passes();
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 용도 × 식별번호 종류 조합 매트릭스
|
|
// ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* @return array<string, array{CashReceiptType, CashReceiptIdentifierType, string, bool}>
|
|
*/
|
|
public static function combinationMatrix(): array
|
|
{
|
|
// 사업자번호 1078648269 는 체크섬 유효값 (아래 별도 테스트에서 고정)
|
|
return [
|
|
'소득공제 + 휴대폰' => [CashReceiptType::INCOME, CashReceiptIdentifierType::PHONE, '01012345678', true],
|
|
'소득공제 + 현금영수증카드' => [CashReceiptType::INCOME, CashReceiptIdentifierType::CARD, '1234567890123', true],
|
|
'소득공제 + 사업자번호(거부)' => [CashReceiptType::INCOME, CashReceiptIdentifierType::BUSINESS, '1078648269', false],
|
|
'지출증빙 + 사업자번호' => [CashReceiptType::EXPENSE, CashReceiptIdentifierType::BUSINESS, '1078648269', true],
|
|
// 팝빌 발행유형 가이드 — 지출증빙에도 휴대폰번호 사용 가능
|
|
'지출증빙 + 휴대폰(허용)' => [CashReceiptType::EXPENSE, CashReceiptIdentifierType::PHONE, '01012345678', true],
|
|
'지출증빙 + 현금영수증카드' => [CashReceiptType::EXPENSE, CashReceiptIdentifierType::CARD, '1234567890123', true],
|
|
];
|
|
}
|
|
|
|
#[Test]
|
|
#[DataProvider('combinationMatrix')]
|
|
public function 용도와_식별번호_종류_조합을_검증한다(
|
|
CashReceiptType $type,
|
|
CashReceiptIdentifierType $identifierType,
|
|
string $value,
|
|
bool $expected,
|
|
): void {
|
|
$this->assertSame($expected, $this->passes($type, $identifierType, $value));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 자진발급 지정번호 (소득공제 전용)
|
|
// ─────────────────────────────────────────────
|
|
|
|
#[Test]
|
|
public function 자진발급_지정번호는_소득공제용으로_허용된다(): void
|
|
{
|
|
$this->assertTrue($this->passes(
|
|
CashReceiptType::INCOME,
|
|
CashReceiptIdentifierType::PHONE,
|
|
CashReceiptIdentifierType::SELF_ISSUE_NUMBER,
|
|
));
|
|
}
|
|
|
|
#[Test]
|
|
public function 자진발급_지정번호는_지출증빙용으로_거부된다(): void
|
|
{
|
|
// 지출증빙 자진발급은 제도상 불가하다.
|
|
$this->assertFalse($this->passes(
|
|
CashReceiptType::EXPENSE,
|
|
CashReceiptIdentifierType::PHONE,
|
|
CashReceiptIdentifierType::SELF_ISSUE_NUMBER,
|
|
));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 사업자등록번호 체크섬
|
|
// ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* @return array<string, array{string, bool}>
|
|
*/
|
|
public static function businessNumbers(): array
|
|
{
|
|
return [
|
|
'유효 체크섬 1' => ['1078648269', true],
|
|
'유효 체크섬 2' => ['2208162517', true],
|
|
'체크섬 불일치 (마지막 자리 오류)' => ['1078648260', false],
|
|
'자릿수 부족' => ['107864826', false],
|
|
'자릿수 초과' => ['10786482690', false],
|
|
'숫자 아님' => ['107864826a', false],
|
|
// 세무서 코드 000 은 체크섬을 수학적으로 만족하지만 발급될 수 없는 번호다.
|
|
'세무서 코드 000' => ['0000000000', false],
|
|
];
|
|
}
|
|
|
|
#[Test]
|
|
#[DataProvider('businessNumbers')]
|
|
public function 사업자등록번호_체크섬을_검증한다(string $number, bool $expected): void
|
|
{
|
|
$this->assertSame(
|
|
$expected,
|
|
$this->passes(CashReceiptType::EXPENSE, CashReceiptIdentifierType::BUSINESS, $number),
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 형식 검증 (휴대폰 / 카드번호)
|
|
// ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* @return array<string, array{string, bool}>
|
|
*/
|
|
public static function phoneNumbers(): array
|
|
{
|
|
return [
|
|
'010 11자리' => ['01012345678', true],
|
|
'011 10자리' => ['0111234567', true],
|
|
'016/017/018/019' => ['01612345678', true],
|
|
'012 (미지원 국번)' => ['01212345678', false],
|
|
'02 지역번호' => ['0212345678', false],
|
|
'자릿수 부족' => ['010123456', false],
|
|
'자릿수 초과' => ['010123456789', false],
|
|
];
|
|
}
|
|
|
|
#[Test]
|
|
#[DataProvider('phoneNumbers')]
|
|
public function 휴대폰번호_형식을_검증한다(string $number, bool $expected): void
|
|
{
|
|
$this->assertSame(
|
|
$expected,
|
|
$this->passes(CashReceiptType::INCOME, CashReceiptIdentifierType::PHONE, $number),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array{string, bool}>
|
|
*/
|
|
public static function cardNumbers(): array
|
|
{
|
|
return [
|
|
'13자리 (최소)' => ['1234567890123', true],
|
|
'19자리 (최대)' => ['1234567890123456789', true],
|
|
'12자리 (미달)' => ['123456789012', false],
|
|
'20자리 (초과)' => ['12345678901234567890', false],
|
|
'숫자 아님' => ['123456789012a', false],
|
|
];
|
|
}
|
|
|
|
#[Test]
|
|
#[DataProvider('cardNumbers')]
|
|
public function 현금영수증_카드번호_형식을_검증한다(string $number, bool $expected): void
|
|
{
|
|
$this->assertSame(
|
|
$expected,
|
|
$this->passes(CashReceiptType::INCOME, CashReceiptIdentifierType::CARD, $number),
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 정규화 / 방어
|
|
// ─────────────────────────────────────────────
|
|
|
|
#[Test]
|
|
public function 하이픈과_공백은_제거된_뒤_검증된다(): void
|
|
{
|
|
$this->assertTrue($this->passes(CashReceiptType::INCOME, CashReceiptIdentifierType::PHONE, '010-1234-5678'));
|
|
$this->assertTrue($this->passes(CashReceiptType::EXPENSE, CashReceiptIdentifierType::BUSINESS, '107-86-48269'));
|
|
$this->assertTrue($this->passes(CashReceiptType::INCOME, CashReceiptIdentifierType::PHONE, '010 1234 5678'));
|
|
}
|
|
|
|
#[Test]
|
|
public function normalize_는_하이픈과_공백만_제거한다(): void
|
|
{
|
|
$this->assertSame('01012345678', CashReceiptIdentifier::normalize('010-1234-5678'));
|
|
$this->assertSame('1078648269', CashReceiptIdentifier::normalize('107-86-48269'));
|
|
$this->assertSame('01012345678', CashReceiptIdentifier::normalize(' 010 1234 5678 '));
|
|
}
|
|
|
|
#[Test]
|
|
public function 용도나_종류가_해석되지_않으면_형식검증을_건너뛴다(): void
|
|
{
|
|
// receipt_type / identifier_type 의 in: 규칙이 이미 오류를 낸 상황 — 중복 오류를 내지 않는다.
|
|
$this->assertTrue($this->passes(null, CashReceiptIdentifierType::PHONE, '완전히잘못된값'));
|
|
$this->assertTrue($this->passes(CashReceiptType::INCOME, null, '완전히잘못된값'));
|
|
}
|
|
}
|