diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index b3ccef0..746919e 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -26,6 +26,7 @@ const icons = { users: '', sliders: '', flag: '', + repay: '', } @@ -59,6 +60,11 @@ const icons = { 계좌 관리 + + + 대출·할부 상환 + 🔒 + 분류 관리 diff --git a/src/router/index.js b/src/router/index.js index a60bd75..efa1c1b 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -145,6 +145,12 @@ const router = createRouter({ component: () => import('../views/account/AccountWalletDetailView.vue'), meta: { requiresAuth: true }, }, + { + path: '/account/repayments', + name: 'account-repayments', + component: () => import('../views/account/RepaymentScheduleView.vue'), + meta: { requiresAuth: true, requiresPremium: true }, + }, { path: '/account/categories', name: 'account-categories', diff --git a/src/utils/repayment.js b/src/utils/repayment.js new file mode 100644 index 0000000..c0718f7 --- /dev/null +++ b/src/utils/repayment.js @@ -0,0 +1,121 @@ +// 대출·카드할부 남은 상환 스케줄 계산 (원금/이자/합계/회차별 남은 잔액) +// - 카드 할부: 무이자 가정(원금만, 이자 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 원리금균등: 매월 상환액 고정 + if (!nRem) { + // 총개월 정보 없음 → 이번 달 이자만 안내(원금 분리 불가) + const interest = Math.round(bal * r) + return { + rows: [{ label: label(y, m), principal: null, interest, total: null, balance: bal }], + partial: true, + } + } + const payment = r > 0 ? Math.round((bal * r) / (1 - Math.pow(1 + r, -nRem))) : Math.round(bal / nRem) + for (let i = 0; i < nRem && bal > 0 && rows.length < cap; i++) { + const interest = Math.round(bal * r) + let principal = payment - interest + if (i === nRem - 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 } +} diff --git a/src/views/UpgradeView.vue b/src/views/UpgradeView.vue index 6e2f9b4..0920186 100644 --- a/src/views/UpgradeView.vue +++ b/src/views/UpgradeView.vue @@ -72,6 +72,7 @@ const premiumFeatures = [ '영수증 OCR 인식', '카드 알림 자동 인식', '투자 · 자산 관리', + '대출 · 할부 상환표 (원금 · 이자 분리)', '태그 · 소분류 무제한', '데이터 백업 / 복구 (엑셀)', '광고 제거', diff --git a/src/views/account/RepaymentScheduleView.vue b/src/views/account/RepaymentScheduleView.vue new file mode 100644 index 0000000..cefcc40 --- /dev/null +++ b/src/views/account/RepaymentScheduleView.vue @@ -0,0 +1,379 @@ + + + + +