// 대출·카드할부 남은 상환 스케줄 계산 (원금/이자/합계/회차별 남은 잔액) // - 카드 할부: 무이자 가정(원금만, 이자 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 원리금균등 // (0) 월 상환금(고정)이 입력돼 있으면 그대로 사용 → 이자=잔액×이율, 원금=상환금-이자 로 은행과 정확히 일치 const fixed = Number(w.loanPayment) || 0 if (fixed > 0) { while (bal > 0 && rows.length < cap) { const interest = Math.round(bal * r) if (fixed <= interest) { // 상환금이 이자에 못 미침(데이터 이상) → 더 진행 못함 rows.push({ label: label(y, m), principal: 0, interest, total: fixed, balance: bal }) break } // 마지막 회차: 남은 잔액 한 번에 정산(은행은 잔여 반올림을 마지막 납입에 합산 → 추가 회차 안 생김) if (bal <= fixed) { rows.push({ label: label(y, m), principal: bal, interest, total: bal + interest, balance: 0 }) break } const principal = fixed - interest bal = 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 } } // (1) 남은 개월수 n 산출: 최초 상환액으로 역산(loanStart 없어도 정확) → 안되면 loanStart 경과 let n = null if (w.loanAmount && w.loanMonths && r > 0) { const origPay = (Number(w.loanAmount) * r) / (1 - Math.pow(1 + r, -Number(w.loanMonths))) // 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 if (n == null) { // 개월·원금 정보 부족 → 이번 달 이자만 안내(원금 분리 불가) const interest = Math.round(bal * r) return { rows: [{ label: label(y, m), principal: null, interest, total: null, balance: bal }], partial: true, } } // (2) 스케줄 상환액은 '현재 잔액'을 n개월에 상각 → 원금 항상 양수, 잔액이 0으로 수렴 // (최초원금 기준을 쓰면 잔액>원금 등 데이터 불일치 시 원금이 음수가 되어 잔액이 증가함) const 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 = Math.max(0, 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 } }