/** * Runway / Cash 누적 저장소. * * 4인 기업 운영의 가장 중요한 숫자 — 현금 잔고 / 월 소진율 / 남은 개월수 — 를 * 한 명령 (`/runway`) 로 본다. 회계 시스템은 아니고, 대표가 머리에 가지고 있는 * "지금 통장에 얼마, 한 달에 얼마 나감" 을 코드 옆에서 잡는 가벼운 트래커. * * 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 외부 회계 SaaS 연동 안 함. * 위치: `/.astra/runway.jsonl`. 사람이 직접 편집 가능, grep / 백업 친화. * * 민감 정보(현금 잔고) 포함되므로 외부로 안 보냄 — 로컬 only. */ import { createEventStore } from '../_shared/eventSourcedStore'; const STORE_REL_PATH = '.astra/runway.jsonl'; export type RunwayEntryType = 'snapshot' | 'expense' | 'revenue' | 'burn'; export interface RunwayEntry { /** unique id — timestamp 기반. */ id: string; /** ISO timestamp. */ timestamp: string; /** 항목 종류 — snapshot(잔고) / expense(지출) / revenue(수입) / burn(월 소진율 수동 설정). */ type: RunwayEntryType; /** 금액 — KRW 기본 단위, 소수점 허용. */ amount: number; /** 통화 — 기본 'KRW'. 추후 'USD' 등 확장 가능. */ currency?: string; /** 카테고리 — expense 의 경우 'salary' / 'rent' / 'saas' / 'misc' 등. */ category?: string; /** 자유 메모. */ memo?: string; } const _store = createEventStore({ relPath: STORE_REL_PATH, validate: (e): e is RunwayEntry => !!e && typeof (e as any).id === 'string' && typeof (e as any).amount === 'number' && typeof (e as any).type === 'string', }); export const getRunwayFilePath = _store.getFilePath; export const readRunway = _store.read; export const appendRunway = _store.append; /** * 현재 상태 계산 — 최신 snapshot, 최근 30일 net burn, 명시적 burn 설정 중 우선순위. * * - latestCash: 가장 최근 'snapshot' entry 의 amount (없으면 null). * - explicitBurn: 가장 최근 'burn' entry — 사용자가 수동 설정한 월 소진율. * - computedBurn: 최근 30일 expense - revenue, 30일 미만이면 일 평균 × 30 으로 보정. * - effectiveBurn: explicitBurn 우선, 없으면 computedBurn. * - runwayMonths: latestCash / effectiveBurn — burn 이 0 이하면 Infinity. */ export interface RunwayStatus { latestCash: number | null; latestCashAt: string | null; explicitBurn: number | null; computedBurn: number | null; effectiveBurn: number | null; runwayMonths: number | null; last30Expense: number; last30Revenue: number; last30Days: number; totalEntries: number; } export function computeRunwayStatus(now: Date = new Date()): RunwayStatus { const entries = readRunway(); const nowMs = now.getTime(); const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; let latestCash: number | null = null; let latestCashAt: string | null = null; let explicitBurn: number | null = null; let last30Expense = 0; let last30Revenue = 0; let oldestRecentMs = nowMs; let hasRecent = false; for (const e of entries) { const t = Date.parse(e.timestamp); if (e.type === 'snapshot') { if (!latestCashAt || (Date.parse(e.timestamp) >= Date.parse(latestCashAt))) { latestCash = e.amount; latestCashAt = e.timestamp; } } else if (e.type === 'burn') { if (!explicitBurn || t >= (entries.find(x => x.type === 'burn' && x.amount === explicitBurn)?.timestamp ? Date.parse(e.timestamp) : 0)) { explicitBurn = e.amount; } } else if (e.type === 'expense' && nowMs - t <= thirtyDaysMs) { last30Expense += e.amount; if (t < oldestRecentMs) oldestRecentMs = t; hasRecent = true; } else if (e.type === 'revenue' && nowMs - t <= thirtyDaysMs) { last30Revenue += e.amount; if (t < oldestRecentMs) oldestRecentMs = t; hasRecent = true; } } // 최신 burn 정확히 다시 — 위 로직이 꼬여서 단순화. explicitBurn = null; let burnAt = 0; for (const e of entries) { if (e.type !== 'burn') continue; const t = Date.parse(e.timestamp); if (t >= burnAt) { explicitBurn = e.amount; burnAt = t; } } const netBurn30 = last30Expense - last30Revenue; let computedBurn: number | null = null; let last30Days = 0; if (hasRecent) { const span = Math.max(1, Math.ceil((nowMs - oldestRecentMs) / (24 * 60 * 60 * 1000))); last30Days = Math.min(30, span); // 30일 미만이면 일 평균 × 30 으로 환산. if (last30Days < 30) computedBurn = (netBurn30 / last30Days) * 30; else computedBurn = netBurn30; } const effectiveBurn = explicitBurn ?? computedBurn; let runwayMonths: number | null = null; if (latestCash !== null && effectiveBurn !== null && effectiveBurn > 0) { runwayMonths = latestCash / effectiveBurn; } else if (latestCash !== null && effectiveBurn !== null && effectiveBurn <= 0) { runwayMonths = Infinity; } return { latestCash, latestCashAt, explicitBurn, computedBurn, effectiveBurn, runwayMonths, last30Expense, last30Revenue, last30Days, totalEntries: entries.length, }; }