diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderCashReceiptRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderCashReceiptRepositoryInterface.php index 4c480c76..cb31644a 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderCashReceiptRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderCashReceiptRepositoryInterface.php @@ -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; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderCashReceiptRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderCashReceiptRepository.php index c1d6d99b..9037b733 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderCashReceiptRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderCashReceiptRepository.php @@ -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(); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php index f46cf88b..b08ae825 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php @@ -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( diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php index 81cfc8e2..f1d23f2c 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php @@ -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 { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/cash-receipt-infrastructure.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/cash-receipt-infrastructure.yaml index 79c0570c..504f13ab 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/cash-receipt-infrastructure.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/cash-receipt-infrastructure.yaml @@ -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 # 감소·증가 모두 전체취소 후 전액 재발급