feat(ecommerce): 현금영수증 발급 API + 입금완료 자동발급 + 금액변동 연동
무통장입금 주문의 현금영수증 발급을 관리자·회원·비회원 세 경로에서 제공하고,
입금이 확인되면 자동 발급하며, 부분환불로 금액이 바뀌면 전액취소 후 재발급한다.
발급 프로바이더에 종속되지 않도록 발급/취소는 필터 훅으로 위임한다.
자동발급 리스너는 결제완료 전이(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" 로
설명하던 오염도 정정했다.
This commit is contained in:
1 parent
eb724ed348
commit
58560eefb1
49 files changed
+5071
-86
No files matched your search
@@ -22,6 +22,14 @@ class ApiDocScaffolder
|
||||
*/
|
||||
private const GEN_END = '<!-- @generated:end -->';
|
||||
|
||||
/**
|
||||
* @var string 응답 본문이 실측 산출물임을 표시하는 출처 마커
|
||||
*
|
||||
* 이 마커가 없는 응답 본문은 사람이 작성한 것으로 간주해 재생성 시 보존한다.
|
||||
* 실측 표본 1회는 사람이 아는 사실(성공·엣지 케이스)보다 좁으므로 덮어쓸 권한이 없다.
|
||||
*/
|
||||
private const PROBED_BODY_MARKER = '<!-- @probed -->';
|
||||
|
||||
/**
|
||||
* @var int 응답 예시의 목록(`data.data[]`) 최대 표시 항목 수
|
||||
*
|
||||
@@ -129,16 +137,17 @@ class ApiDocScaffolder
|
||||
*/
|
||||
private const GEN_KEY_EXTENSIONS = 'api-readme-extensions';
|
||||
|
||||
/**
|
||||
* 확장 목차 섹션 헤딩. extensionIndexBlock() 이 이 헤딩까지 소유하므로,
|
||||
* 사람 서술 추출 시 여기부터는 잘라낸다 (SSoT — 두 곳에 문자열을 두지 않는다).
|
||||
*/
|
||||
private const EXTENSION_INDEX_HEADING = '## 확장 API 레퍼런스';
|
||||
|
||||
/**
|
||||
* @var string 코어/확장 README 의 도메인 목차 생성 블록 키
|
||||
*/
|
||||
private const GEN_KEY_INDEX = 'api-readme-index';
|
||||
|
||||
/**
|
||||
* @var string 코어 README 의 확장 API 목차 섹션 헤딩 (사람 서술 보존 경계)
|
||||
*/
|
||||
private const EXTENSIONS_HEADING = '확장 API 레퍼런스';
|
||||
|
||||
/**
|
||||
* 대상(코어/확장)의 API 문서 디렉토리 목차(README.md)를 생성합니다.
|
||||
*
|
||||
@@ -258,7 +267,7 @@ class ApiDocScaffolder
|
||||
});
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '## '.self::EXTENSIONS_HEADING;
|
||||
$lines[] = self::EXTENSION_INDEX_HEADING;
|
||||
$lines[] = '';
|
||||
$lines[] = '> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.';
|
||||
$lines[] = '';
|
||||
@@ -805,7 +814,7 @@ class ApiDocScaffolder
|
||||
$capped = $this->capExampleSize($masked);
|
||||
$json = json_encode($capped, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return "```http\nHTTP/1.1 {$status}\n```\n\n```json\n".(string) $json."\n```";
|
||||
return self::PROBED_BODY_MARKER."\n\n```http\nHTTP/1.1 {$status}\n```\n\n```json\n".(string) $json."\n```";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1057,7 +1066,13 @@ class ApiDocScaffolder
|
||||
// 순서 주의: 표 자체를 되살린 뒤에 셀 설명을 맞춘다.
|
||||
$withFields = $this->restoreEmptyProbeTable($withProse, $existing, $key, $legacy);
|
||||
$withTables = $this->restoreTableDescriptions($withFields, $existing, $key, $legacy);
|
||||
$merged .= $this->restoreErrorSection($withTables, $existing, $key, $legacy)."\n";
|
||||
// 실측 불가 마커 자리·응답 예시를 사람이 채운 경우 되살린다
|
||||
// (@probed 출처 마커 부재 = 사람 작성분, 위치 기반이므로 블록 본문으로만 대조).
|
||||
$withBodies = $this->applyPreservedResponseBodies(
|
||||
$withTables,
|
||||
$this->extractGeneratedBlockBody($existing, $key) ?? $legacy
|
||||
);
|
||||
$merged .= $this->restoreErrorSection($withBodies, $existing, $key, $legacy)."\n";
|
||||
}
|
||||
|
||||
return $merged;
|
||||
@@ -1434,6 +1449,144 @@ class ApiDocScaffolder
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 섹션의 `실측 제외` 응답 본문을, 기존 섹션에서 사람이 채운 본문으로 되살립니다.
|
||||
*
|
||||
* 실측이 불가한 엔드포인트(쓰기 메서드·바이너리·미치환 path)는 `**응답 필드**` / `**응답 예시**`
|
||||
* 자리에 `<!-- 실측 제외: ... -->` 마커만 생성된다. 사람이 코드를 근거로 그 자리를 채워도
|
||||
* 다음 재생성이 블록을 통째로 재조립하면서 마커로 되돌려, 작성분이 통째로 사라졌다.
|
||||
*
|
||||
* 보존 규칙은 표 셀(`restoreTableDescriptions`)과 같다 — **사람이 쓴 본문은 언제나 이긴다.**
|
||||
* 실측 결과는 기본값이지 덮어쓰기 권한이 아니다. 실측 1회가 관측한 표본은 사람이 코드에서
|
||||
* 읽어낸 사실(성공 케이스·엣지 케이스)보다 좁기 때문이다. 실제로 `reissue` 의 실측 표본은
|
||||
* `data: null`(전액 환불) 하나뿐이라, 실측 우선 규칙은 사람이 쓴 성공 예시를 지웠다.
|
||||
*
|
||||
* 두 하위 섹션의 판정 기준이 다르다:
|
||||
* - **응답 예시**: 실측 산출물에 `@probed` 출처 마커가 붙는다. 마커가 없으면 사람 작성분.
|
||||
* - **응답 필드**: 표 자체는 행 키로 대조하는 `restoreTableDescriptions` 가 이미 지킨다.
|
||||
* 따라서 여기서는 실측 실패로 마커만 남은 자리를 사람이 채운 경우만 되살린다.
|
||||
*
|
||||
* @param string $section 새로 생성된 섹션
|
||||
* @param string|null $previous 기존 문서의 같은 엔드포인트 생성 블록 (없으면 null)
|
||||
* @return string 사람이 작성한 응답 본문이 되살아난 섹션
|
||||
*/
|
||||
private function applyPreservedResponseBodies(string $section, ?string $previous): string
|
||||
{
|
||||
if ($previous === null) {
|
||||
return $section;
|
||||
}
|
||||
|
||||
// [본문 시작 헤딩, 본문 끝 헤딩 후보, 실측 산출물 판정자]
|
||||
$subsections = [
|
||||
[
|
||||
'**응답 필드** (`data` 내부)',
|
||||
['**응답 예시**', '**에러 응답**'],
|
||||
// 실측 성공 시 표가 생기고 그 표는 restoreTableDescriptions 가 지킨다.
|
||||
// 여기서 되살릴 대상은 "실측 실패 마커 자리를 사람이 채운" 경우뿐이다.
|
||||
fn (string $body): bool => $this->isProbeSkipMarker($body),
|
||||
],
|
||||
[
|
||||
'**응답 예시**',
|
||||
['**에러 응답**'],
|
||||
fn (string $body): bool => $this->isProbeSkipMarker($body) || str_contains($body, self::PROBED_BODY_MARKER),
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($subsections as [$label, $terminators, $isAutoGenerated]) {
|
||||
$previousBody = $this->extractSubsectionBody($previous, $label, $terminators);
|
||||
|
||||
// 기존 본문이 없거나 자동 생성 결과면 되살릴 것이 없다.
|
||||
if ($previousBody === null || $isAutoGenerated($previousBody)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bounds = $this->locateSubsectionBody($section, $label, $terminators);
|
||||
|
||||
if ($bounds === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$start, $end] = $bounds;
|
||||
$newBody = substr($section, $start, $end - $start);
|
||||
|
||||
// 새 본문이 사람 작성분이면(= 이 run 이 만든 것이 아니면) 손대지 않는다.
|
||||
// 실무상 새 섹션은 항상 자동 생성이지만, 방어적으로 확인한다.
|
||||
if (! $isAutoGenerated($newBody)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$section = substr($section, 0, $start).$previousBody.substr($section, $end);
|
||||
}
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 본문이 실측 실패로 남은 사람 보강 요청 마커인지 확인합니다.
|
||||
*
|
||||
* @param string $body 응답 본문
|
||||
* @return bool 실측 제외/빈 결과 마커면 true
|
||||
*/
|
||||
private function isProbeSkipMarker(string $body): bool
|
||||
{
|
||||
return str_contains($body, '실측 제외') || str_contains($body, '실측 응답에 필드 없음');
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록에서 `$label` 헤딩 ~ 다음 종료 헤딩 사이의 본문 [시작, 끝] 오프셋을 찾습니다.
|
||||
*
|
||||
* @param string $block 생성 블록 문자열
|
||||
* @param string $label 본문 시작 헤딩
|
||||
* @param array<int, string> $terminators 본문 끝 헤딩 후보
|
||||
* @return array{0: int, 1: int}|null [시작 오프셋, 끝 오프셋] (미발견 시 null)
|
||||
*/
|
||||
private function locateSubsectionBody(string $block, string $label, array $terminators): ?array
|
||||
{
|
||||
$labelPos = strpos($block, $label);
|
||||
|
||||
if ($labelPos === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bodyStart = $labelPos + strlen($label);
|
||||
|
||||
$candidates = [];
|
||||
foreach ($terminators as $terminator) {
|
||||
$pos = strpos($block, $terminator, $bodyStart);
|
||||
if ($pos !== false) {
|
||||
$candidates[] = $pos;
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$bodyStart, min($candidates)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록에서 `$label` 헤딩 ~ 다음 종료 헤딩 사이의 본문 문자열을 추출합니다.
|
||||
*
|
||||
* @param string $block 생성 블록 문자열
|
||||
* @param string $label 본문 시작 헤딩
|
||||
* @param array<int, string> $terminators 본문 끝 헤딩 후보
|
||||
* @return string|null 본문 (미발견 시 null)
|
||||
*/
|
||||
private function extractSubsectionBody(string $block, string $label, array $terminators): ?string
|
||||
{
|
||||
$bounds = $this->locateSubsectionBody($block, $label, $terminators);
|
||||
|
||||
if ($bounds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$start, $end] = $bounds;
|
||||
|
||||
return substr($block, $start, $end - $start);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* README 헤더(인용 블록)와 첫 생성 블록 사이의 사람 개요를 추출합니다.
|
||||
*
|
||||
@@ -1485,13 +1638,11 @@ class ApiDocScaffolder
|
||||
|
||||
$afterGen = substr($existing, $endPos + strlen(self::GEN_END));
|
||||
|
||||
// 확장 목차 섹션(`## 확장 API 레퍼런스`)부터는 생성 블록 소관이므로 제외한다.
|
||||
// 임의의 `## ` 헤딩에서 자르면 사람이 덧붙인 헤딩 섹션이 재생성 때마다 통째로 소실된다.
|
||||
$nextSection = preg_match('/\n## '.preg_quote(self::EXTENSIONS_HEADING, '/').'/', $afterGen, $m, PREG_OFFSET_CAPTURE)
|
||||
? $m[0][1]
|
||||
: strlen($afterGen);
|
||||
|
||||
$prose = trim(substr($afterGen, 0, $nextSection));
|
||||
// 확장 목차 섹션(`## 확장 API 레퍼런스`)은 extensionIndexBlock() 이 헤딩까지 소유하므로 제외한다.
|
||||
// 그 외의 `## ` 헤딩(예: `## 개요`, `## 주의사항`)은 사람이 쓴 서술이므로 보존해야 한다.
|
||||
// (회귀: 임의의 `## ` 에서 잘라 목차 블록 뒤 사람 서술이 재생성 때마다 소실되던 문제)
|
||||
$cut = strpos($afterGen, "\n".self::EXTENSION_INDEX_HEADING);
|
||||
$prose = trim($cut !== false ? substr($afterGen, 0, $cut) : $afterGen);
|
||||
|
||||
return $prose === '' ? null : $prose;
|
||||
}
|
||||
|
||||
@@ -205,6 +205,13 @@ class ParameterDescriber
|
||||
*/
|
||||
public function describe(string $name, string $location = '', string $type = ''): ?string
|
||||
{
|
||||
// path 파라미터는 언제나 리소스 식별자다(라우트 모델 바인딩). 이름이 무엇이든
|
||||
// 정렬·필터 같은 조회 파라미터 의미를 가질 수 없으므로 이름 기반 규칙보다 앞서 처리한다.
|
||||
// (회귀: `{order}` path 가 "정렬 방향 asc/desc" 로 설명되던 문제)
|
||||
if ($location === 'path') {
|
||||
return self::EXACT[$name] ?? $this->describePathParam($name);
|
||||
}
|
||||
|
||||
// sort_order / order 는 타입에 따라 의미가 갈린다:
|
||||
// - 문자열: 정렬 방향(asc/desc)
|
||||
// - 정수: 표시 정렬 순서 값(작을수록 우선 — 컬럼 값)
|
||||
@@ -232,11 +239,7 @@ class ParameterDescriber
|
||||
return self::EXACT[$name];
|
||||
}
|
||||
|
||||
// path 파라미터는 대부분 리소스 식별자 — 위치를 근거로 유추.
|
||||
if ($location === 'path') {
|
||||
return $this->describePathParam($name);
|
||||
}
|
||||
|
||||
// path 파라미터는 위 진입부에서 이미 처리했다.
|
||||
return $this->byPattern($name, $type);
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,18 @@ README.md "API 레퍼런스" 또는 AGENTS.md "API 레퍼런스 진입점" 표
|
||||
따른다. `<!-- @generated:start -->` ~ `<!-- @generated:end -->` 사이는 `api:docgen` 이 재생성하는 추출
|
||||
블록이며, 그 바깥의 사람 서술(`**설명**`)은 재생성 시 보존된다.
|
||||
|
||||
블록 **안**의 사람 작성분도 보존된다. 자동 생성은 비어 있는 자리를 채울 뿐 사람이 쓴 것을 덮어쓰지 않는다:
|
||||
|
||||
| 대상 | 보존 판정 |
|
||||
| --- | --- |
|
||||
| 파라미터 표 `용도` 셀 | 행 키(이름+위치)로 대조. 기존 값이 TODO 스텁이 아니면 유지 |
|
||||
| 응답 필드 표 `용도/설명` 셀 | 행 키(필드)로 대조. 기존 값이 TODO 스텁이 아니면 유지 |
|
||||
| 응답 필드 본문 (실측 실패로 마커만 남은 자리) | 사람이 채웠으면 유지 |
|
||||
| 응답 예시 본문 | 실측 산출물에는 `<!-- @probed -->` 출처 마커가 붙는다. 마커가 없으면 사람 작성분으로 보고 유지 |
|
||||
|
||||
`@probed` 마커가 필요한 이유: 실측은 1회 호출이 관측한 표본 하나뿐이라, 사람이 코드를 읽고 쓴 사실
|
||||
(성공 케이스와 엣지 케이스를 함께 제시하는 등)보다 좁다. 실측 결과는 기본값이지 덮어쓰기 권한이 아니다.
|
||||
|
||||
에러 응답 표는 라우트 메타에서 대표 상태코드를 자동 추론한다: 인증 필수(`auth:sanctum`)→401,
|
||||
`admin`/`permission:` 요구→403, FormRequest 검증 규칙 존재→422, path 파라미터 존재→404.
|
||||
`optional.sanctum`(선택 인증)은 401 을 유발하지 않는다. 도메인 특이 에러(409·429 등)는 사람이 보강한다.
|
||||
@@ -147,8 +159,8 @@ README.md "API 레퍼런스" 또는 AGENTS.md "API 레퍼런스 진입점" 표
|
||||
조립(raw HTTP 요청 골격), 응답 예시는 `<!-- 실측 제외: {사유} — 응답 예시는 사람이 작성하세요. -->` 마커로 남긴다.
|
||||
- 두 예시 블록은 전량 `@generated` 블록 **내부**에 있다. 이미 파라미터/응답 필드 표의 사람 서술이
|
||||
채워진 문서에 예시만 추가할 때는 `api:docgen --examples-only` 로 표·서술을 건드리지 않고 예시 2블록만
|
||||
in-place 삽입/치환한다(측정은 재수행하되 표는 불가침). 백필 커맨드(`backfill-params`/`backfill-fields`)도
|
||||
예시 블록을 건드리지 않는다.
|
||||
in-place 삽입/치환한다(측정은 재수행하되 표는 불가침, 신규 엔드포인트 섹션은 만들지 못한다). 백필
|
||||
커맨드(`backfill-params`/`backfill-fields`)도 예시 블록을 건드리지 않는다.
|
||||
|
||||
### 재생성이 지우지 못하는 것
|
||||
|
||||
@@ -353,9 +365,8 @@ php artisan api:docgen --scope=core --dry-run
|
||||
### 설명 백필 커맨드 (`api:docgen-backfill-*`)
|
||||
|
||||
파라미터/응답 필드 표의 `<!-- TODO: 설명 -->` 셀 중 **도메인 무관 공통 필드**(페이지네이션·정렬·검색·
|
||||
식별자·기간·토글·공통 파생 필드 등)는 전체 재생성 없이 in-place 로 자동 서술한다. 전체 재생성
|
||||
(`api:docgen`)은 `@generated` 블록 표를 통째로 재조립해 실측 예시값·사람 서술 셀을 되돌리므로, TODO
|
||||
셀만 치환하는 별도 백필 커맨드를 쓴다(멱등 — 채워진 셀·실측 예시값 불가침).
|
||||
식별자·기간·토글·공통 파생 필드 등)는 전체 재생성 없이 in-place 로 자동 서술한다. 실측을 재수행하지 않아
|
||||
빠르고, 실측 예시값도 그대로 둔다(멱등 — 채워진 셀·실측 예시값 불가침).
|
||||
|
||||
```bash
|
||||
# 요청 파라미터 표 TODO 셀 백필 (SSoT: App\Support\ApiDoc\ParameterDescriber)
|
||||
|
||||
@@ -229,13 +229,13 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
|
||||
|
||||
<!-- @generated:start:api-readme-extensions -->
|
||||
- **확장 수**: 13 · **엔드포인트 수**: 391
|
||||
- **확장 수**: 13 · **엔드포인트 수**: 398
|
||||
|
||||
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
|
||||
| --- | --- | --- | --- |
|
||||
| `gnuboard7-hello_module` | 모듈 | [docs/api/](../../../modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 |
|
||||
| `sirsoft-board` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
|
||||
| `sirsoft-ecommerce` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 232 |
|
||||
| `sirsoft-ecommerce` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 |
|
||||
| `sirsoft-page` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 현금영수증 관련 문구(`cash_receipt.*` — 발급 용도, 식별번호 종류, 발급/취소 이력 상태, 배송비 과세 방식, 오류 안내) 일본어 번역 추가 — 현금영수증 발급 화면과 이커머스 환경설정이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 현금영수증 발급 요청의 입력 항목명·검증 안내·처리 결과 문구(`cash_receipt.attributes.*`, `cash_receipt.validation.*`, `cash_receipt.messages.*`)와 발급 불가 사유 안내(`cash_receipt.errors.*`) 일본어 번역 추가 — 잘못된 식별번호를 입력했을 때의 안내와 발급·취소 결과 메시지가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.8] - 2026-08-01
|
||||
|
||||
|
||||
@@ -29,5 +29,33 @@ return [
|
||||
'no_provider_handled' => '現金領収書発行リクエストを処理したプロバイダーがありません。',
|
||||
'no_issuable_amount' => '発行可能な現金性金額がありません。',
|
||||
'identifier_unavailable' => '再発行に必要な識別番号を復号化できません。管理者が識別番号を再入力して発行する必要があります。',
|
||||
'payment_not_found' => '決済情報が見つかりません。',
|
||||
'not_cash_payment' => '銀行振込注文のみ現金領収書を発行できます。',
|
||||
'payment_not_paid' => '入金が確認された注文のみ現金領収書を発行できます。',
|
||||
'already_issued' => '既に現金領収書が発行された注文です。',
|
||||
'issue_failed' => '現金領収書の発行に失敗しました。',
|
||||
'cancel_failed' => '現金領収書のキャンセルに失敗しました。',
|
||||
'no_active_receipt' => 'キャンセルする現金領収書がありません。',
|
||||
],
|
||||
'attributes' => [
|
||||
'receipt_type' => '発行目的',
|
||||
'identifier_type' => '発行手段',
|
||||
'identifier' => '識別番号',
|
||||
],
|
||||
'validation' => [
|
||||
'identifier_invalid' => '識別番号の形式が正しくありません。',
|
||||
'identifier_type_not_allowed' => ':type は :identifier_type で発行できません。',
|
||||
'self_issue_income_only' => '自進発行指定番号は所得控除用でのみ発行できます。',
|
||||
'identifier_format' => [
|
||||
'phone' => '携帯電話番号は010、011、016、017、018、019で始まる10~11桁の数字である必要があります。',
|
||||
'card' => '現金領収書カード番号は13~19桁の数字である必要があります。',
|
||||
'business' => '事業者登録番号が正しくありません。10桁の数字を確認してください。',
|
||||
],
|
||||
],
|
||||
'messages' => [
|
||||
'issued' => '現金領収書が発行されました。',
|
||||
'cancelled' => '現金領収書がキャンセルされました。',
|
||||
'reissued' => '現金領収書が再発行されました。',
|
||||
'status_retrieved' => '現金領収書情報を照会しました。',
|
||||
],
|
||||
];
|
||||
@@ -18,11 +18,24 @@
|
||||
|
||||
### Added
|
||||
|
||||
#### 현금영수증
|
||||
|
||||
- 현금영수증 발급 업체를 결제대행사와 따로 고를 수 있습니다 — KG이니시스로 결제받으면서 현금영수증만 토스페이먼츠로 발급하는 구성이 가능합니다. 발급 업체는 이커머스 환경설정에서 선택합니다.
|
||||
- 현금영수증 발급·취소 이력이 남습니다. 부분 환불 등으로 결제금액이 바뀌면 기존 영수증을 취소하고 바뀐 금액으로 다시 발급하며, 그 과정이 모두 기록되어 국세청 신고 근거로 쓸 수 있습니다. 재발급이 실패한 경우에도 이력이 남아 관리자가 확인하고 다시 발급할 수 있습니다.
|
||||
- 주문에 현금성 금액이 기록됩니다. 무통장입금 주문에서 마일리지를 사용한 경우, 실제로 계좌에 입금된 금액만 현금영수증 발급 대상이 됩니다.
|
||||
- 배송비 과세 방식을 이커머스 환경설정에서 고를 수 있습니다 — 안분(기본, 과세상품 비율만큼 배송비를 과세로 계산) / 전액 과세 / 주된 재화를 따름 중 상점의 세무 판단에 맞춰 선택합니다.
|
||||
- 무통장입금 주문의 입금이 확인되면 현금영수증이 자동으로 발급됩니다. 관리자가 "결제완료로 변경"한 경우와 "입금만 기록"한 경우 모두 발급되며, 마일리지로 전액 결제되어 실제 입금액이 없는 주문에는 발급하지 않습니다. 발급은 백그라운드에서 처리되므로 발급 업체 응답이 늦어도 입금 확인 자체는 지연되지 않습니다.
|
||||
- 주문할 때 현금영수증을 신청하지 않았어도 나중에 발급할 수 있습니다 — 관리자 주문 상세와 구매자 주문 상세 양쪽에서 발급할 수 있고, 비회원도 주문 조회 후 발급할 수 있습니다. 발급 취소는 관리자만 할 수 있습니다.
|
||||
- 발급 용도(소득공제/지출증빙)와 발급 수단(휴대폰번호·현금영수증카드번호·사업자등록번호)을 따로 고를 수 있습니다. 지출증빙용도 휴대폰번호로 발급할 수 있으며, 사업자등록번호는 입력 시 유효성을 검사합니다.
|
||||
- 현금영수증을 신청하지 않은 무통장입금 건을 사업자가 자진발급하는 설정을 추가했습니다 (기본 사용 안 함). 현금영수증 의무발행 업종이라면 켜 두면 됩니다.
|
||||
- 현금영수증 발급·취소 이력이 남습니다. 부분 환불 등으로 결제금액이 바뀌면 기존 영수증을 취소하고 바뀐 금액으로 다시 발급하며, 그 과정이 모두 기록되어 국세청 신고 근거로 쓸 수 있습니다. 재발급은 환불 처리가 끝난 뒤 이뤄지므로, 재발급이 실패하더라도 환불 자체는 그대로 완료되고 관리자가 주문 상세에서 다시 발급할 수 있습니다.
|
||||
- 현금영수증 식별번호(휴대폰번호·사업자등록번호 등)는 재발급에 쓸 수 있도록 암호화해 보관하고 구매확정 시점에 폐기합니다. 이력에는 뒤 4자리를 제외한 마스킹 값만 남으며, 발급 업체가 보내온 응답에 섞여 오는 개인정보도 가려서 저장합니다. 주민등록번호는 수집하지 않습니다.
|
||||
- 결제 영수증(카드 매출전표 등)과 현금영수증을 각각 따로 조회할 수 있습니다. 두 영수증은 서로 다른 값으로 보관되어 한쪽이 다른 쪽을 덮어쓰지 않습니다.
|
||||
|
||||
#### 금액·과세
|
||||
|
||||
- 주문에 현금성 금액이 기록됩니다. 무통장입금 주문에서 마일리지를 사용한 경우, 실제로 계좌에 입금된 금액만 현금영수증 발급 대상이 됩니다. 주문을 일부 취소하면 남은 실입금액 기준으로 다시 계산됩니다.
|
||||
- 배송비 과세 방식을 이커머스 환경설정에서 고를 수 있습니다 — 안분(기본, 과세상품 비율만큼 배송비를 과세로 계산) / 전액 과세 / 주된 재화를 따름 중 상점의 세무 판단에 맞춰 선택합니다.
|
||||
|
||||
#### 사이트맵·알림·마일리지
|
||||
|
||||
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
|
||||
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
|
||||
- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
|
||||
@@ -32,6 +45,7 @@
|
||||
|
||||
- 과세·면세 금액 합계에 배송비가 포함됩니다. 그동안 배송비는 과세/면세 어느 쪽에도 집계되지 않아 상품 금액만 합산되었습니다.
|
||||
- 현금영수증 발급 용도 값을 소득공제(income) / 지출증빙(expense)으로 통일했습니다. 기존에 다른 값으로 저장된 주문은 모듈 업데이트 시 자동으로 정리됩니다.
|
||||
- 주문 상세 응답에 현금영수증 정보와 결제 영수증 링크가 포함됩니다.
|
||||
- 관리자 상품 목록이 상품마다 옵션 전체를 함께 내려주지 않습니다. 목록에는 옵션 개수와 재고 합계만 실리고, 옵션 상세는 행을 펼칠 때 필요한 상품의 것만 한 번에 가져옵니다. 옵션을 많이 쓰는 쇼핑몰일수록 목록을 여는 속도와 전송량이 크게 개선됩니다(상품 100건에 상품당 옵션 20건 기준, 목록 응답 크기 약 94% 감소). 화면에서 보이는 내용과 일괄 변경 적용 범위는 이전과 동일하며, 옵션을 목록 응답에 함께 받으려면 주소 뒤에 `?with_options=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 관리자 주문 목록이 주문마다 주문상품 전체를 함께 내려주지 않습니다. 목록은 대표 상품 1건과 "외 N건" 표기만 사용하므로, 이제 그 1건과 개수만 읽습니다. 한 주문에 상품을 많이 담는 쇼핑몰일수록 목록을 여는 속도와 전송량이 개선됩니다. 화면에 보이는 내용(대표 상품·추가옵션 요약·"외 N건"·부분취소 표시)은 이전과 동일하며, 마이페이지 주문내역은 종전대로 주문상품 전체를 보여줍니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 상품 옵션의 표시 순서를 상품 등록/수정 화면과 동일하게 정렬 순서 기준으로 통일했습니다. 이전에는 관리자 상품 목록의 펼친 옵션만 정해진 순서 없이 표시돼 화면마다 순서가 달라 보일 수 있었습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
@@ -142,6 +156,7 @@
|
||||
- 해외 배송지를 수정하면 도시·주·우편번호가 안내 없이 비워지던 문제를 수정했습니다. 이번 수정에서 다시 입력하지 않은 항목은 저장된 값이 그대로 유지됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 관리자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. 주문·상품·마일리지·쿠폰·쿠폰 발급 이력·상품 리뷰·배송 정책·활동 이력 목록이 해당하며, 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다.
|
||||
- 쿠폰 사용조건의 상품 검색에서 결과가 많을 때 목록 끝까지 스크롤해도 다음 묶음이 더 불러와지지 않던 문제를 수정했습니다.
|
||||
- 관리자가 주문상품을 일괄 구매확정할 때와 구매자가 직접 구매확정할 때, 구매확정 시점이 최초 확정 시각으로 동일하게 보존됩니다 — 이미 확정된 주문을 다시 확정 처리해도 시점이 덮어써지지 않습니다.
|
||||
|
||||
## [1.0.4] - 2026-07-16
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 33 · **엔드포인트 수**: 232
|
||||
- **문서 수**: 33 · **엔드포인트 수**: 239
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
@@ -19,12 +19,12 @@
|
||||
| [currency.md](currency.md) | `currency` | 2 |
|
||||
| [dashboard.md](dashboard.md) | `dashboard` | 4 |
|
||||
| [extra-fee-templates.md](extra-fee-templates.md) | `extra-fee-templates` | 10 |
|
||||
| [guest.md](guest.md) | `guest` | 5 |
|
||||
| [guest.md](guest.md) | `guest` | 7 |
|
||||
| [inquiries.md](inquiries.md) | `inquiries` | 10 |
|
||||
| [mileage.md](mileage.md) | `mileage` | 3 |
|
||||
| [mileage-transactions.md](mileage-transactions.md) | `mileage-transactions` | 5 |
|
||||
| [options.md](options.md) | `options` | 3 |
|
||||
| [orders.md](orders.md) | `orders` | 22 |
|
||||
| [orders.md](orders.md) | `orders` | 27 |
|
||||
| [payments.md](payments.md) | `payments` | 1 |
|
||||
| [presets.md](presets.md) | `presets` | 4 |
|
||||
| [product-common-infos.md](product-common-infos.md) | `product-common-infos` | 6 |
|
||||
|
||||
@@ -119,6 +119,184 @@ Content-Type: application/json
|
||||
**설명** 비회원이 조회 토큰으로 인증된 상태에서 자신의 주문을 취소합니다. 주문 소유권은 `VerifyGuestOrderToken` 미들웨어가 `X-Guest-Order-Token` 헤더로 검증하며, `OrderController@cancel`이 회원 취소와 동일한 `OrderCancellationService`를 재사용하되 취소자(`cancelledBy`)는 null로 둡니다. `items`를 전달하면 부분취소, 없으면 전체취소로 처리하고, `refund_priority`로 PG 환불과 포인트 환불 중 우선순위를 지정할 수 있습니다. 취소 후 갱신된 주문 상세를 `GuestOrderResource`로 반환합니다.
|
||||
|
||||
|
||||
### GET /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.show -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.show`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CashReceiptController@show`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| orderNumber | path | string | 예 | — | 대상 order number의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| issuable | boolean | 지금 발급이 가능한지 여부 (무통장 + 입금완료 + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨) |
|
||||
| cash_receipt | object\|null | 현재 활성 영수증 1건 (`CashReceiptResource`). 발급 전이거나 전액 취소된 경우 `null` |
|
||||
|
||||
`cash_receipt` 의 하위 필드 구성은 [발급 API 의 응답 필드 표](orders.md)와 동일합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
발급 전 (발급 가능):
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증 정보를 조회했습니다.",
|
||||
"data": {
|
||||
"issuable": true,
|
||||
"cash_receipt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
발급 완료:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증 정보를 조회했습니다.",
|
||||
"data": {
|
||||
"issuable": false,
|
||||
"cash_receipt": {
|
||||
"id": 12,
|
||||
"provider": "sirsoft-pay_tosspayments",
|
||||
"transaction_type": "issue",
|
||||
"receipt_type": "income",
|
||||
"receipt_type_label": "소득공제용",
|
||||
"amount": 12000,
|
||||
"amount_formatted": "12,000원",
|
||||
"tax_free_amount": 0,
|
||||
"tax_free_amount_formatted": "0원",
|
||||
"identifier_masked": "010****5678",
|
||||
"receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...",
|
||||
"issue_number": "CR20260710000012",
|
||||
"issue_status": "COMPLETED",
|
||||
"error_code": null,
|
||||
"error_message": null,
|
||||
"issued_at": "2026-07-10T14:32:11+09:00",
|
||||
"issued_at_formatted": "2026-07-10 14:32"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
> 요청 예시에는 표시되지 않지만 **`X-Guest-Order-Token` 헤더가 필수**입니다 — 비회원 주문 조회 인증 시 발급받은 토큰을 그대로 전달합니다. 누락하거나 주문번호와 맞지 않으면 404 를 반환합니다.
|
||||
|
||||
**설명** 비회원이 주문 조회 후 해당 주문(`orderNumber`)의 현금영수증 발급 상태를 조회합니다. `VerifyGuestOrderToken` 미들웨어가 `X-Guest-Order-Token` 헤더(비회원 주문 조회 인증 시 발급)를 검증하며, **토큰이 지목한 주문 외에는 접근할 수 없습니다** — 토큰이 없거나 주문번호와 맞지 않으면 404 를 반환합니다.
|
||||
|
||||
응답 형식은 회원용 조회(`GET user/orders/{id}/cash-receipt`)와 동일합니다 — `data.issuable`(발급 가능 여부)과 `data.cash_receipt`(활성 영수증 또는 `null`). 이 엔드포인트는 요청 본문을 받지 않으므로 발급 폼 검증에 걸리지 않습니다.
|
||||
|
||||
|
||||
### POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.issue -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.issue`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CashReceiptController@issue`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| orderNumber | path | string | 예 | — | 대상 order number의 식별자 |
|
||||
| receipt_type | body | string | 예 | `income`, `expense` | 발급 용도 (income 소득공제용 — 개인 연말정산 / expense 지출증빙용 — 사업자 매입세액공제) |
|
||||
| identifier_type | body | string | 예 | `phone`, `card`, `business` | 발급 수단 (phone 휴대폰번호 / card 현금영수증카드번호 / business 사업자등록번호 — 사업자등록번호는 지출증빙 전용) |
|
||||
| identifier | body | string | 예 | max 30 | 식별번호 (하이픈·공백 제거 후 검증 — 휴대폰 10~11자리 / 현금영수증카드 13~19자리 / 사업자등록번호 10자리 체크섬) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"receipt_type": "income",
|
||||
"identifier_type": "phone",
|
||||
"identifier": "example-key"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
`data` 는 발급 이력 1건(`CashReceiptResource`)이며, 필드 구성은 [발급 API 의 응답 필드 표](orders.md)와 동일합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증이 발급되었습니다.",
|
||||
"data": {
|
||||
"id": 12,
|
||||
"provider": "sirsoft-pay_tosspayments",
|
||||
"transaction_type": "issue",
|
||||
"receipt_type": "income",
|
||||
"receipt_type_label": "소득공제용",
|
||||
"amount": 12000,
|
||||
"amount_formatted": "12,000원",
|
||||
"tax_free_amount": 0,
|
||||
"tax_free_amount_formatted": "0원",
|
||||
"identifier_masked": "010****5678",
|
||||
"receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...",
|
||||
"issue_number": "CR20260710000012",
|
||||
"issue_status": "COMPLETED",
|
||||
"error_code": null,
|
||||
"error_message": null,
|
||||
"issued_at": "2026-07-10T14:32:11+09:00",
|
||||
"issued_at_formatted": "2026-07-10 14:32"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
> 요청 예시에는 표시되지 않지만 **`X-Guest-Order-Token` 헤더가 필수**입니다 — 누락하거나 주문번호와 맞지 않으면 404 를 반환합니다.
|
||||
|
||||
**설명** 비회원이 주문 당시 신청하지 않은 현금영수증을 주문상세에서 **직접 사후 발급**합니다. `VerifyGuestOrderToken` 미들웨어가 `X-Guest-Order-Token` 헤더를 검증해 소유권을 보장하며, 토큰이 검증한 주문에 대해서만 발급합니다.
|
||||
|
||||
요청 본문(`receipt_type` / `identifier_type` / `identifier`), 검증 규칙, 발급 가능 조건, 오류 코드 체계는 관리자·회원 발급 API 와 모두 동일합니다 — 이미 발급된 주문은 409(`ALREADY_ISSUED`), 그 외 발급 불가 사유는 422 에 `errors.error_code` 로 구분해 담깁니다.
|
||||
|
||||
**발급 취소는 제공하지 않습니다**(관리자 전용). 회원 경로와 마찬가지로 비회원용 `DELETE` 라우트 자체를 노출하지 않습니다.
|
||||
|
||||
|
||||
### POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/estimate-refund
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.guest.orders.estimate-refund -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.estimate-refund`
|
||||
|
||||
@@ -697,6 +697,288 @@ Content-Type: application/json
|
||||
**설명** 관리자가 특정 주문(`order`)을 전체취소 또는 부분취소합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@cancelOrder`가 `items` 유무에 따라 `OrderCancellationService`의 `cancelOrder()`(전체) 또는 `cancelOrderOptions()`(부분)를 호출합니다. 취소자(`cancelledBy`)로 관리자 ID가 기록되고, `cancel_pg`로 PG 결제 취소 동반 여부를, `refund_priority`로 PG/포인트 환불 우선순위를 지정합니다. 취소 후 갱신된 주문을 `OrderResource`로 반환하며, 취소 불가 상태 등 실패 시 422를 반환합니다.
|
||||
|
||||
|
||||
### DELETE /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.cancel -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.cancel`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CashReceiptController@cancel`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| order | path | string | 예 | — | 대상 order의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/modules/sirsoft-ecommerce/admin/orders/1/cash-receipt HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
`data` 는 항상 `null` 입니다 — 취소 성공 여부는 `success` 로 판정합니다. 취소 후의 원장 상태가 필요하면 주문 상세(`GET admin/orders/{order}`)의 `cash_receipts` 를 다시 조회합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증이 취소되었습니다.",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 관리자가 특정 주문(`order`)에 발급된 현금영수증을 **전액 취소**합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\CashReceiptController@cancel`이 `CashReceiptService::cancelAll()`로 활성 영수증을 발급 프로바이더(토스페이먼츠 등)에 취소 요청합니다. 부분취소 API 는 사용하지 않습니다 — 금액이 바뀌는 경우는 재발급(`/cash-receipt/reissue`)이 담당합니다. 취소 이력은 원장에 남아 국세청 신고 근거로 유지되며, 재발급용 식별번호 암호문은 폐기하지 않습니다(관리자가 같은 번호로 다시 발급할 수 있어야 하므로). 발급 취소는 관리자 전용이며 회원·비회원에게는 노출하지 않습니다.
|
||||
|
||||
실패 시 사유는 `errors.error_code` 로 구분합니다 — 활성 영수증이 없으면 `NO_ACTIVE_RECEIPT`, 프로바이더 취소가 실패하면 `CANCEL_FAILED` 이며 둘 다 422 입니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "취소할 현금영수증이 없습니다.",
|
||||
"errors": {
|
||||
"error_code": "NO_ACTIVE_RECEIPT"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.issue -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.issue`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CashReceiptController@issue`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| order | path | string | 예 | — | 대상 order의 식별자 |
|
||||
| receipt_type | body | string | 예 | `income`, `expense` | 발급 용도 (income 소득공제용 — 개인 연말정산 / expense 지출증빙용 — 사업자 매입세액공제) |
|
||||
| identifier_type | body | string | 예 | `phone`, `card`, `business` | 발급 수단 (phone 휴대폰번호 / card 현금영수증카드번호 / business 사업자등록번호 — 사업자등록번호는 지출증빙 전용) |
|
||||
| identifier | body | string | 예 | max 30 | 식별번호 (하이픈·공백 제거 후 검증 — 휴대폰 10~11자리 / 현금영수증카드 13~19자리 / 사업자등록번호 10자리 체크섬) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/modules/sirsoft-ecommerce/admin/orders/1/cash-receipt HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"receipt_type": "income",
|
||||
"identifier_type": "phone",
|
||||
"identifier": "example-key"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
`data` 는 발급 이력 1건(`CashReceiptResource`)입니다.
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| id | integer | 발급 이력 ID |
|
||||
| provider | string | 발급을 수행한 프로바이더 식별자 (예: `sirsoft-pay_tosspayments`) |
|
||||
| transaction_type | string | 거래 유형 — `issue`(발급) / `cancel`(취소). 발급 응답은 항상 `issue` |
|
||||
| receipt_type | string | 발급 용도 — `income`(소득공제용) / `expense`(지출증빙용) |
|
||||
| receipt_type_label | string | 발급 용도의 다국어 표시명 (예: `소득공제용`) |
|
||||
| amount | integer | 발급 금액 (주문 통화 기준 반올림, 과세분 포함 총액) |
|
||||
| amount_formatted | string | 발급 금액의 통화 표기 문자열 (예: `12,000원`) |
|
||||
| tax_free_amount | integer | 발급 금액 중 면세 금액 |
|
||||
| tax_free_amount_formatted | string | 면세 금액의 통화 표기 문자열 |
|
||||
| identifier_masked | string | 마스킹된 식별번호 (원본은 어떤 응답에도 노출되지 않음) |
|
||||
| receipt_url | string\|null | 프로바이더가 발급한 영수증 조회 링크 |
|
||||
| issue_number | string\|null | 프로바이더가 부여한 승인번호 |
|
||||
| issue_status | string | 발급 상태 — `IN_PROGRESS` / `COMPLETED` / `FAILED`. 성공 응답은 항상 `COMPLETED` |
|
||||
| error_code | string\|null | 실패 사유 코드 (성공 시 `null`) |
|
||||
| error_message | string\|null | 실패 사유 상세 (성공 시 `null`) |
|
||||
| issued_at | string\|null | 발급 일시 (ISO8601, 머신 판독용) |
|
||||
| issued_at_formatted | string\|null | 발급 일시 (사용자 타임존 기준 표시용) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증이 발급되었습니다.",
|
||||
"data": {
|
||||
"id": 12,
|
||||
"provider": "sirsoft-pay_tosspayments",
|
||||
"transaction_type": "issue",
|
||||
"receipt_type": "income",
|
||||
"receipt_type_label": "소득공제용",
|
||||
"amount": 12000,
|
||||
"amount_formatted": "12,000원",
|
||||
"tax_free_amount": 0,
|
||||
"tax_free_amount_formatted": "0원",
|
||||
"identifier_masked": "010****5678",
|
||||
"receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...",
|
||||
"issue_number": "CR20260710000012",
|
||||
"issue_status": "COMPLETED",
|
||||
"error_code": null,
|
||||
"error_message": null,
|
||||
"issued_at": "2026-07-10T14:32:11+09:00",
|
||||
"issued_at_formatted": "2026-07-10 14:32"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 관리자가 특정 주문(`order`)에 현금영수증을 발급합니다. 주문 당시 구매자가 신청하지 않은 건의 **사후 발급**과, 발급 실패 후 식별번호를 다시 입력해 발급하는 경우에 사용합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\CashReceiptController@issue`가 `CashReceiptService::issue()`로 발급 프로바이더에 위임합니다.
|
||||
|
||||
발급 가능 조건은 **무통장입금(dbank) + 입금완료(PAID) + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨** 이며, 하나라도 어긋나면 `errors.error_code` 로 사유를 구분해 반환합니다 — 이미 발급된 주문은 **409**(`ALREADY_ISSUED`), 그 외는 422(`NOT_CASH_PAYMENT` / `PAYMENT_NOT_PAID` / `NO_ISSUABLE_AMOUNT` / `PROVIDER_NOT_CONFIGURED`). 프로바이더가 발급을 거부하면 422(`PROVIDER_ERROR`)와 함께 `errors.error_message` 에 상세 사유가 담깁니다.
|
||||
|
||||
`receipt_type`(발급 용도)과 `identifier_type`(발급 수단)은 독립 필드입니다. 지출증빙용도 휴대폰번호로 발급할 수 있으며, 사업자등록번호는 체크섬을 검증합니다. 소득공제 + 사업자등록번호 조합과, 지출증빙 + 국세청 자진발급 지정번호(`0100001234`) 조합은 거부됩니다.
|
||||
|
||||
응답의 `data` 는 발급 이력 1건이며 `receipt_url`(영수증 조회 링크)·`identifier_masked`(뒤 4자리 외 마스킹)·`issue_number` 를 포함합니다. **식별번호 원본은 응답·로그 어디에도 노출되지 않으며**, 재발급용으로만 암호화 보관하다 구매확정 시 폐기합니다.
|
||||
|
||||
발급 불가 응답의 `errors.error_code` 는 두 계열로 나뉩니다. 프론트는 이 값으로 안내 문구와 버튼 노출을 분기합니다.
|
||||
|
||||
**① 사전 가드**(`resolveIssueBlocker`) — 프로바이더 호출 전에 판정하며 코드 6종이 고정입니다.
|
||||
|
||||
| error_code | 상태코드 | 의미 |
|
||||
| --- | --- | --- |
|
||||
| `ALREADY_ISSUED` | 409 | 이미 활성 현금영수증이 발급된 주문 |
|
||||
| `PROVIDER_NOT_CONFIGURED` | 422 | 현금영수증 발급 프로바이더가 설정되지 않음 |
|
||||
| `PAYMENT_NOT_FOUND` | 422 | 주문에 결제 정보가 없음 |
|
||||
| `NOT_CASH_PAYMENT` | 422 | 무통장입금(dbank) 주문이 아님 |
|
||||
| `PAYMENT_NOT_PAID` | 422 | 입금이 확인되지 않음 |
|
||||
| `NO_ISSUABLE_AMOUNT` | 422 | 발급 가능한 현금성 금액이 0 (전액 마일리지 결제·전액 환불 등) |
|
||||
|
||||
**② 프로바이더 실패** — 422 이며 `error_code` 는 **프로바이더가 반환한 값을 그대로 통과**시킵니다(고정 목록 없음). 어떤 리스너도 발급 요청을 처리하지 않은 경우에만 코어가 `NO_PROVIDER_HANDLED` 를 채웁니다. 상세 사유는 `errors.error_message` 에 담기므로, 프론트는 알 수 없는 코드를 만나면 `error_message` 를 그대로 노출하는 것을 기본 동작으로 삼습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "이미 현금영수증이 발급된 주문입니다.",
|
||||
"errors": {
|
||||
"error_code": "ALREADY_ISSUED"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt/reissue
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.reissue -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.reissue`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CashReceiptController@reissue`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| order | path | string | 예 | — | 대상 order의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/modules/sirsoft-ecommerce/admin/orders/1/cash-receipt/reissue HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
`data` 는 재발급된 이력 1건(`CashReceiptResource`, 필드 구성은 발급 API 와 동일)이거나, 잔여 발급액이 0 이어서 재발급할 것이 없으면 `null` 입니다. 두 경우 모두 200 이므로 **`success` 만으로 판정하지 말고 `data` 의 존재 여부까지 확인**해야 합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
재발급 성공 (잔여 금액 존재):
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증이 재발급되었습니다.",
|
||||
"data": {
|
||||
"id": 14,
|
||||
"provider": "sirsoft-pay_tosspayments",
|
||||
"transaction_type": "issue",
|
||||
"receipt_type": "income",
|
||||
"receipt_type_label": "소득공제용",
|
||||
"amount": 8000,
|
||||
"amount_formatted": "8,000원",
|
||||
"tax_free_amount": 0,
|
||||
"tax_free_amount_formatted": "0원",
|
||||
"identifier_masked": "010****5678",
|
||||
"receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...",
|
||||
"issue_number": "CR20260710000014",
|
||||
"issue_status": "COMPLETED",
|
||||
"error_code": null,
|
||||
"error_message": null,
|
||||
"issued_at": "2026-07-10T15:02:44+09:00",
|
||||
"issued_at_formatted": "2026-07-10 15:02"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
전액 환불되어 재발급 대상 금액이 0 인 경우 (정상 결과):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증이 재발급되었습니다.",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 관리자가 **"취소 성공 + 재발급 실패"** 중간 상태를 복구합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\CashReceiptController@reissue`가 `CashReceiptService::recoverFailedIssue()`를 호출합니다.
|
||||
|
||||
부분환불이 일어나면 기존 영수증을 전액취소하고 잔여 금액으로 재발급하는데(부분취소 API 미사용), 취소는 성공했는데 재발급만 실패하면 활성 영수증이 없는 상태로 남습니다. 이때 원장에는 취소(COMPLETED)와 발급(FAILED) 2행이 남으며, 관리자 주문 상세가 경고 배지를 띄웁니다. 이 엔드포인트가 그 상태를 되돌립니다 — 마지막 발급 이력의 발급 용도와 저장된 식별번호 암호문을 재사용하므로 **요청 본문이 필요 없습니다**.
|
||||
|
||||
활성 영수증이 이미 있으면 금액 동기화로 위임하고, 잔여 발급액이 0(전액 환불)이면 활성 영수증 없음이 정상 결과이므로 `data: null` 과 함께 200 을 반환합니다. 구매확정 후에는 식별번호 암호문이 폐기되므로 복구할 수 없고 422(`REISSUE_FAILED`)를 반환합니다 — 이 경우 관리자가 발급 API(`POST .../cash-receipt`)로 식별번호를 다시 입력해야 합니다.
|
||||
|
||||
|
||||
### PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order}/confirm-deposit
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.orders.confirm-deposit -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.confirm-deposit`
|
||||
@@ -1613,6 +1895,184 @@ Content-Type: application/json
|
||||
**설명** 회원이 마이페이지에서 본인 주문(`id`)을 취소합니다. `auth:sanctum` + `sirsoft-ecommerce.user-orders.cancel` 권한이 필요하며, `User\OrderController@cancel`이 `items` 유무에 따라 `OrderCancellationService`의 `cancelOrderOptions()`(부분) 또는 `cancelOrder()`(전체)를 호출합니다. 취소자(`cancelledBy`)로 회원 본인 ID가 기록되고, `refund_priority`로 PG/포인트 환불 우선순위를 지정합니다. 취소 가능 상태의 주문만 취소되며, 취소 후 갱신된 주문을 `OrderResource`로 반환합니다.
|
||||
|
||||
|
||||
### GET /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.user.orders.cash-receipt.show -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.cash-receipt.show`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\CashReceiptController@show`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| issuable | boolean | 지금 발급이 가능한지 여부 (무통장 + 입금완료 + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨) |
|
||||
| cash_receipt | object\|null | 현재 활성 영수증 1건 (`CashReceiptResource`). 발급 전이거나 전액 취소된 경우 `null` |
|
||||
|
||||
`cash_receipt` 의 하위 필드 구성은 발급 API(`POST admin/orders/{order}/cash-receipt`)의 응답 필드 표와 동일합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
발급 전 (발급 가능):
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증 정보를 조회했습니다.",
|
||||
"data": {
|
||||
"issuable": true,
|
||||
"cash_receipt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
발급 완료:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증 정보를 조회했습니다.",
|
||||
"data": {
|
||||
"issuable": false,
|
||||
"cash_receipt": {
|
||||
"id": 12,
|
||||
"provider": "sirsoft-pay_tosspayments",
|
||||
"transaction_type": "issue",
|
||||
"receipt_type": "income",
|
||||
"receipt_type_label": "소득공제용",
|
||||
"amount": 12000,
|
||||
"amount_formatted": "12,000원",
|
||||
"tax_free_amount": 0,
|
||||
"tax_free_amount_formatted": "0원",
|
||||
"identifier_masked": "010****5678",
|
||||
"receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...",
|
||||
"issue_number": "CR20260710000012",
|
||||
"issue_status": "COMPLETED",
|
||||
"error_code": null,
|
||||
"error_message": null,
|
||||
"issued_at": "2026-07-10T14:32:11+09:00",
|
||||
"issued_at_formatted": "2026-07-10 14:32"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 회원이 마이페이지 주문상세에서 본인 주문(`id`)의 현금영수증 발급 상태를 조회합니다. `auth:sanctum` 인증이 필요하며, `User\CashReceiptController@show`가 클라이언트가 넘긴 사용자 식별자를 신뢰하지 않고 `Auth::id()` 로만 소유권을 판정합니다 — 타인 주문이면 404 를 반환합니다(존재 여부를 노출하지 않기 위해 403 이 아닌 404).
|
||||
|
||||
응답의 `data.issuable` 은 지금 발급이 가능한지 여부(무통장 + 입금완료 + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨)이고, `data.cash_receipt` 는 현재 활성 영수증 또는 발급 전이면 `null` 입니다. 주문상세 화면이 이 두 값으로 **[현금영수증 발급] 버튼**과 **[영수증 보기] 링크** 중 무엇을 보일지 결정합니다.
|
||||
|
||||
|
||||
### POST /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.user.orders.cash-receipt.issue -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.cash-receipt.issue`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\CashReceiptController@issue`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
| receipt_type | body | string | 예 | `income`, `expense` | 발급 용도 (income 소득공제용 — 개인 연말정산 / expense 지출증빙용 — 사업자 매입세액공제) |
|
||||
| identifier_type | body | string | 예 | `phone`, `card`, `business` | 발급 수단 (phone 휴대폰번호 / card 현금영수증카드번호 / business 사업자등록번호 — 사업자등록번호는 지출증빙 전용) |
|
||||
| identifier | body | string | 예 | max 30 | 식별번호 (하이픈·공백 제거 후 검증 — 휴대폰 10~11자리 / 현금영수증카드 13~19자리 / 사업자등록번호 10자리 체크섬) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"receipt_type": "income",
|
||||
"identifier_type": "phone",
|
||||
"identifier": "example-key"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
`data` 는 발급 이력 1건(`CashReceiptResource`)이며, 필드 구성은 관리자 발급 API(`POST admin/orders/{order}/cash-receipt`)의 응답 필드 표와 동일합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "현금영수증이 발급되었습니다.",
|
||||
"data": {
|
||||
"id": 12,
|
||||
"provider": "sirsoft-pay_tosspayments",
|
||||
"transaction_type": "issue",
|
||||
"receipt_type": "income",
|
||||
"receipt_type_label": "소득공제용",
|
||||
"amount": 12000,
|
||||
"amount_formatted": "12,000원",
|
||||
"tax_free_amount": 0,
|
||||
"tax_free_amount_formatted": "0원",
|
||||
"identifier_masked": "010****5678",
|
||||
"receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...",
|
||||
"issue_number": "CR20260710000012",
|
||||
"issue_status": "COMPLETED",
|
||||
"error_code": null,
|
||||
"error_message": null,
|
||||
"issued_at": "2026-07-10T14:32:11+09:00",
|
||||
"issued_at_formatted": "2026-07-10 14:32"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 회원이 주문 당시 신청하지 않은 현금영수증을 마이페이지 주문상세에서 **직접 사후 발급**합니다. `auth:sanctum` 인증이 필요하며, `User\CashReceiptController@issue`가 `Auth::id()` 로 소유권을 확인한 뒤(타인 주문이면 404) 관리자 발급과 **동일한 검증·가드·프로바이더 위임**을 거칩니다.
|
||||
|
||||
요청 본문(`receipt_type` / `identifier_type` / `identifier`)과 오류 코드 체계는 관리자 발급 API 와 같습니다 — 이미 발급된 주문은 409(`ALREADY_ISSUED`), 그 외 발급 불가 사유는 422 에 `errors.error_code` 로 구분해 담깁니다.
|
||||
|
||||
**발급 취소는 제공하지 않습니다** — 국세청 신고 정정을 동반하므로 관리자 전용이며, 유저용 `DELETE` 라우트 자체를 노출하지 않습니다(403 이 아니라 404/405).
|
||||
|
||||
|
||||
### POST /api/modules/sirsoft-ecommerce/user/orders/{id}/estimate-refund
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.user.orders.estimate-refund -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.estimate-refund`
|
||||
|
||||
@@ -26,12 +26,14 @@ use Modules\Sirsoft\Ecommerce\Listeners\EcommerceAdminActivityLogListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\EcommerceNotificationDataListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\EcommerceUserActivityLogListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\InjectAppConfigDeviceListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\IssueCashReceiptOnDepositListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\MergeCartOnLoginListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\MileageTransactionListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\OrderActivityLogListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\OrderStatusNotificationListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\ProductActivityLogListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\ProductInquiryBoardListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\PurgeCashReceiptIdentifierListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SearchProductsListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
|
||||
@@ -2100,6 +2102,8 @@ class Module extends AbstractModule
|
||||
EcommerceNotificationDataListener::class,
|
||||
OrderStatusNotificationListener::class,
|
||||
InjectAppConfigDeviceListener::class,
|
||||
IssueCashReceiptOnDepositListener::class,
|
||||
PurgeCashReceiptIdentifierListener::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ enum PaymentMethodEnum: string
|
||||
/**
|
||||
* 다국어 라벨을 반환합니다.
|
||||
*
|
||||
* @return string
|
||||
* @return string 결제수단 라벨
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
@@ -29,7 +29,7 @@ enum PaymentMethodEnum: string
|
||||
/**
|
||||
* 프론트엔드용 라벨 키를 반환합니다.
|
||||
*
|
||||
* @return string
|
||||
* @return string 결제수단 라벨
|
||||
*/
|
||||
public function getLabel(): string
|
||||
{
|
||||
@@ -39,7 +39,7 @@ enum PaymentMethodEnum: string
|
||||
/**
|
||||
* 모든 값 배열을 반환합니다.
|
||||
*
|
||||
* @return array
|
||||
* @return array<int, string> 결제수단 값 배열
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
@@ -49,7 +49,7 @@ enum PaymentMethodEnum: string
|
||||
/**
|
||||
* 프론트엔드용 옵션 배열을 반환합니다.
|
||||
*
|
||||
* @return array
|
||||
* @return array<int, array{value: string, label: string}> Select 옵션 배열
|
||||
*/
|
||||
public static function toSelectOptions(): array
|
||||
{
|
||||
@@ -65,7 +65,7 @@ enum PaymentMethodEnum: string
|
||||
* 실제 금전 결제인지 판별합니다. DBANK(무통장입금)도 실제 돈이 오가므로 true입니다.
|
||||
* POINT, DEPOSIT, FREE는 내부 처리이므로 false입니다.
|
||||
*
|
||||
* @return bool
|
||||
* @return bool PG 결제 여부
|
||||
*/
|
||||
public function isPgPayment(): bool
|
||||
{
|
||||
@@ -78,7 +78,7 @@ enum PaymentMethodEnum: string
|
||||
* DBANK(무통장입금)는 수동 입금확인이므로 PG 불필요.
|
||||
* POINT, DEPOSIT, FREE는 내부 처리이므로 PG 불필요.
|
||||
*
|
||||
* @return bool
|
||||
* @return bool PG사 선택 필요 여부
|
||||
*/
|
||||
public function needsPgProvider(): bool
|
||||
{
|
||||
@@ -89,4 +89,27 @@ enum PaymentMethodEnum: string
|
||||
self::FREE,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제수단의 현금성(현금영수증 발급 대상) 금액을 산정합니다.
|
||||
*
|
||||
* 무통장입금(dbank)만 구매자가 직접 현금을 입금하므로 실결제액 전액이 현금성이다.
|
||||
* 가상계좌(vbank)는 PG 가 현금영수증을 자동 발급하므로 0, 카드/계좌이체/휴대폰도 0,
|
||||
* 마일리지·예치금·무료는 애초에 현금 입금이 없으므로 0이다.
|
||||
*
|
||||
* 주문 생성 시점(OrderProcessingService)과 취소 재계산 시점(OrderAdjustmentService)이
|
||||
* 같은 규칙을 써야 부분환불 후 현금영수증 재발급액이 잔여 실결제액과 일치한다.
|
||||
* 이 메서드가 그 규칙의 SSoT 다.
|
||||
*
|
||||
* @param int $finalAmount 마일리지·예치금 차감 후 실결제액
|
||||
* @return int 현금성 금액 (음수 방지)
|
||||
*/
|
||||
public function resolveCashEquivalentAmount(int $finalAmount): int
|
||||
{
|
||||
if ($this !== self::DBANK) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, $finalAmount);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\IssueCashReceiptRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\CashReceiptResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
|
||||
/**
|
||||
* 관리자 현금영수증 컨트롤러
|
||||
*
|
||||
* 발급 프로바이더에 종속되지 않는다 — 실제 발급/취소는 CashReceiptService 가
|
||||
* cash_receipt.issue / cash_receipt.cancel 필터 훅으로 프로바이더 리스너에 위임한다.
|
||||
*/
|
||||
class CashReceiptController extends AdminBaseController
|
||||
{
|
||||
/**
|
||||
* @param CashReceiptService $cashReceiptService 현금영수증 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private CashReceiptService $cashReceiptService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 현금영수증을 발급합니다. (주문 시 미신청 건의 사후 발급 포함)
|
||||
*
|
||||
* @param IssueCashReceiptRequest $request 요청
|
||||
* @param Order $order 대상 주문
|
||||
* @return JsonResponse 응답
|
||||
*/
|
||||
public function issue(IssueCashReceiptRequest $request, Order $order): JsonResponse
|
||||
{
|
||||
$order->loadMissing('payment');
|
||||
|
||||
$blocker = $this->cashReceiptService->resolveIssueBlocker($order);
|
||||
|
||||
if ($blocker !== null) {
|
||||
// 이미 발급된 주문에 대한 재발급 요청은 409 로 구분한다 (중복 발급 방지).
|
||||
$status = $blocker === 'ALREADY_ISSUED' ? 409 : 422;
|
||||
$messageKey = $this->cashReceiptService->describeIssueBlocker($blocker);
|
||||
|
||||
return ResponseHelper::error($messageKey, $status, ['error_code' => $blocker]);
|
||||
}
|
||||
|
||||
$receipt = $this->cashReceiptService->issue(
|
||||
$order,
|
||||
$request->getReceiptType(),
|
||||
$request->getIdentifier(),
|
||||
$request->getIdentifierType(),
|
||||
);
|
||||
|
||||
if (! $receipt->isCompletedIssue()) {
|
||||
// 프로바이더가 준 상세 사유는 errors 로 내려보내고, 메시지는 다국어 키를 쓴다.
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-ecommerce::cash_receipt.errors.issue_failed',
|
||||
422,
|
||||
['error_code' => $receipt->error_code, 'error_message' => $receipt->error_message]
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseHelper::success(
|
||||
'sirsoft-ecommerce::cash_receipt.messages.issued',
|
||||
new CashReceiptResource($receipt)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급된 현금영수증을 전액 취소합니다.
|
||||
*
|
||||
* 부분취소 API 는 사용하지 않는다 — 금액이 바뀌는 경우는 reissue 가 담당한다.
|
||||
*
|
||||
* @param Order $order 대상 주문
|
||||
* @return JsonResponse 응답
|
||||
*/
|
||||
public function cancel(Order $order): JsonResponse
|
||||
{
|
||||
$order->loadMissing('payment');
|
||||
|
||||
if ($this->cashReceiptService->findActiveReceipt($order) === null) {
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-ecommerce::cash_receipt.errors.no_active_receipt',
|
||||
422,
|
||||
['error_code' => 'NO_ACTIVE_RECEIPT']
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->cashReceiptService->cancelAll($order)) {
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-ecommerce::cash_receipt.errors.cancel_failed',
|
||||
422,
|
||||
['error_code' => 'CANCEL_FAILED']
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseHelper::success('sirsoft-ecommerce::cash_receipt.messages.cancelled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증을 현재 주문 금액에 맞춰 재발급합니다. (재발급 실패 복구)
|
||||
*
|
||||
* 부분환불 직후 재발급이 실패해 "취소 성공 + 재발급 실패" 상태로 남은 주문을
|
||||
* 관리자가 수동으로 복구하는 경로다. 마지막 발급 이력의 용도와 저장된 암호문을 재사용하며,
|
||||
* 암호문이 폐기된 경우(구매확정 후)에는 관리자가 issue 로 식별번호를 재입력해야 한다.
|
||||
*
|
||||
* @param Order $order 대상 주문
|
||||
* @return JsonResponse 응답
|
||||
*/
|
||||
public function reissue(Order $order): JsonResponse
|
||||
{
|
||||
$order->loadMissing('payment');
|
||||
|
||||
$receipt = $this->cashReceiptService->recoverFailedIssue($order);
|
||||
|
||||
// 발급 대상 금액이 0 이면(전액 환불 등) 활성 영수증이 없는 것이 정상 결과다.
|
||||
// 그 외에 활성 영수증이 없으면 복구가 실패한 것이다.
|
||||
if ($receipt === null && $this->cashReceiptService->calculateIssuableAmount($order)['amount'] > 0) {
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-ecommerce::cash_receipt.errors.issue_failed',
|
||||
422,
|
||||
['error_code' => 'REISSUE_FAILED']
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseHelper::success(
|
||||
'sirsoft-ecommerce::cash_receipt.messages.reissued',
|
||||
$receipt ? new CashReceiptResource($receipt) : null
|
||||
);
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Public;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\IssueCashReceiptRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\ShowCashReceiptRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\CashReceiptResource;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
|
||||
/**
|
||||
* 비회원 현금영수증 컨트롤러 (조회 토큰 보호)
|
||||
*
|
||||
* 주문 소유권은 VerifyGuestOrderToken 미들웨어가 검증한다 — 토큰이 지목한 주문 외에는 접근할 수 없다.
|
||||
* 회원 경로는 User\CashReceiptController 가 담당하며, 발급 취소는 양쪽 모두 제공하지 않는다(관리자 전용).
|
||||
*/
|
||||
class CashReceiptController extends PublicBaseController
|
||||
{
|
||||
/**
|
||||
* @param CashReceiptService $cashReceiptService 현금영수증 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private CashReceiptService $cashReceiptService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 비회원 주문의 현금영수증 발급 상태를 조회합니다.
|
||||
*
|
||||
* @param ShowCashReceiptRequest $request 요청 (토큰 검증된 주문 보유)
|
||||
* @return JsonResponse 응답
|
||||
*/
|
||||
public function show(ShowCashReceiptRequest $request): JsonResponse
|
||||
{
|
||||
$order = $request->getOrder();
|
||||
$order->loadMissing('payment');
|
||||
|
||||
$receipt = $this->cashReceiptService->findActiveReceipt($order);
|
||||
|
||||
return ResponseHelper::success('sirsoft-ecommerce::cash_receipt.messages.status_retrieved', [
|
||||
'issuable' => $this->cashReceiptService->resolveIssueBlocker($order) === null,
|
||||
'cash_receipt' => $receipt ? new CashReceiptResource($receipt) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 주문에 현금영수증을 사후 발급합니다.
|
||||
*
|
||||
* @param IssueCashReceiptRequest $request 요청 (토큰 검증된 주문 보유)
|
||||
* @return JsonResponse 응답
|
||||
*/
|
||||
public function issue(IssueCashReceiptRequest $request): JsonResponse
|
||||
{
|
||||
$order = $request->getOrder();
|
||||
$order->loadMissing('payment');
|
||||
|
||||
$blocker = $this->cashReceiptService->resolveIssueBlocker($order);
|
||||
|
||||
if ($blocker !== null) {
|
||||
$status = $blocker === 'ALREADY_ISSUED' ? 409 : 422;
|
||||
$messageKey = $this->cashReceiptService->describeIssueBlocker($blocker);
|
||||
|
||||
return ResponseHelper::error($messageKey, $status, ['error_code' => $blocker]);
|
||||
}
|
||||
|
||||
$receipt = $this->cashReceiptService->issue(
|
||||
$order,
|
||||
$request->getReceiptType(),
|
||||
$request->getIdentifier(),
|
||||
$request->getIdentifierType(),
|
||||
);
|
||||
|
||||
if (! $receipt->isCompletedIssue()) {
|
||||
// 프로바이더가 준 상세 사유는 errors 로 내려보내고, 메시지는 다국어 키를 쓴다.
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-ecommerce::cash_receipt.errors.issue_failed',
|
||||
422,
|
||||
['error_code' => $receipt->error_code, 'error_message' => $receipt->error_message]
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseHelper::success(
|
||||
'sirsoft-ecommerce::cash_receipt.messages.issued',
|
||||
new CashReceiptResource($receipt)
|
||||
);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Controllers\User;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AuthBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\IssueCashReceiptRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\CashReceiptResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderService;
|
||||
|
||||
/**
|
||||
* 회원 현금영수증 컨트롤러
|
||||
*
|
||||
* 주문 당시 미신청 건을 구매자가 직접 사후 발급한다.
|
||||
* 발급 취소는 제공하지 않는다 — 국세청 신고 정정을 동반하므로 관리자 전용이다(D12).
|
||||
*/
|
||||
class CashReceiptController extends AuthBaseController
|
||||
{
|
||||
/**
|
||||
* @param CashReceiptService $cashReceiptService 현금영수증 서비스
|
||||
* @param OrderService $orderService 주문 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private CashReceiptService $cashReceiptService,
|
||||
private OrderService $orderService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 본인 주문의 현금영수증 발급 상태를 조회합니다.
|
||||
*
|
||||
* @param int $id 주문 ID
|
||||
* @return JsonResponse 응답
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$this->logApiUsage('user.cash-receipt.show');
|
||||
|
||||
$order = $this->findOwnedOrder($id);
|
||||
|
||||
if ($order === null) {
|
||||
return ResponseHelper::moduleError('sirsoft-ecommerce', 'exceptions.order_not_found', 404);
|
||||
}
|
||||
|
||||
$receipt = $this->cashReceiptService->findActiveReceipt($order);
|
||||
|
||||
return ResponseHelper::success('sirsoft-ecommerce::cash_receipt.messages.status_retrieved', [
|
||||
'issuable' => $this->cashReceiptService->resolveIssueBlocker($order) === null,
|
||||
'cash_receipt' => $receipt ? new CashReceiptResource($receipt) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 본인 주문에 현금영수증을 사후 발급합니다.
|
||||
*
|
||||
* @param IssueCashReceiptRequest $request 요청
|
||||
* @param int $id 주문 ID
|
||||
* @return JsonResponse 응답
|
||||
*/
|
||||
public function issue(IssueCashReceiptRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$this->logApiUsage('user.cash-receipt.issue');
|
||||
|
||||
$order = $this->findOwnedOrder($id);
|
||||
|
||||
if ($order === null) {
|
||||
return ResponseHelper::moduleError('sirsoft-ecommerce', 'exceptions.order_not_found', 404);
|
||||
}
|
||||
|
||||
$blocker = $this->cashReceiptService->resolveIssueBlocker($order);
|
||||
|
||||
if ($blocker !== null) {
|
||||
$status = $blocker === 'ALREADY_ISSUED' ? 409 : 422;
|
||||
$messageKey = $this->cashReceiptService->describeIssueBlocker($blocker);
|
||||
|
||||
return ResponseHelper::error($messageKey, $status, ['error_code' => $blocker]);
|
||||
}
|
||||
|
||||
$receipt = $this->cashReceiptService->issue(
|
||||
$order,
|
||||
$request->getReceiptType(),
|
||||
$request->getIdentifier(),
|
||||
$request->getIdentifierType(),
|
||||
);
|
||||
|
||||
if (! $receipt->isCompletedIssue()) {
|
||||
// 프로바이더가 준 상세 사유는 errors 로 내려보내고, 메시지는 다국어 키를 쓴다.
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-ecommerce::cash_receipt.errors.issue_failed',
|
||||
422,
|
||||
['error_code' => $receipt->error_code, 'error_message' => $receipt->error_message]
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseHelper::success(
|
||||
'sirsoft-ecommerce::cash_receipt.messages.issued',
|
||||
new CashReceiptResource($receipt)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인한 회원 본인 소유의 주문을 조회합니다.
|
||||
*
|
||||
* 클라이언트가 넘긴 사용자 식별자를 신뢰하지 않고 Auth::id() 로만 소유권을 판정한다.
|
||||
*
|
||||
* @param int $id 주문 ID
|
||||
* @return Order|null 본인 주문 (아니면 null)
|
||||
*/
|
||||
private function findOwnedOrder(int $id)
|
||||
{
|
||||
$order = $this->orderService->getDetail($id);
|
||||
|
||||
if (! $order || $order->user_id !== Auth::id()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$order->loadMissing('payment');
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ValidatesCashReceiptIssue;
|
||||
|
||||
/**
|
||||
* 현금영수증 발급 요청 (관리자)
|
||||
*
|
||||
* 주문 당시 미신청 건에 대한 사후 발급과, 재발급 실패 복구 후의 수동 발급에 사용된다.
|
||||
* 권한은 라우트 미들웨어(permission:admin,sirsoft-ecommerce.orders.update)에서 처리한다.
|
||||
*/
|
||||
class IssueCashReceiptRequest extends FormRequest
|
||||
{
|
||||
use ValidatesCashReceiptIssue;
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Concerns;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Rules\CashReceiptIdentifier;
|
||||
|
||||
/**
|
||||
* 현금영수증 발급 요청의 공통 검증 트레이트
|
||||
*
|
||||
* 관리자 발급과 유저 사후 발급은 검증 규칙이 동일하다(권한만 다르며 권한은 미들웨어/컨트롤러가 담당).
|
||||
* 발급 가능 조건(dbank·결제완료·미발급·발급액>0) 가드는 주문을 알아야 하므로 컨트롤러가 아닌
|
||||
* FormRequest 에서 처리할 수 없다 — 각 컨트롤러가 CashReceiptService 의 결과로 판정한다.
|
||||
*/
|
||||
trait ValidatesCashReceiptIssue
|
||||
{
|
||||
/**
|
||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인 (권한은 미들웨어/컨트롤러에서 처리)
|
||||
*
|
||||
* @return bool 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에 적용할 검증 규칙
|
||||
*
|
||||
* @return array<string, array<int, mixed>> 검증 규칙 배열
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'receipt_type' => ['required', 'string', 'in:'.implode(',', CashReceiptType::values())],
|
||||
'identifier_type' => ['required', 'string', 'in:'.implode(',', CashReceiptIdentifierType::values())],
|
||||
'identifier' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:30',
|
||||
new CashReceiptIdentifier($this->resolveReceiptType(), $this->resolveIdentifierType()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 필드의 사용자 표시명
|
||||
*
|
||||
* @return array<string, string> 필드명 → 표시명 매핑
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'receipt_type' => __('sirsoft-ecommerce::cash_receipt.attributes.receipt_type'),
|
||||
'identifier_type' => __('sirsoft-ecommerce::cash_receipt.attributes.identifier_type'),
|
||||
'identifier' => __('sirsoft-ecommerce::cash_receipt.attributes.identifier'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 실패 시 응답 커스터마이징
|
||||
*
|
||||
* @param Validator $validator 검증기
|
||||
*
|
||||
* @throws ValidationException 검증 실패 시
|
||||
*/
|
||||
protected function failedValidation(Validator $validator): void
|
||||
{
|
||||
throw new ValidationException($validator, ResponseHelper::error(
|
||||
$validator->errors()->first(),
|
||||
422,
|
||||
$validator->errors()->toArray()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급 용도를 반환합니다.
|
||||
*
|
||||
* @return CashReceiptType 발급 용도
|
||||
*/
|
||||
public function getReceiptType(): CashReceiptType
|
||||
{
|
||||
return CashReceiptType::from($this->validated('receipt_type'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 식별번호 종류를 반환합니다.
|
||||
*
|
||||
* @return CashReceiptIdentifierType 식별번호 종류
|
||||
*/
|
||||
public function getIdentifierType(): CashReceiptIdentifierType
|
||||
{
|
||||
return CashReceiptIdentifierType::from($this->validated('identifier_type'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 하이픈·공백이 제거된 식별번호 원본을 반환합니다.
|
||||
*
|
||||
* @return string 정규화된 식별번호
|
||||
*/
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return CashReceiptIdentifier::normalize((string) $this->validated('identifier'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 전 입력에서 발급 용도를 해석합니다. (Rule 생성자 주입용)
|
||||
*
|
||||
* @return CashReceiptType|null 해석된 용도 (미해석 시 null)
|
||||
*/
|
||||
private function resolveReceiptType(): ?CashReceiptType
|
||||
{
|
||||
$value = $this->input('receipt_type');
|
||||
|
||||
return is_string($value) ? CashReceiptType::tryFrom($value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 전 입력에서 식별번호 종류를 해석합니다. (Rule 생성자 주입용)
|
||||
*
|
||||
* @return CashReceiptIdentifierType|null 해석된 종류 (미해석 시 null)
|
||||
*/
|
||||
private function resolveIdentifierType(): ?CashReceiptIdentifierType
|
||||
{
|
||||
$value = $this->input('identifier_type');
|
||||
|
||||
return is_string($value) ? CashReceiptIdentifierType::tryFrom($value) : null;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Public;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ValidatesCashReceiptIssue;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
|
||||
/**
|
||||
* 현금영수증 발급 요청 (유저 사후 발급 — 회원/비회원 공용)
|
||||
*
|
||||
* 주문 당시 미신청 건을 구매자가 주문상세에서 직접 발급할 때 사용된다.
|
||||
* 비회원 경로의 소유권은 VerifyGuestOrderToken 미들웨어가, 회원 경로는 컨트롤러가 검증한다.
|
||||
* 발급 취소는 유저에게 허용되지 않는다(관리자 전용).
|
||||
*/
|
||||
class IssueCashReceiptRequest extends FormRequest
|
||||
{
|
||||
use ValidatesCashReceiptIssue;
|
||||
|
||||
/**
|
||||
* 미들웨어가 검증한 비회원 주문을 반환합니다.
|
||||
*
|
||||
* @return Order 토큰 검증을 통과한 비회원 주문
|
||||
*/
|
||||
public function getOrder(): Order
|
||||
{
|
||||
$order = $this->attributes->get('guest_order');
|
||||
|
||||
if (! $order instanceof Order) {
|
||||
abort(ResponseHelper::moduleError('sirsoft-ecommerce', 'exceptions.order_not_found', 404));
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Public;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
|
||||
/**
|
||||
* 비회원 현금영수증 상태 조회 요청
|
||||
*
|
||||
* 주문 소유권은 VerifyGuestOrderToken 미들웨어가 검증하며, 본 요청은 입력을 받지 않는다.
|
||||
*/
|
||||
class ShowCashReceiptRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
||||
*
|
||||
* @return bool 항상 true (실제 인증은 VerifyGuestOrderToken 미들웨어가 수행)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에 적용할 검증 규칙
|
||||
*
|
||||
* @return array<string, array<int, string>> 검증 규칙 배열 (입력 없음)
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 미들웨어가 검증한 대상 주문을 반환합니다.
|
||||
*
|
||||
* @return Order 토큰 검증을 통과한 비회원 주문
|
||||
*/
|
||||
public function getOrder(): Order
|
||||
{
|
||||
$order = $this->attributes->get('guest_order');
|
||||
|
||||
if (! $order instanceof Order) {
|
||||
abort(ResponseHelper::moduleError('sirsoft-ecommerce', 'exceptions.order_not_found', 404));
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Resources;
|
||||
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
|
||||
/**
|
||||
* 현금영수증 이력 리소스
|
||||
*
|
||||
* 식별번호는 마스킹 값만 노출한다 — 원본 평문·암호문은 어떤 응답에도 포함되지 않는다.
|
||||
* 프로바이더 원응답(raw_response)도 노출하지 않는다.
|
||||
*/
|
||||
class CashReceiptResource extends BaseApiResource
|
||||
{
|
||||
use HasMultiCurrencyPrices;
|
||||
|
||||
/**
|
||||
* 리소스를 배열로 변환
|
||||
*
|
||||
* @param Request $request 요청
|
||||
* @return array 현금영수증 정보 배열
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'provider' => $this->provider,
|
||||
'transaction_type' => $this->transaction_type,
|
||||
'receipt_type' => $this->receipt_type,
|
||||
'receipt_type_label' => $this->receipt_type?->label(),
|
||||
'amount' => $this->roundToOrderCurrency($this->amount),
|
||||
'amount_formatted' => $this->formatOrderCurrency($this->amount),
|
||||
'tax_free_amount' => $this->roundToOrderCurrency($this->tax_free_amount),
|
||||
'tax_free_amount_formatted' => $this->formatOrderCurrency($this->tax_free_amount),
|
||||
'identifier_masked' => $this->identifier_masked,
|
||||
'receipt_url' => $this->receipt_url,
|
||||
'issue_number' => $this->issue_number,
|
||||
'issue_status' => $this->issue_status,
|
||||
'error_code' => $this->error_code,
|
||||
'error_message' => $this->error_message,
|
||||
// 머신 ISO8601 — 표시는 issued_at_formatted 사용
|
||||
'issued_at' => $this->issued_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses issued_at_formatted sibling
|
||||
'issued_at_formatted' => $this->formatDateTimeStringForUser($this->issued_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,22 @@ class OrderPaymentResource extends BaseApiResource
|
||||
'deposit_due_at' => $this->deposit_due_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses deposit_due_at_formatted sibling
|
||||
'deposit_due_at_formatted' => $this->formatDateTimeStringForUser($this->deposit_due_at),
|
||||
|
||||
// 현금영수증
|
||||
// 현금영수증 (cash_receipt_identifier 는 마스킹 값 — 원본/암호문은 노출하지 않는다)
|
||||
'is_cash_receipt_requested' => (bool) $this->is_cash_receipt_requested,
|
||||
'is_cash_receipt_issued' => (bool) $this->is_cash_receipt_issued,
|
||||
'cash_receipt_type' => $this->cash_receipt_type,
|
||||
'cash_receipt_identifier_type' => $this->cash_receipt_identifier_type,
|
||||
'cash_receipt_identifier' => $this->cash_receipt_identifier,
|
||||
// 머신 ISO8601 — 표시는 cash_receipt_issued_at_formatted 사용
|
||||
'cash_receipt_issued_at' => $this->cash_receipt_issued_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses cash_receipt_issued_at_formatted sibling
|
||||
'cash_receipt_issued_at_formatted' => $this->formatDateTimeStringForUser($this->cash_receipt_issued_at),
|
||||
|
||||
// PG 영수증 URL (카드 매출전표 등) — 현금영수증 URL 과는 다른 값이며 서로 덮어쓰지 않는다.
|
||||
// 현금영수증 URL 은 주문의 cash_receipt.receipt_url 을 쓴다.
|
||||
// receipt_url 은 기존 소비자(KG이니시스 영수증 팝업)가 참조하므로 키를 유지하고,
|
||||
// 의미가 분명한 pg_receipt_url 을 함께 노출한다.
|
||||
'receipt_url' => $this->receipt_url,
|
||||
'pg_receipt_url' => $this->receipt_url,
|
||||
|
||||
// 결제수단별 계좌/카드 요약 정보
|
||||
'account_info' => $this->getAccountInfo(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Helpers\PermissionHelper;
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
|
||||
|
||||
/**
|
||||
@@ -179,6 +180,19 @@ class OrderResource extends BaseApiResource
|
||||
fn ($r) => $r->withOrderCurrency($orderCurrency)
|
||||
)),
|
||||
|
||||
// 현금영수증 — 현재 활성 영수증 1건(없으면 null). 발급 카드의 "발급완료" 상태 근거.
|
||||
'cash_receipt' => $this->whenLoaded('cashReceipts', function () use ($orderCurrency) {
|
||||
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
|
||||
|
||||
return $active
|
||||
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)
|
||||
: null;
|
||||
}),
|
||||
// 발급/취소 전체 이력 — 관리자 화면의 "취소 성공 + 재발급 실패" 경고 배지 판정 근거.
|
||||
'cash_receipts' => $this->whenLoaded('cashReceipts', fn () => CashReceiptResource::collection(
|
||||
$this->cashReceipts->sortByDesc('id')->values()
|
||||
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency))),
|
||||
|
||||
// 배송 정보
|
||||
'shippings' => $this->whenLoaded('shippings', fn () => OrderShippingResource::collection($this->shippings)->each(
|
||||
fn ($r) => $r->withOrderCurrency($orderCurrency)
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
|
||||
/**
|
||||
* 입금완료 시 현금영수증 자동 발급 리스너 (D11)
|
||||
*
|
||||
* 무통장(dbank) 주문의 입금이 확인되는 두 경로를 모두 구독한다:
|
||||
* - order.after_payment_complete : 관리자 결제완료 전이 · 가상계좌 웹훅
|
||||
* - order.after_deposit_recorded : 관리자 "입금만 기록"(결제완료 전이 없이 payment 만 PAID)
|
||||
*
|
||||
* 두 번째 훅이 없으면 "입금만 기록" 경로에서 자동발급이 통째로 누락된다.
|
||||
* 두 훅이 한 입금에 모두 도달할 수 있으나 CashReceiptService::issue() 의 활성 영수증 가드가 멱등을 보장한다.
|
||||
*
|
||||
* 큐 처리(sync 미지정 = 기본 큐)를 유지한다. 이 리스너는 외부 발급 API 를 호출하므로
|
||||
* 입금확인 트랜잭션을 외부 지연·실패에 묶으면 입금확인 자체가 실패한다.
|
||||
* 발급 실패는 이력 테이블에 FAILED 로 남아 관리자 화면의 수동 재발급 경로로 복구된다.
|
||||
*/
|
||||
class IssueCashReceiptOnDepositListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* @param CashReceiptService $cashReceiptService 현금영수증 발급 서비스
|
||||
* @param EcommerceSettingsService $settingsService 이커머스 환경설정 서비스
|
||||
* @param OrderPaymentRepositoryInterface $paymentRepository 주문 결제 Repository
|
||||
*/
|
||||
public function __construct(
|
||||
protected CashReceiptService $cashReceiptService,
|
||||
protected EcommerceSettingsService $settingsService,
|
||||
protected OrderPaymentRepositoryInterface $paymentRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 구독할 훅 목록 반환
|
||||
*
|
||||
* @return array 훅 구독 정의
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'sirsoft-ecommerce.order.after_payment_complete' => [
|
||||
'method' => 'handleDeposit',
|
||||
'priority' => 50,
|
||||
],
|
||||
'sirsoft-ecommerce.order.after_deposit_recorded' => [
|
||||
'method' => 'handleDeposit',
|
||||
'priority' => 50,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드)
|
||||
*
|
||||
* @param mixed ...$args 훅 인자
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 개별 메서드에서 처리하므로 빈 구현
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금이 확인된 주문에 현금영수증을 발급합니다.
|
||||
*
|
||||
* 두 훅의 인자 수가 다르므로(after_deposit_recorded 는 $amount 를 추가로 넘긴다)
|
||||
* 첫 인자만 사용하고 나머지는 무시한다.
|
||||
*
|
||||
* @param Order $order 입금이 확인된 주문
|
||||
*/
|
||||
public function handleDeposit(Order $order): void
|
||||
{
|
||||
try {
|
||||
$order->loadMissing('payment');
|
||||
|
||||
// 발급 가능 조건 단일 가드 — 무통장·결제완료·미발급·프로바이더 설정·발급액>0.
|
||||
// 발급액 검사가 "무통장 + 전액 마일리지" 0원 주문을 걸러낸다: $isZeroPayable 판정은
|
||||
// 결제수단과 무관하게 잔여 결제액만 보므로 그 주문도 after_payment_complete 를 발화시킨다.
|
||||
$blocker = $this->cashReceiptService->resolveIssueBlocker($order);
|
||||
|
||||
if ($blocker !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $this->resolveIssueRequest($order);
|
||||
|
||||
// 구매자가 신청하지 않았고 자진발급도 꺼져 있으면 발급하지 않는다.
|
||||
if ($request === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$type, $identifier, $identifierType] = $request;
|
||||
|
||||
$receipt = $this->cashReceiptService->issue($order, $type, $identifier, $identifierType);
|
||||
|
||||
if (! $receipt->isCompletedIssue()) {
|
||||
Log::warning('IssueCashReceiptOnDepositListener: 자동 발급 실패 — 관리자 수동 발급 필요', [
|
||||
'order_id' => $order->id,
|
||||
'error_code' => $receipt->error_code,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 큐 재시도로 중복 발급되지 않도록(멱등 가드는 있으나) 예외를 삼키고 로그만 남긴다.
|
||||
Log::error('IssueCashReceiptOnDepositListener: 자동 발급 처리 중 예외', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급에 사용할 용도·식별번호를 결정합니다.
|
||||
*
|
||||
* 구매자가 주문 시 신청했으면 저장된 신청 정보(암호문 복호화)를 쓰고,
|
||||
* 신청하지 않았으면 자진발급 설정이 켜진 경우에만 국세청 지정번호로 소득공제 발급한다.
|
||||
* 자진발급은 제도상 소득공제용만 가능하다.
|
||||
*
|
||||
* @param Order $order 주문 모델
|
||||
* @return array{0: CashReceiptType, 1: string, 2: CashReceiptIdentifierType|null}|null 발급 인자 (발급 대상 아니면 null)
|
||||
*/
|
||||
private function resolveIssueRequest(Order $order): ?array
|
||||
{
|
||||
$payment = $order->payment;
|
||||
|
||||
if ($payment?->is_cash_receipt_requested) {
|
||||
$identifier = $this->paymentRepository->resolveCashReceiptIdentifier($payment);
|
||||
$type = $payment->getCashReceiptType();
|
||||
|
||||
// 신청은 했으나 식별번호를 복호화할 수 없으면 자진발급으로 대체하지 않는다.
|
||||
// 구매자가 지정한 식별번호로만 발급해야 하므로 관리자 수동 발급 경로로 남긴다.
|
||||
if ($identifier === null || $type === null) {
|
||||
Log::warning('IssueCashReceiptOnDepositListener: 신청 정보 복호화 불가 — 관리자 수동 발급 필요', [
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$type, $identifier, $payment->cash_receipt_identifier_type];
|
||||
}
|
||||
|
||||
if (! $this->settingsService->isCashReceiptSelfIssueEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
CashReceiptType::INCOME,
|
||||
CashReceiptIdentifierType::SELF_ISSUE_NUMBER,
|
||||
CashReceiptIdentifierType::PHONE,
|
||||
];
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
|
||||
/**
|
||||
* 구매확정 시 재발급용 식별번호 암호문 폐기 리스너 (D15)
|
||||
*
|
||||
* 구매확정 이후에는 환불·재발급이 사실상 발생하지 않으므로, 개인정보 최소보유 원칙에 따라
|
||||
* 재발급용으로만 보관하던 식별번호 암호문(cash_receipt_identifier_encrypted)을 즉시 지운다.
|
||||
*
|
||||
* 이력 테이블·영수증 URL·마스킹 식별번호는 유지된다 (국세청 신고 근거).
|
||||
* 폐기 후 재발급이 필요해지면 관리자가 식별번호를 재입력해 발급한다.
|
||||
*
|
||||
* 서비스 간 직접 결합을 피하기 위해 훅(order.after_purchase_confirmed)을 경유한다.
|
||||
*/
|
||||
class PurgeCashReceiptIdentifierListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* @param CashReceiptService $cashReceiptService 현금영수증 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
protected CashReceiptService $cashReceiptService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 구독할 훅 목록 반환
|
||||
*
|
||||
* @return array 훅 구독 정의
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'sirsoft-ecommerce.order.after_purchase_confirmed' => [
|
||||
'method' => 'purgeIdentifier',
|
||||
'priority' => 50,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드)
|
||||
*
|
||||
* @param mixed ...$args 훅 인자
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 개별 메서드에서 처리하므로 빈 구현
|
||||
}
|
||||
|
||||
/**
|
||||
* 구매확정된 주문의 식별번호 암호문을 폐기합니다.
|
||||
*
|
||||
* 이미 폐기되었거나 애초에 저장된 적 없는 경우에도 안전하다 (멱등).
|
||||
*
|
||||
* @param Order $order 구매확정된 주문
|
||||
*/
|
||||
public function purgeIdentifier(Order $order): void
|
||||
{
|
||||
try {
|
||||
$order->loadMissing('payment');
|
||||
|
||||
$this->cashReceiptService->purgeIdentifier($order);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('PurgeCashReceiptIdentifierListener: 식별번호 폐기 실패', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,4 +103,45 @@ class OrderCashReceipt extends Model
|
||||
return (int) $this->amount === $amount
|
||||
&& (int) $this->tax_free_amount === $taxFreeAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이력 컬렉션에서 활성 영수증만 최신순으로 골라냅니다.
|
||||
*
|
||||
* "활성" = 발급 완료(COMPLETED) 이면서 같은 receipt_key 의 취소 완료 이력이 없는 건.
|
||||
* receipt_key 가 없는 발급 건은 취소 대상을 특정할 수 없으므로 활성으로 보지 않는다.
|
||||
*
|
||||
* Repository(DB 조회)와 Resource(로드된 컬렉션) 양쪽이 같은 판정을 쓰도록
|
||||
* 활성 판정 규칙은 이 메서드가 단일 SSoT 다.
|
||||
*
|
||||
* @param iterable<int, self> $receipts 발급/취소 이력 (정렬 무관)
|
||||
* @return array<int, self> 활성 영수증 (id 내림차순)
|
||||
*/
|
||||
public static function filterActive(iterable $receipts): array
|
||||
{
|
||||
$rows = collect($receipts)
|
||||
->filter(fn (self $row) => $row->issue_status === CashReceiptIssueStatus::COMPLETED)
|
||||
->sortByDesc('id');
|
||||
|
||||
$cancelledKeys = $rows
|
||||
->filter(fn (self $row) => $row->transaction_type === CashReceiptTransactionType::CANCEL)
|
||||
->pluck('receipt_key')
|
||||
->filter()
|
||||
->unique()
|
||||
->all();
|
||||
|
||||
return $rows
|
||||
->filter(function (self $row) use ($cancelledKeys) {
|
||||
if ($row->transaction_type !== CashReceiptTransactionType::ISSUE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($row->receipt_key === null || $row->receipt_key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! in_array($row->receipt_key, $cancelledKeys, true);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ class OrderCashReceiptRepository implements OrderCashReceiptRepositoryInterface
|
||||
*/
|
||||
public function findActiveReceipts(Order $order): Collection
|
||||
{
|
||||
// DB 에서 후보(발급 완료 건 + 취소 완료 건)만 좁힌 뒤,
|
||||
// 활성 판정은 OrderCashReceipt::filterActive() SSoT 에 위임한다.
|
||||
$rows = $this->model->newQuery()
|
||||
->where('order_id', $order->id)
|
||||
->whereIn('transaction_type', [
|
||||
@@ -60,28 +62,7 @@ class OrderCashReceiptRepository implements OrderCashReceiptRepositoryInterface
|
||||
->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());
|
||||
return $this->model->newCollection(OrderCashReceipt::filterActive($rows));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,20 +24,18 @@ class OrderPaymentRepository implements OrderPaymentRepositoryInterface
|
||||
string $identifierMasked,
|
||||
?CashReceiptIdentifierType $identifierType,
|
||||
): OrderPayment {
|
||||
$attributes = [
|
||||
// receipt_url 은 건드리지 않는다 — 그 컬럼은 PG 영수증(카드 매출전표) URL 전용이고
|
||||
// PG 플러그인이 결제 승인 시 기록한다. 현금영수증 URL 은 이력 테이블
|
||||
// (ecommerce_order_cash_receipts.receipt_url) 이 보관하며 OrderResource 가 따로 노출한다.
|
||||
// 여기서 덮어쓰면 카드 매출전표 URL 이 유실되고 한 컬럼이 두 의미를 갖게 된다.
|
||||
$payment->update([
|
||||
'is_cash_receipt_issued' => true,
|
||||
'cash_receipt_type' => $type->value,
|
||||
'cash_receipt_identifier' => $identifierMasked,
|
||||
'cash_receipt_identifier_type' => $identifierType,
|
||||
'cash_receipt_identifier_encrypted' => $identifier,
|
||||
'cash_receipt_issued_at' => $receipt->issued_at,
|
||||
];
|
||||
|
||||
if ($receipt->receipt_url !== null) {
|
||||
$attributes['receipt_url'] = $receipt->receipt_url;
|
||||
}
|
||||
|
||||
$payment->update($attributes);
|
||||
]);
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
|
||||
/**
|
||||
* 현금영수증 식별번호 검증 Custom Rule
|
||||
*
|
||||
* 용도(소득공제/지출증빙)와 식별번호 종류(휴대폰/현금영수증카드/사업자등록번호)의 조합을 먼저 검증한 뒤,
|
||||
* 해당 종류의 형식(정규식 또는 사업자등록번호 체크섬)을 검증한다.
|
||||
*
|
||||
* 형식 규칙과 조합 허용표는 Enum 이 SSoT 다 — CashReceiptType::allowedIdentifierTypes() 와
|
||||
* CashReceiptIdentifierType::matches() 를 그대로 위임 호출하며 본 Rule 은 어떤 규칙도 복제하지 않는다.
|
||||
*
|
||||
* 자진발급 지정번호(0100001234)는 소득공제용 전용이므로 지출증빙 용도에서는 거부한다.
|
||||
*/
|
||||
class CashReceiptIdentifier implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param CashReceiptType|null $type 발급 용도 (미해석 시 조합 검증 생략)
|
||||
* @param CashReceiptIdentifierType|null $identifierType 식별번호 종류 (미해석 시 형식 검증 생략)
|
||||
*/
|
||||
public function __construct(
|
||||
private ?CashReceiptType $type,
|
||||
private ?CashReceiptIdentifierType $identifierType,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 검증을 수행합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 속성명
|
||||
* @param mixed $value 검증 대상 값 (식별번호)
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
$fail(__('sirsoft-ecommerce::cash_receipt.validation.identifier_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 용도·종류가 각각의 in: 규칙에서 이미 걸러졌다면 여기서 중복 오류를 내지 않는다.
|
||||
if ($this->type === null || $this->identifierType === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = self::normalize($value);
|
||||
|
||||
// 자진발급 지정번호는 소득공제용 전용 (지출증빙 자진발급은 제도상 불가).
|
||||
if (CashReceiptIdentifierType::isSelfIssueNumber($normalized)) {
|
||||
if ($this->type !== CashReceiptType::INCOME) {
|
||||
$fail(__('sirsoft-ecommerce::cash_receipt.validation.self_issue_income_only'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 용도 × 식별번호 종류 조합 (지출증빙 + 휴대폰번호는 허용된다).
|
||||
if (! in_array($this->identifierType, $this->type->allowedIdentifierTypes(), true)) {
|
||||
$fail(__('sirsoft-ecommerce::cash_receipt.validation.identifier_type_not_allowed', [
|
||||
'type' => $this->type->label(),
|
||||
'identifier_type' => $this->identifierType->label(),
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->identifierType->matches($normalized)) {
|
||||
$fail(__('sirsoft-ecommerce::cash_receipt.validation.identifier_format.'.$this->identifierType->value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 하이픈·공백을 제거해 식별번호를 정규화합니다.
|
||||
*
|
||||
* @param string $identifier 원본 식별번호
|
||||
* @return string 정규화된 식별번호
|
||||
*/
|
||||
public static function normalize(string $identifier): string
|
||||
{
|
||||
return preg_replace('/[\s-]/', '', $identifier) ?? '';
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
@@ -83,6 +85,15 @@ class CashReceiptService
|
||||
);
|
||||
}
|
||||
|
||||
// 멱등: 이미 활성 영수증이 있으면 중복 발급하지 않는다.
|
||||
// 자동발급 리스너가 두 훅(after_payment_complete / after_deposit_recorded)에 동시 구독되어
|
||||
// 한 입금에 두 번 도달할 수 있고, 관리자/유저가 동시에 발급을 눌러도 여기서 걸린다.
|
||||
$existing = $this->repository->findActiveReceipt($order);
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$target = $this->calculateIssuableAmount($order);
|
||||
|
||||
if ($target['amount'] <= 0) {
|
||||
@@ -294,6 +305,73 @@ class CashReceiptService
|
||||
$this->issue($order, $type, $identifier, $order->payment?->cash_receipt_identifier_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* "취소 성공 + 재발급 실패" 중간 상태를 복구합니다. (관리자 수동 재발급)
|
||||
*
|
||||
* syncFromOrder() 는 활성 영수증이 있을 때만 동작한다 — 재발급이 실패해 활성 영수증이
|
||||
* 사라진 상태는 그 메서드의 관심사가 아니다. 이 메서드가 그 구멍을 메운다:
|
||||
* 발급 이력은 있는데 활성 영수증이 없고 발급 대상 금액이 남아 있으면, 마지막 발급 이력의
|
||||
* 용도와 저장된 암호문으로 다시 발급한다.
|
||||
*
|
||||
* 활성 영수증이 이미 있으면 금액 동기화(syncFromOrder)로 위임한다.
|
||||
*
|
||||
* @param Order $order 주문 모델
|
||||
* @param string|null $reason 사유
|
||||
* @return OrderCashReceipt|null 복구된 영수증 (복구 대상이 없거나 실패 시 null)
|
||||
*/
|
||||
public function recoverFailedIssue(Order $order, ?string $reason = null): ?OrderCashReceipt
|
||||
{
|
||||
// 활성 영수증이 있으면 금액 변동 동기화가 올바른 경로다.
|
||||
if ($this->repository->findActiveReceipt($order) !== null) {
|
||||
$this->syncFromOrder($order, $reason);
|
||||
|
||||
return $this->repository->findActiveReceipt($order);
|
||||
}
|
||||
|
||||
// 발급 대상 금액이 없으면 복구할 것이 없다 (전액 환불 등).
|
||||
if ($this->calculateIssuableAmount($order)['amount'] <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lastIssue = $this->repository->findByOrder($order)
|
||||
->first(fn (OrderCashReceipt $row) => $row->isIssue());
|
||||
|
||||
// 애초에 발급을 시도한 적이 없으면 복구가 아니라 신규 발급이다 (issue() 를 쓰라).
|
||||
if ($lastIssue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$identifier = $this->resolveStoredIdentifier($order);
|
||||
|
||||
if ($identifier === null) {
|
||||
$this->recordFailure(
|
||||
$order,
|
||||
$order->payment,
|
||||
CashReceiptTransactionType::ISSUE,
|
||||
$lastIssue->receipt_type,
|
||||
'IDENTIFIER_UNAVAILABLE',
|
||||
__('sirsoft-ecommerce::cash_receipt.errors.identifier_unavailable'),
|
||||
null,
|
||||
$lastIssue->provider,
|
||||
);
|
||||
|
||||
Log::warning('[cash_receipt] 복구용 식별번호 복호화 불가 — 관리자가 식별번호를 재입력해야 함', [
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$receipt = $this->issue(
|
||||
$order,
|
||||
$lastIssue->receipt_type,
|
||||
$identifier,
|
||||
$order->payment?->cash_receipt_identifier_type,
|
||||
);
|
||||
|
||||
return $receipt->isCompletedIssue() ? $receipt : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 상태 기준 발급 가능 금액을 계산합니다.
|
||||
*
|
||||
@@ -347,6 +425,74 @@ class CashReceiptService
|
||||
return $this->settingsService->getCashReceiptProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문의 현재 활성 영수증을 반환합니다.
|
||||
*
|
||||
* @param Order $order 주문 모델
|
||||
* @return OrderCashReceipt|null 활성 영수증 (없으면 null)
|
||||
*/
|
||||
public function findActiveReceipt(Order $order): ?OrderCashReceipt
|
||||
{
|
||||
return $this->repository->findActiveReceipt($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 이 주문에 현금영수증을 발급할 수 있는지 판정합니다.
|
||||
*
|
||||
* 자동발급 리스너와 관리자/유저 발급 API 가 공유하는 단일 가드다.
|
||||
* 어느 조건에서 막혔는지 호출부가 응답 코드를 정하도록 사유 문자열을 반환한다.
|
||||
*
|
||||
* 발급액 0원 방어: $isZeroPayable 판정은 결제수단과 무관하게 잔여 결제액만 보므로
|
||||
* "무통장 + 전액 마일리지" 0원 주문도 결제완료 훅을 발화시킨다. 그 주문에 0원 영수증을
|
||||
* 발급하지 않도록 현금성 금액을 명시적으로 확인한다 (현금성 필드의 암묵적 귀결에 의존하지 않는다).
|
||||
*
|
||||
* @param Order $order 주문 모델
|
||||
* @return string|null 발급 불가 사유 코드 (발급 가능하면 null)
|
||||
*/
|
||||
public function resolveIssueBlocker(Order $order): ?string
|
||||
{
|
||||
if ($this->resolveProvider() === null) {
|
||||
return 'PROVIDER_NOT_CONFIGURED';
|
||||
}
|
||||
|
||||
$payment = $order->payment;
|
||||
|
||||
if ($payment === null) {
|
||||
return 'PAYMENT_NOT_FOUND';
|
||||
}
|
||||
|
||||
if ($payment->payment_method !== PaymentMethodEnum::DBANK) {
|
||||
return 'NOT_CASH_PAYMENT';
|
||||
}
|
||||
|
||||
if ($payment->payment_status !== PaymentStatusEnum::PAID) {
|
||||
return 'PAYMENT_NOT_PAID';
|
||||
}
|
||||
|
||||
if ($this->repository->findActiveReceipt($order) !== null) {
|
||||
return 'ALREADY_ISSUED';
|
||||
}
|
||||
|
||||
if ($this->calculateIssuableAmount($order)['amount'] <= 0) {
|
||||
return 'NO_ISSUABLE_AMOUNT';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급 불가 사유 코드에 대응하는 다국어 메시지 키를 반환합니다.
|
||||
*
|
||||
* ResponseHelper 가 직접 번역하도록 해석된 문자열이 아닌 키를 반환한다.
|
||||
*
|
||||
* @param string $blocker 사유 코드
|
||||
* @return string 다국어 메시지 키
|
||||
*/
|
||||
public function describeIssueBlocker(string $blocker): string
|
||||
{
|
||||
return 'sirsoft-ecommerce::cash_receipt.errors.'.strtolower($blocker);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재발급용 식별번호 암호문을 폐기합니다. (구매확정 시점)
|
||||
*
|
||||
|
||||
@@ -121,7 +121,7 @@ class OrderAdjustmentService
|
||||
// 9. 옵션별 업데이트 정보 생성
|
||||
$optionUpdates = $this->buildOptionUpdates($order, $excludedMap, $recalcResult);
|
||||
$shippingUpdates = $this->buildShippingUpdates($order, $recalcResult);
|
||||
$orderUpdates = $this->buildOrderUpdates($recalcResult);
|
||||
$orderUpdates = $this->buildOrderUpdates($order, $recalcResult);
|
||||
|
||||
// 10. 취소 대상 아이템 정보
|
||||
$adjustedItems = $this->buildAdjustedItems($order, $excludedMap);
|
||||
@@ -597,6 +597,9 @@ class OrderAdjustmentService
|
||||
'total_amount' => 0,
|
||||
'total_paid_amount' => 0,
|
||||
'total_points_used_amount' => 0,
|
||||
// 전액취소면 남은 현금성 금액도 0 이다. 갱신하지 않으면 CashReceiptService 가
|
||||
// 여전히 발급 대상이 있다고 보고 전체취소 대신 재발급을 시도한다.
|
||||
'total_cash_equivalent_amount' => 0,
|
||||
],
|
||||
optionUpdates: [],
|
||||
shippingUpdates: [],
|
||||
@@ -767,10 +770,11 @@ class OrderAdjustmentService
|
||||
/**
|
||||
* 주문 테이블 업데이트 정보를 생성합니다.
|
||||
*
|
||||
* @param Order $order 주문 (결제수단 판정용)
|
||||
* @param OrderCalculationResult $recalcResult 재계산 결과
|
||||
* @return array 업데이트 데이터
|
||||
*/
|
||||
private function buildOrderUpdates($recalcResult): array
|
||||
private function buildOrderUpdates(Order $order, $recalcResult): array
|
||||
{
|
||||
// 재계산된 프로모션 스냅샷 (부분취소 후 남은 아이템 기준 할인 배분)
|
||||
$promotionsSnapshot = $recalcResult->promotions?->toArray() ?? [];
|
||||
@@ -804,10 +808,34 @@ class OrderAdjustmentService
|
||||
$recalcResult->summary->vatAmount
|
||||
?? VatCalculator::fromTaxableAmount((int) $recalcResult->summary->taxableAmount)
|
||||
),
|
||||
// 현금성 금액도 잔여 실결제액 기준으로 재계산한다. 이 값을 갱신하지 않으면
|
||||
// CashReceiptService::syncFromOrder() 가 "금액 변동 없음"으로 판정해 부분환불 후
|
||||
// 현금영수증 재발급이 일어나지 않는다.
|
||||
'total_cash_equivalent_amount' => $this->resolveCashEquivalentAmount(
|
||||
$order,
|
||||
(int) round((float) $recalcResult->summary->finalAmount),
|
||||
),
|
||||
'promotions_applied_snapshot' => $promotionsSnapshot,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여 실결제액에 대응하는 현금성 금액을 산정합니다.
|
||||
*
|
||||
* 산정 규칙은 PaymentMethodEnum::resolveCashEquivalentAmount() 가 SSoT 다
|
||||
* (주문 생성 시점의 OrderProcessingService 와 동일 규칙).
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @param int $finalAmount 잔여 실결제액 (마일리지·예치금 차감 후)
|
||||
* @return int 현금성 금액
|
||||
*/
|
||||
private function resolveCashEquivalentAmount(Order $order, int $finalAmount): int
|
||||
{
|
||||
$order->loadMissing('payment');
|
||||
|
||||
return $order->payment?->payment_method?->resolveCashEquivalentAmount($finalAmount) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 대상 아이템 정보를 생성합니다.
|
||||
*
|
||||
|
||||
@@ -54,6 +54,7 @@ class OrderCancellationService
|
||||
* @param OrderCancelOptionRepositoryInterface $orderCancelOptionRepository 주문 취소 옵션 Repository
|
||||
* @param OrderRefundRepositoryInterface $orderRefundRepository 주문 환불 Repository
|
||||
* @param OrderRefundOptionRepositoryInterface $orderRefundOptionRepository 주문 환불 옵션 Repository
|
||||
* @param CashReceiptService $cashReceiptService 현금영수증 발급/취소 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
protected OrderAdjustmentService $adjustmentService,
|
||||
@@ -67,8 +68,40 @@ class OrderCancellationService
|
||||
protected OrderCancelOptionRepositoryInterface $orderCancelOptionRepository,
|
||||
protected OrderRefundRepositoryInterface $orderRefundRepository,
|
||||
protected OrderRefundOptionRepositoryInterface $orderRefundOptionRepository,
|
||||
protected CashReceiptService $cashReceiptService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 취소 확정 후 현금영수증을 현재 금액에 맞춰 동기화합니다.
|
||||
*
|
||||
* 발급 이력이 없으면 no-op 이고, 잔여 발급액이 0 이면 전액취소만 수행하며,
|
||||
* 그 외에는 전체취소 후 잔여 과세금액 기준으로 재발급한다 (부분취소 API 미사용).
|
||||
*
|
||||
* 어떤 실패도 호출부로 전파하지 않는다 — 환불은 이미 DB 에 확정됐고 되돌릴 수 없다.
|
||||
* 실패는 이력 테이블(issue_status=FAILED)과 로그에 남아 관리자 수동 재발급으로 복구된다.
|
||||
*
|
||||
* 로그 레벨이 error 인 이유 — CashReceiptService 는 예상 가능한 실패(프로바이더가 success=false
|
||||
* 반환, 복호화 불가)를 스스로 삼키고 warning + FAILED 이력으로 남긴다. 따라서 여기까지 예외가
|
||||
* 올라왔다면 리스너가 계약을 어긴 것이고, 프로바이더 쪽 영수증이 실제로 취소됐는지조차 알 수 없다
|
||||
* (국세청 기록과 DB 가 어긋날 수 있는 상태). 취소/재고/쿠폰 복원 실패(warning)와 달리 즉시
|
||||
* 사람이 개입해야 하므로 PG 환불 실패와 같은 error 로 둔다.
|
||||
*
|
||||
* @param Order $order 취소가 반영된 주문
|
||||
* @param string|null $reason 취소 사유
|
||||
*/
|
||||
protected function syncCashReceipt(Order $order, ?string $reason): void
|
||||
{
|
||||
try {
|
||||
$this->cashReceiptService->syncFromOrder($order, $reason);
|
||||
} catch (\Throwable $e) {
|
||||
// 식별번호·PG raw 응답은 담지 않는다 (개인정보 로그 미노출).
|
||||
Log::error('현금영수증 동기화 실패 — 주문 취소는 확정됨 (관리자 수동 재발급 필요)', [
|
||||
'order_id' => $order->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 주문을 취소합니다.
|
||||
*
|
||||
@@ -292,7 +325,19 @@ class OrderCancellationService
|
||||
$this->finalizeStatus($orderCancel, $orderRefund, $cancelledBy);
|
||||
});
|
||||
|
||||
// ④ after_cancel 훅 (취소 스냅샷 전달)
|
||||
// ④ 현금영수증 동기화 — 취소가 DB 에 확정된 뒤에 수행한다.
|
||||
//
|
||||
// 트랜잭션 안에서 부르지 않는 이유:
|
||||
// (a) 재발급 실패 이력(issue_status=FAILED)은 관리자 화면의 경고 배지 근거이자 국세청 신고
|
||||
// 누락을 막는 원장이다. 롤백에 휩쓸려 사라지면 안 된다.
|
||||
// (b) 프로바이더 취소·재발급은 외부 API 2회 왕복이다. DB 잠금을 그동안 붙들 수 없고,
|
||||
// 실제 영수증은 이미 취소됐는데 트랜잭션만 롤백되면 DB 와 국세청 기록이 어긋난다.
|
||||
//
|
||||
// syncFromOrder 는 내부에서 실패를 삼키고 FAILED 이력으로 남기지만, 프로바이더 리스너가
|
||||
// 예외를 던질 가능성까지 감안해 방어한다 — 환불은 이미 끝났고 되돌릴 수 없다.
|
||||
$this->syncCashReceipt($order->fresh(), $reason);
|
||||
|
||||
// ⑤ after_cancel 훅 (취소 스냅샷 전달)
|
||||
$hookName = $cancelType === CancelTypeEnum::FULL
|
||||
? 'sirsoft-ecommerce.order.after_cancel'
|
||||
: 'sirsoft-ecommerce.order.after_partial_cancel';
|
||||
|
||||
@@ -758,7 +758,8 @@ class OrderOptionService
|
||||
$orderUpdate = ['order_status' => $newStatus->value];
|
||||
// 주문 전체가 구매확정으로 전이되면 헤더에도 확정 시점 기록 (유저 셀프 확정 OrderService::confirmOption 과 대칭).
|
||||
// 멱등: 이미 값이 있으면 유지(최초 확정 시점 보존).
|
||||
if ($newStatus === OrderStatusEnum::CONFIRMED && $order->confirmed_at === null) {
|
||||
$purchaseConfirmed = $newStatus === OrderStatusEnum::CONFIRMED && $order->confirmed_at === null;
|
||||
if ($purchaseConfirmed) {
|
||||
$orderUpdate['confirmed_at'] = now();
|
||||
}
|
||||
$order->update($orderUpdate);
|
||||
@@ -767,6 +768,13 @@ class OrderOptionService
|
||||
// OrderStatusNotificationListener 가 결제완료/배송중/배송완료/구매확정 알림으로 매핑한다.
|
||||
// 목표 상태($newStatus->value)를 스칼라로 명시 전달 — 큐 지연 재로드 오매핑 방지 (N1).
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_status_change', $order->fresh(), $previousStatus, $newStatus->value);
|
||||
|
||||
// 구매확정 훅 — order.confirmed_at 이 최초로 세팅된 순간에만 1회 발화(멱등).
|
||||
// 기존 order.after_confirm 은 completePayment 안의 "결제완료" 훅이지 구매확정 훅이 아니다(이름 혼동 주의).
|
||||
// PurgeCashReceiptIdentifierListener 가 구독해 재발급용 식별번호 암호문을 폐기한다.
|
||||
if ($purchaseConfirmed) {
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_purchase_confirmed', $order->fresh());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -668,17 +668,18 @@ class OrderProcessingService
|
||||
* | card/bank/phone | 0 |
|
||||
* | point/deposit | 0 (현금이 아님) |
|
||||
*
|
||||
* 산정 규칙은 PaymentMethodEnum::resolveCashEquivalentAmount() 가 SSoT 다 —
|
||||
* 취소 재계산(OrderAdjustmentService)도 같은 규칙을 써야 부분환불 후 재발급액이 맞는다.
|
||||
*
|
||||
* @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);
|
||||
return PaymentMethodEnum::tryFrom((string) $paymentMethod)
|
||||
?->resolveCashEquivalentAmount($finalAmount)
|
||||
?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1804,6 +1805,11 @@ class OrderProcessingService
|
||||
'paid_amount_local' => (int) round($amount),
|
||||
'paid_amount_base' => (float) $order->total_due_amount,
|
||||
]);
|
||||
|
||||
// 입금 기록 훅 — 관리자가 "결제완료 전이 없이 입금만 기록"한 경로.
|
||||
// 이 경로는 completePayment 를 타지 않으므로 after_payment_complete 가 발화하지 않는다.
|
||||
// 현금영수증 자동발급 리스너가 두 훅을 모두 구독해 입금 확인 경로 전체를 덮는다.
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_deposit_recorded', $order->fresh(), $amount);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -714,8 +714,9 @@ class OrderService
|
||||
public function confirmOption(Order $order, OrderOption $option): OrderOption
|
||||
{
|
||||
$previousStatus = $order->order_status?->value;
|
||||
$purchaseConfirmed = false;
|
||||
|
||||
return DB::transaction(function () use ($order, $option, $previousStatus) {
|
||||
$result = DB::transaction(function () use ($order, $option, $previousStatus, &$purchaseConfirmed) {
|
||||
HookManager::doAction('sirsoft-ecommerce.order-option.before_confirm', $order, $option);
|
||||
|
||||
$option->update([
|
||||
@@ -734,10 +735,14 @@ class OrderService
|
||||
|
||||
$orderTransitioned = false;
|
||||
if (! $hasUnconfirmed) {
|
||||
$order->update([
|
||||
'order_status' => OrderStatusEnum::CONFIRMED,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
$orderUpdate = ['order_status' => OrderStatusEnum::CONFIRMED];
|
||||
// 멱등: 최초 확정 시점을 보존한다 (OrderOptionService::transitionOrderStatus 와 대칭).
|
||||
// 재확정 시 confirmed_at 을 덮어쓰면 구매확정 훅이 중복 발화하고 확정 시점이 소실된다.
|
||||
$purchaseConfirmed = $order->confirmed_at === null;
|
||||
if ($purchaseConfirmed) {
|
||||
$orderUpdate['confirmed_at'] = now();
|
||||
}
|
||||
$order->update($orderUpdate);
|
||||
$orderTransitioned = true;
|
||||
}
|
||||
|
||||
@@ -757,6 +762,15 @@ class OrderService
|
||||
|
||||
return $option->fresh();
|
||||
});
|
||||
|
||||
// 구매확정 훅 — order.confirmed_at 이 최초로 세팅된 순간에만 1회 발화(멱등).
|
||||
// 커밋 이후에 발화한다: 큐 리스너(PurgeCashReceiptIdentifierListener)가 확정 전 상태를
|
||||
// 읽지 않도록 하고, 리스너 실패가 구매확정 트랜잭션을 롤백시키지 않게 한다.
|
||||
if ($purchaseConfirmed) {
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_purchase_confirmed', $order->fresh());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,29 @@ return [
|
||||
'follow_main_item' => 'Follow Main Item',
|
||||
],
|
||||
|
||||
/*
|
||||
* 입력 필드 표시명
|
||||
*/
|
||||
'attributes' => [
|
||||
'receipt_type' => 'Receipt Purpose',
|
||||
'identifier_type' => 'Identifier Type',
|
||||
'identifier' => 'Identifier',
|
||||
],
|
||||
|
||||
/*
|
||||
* 검증 메시지
|
||||
*/
|
||||
'validation' => [
|
||||
'identifier_invalid' => 'The identifier format is invalid.',
|
||||
'identifier_type_not_allowed' => ':type cannot be issued with a :identifier_type.',
|
||||
'self_issue_income_only' => 'The self-issuance number can only be used for income deduction receipts.',
|
||||
'identifier_format' => [
|
||||
'phone' => 'The mobile number must be 10 to 11 digits starting with 010, 011, 016, 017, 018 or 019.',
|
||||
'card' => 'The cash receipt card number must be 13 to 19 digits.',
|
||||
'business' => 'The business registration number is invalid. Please check the 10 digits.',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
* 오류 메시지
|
||||
*/
|
||||
@@ -52,5 +75,22 @@ return [
|
||||
'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.',
|
||||
'payment_not_found' => 'Payment information could not be found.',
|
||||
'not_cash_payment' => 'Only bank transfer (dbank) orders can be issued a cash receipt.',
|
||||
'payment_not_paid' => 'Only orders with a confirmed deposit can be issued a cash receipt.',
|
||||
'already_issued' => 'A cash receipt has already been issued for this order.',
|
||||
'issue_failed' => 'Failed to issue the cash receipt.',
|
||||
'cancel_failed' => 'Failed to cancel the cash receipt.',
|
||||
'no_active_receipt' => 'There is no cash receipt to cancel.',
|
||||
],
|
||||
|
||||
/*
|
||||
* 성공 메시지
|
||||
*/
|
||||
'messages' => [
|
||||
'issued' => 'The cash receipt has been issued.',
|
||||
'cancelled' => 'The cash receipt has been cancelled.',
|
||||
'reissued' => 'The cash receipt has been re-issued.',
|
||||
'status_retrieved' => 'The cash receipt information has been retrieved.',
|
||||
],
|
||||
];
|
||||
@@ -44,6 +44,29 @@ return [
|
||||
'follow_main_item' => '주된 재화를 따름',
|
||||
],
|
||||
|
||||
/*
|
||||
* 입력 필드 표시명
|
||||
*/
|
||||
'attributes' => [
|
||||
'receipt_type' => '발급 용도',
|
||||
'identifier_type' => '발급 수단',
|
||||
'identifier' => '식별번호',
|
||||
],
|
||||
|
||||
/*
|
||||
* 검증 메시지
|
||||
*/
|
||||
'validation' => [
|
||||
'identifier_invalid' => '식별번호 형식이 올바르지 않습니다.',
|
||||
'identifier_type_not_allowed' => ':type 은(는) :identifier_type 으로 발급할 수 없습니다.',
|
||||
'self_issue_income_only' => '자진발급 지정번호는 소득공제용으로만 발급할 수 있습니다.',
|
||||
'identifier_format' => [
|
||||
'phone' => '휴대폰번호는 010, 011, 016, 017, 018, 019 로 시작하는 10~11자리 숫자여야 합니다.',
|
||||
'card' => '현금영수증 카드번호는 13~19자리 숫자여야 합니다.',
|
||||
'business' => '사업자등록번호가 올바르지 않습니다. 10자리 숫자를 확인해 주세요.',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
* 오류 메시지
|
||||
*/
|
||||
@@ -52,5 +75,22 @@ return [
|
||||
'no_provider_handled' => '현금영수증 발급 요청을 처리한 프로바이더가 없습니다.',
|
||||
'no_issuable_amount' => '발급 가능한 현금성 금액이 없습니다.',
|
||||
'identifier_unavailable' => '재발급에 필요한 식별번호를 복호화할 수 없습니다. 관리자가 식별번호를 다시 입력해 발급해야 합니다.',
|
||||
'payment_not_found' => '결제 정보를 찾을 수 없습니다.',
|
||||
'not_cash_payment' => '무통장입금 주문만 현금영수증을 발급할 수 있습니다.',
|
||||
'payment_not_paid' => '입금이 확인된 주문만 현금영수증을 발급할 수 있습니다.',
|
||||
'already_issued' => '이미 현금영수증이 발급된 주문입니다.',
|
||||
'issue_failed' => '현금영수증 발급에 실패했습니다.',
|
||||
'cancel_failed' => '현금영수증 취소에 실패했습니다.',
|
||||
'no_active_receipt' => '취소할 현금영수증이 없습니다.',
|
||||
],
|
||||
|
||||
/*
|
||||
* 성공 메시지
|
||||
*/
|
||||
'messages' => [
|
||||
'issued' => '현금영수증이 발급되었습니다.',
|
||||
'cancelled' => '현금영수증이 취소되었습니다.',
|
||||
'reissued' => '현금영수증이 재발급되었습니다.',
|
||||
'status_retrieved' => '현금영수증 정보를 조회했습니다.',
|
||||
],
|
||||
];
|
||||
@@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\AdminUserCurrencyController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\AdminUserShippingCountryController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\BrandController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CashReceiptController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CategoryController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CouponController;
|
||||
@@ -23,6 +24,7 @@ use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingCarrierController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ShippingPolicyController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CartController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CashReceiptController as GuestCashReceiptController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CategoryController as PublicCategoryController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CategoryImageController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CheckoutController;
|
||||
@@ -35,6 +37,7 @@ use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductReviewController as
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\PublicCouponController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\WishlistController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Shop\PaymentConfigController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\User\CashReceiptController as UserCashReceiptController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController as UserOrderController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductInquiryController as UserProductInquiryController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\User\ProductReviewController as UserProductReviewController;
|
||||
@@ -288,6 +291,15 @@ Route::prefix('guest/orders/{orderNumber}')
|
||||
Route::post('/options/{optionId}/confirm', [PublicOrderController::class, 'confirmOption'])
|
||||
->whereNumber('optionId')
|
||||
->name('guest.orders.confirm-option');
|
||||
|
||||
// GET /guest/orders/{orderNumber}/cash-receipt - 현금영수증 상태 조회
|
||||
Route::get('/cash-receipt', [GuestCashReceiptController::class, 'show'])
|
||||
->name('guest.orders.cash-receipt.show');
|
||||
|
||||
// POST /guest/orders/{orderNumber}/cash-receipt - 현금영수증 사후 발급
|
||||
// 발급취소는 제공하지 않는다 (관리자 전용).
|
||||
Route::post('/cash-receipt', [GuestCashReceiptController::class, 'issue'])
|
||||
->name('guest.orders.cash-receipt.issue');
|
||||
});
|
||||
|
||||
// 회원/비회원 공유 주문 상세 조회 (optional.sanctum)
|
||||
@@ -416,6 +428,17 @@ Route::prefix('user')->middleware('auth:sanctum')->group(function () {
|
||||
Route::post('/{id}/reorder', [UserOrderController::class, 'reorder'])
|
||||
->whereNumber('id')
|
||||
->name('user.orders.reorder');
|
||||
|
||||
// GET /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt - 현금영수증 상태 조회
|
||||
Route::get('/{id}/cash-receipt', [UserCashReceiptController::class, 'show'])
|
||||
->whereNumber('id')
|
||||
->name('user.orders.cash-receipt.show');
|
||||
|
||||
// POST /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt - 현금영수증 사후 발급
|
||||
// 발급취소는 제공하지 않는다 (관리자 전용).
|
||||
Route::post('/{id}/cash-receipt', [UserCashReceiptController::class, 'issue'])
|
||||
->whereNumber('id')
|
||||
->name('user.orders.cash-receipt.issue');
|
||||
});
|
||||
|
||||
// 리뷰 API (인증 사용자)
|
||||
@@ -1312,6 +1335,24 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function ()
|
||||
Route::post('/{order}/reset-guest-lookup-password', [OrderController::class, 'resetGuestLookupPassword'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.orders.update')
|
||||
->name('admin.orders.reset-guest-lookup-password');
|
||||
|
||||
// 현금영수증 발급 (주문 시 미신청 건의 사후 발급 포함)
|
||||
// POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt
|
||||
Route::post('/{order}/cash-receipt', [CashReceiptController::class, 'issue'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.orders.update')
|
||||
->name('admin.orders.cash-receipt.issue');
|
||||
|
||||
// 현금영수증 발급취소 (전액취소)
|
||||
// DELETE /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt
|
||||
Route::delete('/{order}/cash-receipt', [CashReceiptController::class, 'cancel'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.orders.update')
|
||||
->name('admin.orders.cash-receipt.cancel');
|
||||
|
||||
// 현금영수증 재동기화 ("취소 성공 + 재발급 실패" 중간 상태 복구)
|
||||
// POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt/reissue
|
||||
Route::post('/{order}/cash-receipt/reissue', [CashReceiptController::class, 'reissue'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.orders.update')
|
||||
->name('admin.orders.cash-receipt.reissue');
|
||||
});
|
||||
|
||||
// 리뷰 관리 API (관리자)
|
||||
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 현금영수증 발급 API 테스트 (A-6 / A-8-3 / D12)
|
||||
*
|
||||
* 관리자: 발급 / 발급취소 / 재발급 복구
|
||||
* 유저: 발급 / 상태조회 (발급취소는 제공하지 않는다)
|
||||
*/
|
||||
class CashReceiptControllerTest extends ModuleTestCase
|
||||
{
|
||||
private const PROVIDER = 'tosspayments';
|
||||
|
||||
private const IDENTIFIER = '01012345678';
|
||||
|
||||
private const BUSINESS_NUMBER = '1078648269';
|
||||
|
||||
protected User $adminUser;
|
||||
|
||||
private bool $issueShouldFail = false;
|
||||
|
||||
private int $receiptSequence = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->issueShouldFail = false;
|
||||
$this->receiptSequence = 0;
|
||||
$this->adminUser = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
|
||||
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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' => null,
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.cash_receipt.cancel',
|
||||
fn (array $result, Order $order, string $provider, string $receiptKey) => $provider === self::PROVIDER
|
||||
? [
|
||||
'success' => true, 'error_code' => null, 'error_message' => null,
|
||||
'receipt_key' => $receiptKey, 'raw_response' => null,
|
||||
]
|
||||
: $result,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 무통장 결제완료 주문을 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $paymentOverrides 결제 레코드 오버라이드
|
||||
*/
|
||||
private function makeOrder(?User $owner = null, int $cash = 11000, array $paymentOverrides = []): Order
|
||||
{
|
||||
$order = Order::factory()->create([
|
||||
'user_id' => $owner?->id,
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'total_amount' => $cash,
|
||||
'total_cash_equivalent_amount' => $cash,
|
||||
'total_tax_amount' => $cash,
|
||||
'total_tax_free_amount' => 0,
|
||||
]);
|
||||
|
||||
OrderPayment::factory()->forOrder($order)->create(array_merge([
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
], $paymentOverrides));
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
private function adminUrl(Order $order, string $suffix = ''): string
|
||||
{
|
||||
return "/api/modules/sirsoft-ecommerce/admin/orders/{$order->id}/cash-receipt{$suffix}";
|
||||
}
|
||||
|
||||
private function userUrl(Order $order): string
|
||||
{
|
||||
return "/api/modules/sirsoft-ecommerce/user/orders/{$order->id}/cash-receipt";
|
||||
}
|
||||
|
||||
private function issuePayload(): array
|
||||
{
|
||||
return [
|
||||
'receipt_type' => CashReceiptType::INCOME->value,
|
||||
'identifier_type' => CashReceiptIdentifierType::PHONE->value,
|
||||
'identifier' => self::IDENTIFIER,
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 관리자 발급
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 관리자는_사후_발급할_수_있고_식별번호는_마스킹되어_저장된다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order), $this->issuePayload());
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.identifier_masked', '*******5678');
|
||||
$response->assertJsonPath('data.receipt_url', 'https://example.test/receipt/receipt-1');
|
||||
|
||||
// 응답 어디에도 평문이 없어야 한다
|
||||
$this->assertStringNotContainsString(self::IDENTIFIER, $response->getContent());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 관리자는_지출증빙_사업자번호로_발급할_수_있다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order), [
|
||||
'receipt_type' => CashReceiptType::EXPENSE->value,
|
||||
'identifier_type' => CashReceiptIdentifierType::BUSINESS->value,
|
||||
'identifier' => self::BUSINESS_NUMBER,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.receipt_type', CashReceiptType::EXPENSE->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 사업자번호_체크섬이_틀리면_422_를_반환한다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order), [
|
||||
'receipt_type' => CashReceiptType::EXPENSE->value,
|
||||
'identifier_type' => CashReceiptIdentifierType::BUSINESS->value,
|
||||
'identifier' => '1078648260',
|
||||
])
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 소득공제_사업자번호_조합은_거부된다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order), [
|
||||
'receipt_type' => CashReceiptType::INCOME->value,
|
||||
'identifier_type' => CashReceiptIdentifierType::BUSINESS->value,
|
||||
'identifier' => self::BUSINESS_NUMBER,
|
||||
])
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 무통장이_아닌_주문은_발급이_차단된다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder(null, 11000, ['payment_method' => PaymentMethodEnum::CARD]);
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order), $this->issuePayload())
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.error_code', 'NOT_CASH_PAYMENT');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 미결제_주문은_발급이_차단된다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder(null, 11000, ['payment_status' => PaymentStatusEnum::WAITING_DEPOSIT]);
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order), $this->issuePayload())
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.error_code', 'PAYMENT_NOT_PAID');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 이미_발급된_주문에_다시_발급하면_409_를_반환한다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->actingAs($this->adminUser)->postJson($this->adminUrl($order), $this->issuePayload())->assertOk();
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order), $this->issuePayload())
|
||||
->assertStatus(409)
|
||||
->assertJsonPath('errors.error_code', 'ALREADY_ISSUED');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 프로바이더_발급_실패시_422_와_에러코드를_반환한다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$this->issueShouldFail = true;
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order), $this->issuePayload())
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.error_code', 'PROVIDER_ERROR');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 권한이_없는_관리자는_발급할_수_없다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
$weakAdmin = $this->createAdminUser();
|
||||
|
||||
$this->actingAs($weakAdmin)
|
||||
->postJson($this->adminUrl($order), $this->issuePayload())
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 관리자 발급취소 / 재발급
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 관리자는_발급취소할_수_있다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
$this->actingAs($this->adminUser)->postJson($this->adminUrl($order), $this->issuePayload())->assertOk();
|
||||
|
||||
$this->actingAs($this->adminUser)->deleteJson($this->adminUrl($order))->assertOk();
|
||||
|
||||
$this->assertNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 활성_영수증이_없으면_발급취소는_422_다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->deleteJson($this->adminUrl($order))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.error_code', 'NO_ACTIVE_RECEIPT');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 재발급_ap_i_는_재발급_실패_상태를_복구한다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
$this->actingAs($this->adminUser)->postJson($this->adminUrl($order), $this->issuePayload())->assertOk();
|
||||
|
||||
// 금액 변동 + 재발급 실패 → "취소 성공 + 재발급 실패" 중간 상태를 만든다
|
||||
$this->issueShouldFail = true;
|
||||
$order->fresh()->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
|
||||
app(CashReceiptService::class)->syncFromOrder($order->fresh(), '부분환불');
|
||||
$this->assertNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
|
||||
|
||||
// 프로바이더 복구 후 관리자가 수동 재발급
|
||||
$this->issueShouldFail = false;
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->postJson($this->adminUrl($order, '/reissue'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.amount', 5500);
|
||||
|
||||
$this->assertNotNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 유저
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 유저는_본인_주문에_사후_발급할_수_있다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$owner = $this->createUser();
|
||||
$order = $this->makeOrder($owner);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->postJson($this->userUrl($order), $this->issuePayload())
|
||||
->assertOk()
|
||||
->assertJsonPath('data.identifier_masked', '*******5678');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 유저는_타인_주문에_발급할_수_없다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder($this->createUser());
|
||||
$stranger = $this->createUser();
|
||||
|
||||
$this->actingAs($stranger)
|
||||
->postJson($this->userUrl($order), $this->issuePayload())
|
||||
->assertStatus(404);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 유저는_발급취소할_수_없다(): void
|
||||
{
|
||||
// 유저용 DELETE 라우트 자체가 존재하지 않는다 (관리자 전용).
|
||||
$this->registerProvider();
|
||||
$owner = $this->createUser();
|
||||
$order = $this->makeOrder($owner);
|
||||
$this->actingAs($owner)->postJson($this->userUrl($order), $this->issuePayload())->assertOk();
|
||||
|
||||
$response = $this->actingAs($owner)->deleteJson($this->userUrl($order));
|
||||
|
||||
$this->assertContains($response->getStatusCode(), [404, 405], '유저 발급취소 경로가 없어야 한다');
|
||||
$this->assertNotNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 유저는_발급_상태를_조회할_수_있다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$owner = $this->createUser();
|
||||
$order = $this->makeOrder($owner);
|
||||
|
||||
// 발급 전: issuable = true, cash_receipt = null
|
||||
$this->actingAs($owner)
|
||||
->getJson($this->userUrl($order))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.issuable', true)
|
||||
->assertJsonPath('data.cash_receipt', null);
|
||||
|
||||
$this->actingAs($owner)->postJson($this->userUrl($order), $this->issuePayload())->assertOk();
|
||||
|
||||
// 발급 후: issuable = false, 영수증 노출
|
||||
$this->actingAs($owner)
|
||||
->getJson($this->userUrl($order))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.issuable', false)
|
||||
->assertJsonPath('data.cash_receipt.identifier_masked', '*******5678');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 비로그인_사용자는_유저_발급_ap_i_에_접근할_수_없다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder($this->createUser());
|
||||
|
||||
$this->postJson($this->userUrl($order), $this->issuePayload())->assertStatus(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 발급된_영수증은_주문_이력에_원장으로_남는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->actingAs($this->adminUser)->postJson($this->adminUrl($order), $this->issuePayload())->assertOk();
|
||||
|
||||
$rows = OrderCashReceipt::where('order_id', $order->id)->get();
|
||||
$this->assertCount(1, $rows);
|
||||
// 원장 어디에도 평문 식별번호가 없어야 한다
|
||||
foreach ($rows as $row) {
|
||||
foreach ($row->getAttributes() as $value) {
|
||||
$this->assertStringNotContainsString(self::IDENTIFIER, (string) $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 비회원 (조회 토큰)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 비회원 무통장 결제완료 주문과 조회 토큰을 생성합니다.
|
||||
*
|
||||
* @return array{0: Order, 1: string} 주문과 조회 토큰
|
||||
*/
|
||||
private function makeGuestOrderWithToken(): array
|
||||
{
|
||||
$phone = '010-1234-5678';
|
||||
$password = 'guest1234';
|
||||
|
||||
$order = Order::factory()->forGuest()->create([
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'guest_lookup_password_hash' => Hash::make($password),
|
||||
'total_amount' => 11000,
|
||||
'total_cash_equivalent_amount' => 11000,
|
||||
'total_tax_amount' => 11000,
|
||||
'total_tax_free_amount' => 0,
|
||||
]);
|
||||
|
||||
OrderAddress::factory()->forOrder($order)->create([
|
||||
'address_type' => 'shipping',
|
||||
'orderer_phone' => $phone,
|
||||
]);
|
||||
|
||||
OrderPayment::factory()->forOrder($order)->create([
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
]);
|
||||
|
||||
$token = $this->postJson('/api/modules/sirsoft-ecommerce/guest/orders/verify', [
|
||||
'order_number' => $order->order_number,
|
||||
'orderer_phone' => $phone,
|
||||
'guest_lookup_password' => $password,
|
||||
])->assertOk()->json('data.guest_order_token');
|
||||
|
||||
return [$order->fresh(), $token];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 비회원은_조회_토큰으로_사후_발급할_수_있다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
[$order, $token] = $this->makeGuestOrderWithToken();
|
||||
|
||||
$this->postJson(
|
||||
"/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/cash-receipt",
|
||||
$this->issuePayload(),
|
||||
['X-Guest-Order-Token' => $token],
|
||||
)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.identifier_masked', '*******5678');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 비회원은_토큰_없이_발급할_수_없다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
[$order] = $this->makeGuestOrderWithToken();
|
||||
|
||||
$this->postJson(
|
||||
"/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/cash-receipt",
|
||||
$this->issuePayload(),
|
||||
)->assertStatus(404);
|
||||
|
||||
$this->assertNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 비회원은_발급_상태를_조회할_수_있다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
[$order, $token] = $this->makeGuestOrderWithToken();
|
||||
$headers = ['X-Guest-Order-Token' => $token];
|
||||
$url = "/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/cash-receipt";
|
||||
|
||||
// GET 은 입력을 받지 않는다 (발급 폼 검증에 걸리면 안 된다)
|
||||
$this->getJson($url, $headers)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.issuable', true)
|
||||
->assertJsonPath('data.cash_receipt', null);
|
||||
|
||||
$this->postJson($url, $this->issuePayload(), $headers)->assertOk();
|
||||
|
||||
$this->getJson($url, $headers)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.issuable', false)
|
||||
->assertJsonPath('data.cash_receipt.identifier_masked', '*******5678');
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Enums;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 결제수단별 현금성 금액 산정 규칙 테스트 (A-3)
|
||||
*
|
||||
* 이 규칙은 주문 생성(OrderProcessingService)과 취소 재계산(OrderAdjustmentService)이
|
||||
* 공유하는 SSoT 다. 한쪽만 바뀌면 부분환불 후 현금영수증 재발급액이 틀어진다.
|
||||
*/
|
||||
class PaymentMethodCashEquivalentTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* @return array<string, array{PaymentMethodEnum, int, int}>
|
||||
*/
|
||||
public static function cashEquivalentCases(): array
|
||||
{
|
||||
return [
|
||||
// 무통장만 구매자가 직접 현금을 입금한다
|
||||
'무통장 = 실결제액 전액' => [PaymentMethodEnum::DBANK, 11000, 11000],
|
||||
// 가상계좌는 PG 가 현금영수증을 자동 발급한다
|
||||
'가상계좌 = 0' => [PaymentMethodEnum::VBANK, 11000, 0],
|
||||
'카드 = 0' => [PaymentMethodEnum::CARD, 11000, 0],
|
||||
'마일리지 = 0' => [PaymentMethodEnum::POINT, 11000, 0],
|
||||
'무료 = 0' => [PaymentMethodEnum::FREE, 0, 0],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('cashEquivalentCases')]
|
||||
public function 결제수단별_현금성_금액을_산정한다(
|
||||
PaymentMethodEnum $method,
|
||||
int $finalAmount,
|
||||
int $expected,
|
||||
): void {
|
||||
$this->assertSame($expected, $method->resolveCashEquivalentAmount($finalAmount));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 음수_실결제액은_0_으로_보정된다(): void
|
||||
{
|
||||
$this->assertSame(0, PaymentMethodEnum::DBANK->resolveCashEquivalentAmount(-1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 전액_마일리지_무통장주문의_현금성_금액은_0_이다(): void
|
||||
{
|
||||
// $isZeroPayable 판정은 결제수단과 무관하므로 "무통장 + 전액 마일리지" 0원 주문이 성립한다.
|
||||
// 그 주문에 0원 현금영수증을 발급하지 않으려면 여기서 0이 나와야 한다.
|
||||
$this->assertSame(0, PaymentMethodEnum::DBANK->resolveCashEquivalentAmount(0));
|
||||
}
|
||||
}
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
|
||||
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Extension\HookManager;
|
||||
use App\Jobs\DispatchHookListenerJob;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\IssueCashReceiptOnDepositListener;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 입금완료 자동 발급 리스너 테스트 (D11 / A-8-4)
|
||||
*
|
||||
* 입금이 확인되는 두 경로(after_payment_complete / after_deposit_recorded)를 모두 덮는지,
|
||||
* 0원 주문·미신청·자진발급·멱등·발급실패를 각각 올바르게 처리하는지 검증한다.
|
||||
*/
|
||||
class IssueCashReceiptOnDepositListenerTest extends ModuleTestCase
|
||||
{
|
||||
private const PROVIDER = 'tosspayments';
|
||||
|
||||
private const IDENTIFIER = '01012345678';
|
||||
|
||||
private bool $issueShouldFail = false;
|
||||
|
||||
private int $receiptSequence = 0;
|
||||
|
||||
/** @var array<int, array> 발급 훅이 받은 payload 기록 */
|
||||
private array $issueCalls = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->issueShouldFail = false;
|
||||
$this->receiptSequence = 0;
|
||||
$this->issueCalls = [];
|
||||
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급 프로바이더 리스너를 등록합니다.
|
||||
*/
|
||||
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[] = $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' => null,
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 무통장 결제완료 주문을 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $paymentOverrides 결제 레코드 오버라이드
|
||||
*/
|
||||
private function makeOrder(int $cashEquivalent = 11000, array $paymentOverrides = []): Order
|
||||
{
|
||||
$order = Order::factory()->create([
|
||||
'total_amount' => $cashEquivalent,
|
||||
'total_cash_equivalent_amount' => $cashEquivalent,
|
||||
'total_tax_amount' => $cashEquivalent,
|
||||
'total_tax_free_amount' => 0,
|
||||
]);
|
||||
|
||||
OrderPayment::factory()->create(array_merge([
|
||||
'order_id' => $order->id,
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
], $paymentOverrides));
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증을 신청한 무통장 주문을 생성합니다.
|
||||
*/
|
||||
private function makeRequestedOrder(int $cashEquivalent = 11000): Order
|
||||
{
|
||||
return $this->makeOrder($cashEquivalent, [
|
||||
'is_cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::INCOME->value,
|
||||
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE,
|
||||
'cash_receipt_identifier_encrypted' => self::IDENTIFIER,
|
||||
]);
|
||||
}
|
||||
|
||||
private function listener(): IssueCashReceiptOnDepositListener
|
||||
{
|
||||
return app(IssueCashReceiptOnDepositListener::class);
|
||||
}
|
||||
|
||||
private function activeReceipt(Order $order): ?OrderCashReceipt
|
||||
{
|
||||
return app(CashReceiptService::class)->findActiveReceipt($order->fresh());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 두 훅 경로 모두 발급된다
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 구독_훅은_결제완료와_입금기록_두_경로다(): void
|
||||
{
|
||||
$hooks = IssueCashReceiptOnDepositListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('sirsoft-ecommerce.order.after_payment_complete', $hooks);
|
||||
$this->assertArrayHasKey('sirsoft-ecommerce.order.after_deposit_recorded', $hooks);
|
||||
$this->assertSame('handleDeposit', $hooks['sirsoft-ecommerce.order.after_payment_complete']['method']);
|
||||
$this->assertSame('handleDeposit', $hooks['sirsoft-ecommerce.order.after_deposit_recorded']['method']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function after_payment_complete_발화시_발급된다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
HookListenerRegistrar::register(IssueCashReceiptOnDepositListener::class);
|
||||
$order = $this->makeRequestedOrder();
|
||||
|
||||
// 큐를 타지 않고 즉시 검증하기 위해 핸들러를 직접 호출한다 (큐 디스패치는 별도 테스트).
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$receipt = $this->activeReceipt($order);
|
||||
$this->assertNotNull($receipt);
|
||||
$this->assertSame(CashReceiptIssueStatus::COMPLETED, $receipt->issue_status);
|
||||
$this->assertSame(11000, (int) $receipt->amount);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function after_deposit_recorded_입금만_기록_경로에서도_발급된다(): void
|
||||
{
|
||||
// 이 훅이 없으면 관리자 "입금만 기록" 경로에서 자동발급이 통째로 누락된다.
|
||||
$this->registerProvider();
|
||||
$order = $this->makeRequestedOrder();
|
||||
|
||||
// 훅 인자가 ($order, $amount) 2개인 경로 — 리스너는 첫 인자만 쓴다.
|
||||
$this->listener()->handleDeposit($order, 11000.0);
|
||||
|
||||
$this->assertNotNull($this->activeReceipt($order));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 0원 주문 방어 (A-8-4 ②)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 전액_마일리지_0원_무통장주문에는_발급을_시도하지_않는다(): void
|
||||
{
|
||||
// $isZeroPayable 은 결제수단과 무관하게 잔여 결제액만 보므로 이 주문도
|
||||
// after_payment_complete 를 발화시킨다. 현금성 금액이 0 이므로 발급 대상이 아니다.
|
||||
$this->registerProvider();
|
||||
$order = $this->makeRequestedOrder(0);
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$this->assertCount(0, $this->issueCalls, '발급 API 가 호출되면 안 된다');
|
||||
$this->assertSame(0, OrderCashReceipt::where('order_id', $order->id)->count());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 발급 조건 가드
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 무통장이_아니면_발급하지_않는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder(11000, [
|
||||
'payment_method' => PaymentMethodEnum::CARD,
|
||||
'is_cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::INCOME->value,
|
||||
'cash_receipt_identifier_encrypted' => self::IDENTIFIER,
|
||||
]);
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$this->assertCount(0, $this->issueCalls);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 미결제_상태면_발급하지_않는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder(11000, [
|
||||
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
|
||||
'is_cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::INCOME->value,
|
||||
'cash_receipt_identifier_encrypted' => self::IDENTIFIER,
|
||||
]);
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$this->assertCount(0, $this->issueCalls);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 프로바이더_미설정시_발급하지_않는다(): void
|
||||
{
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', '');
|
||||
$this->registerProvider();
|
||||
$order = $this->makeRequestedOrder();
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$this->assertCount(0, $this->issueCalls);
|
||||
$this->assertSame(0, OrderCashReceipt::where('order_id', $order->id)->count());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 미신청 + 자진발급
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 미신청이고_자진발급_of_f_면_아무것도_하지_않는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_self_issue', false);
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$this->assertCount(0, $this->issueCalls);
|
||||
$this->assertSame(0, OrderCashReceipt::where('order_id', $order->id)->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 미신청이고_자진발급_o_n_이면_국세청_지정번호로_소득공제_발급한다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_self_issue', true);
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$this->assertCount(1, $this->issueCalls);
|
||||
$this->assertSame(CashReceiptIdentifierType::SELF_ISSUE_NUMBER, $this->issueCalls[0]['identifier']);
|
||||
// 자진발급은 제도상 소득공제용만 가능하다.
|
||||
$this->assertSame(CashReceiptType::INCOME->value, $this->issueCalls[0]['type']);
|
||||
|
||||
$this->assertSame(CashReceiptType::INCOME, $this->activeReceipt($order)->receipt_type);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 신청했으나_식별번호를_복호화할_수_없으면_자진발급으로_대체하지_않는다(): void
|
||||
{
|
||||
// 구매자가 지정한 식별번호로만 발급해야 한다 — 관리자 수동 발급 경로로 남긴다.
|
||||
$this->registerProvider();
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_self_issue', true);
|
||||
$order = $this->makeOrder(11000, [
|
||||
'is_cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::INCOME->value,
|
||||
'cash_receipt_identifier_encrypted' => null,
|
||||
]);
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$this->assertCount(0, $this->issueCalls);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 멱등 / 실패
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 이미_발급된_주문에는_중복_발급하지_않는다(): void
|
||||
{
|
||||
// 두 훅이 한 입금에 모두 도달할 수 있다.
|
||||
$this->registerProvider();
|
||||
$order = $this->makeRequestedOrder();
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
$this->listener()->handleDeposit($order->fresh());
|
||||
|
||||
$this->assertCount(1, $this->issueCalls, '발급 API 는 1회만 호출되어야 한다');
|
||||
$this->assertSame(1, OrderCashReceipt::where('order_id', $order->id)->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 발급_실패시_faile_d_이력이_남고_예외는_전파되지_않는다(): void
|
||||
{
|
||||
// 입금확인 자체는 성공 상태로 유지되어야 한다.
|
||||
$this->registerProvider();
|
||||
$this->issueShouldFail = true;
|
||||
$order = $this->makeRequestedOrder();
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
$receipt = OrderCashReceipt::where('order_id', $order->id)->latest('id')->first();
|
||||
$this->assertNotNull($receipt);
|
||||
$this->assertSame(CashReceiptIssueStatus::FAILED, $receipt->issue_status);
|
||||
$this->assertSame('PROVIDER_ERROR', $receipt->error_code);
|
||||
|
||||
// 활성 영수증은 없다 → 관리자 수동 발급 경로가 열려 있다.
|
||||
$this->assertNull($this->activeReceipt($order));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 프로바이더가_예외를_던져도_삼키고_로그만_남긴다(): void
|
||||
{
|
||||
Log::spy();
|
||||
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.cash_receipt.issue',
|
||||
function () {
|
||||
throw new \RuntimeException('PG 통신 실패');
|
||||
},
|
||||
);
|
||||
|
||||
$order = $this->makeRequestedOrder();
|
||||
|
||||
// 입금확인 트랜잭션을 깨뜨리지 않는다 (예외 미전파).
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
// 조용한 실패 금지 — 예외를 삼켰다면 반드시 로그로 남아야 한다.
|
||||
Log::shouldHaveReceived('error')
|
||||
->once()
|
||||
->withArgs(function (string $message, array $context) use ($order) {
|
||||
$this->assertStringContainsString('IssueCashReceiptOnDepositListener', $message);
|
||||
$this->assertSame($order->id, $context['order_id']);
|
||||
$this->assertStringContainsString('PG 통신 실패', $context['error']);
|
||||
|
||||
// 식별번호는 로그에 절대 노출되지 않는다.
|
||||
$this->assertStringNotContainsString(self::IDENTIFIER, $message.json_encode($context));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 발급_실패시_경고_로그를_남긴다(): void
|
||||
{
|
||||
// 프로바이더가 예외 없이 success=false 를 반환한 경우 — 관리자 수동 발급 유도.
|
||||
Log::spy();
|
||||
$this->registerProvider();
|
||||
$this->issueShouldFail = true;
|
||||
$order = $this->makeRequestedOrder();
|
||||
|
||||
$this->listener()->handleDeposit($order);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(function (string $message, array $context) {
|
||||
if (! str_contains($message, 'IssueCashReceiptOnDepositListener')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->assertSame('PROVIDER_ERROR', $context['error_code']);
|
||||
$this->assertStringNotContainsString(self::IDENTIFIER, $message.json_encode($context));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 큐 처리 (sync=true 아님)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 리스너는_sync_가_아니라_큐로_디스패치된다(): void
|
||||
{
|
||||
// 외부 발급 API 호출을 입금확인 트랜잭션에 묶으면 입금확인 자체가 실패한다.
|
||||
$this->assertArrayNotHasKey(
|
||||
'sync',
|
||||
IssueCashReceiptOnDepositListener::getSubscribedHooks()['sirsoft-ecommerce.order.after_payment_complete'],
|
||||
'sync 를 명시하면 안 된다 (기본 큐 처리)',
|
||||
);
|
||||
|
||||
Queue::fake();
|
||||
HookListenerRegistrar::register(IssueCashReceiptOnDepositListener::class);
|
||||
|
||||
$order = $this->makeRequestedOrder();
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_payment_complete', $order);
|
||||
|
||||
Queue::assertPushed(
|
||||
DispatchHookListenerJob::class,
|
||||
fn ($job) => $job->listenerClass === IssueCashReceiptOnDepositListener::class
|
||||
&& $job->method === 'handleDeposit',
|
||||
);
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
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\Listeners\PurgeCashReceiptIdentifierListener;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 구매확정 시 식별번호 암호문 폐기 리스너 테스트 (D15)
|
||||
*
|
||||
* 암호문만 지우고 이력·receipt_url·마스킹 값은 국세청 신고 근거로 유지한다.
|
||||
*/
|
||||
class PurgeCashReceiptIdentifierListenerTest extends ModuleTestCase
|
||||
{
|
||||
private const PROVIDER = 'tosspayments';
|
||||
|
||||
private const IDENTIFIER = '01012345678';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
|
||||
}
|
||||
|
||||
private function registerProvider(): void
|
||||
{
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.cash_receipt.issue',
|
||||
fn (array $result, Order $order, string $provider, array $payload) => $provider === self::PROVIDER
|
||||
? [
|
||||
'success' => true,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'receipt_key' => 'receipt-1',
|
||||
'receipt_url' => 'https://example.test/receipt/receipt-1',
|
||||
'issue_number' => '12345678',
|
||||
'raw_response' => null,
|
||||
]
|
||||
: $result,
|
||||
);
|
||||
}
|
||||
|
||||
private function makeIssuedOrder(): Order
|
||||
{
|
||||
$order = Order::factory()->create([
|
||||
'total_amount' => 11000,
|
||||
'total_cash_equivalent_amount' => 11000,
|
||||
'total_tax_amount' => 11000,
|
||||
'total_tax_free_amount' => 0,
|
||||
]);
|
||||
|
||||
OrderPayment::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
]);
|
||||
|
||||
$order = $order->fresh();
|
||||
|
||||
app(CashReceiptService::class)->issue(
|
||||
$order,
|
||||
CashReceiptType::INCOME,
|
||||
self::IDENTIFIER,
|
||||
CashReceiptIdentifierType::PHONE,
|
||||
);
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
private function listener(): PurgeCashReceiptIdentifierListener
|
||||
{
|
||||
return app(PurgeCashReceiptIdentifierListener::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 구매확정_훅을_구독한다(): void
|
||||
{
|
||||
$hooks = PurgeCashReceiptIdentifierListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('sirsoft-ecommerce.order.after_purchase_confirmed', $hooks);
|
||||
$this->assertSame('purgeIdentifier', $hooks['sirsoft-ecommerce.order.after_purchase_confirmed']['method']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 암호문은_지워지고_이력과_영수증_ur_l과_마스킹은_유지된다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeIssuedOrder();
|
||||
|
||||
// 사전 조건: 암호문이 저장되어 있다
|
||||
$this->assertSame(self::IDENTIFIER, $order->payment->cash_receipt_identifier_encrypted);
|
||||
|
||||
$this->listener()->purgeIdentifier($order);
|
||||
|
||||
$payment = $order->fresh()->payment;
|
||||
$this->assertNull($payment->cash_receipt_identifier_encrypted, '암호문은 폐기되어야 한다');
|
||||
|
||||
// 국세청 신고 근거는 유지된다
|
||||
$this->assertTrue((bool) $payment->is_cash_receipt_issued);
|
||||
$this->assertSame('*******5678', $payment->cash_receipt_identifier);
|
||||
|
||||
$receipt = OrderCashReceipt::where('order_id', $order->id)->latest('id')->first();
|
||||
$this->assertNotNull($receipt);
|
||||
$this->assertSame('https://example.test/receipt/receipt-1', $receipt->receipt_url);
|
||||
$this->assertSame('*******5678', $receipt->identifier_masked);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 폐기_후_재발급하려면_식별번호를_다시_입력해야_한다(): void
|
||||
{
|
||||
// 암호문이 없으면 syncFromOrder 는 재발급을 시도하지 않고 FAILED 이력만 남긴다 (조용한 실패 금지).
|
||||
$this->registerProvider();
|
||||
$order = $this->makeIssuedOrder();
|
||||
$this->listener()->purgeIdentifier($order);
|
||||
|
||||
$order->fresh()->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
|
||||
|
||||
app(CashReceiptService::class)->syncFromOrder($order->fresh(), '부분환불');
|
||||
|
||||
$failed = OrderCashReceipt::where('order_id', $order->id)
|
||||
->where('error_code', 'IDENTIFIER_UNAVAILABLE')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($failed, '식별번호 부재 실패 이력이 남아야 한다');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 중복_확정에도_안전하다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeIssuedOrder();
|
||||
|
||||
$this->listener()->purgeIdentifier($order);
|
||||
$this->listener()->purgeIdentifier($order->fresh());
|
||||
|
||||
$this->assertNull($order->fresh()->payment->cash_receipt_identifier_encrypted);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 결제_레코드가_없어도_예외를_던지지_않는다(): void
|
||||
{
|
||||
$order = Order::factory()->create();
|
||||
|
||||
$this->listener()->purgeIdentifier($order);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Resources;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\OrderPaymentResource;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\OrderResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 현금영수증 리소스 노출 테스트 (A-5)
|
||||
*
|
||||
* OrderResource 는 활성 영수증 1건(cash_receipt)과 전체 이력(cash_receipts)을 노출한다.
|
||||
* 전체 이력은 관리자 화면의 "취소 성공 + 재발급 실패" 경고 배지 판정 근거다.
|
||||
* 어느 쪽도 식별번호 원본이나 프로바이더 원응답을 노출하지 않는다.
|
||||
*/
|
||||
class CashReceiptResourceTest extends ModuleTestCase
|
||||
{
|
||||
private const PROVIDER = 'tosspayments';
|
||||
|
||||
private const IDENTIFIER = '01012345678';
|
||||
|
||||
private bool $issueShouldFail = false;
|
||||
|
||||
private int $receiptSequence = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->issueShouldFail = false;
|
||||
$this->receiptSequence = 0;
|
||||
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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' => ['customerIdentityNumber' => self::IDENTIFIER],
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private function makeOrder(): Order
|
||||
{
|
||||
$order = Order::factory()->create([
|
||||
'total_amount' => 11000,
|
||||
'total_cash_equivalent_amount' => 11000,
|
||||
'total_tax_amount' => 11000,
|
||||
'total_tax_free_amount' => 0,
|
||||
]);
|
||||
|
||||
OrderPayment::factory()->forOrder($order)->create([
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
'receipt_url' => 'https://pg.example.test/slip/1',
|
||||
]);
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계를 로드한 뒤 OrderResource 의 현금영수증 관련 필드만 직렬화합니다.
|
||||
*
|
||||
* 주문 리소스 전체를 json_encode 하면 미로드 관계(shippingAddress 등)의 리소스가
|
||||
* null 을 만나 터진다 — 본 테스트의 관심사가 아니므로 해당 필드만 뽑아 직렬화한다.
|
||||
*
|
||||
* @return array{payment: array, cash_receipt: array|null, cash_receipts: array}
|
||||
*/
|
||||
private function resourceArray(Order $order): array
|
||||
{
|
||||
$order = $order->fresh();
|
||||
$order->load(['payment', 'cashReceipts']);
|
||||
|
||||
$request = Request::create('/');
|
||||
$array = (new OrderResource($order))->toArray($request);
|
||||
|
||||
return [
|
||||
'payment' => json_decode(json_encode($array['payment']), true),
|
||||
'cash_receipt' => json_decode(json_encode($array['cash_receipt']), true),
|
||||
'cash_receipts' => json_decode(json_encode($array['cash_receipts']), true),
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 현금영수증_발급이_결제영수증_주소를_덮어쓰지_않는다(): void
|
||||
{
|
||||
// payment.receipt_url = PG 매출전표, cash_receipts.receipt_url = 현금영수증.
|
||||
// 한 컬럼이 두 의미를 갖지 않도록 서로 침범하지 않아야 한다.
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
app(CashReceiptService::class)->issue(
|
||||
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
||||
);
|
||||
|
||||
$payment = $order->fresh()->payment;
|
||||
$array = json_decode(json_encode(
|
||||
(new OrderPaymentResource($payment))->toArray(Request::create('/'))
|
||||
), true);
|
||||
|
||||
$this->assertSame('https://pg.example.test/slip/1', $array['receipt_url'], 'PG 매출전표가 보존되어야 한다');
|
||||
$this->assertSame('https://pg.example.test/slip/1', $array['pg_receipt_url']);
|
||||
$this->assertTrue($array['is_cash_receipt_issued']);
|
||||
$this->assertSame('*******5678', $array['cash_receipt_identifier']);
|
||||
$this->assertNotNull($array['cash_receipt_issued_at']);
|
||||
|
||||
// 평문·암호문은 어디에도 없어야 한다
|
||||
$this->assertStringNotContainsString(self::IDENTIFIER, json_encode($array));
|
||||
$this->assertArrayNotHasKey('cash_receipt_identifier_encrypted', $array);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 주문_리소스가_결제영수증과_현금영수증을_따로_노출한다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
app(CashReceiptService::class)->issue(
|
||||
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
||||
);
|
||||
|
||||
$array = $this->resourceArray($order);
|
||||
|
||||
// 두 영수증이 각각 다른 경로로 조회된다 (복합결제 도입 시 두 버튼을 동시 노출 가능)
|
||||
$this->assertSame('https://pg.example.test/slip/1', $array['payment']['pg_receipt_url']);
|
||||
|
||||
$this->assertNotNull($array['cash_receipt']);
|
||||
$this->assertSame('https://example.test/receipt/receipt-1', $array['cash_receipt']['receipt_url']);
|
||||
$this->assertSame('*******5678', $array['cash_receipt']['identifier_masked']);
|
||||
$this->assertSame(CashReceiptType::INCOME->value, $array['cash_receipt']['receipt_type']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 발급_이력이_없으면_활성_영수증은_null_이다(): void
|
||||
{
|
||||
$order = $this->makeOrder();
|
||||
|
||||
$array = $this->resourceArray($order);
|
||||
|
||||
$this->assertNull($array['cash_receipt']);
|
||||
$this->assertCount(0, $array['cash_receipts']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 재발급_실패_상태는_활성없음_과_실패이력으로_드러난다(): void
|
||||
{
|
||||
// 관리자 화면의 "경고 배지 + 수동 재발급" 판정 근거.
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
$service = app(CashReceiptService::class);
|
||||
$service->issue($order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE);
|
||||
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.cash_receipt.cancel',
|
||||
fn (array $result, Order $order, string $provider, string $receiptKey) => [
|
||||
'success' => true, 'error_code' => null, 'error_message' => null,
|
||||
'receipt_key' => $receiptKey, 'raw_response' => null,
|
||||
],
|
||||
);
|
||||
|
||||
$this->issueShouldFail = true;
|
||||
$order->fresh()->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
|
||||
$service->syncFromOrder($order->fresh(), '부분환불');
|
||||
|
||||
$array = $this->resourceArray($order);
|
||||
|
||||
$this->assertNull($array['cash_receipt'], '활성 영수증이 없어야 한다');
|
||||
|
||||
// 최신순: 실패한 발급 → 취소 → 최초 발급
|
||||
$ledger = $array['cash_receipts'];
|
||||
$this->assertCount(3, $ledger);
|
||||
$this->assertSame(CashReceiptIssueStatus::FAILED->value, $ledger[0]['issue_status']);
|
||||
$this->assertSame('PROVIDER_ERROR', $ledger[0]['error_code']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 리소스는_프로바이더_원응답을_노출하지_않는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
app(CashReceiptService::class)->issue(
|
||||
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
||||
);
|
||||
|
||||
$array = $this->resourceArray($order);
|
||||
|
||||
$this->assertArrayNotHasKey('raw_response', $array['cash_receipt']);
|
||||
$this->assertStringNotContainsString(self::IDENTIFIER, json_encode($array['cash_receipt']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?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, '완전히잘못된값'));
|
||||
}
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderCancel;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderCancellationService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 주문 취소 → 현금영수증 동기화 연동 테스트 (A-7)
|
||||
*
|
||||
* syncFromOrder() 는 취소 트랜잭션이 커밋된 뒤에 호출된다. 따라서:
|
||||
* - 재발급이 실패해도 주문 취소는 롤백되지 않는다
|
||||
* - 실패 이력(FAILED)이 원장에 남아 관리자 수동 재발급의 근거가 된다
|
||||
*/
|
||||
class CashReceiptCancellationSyncTest extends ModuleTestCase
|
||||
{
|
||||
private const PROVIDER = 'tosspayments';
|
||||
|
||||
private const IDENTIFIER = '01012345678';
|
||||
|
||||
private bool $issueShouldFail = false;
|
||||
|
||||
private int $receiptSequence = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->issueShouldFail = false;
|
||||
$this->receiptSequence = 0;
|
||||
|
||||
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급/취소 프로바이더 리스너를 등록합니다.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
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' => null,
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.cash_receipt.cancel',
|
||||
fn (array $result, Order $order, string $provider, string $receiptKey) => $provider === self::PROVIDER
|
||||
? [
|
||||
'success' => true,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'receipt_key' => $receiptKey,
|
||||
'raw_response' => null,
|
||||
]
|
||||
: $result,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증이 발급된 무통장 결제완료 주문(옵션 2개)을 생성합니다.
|
||||
*
|
||||
* @return array{order: Order, options: array<int, OrderOption>}
|
||||
*/
|
||||
private function makeIssuedOrder(int $unitPrice = 10000, int $optionCount = 2): array
|
||||
{
|
||||
$total = $unitPrice * $optionCount;
|
||||
|
||||
$order = Order::factory()->create([
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'subtotal_amount' => $total,
|
||||
'total_amount' => $total,
|
||||
'total_paid_amount' => $total,
|
||||
'total_due_amount' => 0,
|
||||
'total_cancelled_amount' => 0,
|
||||
'cancellation_count' => 0,
|
||||
'paid_at' => now(),
|
||||
'promotions_applied_snapshot' => [],
|
||||
'shipping_policy_applied_snapshot' => [],
|
||||
// 무통장 실입금액 = 전액 현금성. 전액 과세.
|
||||
'total_cash_equivalent_amount' => $total,
|
||||
'total_tax_amount' => $total,
|
||||
'total_tax_free_amount' => 0,
|
||||
]);
|
||||
|
||||
$snapshot = [
|
||||
'product_snapshot' => [
|
||||
'id' => null, 'name' => ['ko' => 't', 'en' => 't'], 'product_code' => null,
|
||||
'sku' => null, 'brand_id' => null, 'list_price' => $unitPrice, 'selling_price' => $unitPrice,
|
||||
'currency_code' => 'KRW', 'stock_quantity' => 100, 'tax_status' => 'taxable',
|
||||
'tax_rate' => 10, 'has_options' => false, 'option_groups' => null, 'thumbnail_url' => null,
|
||||
],
|
||||
'option_snapshot' => [
|
||||
'id' => null, 'option_code' => null, 'option_values' => null, 'option_name' => 't',
|
||||
'price_adjustment' => 0, 'list_price' => $unitPrice, 'selling_price' => $unitPrice,
|
||||
'currency_code' => 'KRW', 'stock_quantity' => 100, 'weight' => 0, 'volume' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
$options = [];
|
||||
for ($i = 0; $i < $optionCount; $i++) {
|
||||
$options[] = OrderOption::factory()->forOrder($order)->create(array_merge([
|
||||
'quantity' => 1,
|
||||
'unit_price' => $unitPrice,
|
||||
'subtotal_price' => $unitPrice,
|
||||
'subtotal_paid_amount' => $unitPrice,
|
||||
'subtotal_discount_amount' => 0,
|
||||
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
], $snapshot));
|
||||
}
|
||||
|
||||
OrderPayment::factory()->forOrder($order)->create([
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
'paid_amount_local' => $total,
|
||||
'paid_amount_base' => $total,
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
$order = $order->fresh();
|
||||
|
||||
app(CashReceiptService::class)->issue(
|
||||
$order,
|
||||
CashReceiptType::INCOME,
|
||||
self::IDENTIFIER,
|
||||
CashReceiptIdentifierType::PHONE,
|
||||
);
|
||||
|
||||
return ['order' => $order->fresh(), 'options' => $options];
|
||||
}
|
||||
|
||||
private function activeReceipt(Order $order): ?OrderCashReceipt
|
||||
{
|
||||
return app(CashReceiptService::class)->findActiveReceipt($order->fresh());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 부분취소시_전체취소_후_잔여금액으로_재발급된다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
['order' => $order, 'options' => $options] = $this->makeIssuedOrder(10000, 2);
|
||||
|
||||
$this->assertSame(20000, (int) $this->activeReceipt($order)->amount);
|
||||
|
||||
app(OrderCancellationService::class)->cancelOrderOptions(
|
||||
order: $order,
|
||||
cancelItems: [['order_option_id' => $options[0]->id, 'cancel_quantity' => 1]],
|
||||
reason: '단순변심',
|
||||
reasonDetail: null,
|
||||
cancelledBy: null,
|
||||
cancelPg: false,
|
||||
);
|
||||
|
||||
$active = $this->activeReceipt($order);
|
||||
$this->assertNotNull($active, '잔여 금액에 대한 영수증이 재발급되어야 한다');
|
||||
$this->assertSame(10000, (int) $active->amount, '잔여 10000원으로 재발급');
|
||||
$this->assertSame(CashReceiptType::INCOME, $active->receipt_type, '발급 용도가 유지된다');
|
||||
|
||||
// 원장: 최초 발급 + 전체취소 + 재발급 = 3행
|
||||
$this->assertSame(3, OrderCashReceipt::where('order_id', $order->id)->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 전액취소시_영수증은_전체취소만_되고_재발급되지_않는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
['order' => $order] = $this->makeIssuedOrder(10000, 2);
|
||||
|
||||
app(OrderCancellationService::class)->cancelOrder(
|
||||
order: $order,
|
||||
reason: '단순변심',
|
||||
reasonDetail: null,
|
||||
cancelledBy: null,
|
||||
cancelPg: false,
|
||||
);
|
||||
|
||||
$this->assertNull($this->activeReceipt($order), '활성 영수증이 없어야 한다');
|
||||
|
||||
$cancelRow = OrderCashReceipt::where('order_id', $order->id)
|
||||
->where('transaction_type', CashReceiptTransactionType::CANCEL)
|
||||
->first();
|
||||
$this->assertNotNull($cancelRow);
|
||||
$this->assertSame(CashReceiptIssueStatus::COMPLETED, $cancelRow->issue_status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 취소는_성공하고_재발급만_실패해도_주문취소_트랜잭션은_롤백되지_않는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
['order' => $order, 'options' => $options] = $this->makeIssuedOrder(10000, 2);
|
||||
|
||||
// 최초 발급은 성공했고, 재발급 시점부터 프로바이더가 실패한다.
|
||||
$this->issueShouldFail = true;
|
||||
|
||||
app(OrderCancellationService::class)->cancelOrderOptions(
|
||||
order: $order,
|
||||
cancelItems: [['order_option_id' => $options[0]->id, 'cancel_quantity' => 1]],
|
||||
reason: '단순변심',
|
||||
reasonDetail: null,
|
||||
cancelledBy: null,
|
||||
cancelPg: false,
|
||||
);
|
||||
|
||||
// (a) 주문 취소는 확정되어 있어야 한다
|
||||
$this->assertSame(1, OrderCancel::where('order_id', $order->id)->count(), '취소 레코드가 남아야 한다');
|
||||
$this->assertSame(
|
||||
OrderStatusEnum::CANCELLED,
|
||||
$options[0]->fresh()->option_status,
|
||||
'취소된 옵션 상태가 확정되어야 한다',
|
||||
);
|
||||
|
||||
// (b) 이력 2행: 전체취소 COMPLETED + 재발급 FAILED
|
||||
$cancelRow = OrderCashReceipt::where('order_id', $order->id)
|
||||
->where('transaction_type', CashReceiptTransactionType::CANCEL)
|
||||
->first();
|
||||
$this->assertNotNull($cancelRow);
|
||||
$this->assertSame(CashReceiptIssueStatus::COMPLETED, $cancelRow->issue_status);
|
||||
|
||||
$failedIssue = OrderCashReceipt::where('order_id', $order->id)
|
||||
->where('transaction_type', CashReceiptTransactionType::ISSUE)
|
||||
->where('issue_status', CashReceiptIssueStatus::FAILED)
|
||||
->first();
|
||||
$this->assertNotNull($failedIssue, '재발급 실패 이력이 원장에 남아야 한다');
|
||||
$this->assertSame('PROVIDER_ERROR', $failedIssue->error_code);
|
||||
|
||||
// (c) 활성 영수증 없음 → 관리자 수동 재발급 경로가 열린다
|
||||
$this->assertNull($this->activeReceipt($order));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 프로바이더가_예외를_던져도_주문취소는_확정된다(): void
|
||||
{
|
||||
// syncFromOrder 는 이미 커밋된 취소를 되돌릴 수 없다 — 예외를 삼키고 로그만 남긴다.
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.cash_receipt.issue',
|
||||
fn (array $result, Order $order, string $provider, array $payload) => $provider === self::PROVIDER
|
||||
? [
|
||||
'success' => true, 'error_code' => null, 'error_message' => null,
|
||||
'receipt_key' => 'receipt-1', 'receipt_url' => 'https://example.test/r/1',
|
||||
'issue_number' => '1', 'raw_response' => null,
|
||||
]
|
||||
: $result,
|
||||
);
|
||||
HookManager::addFilter('sirsoft-ecommerce.cash_receipt.cancel', function () {
|
||||
throw new \RuntimeException('PG 통신 실패');
|
||||
});
|
||||
|
||||
['order' => $order, 'options' => $options] = $this->makeIssuedOrder(10000, 2);
|
||||
|
||||
app(OrderCancellationService::class)->cancelOrderOptions(
|
||||
order: $order,
|
||||
cancelItems: [['order_option_id' => $options[0]->id, 'cancel_quantity' => 1]],
|
||||
reason: '단순변심',
|
||||
reasonDetail: null,
|
||||
cancelledBy: null,
|
||||
cancelPg: false,
|
||||
);
|
||||
|
||||
$this->assertSame(1, OrderCancel::where('order_id', $order->id)->count(), '주문 취소는 확정되어야 한다');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 발급_이력이_없는_주문의_취소는_영수증을_건드리지_않는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
['order' => $order, 'options' => $options] = $this->makeIssuedOrder(10000, 2);
|
||||
|
||||
// 발급 이력을 지우고 취소 — syncFromOrder 는 no-op 이어야 한다.
|
||||
OrderCashReceipt::where('order_id', $order->id)->delete();
|
||||
|
||||
app(OrderCancellationService::class)->cancelOrderOptions(
|
||||
order: $order,
|
||||
cancelItems: [['order_option_id' => $options[0]->id, 'cancel_quantity' => 1]],
|
||||
reason: '단순변심',
|
||||
reasonDetail: null,
|
||||
cancelledBy: null,
|
||||
cancelPg: false,
|
||||
);
|
||||
|
||||
$this->assertSame(0, OrderCashReceipt::where('order_id', $order->id)->count());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// recoverFailedIssue() — 관리자 수동 복구
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* "취소 성공 + 재발급 실패" 중간 상태를 만듭니다.
|
||||
*
|
||||
* @return Order 활성 영수증이 사라진 주문
|
||||
*/
|
||||
private function makeReissueFailedState(): Order
|
||||
{
|
||||
['order' => $order, 'options' => $options] = $this->makeIssuedOrder(10000, 2);
|
||||
|
||||
$this->issueShouldFail = true;
|
||||
|
||||
app(OrderCancellationService::class)->cancelOrderOptions(
|
||||
order: $order,
|
||||
cancelItems: [['order_option_id' => $options[0]->id, 'cancel_quantity' => 1]],
|
||||
reason: '단순변심',
|
||||
reasonDetail: null,
|
||||
cancelledBy: null,
|
||||
cancelPg: false,
|
||||
);
|
||||
|
||||
$this->assertNull($this->activeReceipt($order), '사전 조건: 활성 영수증이 없어야 한다');
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recover_는_재발급_실패_상태를_잔여금액으로_복구한다(): void
|
||||
{
|
||||
// syncFromOrder 는 활성 영수증이 없으면 no-op 이므로 이 상태를 복구하지 못한다.
|
||||
$this->registerProvider();
|
||||
$order = $this->makeReissueFailedState();
|
||||
|
||||
$this->issueShouldFail = false;
|
||||
|
||||
$receipt = app(CashReceiptService::class)->recoverFailedIssue($order->fresh());
|
||||
|
||||
$this->assertNotNull($receipt);
|
||||
$this->assertSame(10000, (int) $receipt->amount, '잔여 금액으로 발급되어야 한다');
|
||||
$this->assertSame(CashReceiptType::INCOME, $receipt->receipt_type, '최초 발급 용도를 유지한다');
|
||||
$this->assertSame(CashReceiptIssueStatus::COMPLETED, $receipt->issue_status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recover_는_활성_영수증이_있으면_금액_동기화로_위임한다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
['order' => $order] = $this->makeIssuedOrder(10000, 2);
|
||||
|
||||
// 금액만 바뀐 상태 (취소 없이 직접 조작)
|
||||
$order->fresh()->update(['total_cash_equivalent_amount' => 5000, 'total_tax_amount' => 5000]);
|
||||
|
||||
$receipt = app(CashReceiptService::class)->recoverFailedIssue($order->fresh());
|
||||
|
||||
$this->assertNotNull($receipt);
|
||||
$this->assertSame(5000, (int) $receipt->amount);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recover_는_암호문이_폐기되었으면_실패이력만_남기고_발급하지_않는다(): void
|
||||
{
|
||||
// 구매확정으로 암호문이 폐기된 경우 — 관리자가 식별번호를 재입력해야 한다.
|
||||
$this->registerProvider();
|
||||
$order = $this->makeReissueFailedState();
|
||||
$this->issueShouldFail = false;
|
||||
|
||||
app(CashReceiptService::class)->purgeIdentifier($order->fresh());
|
||||
|
||||
$receipt = app(CashReceiptService::class)->recoverFailedIssue($order->fresh());
|
||||
|
||||
$this->assertNull($receipt);
|
||||
$this->assertNotNull(
|
||||
OrderCashReceipt::where('order_id', $order->id)->where('error_code', 'IDENTIFIER_UNAVAILABLE')->first(),
|
||||
'식별번호 부재 실패 이력이 남아야 한다',
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recover_는_발급_시도_이력이_없으면_아무것도_하지_않는다(): void
|
||||
{
|
||||
// 복구가 아니라 신규 발급이다 — issue() 를 써야 한다.
|
||||
$this->registerProvider();
|
||||
['order' => $order] = $this->makeIssuedOrder(10000, 2);
|
||||
OrderCashReceipt::where('order_id', $order->id)->delete();
|
||||
|
||||
$this->assertNull(app(CashReceiptService::class)->recoverFailedIssue($order->fresh()));
|
||||
$this->assertSame(0, OrderCashReceipt::where('order_id', $order->id)->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recover_는_발급대상_금액이_0이면_아무것도_하지_않는다(): void
|
||||
{
|
||||
// 전액 환불된 주문 — 활성 영수증이 없는 것이 정상 결과다.
|
||||
$this->registerProvider();
|
||||
$order = $this->makeReissueFailedState();
|
||||
$order->fresh()->update(['total_cash_equivalent_amount' => 0]);
|
||||
|
||||
$this->assertNull(app(CashReceiptService::class)->recoverFailedIssue($order->fresh()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* S2 신규 action 훅 발화 테스트
|
||||
*
|
||||
* - order.after_deposit_recorded : 관리자 "입금만 기록" 경로 (기존에 훅이 0개였다)
|
||||
* - order.after_purchase_confirmed : 구매확정 시점 (기존 order.after_confirm 은 결제완료 훅이라 이름만 비슷하다)
|
||||
*/
|
||||
class CashReceiptHookFiringTest extends ModuleTestCase
|
||||
{
|
||||
/** @var array<int, array> 발화된 훅 인자 기록 */
|
||||
private array $fired = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->fired = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 발화를 기록합니다.
|
||||
*/
|
||||
private function capture(string $hook): void
|
||||
{
|
||||
HookManager::addAction($hook, function (...$args) {
|
||||
$this->fired[] = $args;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 무통장 미결제 주문을 생성합니다.
|
||||
*/
|
||||
private function makeUnpaidDbankOrder(int $due = 11000): Order
|
||||
{
|
||||
$order = Order::factory()->create([
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'total_amount' => $due,
|
||||
'total_due_amount' => $due,
|
||||
'subtotal_amount' => $due,
|
||||
'base_shipping_amount' => 0,
|
||||
'extra_shipping_amount' => 0,
|
||||
'shipping_discount_amount' => 0,
|
||||
'total_product_coupon_discount_amount' => 0,
|
||||
'total_order_coupon_discount_amount' => 0,
|
||||
'total_code_discount_amount' => 0,
|
||||
'total_points_used_amount' => 0,
|
||||
]);
|
||||
|
||||
OrderPayment::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
|
||||
]);
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// order.after_deposit_recorded
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function 입금만_기록_경로에서_after_deposit_recorded_가_발화한다(): void
|
||||
{
|
||||
$this->capture('sirsoft-ecommerce.order.after_deposit_recorded');
|
||||
|
||||
$order = $this->makeUnpaidDbankOrder(11000);
|
||||
|
||||
// markOrderComplete = false → completePayment 를 타지 않고 recordDepositPayment 만 수행
|
||||
app(OrderProcessingService::class)->confirmManualDeposit($order, 11000.0, '홍길동', false);
|
||||
|
||||
$this->assertCount(1, $this->fired, 'after_deposit_recorded 가 1회 발화해야 한다');
|
||||
$this->assertInstanceOf(Order::class, $this->fired[0][0]);
|
||||
$this->assertSame($order->id, $this->fired[0][0]->id);
|
||||
$this->assertSame(11000.0, $this->fired[0][1]);
|
||||
|
||||
// 결제 레코드는 PAID 로 정합화된다
|
||||
$this->assertSame(PaymentStatusEnum::PAID, $order->fresh()->payment->payment_status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 결제완료_전이_경로에서는_after_deposit_recorded_가_발화하지_않는다(): void
|
||||
{
|
||||
// markOrderComplete=true 이고 주문이 아직 결제완료가 아니면 completePayment 에 위임되고,
|
||||
// 그 경로는 after_payment_complete 를 발화한다 (입금 기록 훅은 발화하지 않는다).
|
||||
$this->capture('sirsoft-ecommerce.order.after_deposit_recorded');
|
||||
$this->capture('sirsoft-ecommerce.order.after_payment_complete');
|
||||
|
||||
$order = $this->makeUnpaidDbankOrder(11000);
|
||||
$order->update(['order_status' => OrderStatusEnum::PENDING_PAYMENT]);
|
||||
|
||||
app(OrderProcessingService::class)->confirmManualDeposit($order->fresh(), 11000.0, null, true);
|
||||
|
||||
// capture 는 두 훅을 같은 배열에 모으므로, 발화한 것이 결제완료 훅뿐인지 확인한다.
|
||||
$this->assertCount(1, $this->fired, 'after_payment_complete 만 1회 발화해야 한다');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// order.after_purchase_confirmed
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 확정 가능한 옵션 1개짜리 주문을 만듭니다.
|
||||
*/
|
||||
private function makeConfirmableOrder(): array
|
||||
{
|
||||
$order = Order::factory()->create([
|
||||
'order_status' => OrderStatusEnum::DELIVERED,
|
||||
'confirmed_at' => null,
|
||||
]);
|
||||
|
||||
$option = OrderOption::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'option_status' => OrderStatusEnum::DELIVERED,
|
||||
'confirmed_at' => null,
|
||||
]);
|
||||
|
||||
return [$order->fresh(), $option->fresh()];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 유저_셀프_구매확정시_after_purchase_confirmed_가_발화한다(): void
|
||||
{
|
||||
$this->capture('sirsoft-ecommerce.order.after_purchase_confirmed');
|
||||
|
||||
[$order, $option] = $this->makeConfirmableOrder();
|
||||
|
||||
app(OrderService::class)->confirmOption($order, $option);
|
||||
|
||||
$this->assertCount(1, $this->fired, '구매확정 훅이 1회 발화해야 한다');
|
||||
$this->assertSame($order->id, $this->fired[0][0]->id);
|
||||
$this->assertNotNull($order->fresh()->confirmed_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 관리자_옵션_일괄확정시에도_after_purchase_confirmed_가_발화한다(): void
|
||||
{
|
||||
// OrderOptionService::transitionOrderStatus 경유 경로.
|
||||
// 유저 셀프 확정(OrderService::confirmOption)과 별개의 발화 지점이므로 따로 검증한다.
|
||||
$this->capture('sirsoft-ecommerce.order.after_purchase_confirmed');
|
||||
|
||||
[$order, $option] = $this->makeConfirmableOrder();
|
||||
|
||||
// 옵션을 확정 상태로 만든 뒤 부모 주문 상태를 파생 동기화한다.
|
||||
$option->update(['option_status' => OrderStatusEnum::CONFIRMED, 'confirmed_at' => now()]);
|
||||
app(OrderOptionService::class)->syncParentOrderStatus($order->id);
|
||||
|
||||
$this->assertCount(1, $this->fired, '옵션 일괄확정 경로에서도 구매확정 훅이 1회 발화해야 한다');
|
||||
$this->assertSame($order->id, $this->fired[0][0]->id);
|
||||
$this->assertSame(OrderStatusEnum::CONFIRMED, $order->fresh()->order_status);
|
||||
$this->assertNotNull($order->fresh()->confirmed_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 옵션_일괄확정_경로도_confirmed_at_이_있으면_재발화하지_않는다(): void
|
||||
{
|
||||
// 멱등: 두 발화 지점 모두 최초 확정 시점을 보존해야 한다.
|
||||
$this->capture('sirsoft-ecommerce.order.after_purchase_confirmed');
|
||||
|
||||
[$order, $option] = $this->makeConfirmableOrder();
|
||||
$confirmedAt = now()->subDay();
|
||||
$order->update(['confirmed_at' => $confirmedAt]);
|
||||
|
||||
$option->update(['option_status' => OrderStatusEnum::CONFIRMED, 'confirmed_at' => now()]);
|
||||
app(OrderOptionService::class)->syncParentOrderStatus($order->id);
|
||||
|
||||
$this->assertCount(0, $this->fired, '이미 확정된 주문은 훅을 재발화하지 않는다');
|
||||
$this->assertSame(
|
||||
$confirmedAt->toDateTimeString(),
|
||||
$order->fresh()->confirmed_at->toDateTimeString(),
|
||||
'최초 확정 시점이 보존되어야 한다',
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function confirmed_at_이_이미_있으면_구매확정_훅이_재발화하지_않는다(): void
|
||||
{
|
||||
// 멱등: 최초 확정 시점을 보존하고 훅도 1회만 발화한다.
|
||||
$this->capture('sirsoft-ecommerce.order.after_purchase_confirmed');
|
||||
|
||||
[$order, $option] = $this->makeConfirmableOrder();
|
||||
$confirmedAt = now()->subDay();
|
||||
$order->update(['confirmed_at' => $confirmedAt]);
|
||||
|
||||
app(OrderService::class)->confirmOption($order->fresh(), $option);
|
||||
|
||||
$this->assertCount(0, $this->fired, '이미 확정된 주문은 훅을 재발화하지 않는다');
|
||||
$this->assertSame(
|
||||
$confirmedAt->toDateTimeString(),
|
||||
$order->fresh()->confirmed_at->toDateTimeString(),
|
||||
'최초 확정 시점이 보존되어야 한다',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -496,9 +496,10 @@ class CashReceiptServiceTest extends ModuleTestCase
|
||||
$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, '이력은 유지');
|
||||
// 현금영수증 URL 은 이력 테이블이 보관한다. payment.receipt_url 은 PG 영수증(카드 매출전표)
|
||||
// 전용 컬럼이므로 현금영수증 발급이 건드리지 않는다.
|
||||
$this->assertNotNull($receipt->fresh()->receipt_url, '현금영수증 URL 은 이력에 유지');
|
||||
$this->assertSame(1, OrderCashReceipt::where('order_id', $order->id)->count());
|
||||
}
|
||||
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는
|
||||
# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경(S2)은
|
||||
# 서버 전용(발급 API · 자동발급 리스너 · 신규 훅 2종 · 금액변동 연동)이며 UI 산출물이 없다.
|
||||
# 신청/발급 UI 는 S3 범위 — 그 시점에 E2E axis 를 추가한다.
|
||||
feature: 현금영수증 발급 API · 입금완료 자동발급 · 금액변동 연동
|
||||
|
||||
description: |
|
||||
S1 이 만든 CashReceiptService 위에 관리자/유저/비회원 발급 API 와 입금완료 자동발급 리스너를 얹는다.
|
||||
|
||||
입금이 확인되는 경로는 두 갈래다 — 관리자 결제완료 전이(after_payment_complete)와
|
||||
관리자 "입금만 기록"(after_deposit_recorded, 본 세션 신설). 후자에는 그동안 훅이 하나도 없어
|
||||
자동발급이 통째로 누락되는 구멍이 있었다. 리스너가 두 훅을 모두 구독해 이를 메운다.
|
||||
|
||||
0원 주문 함정: $isZeroPayable 판정은 결제수단과 무관하게 잔여 결제액만 본다. 따라서
|
||||
"무통장 + 전액 마일리지" 0원 주문도 결제완료 훅을 발화시킨다. 발급 가능 판정에 현금성 금액 > 0 을
|
||||
명시적으로 포함해 그 주문에 0원 영수증을 발급하지 않는다(암묵적 귀결에 의존하지 않는다).
|
||||
|
||||
구매확정 훅(after_purchase_confirmed, 본 세션 신설)이 재발급용 식별번호 암호문을 폐기한다.
|
||||
기존 order.after_confirm 은 결제완료 훅이지 구매확정 훅이 아니다(이름만 비슷하다).
|
||||
|
||||
금액 변동 연동: 주문 취소가 DB 에 확정된 뒤 syncFromOrder() 를 호출한다. 트랜잭션 안에서 부르면
|
||||
(a) 재발급 실패 이력이 롤백에 휩쓸려 사라지고 (b) 외부 API 2회 왕복 동안 DB 잠금을 붙들며
|
||||
(c) 실제 영수증은 취소됐는데 DB 만 롤백되어 국세청 기록과 어긋난다.
|
||||
|
||||
발견한 사전 결함(본 세션에서 수정): total_cash_equivalent_amount 가 주문 생성 시에만 기록되고
|
||||
취소 재계산에서 갱신되지 않아 부분환불 후 재발급이 아예 동작하지 않았다. 산정 규칙을
|
||||
PaymentMethodEnum::resolveCashEquivalentAmount() 로 SSoT 화하고 취소 재계산이 이를 공유한다.
|
||||
|
||||
axes:
|
||||
deposit_path: # 입금 확인 경로 — 자동발급 리스너 구독 대상
|
||||
- payment_complete # 관리자 결제완료 전이 / 가상계좌 웹훅 → after_payment_complete
|
||||
- deposit_recorded # 관리자 "입금만 기록" → after_deposit_recorded (신설)
|
||||
- zero_amount_order # 무통장 + 전액 마일리지 → 훅은 발화하나 발급 금지
|
||||
|
||||
request_state: # 구매자의 신청 여부 × 자진발급 설정
|
||||
- requested # 주문 시 신청 → 저장된 용도·식별번호로 발급
|
||||
- not_requested_off # 미신청 + 자진발급 OFF → no-op
|
||||
- not_requested_on # 미신청 + 자진발급 ON → 0100001234 소득공제
|
||||
- requested_undecryptable # 신청했으나 복호화 불가 → 자진발급 대체 금지, 수동 발급 경로
|
||||
|
||||
issue_actor: # 발급 주체
|
||||
- auto_listener # 입금완료 자동 발급 (큐)
|
||||
- admin # 관리자 사후 발급 / 발급취소 / 재발급 복구
|
||||
- member # 회원 본인 사후 발급 (발급취소 불가)
|
||||
- guest # 비회원 토큰 사후 발급 (발급취소 불가)
|
||||
|
||||
issue_blocker: # 발급 불가 사유 (단일 가드가 판정)
|
||||
- none # 발급 가능
|
||||
- not_cash_payment # 무통장 아님 → 422
|
||||
- payment_not_paid # 미입금 → 422
|
||||
- already_issued # 이미 발급됨 → 409
|
||||
- no_issuable_amount # 현금성 금액 0 → 422
|
||||
- provider_not_configured # 프로바이더 미설정 → 422
|
||||
|
||||
receipt_type:
|
||||
- income # 소득공제용
|
||||
- expense # 지출증빙용
|
||||
|
||||
identifier_type:
|
||||
- phone # 휴대폰번호 (소득공제/지출증빙 공통)
|
||||
- card # 현금영수증 카드번호 (소득공제/지출증빙 공통)
|
||||
- business # 사업자등록번호 (지출증빙 전용, 체크섬)
|
||||
- self_issue # 국세청 자진발급 지정번호 (소득공제 전용)
|
||||
|
||||
amount_change: # 금액 변동 → syncFromOrder
|
||||
- partial_cancel # 부분취소 → 전체취소 + 잔여금액 재발급
|
||||
- full_cancel # 전액취소 → 전체취소만
|
||||
- no_receipt # 발급 이력 없음 → no-op
|
||||
|
||||
reissue_outcome: # 재발급 결과 (취소는 이미 커밋됨)
|
||||
- success # 재발급 성공
|
||||
- provider_failed # 프로바이더 실패 → FAILED 이력 + 주문취소 유지
|
||||
- provider_threw # 프로바이더 예외 → 삼키고 주문취소 유지
|
||||
|
||||
purchase_confirm_path: # 구매확정 경로 (암호문 폐기 훅)
|
||||
- user_self # 유저 셀프 확정 (OrderService::confirmOption)
|
||||
- admin_bulk # 옵션 일괄 확정 → 부모 주문 전이 (OrderOptionService)
|
||||
- already_confirmed # 재확정 → 훅 미발화(멱등), 최초 확정 시점 보존
|
||||
|
||||
effects:
|
||||
# 신규 훅
|
||||
- after_deposit_recorded_fires_on_record_only # "입금만 기록" 경로에서 신규 훅 발화
|
||||
- after_deposit_recorded_silent_on_complete_path # 결제완료 전이 경로에서는 미발화
|
||||
- after_purchase_confirmed_fires_on_first_confirm # confirmed_at 최초 세팅 시 1회만
|
||||
- after_purchase_confirmed_idempotent # 재확정 시 미발화 + 최초 확정 시점 보존
|
||||
- after_purchase_confirmed_fires_from_both_paths # 유저 셀프 / 옵션 일괄 확정 양 경로
|
||||
|
||||
# 자동발급 리스너
|
||||
- listener_subscribes_both_deposit_hooks # 두 입금 훅 모두 구독 (구멍 차단)
|
||||
- listener_dispatched_to_queue_not_sync # sync 미지정 = 기본 큐 (외부 API 를 트랜잭션에 묶지 않음)
|
||||
- listener_issues_when_requested # 신청 건 → 저장된 용도·식별번호로 발급
|
||||
- listener_self_issues_when_enabled # 미신청 + 자진발급 ON → 0100001234 소득공제
|
||||
- listener_noop_when_not_requested_and_off # 미신청 + 자진발급 OFF → no-op
|
||||
- listener_no_self_issue_fallback_on_decrypt_fail # 복호화 불가 시 자진발급으로 대체하지 않음
|
||||
- listener_idempotent_across_both_hooks # 한 입금에 두 훅이 도달해도 1회만 발급
|
||||
- listener_skips_zero_cash_equivalent_order # 무통장 + 전액 마일리지 0원 주문 → 발급 미시도
|
||||
- listener_failure_records_failed_and_survives # 발급 실패 → FAILED 이력, 입금확인은 성공 유지
|
||||
- listener_swallows_provider_exception # 프로바이더 예외 → 삼키고 로그만 (조용한 실패 금지)
|
||||
- listener_logs_on_swallowed_failure # 삼킨 실패는 반드시 error/warning 로그로 남음
|
||||
- identifier_never_appears_in_logs # 발급 실패·예외 로그 어디에도 식별번호 원본 부재
|
||||
|
||||
# 발급 가능 가드 (단일 SSoT)
|
||||
- blocker_guard_shared_by_listener_and_apis # 리스너와 3개 API 가 같은 판정을 공유
|
||||
- blocker_not_cash_payment_returns_422
|
||||
- blocker_payment_not_paid_returns_422
|
||||
- blocker_already_issued_returns_409 # 중복 발급은 409 로 구분
|
||||
- blocker_no_issuable_amount_returns_422
|
||||
- issue_guard_also_enforced_inside_service # 서비스 내부 이중 방어(활성 영수증 존재 시 no-op)
|
||||
|
||||
# 발급 API
|
||||
- admin_can_issue_after_the_fact # 주문 시 미신청 건의 사후 발급
|
||||
- admin_can_cancel_issued_receipt # 관리자 발급취소 (전액취소)
|
||||
- admin_cancel_without_active_receipt_returns_422
|
||||
- admin_reissue_recovers_failed_state # 재발급 API 가 "취소 성공 + 재발급 실패" 복구
|
||||
- member_can_issue_own_order # 회원 본인 주문 사후 발급
|
||||
- member_cannot_issue_others_order # 타인 주문 → 404
|
||||
- member_cannot_cancel_receipt # 유저 발급취소 경로 부재 (관리자 전용)
|
||||
- member_can_read_issue_status # 발급 상태 조회 (issuable + 영수증)
|
||||
- guest_issues_via_verified_token # 비회원은 조회 토큰으로만 접근
|
||||
- unauthenticated_user_route_returns_401
|
||||
- admin_without_permission_returns_403 # permission 미들웨어가 차단
|
||||
- identifier_never_returned_in_response # 응답 어디에도 평문 식별번호 부재
|
||||
- identifier_masked_in_ledger_rows # 원장 전 컬럼에 평문 부재
|
||||
|
||||
# 검증 (FormRequest + Custom Rule)
|
||||
- validation_lives_in_formrequest_only # Service 에 검증 로직 없음
|
||||
- business_number_checksum_enforced # 체크섬 불일치 → 422
|
||||
- income_with_business_number_rejected # 소득공제 + 사업자번호 → 422
|
||||
- expense_with_phone_allowed # 지출증빙 + 휴대폰번호 → 허용
|
||||
- self_issue_number_rejected_for_expense # 자진발급 지정번호 + 지출증빙 → 422
|
||||
- identifier_hyphens_normalized_before_validation
|
||||
|
||||
# 금액 변동 연동
|
||||
- sync_called_after_cancellation_commit # 취소 확정 이후 호출 (트랜잭션 밖)
|
||||
- partial_cancel_reissues_at_residual_amount # 부분취소 → 잔여금액으로 재발급
|
||||
- full_cancel_cancels_receipt_without_reissue # 전액취소 → 전체취소만
|
||||
- reissue_failure_does_not_rollback_cancellation # 재발급 실패해도 주문취소는 확정
|
||||
- reissue_failure_leaves_two_ledger_rows # cancel COMPLETED + issue FAILED
|
||||
- provider_exception_does_not_rollback_cancellation
|
||||
- sync_noop_when_no_receipt_history
|
||||
|
||||
# 현금성 금액 재계산 (사전 결함 수정)
|
||||
- cash_equivalent_rule_is_single_ssot # PaymentMethodEnum 이 산정 규칙 SSoT
|
||||
- cash_equivalent_recalculated_on_partial_cancel # 취소 재계산이 현금성 금액을 갱신
|
||||
- cash_equivalent_zeroed_on_full_cancel # 전액취소 → 현금성 0
|
||||
- cash_equivalent_zero_for_non_dbank_methods
|
||||
|
||||
# 리소스 노출 · 영수증 분리
|
||||
- payment_resource_exposes_pg_receipt_url # PG 영수증(카드 매출전표) URL 노출
|
||||
- cash_receipt_issue_does_not_overwrite_pg_url # 현금영수증 발급이 PG 영수증 URL 을 덮어쓰지 않음
|
||||
- pg_and_cash_receipts_independently_retrievable # 두 영수증을 각각 다른 경로로 조회 (복합결제 대비)
|
||||
- order_resource_exposes_active_cash_receipt # 활성 영수증 1건
|
||||
- order_resource_exposes_cash_receipt_ledger # 전체 이력 (경고 배지 판정 근거)
|
||||
- cash_receipt_resource_hides_raw_response # 프로바이더 원응답 미노출
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Rules/CashReceiptIdentifierTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/IssueCashReceiptOnDepositListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/PurgeCashReceiptIdentifierListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptHookFiringTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptCancellationSyncTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/PaymentMethodCashEquivalentTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/CashReceiptResourceTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/CashReceiptControllerTest.php
|
||||
@@ -347,6 +347,335 @@ class ApiDocPipelineTest extends TestCase
|
||||
$this->assertStringNotContainsString('TODO: 이 엔드포인트의 용도', $regenerated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 재생성_시_파라미터_표의_사람_용도_셀을_보존한다(): void
|
||||
{
|
||||
// 회귀: 전체 재생성이 @generated 블록 안 파라미터 표를 통째로 재조립하면서
|
||||
// 정적 추출로 재현 불가능한 도메인 서술(`용도` 셀)이 TODO 로 되돌아가던 문제.
|
||||
$scaffolder = new ApiDocScaffolder;
|
||||
|
||||
$route = [
|
||||
'method' => 'POST', 'uri' => '/api/y', 'name' => 'api.y.store',
|
||||
'controller' => 'C', 'controller_method' => 'store', 'permission' => null,
|
||||
'middleware' => [], 'path_params' => [],
|
||||
];
|
||||
// `memo` 는 ParameterDescriber 가 설명할 수 없는 도메인 특이 파라미터 → TODO 스텁이 된다.
|
||||
$request = [
|
||||
'request_class' => null,
|
||||
'params' => [['name' => 'memo', 'location' => 'body', 'type' => 'string', 'required' => false, 'allowed' => '', 'rules' => []]],
|
||||
'hook_filters' => [],
|
||||
];
|
||||
$schema = ['envelope' => ['data'], 'shape' => 'object', 'fields' => [], 'pagination' => false];
|
||||
|
||||
$section = $scaffolder->endpointSection($route, $request, $schema, ['status' => 200, 'skipped_reason' => null]);
|
||||
$header = "# Y\n";
|
||||
|
||||
$first = $scaffolder->mergeDocument(null, $header, [$section], ['api.y.store']);
|
||||
$this->assertStringContainsString('<!-- TODO: 용도 -->', $first);
|
||||
|
||||
// 사람이 `용도` 셀을 채운다 (마지막 열만 치환).
|
||||
$withCell = preg_replace(
|
||||
'/^\| memo \|(.*)\| <!-- TODO: 용도 --> \|$/m',
|
||||
'| memo |$1| 관리자 메모 (내부 관리용, 고객 비노출) |',
|
||||
$first
|
||||
);
|
||||
$this->assertStringContainsString('관리자 메모 (내부 관리용, 고객 비노출)', $withCell);
|
||||
|
||||
// 재생성해도 사람이 채운 셀이 살아남아야 한다.
|
||||
$regenerated = $scaffolder->mergeDocument($withCell, $header, [$section], ['api.y.store']);
|
||||
|
||||
$this->assertStringContainsString('관리자 메모 (내부 관리용, 고객 비노출)', $regenerated);
|
||||
$this->assertStringNotContainsString('<!-- TODO: 용도 -->', $regenerated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 실측_제외_응답_섹션을_사람이_채우면_재생성해도_보존한다(): void
|
||||
{
|
||||
// 회귀: 쓰기 메서드·미치환 path 등으로 실측이 불가한 엔드포인트는 응답 필드/예시 자리에
|
||||
// `<!-- 실측 제외: ... -->` 마커만 남는다. 사람이 그 자리를 코드 근거로 채워도 다음 재생성이
|
||||
// 블록을 통째로 재조립하며 마커로 되돌려, 작성분이 통째로 사라지던 문제.
|
||||
$scaffolder = new ApiDocScaffolder;
|
||||
|
||||
$route = [
|
||||
'method' => 'POST', 'uri' => '/api/z', 'name' => 'api.z.store',
|
||||
'controller' => 'C', 'controller_method' => 'store', 'permission' => null,
|
||||
'middleware' => [], 'path_params' => [],
|
||||
];
|
||||
$request = ['request_class' => null, 'params' => [], 'hook_filters' => []];
|
||||
|
||||
// 실측 실패(쓰기 메서드) → 응답 필드·예시 모두 마커
|
||||
$section = $scaffolder->endpointSection($route, $request, null, ['status' => null, 'skipped_reason' => 'write-method']);
|
||||
$header = "# Z\n";
|
||||
|
||||
$first = $scaffolder->mergeDocument(null, $header, [$section], ['api.z.store']);
|
||||
$this->assertStringContainsString('실측 제외: write-method — 응답 필드는 사람이 작성하세요.', $first);
|
||||
$this->assertStringContainsString('실측 제외: write-method — 응답 예시는 사람이 작성하세요.', $first);
|
||||
|
||||
// 사람이 두 자리를 코드 근거로 채운다.
|
||||
$withBody = str_replace(
|
||||
'<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->',
|
||||
"| 필드 | 타입 | 설명 |\n| --- | --- | --- |\n| issuable | boolean | 지금 발급이 가능한지 여부 |",
|
||||
$first
|
||||
);
|
||||
$withBody = str_replace(
|
||||
'<!-- 실측 제외: write-method — 응답 예시는 사람이 작성하세요. -->',
|
||||
"```json\n{\n \"success\": true,\n \"data\": null\n}\n```",
|
||||
$withBody
|
||||
);
|
||||
|
||||
// 재생성해도 사람이 채운 응답 필드 표와 응답 예시가 살아남아야 한다.
|
||||
$regenerated = $scaffolder->mergeDocument($withBody, $header, [$section], ['api.z.store']);
|
||||
|
||||
$this->assertStringContainsString('| issuable | boolean | 지금 발급이 가능한지 여부 |', $regenerated);
|
||||
$this->assertStringContainsString('"success": true', $regenerated);
|
||||
$this->assertStringNotContainsString('응답 필드는 사람이 작성하세요.', $regenerated);
|
||||
$this->assertStringNotContainsString('응답 예시는 사람이 작성하세요.', $regenerated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 문서에_엔드포인트가_여러_개여도_각자의_응답_본문만_보존한다(): void
|
||||
{
|
||||
// 회귀: extractGeneratedBlock() 은 앞의 `## ` 헤딩까지 거슬러 올라가는데 엔드포인트 헤딩은
|
||||
// `### ` 라서 문서 상단까지 올라가 여러 엔드포인트를 한 덩어리로 반환한다. 위치 기반으로
|
||||
// 응답 본문을 떼어내면 항상 "그 덩어리의 첫 엔드포인트" 본문을 집어, 두 번째 이후
|
||||
// 엔드포인트의 사람 작성분이 소실되고 첫 엔드포인트 본문이 복제되던 문제.
|
||||
$scaffolder = new ApiDocScaffolder;
|
||||
|
||||
$makeRoute = fn (string $name, string $uri): array => [
|
||||
'method' => 'POST', 'uri' => $uri, 'name' => $name,
|
||||
'controller' => 'C', 'controller_method' => 'store', 'permission' => null,
|
||||
'middleware' => [], 'path_params' => [],
|
||||
];
|
||||
$request = ['request_class' => null, 'params' => [], 'hook_filters' => []];
|
||||
$skipped = ['status' => null, 'skipped_reason' => 'write-method'];
|
||||
|
||||
$keys = ['api.a.store', 'api.b.store'];
|
||||
$sections = [
|
||||
$scaffolder->endpointSection($makeRoute('api.a.store', '/api/a'), $request, null, $skipped),
|
||||
$scaffolder->endpointSection($makeRoute('api.b.store', '/api/b'), $request, null, $skipped),
|
||||
];
|
||||
|
||||
// 문서 상단에 `## ` 헤딩이 있고 엔드포인트는 `### ` 로 나열되는 실제 구조를 재현한다.
|
||||
$header = "# Multi\n\n## TL;DR (5초 요약)\n\n설명\n";
|
||||
$first = $scaffolder->mergeDocument(null, $header, $sections, $keys);
|
||||
|
||||
// 두 엔드포인트의 응답 필드를 서로 다른 내용으로 채운다.
|
||||
$filled = preg_replace_callback(
|
||||
'/<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요\. -->/',
|
||||
function () {
|
||||
static $n = 0;
|
||||
$n++;
|
||||
|
||||
return "| 필드 | 타입 | 설명 |\n| --- | --- | --- |\n| field_{$n} | string | 엔드포인트 {$n} 전용 필드 |";
|
||||
},
|
||||
$first
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('엔드포인트 1 전용 필드', $filled);
|
||||
$this->assertStringContainsString('엔드포인트 2 전용 필드', $filled);
|
||||
|
||||
$regenerated = $scaffolder->mergeDocument($filled, $header, $sections, $keys);
|
||||
|
||||
// 각 엔드포인트가 자기 본문을 유지해야 한다 (2번이 1번 본문으로 덮이면 안 된다).
|
||||
$this->assertStringContainsString('엔드포인트 1 전용 필드', $regenerated);
|
||||
$this->assertStringContainsString('엔드포인트 2 전용 필드', $regenerated);
|
||||
$this->assertSame(1, substr_count($regenerated, '엔드포인트 1 전용 필드'));
|
||||
$this->assertSame(1, substr_count($regenerated, '엔드포인트 2 전용 필드'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 실측에_성공해도_사람이_쓴_응답_예시는_덮어쓰지_않는다(): void
|
||||
{
|
||||
// 회귀: `reissue` 는 실측 표본이 `data: null`(전액 환불) 하나뿐인데, 사람은 코드를 읽고
|
||||
// 성공 케이스 예시까지 써 두었다. "실측이 사람을 이긴다" 규칙은 그 성공 예시를 지웠다.
|
||||
// 실측은 기본값이지 덮어쓰기 권한이 아니다.
|
||||
$scaffolder = new ApiDocScaffolder;
|
||||
|
||||
$route = [
|
||||
'method' => 'POST', 'uri' => '/api/r', 'name' => 'api.r.reissue',
|
||||
'controller' => 'C', 'controller_method' => 'reissue', 'permission' => null,
|
||||
'middleware' => [], 'path_params' => [],
|
||||
];
|
||||
$request = ['request_class' => null, 'params' => [], 'hook_filters' => []];
|
||||
|
||||
// 실측은 성공하지만 표본이 빈약하다 (data: null).
|
||||
$schema = ['envelope' => ['data'], 'shape' => 'object', 'fields' => [], 'pagination' => false];
|
||||
$probeMeta = ['status' => 200, 'skipped_reason' => null, 'body' => ['success' => true, 'data' => null]];
|
||||
|
||||
$section = $scaffolder->endpointSection($route, $request, $schema, $probeMeta);
|
||||
$header = "# R\n";
|
||||
|
||||
$first = $scaffolder->mergeDocument(null, $header, [$section], ['api.r.reissue']);
|
||||
|
||||
// 실측 산출 예시에는 출처 마커가 붙는다.
|
||||
$this->assertStringContainsString('<!-- @probed -->', $first);
|
||||
|
||||
// 사람이 성공 케이스 예시를 덧붙여 더 풍부하게 만든다 (출처 마커 없음).
|
||||
$humanExample = "재발급 성공:\n\n```json\n{\n \"success\": true,\n \"data\": {\"id\": 14}\n}\n```\n\n전액 환불 시:\n\n```json\n{\n \"success\": true,\n \"data\": null\n}\n```";
|
||||
$withHuman = preg_replace(
|
||||
'/<!-- @probed -->.*?(?=\*\*에러 응답\*\*)/s',
|
||||
$humanExample."\n\n",
|
||||
$first
|
||||
);
|
||||
$this->assertStringContainsString('재발급 성공:', $withHuman);
|
||||
$this->assertStringNotContainsString('<!-- @probed -->', $withHuman);
|
||||
|
||||
// 같은 실측 결과로 재생성해도 사람이 쓴 예시가 살아남아야 한다.
|
||||
$regenerated = $scaffolder->mergeDocument($withHuman, $header, [$section], ['api.r.reissue']);
|
||||
|
||||
$this->assertStringContainsString('재발급 성공:', $regenerated);
|
||||
$this->assertStringContainsString('전액 환불 시:', $regenerated);
|
||||
$this->assertStringNotContainsString('<!-- @probed -->', $regenerated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 실측이_가능해지면_비어있던_응답_섹션은_실측_결과로_갱신된다(): void
|
||||
{
|
||||
// 위 보존 규칙이 "영원히 갱신 불가"가 되면 안 된다 — 사람이 손대지 않은 마커 상태에서
|
||||
// 실측이 성공하면 실측 표/예시로 갱신되어야 한다.
|
||||
$scaffolder = new ApiDocScaffolder;
|
||||
|
||||
$route = [
|
||||
'method' => 'POST', 'uri' => '/api/z', 'name' => 'api.z.store',
|
||||
'controller' => 'C', 'controller_method' => 'store', 'permission' => null,
|
||||
'middleware' => [], 'path_params' => [],
|
||||
];
|
||||
$request = ['request_class' => null, 'params' => [], 'hook_filters' => []];
|
||||
$header = "# Z\n";
|
||||
|
||||
$skipped = $scaffolder->endpointSection($route, $request, null, ['status' => null, 'skipped_reason' => 'write-method']);
|
||||
$first = $scaffolder->mergeDocument(null, $header, [$skipped], ['api.z.store']);
|
||||
$this->assertStringContainsString('실측 제외: write-method', $first);
|
||||
|
||||
// 이번 run 에서 실측 성공
|
||||
$schema = ['envelope' => ['data'], 'shape' => 'object', 'fields' => [['name' => 'id', 'type' => 'integer', 'sample' => '1']], 'pagination' => false];
|
||||
$probed = $scaffolder->endpointSection($route, $request, $schema, ['status' => 200, 'skipped_reason' => null]);
|
||||
|
||||
$regenerated = $scaffolder->mergeDocument($first, $header, [$probed], ['api.z.store']);
|
||||
|
||||
$this->assertStringNotContainsString('실측 제외: write-method', $regenerated);
|
||||
$this->assertStringContainsString('| id |', $regenerated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 재생성_시_응답_필드_표의_사람_설명_셀을_보존한다(): void
|
||||
{
|
||||
// 파라미터 표와 동일한 회귀 — 응답 필드 표의 마지막 열(용도/설명)도 사람이 채운다.
|
||||
$scaffolder = new ApiDocScaffolder;
|
||||
|
||||
$route = [
|
||||
'method' => 'GET', 'uri' => '/api/z', 'name' => 'api.z.show',
|
||||
'controller' => 'C', 'controller_method' => 'show', 'permission' => null,
|
||||
'middleware' => [], 'path_params' => [],
|
||||
];
|
||||
$request = ['request_class' => null, 'params' => [], 'hook_filters' => []];
|
||||
// `nickname` 은 ResourceFieldDescriber 가 설명하지 못하는 도메인 특이 필드.
|
||||
$schema = [
|
||||
'envelope' => ['data'], 'shape' => 'object',
|
||||
'fields' => [['name' => 'nickname', 'type' => 'string', 'sample' => 'gnu']],
|
||||
'pagination' => false,
|
||||
];
|
||||
|
||||
$section = $scaffolder->endpointSection($route, $request, $schema, ['status' => 200, 'skipped_reason' => null]);
|
||||
$header = "# Z\n";
|
||||
|
||||
$first = $scaffolder->mergeDocument(null, $header, [$section], ['api.z.show']);
|
||||
$this->assertStringContainsString('<!-- TODO: 설명 -->', $first);
|
||||
|
||||
// 응답 필드 표의 마지막 열을 사람이 채운 상태로 만든다.
|
||||
$withCell = preg_replace(
|
||||
'/^\| nickname \|(.*)\| <!-- TODO: 설명 --> \|$/m',
|
||||
'| nickname |$1| 화면에 표시되는 별명 (중복 허용) |',
|
||||
$first
|
||||
);
|
||||
$this->assertStringContainsString('화면에 표시되는 별명 (중복 허용)', $withCell);
|
||||
|
||||
$regenerated = $scaffolder->mergeDocument($withCell, $header, [$section], ['api.z.show']);
|
||||
|
||||
$this->assertStringContainsString('화면에 표시되는 별명 (중복 허용)', $regenerated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 재생성_시_사람이_고쳐쓴_셀은_자동_설명으로_되돌아가지_않는다(): void
|
||||
{
|
||||
// 회귀: ParameterDescriber 가 값을 만들어내는 셀(TODO 가 아닌 셀)은
|
||||
// 사람이 도메인에 맞게 고쳐도 매 재생성마다 자동 설명으로 덮어써지던 문제.
|
||||
// 예: `identifier` → "대상 확장/리소스의 식별자" (도메인 무관 일반화)
|
||||
$scaffolder = new ApiDocScaffolder;
|
||||
|
||||
$route = [
|
||||
'method' => 'POST', 'uri' => '/api/w', 'name' => 'api.w.store',
|
||||
'controller' => 'C', 'controller_method' => 'store', 'permission' => null,
|
||||
'middleware' => [], 'path_params' => [],
|
||||
];
|
||||
$request = [
|
||||
'request_class' => null,
|
||||
'params' => [['name' => 'identifier', 'location' => 'body', 'type' => 'string', 'required' => true, 'allowed' => '', 'rules' => []]],
|
||||
'hook_filters' => [],
|
||||
];
|
||||
$schema = ['envelope' => ['data'], 'shape' => 'object', 'fields' => [], 'pagination' => false];
|
||||
|
||||
$section = $scaffolder->endpointSection($route, $request, $schema, ['status' => 200, 'skipped_reason' => null]);
|
||||
$header = "# W\n";
|
||||
|
||||
// 최초 생성: describer 가 일반 설명을 채운다 (TODO 아님).
|
||||
$first = $scaffolder->mergeDocument(null, $header, [$section], ['api.w.store']);
|
||||
$this->assertStringContainsString('식별자', $first);
|
||||
$this->assertStringNotContainsString('<!-- TODO: 용도 -->', $first);
|
||||
|
||||
// 사람이 도메인 설명으로 고쳐 쓴다.
|
||||
$withCell = preg_replace(
|
||||
'/^\| identifier \|(.*)\| [^|]+ \|$/m',
|
||||
'| identifier |$1| 현금영수증 식별번호 (휴대폰/카드/사업자번호) |',
|
||||
$first
|
||||
);
|
||||
$this->assertStringContainsString('현금영수증 식별번호', $withCell);
|
||||
|
||||
// 재생성해도 사람이 고친 셀이 유지되어야 한다.
|
||||
$regenerated = $scaffolder->mergeDocument($withCell, $header, [$section], ['api.w.store']);
|
||||
|
||||
$this->assertStringContainsString('현금영수증 식별번호', $regenerated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 재생성_시_이스케이프된_파이프를_담은_셀도_보존한다(): void
|
||||
{
|
||||
// 회귀: 표 셀에 `\|` (이스케이프된 파이프)가 있으면 열 분해가 어긋나
|
||||
// 보존 대상 행으로 인식되지 못하고 TODO 로 퇴행하던 문제.
|
||||
// 실제 사례: `인증 대상 해시 필터 (SHA256(email\|phone), PII 원본 대신 해시로 추적)`
|
||||
$scaffolder = new ApiDocScaffolder;
|
||||
|
||||
$route = [
|
||||
'method' => 'GET', 'uri' => '/api/v', 'name' => 'api.v.index',
|
||||
'controller' => 'C', 'controller_method' => 'index', 'permission' => null,
|
||||
'middleware' => [], 'path_params' => [],
|
||||
];
|
||||
$request = [
|
||||
'request_class' => null,
|
||||
'params' => [['name' => 'target_hash', 'location' => 'query', 'type' => 'string', 'required' => false, 'allowed' => '', 'rules' => []]],
|
||||
'hook_filters' => [],
|
||||
];
|
||||
$schema = ['envelope' => ['data'], 'shape' => 'object', 'fields' => [], 'pagination' => false];
|
||||
|
||||
$section = $scaffolder->endpointSection($route, $request, $schema, ['status' => 200, 'skipped_reason' => null]);
|
||||
$header = "# V\n";
|
||||
|
||||
$first = $scaffolder->mergeDocument(null, $header, [$section], ['api.v.index']);
|
||||
$this->assertStringContainsString('<!-- TODO: 용도 -->', $first);
|
||||
|
||||
$human = '인증 대상 해시 필터 (SHA256(email\\|phone), PII 원본 대신 해시로 추적)';
|
||||
$stubRow = '| target_hash | query | string | 아니오 | — | <!-- TODO: 용도 --> |';
|
||||
$humanRow = '| target_hash | query | string | 아니오 | — | '.$human.' |';
|
||||
|
||||
$this->assertStringContainsString($stubRow, $first);
|
||||
$withCell = str_replace($stubRow, $humanRow, $first);
|
||||
|
||||
$regenerated = $scaffolder->mergeDocument($withCell, $header, [$section], ['api.v.index']);
|
||||
|
||||
$this->assertStringContainsString($human, $regenerated);
|
||||
$this->assertStringNotContainsString('<!-- TODO: 용도 -->', $regenerated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ge_t_인증_엔드포인트_요청_예시가_raw_htt_p_요청이다(): void
|
||||
{
|
||||
|
||||
@@ -51,6 +51,32 @@ class ParameterDescriberTest extends TestCase
|
||||
$this->assertStringContainsString('표시 정렬 순서', $describer->describe('order', 'body', 'integer'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function path_의_order_는_정렬방향이_아니라_리소스_식별자다(): void
|
||||
{
|
||||
// 라우트 모델 바인딩 {order} 는 주문 리소스를 가리킨다 — 정렬 방향일 수 없다.
|
||||
// (회귀: `| order | path | ... | 정렬 방향 (asc 오름차순 / desc 내림차순) |` 오염)
|
||||
$describer = new ParameterDescriber;
|
||||
|
||||
$description = $describer->describe('order', 'path', 'string');
|
||||
|
||||
$this->assertNotNull($description);
|
||||
$this->assertStringNotContainsString('정렬 방향', $description);
|
||||
$this->assertStringNotContainsString('asc', $description);
|
||||
$this->assertStringContainsString('식별자', $description);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function path_의_sort_order_도_정렬방향으로_설명하지_않는다(): void
|
||||
{
|
||||
// path 에 sort_order 가 올 일은 없지만, 위치 우선 규칙이 이름보다 앞서야 한다.
|
||||
$describer = new ParameterDescriber;
|
||||
|
||||
$description = $describer->describe('sort_order', 'path', 'string');
|
||||
|
||||
$this->assertStringNotContainsString('정렬 방향', (string) $description);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function path_식별자_파라미터를_위치_기반으로_설명한다(): void
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user