Initial commit - cleaned for CV

This commit is contained in:
Renato97
2026-03-31 01:23:33 -03:00
commit 9c11f23af0
142 changed files with 13690 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]
)
}

84
lib/auth.ts Normal file
View File

@@ -0,0 +1,84 @@
import fs from 'fs';
import path from 'path';
import { User } from './types';
import bcrypt from 'bcryptjs';
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
const USERS_FILE = path.join(process.cwd(), 'data', 'users.json');
const SECRET_KEY = new TextEncoder().encode(process.env.JWT_SECRET || 'fallback-secret-key-change-me');
// --- User Management ---
export function getUsers(): User[] {
if (!fs.existsSync(USERS_FILE)) {
fs.writeFileSync(USERS_FILE, JSON.stringify([]));
return [];
}
try {
const data = fs.readFileSync(USERS_FILE, 'utf8');
return JSON.parse(data);
} catch (e) {
return [];
}
}
export function saveUser(user: User) {
const users = getUsers();
const existingIndex = users.findIndex(u => u.username === user.username);
if (existingIndex >= 0) {
users[existingIndex] = user;
} else {
users.push(user);
}
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
}
export function findUser(username: string): User | undefined {
return getUsers().find(u => u.username === username);
}
// --- Security Utils ---
export async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return await bcrypt.compare(password, hash);
}
// --- Session Management (JWT) ---
export async function createSession(user: User) {
const token = await new SignJWT({ username: user.username, chatId: user.chatId })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(SECRET_KEY);
cookies().set('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
}
export async function verifySession() {
const session = cookies().get('session')?.value;
if (!session) return null;
try {
const { payload } = await jwtVerify(session, SECRET_KEY);
return payload as { username: string; chatId: string };
} catch (error) {
return null;
}
}
export function destroySession() {
cookies().delete('session');
}

64
lib/otp.ts Normal file
View File

@@ -0,0 +1,64 @@
import fs from 'fs';
import path from 'path';
const OTP_FILE = path.join(process.cwd(), 'auth-otp.json');
interface OTPData {
code: string;
expiresAt: number;
}
// Map username -> OTP Data
type OTPStore = Record<string, OTPData>;
function getStore(): OTPStore {
if (!fs.existsSync(OTP_FILE)) return {};
try {
return JSON.parse(fs.readFileSync(OTP_FILE, 'utf8'));
} catch {
return {};
}
}
function saveStore(store: OTPStore) {
try {
fs.writeFileSync(OTP_FILE, JSON.stringify(store));
} catch (err) {
console.error("Error saving OTP store:", err);
}
}
export function generateOTP(username: string): string {
const code = Math.floor(100000 + Math.random() * 900000).toString(); // 6 digits
const store = getStore();
store[username] = {
code,
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
};
saveStore(store);
return code;
}
export function verifyOTP(username: string, code: string): boolean {
const store = getStore();
const entry = store[username];
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
delete store[username];
saveStore(store);
return false;
}
// Compare strings safely
if (String(entry.code).trim() === String(code).trim()) {
delete store[username]; // Consume OTP so it can't be reused
saveStore(store);
return true;
}
return false;
}

61
lib/predictions.ts Normal file
View File

@@ -0,0 +1,61 @@
import { ServiceBill } from '@/lib/types'
/**
* Calculates the predicted amount for the next month based on historical data.
* Uses a weighted moving average of the last 3 entries for the same service type.
* Weights: 50% (most recent), 30% (previous), 20% (oldest).
*/
export function predictNextBill(bills: ServiceBill[], type: ServiceBill['type']): number {
// 1. Filter bills by type
const relevantBills = bills
.filter((b) => b.type === type)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) // Newest first
if (relevantBills.length === 0) return 0
// 2. Take up to 3 most recent bills
const recent = relevantBills.slice(0, 3)
// 3. Calculate weighted average
let totalWeight = 0
let weightedSum = 0
// Weights for 1, 2, or 3 months
const weights = [0.5, 0.3, 0.2]
recent.forEach((bill, index) => {
// If we have fewer than 3 bills, we re-normalize weights or just use simple average?
// Let's stick to the weights but normalize if unmatched.
// Actually, simple approach:
// 1 bill: 100%
// 2 bills: 62.5% / 37.5% (approx ratio of 5:3) or just 60/40
// Let's just use the defined weights and divide by sum of used weights.
const w = weights[index]
weightedSum += bill.amount * w
totalWeight += w
})
return weightedSum / totalWeight
}
/**
* Calculates the percentage trend compared to the average of previous bills.
* Positive = Spending more. Negative = Spending less.
*/
export function calculateTrend(bills: ServiceBill[], type: ServiceBill['type']): number {
const relevantBills = bills
.filter((b) => b.type === type)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
if (relevantBills.length < 2) return 0
const latest = relevantBills[0].amount
const previous = relevantBills.slice(1, 4) // Average of up to 3 previous bills
if (previous.length === 0) return 0
const avgPrevious = previous.reduce((sum, b) => sum + b.amount, 0) / previous.length
return ((latest - avgPrevious) / avgPrevious) * 100
}

72
lib/server-db.ts Normal file
View File

@@ -0,0 +1,72 @@
import fs from 'fs';
import path from 'path';
import { AppState } from './types';
const DATA_DIR = path.join(process.cwd(), 'data');
const LEGACY_DB_FILE = path.join(DATA_DIR, 'db.json');
const defaultState: AppState = {
fixedDebts: [],
variableDebts: [],
creditCards: [],
cardPayments: [],
incomes: [],
monthlyBudgets: [],
serviceBills: [],
alerts: [],
currentMonth: new Date().getMonth(),
currentYear: new Date().getFullYear(),
};
function getFilePath(username: string) {
// Sanitize username to prevent path traversal
const safeUsername = username.replace(/[^a-zA-Z0-9_-]/g, '');
return path.join(DATA_DIR, `db_${safeUsername}.json`);
}
export function getDatabase(username: string): AppState {
const filePath = getFilePath(username);
if (!fs.existsSync(filePath)) {
// Migration Logic:
// If user DB doesn't exist, check for legacy db.json
if (fs.existsSync(LEGACY_DB_FILE)) {
try {
const legacyData = JSON.parse(fs.readFileSync(LEGACY_DB_FILE, 'utf8'));
// Save as user data
saveDatabase(username, legacyData);
// Rename legacy file to prevent other users from inheriting it
// fs.renameSync(LEGACY_DB_FILE, `${LEGACY_DB_FILE}.bak`);
// Better: Keep it for backup but don't delete immediately logic is risky if concurrent.
// Let's just copy it. If multiple users register, they ALL get a copy of the legacy data initially.
// This is safer for "I lost my data" panic, but less private.
// Assuming single-tenant primary use case, this is fine.
return legacyData;
} catch (e) {
console.error("Migration error:", e);
}
}
// Default new state
saveDatabase(username, defaultState);
return defaultState;
}
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
console.error(`Database read error for ${username}:`, error);
return defaultState;
}
}
export function saveDatabase(username: string, data: AppState) {
const filePath = getFilePath(username);
try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
} catch (error) {
console.error(`Database write error for ${username}:`, error);
}
}

32
lib/store.ts Normal file
View File

@@ -0,0 +1,32 @@
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'
import { createIncomesSlice, IncomesSlice } from './store/slices/incomesSlice'
import { createServicesSlice, ServicesSlice } from './store/slices/servicesSlice'
// Combined State Interface
// Note: We extend the individual slices to create the full store interface
export interface FinanzasState extends DebtsSlice, CardsSlice, BudgetSlice, AlertsSlice, ServicesSlice, IncomesSlice { }
export const useFinanzasStore = create<FinanzasState>()(
persist(
(...a) => ({
...createDebtsSlice(...a),
...createCardsSlice(...a),
...createBudgetSlice(...a),
...createAlertsSlice(...a),
...createServicesSlice(...a),
...createIncomesSlice(...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
),
})),
})

View File

@@ -0,0 +1,33 @@
import { StateCreator } from 'zustand'
import { Income, AppState } from '@/lib/types'
import { v4 as uuidv4 } from 'uuid'
export interface IncomesSlice {
incomes: Income[]
addIncome: (income: Omit<Income, 'id'>) => void
removeIncome: (id: string) => void
updateIncome: (id: string, income: Partial<Income>) => void
}
export const createIncomesSlice: StateCreator<
AppState & IncomesSlice,
[],
[],
IncomesSlice
> = (set) => ({
incomes: [],
addIncome: (income) =>
set((state) => ({
incomes: [...(state.incomes || []), { ...income, id: uuidv4() }],
})),
removeIncome: (id) =>
set((state) => ({
incomes: state.incomes.filter((i) => i.id !== id),
})),
updateIncome: (id, updatedIncome) =>
set((state) => ({
incomes: state.incomes.map((i) =>
i.id === id ? { ...i, ...updatedIncome } : i
),
})),
})

View File

@@ -0,0 +1,35 @@
import { StateCreator } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import { ServiceBill } from '@/lib/types'
export interface ServicesSlice {
serviceBills: ServiceBill[]
addServiceBill: (bill: Omit<ServiceBill, 'id' | 'isPaid'>) => void
deleteServiceBill: (id: string) => void
toggleServiceBillPaid: (id: string) => void
}
export const createServicesSlice: StateCreator<ServicesSlice> = (set) => ({
serviceBills: [],
addServiceBill: (bill) =>
set((state) => ({
serviceBills: [
...state.serviceBills,
{ ...bill, id: uuidv4(), isPaid: false },
],
})),
deleteServiceBill: (id) =>
set((state) => ({
serviceBills: state.serviceBills.filter((b) => b.id !== id),
})),
toggleServiceBillPaid: (id) =>
set((state) => ({
serviceBills: state.serviceBills.map((b) =>
b.id === id ? { ...b, isPaid: !b.isPaid } : b
),
})),
})

121
lib/types.ts Normal file
View File

@@ -0,0 +1,121 @@
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 ServiceBill {
id: string
type: 'electricity' | 'water' | 'gas' | 'internet'
amount: number
usage?: number
unit?: string
date: string
period: string // YYYY-MM
isPaid: boolean
notes?: string
}
export interface Income {
id: string
amount: number
description: string
date: string
category: 'salary' | 'freelance' | 'business' | 'gift' | 'other'
}
export interface AppState {
fixedDebts: FixedDebt[]
variableDebts: VariableDebt[]
creditCards: CreditCard[]
cardPayments: CardPayment[]
incomes: Income[] // Added incomes
monthlyBudgets: MonthlyBudget[]
serviceBills: ServiceBill[]
alerts: Alert[]
currentMonth: number
currentYear: number
}
export interface AIServiceConfig {
id: string
name: string
endpoint: string
token: string
model?: string
}
export interface AppSettings {
telegram: {
botToken: string
chatId: string
}
aiProviders: AIServiceConfig[]
}
// Auth Types
export interface User {
id: string
username: string
passwordHash: string
chatId: string
knownIps: string[]
}

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)
}