Initial commit - cleaned for CV
This commit is contained in:
359
lib/alerts.ts
Normal file
359
lib/alerts.ts
Normal 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
84
lib/auth.ts
Normal 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
64
lib/otp.ts
Normal 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
61
lib/predictions.ts
Normal 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
72
lib/server-db.ts
Normal 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
32
lib/store.ts
Normal 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) => ({ ... })
|
||||
}
|
||||
)
|
||||
)
|
||||
45
lib/store/slices/alertsSlice.ts
Normal file
45
lib/store/slices/alertsSlice.ts
Normal 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: [],
|
||||
})),
|
||||
})
|
||||
39
lib/store/slices/budgetSlice.ts
Normal file
39
lib/store/slices/budgetSlice.ts
Normal 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
|
||||
),
|
||||
})),
|
||||
})
|
||||
47
lib/store/slices/cardsSlice.ts
Normal file
47
lib/store/slices/cardsSlice.ts
Normal 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),
|
||||
})),
|
||||
})
|
||||
73
lib/store/slices/debtsSlice.ts
Normal file
73
lib/store/slices/debtsSlice.ts
Normal 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
|
||||
),
|
||||
})),
|
||||
})
|
||||
33
lib/store/slices/incomesSlice.ts
Normal file
33
lib/store/slices/incomesSlice.ts
Normal 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
|
||||
),
|
||||
})),
|
||||
})
|
||||
35
lib/store/slices/servicesSlice.ts
Normal file
35
lib/store/slices/servicesSlice.ts
Normal 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
121
lib/types.ts
Normal 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
189
lib/utils.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user