Files
sb-front/src/utils/repayment.js
T
sb a42715834b
Deploy / deploy (push) Failing after 12m41s
fix(repayment): 원리금균등 남은 회차를 잔액으로 역산 + 라벨 정리
- 기존 대출(loanStart 없음)이 남은 회차=전체(60회)로 나오던 문제 수정:
  현재 잔액과 최초 상환액으로 남은 개월수 역산(날짜 정보 불필요)
- '이번 달 상환' → '이번 달 상환 예정금액', 금액 앞 '이번 달' 제거
- '남은 N회' → '남은 회차 N회'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:43:49 +09:00

135 lines
5.4 KiB
JavaScript

// 대출·카드할부 남은 상환 스케줄 계산 (원금/이자/합계/회차별 남은 잔액)
// - 카드 할부: 무이자 가정(원금만, 이자 0)
// - 대출: 상환방식별 상각 (원리금균등/원금균등/만기일시), 현재 남은 원금(balance) 기준
function addMonths(y, m, delta) {
const d = new Date(y, m - 1 + delta, 1)
return { y: d.getFullYear(), m: d.getMonth() + 1 }
}
function label(y, m) {
return `${y}.${String(m).padStart(2, '0')}`
}
// (by,bm) - (ay,am) 개월 차
function monthsBetween(ay, am, by, bm) {
return (by - ay) * 12 + (bm - am)
}
export function currentYm() {
const d = new Date()
return { y: d.getFullYear(), m: d.getMonth() + 1 }
}
// 대출 남은 상환 스케줄. 반환: { rows:[{label,principal,interest,total,balance}], partial:boolean }
export function loanSchedule(w, now = currentYm(), cap = 600) {
const r = (Number(w.loanRate) || 0) / 100 / 12
let bal = Math.abs(Number(w.balance) || 0) // 현재 남은 원금
const method = w.loanMethod || 'EQUAL_PAYMENT'
if (bal <= 0) return { rows: [], partial: false }
// 경과·남은 개월수
let elapsed = 0
if (w.loanStart) {
const [sy, sm] = String(w.loanStart).slice(0, 7).split('-').map(Number)
if (sy && sm) elapsed = Math.max(0, monthsBetween(sy, sm, now.y, now.m))
}
const nRem = w.loanMonths ? Math.max(1, Number(w.loanMonths) - elapsed) : null
const rows = []
let { y, m } = now
if (method === 'EQUAL_PRINCIPAL') {
// 원금균등: 매월 원금 고정 = 최초원금 / 총개월 (없으면 잔액/남은개월)
const principalPer =
w.loanAmount && w.loanMonths
? Math.round(Number(w.loanAmount) / Number(w.loanMonths))
: nRem
? Math.round(bal / nRem)
: bal
while (bal > 0 && rows.length < cap) {
const interest = Math.round(bal * r)
const principal = Math.min(principalPer, bal)
bal = Math.max(0, bal - principal)
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
;({ y, m } = addMonths(y, m, 1))
}
return { rows, partial: false }
}
if (method === 'BULLET') {
// 만기일시: 매월 이자만, 마지막 달에 원금 전액
const n = nRem || 1
for (let i = 0; i < n && rows.length < cap; i++) {
const interest = Math.round(bal * r)
const isLast = i === n - 1
const principal = isLast ? bal : 0
const nb = isLast ? 0 : bal
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: nb })
bal = nb
;({ y, m } = addMonths(y, m, 1))
}
return { rows, partial: !nRem }
}
// EQUAL_PAYMENT 원리금균등: 매월 상환액 고정
// 남은 개월수는 '현재 잔액 + 최초 상환액'으로 역산 → loanStart 없어도 정확(기존 대출 대응)
let n = null
let payment = null
if (w.loanAmount && w.loanMonths && r > 0) {
const origPay = (Number(w.loanAmount) * r) / (1 - Math.pow(1 + r, -Number(w.loanMonths)))
payment = Math.round(origPay)
// bal = origPay·(1-(1+r)^-n)/r → n = -ln(1 - bal·r/origPay) / ln(1+r)
const ratio = 1 - (bal * r) / origPay
if (ratio > 0 && ratio < 1) {
n = Math.min(Number(w.loanMonths), Math.max(1, Math.ceil(-Math.log(ratio) / Math.log(1 + r) - 1e-9)))
}
}
if (n == null) n = nRem // 잔액 역산 불가 시 loanStart 경과 기반
if (n == null) {
// 개월·원금 정보 부족 → 이번 달 이자만 안내(원금 분리 불가)
const interest = Math.round(bal * r)
return {
rows: [{ label: label(y, m), principal: null, interest, total: null, balance: bal }],
partial: true,
}
}
if (payment == null) payment = r > 0 ? Math.round((bal * r) / (1 - Math.pow(1 + r, -n))) : Math.round(bal / n)
for (let i = 0; i < n && bal > 0 && rows.length < cap; i++) {
const interest = Math.round(bal * r)
let principal = payment - interest
if (i === n - 1 || principal > bal) principal = bal // 마지막 회차 잔액 정산
bal = Math.max(0, bal - principal)
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
;({ y, m } = addMonths(y, m, 1))
}
return { rows, partial: false }
}
// 카드 할부(무이자) 남은 스케줄. entry: {amount, installmentMonths, entryDate}
// 반환: { rows:[...], monthly } 또는 null(진행중 할부 아님)
export function cardInstallmentSchedule(entry, now = currentYm()) {
const N = Number(entry.installmentMonths) || 0
const amount = Math.abs(Number(entry.amount) || 0)
const date = entry.entryDate ? String(entry.entryDate).slice(0, 7) : ''
if (N < 2 || amount <= 0 || !date) return null
const [py, pm] = date.split('-').map(Number)
if (!py || !pm) return null
const monthly = Math.round(amount / N)
// 청구 회차: 구매 다음 달부터 N개월 (k = 1..N). 마지막 회차는 반올림 오차 정산.
const all = []
for (let k = 1; k <= N; k++) {
const { y, m } = addMonths(py, pm, k)
const principal = k === N ? amount - monthly * (N - 1) : monthly
all.push({ y, m, principal })
}
const remaining = all.filter((x) => monthsBetween(now.y, now.m, x.y, x.m) >= 0)
if (!remaining.length) return null
let bal = remaining.reduce((s, x) => s + x.principal, 0)
const rows = remaining.map((x) => {
bal -= x.principal
return { label: label(x.y, x.m), principal: x.principal, interest: 0, total: x.principal, balance: Math.max(0, bal) }
})
return { rows, monthly }
}