CtrlK
BlogDocsLog inGet started
Tessl Logo

expense-tracking-patterns

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-patterns
What are skills?

79

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

Expense Tracking Patterns - Savvy AI

Conocimiento especializado sobre patrones de control de gastos personales, basado en las mejores pr\u00e1cticas de plantillas de Excel.

Estructura de Datos

Modelo de Transacci\u00f3n

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
}

Modelo de Categor\u00eda

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

Categor\u00edas por Defecto

Gastos Fijos

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'),
    ],
  ),
];

Gastos Variables

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'),
    ],
  ),
];

Ingresos

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'),
    ],
  ),
];

F\u00f3rmulas y C\u00e1lculos

Balance B\u00e1sico

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

Agrupaci\u00f3n por Categor\u00eda

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

Presupuesto vs Real

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 }

Proyecci\u00f3n de Ahorro

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

Cash Flow Proyectado

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

Detecci\u00f3n de Gastos Recurrentes

Full algorithm: See .claude/skills/cash-flow-projection/SKILL.md (RecurrenceDetector section).

Key concepts:

  • Group transactions by normalized concept + rounded amount
  • Calculate intervals between occurrences, infer frequency
  • Frequencies: daily, weekly, biweekly, monthly, quarterly, yearly
  • Confidence based on coefficient of variation

Algoritmo de Detecci\u00f3n

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

Parser de Comandos de Voz

Patrones de Reconocimiento

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

Regla 50/30/20

Validador de Presupuesto

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;
}
Repository
iru97/Savvy-ai
Last updated
Created

Is this your skill?

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.