무통장입금 주문의 현금영수증 발급을 관리자·회원·비회원 세 경로에서 제공하고,
입금이 확인되면 자동 발급하며, 부분환불로 금액이 바뀌면 전액취소 후 재발급한다.
발급 프로바이더에 종속되지 않도록 발급/취소는 필터 훅으로 위임한다.
자동발급 리스너는 결제완료 전이(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" 로
설명하던 오염도 정정했다.
211 lines
8.7 KiB
PHP
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(),
|
|
'최초 확정 시점이 보존되어야 한다',
|
|
);
|
|
}
|
|
}
|