Files
HeuJung 58560eefb1 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" 로
 설명하던 오염도 정정했다.
2026-08-06 12:17:24 +09:00

211 lines
8.7 KiB
PHP

<?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(),
'최초 확정 시점이 보존되어야 한다',
);
}
}