다크모드
차수 체인 · 마감 · 이월 — 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 대상 슬라이스)까지 useBillingPeriod는 line+edition당 단일 엔트리였다 — "지금 차수"의 필드(고지서명·연월·기간·납기)를 편집할 수는 있었지만, 마감해서 다음 달로 넘어가는 개념 자체가 없었다. 미수금 연령분석(AR Aging)의 "차기이월"도 화면상 파생 계산일 뿐, 실제로 새 차수를 열고 이전 차수를 잠그는 상태 전이는 미구현이었다.
본 트랙(6개 서브태스크, T1~T6)이 이를 채운다:
- T1 — 스토어를 단일 엔트리 → 체인(entries 배열 + currentIndex)으로 진화.
- T2 — 날짜·차수 하드코딩 상수(
REFERENCE_DATE/CURRENT_PERIOD)를 체인에서 파생하는 앵커(billingAnchor.js)로 대체 — 전 엔진 소비처 재배선. - T3 — 이월에 필요한 캐논 변이 seam 4종(중복 VAT 방지 포함) 신설.
- T4 —
usePeriodClose마감 오케스트레이터 — 실행 순서가 정합의 전부. - T5 — 마감 UI(연령분석 액션바 + 게이트 다이얼로그) + 차수 이력 카드 + 헤더 라벨 동적화.
- 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. 해법 — appendCarriedPeriod의 vatIncluded: 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"
이 트랙 이전부터 존재하던 초기 데이터(개시미수, useInitialReceivables의 addPeriod 유입분)는 세전 원금으로 시딩된다(그래야 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-hong | 116,032 | 116,032 |
| c-hong-102 | 140,415 | 140,415 |
| c-kim | 89,832 | 89,832 |
| c-gap | 169,732 | 169,732 |
| c-gen-0005 | 274,664 | 274,664 |
| c-gen-0009 | 61,555 | 61,555 |
| c-gen-0010 | 284,006 | 284,006 |
| c-gen-0013 | 57,090 | 57,090 |
| c-gen-0015 | 272,101 | 272,101 |
| c-end | 10,400 | 10,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는 단방향 전이만 제공한다. 이유:
- 회계적 정합성: 마감 후 이월 스냅샷·검침 롤·수납 리셋·선수금 재적립이 이미 다음 차수에 반영됐다. 재오픈해서 닫힌 차수를 고치면 이미 파생된 다음 차수 값과 정합이 깨진다(재계산 캐스케이드가 없는 설계이므로).
- 불변식 보존: §5의 seam 항등은 "한 번만 계산해 고정"을 전제로 성립한다. 재오픈을 허용하면 "고정된 값"이라는 전제 자체가 무너진다.
- 실제 회계 실무에서도 마감된 장부는 재오픈이 아니라 정정분개(다음 기 조정)로 처리한다 — 이 설계도 동일 원칙을 따른다.
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().canClose가 false면 closePeriod()는 아무 부작용 없이 즉시 { ok: false, reason: ... }를 반환한다(마감 로직 진입 자체를 안 함 — closeGate()를 다시 호출해 재확인하는 것이 유일한 사이드이펙트). 가수금은 "귀속 계약 미확정 입금"이라 이월 스냅샷에 자동 포함시킬 방법이 없다(어느 계약의 잔여에 더해야 할지 알 수 없음) — 반드시 사람이 재분류해야 한다.
6-4. prior-only 계약 정합
전기이월만 있고 당기 부과 라인이 없는 계약(예: 공실 전환·계약 해지 후에도 미수가 남은 경우, c-end 참고)은 identityOf()가 당기 lines에서 찾지 못하면 null을 반환한다 — 이 경우 appendCarriedPeriod는 useInitialReceivables가 이미 유지하고 있는 기존 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.js의COLLECTION_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.js의buildRow가 반환 행에dueDay: p.dueDay(옵셔널)를 추가 노출하고,usePeriodClose.collectCarry()가p.dueDay != null이면 스냅샷에 실어appendCarriedPeriod(contractCode, { label, dueDay, billedByComponent }, identity)로 그대로 전달한다.useInitialReceivables.appendCarriedPeriod의 시그니처가dueDay옵셔널 인자를 받도록 확장됐다(addPeriod와 대칭 — §신규 계약 vs 이월 스냅샷 구분 유지). 값이 없으면(dueDay == null) 필드 자체를 넣지 않는다(스프레드 가드) — 기존 데이터 회귀 없음. - A-3 —
managementCode끝자리 회전(다자리 시퀀스 퇴행) ✅ 해소:buildNextEntry의seqSuffix파생이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.sql이office_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로 순서 보존), closedSummary → billing_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_chain2행을 서울 프로젝트로 이관했고, 원본·대상 건수를 대조했다. 전체 적용 마이그레이션 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.js등closePeriod()시나리오 테스트)가 실 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가 이 템플릿을 따름).