feat(ecommerce): 현금영수증 발급 회차를 훅 페이로드로 제공

토스페이먼츠는 동일 orderId 로 재발급을 요청하면 중복으로 거부한다.
계획서 §B-8 은 "{order_number}-cr{seq}" 형태의 회차 접미를 요구하는데,
기존 발급 훅 페이로드에는 order_number 원본만 있어 리스너가 회차를 알 수 없었다.

회차는 코어가 센다. 프로바이더 리스너가 이력 테이블 구조를 알 필요가 없고,
향후 팝빌·바로빌 등 다른 발급사도 같은 숫자를 그대로 쓸 수 있다.
프로바이더 종속 문자열 조립은 리스너 내부에만 둔다.

실패한 발급도 회차를 소모한다 — 프로바이더에 요청이 도달한 뒤 응답만 실패한
경우 같은 식별자를 재사용하면 중복 거부되기 때문이다. 취소 이력은 소모하지 않는다.
This commit is contained in:
HeuJung
2026-08-06 11:48:13 +09:00
parent af1da5fe0b
commit cd82fe3fb4
5 changed files with 94 additions and 0 deletions
@@ -53,4 +53,18 @@ interface OrderCashReceiptRepositoryInterface
* @return OrderCashReceipt|null 이력 (없으면 null)
*/
public function findByReceiptKey(string $receiptKey): ?OrderCashReceipt;
/**
* 주문의 발급(issue) 시도 횟수를 반환합니다. (성공/실패 무관)
*
* 프로바이더가 발급 요청 식별자를 회차로 구분해야 할 때 쓰인다.
* 예: 토스페이먼츠는 동일 orderId 재사용을 중복으로 거부하므로 회차 접미가 필요하다.
*
* 실패한 시도까지 세는 이유 — 프로바이더에 요청이 도달한 뒤 응답만 실패했을 수 있고,
* 그 경우 같은 식별자를 재사용하면 중복 거부된다.
*
* @param Order $order 주문 모델
* @return int 발급 이력 건수
*/
public function countIssues(Order $order): int;
}
@@ -94,4 +94,15 @@ class OrderCashReceiptRepository implements OrderCashReceiptRepositoryInterface
->orderByDesc('id')
->first();
}
/**
* {@inheritDoc}
*/
public function countIssues(Order $order): int
{
return $this->model->newQuery()
->where('order_id', $order->id)
->where('transaction_type', CashReceiptTransactionType::ISSUE->value)
->count();
}
}
@@ -106,6 +106,10 @@ class CashReceiptService
'tax_free_amount' => $target['tax_free_amount'],
'order_name' => $this->buildOrderName($order),
'order_number' => $order->order_number,
// 이번 발급의 회차 (1부터). 프로바이더가 발급 요청 식별자를 회차로 구분할 때 사용한다.
// 토스는 동일 orderId 재사용을 중복 거부하므로 "{order_number}-cr{seq}" 로 접미한다.
// 프로바이더 종속 문자열 조립은 리스너가 하고, 코어는 숫자만 제공한다.
'issue_sequence' => $this->repository->countIssues($order) + 1,
];
$result = HookManager::applyFilters(
@@ -591,6 +591,67 @@ class CashReceiptServiceTest extends ModuleTestCase
);
}
#[Test]
public function 최초_발급의_회차는_1이다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(1, $this->issueCalls[0]['payload']['issue_sequence']);
}
#[Test]
public function 재발급의_회차는_증가한다(): void
{
// 토스는 동일 orderId 재사용을 중복 거부하므로 재발급마다 회차가 달라야 한다.
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$order->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
$this->service()->syncFromOrder($order->fresh(), '부분환불');
$this->assertSame(1, $this->issueCalls[0]['payload']['issue_sequence']);
$this->assertSame(2, $this->issueCalls[1]['payload']['issue_sequence'], '재발급은 2회차');
}
#[Test]
public function 발급이_실패해도_다음_회차는_증가한다(): void
{
// 프로바이더에 요청이 도달한 뒤 응답만 실패했을 수 있다.
// 같은 식별자를 재사용하면 중복 거부되므로 실패한 시도도 회차를 소모해야 한다.
$this->registerProvider();
$this->issueShouldFail = true;
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(1, $this->issueCalls[0]['payload']['issue_sequence']);
$this->issueShouldFail = false;
$this->service()->issue($order->fresh(), CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(2, $this->issueCalls[1]['payload']['issue_sequence'], '실패한 1회차를 건너뛴다');
}
#[Test]
public function 취소_이력은_발급_회차를_소모하지_않는다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->service()->cancelAll($order->fresh(), '전액취소');
// 취소 1건이 남았지만 발급 회차는 issue 이력만 센다
$repository = app(OrderCashReceiptRepositoryInterface::class);
$this->assertSame(1, $repository->countIssues($order->fresh()));
$this->service()->issue($order->fresh(), CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(2, $this->issueCalls[1]['payload']['issue_sequence']);
}
#[Test]
public function 주문_삭제_시_현금영수증_이력도_명시적으로_삭제된다(): void
{
@@ -80,6 +80,10 @@ effects:
- issue_delegates_to_provider_filter # 발급을 cash_receipt.issue 필터로 위임
- cancel_delegates_without_amount # 취소 필터에 금액을 넘기지 않음(항상 전액취소)
- issue_records_ledger_and_payment_summary # 이력 + 결제 요약 컬럼 동시 갱신
- issue_sequence_starts_at_one # 최초 발급 회차 = 1
- issue_sequence_increments_on_reissue # 재발급마다 회차 증가 (토스 orderId 중복 거부 회피)
- failed_issue_consumes_sequence # 실패한 발급도 회차를 소모 (프로바이더 도달 후 실패 대비)
- cancel_does_not_consume_issue_sequence # 취소 이력은 발급 회차를 소모하지 않음
# syncFromOrder — 무상태 재계산
- sync_reissues_on_amount_change # 감소·증가 모두 전체취소 후 전액 재발급