feat(ecommerce): 현금영수증 발급 회차를 훅 페이로드로 제공
토스페이먼츠는 동일 orderId 로 재발급을 요청하면 중복으로 거부한다.
계획서 §B-8 은 "{order_number}-cr{seq}" 형태의 회차 접미를 요구하는데,
기존 발급 훅 페이로드에는 order_number 원본만 있어 리스너가 회차를 알 수 없었다.
회차는 코어가 센다. 프로바이더 리스너가 이력 테이블 구조를 알 필요가 없고,
향후 팝빌·바로빌 등 다른 발급사도 같은 숫자를 그대로 쓸 수 있다.
프로바이더 종속 문자열 조립은 리스너 내부에만 둔다.
실패한 발급도 회차를 소모한다 — 프로바이더에 요청이 도달한 뒤 응답만 실패한
경우 같은 식별자를 재사용하면 중복 거부되기 때문이다. 취소 이력은 소모하지 않는다.
This commit is contained in:
+14
@@ -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 # 감소·증가 모두 전체취소 후 전액 재발급
|
||||
|
||||
Reference in New Issue
Block a user