다크모드
매입 P4(카드매입)/P5(정정·반품) — BE 참고
골드너스 통합 P-시리즈 세 번째(완결) 슬라이스 — P-시리즈 6이벤트(P1·P2·P3·P4·P5·P6) 완성. 정본 설계:
docs/superpowers/specs/2026-07-07-purchasing-p4-p5-design.md. 정본 블루프린트:docs/BLUEPRINT-PURCHASING-ACCOUNTING-2026-07-05.md(§2 P4·P5 행 — 이번 슬라이스로 as-built 승격). P1/P2/P3/P6 계약은docs/handoff/backend/purchasing-p1-p2.md·docs/handoff/backend/purchasing-p3-p6.md참고 — 이 문서는 그 위에 얹힌 확장만 다룬다. E-시리즈 대칭:
useBillingJournal E7(반품/역분개, append-only) ↔ usePurchaseJournal P5(reversePurchase 미러)1. 개요
스코프: P4(카드매입 — documentType:'카드'·귀속=승인일·카드사 채무 0253·결제는 기존 P2 재사용) · P5(정정/반품 — reversePurchase(id, {kind, amount?, reason})·append-only 역분개·매입환출및에누리 0157) · P5 역분개 전표(vouchersPurchaseReversal) · vouchersPurchase의 status !== '취소' 필터 활성화(P1/P2 시절 dead code였던 필터가 이제 실제로 '반품'/'정정' 상태를 통과시킴 — 상태값 자체가 '취소'가 아니므로 원 P1 전표는 항상 유지) · transaction 콘솔 확장(카드매입 등록·정정/반품 다이얼로그·상태 배지·역분개 전표 조회).
스코프 아웃(후속): 카드사 명세서 자동 대사·3자통과·은행수집·선급금 환급(P-adv-refund)·3원대사·반품/정정 취소(되돌리기)·거래처명 마스터.
2. 데이터 모델 확장 (usePurchases.js)
2-1. recordPurchase — P4 카드매입 필드
js
recordPurchase({ vendorCode, purchaseDate, category, accountCode, supplyAmount, taxType,
documentType, cardIssuer = null, memo = '' })documentType에'카드'허용(기존'세금계산서'/'계산서'에 추가).cardIssuer(카드사명, 예: '신한카드') 신규 필드 — 카드매입이 아니면null.- 귀속일 =
purchaseDate(승인일) — 신규 날짜 필드 불요, P1과 완전히 동일한 필드로 구현. - 채무 계정 분기 확장 —
payableAccountFor(category, documentType):js재화를 카드로 결제해도 매입세금계산서 계정분류(0251)가 아니라 카드사에 대한 채무이므로 0253으로 귀속한다(spec §2 P4 명시 결정).function payableAccountFor(category, documentType) { if (documentType === "카드") return "0253"; // 카드사 채무 — category 무관 항상 미지급금 return category === "재화" ? "0251" : "0253"; } - 결제(인출)는 신규 로직 0줄 — 기존
usePayments.recordPayment(cardIssuer 또는 카드사 vendorCode, paymentDate=인출일)를 그대로 재사용. 승인일(purchaseDate)≠인출일(paymentDate) 이원화가 자동으로 성립.
2-2. purchase 원자 — P5 상태 필드 확장
js
purchase = {
id,
vendorCode,
purchaseDate,
category,
accountCode,
supplyAmount,
taxType,
vatAmount,
payable,
payableAccountCode,
documentType,
cardIssuer,
memo,
// P5 신규 —
status, // '정상' | '반품' | '정정' (기본 '정상')
reversedSupply, // 누계(원)
reversedVat, // 누계(원)
reversedPayable, // 누계(원)
reversalKind, // 최근 kind('반품'|'에누리') — 이력 요약용
reversalReason, // 최근 reason
reversedAt, // 최근 처리일
reversals: [], // [{ id, purchaseId, kind, reason, at, supply, vat, payable, accountCode, payableAccountCode }]
};회귀 가드: 기존(status 없이 생성된) 매입도 recordPurchase가 항상 status:'정상', reversed*:0, reversals:[]을 채워 넣으므로, usePurchaseJournal의 status !== '취소' 필터·payablesByVendor가 P1/P2 시절 매입에 회귀 영향이 없다.
2-3. reversePurchase(id, { kind, amount?, reason, at? }) — P5 역분개(E7 append-only 미러)
js
function reversePurchase(id, { kind, amount = null, reason = '', at = today } = {})- 원
purchaseatom을 절대 변형하지 않는다(append-only) —reversedSupply/reversedVat/reversedPayable누계 +reversals[]이력만 누적.supplyAmount/vatAmount/payable(원 P1 값)은 불변. - kind='반품'(전액): 아직 반품/에누리로 소진되지 않은 잔여 공급가액 전부(
supplyAmount - reversedSupply)를 역분개 대상으로 잡는다.status='반품'. 이미status==='반품'인 매입은 추가 반품 불가({ok:false, reason:'이미 반품 처리된 매입'}). - kind='에누리'(부분):
amount(공급가액 감액분)만큼만 감액.amount가 0 이하거나 잔여 공급가액을 초과하면 거부.status='정정'. 동일 매입에 여러 번 적용 가능(각 호출이reversals[]에 별도 원자로 추가·누계에 합산) — 하드닝 테스트로 다건 검증(§4). - VAT 역산: 원 매입의
vatRateAt(purchaseDate)로Math.round(supply * rate)—recordPurchase와 동일 산식(대칭 역분개). 저장된reversal.vat을 재계산·재반올림하지 않고 그대로 전표(vouchersPurchaseReversal)가 소비한다(§4 드리프트 방지 불변식). - 지급후 가드(MVP):
outstandingOf(p) = max(0, payable - reversedPayable - paidByPurchase[p.id])로 계산한 현재 헤드룸을 이번 역분개(payableDelta = supply + vat)가 초과하면 차단 —{ok:false, reason:'이미 지급 — 환급/선급 전환 필요', headroom, requested}. append-only 원칙상 가드 실패 시 원 atom·기존 reversals 모두 무변형. - 반환:
{ok:true, reversedSupply, reversedVat, reversedPayable, kind, reason, id}또는{ok:false, reason, ...}.
2-4. outstandingOf/payablesByVendor — reversedPayable 반영
js
function outstandingOf(purchase) {
return Math.max(
0,
purchase.payable - (purchase.reversedPayable ?? 0) - (paidByPurchase[purchase.id] ?? 0),
);
}반품/에누리와 지급(paidByPurchase) 두 오버레이가 동시에 payable을 잠식해도 Math.max(0, ...)로 음수 방지. payablesByVendor()/payableBalanceByVendor()는 이 반영값을 즉시 투영(완납·완전반품된 매입은 열린 채무 목록에서 제외).
3. GL 전표 계약 확장 (usePurchaseJournal.js)
P1/P4 — vouchersPurchase() (카드매입 event 분기만 추가)
documentType==='카드': event='카드매입', partner=cardIssuer(없으면 vendorCode 폴백)
그 외: event='매입확정', partner=vendorCode
DR/CR 라인 구성은 완전 동일(신규 전표 로직 없음)status !== '취소'필터 활성화: 현재 코드베이스에'취소'상태를 만드는 경로가 없다(반품은status='반품', 취소가 아님) — 이 필터는 방어적 대칭일 뿐, 반품/정정된 매입도 원 P1 전표는 그대로 방출된다(append-only, E7 상속).
P5 — vouchersPurchaseReversal() (신규)
purchase.reversals[](append-only 원자)를 그대로 순회해 방출 — 저장된 supply/vat/payable을 재계산·재반올림 없이 그대로 사용(★T2 Important — 전표와 서브원장이 동일 수치를 공유해야 GL 대사가 드리프트하지 않는다).
반품(kind='반품'): DR payableAccountCode(payable) / CR accountCode(supply) + CR 0135(vat, 과세만)
— 원 P1의 정확한 역. event '매입반품'.
에누리(kind='에누리'): DR payableAccountCode(payable, 감액분) / CR 0157 매입환출및에누리(supply, 감액분)
+ CR 0135(vat, 감액분·과세만). event '매입에누리'.면세(vat=0)면 0135 라인 생략. 각 전표는 refPurchaseId: purchase.id를 실어 원 매입과 연결(콘솔 상세 시트가 이 필드로 필터링, §5 FE 참고).
★T3 Minor(billingRef 글로벌 seq, 이번 하드닝에서 정정): 종전엔 p.reversals.forEach((r,i)=>...)로 매입별 로컬 index를 billingRef의 seq로 썼다 — 동일 날짜에 서로 다른 매입 2건이 각각 반품/에누리되면 billingRef(예: PURRET-20251210-0001)가 충돌했다(감사추적 정합 위반, id는 rev-N으로 전역 유일이라 실질 충돌은 없었으나 표시용 코드가 중복). sibling(vouchersPurchase/vouchersPayment)처럼 전체 매입의 reversals를 먼저 평탄화한 뒤 그 배열의 전역 index로 seq를 매기도록 정정했다.
전표 발생 순서(purchaseVouchers())
js
[
...vouchersPurchase(),
...vouchersPayment(),
...vouchersPrepaidApply(),
...vouchersDisbursement(),
...vouchersReclassDisbursement(),
...vouchersPurchaseReversal(),
];4. 불변식 (하드닝 3건 포함)
- P5 전표 균형: 반품/에누리 전표 모두 DR=CR.
- 반품 순액 0: 원 P1(CR payable 전액) + P5 반품(DR payable 전액) 상쇄 —
netOf('0251')+netOf('0253')이 그 매입 건에 한해 0. - 에누리 순액 = 원채무 − 에누리분:
0157순대변 = 에누리 kind로 감액된 공급가액 합. - billingRef 전역 유일(★하드닝 T3 Minor) — 동일 날짜 다건 반품/에누리도
PURRET-/PURDSC-seq가 충돌하지 않는다. - 에누리 VAT 드리프트 0(★하드닝 T3 Important) — 동일 매입에 비10%정합 금액으로 2건 이상 부분 에누리를 순차 적용해도, 전표(
vouchersPurchaseReversal)의 reversedVat 합과 서브원장(purchase.reversedVat누계)이 정확히 일치한다(둘 다reversals[]에 저장된 실측값을 그대로 공유 — 재계산·재반올림 없음). 문서화 참고: 이 두 부분 에누리를 만약 "한 번에" 합산 처리했다면 반올림 경계를 다르게 통과해 이론상 ±1원의 차이가 날 수 있다(예:12345+6789=19134를 분할 처리하면1235+679=1914, 일괄 처리하면round(1913.4)=1913— 1원 차이). 그러나 전표↔서브원장 사이엔 이 드리프트가 절대 발생하지 않는다(핵심 불변식 — 테스트:usePurchases.spec.js"동일 매입 2건 이상 부분 에누리"). - 혼합 GL 대사에 반품/에누리·카드매입 포함(★하드닝 T3 Minor) — 정상+지급+카드매입+반품+에누리 포트폴리오에서
0251·0253·0157·01354계정 전표합이 서브원장(각각payableBalanceByVendor()합·에누리 supply 합·vatAmount-reversedVat합)과 일치한다(테스트:usePurchaseJournal.spec.js"혼합 포트폴리오"). - 회귀0: 기존 P1/P2/P3/P6·E-시리즈 무영향.
status필터 활성화가 기존 정상 매입(전부status==='정상')에 영향 없음.
5. GL 대사 재실행 (데모 시드, DEMO_CONFIG.householdCount=15 기준, scale=0.05)
seedDemoPurchases() → seedDemoPayments() → seedDemoDisbursements() 순서 부트스트랩(main.js 순서 그대로) 후 재실행(스크래치 vitest, docs/superpowers/sdd/task-5-report.md에 원본 로그 보존):
전표 종류별 건수: P1(카드매입 1건 포함) 13 · P2/P3 4 · P-adv-apply 0 · P6 1 · P-reclass 0 · P5(신규) 1 · 합계 19. 전부 균형(DR=CR).
| 계정 | DR | CR | 잔액(net) | 서브원장 | 일치 |
|---|---|---|---|---|---|
0251 외상매입금 | 11,000 | 165,000 | −154,000 | — | (0253과 합산 대사) |
0253 미지급금(카드사 채무 포함) | 352,000 | 627,000 | −275,000 | — | (0251과 합산 대사) |
| 0251+0253 합계(채무) | — | — | 429,000 | payableBalanceByVendor() 합 = 429,000 | ✅ |
0157 매입환출및에누리 | 0 | 10,000 | −10,000(순대변) | 에누리 supply 합 = 10,000 | ✅ |
0135 부가세대급금 | 72,000 | 1,000 | 71,000(순차변) | vatAmount−reversedVat 합 = 71,000 | ✅ |
0103 보통예금 | 0 | 366,500 | −366,500(순유출) | Σpayments.amount(360,500)+Σdisbursements.amount(6,000) = 366,500 | ✅ |
0131 선급금 | 8,500 | 0 | 8,500 | prepaidBalanceByVendor() 합 = 8,500 | ✅ |
0134 가지급금 | 6,000 | 0 | 6,000 | disbursementBalance().total = 6,000 | ✅ |
반품 순액 0 개별 검증: 위 시드는 에누리(v-goods-supply, 부분)만 포함하므로, 별도로 신규 매입(500,000 과세) 등록 → 즉시 reversePurchase(kind:'반품') 적용 후 payableBalanceByVendor()에서 그 거래처 잔액 = 0 확인(반품 순액 불변식 실측).
카드매입 이원화 확인: v-card-shinhan 매입(승인일 2025-12-10, payableAccountCode:'0253') — buildPayments의 대응 지급(paymentDate 2026-01-15)이 카드사 채무를 FIFO 전액 충당. 승인일≠인출일이 정확히 이원화됨.
6. As-built vs 후속
As-built(이번 슬라이스): 카드매입 등록(documentType:'카드'·cardIssuer)·귀속=승인일·카드사 채무 0253·기존 P2 결제 재사용 · 정정/반품(reversePurchase, 반품 전액/에누리 부분·다건 가능)·append-only 이력(reversals[])·지급후 가드 · P5 역분개 전표(vouchersPurchaseReversal, 0157 매입환출및에누리) · vouchersPurchase status 필터 활성화(회귀 없음 확인) · transaction 콘솔 확장(카드매입 등록·정정/반품 다이얼로그·상태 배지·역분개 전표 상세) · GL 대사(7계정, §5) · 하드닝 3건(billingRef 글로벌·에누리 드리프트 pin·혼합대사 확장).
후속(스코프 아웃):
- 카드사 명세서 자동 대사(3자 매칭) — 전혀 미착수.
- 반품/정정 취소(되돌리기) —
reversePurchase가 append-only라 원 매입은 항상 남지만, "이 반품 자체를 취소"하는 역-역분개 액션은 코드베이스에 없음. - 이미 지급된 매입의 반품 시 자동 환급/선급 전환 — 현재는 헤드룸 초과 시 차단만(§2-3), 초과분을 자동으로 선급금(0131)에 적립하거나 실제 환급(0103) 처리하는 액션은 미구현.
- 3자 통과 거래·3원 대사 리포트 — P1P2/P3P6 문서와 동일 후속.
- 거래처명 마스터 연결.