Patrones y l\u00f3gica de negocio para control de gastos basados en plantillas de Excel. Incluye categorizaci\u00f3n, f\u00f3rmulas, c\u00e1lculos financieros y detecci\u00f3n de gastos recurrentes. Usar cuando se trabaje con l\u00f3gica de transacciones, presupuestos o reportes financieros.
Install with Tessl CLI
npx tessl i github:iru97/Savvy-ai --skill expense-tracking-patterns79
Does it follow best practices?
If you maintain this skill, you can automatically optimize it using the tessl CLI to improve its score:
npx tessl skill review --optimize ./path/to/skillValidation for skill structure
Conocimiento especializado sobre patrones de control de gastos personales, basado en las mejores pr\u00e1cticas de plantillas de Excel.
class Transaction {
final String id;
final DateTime fecha;
final TransactionType tipo; // ingreso | egreso
final String categoriaId;
final String? subcategoriaId;
final double monto;
final String concepto;
final String metodoPago;
final bool esRecurrente;
final RecurrenceConfig? recurrencia;
final List<String> etiquetas;
final String? notas;
// Campos calculados
bool get esGasto => tipo == TransactionType.egreso;
bool get esIngreso => tipo == TransactionType.ingreso;
}
enum TransactionType { ingreso, egreso }
class RecurrenceConfig {
final RecurrenceFrequency frecuencia;
final int intervalo; // cada X per\u00edodos
final DateTime? fechaProximoCargo;
final DateTime? fechaFin;
final bool activo;
}
enum RecurrenceFrequency {
diario,
semanal,
quincenal,
mensual,
trimestral,
semestral,
anual
}class Category {
final String id;
final String nombre;
final CategoryType tipo; // ingreso | egreso
final ExpenseClassification clasificacion;
final String icono;
final String color;
final List<Subcategory> subcategorias;
final bool deducibleImpuestos;
final bool activa;
}
enum CategoryType { ingreso, egreso }
enum ExpenseClassification {
fijo, // Gastos que no cambian mes a mes
variable, // Gastos que var\u00edan
ahorro // Destinado a ahorro/inversi\u00f3n
}
class Subcategory {
final String id;
final String nombre;
final String? icono;
}final gastosFixos = [
Category(
id: 'vivienda',
nombre: 'Vivienda',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.fijo,
icono: 'home',
color: '#4CAF50',
subcategorias: [
Subcategory(id: 'renta', nombre: 'Renta/Alquiler'),
Subcategory(id: 'hipoteca', nombre: 'Hipoteca'),
Subcategory(id: 'mantenimiento', nombre: 'Mantenimiento'),
Subcategory(id: 'impuestos_prediales', nombre: 'Impuestos prediales'),
],
),
Category(
id: 'servicios',
nombre: 'Servicios',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.fijo,
icono: 'flash_on',
color: '#2196F3',
subcategorias: [
Subcategory(id: 'electricidad', nombre: 'Electricidad'),
Subcategory(id: 'agua', nombre: 'Agua'),
Subcategory(id: 'gas', nombre: 'Gas'),
Subcategory(id: 'internet', nombre: 'Internet'),
Subcategory(id: 'telefono', nombre: 'Tel\u00e9fono'),
],
),
Category(
id: 'seguros',
nombre: 'Seguros',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.fijo,
icono: 'security',
color: '#9C27B0',
subcategorias: [
Subcategory(id: 'seguro_salud', nombre: 'Salud'),
Subcategory(id: 'seguro_auto', nombre: 'Auto'),
Subcategory(id: 'seguro_vida', nombre: 'Vida'),
Subcategory(id: 'seguro_hogar', nombre: 'Hogar'),
],
),
Category(
id: 'suscripciones',
nombre: 'Suscripciones',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.fijo,
icono: 'subscriptions',
color: '#FF5722',
subcategorias: [
Subcategory(id: 'streaming', nombre: 'Streaming'),
Subcategory(id: 'software', nombre: 'Software'),
Subcategory(id: 'gimnasio', nombre: 'Gimnasio'),
Subcategory(id: 'membresias', nombre: 'Membres\u00edas'),
],
),
Category(
id: 'deudas',
nombre: 'Deudas/Pr\u00e9stamos',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.fijo,
icono: 'account_balance',
color: '#F44336',
subcategorias: [
Subcategory(id: 'prestamo_personal', nombre: 'Pr\u00e9stamo personal'),
Subcategory(id: 'tarjeta_credito', nombre: 'Tarjeta de cr\u00e9dito'),
Subcategory(id: 'prestamo_auto', nombre: 'Pr\u00e9stamo auto'),
Subcategory(id: 'prestamo_estudiantil', nombre: 'Pr\u00e9stamo estudiantil'),
],
),
];final gastosVariables = [
Category(
id: 'alimentacion',
nombre: 'Alimentaci\u00f3n',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.variable,
icono: 'restaurant',
color: '#FF9800',
subcategorias: [
Subcategory(id: 'supermercado', nombre: 'Supermercado'),
Subcategory(id: 'restaurantes', nombre: 'Restaurantes'),
Subcategory(id: 'cafeterias', nombre: 'Cafeter\u00edas'),
Subcategory(id: 'delivery', nombre: 'Delivery'),
],
),
Category(
id: 'transporte',
nombre: 'Transporte',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.variable,
icono: 'directions_car',
color: '#607D8B',
subcategorias: [
Subcategory(id: 'gasolina', nombre: 'Gasolina'),
Subcategory(id: 'uber_taxi', nombre: 'Uber/Taxi'),
Subcategory(id: 'transporte_publico', nombre: 'Transporte p\u00fablico'),
Subcategory(id: 'estacionamiento', nombre: 'Estacionamiento'),
],
),
Category(
id: 'salud',
nombre: 'Salud',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.variable,
icono: 'local_hospital',
color: '#E91E63',
subcategorias: [
Subcategory(id: 'medicos', nombre: 'M\u00e9dicos'),
Subcategory(id: 'medicamentos', nombre: 'Medicamentos'),
Subcategory(id: 'terapias', nombre: 'Terapias'),
Subcategory(id: 'examenes', nombre: 'Ex\u00e1menes'),
],
),
Category(
id: 'entretenimiento',
nombre: 'Entretenimiento',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.variable,
icono: 'movie',
color: '#673AB7',
subcategorias: [
Subcategory(id: 'cine', nombre: 'Cine'),
Subcategory(id: 'eventos', nombre: 'Eventos'),
Subcategory(id: 'hobbies', nombre: 'Hobbies'),
Subcategory(id: 'viajes', nombre: 'Viajes'),
],
),
Category(
id: 'compras',
nombre: 'Compras',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.variable,
icono: 'shopping_bag',
color: '#00BCD4',
subcategorias: [
Subcategory(id: 'ropa', nombre: 'Ropa'),
Subcategory(id: 'electronicos', nombre: 'Electr\u00f3nicos'),
Subcategory(id: 'hogar', nombre: 'Hogar'),
Subcategory(id: 'cuidado_personal', nombre: 'Cuidado personal'),
],
),
Category(
id: 'educacion',
nombre: 'Educaci\u00f3n',
tipo: CategoryType.egreso,
clasificacion: ExpenseClassification.variable,
icono: 'school',
color: '#3F51B5',
subcategorias: [
Subcategory(id: 'cursos', nombre: 'Cursos'),
Subcategory(id: 'libros', nombre: 'Libros'),
Subcategory(id: 'materiales', nombre: 'Materiales'),
],
),
];final ingresos = [
Category(
id: 'salario',
nombre: 'Salario',
tipo: CategoryType.ingreso,
clasificacion: ExpenseClassification.fijo,
icono: 'work',
color: '#4CAF50',
subcategorias: [
Subcategory(id: 'salario_principal', nombre: 'Salario principal'),
Subcategory(id: 'bonos', nombre: 'Bonos'),
Subcategory(id: 'horas_extra', nombre: 'Horas extra'),
],
),
Category(
id: 'freelance',
nombre: 'Freelance/Honorarios',
tipo: CategoryType.ingreso,
clasificacion: ExpenseClassification.variable,
icono: 'laptop',
color: '#2196F3',
subcategorias: [],
),
Category(
id: 'inversiones',
nombre: 'Inversiones',
tipo: CategoryType.ingreso,
clasificacion: ExpenseClassification.variable,
icono: 'trending_up',
color: '#FF9800',
subcategorias: [
Subcategory(id: 'dividendos', nombre: 'Dividendos'),
Subcategory(id: 'intereses', nombre: 'Intereses'),
Subcategory(id: 'ganancias_capital', nombre: 'Ganancias de capital'),
],
),
Category(
id: 'otros_ingresos',
nombre: 'Otros Ingresos',
tipo: CategoryType.ingreso,
clasificacion: ExpenseClassification.variable,
icono: 'attach_money',
color: '#9C27B0',
subcategorias: [
Subcategory(id: 'regalos', nombre: 'Regalos'),
Subcategory(id: 'ventas', nombre: 'Ventas'),
Subcategory(id: 'reembolsos', nombre: 'Reembolsos'),
],
),
];class FinancialCalculator {
/// Balance = Ingresos - Egresos
static double calcularBalance(List<Transaction> transacciones) {
return transacciones.fold(0.0, (sum, t) {
return t.esIngreso ? sum + t.monto : sum - t.monto;
});
}
/// Total de ingresos
static double totalIngresos(List<Transaction> transacciones) {
return transacciones
.where((t) => t.esIngreso)
.fold(0.0, (sum, t) => sum + t.monto);
}
/// Total de egresos
static double totalEgresos(List<Transaction> transacciones) {
return transacciones
.where((t) => t.esGasto)
.fold(0.0, (sum, t) => sum + t.monto);
}
}class CategoryAnalyzer {
/// Gasto por categor\u00eda
static Map<String, double> gastoPorCategoria(List<Transaction> transacciones) {
final gastos = transacciones.where((t) => t.esGasto);
final grouped = <String, double>{};
for (final t in gastos) {
grouped[t.categoriaId] = (grouped[t.categoriaId] ?? 0) + t.monto;
}
return grouped;
}
/// Porcentaje por categor\u00eda
static Map<String, double> porcentajePorCategoria(List<Transaction> transacciones) {
final gastos = gastoPorCategoria(transacciones);
final total = gastos.values.fold(0.0, (sum, v) => sum + v);
if (total == 0) return {};
return gastos.map((k, v) => MapEntry(k, (v / total) * 100));
}
/// Gasto por clasificaci\u00f3n (fijo/variable)
static Map<ExpenseClassification, double> gastoPorClasificacion(
List<Transaction> transacciones,
Map<String, Category> categorias,
) {
final result = <ExpenseClassification, double>{};
for (final t in transacciones.where((t) => t.esGasto)) {
final cat = categorias[t.categoriaId];
if (cat != null) {
final clasificacion = cat.clasificacion;
result[clasificacion] = (result[clasificacion] ?? 0) + t.monto;
}
}
return result;
}
}class BudgetAnalyzer {
/// Comparativa presupuesto vs gastado
static BudgetComparison comparar(
double presupuestado,
double gastado,
) {
final diferencia = presupuestado - gastado;
final porcentajeUso = presupuestado > 0
? (gastado / presupuestado) * 100
: 0.0;
return BudgetComparison(
presupuestado: presupuestado,
gastado: gastado,
diferencia: diferencia,
porcentajeUso: porcentajeUso,
excedido: gastado > presupuestado,
);
}
/// Estado de alerta basado en porcentaje
static BudgetAlert getAlertLevel(double porcentajeUso) {
if (porcentajeUso >= 100) return BudgetAlert.excedido;
if (porcentajeUso >= 90) return BudgetAlert.critico;
if (porcentajeUso >= 75) return BudgetAlert.advertencia;
return BudgetAlert.normal;
}
}
class BudgetComparison {
final double presupuestado;
final double gastado;
final double diferencia;
final double porcentajeUso;
final bool excedido;
const BudgetComparison({
required this.presupuestado,
required this.gastado,
required this.diferencia,
required this.porcentajeUso,
required this.excedido,
});
}
enum BudgetAlert { normal, advertencia, critico, excedido }class SavingsProjector {
/// Valor futuro con inter\u00e9s compuesto
/// FV = PV * (1 + r/n)^(n*t) + PMT * (((1 + r/n)^(n*t) - 1) / (r/n))
static double valorFuturo({
required double balanceInicial,
required double depositoMensual,
required double tasaAnual, // como decimal, ej: 0.05 para 5%
required int meses,
}) {
if (tasaAnual == 0) {
return balanceInicial + (depositoMensual * meses);
}
final tasaMensual = tasaAnual / 12;
final factor = pow(1 + tasaMensual, meses);
// Valor futuro del balance inicial
final fvBalanceInicial = balanceInicial * factor;
// Valor futuro de los dep\u00f3sitos (anualidad)
final fvDepositos = depositoMensual * ((factor - 1) / tasaMensual);
return fvBalanceInicial + fvDepositos;
}
/// Proyecci\u00f3n mensual de ahorro
static List<SavingsProjection> proyectarMeses({
required double balanceInicial,
required double depositoMensual,
required double tasaAnual,
required int meses,
}) {
final proyecciones = <SavingsProjection>[];
double balance = balanceInicial;
final tasaMensual = tasaAnual / 12;
for (int i = 1; i <= meses; i++) {
final interes = balance * tasaMensual;
balance = balance + interes + depositoMensual;
proyecciones.add(SavingsProjection(
mes: i,
balance: balance,
deposito: depositoMensual,
interes: interes,
));
}
return proyecciones;
}
}
class SavingsProjection {
final int mes;
final double balance;
final double deposito;
final double interes;
const SavingsProjection({
required this.mes,
required this.balance,
required this.deposito,
required this.interes,
});
}class CashFlowProjector {
/// Proyecta el balance considerando gastos recurrentes
static List<CashFlowPoint> proyectarCashFlow({
required double balanceActual,
required List<Transaction> recurrentes,
required int diasProyeccion,
}) {
final puntos = <CashFlowPoint>[];
double balance = balanceActual;
final hoy = DateTime.now();
for (int i = 0; i <= diasProyeccion; i++) {
final fecha = hoy.add(Duration(days: i));
// Buscar transacciones recurrentes para esta fecha
for (final t in recurrentes) {
if (_esFechaRecurrente(t, fecha)) {
balance += t.esIngreso ? t.monto : -t.monto;
}
}
puntos.add(CashFlowPoint(fecha: fecha, balance: balance));
}
return puntos;
}
static bool _esFechaRecurrente(Transaction t, DateTime fecha) {
if (!t.esRecurrente || t.recurrencia == null) return false;
final rec = t.recurrencia!;
if (!rec.activo) return false;
// L\u00f3gica simplificada - verificar si es d\u00eda de cobro
final proximoCargo = rec.fechaProximoCargo;
if (proximoCargo == null) return false;
return fecha.year == proximoCargo.year &&
fecha.month == proximoCargo.month &&
fecha.day == proximoCargo.day;
}
}
class CashFlowPoint {
final DateTime fecha;
final double balance;
const CashFlowPoint({required this.fecha, required this.balance});
}Full algorithm: See
.claude/skills/cash-flow-projection/SKILL.md(RecurrenceDetector section).
Key concepts:
class RecurrenceDetector {
/// Detecta patrones recurrentes en transacciones
static List<RecurrentPattern> detectarPatrones(
List<Transaction> transacciones,
{int minimoOcurrencias = 2}
) {
final patrones = <RecurrentPattern>[];
// Agrupar por concepto similar y monto
final grupos = _agruparSimilares(transacciones);
for (final grupo in grupos.entries) {
if (grupo.value.length >= minimoOcurrencias) {
final frecuencia = _detectarFrecuencia(grupo.value);
if (frecuencia != null) {
patrones.add(RecurrentPattern(
concepto: grupo.key,
monto: grupo.value.first.monto,
frecuencia: frecuencia,
ocurrencias: grupo.value.length,
transacciones: grupo.value,
));
}
}
}
return patrones;
}
static Map<String, List<Transaction>> _agruparSimilares(
List<Transaction> transacciones,
) {
final grupos = <String, List<Transaction>>{};
for (final t in transacciones) {
// Normalizar concepto para agrupaci\u00f3n
final key = _normalizarConcepto(t.concepto);
grupos.putIfAbsent(key, () => []).add(t);
}
return grupos;
}
static String _normalizarConcepto(String concepto) {
return concepto
.toLowerCase()
.replaceAll(RegExp(r'[0-9]'), '') // Quitar n\u00fameros
.replaceAll(RegExp(r'\s+'), ' ') // Normalizar espacios
.trim();
}
static RecurrenceFrequency? _detectarFrecuencia(List<Transaction> txns) {
if (txns.length < 2) return null;
// Ordenar por fecha
final sorted = List<Transaction>.from(txns)
..sort((a, b) => a.fecha.compareTo(b.fecha));
// Calcular diferencias en d\u00edas
final diferencias = <int>[];
for (int i = 1; i < sorted.length; i++) {
final diff = sorted[i].fecha.difference(sorted[i-1].fecha).inDays;
diferencias.add(diff);
}
// Calcular promedio
final promedio = diferencias.reduce((a, b) => a + b) / diferencias.length;
// Determinar frecuencia basada en promedio
if (promedio <= 1) return RecurrenceFrequency.diario;
if (promedio >= 6 && promedio <= 8) return RecurrenceFrequency.semanal;
if (promedio >= 13 && promedio <= 16) return RecurrenceFrequency.quincenal;
if (promedio >= 28 && promedio <= 32) return RecurrenceFrequency.mensual;
if (promedio >= 88 && promedio <= 95) return RecurrenceFrequency.trimestral;
if (promedio >= 175 && promedio <= 190) return RecurrenceFrequency.semestral;
if (promedio >= 360 && promedio <= 370) return RecurrenceFrequency.anual;
return null;
}
}
class RecurrentPattern {
final String concepto;
final double monto;
final RecurrenceFrequency frecuencia;
final int ocurrencias;
final List<Transaction> transacciones;
const RecurrentPattern({
required this.concepto,
required this.monto,
required this.frecuencia,
required this.ocurrencias,
required this.transacciones,
});
/// Calcula el gasto anual proyectado
double get gastoAnual {
switch (frecuencia) {
case RecurrenceFrequency.diario: return monto * 365;
case RecurrenceFrequency.semanal: return monto * 52;
case RecurrenceFrequency.quincenal: return monto * 26;
case RecurrenceFrequency.mensual: return monto * 12;
case RecurrenceFrequency.trimestral: return monto * 4;
case RecurrenceFrequency.semestral: return monto * 2;
case RecurrenceFrequency.anual: return monto;
}
}
}class VoiceCommandParser {
/// Parsea un comando de voz y extrae la transacci\u00f3n
static ParsedTransaction? parse(String comando) {
final normalized = comando.toLowerCase().trim();
// Patrones de ingreso
if (_matchIngreso(normalized)) {
return _parseIngreso(normalized);
}
// Patrones de gasto
if (_matchGasto(normalized)) {
return _parseGasto(normalized);
}
// Patrones de gasto recurrente
if (_matchRecurrente(normalized)) {
return _parseRecurrente(normalized);
}
return null;
}
// === PATRONES DE INGRESO ===
static final _ingresosPatterns = [
RegExp(r'(?:acabo de |me |ya )?cobr(?:\u00e9|aron|ar) (\d+(?:[.,]\d+)?)(?: (?:pesos|euros|d\u00f3lares))?(?: (?:de |del |por )?(.+))?'),
RegExp(r'(?:recib\u00ed|recibi|me pagaron) (\d+(?:[.,]\d+)?)(?: (?:de |del |por )?(.+))?'),
RegExp(r'(?:entr\u00f3|entro|ingres\u00f3) (\d+(?:[.,]\d+)?)(?: (?:de |del |por )?(.+))?'),
];
static bool _matchIngreso(String cmd) {
return _ingresosPatterns.any((p) => p.hasMatch(cmd));
}
static ParsedTransaction? _parseIngreso(String cmd) {
for (final pattern in _ingresosPatterns) {
final match = pattern.firstMatch(cmd);
if (match != null) {
final monto = _parseMonto(match.group(1)!);
final concepto = match.group(2)?.trim() ?? 'Ingreso';
return ParsedTransaction(
tipo: TransactionType.ingreso,
monto: monto,
concepto: concepto,
categoriaId: _inferirCategoriaIngreso(concepto),
);
}
}
return null;
}
// === PATRONES DE GASTO ===
static final _gastosPatterns = [
RegExp(r'(?:gast\u00e9|gaste|pagu\u00e9|pague) (\d+(?:[.,]\d+)?)(?: (?:pesos|euros|d\u00f3lares))?(?: (?:en |por )?(.+))?'),
RegExp(r'(?:me cost\u00f3|costo) (\d+(?:[.,]\d+)?)(?: (?:el |la |los |las )?(.+))?'),
RegExp(r'(?:compr\u00e9|compre) (.+?)(?: (?:por|en) )?(\d+(?:[.,]\d+)?)'),
];
static bool _matchGasto(String cmd) {
return _gastosPatterns.any((p) => p.hasMatch(cmd));
}
static ParsedTransaction? _parseGasto(String cmd) {
for (final pattern in _gastosPatterns) {
final match = pattern.firstMatch(cmd);
if (match != null) {
double monto;
String concepto;
// El patr\u00f3n de "compr\u00e9" tiene orden invertido
if (pattern.pattern.contains('compr')) {
concepto = match.group(1)?.trim() ?? 'Compra';
monto = _parseMonto(match.group(2)!);
} else {
monto = _parseMonto(match.group(1)!);
concepto = match.group(2)?.trim() ?? 'Gasto';
}
final categoria = _inferirCategoriaGasto(concepto);
return ParsedTransaction(
tipo: TransactionType.egreso,
monto: monto,
concepto: concepto,
categoriaId: categoria.categoriaId,
subcategoriaId: categoria.subcategoriaId,
);
}
}
return null;
}
// === PATRONES RECURRENTES ===
static final _recurrentePatterns = [
RegExp(r'(?:ya )?me cobraron (\d+(?:[.,]\d+)?)(?: (?:del |de la |de )?(.+))'),
RegExp(r'(?:pagu\u00e9|pague) (?:el |la )?(.+?)(?: de )?([\d,]+)'),
];
static bool _matchRecurrente(String cmd) {
return cmd.contains('pr\u00e9stamo') ||
cmd.contains('prestamo') ||
cmd.contains('suscripci\u00f3n') ||
cmd.contains('suscripcion') ||
cmd.contains('mensualidad') ||
_recurrentePatterns.any((p) => p.hasMatch(cmd));
}
static ParsedTransaction? _parseRecurrente(String cmd) {
// Similar a parseGasto pero marca como recurrente
final parsed = _parseGasto(cmd);
if (parsed != null) {
return parsed.copyWith(esRecurrente: true);
}
return null;
}
// === UTILIDADES ===
static double _parseMonto(String montoStr) {
return double.parse(montoStr.replaceAll(',', '.'));
}
static String _inferirCategoriaIngreso(String concepto) {
final lower = concepto.toLowerCase();
if (lower.contains('salario') || lower.contains('sueldo') ||
lower.contains('n\u00f3mina') || lower.contains('nomina')) {
return 'salario';
}
if (lower.contains('freelance') || lower.contains('proyecto') ||
lower.contains('cliente')) {
return 'freelance';
}
if (lower.contains('dividendo') || lower.contains('inter\u00e9s') ||
lower.contains('inversi\u00f3n')) {
return 'inversiones';
}
return 'otros_ingresos';
}
static ({String categoriaId, String? subcategoriaId}) _inferirCategoriaGasto(
String concepto,
) {
final lower = concepto.toLowerCase();
// Alimentaci\u00f3n
if (lower.contains('comer') || lower.contains('cenar') ||
lower.contains('almorzar') || lower.contains('desayunar') ||
lower.contains('restaurante')) {
return (categoriaId: 'alimentacion', subcategoriaId: 'restaurantes');
}
if (lower.contains('super') || lower.contains('mercado') ||
lower.contains('despensa')) {
return (categoriaId: 'alimentacion', subcategoriaId: 'supermercado');
}
if (lower.contains('caf\u00e9') || lower.contains('cafe') ||
lower.contains('starbucks')) {
return (categoriaId: 'alimentacion', subcategoriaId: 'cafeterias');
}
// Transporte
if (lower.contains('gasolina') || lower.contains('gas')) {
return (categoriaId: 'transporte', subcategoriaId: 'gasolina');
}
if (lower.contains('uber') || lower.contains('taxi') ||
lower.contains('didi')) {
return (categoriaId: 'transporte', subcategoriaId: 'uber_taxi');
}
// Entretenimiento
if (lower.contains('cine') || lower.contains('pel\u00edcula')) {
return (categoriaId: 'entretenimiento', subcategoriaId: 'cine');
}
// Salud
if (lower.contains('doctor') || lower.contains('m\u00e9dico') ||
lower.contains('consulta')) {
return (categoriaId: 'salud', subcategoriaId: 'medicos');
}
if (lower.contains('medicina') || lower.contains('farmacia')) {
return (categoriaId: 'salud', subcategoriaId: 'medicamentos');
}
// Compras
if (lower.contains('ropa') || lower.contains('zapatos')) {
return (categoriaId: 'compras', subcategoriaId: 'ropa');
}
// Default
return (categoriaId: 'compras', subcategoriaId: null);
}
}
class ParsedTransaction {
final TransactionType tipo;
final double monto;
final String concepto;
final String categoriaId;
final String? subcategoriaId;
final bool esRecurrente;
const ParsedTransaction({
required this.tipo,
required this.monto,
required this.concepto,
required this.categoriaId,
this.subcategoriaId,
this.esRecurrente = false,
});
ParsedTransaction copyWith({bool? esRecurrente}) {
return ParsedTransaction(
tipo: tipo,
monto: monto,
concepto: concepto,
categoriaId: categoriaId,
subcategoriaId: subcategoriaId,
esRecurrente: esRecurrente ?? this.esRecurrente,
);
}
}class BudgetRuleValidator {
/// Verifica cumplimiento de regla 50/30/20
static BudgetRuleResult validar({
required double ingresosTotales,
required double gastosFijos,
required double gastosVariables,
required double ahorro,
}) {
final totalGastos = gastosFijos + gastosVariables + ahorro;
final porcentajeFijos = (gastosFijos / ingresosTotales) * 100;
final porcentajeVariables = (gastosVariables / ingresosTotales) * 100;
final porcentajeAhorro = (ahorro / ingresosTotales) * 100;
return BudgetRuleResult(
// Necesidades (deber\u00eda ser <= 50%)
necesidades: RuleCategory(
actual: porcentajeFijos,
objetivo: 50,
cumple: porcentajeFijos <= 50,
),
// Deseos (deber\u00eda ser <= 30%)
deseos: RuleCategory(
actual: porcentajeVariables,
objetivo: 30,
cumple: porcentajeVariables <= 30,
),
// Ahorro (deber\u00eda ser >= 20%)
ahorro: RuleCategory(
actual: porcentajeAhorro,
objetivo: 20,
cumple: porcentajeAhorro >= 20,
),
);
}
}
class BudgetRuleResult {
final RuleCategory necesidades;
final RuleCategory deseos;
final RuleCategory ahorro;
const BudgetRuleResult({
required this.necesidades,
required this.deseos,
required this.ahorro,
});
bool get cumpleTodo =>
necesidades.cumple && deseos.cumple && ahorro.cumple;
}
class RuleCategory {
final double actual;
final double objetivo;
final bool cumple;
const RuleCategory({
required this.actual,
required this.objetivo,
required this.cumple,
});
double get diferencia => actual - objetivo;
}1e99f86
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.