feat: initial commit - finanzas app

Complete personal finance management application with:
- Dashboard with financial metrics and alerts
- Credit card management and payments
- Fixed and variable debt tracking
- Monthly budget planning
- Intelligent alert system
- Responsive design with Tailwind CSS

Tech stack: Next.js 14, TypeScript, Zustand, Recharts

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
renato97
2026-01-29 00:00:32 +00:00
commit 712b06f118
65 changed files with 8556 additions and 0 deletions

359
lib/alerts.ts Normal file
View File

@@ -0,0 +1,359 @@
import {
FixedDebt,
VariableDebt,
CreditCard,
MonthlyBudget,
Alert,
} from './types'
import {
getDaysUntil,
getNextDateByDay,
formatCurrency,
calculateTotalFixedDebts,
calculateTotalVariableDebts,
} from './utils'
export interface GenerateAlertsParams {
fixedDebts: FixedDebt[]
variableDebts: VariableDebt[]
creditCards: CreditCard[]
monthlyBudgets: MonthlyBudget[]
currentMonth: number
currentYear: number
}
interface AlertDraft {
type: Alert['type']
title: string
message: string
severity: Alert['severity']
relatedId?: string
}
/**
* Obtiene las deudas fijas no pagadas que vencen en los próximos N días
*/
export function getUpcomingFixedDebts(
fixedDebts: FixedDebt[],
days: number
): Array<{ debt: FixedDebt; daysUntil: number; dueDate: Date }> {
const today = new Date()
const currentDay = today.getDate()
const currentMonth = today.getMonth()
const currentYear = today.getFullYear()
return fixedDebts
.filter((debt) => !debt.isPaid)
.map((debt) => {
// Calcular la fecha de vencimiento para este mes
let dueDate = new Date(currentYear, currentMonth, debt.dueDay)
// Si ya pasó, calcular para el mes siguiente
if (currentDay > debt.dueDay) {
dueDate = new Date(currentYear, currentMonth + 1, debt.dueDay)
}
const daysUntil = getDaysUntil(dueDate)
return { debt, daysUntil, dueDate }
})
.filter(({ daysUntil }) => daysUntil >= 0 && daysUntil <= days)
.sort((a, b) => a.daysUntil - b.daysUntil)
}
/**
* Obtiene el presupuesto del mes actual
*/
export function getCurrentMonthBudget(
monthlyBudgets: MonthlyBudget[],
month: number,
year: number
): MonthlyBudget | null {
return (
monthlyBudgets.find(
(budget) => budget.month === month && budget.year === year
) || null
)
}
/**
* Calcula el gasto actual del mes (deudas fijas + variables no pagadas)
*/
export function calculateCurrentSpending(
fixedDebts: FixedDebt[],
variableDebts: VariableDebt[]
): number {
const fixedSpending = calculateTotalFixedDebts(fixedDebts)
const variableSpending = calculateTotalVariableDebts(variableDebts)
return fixedSpending + variableSpending
}
interface CardEvent {
card: CreditCard
type: 'closing' | 'due'
daysUntil: number
date: Date
}
/**
* Obtiene los eventos próximos de tarjetas (cierre o vencimiento)
*/
export function getUpcomingCardEvents(
creditCards: CreditCard[],
days: number
): CardEvent[] {
const events: CardEvent[] = []
for (const card of creditCards) {
// Calcular próximo cierre
const closingDate = getNextDateByDay(card.closingDay)
const daysUntilClosing = getDaysUntil(closingDate)
if (daysUntilClosing >= 0 && daysUntilClosing <= days) {
events.push({
card,
type: 'closing',
daysUntil: daysUntilClosing,
date: closingDate,
})
}
// Calcular próximo vencimiento
const dueDate = getNextDateByDay(card.dueDay)
const daysUntilDue = getDaysUntil(dueDate)
if (daysUntilDue >= 0 && daysUntilDue <= days) {
events.push({
card,
type: 'due',
daysUntil: daysUntilDue,
date: dueDate,
})
}
}
// Ordenar por días hasta el evento
return events.sort((a, b) => a.daysUntil - b.daysUntil)
}
/**
* Genera alertas de pagos próximos (deudas fijas)
*/
function generatePaymentDueAlerts(fixedDebts: FixedDebt[]): AlertDraft[] {
const upcomingDebts = getUpcomingFixedDebts(fixedDebts, 3)
const alerts: AlertDraft[] = []
for (const { debt, daysUntil } of upcomingDebts) {
const severity: Alert['severity'] =
daysUntil <= 1 ? 'danger' : 'warning'
const daysText = daysUntil === 0 ? 'hoy' : daysUntil === 1 ? 'mañana' : `en ${daysUntil} días`
alerts.push({
type: 'PAYMENT_DUE',
title: 'Pago próximo',
message: `'${debt.name}' vence ${daysText}: ${formatCurrency(debt.amount)}`,
severity,
relatedId: debt.id,
})
}
return alerts
}
/**
* Genera alertas de presupuesto
*/
function generateBudgetAlerts(
fixedDebts: FixedDebt[],
variableDebts: VariableDebt[],
monthlyBudgets: MonthlyBudget[],
currentMonth: number,
currentYear: number
): AlertDraft[] {
const currentBudget = getCurrentMonthBudget(
monthlyBudgets,
currentMonth,
currentYear
)
if (!currentBudget) {
return []
}
const totalBudget =
currentBudget.fixedExpenses + currentBudget.variableExpenses
if (totalBudget <= 0) {
return []
}
const currentSpending = calculateCurrentSpending(fixedDebts, variableDebts)
const percentageUsed = (currentSpending / totalBudget) * 100
if (percentageUsed < 80) {
return []
}
const severity: Alert['severity'] =
percentageUsed > 95 ? 'danger' : 'warning'
return [
{
type: 'BUDGET_WARNING',
title: 'Presupuesto al límite',
message: `Has usado el ${percentageUsed.toFixed(1)}% de tu presupuesto mensual`,
severity,
},
]
}
/**
* Genera alertas de eventos de tarjetas (cierre y vencimiento)
*/
function generateCardAlerts(creditCards: CreditCard[]): AlertDraft[] {
const events = getUpcomingCardEvents(creditCards, 3)
const closingAlerts: AlertDraft[] = []
const dueAlerts: AlertDraft[] = []
for (const event of events) {
if (event.type === 'closing') {
const daysText =
event.daysUntil === 0
? 'hoy'
: event.daysUntil === 1
? 'mañana'
: `en ${event.daysUntil} días`
closingAlerts.push({
type: 'CARD_CLOSING',
title: 'Cierre de tarjeta próximo',
message: `Tu tarjeta ${event.card.name} cierra ${daysText}. Balance: ${formatCurrency(event.card.currentBalance)}`,
severity: 'info',
relatedId: event.card.id,
})
} else {
const severity: Alert['severity'] =
event.daysUntil <= 2 ? 'warning' : 'info'
const daysText =
event.daysUntil === 0
? 'hoy'
: event.daysUntil === 1
? 'mañana'
: `en ${event.daysUntil} días`
dueAlerts.push({
type: 'CARD_DUE',
title: 'Vencimiento de tarjeta',
message: `Vencimiento de ${event.card.name} ${daysText}. Balance: ${formatCurrency(event.card.currentBalance)}`,
severity,
relatedId: event.card.id,
})
}
}
return [...closingAlerts, ...dueAlerts]
}
/**
* Genera alertas de meta de ahorro
*/
function generateSavingsAlerts(
fixedDebts: FixedDebt[],
variableDebts: VariableDebt[],
monthlyBudgets: MonthlyBudget[],
currentMonth: number,
currentYear: number
): AlertDraft[] {
const currentBudget = getCurrentMonthBudget(
monthlyBudgets,
currentMonth,
currentYear
)
if (!currentBudget || currentBudget.savingsGoal <= 0) {
return []
}
const currentSpending = calculateCurrentSpending(fixedDebts, variableDebts)
const projectedSavings = currentBudget.totalIncome - currentSpending
if (projectedSavings >= currentBudget.savingsGoal) {
return []
}
const percentageBelow =
((currentBudget.savingsGoal - projectedSavings) / currentBudget.savingsGoal) *
100
return [
{
type: 'SAVINGS_GOAL',
title: 'Meta de ahorro',
message: `Vas ${percentageBelow.toFixed(0)}% por debajo de tu meta de ahorro mensual`,
severity: 'info',
},
]
}
/**
* Elimina alertas duplicadas basándose en tipo y relatedId
*/
function deduplicateAlerts(alerts: AlertDraft[]): AlertDraft[] {
const seen = new Set<string>()
return alerts.filter((alert) => {
const key = `${alert.type}-${alert.relatedId || 'global'}`
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
/**
* Genera todas las alertas inteligentes basadas en el estado actual
*/
export function generateAlerts(params: GenerateAlertsParams): AlertDraft[] {
const {
fixedDebts,
variableDebts,
creditCards,
monthlyBudgets,
currentMonth,
currentYear,
} = params
const allAlerts: AlertDraft[] = [
...generatePaymentDueAlerts(fixedDebts),
...generateBudgetAlerts(
fixedDebts,
variableDebts,
monthlyBudgets,
currentMonth,
currentYear
),
...generateCardAlerts(creditCards),
...generateSavingsAlerts(
fixedDebts,
variableDebts,
monthlyBudgets,
currentMonth,
currentYear
),
]
// Eliminar duplicados y ordenar por severidad (danger > warning > info)
const uniqueAlerts = deduplicateAlerts(allAlerts)
const severityOrder = { danger: 0, warning: 1, info: 2 }
return uniqueAlerts.sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity]
)
}

27
lib/store.ts Normal file
View File

@@ -0,0 +1,27 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { AppState } from '@/lib/types'
import { createDebtsSlice, DebtsSlice } from './store/slices/debtsSlice'
import { createCardsSlice, CardsSlice } from './store/slices/cardsSlice'
import { createBudgetSlice, BudgetSlice } from './store/slices/budgetSlice'
import { createAlertsSlice, AlertsSlice } from './store/slices/alertsSlice'
// Combined State Interface
// Note: We extend the individual slices to create the full store interface
export interface FinanzasState extends DebtsSlice, CardsSlice, BudgetSlice, AlertsSlice { }
export const useFinanzasStore = create<FinanzasState>()(
persist(
(...a) => ({
...createDebtsSlice(...a),
...createCardsSlice(...a),
...createBudgetSlice(...a),
...createAlertsSlice(...a),
}),
{
name: 'finanzas-storage',
// Optional: Filter what gets persisted if needed in the future
// partialize: (state) => ({ ... })
}
)
)

View File

@@ -0,0 +1,45 @@
import { StateCreator } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import { Alert } from '@/lib/types'
export interface AlertsSlice {
alerts: Alert[]
addAlert: (alert: Omit<Alert, 'id' | 'date'>) => void
markAlertAsRead: (id: string) => void
deleteAlert: (id: string) => void
clearAllAlerts: () => void
}
export const createAlertsSlice: StateCreator<AlertsSlice> = (set) => ({
alerts: [],
addAlert: (alert) =>
set((state) => ({
alerts: [
...state.alerts,
{
...alert,
id: uuidv4(),
date: new Date().toISOString(),
},
],
})),
markAlertAsRead: (id) =>
set((state) => ({
alerts: state.alerts.map((a) =>
a.id === id ? { ...a, isRead: true } : a
),
})),
deleteAlert: (id) =>
set((state) => ({
alerts: state.alerts.filter((a) => a.id !== id),
})),
clearAllAlerts: () =>
set(() => ({
alerts: [],
})),
})

View File

@@ -0,0 +1,39 @@
import { StateCreator } from 'zustand'
import { MonthlyBudget } from '@/lib/types'
const now = new Date()
export interface BudgetSlice {
monthlyBudgets: MonthlyBudget[]
currentMonth: number
currentYear: number
setMonthlyBudget: (budget: MonthlyBudget) => void
updateMonthlyBudget: (month: number, year: number, updates: Partial<MonthlyBudget>) => void
}
export const createBudgetSlice: StateCreator<BudgetSlice> = (set) => ({
monthlyBudgets: [],
currentMonth: now.getMonth() + 1,
currentYear: now.getFullYear(),
setMonthlyBudget: (budget) =>
set((state) => {
const existingIndex = state.monthlyBudgets.findIndex(
(b) => b.month === budget.month && b.year === budget.year
)
if (existingIndex >= 0) {
const newBudgets = [...state.monthlyBudgets]
newBudgets[existingIndex] = budget
return { monthlyBudgets: newBudgets }
}
return { monthlyBudgets: [...state.monthlyBudgets, budget] }
}),
updateMonthlyBudget: (month, year, updates) =>
set((state) => ({
monthlyBudgets: state.monthlyBudgets.map((b) =>
b.month === month && b.year === year ? { ...b, ...updates } : b
),
})),
})

View File

@@ -0,0 +1,47 @@
import { StateCreator } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import { CreditCard, CardPayment } from '@/lib/types'
export interface CardsSlice {
creditCards: CreditCard[]
cardPayments: CardPayment[]
addCreditCard: (card: Omit<CreditCard, 'id'>) => void
updateCreditCard: (id: string, card: Partial<CreditCard>) => void
deleteCreditCard: (id: string) => void
addCardPayment: (payment: Omit<CardPayment, 'id'>) => void
deleteCardPayment: (id: string) => void
}
export const createCardsSlice: StateCreator<CardsSlice> = (set) => ({
creditCards: [],
cardPayments: [],
addCreditCard: (card) =>
set((state) => ({
creditCards: [...state.creditCards, { ...card, id: uuidv4() }],
})),
updateCreditCard: (id, card) =>
set((state) => ({
creditCards: state.creditCards.map((c) =>
c.id === id ? { ...c, ...card } : c
),
})),
deleteCreditCard: (id) =>
set((state) => ({
creditCards: state.creditCards.filter((c) => c.id !== id),
})),
addCardPayment: (payment) =>
set((state) => ({
cardPayments: [...state.cardPayments, { ...payment, id: uuidv4() }],
})),
deleteCardPayment: (id) =>
set((state) => ({
cardPayments: state.cardPayments.filter((p) => p.id !== id),
})),
})

View File

@@ -0,0 +1,73 @@
import { StateCreator } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import { FixedDebt, VariableDebt } from '@/lib/types'
export interface DebtsSlice {
fixedDebts: FixedDebt[]
variableDebts: VariableDebt[]
// Actions Fixed
addFixedDebt: (debt: Omit<FixedDebt, 'id'>) => void
updateFixedDebt: (id: string, debt: Partial<FixedDebt>) => void
deleteFixedDebt: (id: string) => void
toggleFixedDebtPaid: (id: string) => void
// Actions Variable
addVariableDebt: (debt: Omit<VariableDebt, 'id'>) => void
updateVariableDebt: (id: string, debt: Partial<VariableDebt>) => void
deleteVariableDebt: (id: string) => void
toggleVariableDebtPaid: (id: string) => void
}
export const createDebtsSlice: StateCreator<DebtsSlice> = (set) => ({
fixedDebts: [],
variableDebts: [],
addFixedDebt: (debt) =>
set((state) => ({
fixedDebts: [...state.fixedDebts, { ...debt, id: uuidv4() }],
})),
updateFixedDebt: (id, debt) =>
set((state) => ({
fixedDebts: state.fixedDebts.map((d) =>
d.id === id ? { ...d, ...debt } : d
),
})),
deleteFixedDebt: (id) =>
set((state) => ({
fixedDebts: state.fixedDebts.filter((d) => d.id !== id),
})),
toggleFixedDebtPaid: (id) =>
set((state) => ({
fixedDebts: state.fixedDebts.map((d) =>
d.id === id ? { ...d, isPaid: !d.isPaid } : d
),
})),
addVariableDebt: (debt) =>
set((state) => ({
variableDebts: [...state.variableDebts, { ...debt, id: uuidv4() }],
})),
updateVariableDebt: (id, debt) =>
set((state) => ({
variableDebts: state.variableDebts.map((d) =>
d.id === id ? { ...d, ...debt } : d
),
})),
deleteVariableDebt: (id) =>
set((state) => ({
variableDebts: state.variableDebts.filter((d) => d.id !== id),
})),
toggleVariableDebtPaid: (id) =>
set((state) => ({
variableDebts: state.variableDebts.map((d) =>
d.id === id ? { ...d, isPaid: !d.isPaid } : d
),
})),
})

74
lib/types.ts Normal file
View File

@@ -0,0 +1,74 @@
export interface FixedDebt {
id: string
name: string
amount: number
dueDay: number
category: 'housing' | 'services' | 'subscription' | 'other'
isAutoDebit: boolean
isPaid: boolean
notes?: string
}
export interface VariableDebt {
id: string
name: string
amount: number
date: string
category: 'shopping' | 'food' | 'entertainment' | 'health' | 'transport' | 'other'
isPaid: boolean
notes?: string
}
export interface CreditCard {
id: string
name: string
lastFourDigits: string
closingDay: number
dueDay: number
currentBalance: number
creditLimit: number
color: string
}
export interface CardPayment {
id: string
cardId: string
amount: number
date: string
description: string
installments?: {
current: number
total: number
}
}
export interface MonthlyBudget {
month: number
year: number
totalIncome: number
fixedExpenses: number
variableExpenses: number
savingsGoal: number
}
export interface Alert {
id: string
type: 'PAYMENT_DUE' | 'BUDGET_WARNING' | 'CARD_CLOSING' | 'CARD_DUE' | 'SAVINGS_GOAL' | 'UNUSUAL_SPENDING'
title: string
message: string
severity: 'info' | 'warning' | 'danger'
date: string
isRead: boolean
relatedId?: string
}
export interface AppState {
fixedDebts: FixedDebt[]
variableDebts: VariableDebt[]
creditCards: CreditCard[]
cardPayments: CardPayment[]
monthlyBudgets: MonthlyBudget[]
alerts: Alert[]
currentMonth: number
currentYear: number
}

189
lib/utils.ts Normal file
View File

@@ -0,0 +1,189 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { FixedDebt, VariableDebt, CardPayment } from './types'
/**
* Combina clases de Tailwind CSS usando clsx y tailwind-merge
* Permite combinar múltiples clases condicionalmente
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
/**
* Formatea un número como moneda (pesos argentinos/USD)
* Ejemplo: 1500.50 -> "$ 1.500,50"
*/
export function formatCurrency(amount: number): string {
const formatter = new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: 'ARS',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
return formatter.format(amount)
}
/**
* Formatea una fecha en formato legible en español
* Ejemplo: "28 de enero de 2026"
*/
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
const formatter = new Intl.DateTimeFormat('es-AR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
return formatter.format(d)
}
/**
* Formatea una fecha en formato corto
* Ejemplo: "28/01/2026"
*/
export function formatShortDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
const formatter = new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
return formatter.format(d)
}
/**
* Calcula los días hasta una fecha específica
* Retorna un número negativo si la fecha ya pasó
*/
export function getDaysUntil(date: string | Date): number {
const targetDate = typeof date === 'string' ? new Date(date) : date
const today = new Date()
// Reset hours to compare only dates
const target = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate())
const current = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const diffTime = target.getTime() - current.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays
}
/**
* Obtiene la próxima fecha para un día específico del mes
* Si el día ya pasó este mes, devuelve el del mes siguiente
*/
export function getNextDateByDay(dayOfMonth: number): Date {
const today = new Date()
const currentYear = today.getFullYear()
const currentMonth = today.getMonth()
const currentDay = today.getDate()
let targetYear = currentYear
let targetMonth = currentMonth
// Si el día ya pasó este mes, ir al siguiente mes
if (currentDay > dayOfMonth) {
targetMonth += 1
if (targetMonth > 11) {
targetMonth = 0
targetYear += 1
}
}
// Ajustar si el día no existe en el mes objetivo (ej: 31 de febrero)
const lastDayOfMonth = new Date(targetYear, targetMonth + 1, 0).getDate()
const targetDay = Math.min(dayOfMonth, lastDayOfMonth)
return new Date(targetYear, targetMonth, targetDay)
}
/**
* Obtiene el nombre del mes en español
* El mes debe ser 1-12 (enero = 1)
*/
export function getMonthName(month: number): string {
const monthNames = [
'enero',
'febrero',
'marzo',
'abril',
'mayo',
'junio',
'julio',
'agosto',
'septiembre',
'octubre',
'noviembre',
'diciembre',
]
if (month < 1 || month > 12) {
throw new Error('El mes debe estar entre 1 y 12')
}
return monthNames[month - 1]
}
/**
* Calcula el total de deudas fijas no pagadas
*/
export function calculateTotalFixedDebts(debts: FixedDebt[]): number {
return debts
.filter((debt) => !debt.isPaid)
.reduce((total, debt) => total + debt.amount, 0)
}
/**
* Calcula el total de deudas variables no pagadas
*/
export function calculateTotalVariableDebts(debts: VariableDebt[]): number {
return debts
.filter((debt) => !debt.isPaid)
.reduce((total, debt) => total + debt.amount, 0)
}
/**
* Calcula el total de pagos de tarjeta
* Opcionalmente filtrados por cardId
*/
export function calculateCardPayments(
payments: CardPayment[],
cardId?: string
): number {
const filteredPayments = cardId
? payments.filter((payment) => payment.cardId === cardId)
: payments
return filteredPayments.reduce((total, payment) => total + payment.amount, 0)
}
/**
* Calcula la próxima fecha de cierre de tarjeta
* Si el día de cierre ya pasó este mes, devuelve el del mes siguiente
*/
export function calculateNextClosingDate(closingDay: number): Date {
return getNextDateByDay(closingDay)
}
/**
* Calcula la próxima fecha de vencimiento de tarjeta
* Si el día de vencimiento ya pasó este mes, devuelve el del mes siguiente
*/
export function calculateNextDueDate(dueDay: number): Date {
return getNextDateByDay(dueDay)
}
/**
* Calcula el porcentaje de utilización de una tarjeta de crédito
* Retorna un valor entre 0 y 100
*/
export function getCardUtilization(balance: number, limit: number): number {
if (limit <= 0) {
return 0
}
const utilization = (balance / limit) * 100
return Math.min(Math.max(utilization, 0), 100)
}