Skip to content

차수 체인 · 마감 · 이월 — BE 참고 매뉴얼

대상 독자: BE 개발자/AI. 관리비 상용화 "차수 체인" 트랙(2026-07-11) 산출물. 정본 설계: docs/superpowers/plans/2026-07-11-period-chain-close-carryforward-design.md(레포 내 spec — 파일명 참고, 실제 커밋 경로는 .superpowers/docs/superpowers 어느 쪽이든 T1~T6 커밋 메시지의 "차수체인 T{n}" 태그로 grep 가능). as-built 소스: src/composables/useBillingPeriod.js · usePeriodClose.js · billingAnchor.js · billingRepo.js.


1. 배경 — 무엇이 없었나

이전(docs/handoff/backend/billing-console-period.md 대상 슬라이스)까지 useBillingPeriodline+edition당 단일 엔트리였다 — "지금 차수"의 필드(고지서명·연월·기간·납기)를 편집할 수는 있었지만, 마감해서 다음 달로 넘어가는 개념 자체가 없었다. 미수금 연령분석(AR Aging)의 "차기이월"도 화면상 파생 계산일 뿐, 실제로 새 차수를 열고 이전 차수를 잠그는 상태 전이는 미구현이었다.

본 트랙(6개 서브태스크, T1~T6)이 이를 채운다:

  1. T1 — 스토어를 단일 엔트리 → 체인(entries 배열 + currentIndex)으로 진화.
  2. T2 — 날짜·차수 하드코딩 상수(REFERENCE_DATE/CURRENT_PERIOD)를 체인에서 파생하는 앵커(billingAnchor.js)로 대체 — 전 엔진 소비처 재배선.
  3. T3 — 이월에 필요한 캐논 변이 seam 4종(중복 VAT 방지 포함) 신설.
  4. T4usePeriodClose 마감 오케스트레이터 — 실행 순서가 정합의 전부.
  5. T5 — 마감 UI(연령분석 액션바 + 게이트 다이얼로그) + 차수 이력 카드 + 헤더 라벨 동적화.
  6. T6 — 영속 seam(billingRepo.js) — mock(localStorage) 활성, Supabase 어댑터 구현 완료·미접속(자격 부재).

2. 상태머신 — 차수 엔트리

2-1. 엔트리 구조

entry = {
  // 편집 가능(8필드) — PERIOD_EDITABLE_FIELDS
  noticeName, cycle, year, month, startDate, endDate, dueDate, overdueBaseDate,
  // 읽기전용 메타(4필드) — PERIOD_READONLY_FIELDS
  managementCode, createdDate, editedDate, operator,
  // 체인 상태(T1 신설)
  status: 'open' | 'closed',
  closedSummary: { closedAt, 부과총액, 수납총액, 이월총액, 선수금잔액, 세대수 } | null
}

2-2. 체인 저장 구조

state[key] = { entries: [entry, entry, ...], currentIndex: number }
key = `${line}:${edition ?? 'default'}`   // buildPeriodKey — T1 이전과 동일 키잉
  • entries과거→현재 순(index 0이 가장 오래됨). currentIndex는 항상 유일한 status:'open' 엔트리를 가리킨다.
  • entries[currentIndex] 이전 인덱스는 전부 status:'closed'. 닫힌 엔트리는 재오픈 불가(§6).

2-3. 원자 전이 — closeCurrentPeriod(line, edition, { closedSummary, nextEntry })

js
// useBillingPeriod.js
export function closeCurrentPeriod(line, edition, { closedSummary, nextEntry } = {}) {
  if (!nextEntry || typeof nextEntry !== "object")
    return { ok: false, reason: "nextEntry required" };
  const chain = chainOf(line, edition);
  const current = chain.entries[chain.currentIndex];
  if (current.status === "closed") return { ok: false, reason: "already closed" };
  const closedLabel = `${current.year}-${String(current.month).padStart(2, "0")}`;
  const entries = chain.entries.slice();
  entries[chain.currentIndex] = {
    ...current,
    status: "closed",
    closedSummary: closedSummary ? { ...closedSummary } : null,
  };
  entries.push({ ...sanitizeEntry(nextEntry), status: "open", closedSummary: null });
  state.value = { ...state.value, [key]: { entries, currentIndex: entries.length - 1 } };
  return { ok: true, closedLabel };
}

이 함수는 값을 계산하지 않는다closedSummary/nextEntry는 전부 호출자(usePeriodClose)가 캐논 소스(§4)에서 이미 계산한 값을 그대로 주입한다. 스토어 층은 clone-replace(원자 배열 교체)만 담당한다 — 계산 로직과 저장 로직의 분리가 이 설계의 핵심.

2-4. 구형 데이터 마이그레이션

localStorage(또는 Supabase)에 아직 구 단일 엔트리 형태({noticeName, cycle, ...}, entries 배열 없음)가 저장돼 있을 수 있다. sanitizeChain()이 로드 시 투명하게 감지해 1엔트리 체인으로 승격한다:

js
function sanitizeChain(parsed) {
  if (!parsed || typeof parsed !== "object") return { entries: [cloneDefault()], currentIndex: 0 };
  if (Array.isArray(parsed.entries)) {
    /* 신형 — 항목별 sanitize */
  }
  return { entries: [sanitizeEntry(parsed)], currentIndex: 0 }; // 구형 → 신형 마이그레이션
}

역방향 마이그레이션(체인 → 단일 엔트리)은 없다 — 단방향 진화.


3. 마감 순서도 — usePeriodClose.closePeriod()

마감은 재계산 없음이 원칙이다. 모든 금액은 기존 캐논(부과/수납/이월 엔진)이 이미 계산해 놓은 값을 그대로 고정·복사할 뿐이다. 실행 순서 자체가 정합을 보장한다:

①(전진 전) 스냅샷 수집    collectCarry() + advanceBalanceByContract()
②           체인 전진      closeCurrentPeriod() — current→closed, nextEntry→open
③           이월 append    appendCarriedPeriod() × N계약 (전진 후라 구 라벨이 "과거"가 되어 라벨 가드 통과)
④           원자 롤        meters.rollForward() + 말일 유효 라인 만근 복사(replaceLines) + resetReceipts() + resetForwarding()
⑤           선수금 재적립  creditAdvance() × N계약 (승계)
⑥           스테이지 리셋  resetStages(line, edition) — 새 차수는 전 스테이지 pending
⑦           asOf 복귀      useCollectingAsOf().resetToPresent()

3-1. 왜 이 순서인가 — 순서 의존성

  • ①이 ②보다 먼저여야 하는 이유: collectCarry()가 소비하는 useForwarding().forwardingByContract()현재(open) 차수 기준으로 잔여를 계산한다. ②(체인 전진) 후에 호출하면 currentAsOf()가 이미 다음 달로 넘어가 있어 잘못된 시점의 잔여를 읽게 된다.
  • ③이 ②보다 반드시 뒤여야 하는 이유: appendCarriedPeriod()가 호출하는 useInitialReceivables의 라벨 가드는 "당기(현재 open 라벨)로는 append할 수 없다"는 불변식을 갖는다(당기는 addPeriod로만 채워진다 — §신규 계약 구분과 이월 스냅샷 구분 유지). ②에서 라벨이 이미 다음 달로 넘어갔기 때문에, 구 차수 라벨(방금 닫힌 라벨)은 이제 "과거"가 되어 가드를 무사히 통과한다. 순서를 바꾸면(③을 ②보다 먼저 실행하면) 가드에 걸려 append가 거부된다.
  • ④가 ③보다 뒤여야 하는 이유: 검침 롤포워드·라인 복사·수납 리셋은 "다음 차수를 시작 상태로 만드는" 작업이다. 이월 스냅샷(③)이 먼저 고정돼야 다음 차수가 깨끗한 상태에서 출발한다.
  • ⑥이 ⑤보다 뒤인 이유: 스테이지 리셋은 UI 게이트(부과→청구→수납 진행 상태)만 초기화한다 — 데이터 이관(⑤ 선수금 재적립)과는 독립이지만, 관례상 "데이터부터 완결 후 UI 게이트를 연다"는 순서를 지킨다.
  • ⑦이 맨 마지막인 이유: resetToPresent()는 수납 조회 화면의 asOf(기준일)를 "현재"로 재동기화하는데, 이 "현재"는 ②에서 이미 전진한 새 차수의 endDate를 읽는다 — 체인 전진(②)이 완료된 뒤에만 올바른 값을 반환한다.

3-2. buildNextEntry(current) — 차기 엔트리 파생 규칙

js
// export = 테스트 전용 export(A-3 managementCode 다자리 시퀀스 단위검증, usePeriodClose.spec.js)
export function buildNextEntry(current) {
  const y = Number(current.year),
    m = Number(current.month);
  const ny = m === 12 ? y + 1 : y;
  const nm = m === 12 ? 1 : m + 1;
  const dueDay = Number(String(current.dueDate).slice(8, 10)) || 15; // 당월 dueDate의 "일"만 추출
  const end = lastDay(ny, nm);
  // 마지막 숫자 그룹 전체 파싱(A-3, 2026-07-11 해소) — 이전엔 마지막 한 자리만 취해 두자리 이상
  // 시퀀스에서 (예: a3b3c10 → a3b3c1) 퇴행하던 결함이 있었다.
  const seqSuffix = Number((String(current.managementCode).match(/\d+$/) ?? ["0"])[0]) + 1;
  return {
    noticeName: `${ny}년 ${nm}월 고지서`,
    cycle: nm,
    year: ny,
    month: pad2(nm),
    startDate: `${ny}-${pad2(nm)}-01`,
    endDate: `${ny}-${pad2(nm)}-${pad2(end)}`,
    dueDate: `${ny}-${pad2(nm)}-${pad2(Math.min(dueDay, end))}`, // "일"을 익월로 평행이동(말일 클램프)
    overdueBaseDate: `${ny}-${pad2(nm)}-${pad2(Math.min(dueDay, end))}`,
    managementCode: `a3b3c${seqSuffix}`, // 끝자리 +1 회전(다자리 안전, A-3 해소 — §7 참고)
    createdDate: current.endDate,
    editedDate: current.endDate,
    operator: current.operator,
  };
}
  • 익월 1일~말일 자동 계산(연말 롤오버 포함: 12월 → 1월+연도 증가).
  • 납기/연체기산일은 "일"(day-of-month)만 평행이동, 말일을 넘으면 그 달 말일로 클램프.
  • 이 엔트리 자체에는 dueDay 필드가 없다(다음 차수의 dueDate/overdueBaseDate 계산에만 쓰이는 지역 변수) — 계약별 dueDay 원자값 보존은 별도 경로(§7 A-2)로 처리된다.

4. vatIncluded seam — 이중 VAT 방지 (T3)

4-1. 문제

이월 시 "지난달 미수 잔여"를 다음 차수의 전기이월로 넣을 때, 그 잔여 금액은 이미 부가세가 포함된 gross(청구 시점에 VAT가 계산돼 있던 값)다. 그런데 useForwarding.js의 기존 buildRow는 모든 entry에 대해 withVat(billedByComponent)를 호출해 부가세를 재계산(덮어쓰기)한다 — 신규 당기 부과분(addPeriod로 들어온 세전 원금)에는 맞는 로직이지만, 이미 gross인 이월 스냅샷에 다시 적용하면 부가세가 두 번 반영된다.

4-2. 해법 — appendCarriedPeriodvatIncluded: true 플래그

js
// useInitialReceivables.js
export function appendCarriedPeriod(contractCode, { label, billedByComponent }, identity) {
  // ...당기 라벨 가드 없음(addPeriod와 차이) — 신규 계약이면 priorIdentity 등재
  // entry에 vatIncluded: true 플래그를 단다
}
js
// useForwarding.js:68 — buildRow의 gross 결정
p.vatIncluded ? { ...p.billedByComponent } : withVat(p.billedByComponent);
  • vatIncluded: true(이월 스냅샷) → 그대로 통과(재계산 없음, gross 그 자체).
  • 플래그 없음(기존 당기 부과 entry, addPeriod로 생성) → 종전 withVat 경로 그대로(회귀 0).

4-3. 시드 값 규칙 — "시드 prior는 세전·이월 잔여는 gross"

이 트랙 이전부터 존재하던 초기 데이터(개시미수, useInitialReceivablesaddPeriod 유입분)는 세전 원금으로 시딩된다(그래야 withVat가 최초 1회 정상적으로 세금을 얹는다). 반면 마감이 생성하는 이월 스냅샷(appendCarriedPeriod)은 이미 청구 시점에 세금이 계산된 gross이므로 vatIncluded: true로 구분해야 한다. 이 둘을 혼동하면(예: 이월 스냅샷을 세전으로 착각해 vatIncluded 없이 append) 다음 차수 전기이월 금액이 실제보다 커진다(이중 과세).

4-4. 회귀 실증 — red-on-revert

vatIncluded 분기(useForwarding.js:68)를 강제로 제거하면(항상 withVat 적용), 테스트 데이터가 정확히 10% VAT 비율이 아닌 잔여(37/1537, 부분 충당 후 잔여)를 갖도록 설계돼 있어 즉시 실패(expected 1600 to be 1537)한다 — 최초 테스트가 10%로 딱 떨어지는 값을 써서 동어반복(분기를 지워도 우연히 통과)이었던 결함을 리뷰가 적발해 정정했다(T3 리뷰 후속 커밋). 이 회귀 테스트가 vatIncluded 로직의 유일한 방어선이므로, seam을 건드릴 때는 반드시 src/composables/__tests__/useForwarding.spec.js의 "vatIncluded 이월 entry" describe를 먼저 확인.


5. 차수 seam 항등 불변식

불변식: 마감 직전 계약별 "차기이월"(useForwarding().forwardingByContract()의 outstanding 합) == 마감 후 동일 계약의 "전기이월"(새 차수의 forwarding 잔여).

이 불변식이 성립하는 이유는 재계산을 하지 않기 때문이다 — collectCarry()가 마감 직전 잔여를 얕은 복사({ ...p.byComponent })로 스냅샷 뜨고, 그 값을 그대로 appendCarriedPeriod에 주입한다. 계산 경로가 하나뿐이므로 값이 어긋날 여지가 구조적으로 없다.

T4 구현 시 시드 데이터(seedDemoReceipts()) 기준 10개 계약 전수 실측(마감 1회 실행 후 스크래치 스크립트로 대조, 커밋에는 미포함 — 재현하려면 usePeriodClose.spec.js 패턴을 참고해 별도 스크립트 작성):

계약before(carry)after(forwarding)
c-hong116,032116,032
c-hong-102140,415140,415
c-kim89,83289,832
c-gap169,732169,732
c-gen-0005274,664274,664
c-gen-000961,55561,555
c-gen-0010284,006284,006
c-gen-001357,09057,090
c-gen-0015272,101272,101
c-end10,40010,400

MISMATCHES = 0/10. 이 불변식은 회귀 테스트로 고정돼 있지 않다(스크래치 실측만) — 향후 캐논 변경 시 수동 재검증 권고.

부가 확인된 성질:

  • 마감 시점 말일(endDate) 유효 라인만 다음 차수로 만근 복사된다(§6-2, 기중 종료 라인은 복사되지 않음).
  • 마감 직후 새 차수의 수납(receipts)은 0건, 선수금 잔액은 계약별로 마감 전후 동치(승계).
  • 닫힌 엔트리의 closedSummary.이월총액은 게이트(closeGate()) 스냅샷 값과 고정 일치.

6. 재오픈 불가 근거 & 엣지케이스

6-1. 재오픈이 없는 이유

updatePeriod()current.status === 'closed'면 즉시 { ok: false, reason: 'closed' }를 반환한다(방어 코드 — 정상 흐름상 current는 항상 open이므로 이 분기는 실제로는 도달하지 않지만, 향후 호출 경로가 늘어날 때의 안전망). 재오픈 API 자체가 없다closeCurrentPeriod는 단방향 전이만 제공한다. 이유:

  1. 회계적 정합성: 마감 후 이월 스냅샷·검침 롤·수납 리셋·선수금 재적립이 이미 다음 차수에 반영됐다. 재오픈해서 닫힌 차수를 고치면 이미 파생된 다음 차수 값과 정합이 깨진다(재계산 캐스케이드가 없는 설계이므로).
  2. 불변식 보존: §5의 seam 항등은 "한 번만 계산해 고정"을 전제로 성립한다. 재오픈을 허용하면 "고정된 값"이라는 전제 자체가 무너진다.
  3. 실제 회계 실무에서도 마감된 장부는 재오픈이 아니라 정정분개(다음 기 조정)로 처리한다 — 이 설계도 동일 원칙을 따른다.

6-2. 기중 종료 라인 미복사

js
const copied = allocations.lines.value
  .filter((l) => l.end === closedEnd) // 말일 유효 라인만
  .map((l, i) => ({ ...l, id: `${l.id}-r${next.month}`, start: next.startDate, end: next.endDate, ... }))

계약이 차수 중간에 종료된 경우(l.end !== closedEnd, 예: 15일 계약 종료) 그 라인은 다음 차수로 복사되지 않는다. 의도된 설계 — 계약이 끝난 세대를 다음 달에도 부과 대상으로 만근 복사하면 신규 입주자(교체 세대)와 이중 부과가 발생한다. 계약 갱신/신규 계약은 별도 온보딩 플로우(개시미수·초기데이터)로 처리한다.

6-3. 가수금(suspense) 마감 차단

js
const suspense = receipts.suspenseBalance();
if (suspense.entries.length > 0)
  blockers.push(`가수금 ${suspense.entries.length}건(...) 정리 필요`);

closeGate().canClosefalseclosePeriod()는 아무 부작용 없이 즉시 { ok: false, reason: ... }를 반환한다(마감 로직 진입 자체를 안 함 — closeGate()를 다시 호출해 재확인하는 것이 유일한 사이드이펙트). 가수금은 "귀속 계약 미확정 입금"이라 이월 스냅샷에 자동 포함시킬 방법이 없다(어느 계약의 잔여에 더해야 할지 알 수 없음) — 반드시 사람이 재분류해야 한다.

6-4. prior-only 계약 정합

전기이월만 있고 당기 부과 라인이 없는 계약(예: 공실 전환·계약 해지 후에도 미수가 남은 경우, c-end 참고)은 identityOf()가 당기 lines에서 찾지 못하면 null을 반환한다 — 이 경우 appendCarriedPerioduseInitialReceivables가 이미 유지하고 있는 기존 priorIdentity 파생분을 그대로 재사용한다(새 identity로 덮어쓰지 않음). 즉 identity 갱신은 "당기 라인이 있을 때만" 일어나고, 없으면 기존 값이 보존된다.


7. 후속 Minor — 반영 이력 (2026-07-11 일괄 해소, A-1~A-3)

이 트랙(T1~T6) 완료 직후 opus 최종 리뷰가 적발한 잔여 결함 — 같은 날 별도 커밋(fix(billing): 수납 전표 기간 태그 동적화 (A-1), fix(billing): 차수체인 Minor 일괄 (A-2~A-5))으로 전부 반영 완료됐다. §3-2·§4 코드 인용은 이미 반영 후 상태다.

  • A-1 — 수납 전표 billingRef 태그 정적 잔존 ✅ 해소: useBillingJournal.jsCOLLECTION_PERIOD_TAG = CURRENT_PERIOD.replace('-', '')는 모듈 로드 시 1회 평가되는 상수였다 — 마감으로 체인이 전진해도 값이 그대로 '202512'에 박혀, 마감 후 신규 수납 전표(RCP-/SUS-/RECLASS- 접두)의 billingRef가 계속 옛 차수 태그를 달았다. vouchersCollection() 내부에서 const periodTag = currentLabel().replace('-', '')(billingAnchor.js 소비)로 매 호출 시 재평가하도록 교체 — 마감 후 신규 수납은 다음 차수 태그(예: RCP-202601-…)를 단다. 과거에 이미 생성된 전표의 라벨은 소급 갱신되지 않는다 — 마감 시 resetReceipts()receipts를 리셋하므로, 실질적으로 "과거 전표가 새 태그로 바뀌는" 시나리오 자체가 발생하지 않는다(리셋된 빈 목록에서 새로 쌓이는 전표만 새 태그를 받음).
  • A-2 — 재이월 시 납기일 "일자" 미보존 ✅ 해소: 이전에는 계약별 차수 dueDay가 이월 스냅샷 어디에도 실리지 않아 마감을 거치면 유실됐다(표시용 overdueBaseDate는 §3-2 규칙으로 새로 파생되지만, 그 계약/차수가 원래 갖고 있던 개별 dueDay 원자값 자체는 사라졌다). useForwarding.jsbuildRow가 반환 행에 dueDay: p.dueDay(옵셔널)를 추가 노출하고, usePeriodClose.collectCarry()p.dueDay != null이면 스냅샷에 실어 appendCarriedPeriod(contractCode, { label, dueDay, billedByComponent }, identity)로 그대로 전달한다. useInitialReceivables.appendCarriedPeriod의 시그니처가 dueDay 옵셔널 인자를 받도록 확장됐다(addPeriod와 대칭 — §신규 계약 vs 이월 스냅샷 구분 유지). 값이 없으면(dueDay == null) 필드 자체를 넣지 않는다(스프레드 가드) — 기존 데이터 회귀 없음.
  • A-3 — managementCode 끝자리 회전(다자리 시퀀스 퇴행) ✅ 해소: buildNextEntryseqSuffix 파생이 String(managementCode).replace(/\D/g,'').slice(-1)(마지막 숫자 한 자리만 취함 — a3b3c10 다음 마감에서 끝자리만 보면 0이라 a3b3c1로 퇴행하던 결함)이었다. (String(current.managementCode).match(/\d+$/) ?? ['0'])[0]로 교체 — 관리코드 끝의 연속 숫자 런 전체를 정수로 파싱한다. a3b3c9 → a3b3c10 → a3b3c11처럼 자릿수와 무관하게 단조증가한다. buildNextEntry는 이제 usePeriodClose.js에서 테스트 전용 named export로 승격돼 단위검증 가능(usePeriodClose.spec.js 신규 케이스).
  • (부수) collectCarry()의 map 인덱스 미사용 ✅ 정리: 동작 영향 없던 cosmetic 미사용 변수 제거.

8. DDL — 영속 스키마

8-1. 현재(jsonb 스탠드인) — 서울 DB 적용 완료

sql
create table if not exists billing_period_chain (
  key text primary key,          -- `${line}:${edition}` 예: 'service-charge:actual'
  chain jsonb not null,          -- { entries: [...], currentIndex: N } 통짜 저장
  updated_at timestamptz not null default now()
);
  • makeSupabaseRepo(client)(src/composables/billingRepo.js)가 이 테이블을 select('key, chain') / upsert({ key, chain, updated_at })로 왕복한다.
  • 키당 1행, 체인 전체를 jsonb 통짜 저장 — 정규화하지 않는다. 기획단 표준(docs/decisions/PLANNING-STAGE-DB-SUPABASE-2026-07-09.md) — "DB는 멍청한 저장소, 업무 로직은 앱 층"에 따라 계산·검증은 전부 컴포저블에 남기고 DB는 저장만.
  • ✅ 초기 DDL 적용(2026-07-13)supabase/migrations/20260713110000_billing_period_chain.sql이 테이블을 생성했다.
  • ✅ Office 격리 정정(2026-07-14)20260714070000_secure_billing_period_chain_by_office.sqloffice_id(office_id,key) unique를 추가하고 기획단 anon full access를 제거했다. 조회는 Company 멤버, 쓰기는 Office 멤버 또는 Company 관리자만 가능하다. office_id 없는 레거시 기획 행은 RLS로 접근할 수 없다.

8-2. 정규화 목표형 (프로덕션 MariaDB 이관 시 권장 — 미구현·참고용)

jsonb 통짜 저장은 기획단 속도를 위한 타협이다. 프로덕션에서는 아래처럼 정규화하는 것을 권장한다(개발팀 판단 필요 — 본 트랙은 이 스키마를 구현하지 않았다):

sql
-- 차수 엔트리 1행 = 1차수(라인+에디션+연월 유니크)
create table billing_period (
  id            bigint generated always as identity primary key,
  line          text not null,             -- 'service-charge' 등
  edition       text not null,             -- 'actual' | 'provisional'
  seq_index     int not null,              -- entries 배열 인덱스(과거→현재 순서 보존)
  notice_name   text not null,
  cycle         int not null,
  year          int not null,
  month         text not null,             -- 'MM'
  start_date    date not null,
  end_date      date not null,
  due_date      date not null,
  overdue_base_date date not null,
  management_code text not null,
  created_date  date not null,
  edited_date   date not null,
  operator      text not null,
  status        text not null check (status in ('open','closed')),
  unique (line, edition, seq_index)
);

-- 마감 시점 스냅샷(1:1 with closed entry)
create table billing_period_closed_summary (
  period_id     bigint primary key references billing_period(id),
  closed_at     date not null,
  charged_total   numeric not null,   -- 부과총액
  collected_total numeric not null,   -- 수납총액
  carried_total   numeric not null,   -- 이월총액
  advance_balance numeric not null,   -- 선수금잔액
  unit_count      int not null        -- 세대수
);

-- 계약별 이월 스냅샷(carry_snapshot) — appendCarriedPeriod가 쓰는 payload를 정규화
create table carry_snapshot (
  id              bigint generated always as identity primary key,
  period_id       bigint not null references billing_period(id),  -- 어느 차수 마감에서 생성됐는지
  contract_code   text not null,
  label           text not null,            -- 원 차수 라벨(YYYY-MM) — 버킷 추적용
  component_code  text not null,            -- 항목 성격(원금/연체료/VAT 등)
  amount          numeric not null,
  vat_included    boolean not null default true,  -- §4 — 이월 스냅샷은 항상 gross
  unit_code       text,                      -- identity — null 허용(prior-only 미확정)
  member_code     text
);

이관 시 참고: chain.entries[]billing_period 행별 전개(seq_index로 순서 보존), closedSummarybilling_period_closed_summary 1:1, 마감 시 appendCarriedPeriod 호출마다 만든 {contractCode, label, billedByComponent, identity}carry_snapshot에 컴포넌트별 행 전개(billedByComponent{항목명: 금액} 맵이므로 행 하나당 컴포넌트 하나로 정규화).


9. 백엔드 접속 상태 (2026-07-13)

  • 로컬 앱 backend = 서울 Supabase. 무시 파일 .env.local에 서울 Project URL·publishable key를 저장했으며, resolveBillingRepo()는 일반 실행에서 makeSupabaseRepo(client)를 선택한다. Mac/Windows 각 로컬에는 이 무시 파일을 별도로 보관해야 한다.
  • Mumbai 원본의 billing_period_chain 2행을 서울 프로젝트로 이관했고, 원본·대상 건수를 대조했다. 전체 적용 마이그레이션 7개는 supabase_migrations.schema_migrations에도 등록해 이후 CLI가 재적용하지 않게 했다.
  • 남은 검증: 실제 사용자 조작으로 차수 저장 후 새로고침 복구를 확인하는 앱 레벨 스모크 테스트. DDL·RLS 정책·데이터 건수는 DB에서 검증 완료했다.
  • vitest 가드 추가(2026-07-11 후속): resolveBillingRepo() 최상단에 if (import.meta.env.MODE === 'test') return { repo: mockRepo, backend: 'mock' }를 추가했다 — 테스트 러너(usePeriodClose.spec.jsclosePeriod() 시나리오 테스트)가 실 Supabase에 기록을 남기는 사례가 실측 발견돼(공유 실 DB 오염·픽스처 키 누출) 즉시 차단한 것. 테스트 환경은 URL/키 존재 여부와 무관하게 항상 mock(localStorage) 백엔드로 강제된다.

10. 관련 문서

  • docs/handoff/backend/billing-console-period.md — 이 트랙 이전(단일 엔트리) 스토어 계약. §편집 서술은 폐기됐지만 필드·키잉·sanitize 원칙은 그대로 유효(문서 상단 진화 노트 참고).
  • docs/handoff/frontend/billing-period-chain.md — 본 트랙의 FE 대응 문서(앵커 재배선 지도·UI 배선·테스트 위치).
  • docs/manual/billing-period-close.md — 경리 사용자용 마감 절차 매뉴얼.
  • docs/manual/receivable-aging.md — 미수금 연령분석 화면(마감 버튼이 위치한 화면) 기존 매뉴얼.
  • docs/decisions/PLANNING-STAGE-DB-SUPABASE-2026-07-09.md — Supabase seam 전략 정본(billingRepo가 이 템플릿을 따름).