Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/CashReceiptControllerTest.php
T
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

512 lines
19 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
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\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 현금영수증 발급 API 테스트 (A-6 / A-8-3 / D12)
*
* 관리자: 발급 / 발급취소 / 재발급 복구
* 유저: 발급 / 상태조회 (발급취소는 제공하지 않는다)
*/
class CashReceiptControllerTest extends ModuleTestCase
{
private const PROVIDER = 'tosspayments';
private const IDENTIFIER = '01012345678';
private const BUSINESS_NUMBER = '1078648269';
protected User $adminUser;
private bool $issueShouldFail = false;
private int $receiptSequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->issueShouldFail = false;
$this->receiptSequence = 0;
$this->adminUser = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
}
private function registerProvider(): void
{
HookManager::addFilter(
'sirsoft-ecommerce.cash_receipt.issue',
function (array $result, Order $order, string $provider, array $payload) {
if ($provider !== self::PROVIDER) {
return $result;
}
if ($this->issueShouldFail) {
return [
'success' => false,
'error_code' => 'PROVIDER_ERROR',
'error_message' => '발급 실패',
'receipt_key' => null,
'receipt_url' => null,
'issue_number' => null,
'raw_response' => null,
];
}
$key = 'receipt-'.(++$this->receiptSequence);
return [
'success' => true,
'error_code' => null,
'error_message' => null,
'receipt_key' => $key,
'receipt_url' => "https://example.test/receipt/{$key}",
'issue_number' => '12345678',
'raw_response' => null,
];
},
);
HookManager::addFilter(
'sirsoft-ecommerce.cash_receipt.cancel',
fn (array $result, Order $order, string $provider, string $receiptKey) => $provider === self::PROVIDER
? [
'success' => true, 'error_code' => null, 'error_message' => null,
'receipt_key' => $receiptKey, 'raw_response' => null,
]
: $result,
);
}
/**
* 무통장 결제완료 주문을 생성합니다.
*
* @param array<string, mixed> $paymentOverrides 결제 레코드 오버라이드
*/
private function makeOrder(?User $owner = null, int $cash = 11000, array $paymentOverrides = []): Order
{
$order = Order::factory()->create([
'user_id' => $owner?->id,
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'total_amount' => $cash,
'total_cash_equivalent_amount' => $cash,
'total_tax_amount' => $cash,
'total_tax_free_amount' => 0,
]);
OrderPayment::factory()->forOrder($order)->create(array_merge([
'payment_method' => PaymentMethodEnum::DBANK,
'payment_status' => PaymentStatusEnum::PAID,
], $paymentOverrides));
return $order->fresh();
}
private function adminUrl(Order $order, string $suffix = ''): string
{
return "/api/modules/sirsoft-ecommerce/admin/orders/{$order->id}/cash-receipt{$suffix}";
}
private function userUrl(Order $order): string
{
return "/api/modules/sirsoft-ecommerce/user/orders/{$order->id}/cash-receipt";
}
private function issuePayload(): array
{
return [
'receipt_type' => CashReceiptType::INCOME->value,
'identifier_type' => CashReceiptIdentifierType::PHONE->value,
'identifier' => self::IDENTIFIER,
];
}
// ─────────────────────────────────────────────
// 관리자 발급
// ─────────────────────────────────────────────
#[Test]
public function 관리자는_사후_발급할_수_있고_식별번호는_마스킹되어_저장된다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$response = $this->actingAs($this->adminUser)
->postJson($this->adminUrl($order), $this->issuePayload());
$response->assertOk();
$response->assertJsonPath('data.identifier_masked', '*******5678');
$response->assertJsonPath('data.receipt_url', 'https://example.test/receipt/receipt-1');
// 응답 어디에도 평문이 없어야 한다
$this->assertStringNotContainsString(self::IDENTIFIER, $response->getContent());
}
#[Test]
public function 관리자는_지출증빙_사업자번호로_발급할_수_있다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$this->actingAs($this->adminUser)
->postJson($this->adminUrl($order), [
'receipt_type' => CashReceiptType::EXPENSE->value,
'identifier_type' => CashReceiptIdentifierType::BUSINESS->value,
'identifier' => self::BUSINESS_NUMBER,
])
->assertOk()
->assertJsonPath('data.receipt_type', CashReceiptType::EXPENSE->value);
}
#[Test]
public function 사업자번호_체크섬이_틀리면_422_를_반환한다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$this->actingAs($this->adminUser)
->postJson($this->adminUrl($order), [
'receipt_type' => CashReceiptType::EXPENSE->value,
'identifier_type' => CashReceiptIdentifierType::BUSINESS->value,
'identifier' => '1078648260',
])
->assertStatus(422);
}
#[Test]
public function 소득공제_사업자번호_조합은_거부된다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$this->actingAs($this->adminUser)
->postJson($this->adminUrl($order), [
'receipt_type' => CashReceiptType::INCOME->value,
'identifier_type' => CashReceiptIdentifierType::BUSINESS->value,
'identifier' => self::BUSINESS_NUMBER,
])
->assertStatus(422);
}
#[Test]
public function 무통장이_아닌_주문은_발급이_차단된다(): void
{
$this->registerProvider();
$order = $this->makeOrder(null, 11000, ['payment_method' => PaymentMethodEnum::CARD]);
$this->actingAs($this->adminUser)
->postJson($this->adminUrl($order), $this->issuePayload())
->assertStatus(422)
->assertJsonPath('errors.error_code', 'NOT_CASH_PAYMENT');
}
#[Test]
public function 미결제_주문은_발급이_차단된다(): void
{
$this->registerProvider();
$order = $this->makeOrder(null, 11000, ['payment_status' => PaymentStatusEnum::WAITING_DEPOSIT]);
$this->actingAs($this->adminUser)
->postJson($this->adminUrl($order), $this->issuePayload())
->assertStatus(422)
->assertJsonPath('errors.error_code', 'PAYMENT_NOT_PAID');
}
#[Test]
public function 이미_발급된_주문에_다시_발급하면_409_를_반환한다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$this->actingAs($this->adminUser)->postJson($this->adminUrl($order), $this->issuePayload())->assertOk();
$this->actingAs($this->adminUser)
->postJson($this->adminUrl($order), $this->issuePayload())
->assertStatus(409)
->assertJsonPath('errors.error_code', 'ALREADY_ISSUED');
}
#[Test]
public function 프로바이더_발급_실패시_422_와_에러코드를_반환한다(): void
{
$this->registerProvider();
$this->issueShouldFail = true;
$order = $this->makeOrder();
$this->actingAs($this->adminUser)
->postJson($this->adminUrl($order), $this->issuePayload())
->assertStatus(422)
->assertJsonPath('errors.error_code', 'PROVIDER_ERROR');
}
#[Test]
public function 권한이_없는_관리자는_발급할_수_없다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$weakAdmin = $this->createAdminUser();
$this->actingAs($weakAdmin)
->postJson($this->adminUrl($order), $this->issuePayload())
->assertStatus(403);
}
// ─────────────────────────────────────────────
// 관리자 발급취소 / 재발급
// ─────────────────────────────────────────────
#[Test]
public function 관리자는_발급취소할_수_있다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$this->actingAs($this->adminUser)->postJson($this->adminUrl($order), $this->issuePayload())->assertOk();
$this->actingAs($this->adminUser)->deleteJson($this->adminUrl($order))->assertOk();
$this->assertNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
}
#[Test]
public function 활성_영수증이_없으면_발급취소는_422_다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$this->actingAs($this->adminUser)
->deleteJson($this->adminUrl($order))
->assertStatus(422)
->assertJsonPath('errors.error_code', 'NO_ACTIVE_RECEIPT');
}
#[Test]
public function 재발급_ap_i_는_재발급_실패_상태를_복구한다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$this->actingAs($this->adminUser)->postJson($this->adminUrl($order), $this->issuePayload())->assertOk();
// 금액 변동 + 재발급 실패 → "취소 성공 + 재발급 실패" 중간 상태를 만든다
$this->issueShouldFail = true;
$order->fresh()->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
app(CashReceiptService::class)->syncFromOrder($order->fresh(), '부분환불');
$this->assertNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
// 프로바이더 복구 후 관리자가 수동 재발급
$this->issueShouldFail = false;
$this->actingAs($this->adminUser)
->postJson($this->adminUrl($order, '/reissue'))
->assertOk()
->assertJsonPath('data.amount', 5500);
$this->assertNotNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
}
// ─────────────────────────────────────────────
// 유저
// ─────────────────────────────────────────────
#[Test]
public function 유저는_본인_주문에_사후_발급할_수_있다(): void
{
$this->registerProvider();
$owner = $this->createUser();
$order = $this->makeOrder($owner);
$this->actingAs($owner)
->postJson($this->userUrl($order), $this->issuePayload())
->assertOk()
->assertJsonPath('data.identifier_masked', '*******5678');
}
#[Test]
public function 유저는_타인_주문에_발급할_수_없다(): void
{
$this->registerProvider();
$order = $this->makeOrder($this->createUser());
$stranger = $this->createUser();
$this->actingAs($stranger)
->postJson($this->userUrl($order), $this->issuePayload())
->assertStatus(404);
}
#[Test]
public function 유저는_발급취소할_수_없다(): void
{
// 유저용 DELETE 라우트 자체가 존재하지 않는다 (관리자 전용).
$this->registerProvider();
$owner = $this->createUser();
$order = $this->makeOrder($owner);
$this->actingAs($owner)->postJson($this->userUrl($order), $this->issuePayload())->assertOk();
$response = $this->actingAs($owner)->deleteJson($this->userUrl($order));
$this->assertContains($response->getStatusCode(), [404, 405], '유저 발급취소 경로가 없어야 한다');
$this->assertNotNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
}
#[Test]
public function 유저는_발급_상태를_조회할_수_있다(): void
{
$this->registerProvider();
$owner = $this->createUser();
$order = $this->makeOrder($owner);
// 발급 전: issuable = true, cash_receipt = null
$this->actingAs($owner)
->getJson($this->userUrl($order))
->assertOk()
->assertJsonPath('data.issuable', true)
->assertJsonPath('data.cash_receipt', null);
$this->actingAs($owner)->postJson($this->userUrl($order), $this->issuePayload())->assertOk();
// 발급 후: issuable = false, 영수증 노출
$this->actingAs($owner)
->getJson($this->userUrl($order))
->assertOk()
->assertJsonPath('data.issuable', false)
->assertJsonPath('data.cash_receipt.identifier_masked', '*******5678');
}
#[Test]
public function 비로그인_사용자는_유저_발급_ap_i_에_접근할_수_없다(): void
{
$this->registerProvider();
$order = $this->makeOrder($this->createUser());
$this->postJson($this->userUrl($order), $this->issuePayload())->assertStatus(401);
}
#[Test]
public function 발급된_영수증은_주문_이력에_원장으로_남는다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
$this->actingAs($this->adminUser)->postJson($this->adminUrl($order), $this->issuePayload())->assertOk();
$rows = OrderCashReceipt::where('order_id', $order->id)->get();
$this->assertCount(1, $rows);
// 원장 어디에도 평문 식별번호가 없어야 한다
foreach ($rows as $row) {
foreach ($row->getAttributes() as $value) {
$this->assertStringNotContainsString(self::IDENTIFIER, (string) $value);
}
}
}
// ─────────────────────────────────────────────
// 비회원 (조회 토큰)
// ─────────────────────────────────────────────
/**
* 비회원 무통장 결제완료 주문과 조회 토큰을 생성합니다.
*
* @return array{0: Order, 1: string} 주문과 조회 토큰
*/
private function makeGuestOrderWithToken(): array
{
$phone = '010-1234-5678';
$password = 'guest1234';
$order = Order::factory()->forGuest()->create([
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'guest_lookup_password_hash' => Hash::make($password),
'total_amount' => 11000,
'total_cash_equivalent_amount' => 11000,
'total_tax_amount' => 11000,
'total_tax_free_amount' => 0,
]);
OrderAddress::factory()->forOrder($order)->create([
'address_type' => 'shipping',
'orderer_phone' => $phone,
]);
OrderPayment::factory()->forOrder($order)->create([
'payment_method' => PaymentMethodEnum::DBANK,
'payment_status' => PaymentStatusEnum::PAID,
]);
$token = $this->postJson('/api/modules/sirsoft-ecommerce/guest/orders/verify', [
'order_number' => $order->order_number,
'orderer_phone' => $phone,
'guest_lookup_password' => $password,
])->assertOk()->json('data.guest_order_token');
return [$order->fresh(), $token];
}
#[Test]
public function 비회원은_조회_토큰으로_사후_발급할_수_있다(): void
{
$this->registerProvider();
[$order, $token] = $this->makeGuestOrderWithToken();
$this->postJson(
"/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/cash-receipt",
$this->issuePayload(),
['X-Guest-Order-Token' => $token],
)
->assertOk()
->assertJsonPath('data.identifier_masked', '*******5678');
}
#[Test]
public function 비회원은_토큰_없이_발급할_수_없다(): void
{
$this->registerProvider();
[$order] = $this->makeGuestOrderWithToken();
$this->postJson(
"/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/cash-receipt",
$this->issuePayload(),
)->assertStatus(404);
$this->assertNull(app(CashReceiptService::class)->findActiveReceipt($order->fresh()));
}
#[Test]
public function 비회원은_발급_상태를_조회할_수_있다(): void
{
$this->registerProvider();
[$order, $token] = $this->makeGuestOrderWithToken();
$headers = ['X-Guest-Order-Token' => $token];
$url = "/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/cash-receipt";
// GET 은 입력을 받지 않는다 (발급 폼 검증에 걸리면 안 된다)
$this->getJson($url, $headers)
->assertOk()
->assertJsonPath('data.issuable', true)
->assertJsonPath('data.cash_receipt', null);
$this->postJson($url, $this->issuePayload(), $headers)->assertOk();
$this->getJson($url, $headers)
->assertOk()
->assertJsonPath('data.issuable', false)
->assertJsonPath('data.cash_receipt.identifier_masked', '*******5678');
}
}