// web/src/pages/settings/PlansSettings.tsx import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState, type FormEvent, } from "react"; import { Money } from "../../components/ui"; import CurrencyInput from "../../components/CurrencyInput"; import { useDashboard } from "../../hooks/useDashboard"; import { useToast } from "../../components/Toast"; import { dateStringToUTCMidnight, formatDateInTimezone, getBrowserTimezone, getTodayInTimezone, isoToDateString, } from "../../utils/timezone"; import { fixedPlansApi } from "../../api/fixedPlans"; type FixedPlan = { id: string; name: string; amountMode?: "fixed" | "estimated"; estimatedCents?: number | null; actualCents?: number | null; actualRecordedAt?: string | null; actualCycleDueOn?: string | null; totalCents: number; fundedCents: number; priority: number; dueOn: string; cycleStart: string; frequency?: "one-time" | "weekly" | "biweekly" | "monthly"; autoPayEnabled?: boolean; paymentSchedule?: any; nextPaymentDate?: string | null; }; type LocalPlan = FixedPlan & { _isNew?: boolean; _isDeleted?: boolean }; function parseCurrencyToCents(value: string): number { const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, ""); const [whole, ...fractionParts] = cleaned.split("."); const fraction = fractionParts.join(""); const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; const parsed = Number.parseFloat(normalized || "0"); return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; } export type PlansSettingsHandle = { save: () => Promise; }; interface PlansSettingsProps { onDirtyChange?: (dirty: boolean) => void; } const PlansSettings = forwardRef( function PlansSettings({ onDirtyChange }, ref) { const { data, isLoading, error, refetch, isFetching } = useDashboard(); const plans = (data?.fixedPlans ?? []) as FixedPlan[]; const { push } = useToast(); // Get user timezone from dashboard data const userTimezone = data?.user?.timezone || getBrowserTimezone(); const incomeType = data?.user?.incomeType ?? "regular"; const incomeFrequency = data?.user?.incomeFrequency; const firstIncomeDate = data?.user?.firstIncomeDate ?? null; // Local editable state (preview mode) const [localPlans, setLocalPlans] = useState([]); const [initialized, setInitialized] = useState(false); const [isSaving, setIsSaving] = useState(false); const [deletePrompt, setDeletePrompt] = useState(null); const [actualInputs, setActualInputs] = useState>({}); const [applyingActualByPlan, setApplyingActualByPlan] = useState>({}); const [trueUpMessages, setTrueUpMessages] = useState>({}); useEffect(() => { if (!initialized && plans.length > 0) { setLocalPlans(plans.map((p) => ({ ...p }))); setInitialized(true); } }, [plans, initialized]); const resetToServer = useCallback( (nextPlans: FixedPlan[] = plans) => { setLocalPlans(nextPlans.map((p) => ({ ...p }))); setInitialized(true); }, [plans] ); const activePlans = useMemo( () => localPlans.filter((p) => !p._isDeleted), [localPlans] ); const hasChanges = useMemo(() => { if (localPlans.length === 0) return false; if (localPlans.some((p) => p._isNew || p._isDeleted)) return true; for (const local of localPlans) { if (local._isNew || local._isDeleted) continue; const server = plans.find((p) => p.id === local.id); if (!server) return true; const scheduleEqual = JSON.stringify(local.paymentSchedule ?? null) === JSON.stringify(server.paymentSchedule ?? null); if ( local.name !== server.name || local.totalCents !== server.totalCents || (local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed") || (local.estimatedCents ?? null) !== (server.estimatedCents ?? null) || local.priority !== server.priority || local.dueOn !== server.dueOn || (local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false) || (local.frequency ?? null) !== (server.frequency ?? null) || !scheduleEqual || (local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null) ) { return true; } } for (const server of plans) { if (!localPlans.find((p) => p.id === server.id)) return true; } return false; }, [localPlans, plans]); useEffect(() => { onDirtyChange?.(hasChanges); }, [hasChanges, onDirtyChange]); // Form state for adding new plan const [name, setName] = useState(""); const [totalInput, setTotalInput] = useState(""); const [priority, setPriority] = useState(""); const [due, setDue] = useState(getTodayInTimezone(userTimezone)); const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly"); const [autoPayEnabled, setAutoPayEnabled] = useState(false); const [amountMode, setAmountMode] = useState<"fixed" | "estimated">("fixed"); const totalCents = Math.max(0, parseCurrencyToCents(totalInput)); const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0)); const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving; function mapScheduleFrequency( nextFrequency?: FixedPlan["frequency"] ): "daily" | "weekly" | "biweekly" | "monthly" | "custom" { if (nextFrequency === "weekly") return "weekly"; if (nextFrequency === "biweekly") return "biweekly"; return "monthly"; } function buildDefaultSchedule(nextFrequency?: FixedPlan["frequency"]) { return { frequency: mapScheduleFrequency(nextFrequency), minFundingPercent: 100, }; } const onAdd = (e: FormEvent) => { e.preventDefault(); if (!name.trim()) return; const dueOnISO = dateStringToUTCMidnight(due, userTimezone); const schedule = autoPayEnabled ? buildDefaultSchedule(frequency || undefined) : null; const nextPaymentDate = autoPayEnabled && schedule ? calculateNextPaymentDate(dueOnISO, schedule, userTimezone) : null; const tempId = `temp_${Date.now()}`; const newPlan: LocalPlan = { id: tempId, name: name.trim(), amountMode, estimatedCents: amountMode === "estimated" ? totalCents : null, totalCents, fundedCents: 0, priority: parsedPriority || localPlans.length + 1, dueOn: dueOnISO, cycleStart: dueOnISO, frequency: frequency || undefined, autoPayEnabled, paymentSchedule: schedule, nextPaymentDate, _isNew: true, }; setLocalPlans((prev) => [...prev, newPlan]); setName(""); setTotalInput(""); setPriority(""); setDue(getTodayInTimezone(userTimezone)); setFrequency("monthly"); setAutoPayEnabled(false); setAmountMode("fixed"); }; function toUserMidnight(iso: string, timezone: string) { const dateStr = isoToDateString(iso, timezone); return new Date(dateStringToUTCMidnight(dateStr, timezone)); } function countPayPeriodsBetween( startIso: string, endIso: string, firstIncomeIso: string, frequency: NonNullable, timezone: string ) { let count = 0; let nextPayDate = toUserMidnight(firstIncomeIso, timezone); const normalizedStart = toUserMidnight(startIso, timezone); const normalizedEnd = toUserMidnight(endIso, timezone); const targetDay = Number(isoToDateString(firstIncomeIso, timezone).split("-")[2] || "1"); const advanceByPeriod = () => { if (frequency === "monthly") { const year = nextPayDate.getUTCFullYear(); const month = nextPayDate.getUTCMonth() + 1; const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth))); } else { const days = frequency === "biweekly" ? 14 : 7; nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000); } }; while (nextPayDate < normalizedStart) { advanceByPeriod(); } while (nextPayDate < normalizedEnd) { count++; advanceByPeriod(); } return Math.max(1, count); } function getFundingAhead(plan: FixedPlan) { if ( incomeType !== "regular" || !incomeFrequency || !firstIncomeDate || !plan.cycleStart || !plan.dueOn ) { return null; } const now = new Date().toISOString(); let cycleStart = plan.cycleStart; const dueOn = plan.dueOn; let cycleStartDate: Date; let dueDate: Date; let nowDate: Date; try { cycleStartDate = toUserMidnight(cycleStart, userTimezone); dueDate = toUserMidnight(dueOn, userTimezone); nowDate = toUserMidnight(now, userTimezone); } catch { return null; } if (cycleStartDate >= dueDate || cycleStartDate > nowDate) { cycleStart = now; } const totalPeriods = countPayPeriodsBetween( cycleStart, dueOn, firstIncomeDate, incomeFrequency, userTimezone ); const elapsedPeriods = countPayPeriodsBetween( cycleStart, now, firstIncomeDate, incomeFrequency, userTimezone ); const targetFunded = Math.min( plan.totalCents, Math.ceil((plan.totalCents * elapsedPeriods) / totalPeriods) ); const aheadBy = Math.max(0, plan.fundedCents - targetFunded); return aheadBy > 0 ? aheadBy : null; } function calculateNextPaymentDate( dueOnISO: string, schedule: any, timezone: string ): string | null { if (!schedule || !schedule.frequency) return null; const dateStr = isoToDateString(dueOnISO, timezone); const [year, month, day] = dateStr.split("-").map(Number); const base = new Date(Date.UTC(year, month - 1, day)); const toDateString = (d: Date) => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String( d.getUTCDate() ).padStart(2, "0")}`; switch (schedule.frequency) { case "daily": base.setUTCDate(base.getUTCDate() + 1); break; case "weekly": { const targetDay = schedule.dayOfWeek ?? 0; const currentDay = base.getUTCDay(); const daysUntilTarget = (targetDay - currentDay + 7) % 7; base.setUTCDate(base.getUTCDate() + (daysUntilTarget || 7)); break; } case "biweekly": base.setUTCDate(base.getUTCDate() + 14); break; case "monthly": { const targetDay = schedule.dayOfMonth; const nextMonth = base.getUTCMonth() + 1; const nextYear = base.getUTCFullYear() + Math.floor(nextMonth / 12); const normalizedMonth = nextMonth % 12; const daysInMonth = new Date(Date.UTC(nextYear, normalizedMonth + 1, 0)).getUTCDate(); base.setUTCFullYear(nextYear, normalizedMonth, targetDay ? Math.min(targetDay, daysInMonth) : base.getUTCDate()); break; } case "custom": base.setUTCDate(base.getUTCDate() + Math.max(1, Number(schedule.everyNDays || 0))); break; default: return null; } return dateStringToUTCMidnight(toDateString(base), timezone); } const onEdit = (id: string, patch: Partial) => { setLocalPlans((prev) => prev.map((p) => { if (p.id !== id) return p; const next: LocalPlan = { ...p, ...patch }; if (patch.frequency !== undefined && next.autoPayEnabled) { const schedule = next.paymentSchedule ?? buildDefaultSchedule(patch.frequency); next.paymentSchedule = { ...schedule, frequency: mapScheduleFrequency(patch.frequency), }; } if (patch.totalCents !== undefined) { next.totalCents = Math.max(0, Math.round(patch.totalCents)); } if (patch.priority !== undefined) { next.priority = Math.max(0, Math.floor(patch.priority)); } if (patch.amountMode !== undefined) { const mode = patch.amountMode; next.amountMode = mode; if (mode === "fixed") { next.estimatedCents = null; } else { next.estimatedCents = next.estimatedCents ?? next.totalCents; } } if (patch.estimatedCents !== undefined) { const nextEstimate = Math.max(0, Math.round(patch.estimatedCents ?? 0)); next.estimatedCents = nextEstimate; const hasActualForCycle = !!next.actualCycleDueOn && next.actualCycleDueOn === next.dueOn; if ((next.amountMode ?? "fixed") === "estimated" && !hasActualForCycle) { next.totalCents = nextEstimate; } } if (patch.autoPayEnabled !== undefined && !patch.autoPayEnabled) { next.nextPaymentDate = null; } if (next.autoPayEnabled && next.paymentSchedule) { const dueOnISO = patch.dueOn ?? next.dueOn; next.nextPaymentDate = calculateNextPaymentDate( dueOnISO, next.paymentSchedule, userTimezone ); } return next; }) ); }; const onDelete = (id: string) => { setLocalPlans((prev) => prev .map((p) => { if (p.id !== id) return p; if (p._isNew) return { ...p, _isDeleted: true }; return { ...p, _isDeleted: true }; }) .filter((p) => !(p._isNew && p._isDeleted)) ); }; const onCancel = () => { resetToServer(); push("ok", "Changes discarded"); }; const onSave = useCallback(async (): Promise => { const normalizedPriorityOrder = activePlans .slice() .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) .map((plan, index) => ({ id: plan.id, priority: index + 1 })); const priorityById = new Map( normalizedPriorityOrder.map((item) => [item.id, item.priority]) ); const normalizedPlans = localPlans.map((plan) => plan._isDeleted ? plan : { ...plan, priority: priorityById.get(plan.id) ?? plan.priority } ); for (const plan of localPlans) { if (plan._isDeleted) continue; if (plan.totalCents < (plan.fundedCents ?? 0)) { push( "err", `Total for ${plan.name} cannot be less than funded amount.` ); return false; } } setIsSaving(true); try { const toDelete = normalizedPlans.filter((p) => p._isDeleted && !p._isNew); for (const plan of toDelete) { await fixedPlansApi.delete(plan.id); } const toCreate = normalizedPlans.filter((p) => p._isNew && !p._isDeleted); for (const plan of toCreate) { const created = await fixedPlansApi.create({ name: plan.name, totalCents: plan.totalCents, fundedCents: plan.fundedCents ?? 0, amountMode: plan.amountMode ?? "fixed", estimatedCents: (plan.amountMode ?? "fixed") === "estimated" ? (plan.estimatedCents ?? plan.totalCents) : null, priority: plan.priority, dueOn: plan.dueOn, frequency: plan.frequency, autoPayEnabled: plan.autoPayEnabled ?? false, paymentSchedule: plan.paymentSchedule ?? undefined, nextPaymentDate: plan.nextPaymentDate ?? undefined, }); if (plan.autoPayEnabled) { try { const res = await fixedPlansApi.fundFromAvailable(created.id); if (res.funded) { const dollars = (res.fundedAmountCents / 100).toFixed(2); push("ok", `Funded $${dollars} toward ${plan.name}.`); } else { push("err", `Not enough budget to fund ${plan.name}.`); } } catch (err: any) { push( "err", err?.message ?? `Funding ${plan.name} failed` ); } } } const toUpdate = normalizedPlans.filter((p) => !p._isNew && !p._isDeleted); for (const local of toUpdate) { const server = plans.find((p) => p.id === local.id); if (!server) continue; const patch: Partial = {}; if (local.name !== server.name) patch.name = local.name; if (local.totalCents !== server.totalCents) patch.totalCents = local.totalCents; if (local.priority !== server.priority) patch.priority = local.priority; if (local.dueOn !== server.dueOn) patch.dueOn = local.dueOn; if ((local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false)) patch.autoPayEnabled = local.autoPayEnabled; if ( JSON.stringify(local.paymentSchedule ?? null) !== JSON.stringify(server.paymentSchedule ?? null) ) patch.paymentSchedule = local.paymentSchedule; if ((local.frequency ?? null) !== (server.frequency ?? null)) patch.frequency = local.frequency; if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null)) patch.nextPaymentDate = local.nextPaymentDate ?? null; if ((local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed")) patch.amountMode = local.amountMode ?? "fixed"; if ((local.estimatedCents ?? null) !== (server.estimatedCents ?? null)) patch.estimatedCents = local.estimatedCents ?? 0; if (Object.keys(patch).length > 0) { await fixedPlansApi.update(local.id, patch); } const paymentPlanEnabled = !!local.autoPayEnabled && local.paymentSchedule !== null && local.paymentSchedule !== undefined; const amountChanged = local.totalCents !== server.totalCents; const dueChanged = local.dueOn !== server.dueOn; if (paymentPlanEnabled && (amountChanged || dueChanged)) { try { const res = await fixedPlansApi.catchUpFunding(local.id); if (res.funded) { const dollars = (res.fundedAmountCents / 100).toFixed(2); push("ok", `Funded $${dollars} toward ${local.name}.`); } else if (res.message === "Insufficient available budget") { push("err", `Not enough budget to fund ${local.name}.`); } } catch (err: any) { push("err", err?.message ?? `Funding ${local.name} failed`); } } } push("ok", "Fixed expenses saved successfully"); const refreshed = await refetch(); const nextPlans = (refreshed.data?.fixedPlans ?? plans) as FixedPlan[]; resetToServer(nextPlans); return true; } catch (err: any) { push("err", err?.message ?? "Save failed"); return false; } finally { setIsSaving(false); } }, [localPlans, plans, refetch, resetToServer, push]); const onApplyActual = useCallback( async (plan: LocalPlan) => { if (plan._isNew) { push("err", "Save this plan before applying an actual amount."); return; } const raw = actualInputs[plan.id] ?? ""; const actualCents = Math.max(0, parseCurrencyToCents(raw)); setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: true })); setTrueUpMessages((prev) => ({ ...prev, [plan.id]: "" })); try { const res = await fixedPlansApi.trueUpActual(plan.id, { actualCents }); setLocalPlans((prev) => prev.map((p) => p.id !== plan.id ? p : { ...p, totalCents: res.totalCents, fundedCents: res.fundedCents, actualCents: res.actualCents, actualCycleDueOn: p.dueOn, actualRecordedAt: new Date().toISOString(), } ) ); const summary = res.deltaCents > 0 ? `Actual is higher. Pulled $${(res.autoPulledCents / 100).toFixed(2)} from available budget. Remaining shortfall $${(res.remainingShortfallCents / 100).toFixed(2)}.` : res.deltaCents < 0 ? `Actual is lower. Returned $${(res.refundedCents / 100).toFixed(2)} to available budget.` : "Actual matches estimate. No adjustment needed."; setTrueUpMessages((prev) => ({ ...prev, [plan.id]: summary })); push("ok", "Actual amount applied."); } catch (err: any) { push("err", err?.message ?? "Unable to apply actual amount."); } finally { setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: false })); } }, [actualInputs, push] ); useImperativeHandle(ref, () => ({ save: onSave }), [onSave]); if (isLoading) return (
Loading fixed expenses...
); if (error) { return (

Couldn't load fixed expenses.

); } return (

Fixed Expenses

{hasChanges && ( Unsaved changes )}

Bills and recurring expenses that get funded over time until due.

{/* Add form */}
setName(e.target.value)} /> {amountMode === "estimated" && (
Tip: For variable bills (utilities, usage-based charges), set a slightly higher estimate to avoid due-date shortfalls.
)} setDue(e.target.value)} /> {activePlans.length === 0 ? (
No fixed expenses yet.
) : (
{activePlans.map((plan) => { const aheadCents = getFundingAhead(plan); const fundedCents = plan.fundedCents ?? 0; const totalCents = plan.totalCents || 1; // Avoid division by zero const progressPercent = Math.min(100, (fundedCents / totalCents) * 100); return (
{/* Header row */}
onEdit(plan.id, { name: v })} />
onEdit(plan.id, { totalCents: cents })} />
{/* Details row */}
Bill Type
Due onEdit(plan.id, { dueOn: iso })} />
Freq onEdit(plan.id, { frequency: (v || undefined) as FixedPlan["frequency"], }) } />
{(plan.amountMode ?? "fixed") === "estimated" && (
Estimate onEdit(plan.id, { estimatedCents: cents })} />
)} {aheadCents !== null && (
+{new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0, }).format((aheadCents ?? 0) / 100)} ahead
)}
{/* Progress bar */}
/
{(plan.amountMode ?? "fixed") === "estimated" && (
Keep this estimate slightly high for variable bills, then true-up with the actual amount when posted.
{trueUpMessages[plan.id] ? (
{trueUpMessages[plan.id]}
) : null}
)} {/* Actions row */}
{plan.autoPayEnabled && (
{incomeType === "regular" ? "Auto-funded each paycheck until fully funded" : "Prioritized in budget allocation" }
)}
)})}
)} {deletePrompt && (

Delete fixed expense?

Are you sure you want to delete {deletePrompt.name}? This action cannot be undone.

{!deletePrompt._isNew && (deletePrompt.fundedCents ?? 0) > 0 && (
Funded amount{" "} {" "} will be refunded to your available budget.
)}
)} {hasChanges && (
)}
); } ); export default PlansSettings; // Inline editor components function InlineEditText({ value, onChange, placeholder, }: { value: string; onChange: (v: string) => void; placeholder?: string; }) { const [v, setV] = useState(value); const [editing, setEditing] = useState(false); useEffect(() => setV(value), [value]); const commit = () => { if (v !== value) onChange(v.trim()); setEditing(false); }; return editing ? ( setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} placeholder={placeholder} autoFocus /> ) : ( ); } function InlineEditMoney({ cents, onChange, placeholder, }: { cents: number; onChange: (cents: number) => void; placeholder?: string; }) { const [editing, setEditing] = useState(false); const [v, setV] = useState((cents / 100).toFixed(2)); useEffect(() => setV((cents / 100).toFixed(2)), [cents]); const commit = () => { const newCents = Math.max(0, parseCurrencyToCents(v)); if (newCents !== cents) onChange(newCents); setEditing(false); }; return editing ? ( setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} placeholder={placeholder} autoFocus /> ) : ( ); } function InlineEditSelect({ value, onChange, }: { value: string; onChange: (value: string) => void; }) { return ( ); } function InlineEditDate({ value, timezone, onChange, }: { value: string; timezone: string; onChange: (iso: string) => void; }) { const [editing, setEditing] = useState(false); const [v, setV] = useState(isoToDateString(value, timezone)); useEffect(() => setV(isoToDateString(value, timezone)), [value, timezone]); const commit = () => { if (v) { const nextISO = dateStringToUTCMidnight(v, timezone); if (nextISO !== value) onChange(nextISO); } setEditing(false); }; return editing ? ( setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus /> ) : ( ); }