fix(ecommerce,tosspayments,templates): 에스크로 표시 누락·결제수단 전환 시 입력 잔류 수정

- 에스크로 표시가 영구히 렌더되지 않던 문제: is_escrow 가 DB·모델·부분취소 차단
 로직에는 있으나 OrderPaymentResource 가 내보내지 않아, 이 값을 참조하는 주문
 상세(관리자·구매자)의 에스크로 행이 항상 false 로 평가됐다. Resource 에 노출한다.

- 결제수단을 바꿔도 이전 수단의 입력이 주문에 실리던 문제: 결제수단 버튼이
 _local.paymentMethod 만 바꾸고 슬롯이 소유한 확장 페이로드·환불계좌는 비우지
 않았다. 확장 슬롯과 환불계좌 블록은 결제수단 조건부로 언마운트되지만 _local
 값은 남으므로, 무통장에서 현금영수증·환불계좌를 입력한 뒤 카드로 전환하면 그
 값이 주문 생성 요청에 그대로 실렸다.
 프론트는 결제수단 전환 시 슬롯 소유 상태를 초기화하고, 서버는 환불계좌가 쓰이지
 않는 결제수단이면 저장하지 않도록 게이팅한다(현금영수증은 이미 dbank 게이트 보유).

- Modal 이 받지 않는 size prop 을 쓰던 4곳을 width 로 교체(무시되어 의도한 폭이
 나오지 않았다). 토스 에러 모달의 setState target 을 params 안으로 옮긴다.

- 레이아웃 편집기 팔레트에서 체크박스를 넣으면 라벨 없이 네모 칸만 놓이던 문제:
 Checkbox 는 label prop 을 렌더하지 않으므로 Label 래핑 + Span 텍스트로 바꾸고,
 Accordion 이 Label 을 받도록 중첩 스펙을 맞춘다.
This commit is contained in:
HeuJung
2026-08-06 13:58:00 +09:00
parent 38a5cbe550
commit b07d6c8953
17 changed files with 292 additions and 14 deletions
@@ -31,14 +31,14 @@
#### 신청·발급 화면
- 주문서의 무통장입금 결제 영역에서 현금영수증을 신청할 수 있습니다. 용도(소득공제/지출증빙)에 따라 고를 수 있는 발급 수단이 바뀌며, 그 용도에 쓸 수 없는 수단이 선택되어 있으면 자동으로 되돌립니다. 발급 업체를 설정하지 않은 상점에서는 신청란이 나타나지 않습니다.
- 주문서의 무통장입금 결제 영역에서 현금영수증을 신청할 수 있습니다. 용도(소득공제/지출증빙)에 따라 고를 수 있는 발급 수단이 바뀌며, 그 용도에 쓸 수 없는 수단이 선택되어 있으면 자동으로 되돌립니다. 발급 업체를 설정하지 않은 상점에서는 신청란이 나타나지 않습니다. 결제수단을 다른 것으로 바꾸면 입력했던 신청 내용은 지워집니다.
- 구매자 주문 상세에 현금영수증 카드가 표시됩니다. 입금 전에는 안내만, 입금 후 미발급이면 발급 버튼, 발급 후에는 발급일시·용도·식별번호와 영수증 보기 링크가 나타납니다. 환불에 따른 재발급이 실패한 경우에는 안내 문구가 표시됩니다.
- 관리자 주문 상세에 현금영수증 카드가 표시됩니다. 발급·발급취소·수동 재발급을 할 수 있고 발급·취소 이력을 함께 볼 수 있습니다. 재발급이 실패한 주문에는 경고와 함께 다시 발급하는 버튼이 나타납니다.
- 이커머스 환경설정 주문설정 탭에서 현금영수증 발급 업체, 배송비 과세 방식, 자진발급 사용 여부를 설정할 수 있습니다.
#### 환불 계좌
- 주문서의 무통장입금·가상계좌 결제 영역에서 환불받을 계좌(은행·계좌번호·예금주)를 미리 입력할 수 있습니다. 입력하지 않아도 주문할 수 있지만, 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다.
- 주문서의 무통장입금·가상계좌 결제 영역에서 환불받을 계좌(은행·계좌번호·예금주)를 미리 입력할 수 있습니다. 입력하지 않아도 주문할 수 있지만, 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다. 무통장입금·가상계좌가 아닌 결제수단으로 주문하면 환불 계좌는 저장되지 않습니다 — 계좌를 입력한 뒤 카드 등으로 결제수단을 바꿔도 필요 없는 계좌정보가 주문에 남지 않습니다.
- 관리자 주문 취소 화면에서 환불 계좌를 입력하거나 수정할 수 있습니다. 주문할 때 입력한 계좌가 있으면 미리 채워집니다. 입금이 완료된 가상계좌 주문은 환불에 계좌가 반드시 필요하므로 입력하지 않으면 취소할 수 없습니다. 무통장입금은 관리자가 직접 이체할 때 참고하도록 선택 입력이며, 카드·간편결제는 원래 결제수단으로 환불되므로 계좌란이 나타나지 않습니다.
#### 금액·과세
@@ -11,7 +11,7 @@
"name": "Modal",
"props": {
"title": "$t:sirsoft-ecommerce.order.cash_receipt.issue_title",
"size": "small"
"width": "480px"
},
"children": [
{
@@ -8,7 +8,7 @@
"name": "Modal",
"props": {
"title": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_title",
"size": "small"
"width": "480px"
},
"children": [
{
@@ -90,6 +90,23 @@ enum PaymentMethodEnum: string
]);
}
/**
* 환불 계좌를 받아야 하는 결제수단인지 확인합니다.
*
* 무통장입금은 관리자가 수동으로 이체할 대상 계좌가 필요하고, 가상계좌는 PG 환불 API 의
* refundReceiveAccount 로 계좌가 필요하다. 카드·계좌이체·휴대폰은 원거래 취소로 환불되고
* 마일리지·예치금·무료는 내부 처리이므로 계좌가 필요 없다.
*
* 체크아웃 화면은 결제수단을 바꿔도 입력값을 비우지 않으므로, 계좌가 필요 없는 주문에
* 계좌정보가 저장되지 않도록 서버가 이 축으로 게이팅한다.
*
* @return bool 환불 계좌 필요 여부
*/
public function needsRefundBankAccount(): bool
{
return in_array($this, [self::DBANK, self::VBANK], true);
}
/**
* 결제수단의 현금성(현금영수증 발급 대상) 금액을 산정합니다.
*
@@ -364,6 +364,13 @@ class CreateOrderRequest extends FormRequest
*/
public function getRefundBankInfo(): ?array
{
// 환불 계좌가 쓰이지 않는 결제수단(카드·계좌이체·휴대폰·마일리지·예치금·무료)이면 저장하지 않는다.
// 체크아웃 화면은 결제수단을 바꿔도 입력값을 비우지 않으므로, 무통장에서 계좌를 넣고
// 카드로 전환하면 그 값이 그대로 전송된다. getCashReceiptInfo() 와 같은 축으로 게이팅한다.
if (! PaymentMethodEnum::tryFrom((string) $this->input('payment_method'))?->needsRefundBankAccount()) {
return null;
}
$bankCode = $this->input('refund_bank.bank_code');
if (blank($bankCode)) {
@@ -72,6 +72,10 @@ class OrderPaymentResource extends BaseApiResource
'vbank_due_at' => $this->vbank_due_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601 parsed by PG plugin JS injectors, display uses *_formatted sibling
'vbank_due_at_formatted' => $this->formatDateTimeStringForUser($this->vbank_due_at),
// 에스크로 여부 — 결제 시점 스냅샷. 부분취소 차단(PaymentRefundListener)의 기준이자
// 주문 상세(관리자/회원)에서 에스크로 거래임을 표시하는 값.
'is_escrow' => (bool) $this->is_escrow,
// 무통장입금 정보
'dbank_name' => $this->dbank_name,
'dbank_account' => $this->dbank_account,
@@ -348,6 +348,62 @@ class CheckoutCashReceiptAndRefundBankTest extends ModuleTestCase
$this->assertNotNull($payment->refund_bank_name);
}
/**
* 환불계좌가 의미를 갖지 않는 결제수단이면 저장하지 않는다.
*
* 환불계좌는 무통장(관리자 수동 이체 대상)과 가상계좌(PG 환불 API 의 refundReceiveAccount)
* 에서만 쓰인다. 카드/간편결제는 원거래 취소로 환불되므로 계좌가 필요 없다.
*
* 체크아웃 화면은 결제수단을 바꿔도 _local 의 환불계좌 입력값을 비우지 않으므로,
* 무통장에서 계좌를 넣고 카드로 전환하면 그 값이 그대로 전송된다. 발급될 수 없는
* 주문에 계좌정보를 남기지 않도록 현금영수증(getCashReceiptInfo)과 같은 축으로 게이팅한다.
*
* @param string $paymentMethod 환불계좌를 쓰지 않는 결제수단
*/
#[DataProvider('nonRefundBankPaymentMethodProvider')]
public function test_환불계좌를_쓰지_않는_결제수단이면_저장하지_않는다(string $paymentMethod): void
{
$this->postOrder([
'payment_method' => $paymentMethod,
'dbank' => null,
'refund_bank' => [
'bank_code' => '004',
'account_number' => '110-123-456789',
'holder' => '홍길동',
],
])->assertStatus(201);
$payment = $this->latestPayment();
$this->assertNull(
$payment->refund_bank_code,
"{$paymentMethod} 주문에 환불계좌가 저장되어서는 안 된다"
);
$this->assertNull($payment->refund_bank_account);
$this->assertNull($payment->refund_bank_holder);
$this->assertNull($payment->refund_bank_name);
}
/**
* 환불계좌를 쓰지 않는 결제수단 (무통장·가상계좌 제외).
*
* @return array<string, array{string}>
*/
public static function nonRefundBankPaymentMethodProvider(): array
{
$cases = [];
foreach (PaymentMethodEnum::cases() as $method) {
if (in_array($method, [PaymentMethodEnum::DBANK, PaymentMethodEnum::VBANK], true)) {
continue;
}
$cases[$method->value] = [$method->value];
}
return $cases;
}
/**
* @return array<string, array{array<string, mixed>, array<int, string>}>
*/
@@ -20,6 +20,7 @@
* checkout_purpose_switch_resets_invalid_identifier,
* checkout_identifier_type_options_by_purpose,
* checkout_identifier_cleared_on_type_change,
* checkout_state_reset_on_payment_method_change,
* new_ui_uses_portable_only,
* new_ui_has_no_tailwind_breakpoint
*/
@@ -313,6 +314,38 @@ test.describe('체크아웃 현금영수증 신청 폼 (무통장 슬롯 주입)
await expect(page.locator(IDENTIFIER)).toHaveValue('');
});
test('결제수단을 바꾸면 현금영수증 입력이 초기화된다 (이전 수단 값 잔류 금지)', async ({ page }) => {
// 회귀: 결제수단 버튼이 _local.paymentMethod 만 바꾸고 슬롯이 소유한 _local
// (checkoutExtraPayload / refundBank*) 은 비우지 않아, 무통장에서 현금영수증을 입력한 뒤
// 카드로 전환하면 그 값이 주문 생성 POST 에 그대로 실렸다.
await gotoCheckout(page);
await selectDbank(page);
const others = page.locator('[data-testid^="checkout-payment-method-"]:not([data-testid$="-dbank"])');
if ((await others.count()) === 0) {
test.info().annotations.push({
type: 'coverage-gap',
description: '무통장 외 활성 결제수단이 없어 전환 시 초기화는 미검증 (환경설정 의존)',
});
return;
}
// 무통장에서 현금영수증 신청 + 번호 입력
await page.locator(TOGGLE).check();
await page.locator(IDENTIFIER).fill('01012345678');
await expect(page.locator(IDENTIFIER)).toHaveValue('01012345678');
// 다른 결제수단으로 전환 → 슬롯 언마운트
await others.first().click();
await expect(page.locator(SLOT)).toHaveCount(0);
// 무통장으로 돌아오면 이전 입력이 남아 있지 않다 (신청 토글이 꺼진 초기 상태)
await selectDbank(page);
await expect(page.locator(SLOT)).toHaveCount(1);
await expect(page.locator(TOGGLE)).not.toBeChecked();
await expect(page.locator(FIELDS)).toHaveCount(0);
});
test('발급수단을 바꾸면 이전에 입력한 번호가 비워진다', async ({ page }) => {
await gotoCheckout(page);
await selectDbank(page);
@@ -50,4 +50,29 @@ class OrderPaymentResourceTest extends ModuleTestCase
$this->assertStringContainsString('신한카드', $array['account_info']);
$this->assertStringContainsString('1234-****-****-5678', $array['account_info']);
}
/**
* 에스크로 여부가 API 로 노출된다.
*
* 회귀 배경(#454): is_escrow 는 DB·모델·부분취소 차단 로직(PaymentRefundListener)에는
* 있었지만 Resource 가 내보내지 않아, 이 값을 참조하는 주문 상세 화면(관리자/회원)의
* 에스크로 표시가 항상 false 로 평가되어 렌더되지 않았다.
*/
public function test_is_escrow_is_exposed(): void
{
$escrow = OrderPaymentFactory::new()->make([
'payment_method' => PaymentMethodEnum::VBANK,
'is_escrow' => true,
]);
$normal = OrderPaymentFactory::new()->make([
'payment_method' => PaymentMethodEnum::VBANK,
'is_escrow' => false,
]);
$request = Request::create('/');
$this->assertTrue((new OrderPaymentResource($escrow))->toArray($request)['is_escrow']);
$this->assertFalse((new OrderPaymentResource($normal))->toArray($request)['is_escrow']);
}
}
@@ -139,6 +139,8 @@ effects:
- checkout_purpose_switch_resets_invalid_identifier # 소득공제 전환 시 business → phone 리셋
- checkout_identifier_type_options_by_purpose # 소득공제 2종 / 지출증빙 3종
- checkout_identifier_cleared_on_type_change # 발급수단 변경 시 번호 초기화
- checkout_state_reset_on_payment_method_change # 결제수단 전환 시 슬롯 소유 _local 초기화 (이전 수단 값 잔류 금지)
- refund_bank_not_stored_for_non_bank_methods # 환불계좌를 쓰지 않는 결제수단이면 서버가 저장하지 않음
# 주문 생성 저장 (FormRequest 4단계 사슬)
- checkout_cash_receipt_request_persisted # is_cash_receipt_requested + 용도 + 수단
@@ -27,6 +27,7 @@
- 가상계좌·계좌이체 결제에 에스크로(구매안전서비스)를 적용할 수 있습니다. 사용 안 함 / 강제 사용 / 구매자 선택 중에서 고를 수 있습니다.
- 에스크로 결제 시 결제창에 상품 정보(상품명·단가·수량)를 함께 전달합니다. 가상계좌와 계좌이체 모두에 적용됩니다.
- 에스크로를 켜면 설정 화면에 운영 안내가 표시됩니다 — 에스크로 주문은 부분취소가 불가하다는 점과, 배송정보는 토스 상점관리자에서 등록해야 한다는 점을 안내하며 상점관리자로 바로 이동할 수 있습니다.
- 에스크로로 결제된 주문은 관리자 주문 상세와 구매자 주문 상세에 에스크로 거래임이 표시됩니다.
#### 현금영수증
@@ -7,7 +7,7 @@
"name": "Modal",
"props": {
"title": "$t:sirsoft-tosspayments.payment_error_title",
"size": "sm"
"width": "420px"
},
"children": [
{
@@ -54,8 +54,8 @@
{
"type": "click",
"handler": "setState",
"target": "local",
"params": {
"target": "local",
"paymentErrorMessage": null
}
},
@@ -79,7 +79,7 @@
"name": "Modal",
"props": {
"title": "$t:sirsoft-tosspayments.payment_cancel_title",
"size": "sm"
"width": "420px"
},
"children": [
{
@@ -200,6 +200,49 @@ describe('sirsoft-admin_basic editor-spec defaultNode 공통 디자인', () => {
});
});
// Checkbox 라벨 렌더 회귀 가드.
// Checkbox 컴포넌트는 label prop 을 받기만 하고 렌더하지 않는다(부모 Label 이 표시 담당).
// 팔레트가 label prop 만 심으면 편집기 삽입 시 라벨 없는 빈 체크박스가 나온다.
describe('Checkbox defaultNode — 라벨이 실제로 렌더된다', () => {
it('Checkbox defaultNode 는 label prop 이 아니라 Label 래핑 + Span 텍스트를 쓴다', () => {
const node = entries.Checkbox?.defaultNode;
expect(node).toBeDefined();
// 최상위는 Label — Checkbox 를 감싸 클릭 영역과 표시를 함께 제공한다.
expect(node?.name).toBe('Label');
const children = node?.children ?? [];
const checkbox = children.find((c) => c.name === 'Checkbox');
const span = children.find((c) => c.name === 'Span');
expect(checkbox, 'Checkbox 자식 누락').toBeDefined();
expect(span, '라벨 텍스트 Span 누락').toBeDefined();
expect(span?.text).toBe('$t:layout_editor.palette.checkbox.default_label');
// 렌더되지 않는 label prop 에 의존하지 않는다.
expect(node?.props?.label, 'Label 에 label prop 사용 금지').toBeUndefined();
expect(checkbox?.props?.label, 'Checkbox 의 label prop 은 렌더되지 않는다').toBeUndefined();
});
it('Checkbox defaultNode 를 렌더하면 라벨 텍스트가 DOM 에 나타난다', async () => {
const node = entries.Checkbox?.defaultNode as DefaultNode;
const registry = createMockComponentRegistryWithBasics();
const t = createLayoutTest(wrapAsLayout(node), {
templateId: 'sirsoft-admin_basic',
componentRegistry: registry,
locale: 'ko',
});
const { container } = await t.render();
// 체크박스 input 과 라벨 텍스트가 함께 렌더된다(빈 체크박스 회귀 차단).
const label = container.querySelector('label');
expect(label, 'Label 래퍼가 렌더되어야 함').not.toBeNull();
expect((label?.textContent ?? '').trim().length, '라벨 텍스트가 비어 있음').toBeGreaterThan(0);
t.cleanup();
});
});
// defaultNode prop shape 결함 회귀 가드.
// 라이브 검증에서 발견한 defaultNode↔컴포넌트 shape 불일치 결함들이 되돌아가지 않게 한다.
describe('defaultNode prop shape 정합', () => {
@@ -673,11 +673,24 @@
"requiresDefaultNode": true,
"defaultNode": {
"type": "basic",
"name": "Checkbox",
"name": "Label",
"props": {
"label": "$t:layout_editor.palette.checkbox.default_label",
"className": "w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400 focus:ring-blue-500"
}
"className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"className": "w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400 focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:layout_editor.palette.checkbox.default_label"
}
]
}
},
"RadioGroup": {
@@ -466,7 +466,8 @@
"Input",
"Textarea",
"Select",
"Checkbox"
"Checkbox",
"Label"
]
}
}
@@ -18,6 +18,7 @@
import { describe, it, expect } from 'vitest';
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
import checkoutSummaryJson from '../../layouts/partials/shop/_checkout_summary.json';
import checkoutPaymentJson from '../../layouts/partials/shop/_checkout_payment.json';
import checkoutJson from '../../layouts/shop/checkout.json';
/** 객체 트리에서 조건을 만족하는 첫 노드를 깊이우선 탐색 */
@@ -231,6 +232,57 @@ describe('주문 생성 body — 확장 병합 칸 계약', () => {
});
});
// 결제수단 전환 시 이전 수단이 소유하던 입력값이 남아 payload 에 실리던 결함의 회귀 가드.
// 확장 슬롯(현금영수증)과 환불계좌 블록은 결제수단 조건부로 언마운트되지만 _local 값은 남는다.
// 무통장에서 입력 → 카드로 전환 시 그 값이 그대로 주문 생성 POST 에 실렸다.
describe('결제수단 전환 — 이전 수단 소유 상태 초기화', () => {
it('결제수단 선택 액션이 확장 칸과 환불계좌를 함께 비운다', () => {
const methodSelect = findNode(
checkoutPaymentJson,
(n: any) =>
Array.isArray(n?.actions) &&
n.actions.some(
(a: any) =>
a?.type === 'click' &&
JSON.stringify(a).includes('_local.paymentMethod'),
),
);
expect(methodSelect, '결제수단 선택 액션 노드를 찾지 못했다').toBeDefined();
const clickAction = methodSelect.actions.find((a: any) => a?.type === 'click');
const inner = clickAction?.params?.actions ?? [];
const targets = inner.map((a: any) => a?.params?.target);
// 결제수단 자체는 설정하고
expect(targets).toContain('_local.paymentMethod');
// 이전 수단이 소유하던 상태는 비운다
expect(targets).toContain('_local.checkoutExtraPayload');
expect(targets).toContain('_local.refundBankCode');
expect(targets).toContain('_local.refundBankAccount');
expect(targets).toContain('_local.refundBankHolder');
});
it('초기화된 상태로 조립한 payload 에는 이전 수단의 값이 남지 않는다', () => {
// 무통장에서 현금영수증·환불계좌를 입력한 뒤 카드로 전환된 직후의 _local 상태
const ctx = baseCtx({
_local: {
...baseCtx()._local,
paymentMethod: 'card',
checkoutExtraPayload: {},
refundBankCode: '',
refundBankAccount: '',
refundBankHolder: '',
},
});
const body = evalBody(ctx);
expect(body.refund_bank).toBeNull();
expect(body.cash_receipt_requested).toBeUndefined();
expect(body.cash_receipt_identifier).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────
// #454 — 플러그인 결제수단(toss_*)의 코어 결제수단 전송
//
@@ -146,9 +146,33 @@
],
"actions": [
{
"comment": "결제수단 전환 시 이전 수단이 소유하던 입력값을 함께 비운다. 확장 슬롯(현금영수증 등)과 환불계좌 블록은 결제수단 조건부로 언마운트되지만 _local 값은 남으므로, 초기화하지 않으면 무통장에서 입력한 값이 카드 주문 payload 에 그대로 실린다.",
"type": "click",
"handler": "setState",
"params": { "target": "_local.paymentMethod", "value": "{{method.id}}" }
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": { "target": "_local.paymentMethod", "value": "{{method.id}}" }
},
{
"handler": "setState",
"params": { "target": "_local.checkoutExtraPayload", "value": {} }
},
{
"handler": "setState",
"params": { "target": "_local.refundBankCode", "value": "" }
},
{
"handler": "setState",
"params": { "target": "_local.refundBankAccount", "value": "" }
},
{
"handler": "setState",
"params": { "target": "_local.refundBankHolder", "value": "" }
}
]
}
}
]
}