Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/src/__tests__/vat-refund-single-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,12 @@ describe('부가세 환급 판정의 단일 출처', () => {
const rule = codeOnly(read('shared/pricing/vat-refund.ts'));
expect(rule).toMatch(/'simplified'/);
expect(rule).toMatch(/'consumer'/);
expect(rule).toMatch(/'vat_exempt'/);
});

it('🔴 택배(개인사업자 혜택) 판정도 isIndividualBenefit() 를 거친다 — 면세사업자가 빠지지 않게', () => {
for (const f of BUILDERS) {
expect(codeOnly(read(f)), `${f}`).toMatch(/is_individual:\s*isIndividualBenefit\(/);
}
});
});
2 changes: 1 addition & 1 deletion backend/src/routes/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ async function buildParams(
const trim_price = bodyOnly ? 0 : rawTrim;

const bizType = (customer?.biz_type ?? 'individual') as
'individual' | 'corporation' | 'simplified' | 'consumer';
'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer';
// 지방보조금 미적용: 관리자 DB 토글(active=false) 또는 견적별 영업 토글
const localOff = extra?.local_subsidy_off === true || subsidyLoc?.active === false;

Expand Down
5 changes: 3 additions & 2 deletions backend/src/services/quote-calc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
dieselDeducts, toDieselStatus, type QuoteParams,
resolveCarPrice,
noVatRefund,
isIndividualBenefit,
readCustomOptions, type CustomOption,
bodyOnlyParams,
vehicleOnlyParams,
Expand Down Expand Up @@ -140,7 +141,7 @@ export async function buildQuoteParams(
const zeroed = extra?.promotion_zeroed ?? [];
const { trim_price, option_sum } = assembleOptionSum(selections, price, zeroed, extra?.custom_options ?? []);
const bizType = (customer?.biz_type ?? 'individual') as
'individual' | 'corporation' | 'simplified' | 'consumer';
'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer';

// 지방보조금 미적용: 관리자 DB 토글(subsidy_local.active=false) 또는 견적별 영업 토글
const localOff = extra?.local_subsidy_off === true || subsidyLoc?.active === false;
Expand All @@ -160,7 +161,7 @@ export async function buildQuoteParams(
no_vat_refund: noVatRefund(bizType),
is_sosang: customer?.is_sosang ?? false,
sosang_rate: subsidyNat?.sosang_rate ? Number(subsidyNat.sosang_rate) : 0.3,
is_individual: bizType === 'individual',
is_individual: isIndividualBenefit(bizType), // 면세사업자도 개인사업자 혜택(택배)
has_transport_license: customer?.has_transport_license ?? false,
takbae_rate: TAKBAE_RATE,
body_price: Math.round(option_sum * 1.1), // I16 VAT포함(옛 0원처리가 반영된 합계)
Expand Down
4 changes: 2 additions & 2 deletions backend/src/services/quote-pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const BODYTYPE_DISP: Record<string, string> = { BODY_REEFER: '냉장/냉동', BO
const TOP_DISP: Record<string, string> = { TOP_LOW: '저상', TOP_STD: '표준' };
const DOOR_DISP: Record<string, string> = { DOOR_SWING: '여닫이', DOOR_SLIDE: '슬라이딩', DOOR_EVSLIDE: 'EV미닫이', DOOR_COUPANG: '미닫이', DOOR_FOLD: '양문미닫이' };
const PART_DISP: Record<string, string> = { PART_NET: '그물망', PART_REEFER: '냉장/냉동 이동식', PART_NONE: 'X' };
const BIZ_DISP: Record<string, string> = { individual: '개인사업자', corporation: '법인사업자', simplified: '간이과세자', consumer: '일반구매자' };
const BIZ_DISP: Record<string, string> = { individual: '개인사업자', corporation: '법인사업자', simplified: '간이과세자', vat_exempt: '면세사업자', consumer: '일반구매자' };
const ox = (on: boolean) => (on ? 'O' : 'X');

// ── 포맷 ──
Expand Down Expand Up @@ -225,7 +225,7 @@ export async function generateQuotePdf(quoteId: number): Promise<QuotePdfResult>
const bodyOnly = inp['body_only'] === true;
/** 차량만 견적 — 특장을 장착하지 않는다. 특장만과 동시에 참일 수 없다. */
const vehicleOnly = !bodyOnly && inp['vehicle_only'] === true;
/** 일반구매자(비사업자) — 부가세 환급 대상이 아니다 */
/** 일반구매자·간이과세자·면세사업자 — 부가세 환급 대상이 아니다 */
const noVatRefund = params.no_vat_refund === true;

const data = {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface SaveQuoteRequest extends Partial<QuotePriceExtras> {
ceo_name?: string // 대표이사 — 법인사업자일 때만(계약서 서명블록)
email?: string
phone?: string
biz_type: 'individual' | 'corporation' | 'simplified' | 'consumer'
biz_type: 'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer'
is_sosang: boolean
region?: string
address?: string
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/CustomerViewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useEscapeClose } from '../lib/escClose'
// 영업의 「수정」 폼을 그대로 쓰지 않고, 같은 항목을 읽기 전용으로 보여준다.
// 값은 견적 저장 시 함께 굳어진 입력 스냅샷(quote.inputs)과 고객 마스터에서 그대로 읽는다.
const BIZ_KO: Record<string, string> = {
individual: '개인사업자', corporate: '법인사업자', simplified: '간이과세자', personal: '개인',
individual: '개인사업자', corporate: '법인사업자', simplified: '간이과세자', vat_exempt: '면세사업자', personal: '개인',
}
const DIESEL_KO: Record<string, string> = {
none: '경유차 없음', keep: '경유차 유지 후 전기차 전환', scrap: '경유차 폐차 후 전기차 전환',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/SubsidyInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const BUSINESS_TYPE_OPTIONS: { value: BusinessType; label: string }[] = [
{ value: 'individual', label: '개인사업자' },
{ value: 'corporate', label: '법인사업자' },
{ value: 'simplified', label: '간이과세자' },
{ value: 'vat_exempt', label: '면세사업자' },
{ value: 'consumer', label: '일반구매자' },
]

Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export const EN: Record<string, string> = {
'가 되어 제작 배정을 할 수 있습니다. 견적서·계약서는 이 시점의 내용으로 고정되어 더 이상 고칠 수 없습니다.': 'without an e-signature, and production can be assigned. The quotation and contract are frozen as they are at this point and can no longer be edited.',
'가운데 자리': 'Middle digits',
'간이과세자': 'Simplified taxpayer',
'면세사업자': 'VAT-exempt business',
'값': 'Value',
'같은 고객·같은 옵션으로 새 견적을 만듭니다 (새 번호·임시저장)': 'Creates a new quote with the same customer and options (new number, draft)',
'개월수': 'Months',
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/liveQuote.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ApiPricingBundle, CustomerInfo } from '@shared/types/index'
import type { QuoteResult } from '@shared/pricing/core'
import { calcQuote, assembleOptionSum, makePriceLookup, groupOfPriceCode, bodyOnlyParams, vehicleOnlyParams, noVatRefund, resolveCarPrice, TAKBAE_RATE, DEFAULT_TAX_EXEMPT_TYPE, rowState } from '@shared/pricing/core'
import { calcQuote, assembleOptionSum, makePriceLookup, groupOfPriceCode, bodyOnlyParams, vehicleOnlyParams, noVatRefund, isIndividualBenefit, resolveCarPrice, TAKBAE_RATE, DEFAULT_TAX_EXEMPT_TYPE, rowState } from '@shared/pricing/core'
import type { CustomOption, CustomOptionDraft } from '@shared/pricing/core'

/**
Expand Down Expand Up @@ -114,7 +114,7 @@ export function buildLiveTotal(args: LiveTotalArgs): QuoteResult | null {
no_vat_refund: noVatRefund(biz),
is_sosang: subsidyInputs.is_small_business ?? false,
sosang_rate: bundle.subsidy_national?.sosang_rate ?? 0.3,
is_individual: biz === 'individual',
is_individual: isIndividualBenefit(biz), // 면세사업자도 개인사업자 혜택(택배)
has_transport_license: subsidyInputs.has_transport_license ?? false,
takbae_rate: TAKBAE_RATE,
body_price: Math.round(option_sum * 1.1),
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/quoteCustomer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import type { QuoteSaveValues } from '../components/QuoteSaveModal'
*/

/** 화면 표기 → 저장값 */
export function mapBizType(bt: CustomerInfo['business_type'] | undefined): 'individual' | 'corporation' | 'simplified' | 'consumer' {
export function mapBizType(bt: CustomerInfo['business_type'] | undefined): 'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer' {
if (bt === 'corporate') return 'corporation'
if (bt === 'simplified') return 'simplified'
if (bt === 'vat_exempt') return 'vat_exempt' // 면세사업자 — 개인사업자 혜택, 부가세 환급 불가
if (bt === 'consumer') return 'consumer' // 일반구매자(비사업자) — 부가세 환급 불가
return 'individual'
}
Expand All @@ -21,6 +22,7 @@ export function mapBizType(bt: CustomerInfo['business_type'] | undefined): 'indi
export function unmapBizType(v: unknown): CustomerInfo['business_type'] {
if (v === 'corporation') return 'corporate'
if (v === 'simplified') return 'simplified'
if (v === 'vat_exempt') return 'vat_exempt'
if (v === 'consumer') return 'consumer'
return 'individual'
}
Expand Down
6 changes: 3 additions & 3 deletions shared/pricing/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type {
} from './types.js';

import type { PricingParams, PricingResult } from './types.js';
import { noVatRefund } from './vat-refund.js';
import { noVatRefund, isIndividualBenefit } from './vat-refund.js';

export {
assembleOptionSum, optionBreakdown, valueUnitPrice, doorAddUnitPrice,
Expand All @@ -28,7 +28,7 @@ export { bodyOnlyParams } from './body-only.js';
export { vehicleOnlyParams } from './vehicle-only.js';

// 가격바가 보여줄 파생값(계산은 여기 한 곳에만 — 화면·앱이 공유한다)
export { noVatRefund, NO_VAT_REFUND_BIZ_TYPES } from './vat-refund.js';
export { noVatRefund, NO_VAT_REFUND_BIZ_TYPES, isIndividualBenefit } from './vat-refund.js';
export { resolveCarPrice, trimPriceVatIncluded, resolveTrimLabel, CAR_TRIM_LABEL_MAX } from './car-price.js';

// 커스텀 특장 옵션 — 단가표에 없는 사양을 영업이 직접 적어 넣는다
Expand Down Expand Up @@ -75,7 +75,7 @@ export function calcPrice(p: PricingParams): PricingResult {
const subsidy_diesel =
c.biz_type === 'corporation' && c.diesel_conversion ? s.diesel_conversion : 0;
const subsidy_takbae =
c.has_transport_license && c.biz_type === 'individual'
c.has_transport_license && isIndividualBenefit(c.biz_type)
? Math.floor(subsidy_national * s.takbae_rate)
: 0;
const subsidy_total =
Expand Down
7 changes: 5 additions & 2 deletions shared/pricing/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/** individual=개인사업자 · corporation=법인사업자 · simplified=간이과세자 · consumer=일반구매자(부가세 환급 불가) */
export type BizType = 'individual' | 'corporation' | 'simplified' | 'consumer';
/**
* individual=개인사업자 · corporation=법인사업자 · simplified=간이과세자 ·
* vat_exempt=면세사업자(개인사업자와 같은 혜택, 부가세 환급만 불가) · consumer=일반구매자(부가세 환급 불가)
*/
export type BizType = 'individual' | 'corporation' | 'simplified' | 'vat_exempt' | 'consumer';

/** 등록비·기타비 상수 (tax_config) */
export interface TaxConfig {
Expand Down
49 changes: 47 additions & 2 deletions shared/pricing/vat-refund.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { calcQuote } from './quote.js';
import { calcPrice } from './core.js';
import { noVatRefund, NO_VAT_REFUND_BIZ_TYPES } from './vat-refund.js';
import { noVatRefund, NO_VAT_REFUND_BIZ_TYPES, isIndividualBenefit } from './vat-refund.js';
import { QUOTE_PARAMS, REGRESSION_PARAMS } from './fixtures.js';
import type { BizType } from './types.js';

Expand Down Expand Up @@ -43,13 +43,17 @@ describe('환급 대상 판정 — 사업자 구분', () => {
expect(noVatRefund('consumer')).toBe(true);
});

it('🔴 면세사업자는 환급 대상이 아니다', () => {
expect(noVatRefund('vat_exempt')).toBe(true);
});

it('개인·법인 사업자는 환급 대상이다', () => {
expect(noVatRefund('individual')).toBe(false);
expect(noVatRefund('corporation')).toBe(false);
});

it('BizType 이 전부 둘 중 한쪽으로 판정된다 — 새 구분이 생기면 여기서 걸린다', () => {
const all: BizType[] = ['individual', 'corporation', 'simplified', 'consumer'];
const all: BizType[] = ['individual', 'corporation', 'simplified', 'vat_exempt', 'consumer'];
expect(all.filter(noVatRefund).sort()).toEqual([...NO_VAT_REFUND_BIZ_TYPES].sort());
});
});
Expand Down Expand Up @@ -77,3 +81,44 @@ describe('Ver1.21 엔진(calcPrice) — 같은 규칙을 쓴다', () => {
expect(r.vat_refunded_price).toBe(r.applied_price - r.vat);
});
});

/**
* 면세사업자 = **개인사업자와 같은 혜택, 부가세 환급만 0원.**
*
* 부가세는 그대로 내고(공급가 10%), 보조금(국고·지방·소상공인·택배)도 개인사업자와 같다.
* 그러니 실구매가는 정확히 「같은 조건의 개인사업자 + 부가세액」이어야 한다.
*/
describe('면세사업자', () => {
it('🔴 개인사업자 혜택(택배 보조금) 대상이다 — 법인·간이·일반은 아니다', () => {
expect(isIndividualBenefit('individual')).toBe(true);
expect(isIndividualBenefit('vat_exempt')).toBe(true);
expect(isIndividualBenefit('corporation')).toBe(false);
expect(isIndividualBenefit('simplified')).toBe(false);
expect(isIndividualBenefit('consumer')).toBe(false);
});

it('🔴 calcPrice — 보조금은 개인사업자와 같고, 실구매가는 부가세만큼 비싸다', () => {
const run = (biz: BizType) => {
const r = calcPrice({ ...REGRESSION_PARAMS, customer: { ...REGRESSION_PARAMS.customer, biz_type: biz } });
if (r.status !== 'ok') throw new Error('expected ok');
return r;
};
const ind = run('individual');
const ex = run('vat_exempt');
expect(ex.vat).toBe(ind.vat);
expect(ex.vat).toBeGreaterThan(0);
expect(ex.subsidy_total).toBe(ind.subsidy_total);
expect(ex.subsidy_takbae).toBe(ind.subsidy_takbae);
expect(ex.subsidy_takbae).toBeGreaterThan(0);
expect(ex.vat_refunded_price).toBe(ex.applied_price);
expect(ex.real_price).toBe(ind.real_price + ind.vat);
});

it('🔴 calcQuote — 개인사업자 파라미터에서 환급만 막으면 환급액 0, 실구매가는 그만큼 비싸다', () => {
const ind = calcQuote({ ...QUOTE_PARAMS, is_individual: true, no_vat_refund: noVatRefund('individual') });
const ex = calcQuote({ ...QUOTE_PARAMS, is_individual: isIndividualBenefit('vat_exempt'), no_vat_refund: noVatRefund('vat_exempt') });
expect(ex.vat_refund_price).toBe(0);
expect(ex.subsidy_total).toBe(ind.subsidy_total);
expect(ex.real_price).toBeGreaterThan(ind.real_price);
});
});
13 changes: 12 additions & 1 deletion shared/pricing/vat-refund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* 환급을 못 받는 쪽:
* · `consumer` 일반구매자(비사업자) — 매입세액공제 자체가 없다
* · `simplified` 간이과세자 — 세금계산서로 매입세액을 돌려받지 못한다
* · `vat_exempt` 면세사업자 — 부가세는 내지만 매입세액공제를 받지 못한다
*
* ⚠️ 이 판정을 호출부에서 `biz === 'consumer'` 처럼 다시 쓰지 말 것.
* 실제로 그렇게 두 곳(서버·화면)에 흩어져 있었고, 간이과세자를 넣을 때
Expand All @@ -13,9 +14,19 @@
import type { BizType } from './types.js';

/** 부가세 환급 대상이 아닌 사업자 구분 */
export const NO_VAT_REFUND_BIZ_TYPES: readonly BizType[] = ['consumer', 'simplified'];
export const NO_VAT_REFUND_BIZ_TYPES: readonly BizType[] = ['consumer', 'simplified', 'vat_exempt'];

/** 이 사업자 구분은 부가세를 환급받지 못하는가 */
export function noVatRefund(biz: BizType | string | null | undefined): boolean {
return NO_VAT_REFUND_BIZ_TYPES.includes(biz as BizType);
}

/**
* 개인사업자 혜택(택배 보조금) 대상인가.
*
* 면세사업자는 개인사업자와 **같은 혜택**을 받는다 — 다른 점은 부가세 환급뿐이다.
* 판정을 호출부에서 `biz === 'individual'` 로 다시 쓰면 면세사업자가 빠진다.
*/
export function isIndividualBenefit(biz: BizType | string | null | undefined): boolean {
return biz === 'individual' || biz === 'vat_exempt';
}
7 changes: 5 additions & 2 deletions shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ export interface ModelOptionsResponse {

// ── 고객 정보 ──────────────────────────────────────────────────────────

/** individual=개인사업자 · corporate=법인사업자 · simplified=간이과세자 · consumer=일반구매자(부가세 환급 불가) */
export type BusinessType = 'individual' | 'corporate' | 'simplified' | 'consumer';
/**
* individual=개인사업자 · corporate=법인사업자 · simplified=간이과세자 ·
* vat_exempt=면세사업자(개인사업자와 같은 혜택, 부가세 환급만 불가) · consumer=일반구매자(부가세 환급 불가)
*/
export type BusinessType = 'individual' | 'corporate' | 'simplified' | 'vat_exempt' | 'consumer';

export interface CustomerInfo {
/** 법인사업자면 **상호**, 그 외에는 **성명**. 계약서 `{{buyer_name}}` 으로 나간다. */
Expand Down
Loading