Skip to content

매입 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) · vouchersPurchasestatus !== '취소' 필터 활성화(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
    function payableAccountFor(category, documentType) {
      if (documentType === "카드") return "0253"; // 카드사 채무 — category 무관 항상 미지급금
      return category === "재화" ? "0251" : "0253";
    }
    재화를 카드로 결제해도 매입세금계산서 계정분류(0251)가 아니라 카드사에 대한 채무이므로 0253으로 귀속한다(spec §2 P4 명시 결정).
  • 결제(인출)는 신규 로직 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:[]을 채워 넣으므로, usePurchaseJournalstatus !== '취소' 필터·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 } = {})
  • purchase atom을 절대 변형하지 않는다(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)=>...)매입별 로컬 indexbillingRef의 seq로 썼다 — 동일 날짜에 서로 다른 매입 2건이 각각 반품/에누리되면 billingRef(예: PURRET-20251210-0001)가 충돌했다(감사추적 정합 위반, idrev-N으로 전역 유일이라 실질 충돌은 없었으나 표시용 코드가 중복). sibling(vouchersPurchase/vouchersPayment)처럼 전체 매입의 reversals를 먼저 평탄화한 뒤 그 배열의 전역 index로 seq를 매기도록 정정했다.

전표 발생 순서(purchaseVouchers())

js
[
  ...vouchersPurchase(),
  ...vouchersPayment(),
  ...vouchersPrepaidApply(),
  ...vouchersDisbursement(),
  ...vouchersReclassDisbursement(),
  ...vouchersPurchaseReversal(),
];

4. 불변식 (하드닝 3건 포함)

  1. P5 전표 균형: 반품/에누리 전표 모두 DR=CR.
  2. 반품 순액 0: 원 P1(CR payable 전액) + P5 반품(DR payable 전액) 상쇄 — netOf('0251')+netOf('0253')이 그 매입 건에 한해 0.
  3. 에누리 순액 = 원채무 − 에누리분: 0157 순대변 = 에누리 kind로 감액된 공급가액 합.
  4. billingRef 전역 유일(★하드닝 T3 Minor) — 동일 날짜 다건 반품/에누리도 PURRET-/PURDSC- seq가 충돌하지 않는다.
  5. 에누리 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건 이상 부분 에누리").
  6. 혼합 GL 대사에 반품/에누리·카드매입 포함(★하드닝 T3 Minor) — 정상+지급+카드매입+반품+에누리 포트폴리오에서 0251·0253·0157·0135 4계정 전표합이 서브원장(각각 payableBalanceByVendor() 합·에누리 supply 합·vatAmount-reversedVat 합)과 일치한다(테스트: usePurchaseJournal.spec.js "혼합 포트폴리오").
  7. 회귀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).

계정DRCR잔액(net)서브원장일치
0251 외상매입금11,000165,000−154,000(0253과 합산 대사)
0253 미지급금(카드사 채무 포함)352,000627,000−275,000(0251과 합산 대사)
0251+0253 합계(채무)429,000payableBalanceByVendor() 합 = 429,000
0157 매입환출및에누리010,000−10,000(순대변)에누리 supply 합 = 10,000
0135 부가세대급금72,0001,00071,000(순차변)vatAmount−reversedVat 합 = 71,000
0103 보통예금0366,500−366,500(순유출)Σpayments.amount(360,500)+Σdisbursements.amount(6,000) = 366,500
0131 선급금8,50008,500prepaidBalanceByVendor() 합 = 8,500
0134 가지급금6,00006,000disbursementBalance().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 문서와 동일 후속.
  • 거래처명 마스터 연결.