Skip to content

BE 참고 — 연체료 발생 중지 (accrualPause, 2026-06-25)

독자: BE 개발자/AI. 연체료 발생 중지(매월 일자창 freeze) 기능의 의미론·알고리즘 계약·정책 모델·소비 지점·범위 밖을 정의한다. as-built 기준: 브랜치 worktree-late-fee-pause (2026-06-25). 연계 문서: FE 핸드오프 docs/handoff/frontend/late-fee-accrual-pause.md, 연체료 엔진 spec docs/superpowers/specs/2026-06-19-late-fee-interest-engine-design.md.


1. 의미론 — 유예+catch-up (B형), 면제 아님

발생 중지는 연체료 면제(forgiveness)가 아니다. 정확한 의미:

  • 창 내(startDay ≤ 오늘 일 ≤ endDay): asOfstartDay − 1로 동결 → 그 날짜 기준으로만 연체료 계산. 창 안에서 매일 다시 계산해도 같은 값이 나온다(단조성).
  • 창 이후(오늘 일 > endDay): asOf가 그대로 통과 → 유예됐던 일수까지 포함해 정상 산정(catch-up). 총 연체료는 중지 기간이 없었을 때와 동일.
  • 창 이전(오늘 일 < startDay): 중지 없음, asOf 통과.

실효: "매월 10~20일 청구 작업 기간에는 연체료가 늘지 않고 21일부터 다시 정상 가산"과 같은 운영 편의 목적. 면제 요청은 별도 감면(lateFeeWaiver) 경로를 사용한다.


2. 핵심 함수 — effectiveAsOf(asOf, pause)

파일: src/composables/interestEngine.js

js
export function effectiveAsOf(asOf, pause) {
  if (!pause || !pause.enabled) return asOf;
  const [y, m, d] = asOf.split("-").map(Number);
  if (d < pause.startDay || d > pause.endDay) return asOf;
  return new Date(Date.UTC(y, m - 1, pause.startDay - 1)).toISOString().slice(0, 10);
}

규칙 상세

조건반환
pause == null 또는 pause.enabled === falseasOf 그대로
d < startDay 또는 d > endDayasOf 그대로
startDay ≤ d ≤ endDay (창 내)그 달 startDay − 1일 (ISO 문자열)
startDay === 1 (창 내)전월 말일 (Date.UTC(y, m-1, 0) = 전월 0번째 = 전월 마지막 날)

단조성 불변식

effectiveAsOf(asOf, pause) ≤ asOf (항상 과거 또는 동일). 미래로 당기는 경우 없음.

결정성

순수 함수. 현재 시각 비의존. 동일 인자 → 항상 동일 반환.

예시

asOf = '2025-12-15', startDay=10, endDay=20
d=15, 10≤15≤20 → freeze → '2025-12-09' (startDay-1 = 10-1 = 9일)

asOf = '2025-12-08', startDay=10, endDay=20
d=8 < 10 → asOf 그대로 = '2025-12-08'

asOf = '2025-12-21', startDay=10, endDay=20
d=21 > 20 → asOf 그대로 = '2025-12-21'

asOf = '2025-12-15', startDay=1, endDay=5
d=3, 1≤3≤5 → freeze → Date.UTC(2025, 10, 0) = 2025-11-30 (전월 말일)

3. 정책 계약 — accrualPause 필드

파일: src/composables/lateFeePolicy.js

js
// DEFAULT_LATE_FEE_POLICY 내
accrualPause: { enabled: false, startDay: 10, endDay: 20 }

필드 정의

필드타입기본의미
enabledbooleanfalse기능 활성 여부. false면 effectiveAsOf 즉시 통과
startDaynumber (1–31)10창 시작일(해당 달 일수) — 이 날 포함
endDaynumber (1–31)20창 종료일(해당 달 일수) — 이 날 포함

검증 규칙 (setAccrualPause)

js
function setAccrualPause(partial) {
  const next = { ...policy.value.accrualPause, ...partial };
  if (next.startDay < 1 || next.endDay > 31 || next.startDay > next.endDay) return; // 무효 무시
  policy.value = { ...policy.value, accrualPause: next };
}
  • 1 ≤ startDay ≤ endDay ≤ 31 위반 시 no-op (정책 불변).
  • 부분 업데이트(partial) 허용 — enabled만, 또는 날짜만 갱신 가능.
  • 공유 싱글톤 DEFAULT_LATE_FEE_POLICY는 절대 변이하지 않는다(clone 교체).

API 계약 (UI → 정책 레이어)

setAccrualPause({ enabled: true })                      // 활성화, 날짜는 현재값 유지
setAccrualPause({ enabled: false })                     // 비활성화
setAccrualPause({ startDay: 5, endDay: 15 })            // 날짜만 변경, enabled 현재값 유지
setAccrualPause({ enabled: true, startDay: 5, endDay: 15 })  // 전체 갱신

4. 소비 지점 — useLateFee.lateFeesFor

파일: src/composables/useLateFee.js

js
function lateFeesFor(axis, key, asOf) {
  // …installments 조립 생략…
  const evalDate = effectiveAsOf(asOf, policy.value.accrualPause);
  return computeLateFees(installments, evalDate, {
    ...policy.value,
    rateScheduleVersions: globalRateScheduleVersions.value,
  });
}

asOf(수납 기준일)를 effectiveAsOf로 동결한 뒤 computeLateFees에 전달. 엔진 코어(lateFeeOf, overdueDays, rateSegments, computeLateFees)는 변경 없음 — asOf만 동결되어 통과한다.


5. 범위 밖 (Out-of-Scope)

항목현황
면제(forgiveness)별도 경로(lateFeeWaiver). 발생 중지와 무관.
per-account 중지❌ 예정. 현재 단지 전체(단일 lateFeePolicy 싱글톤) 적용.
3계층 스코프(프리셋/글로벌/로컬)❌ 예정. accrualPause에는 스코프 분기 없음(충당순서·연체요율과 달리).
일회성 날짜 구간(특정일~특정일)❌ 예정. 현재 "매월 N일~M일" 반복 창만 지원.
effective-dated 버전화❌ 예정(여전). accrualPause는 버전 인프라 없이 전역 단일 창 — W4(아래)는 버전화 대신 posted/closed 존재 시 편집 자체 거부로 소급 재작성만 차단.
세금계산서 발행 가드발생 중지는 발행 여부에 무관. 세금계산서 가드는 인식기준(recognitionBasis) 레이어에서 관리.

6. 불변식 요약

  1. effectiveAsOf ≤ asOf — 항상 과거로만 동결.
  2. enabled=false 또는 pause == nulleffectiveAsOf === asOf — 기능 비활성 시 엔진 무영향.
  3. 창 이후(d > endDay) → effectiveAsOf === asOf — 유예 일수는 그대로 누적(catch-up).
  4. DEFAULT_LATE_FEE_POLICY는 절대 변이되지 않음 — 테스트 격리 보장.
  5. 엔진 코어(lateFeeOf, computeLateFees) 시그니처 보존 — evalDate만 필터링.

2026-06-26 — 4차 감사 수정 (Q2·Q3)

  • Q2 발생중지 무음 저장실패 (lateFeePolicy.setAccrualPause · SheetUpdateSurchargePause.confirmEdit): setAccrualPausestart>end(1~31 위반)를 무음 early-return하는데 confirmEdit이 반환을 안 보고 무조건 시트를 닫아 정책↔표시 불일치("저장됐다 믿는데 미적용"). 수정: setAccrualPause가 적용 boolean 반환(창은 항상 1≤start≤end≤31 유효해야 저장). confirmEdit이 성공 시에만 닫고 실패 시 편집 유지 + invalidRange 에러(alert alert-error-subtle). startEdit/cancelEdit이 리셋. 테스트: SurchargePause.spec.js(무효 유지·유효 닫힘).
  • Q3 rateSchedule reset 누수 (lateFeePolicy.resetRateSchedule): setRateSchedule가 모듈 싱글톤 globalRateScheduleVersions를 변이하나 reset 부재 → policy.value만 복원 시 엔진(거래일 버전)이 누수 요율로 계산. resetRateSchedule()(PRESET 복제 복원 + policy.rateSchedule 동기) 추가. useLateFeePolicy 반환에 노출. 테스트 afterEach 보강.

2026-06-26 — 5차 감사 수정 (R1) + R2~R4 보류

  • R1 연체료 구간 fee 정수 양자화 (interestEngine.lateFeeOf/quantizeBreakdown): 구간별 segFee를 raw float로 노출하고 총액만 절사해, 표시 경로(useDerivation·LateFeeTab)가 구간 개별 반올림 시 Σ구간≠총액(303≠302)·분수 원 렌더(불변식 #9). quantizeBreakdown(largest-remainder)으로 총액을 raw 비중대로 정수 배분해 Σ(정수구간)===fee 보장(cap 후 적용). 표시 소비처는 정수 그대로 사용. 배분 셀·청구상세 펼침Σ는 이미 정수 정합(R1-class는 엔진 국한). 테스트: interestEngine.spec.js.
  • R2~R4 보류(docs/AUDIT-CORE-BILLING-DEFECTS-5TH-2026-06-26.md): R2 선수금 환급 후 취소 비대칭(환급/역분개 기능과)·R3 음수 과세원금 VAT 게이트(음수보정/역분개와)·R4 전액할인 퇴화전표(전액할인 기능과). 전부 현 데이터 미도달·미배선이라 트리거 기능 구현 시 회귀가드로 동반. 픽스 스케치는 로드맵 문서 참조.

코어 거래단위 적대적 감사 4라운드 종결: 1차 15 + 3차 6(T1T6) + 4차 4(Q1Q4) + 5차 1(R1, R2~R4 보류). 5차 직교 렌즈로도 거래단위 불변식 위반 0 → 코어 견고 수렴.

2026-07-06 — 정책 게이트 W4-2 (FB-6) — posted/closed 게이트

Fable 1차 감사 FB-6 해소. 설계: docs/superpowers/specs/2026-07-06-policy-posting-gate-design.md. 상세 as-built(요율 effective-dating 포함): docs/handoff/backend/collecting-detail.md "정책 게이트 W4" T2.

setAccrualPause2번째 인자 lockedUntil(선택) 를 받도록 확장됐다:

js
function setAccrualPause(partial, lockedUntil) {
  const next = { ...policy.value.accrualPause, ...partial };
  if (next.startDay < 1 || next.endDay > 31 || next.startDay > next.endDay) return false;
  if (lockedUntil) return false; // ← W4-2: 전기완료/마감 존재 시 편집 자체 거부
  policy.value = { ...policy.value, accrualPause: next };
  return true;
}
  • accrualPause는 effective-dated 버전 인프라가 없다(전역 단일 창을 모든 asOf에 적용) — "미래분만 새 버전" 방식이 rateSchedule처럼 불가능하므로, lockedUntil truthy(전기완료 전표 또는 마감 회계기간 존재)면 부분 교체 대신 편집을 통째로 거부(보수적 게이트). 유효창 검증(1≤start≤end≤31)은 기존 규칙 그대로 우선 적용 — 무효 창은 lockedUntil과 무관하게 항상 거부.
  • lockedUntil은 leaf 유지 원칙(본 모듈은 useGeneralLedger/useClosing 미import) 때문에 소비처가 조회해 주입하는 boolean/truthy 신호다. 소비처(SheetUpdateSurchargePause.vue)가 useGeneralLedger().postedVouchers·useClosing().closedPeriods를 조회해 값이 있으면 truthy를 넘긴다.
  • 반환값 false는 두 사유(무효 창 / posted·closed 잠금) 중 하나 — 호출부가 사유별로 다른 메시지를 보이려면 별도 판정(현재는 UI가 lockedUntil을 자체 computed로 미리 계산해 알림 문구를 분기).
  • 회귀: 데모 초기(postedIds·closedPeriods 비어있음)에선 lockedUntil이 항상 falsy라 기존 동작과 동치.