from pathlib import Path import typer from rich.console import Console from rich.table import Table from cashflow_model import ( Account, Asset, ExchangeRate, FinancialModel, Liability, RecurringCashflow, Transaction, ) app = typer.Typer(name="config", help="Управление моделью (счета, транзакции, валюты...)") console = Console() DATA_DIR = Path("data") MODEL_PATH = DATA_DIR / "model.json" def _load_model() -> FinancialModel: if MODEL_PATH.exists(): return FinancialModel.load(MODEL_PATH) return FinancialModel() def _save_model(model: FinancialModel) -> None: model.save(MODEL_PATH) # ── base-currency ────────────────────────────────────────────── @app.command() def base_currency( currency: str = typer.Argument(None, help="Код валюты (RUB, USD, EUR...)"), ) -> None: """Показать или установить базовую валюту модели""" model = _load_model() if currency: model.base_currency = currency.upper() _save_model(model) console.print(f"[green]OK[/green] Базовая валюта установлена: {model.base_currency}") else: console.print(f"Базовая валюта: [bold]{model.base_currency}[/bold]") # ── rate ─────────────────────────────────────────────────────── @app.command() def rate_set( from_currency: str = typer.Argument(..., help="Из валюты"), to_currency: str = typer.Argument(..., help="В валюту"), rate: float = typer.Argument(..., help="Курс"), ) -> None: """Добавить или обновить курс валюты""" model = _load_model() fc, tc = from_currency.upper(), to_currency.upper() existing = [r for r in model.exchange_rates if r.from_currency == fc and r.to_currency == tc] if existing: existing[0].rate = rate else: model.exchange_rates.append(ExchangeRate( from_currency=from_currency.upper(), to_currency=to_currency.upper(), rate=rate, )) _save_model(model) fc, tc = from_currency.upper(), to_currency.upper() console.print(f"[green]OK[/green] Курс {fc} → {tc} = {rate}") @app.command(name="rate-list") def rate_list() -> None: """Список курсов валют""" model = _load_model() if not model.exchange_rates: console.print("[yellow]Курсы не заданы[/yellow]") return table = Table(title="Курсы валют") table.add_column("Из", style="cyan") table.add_column("В", style="cyan") table.add_column("Курс", justify="right") for r in model.exchange_rates: table.add_row(r.from_currency, r.to_currency, str(r.rate)) console.print(table) @app.command(name="rate-remove") def rate_remove( from_currency: str = typer.Argument(..., help="Из валюты"), to_currency: str = typer.Argument(..., help="В валюту"), ) -> None: """Удалить курс валюты""" model = _load_model() fc, tc = from_currency.upper(), to_currency.upper() initial = len(model.exchange_rates) model.exchange_rates = [ r for r in model.exchange_rates if not (r.from_currency == fc and r.to_currency == tc) ] if len(model.exchange_rates) < initial: _save_model(model) console.print(f"[green]OK[/green] Курс {fc} → {tc} удалён") else: console.print(f"[red]Курс {fc} → {tc} не найден[/red]") raise typer.Exit(1) # ── account ──────────────────────────────────────────────────── @app.command() def account_add( name: str = typer.Option(..., "--name", "-n", help="Название счёта"), balance: float = typer.Option(0.0, "--balance", "-b", help="Начальный баланс"), currency: str = typer.Option("", "--currency", "-c", help="Валюта счёта"), ) -> None: """Добавить счёт""" model = _load_model() curr = currency.upper() if currency else model.base_currency account = Account(name=name, balance=balance, currency=curr) model.accounts.append(account) _save_model(model) console.print(f"[green]OK[/green] Счёт '{name}' добавлен (баланс: {balance}, валюта: {curr})") @app.command(name="account-list") def account_list() -> None: """Список счетов""" model = _load_model() if not model.accounts: console.print("[yellow]Счета не добавлены[/yellow]") return table = Table(title="Счета") table.add_column("ID", style="dim") table.add_column("Название", style="cyan") table.add_column("Валюта") table.add_column("Баланс", justify="right") for a in model.accounts: table.add_row(str(a.id)[:8], a.name, a.currency, f"{a.balance:,.2f}") console.print(table) @app.command(name="account-remove") def account_remove( identifier: str = typer.Argument(..., help="ID или название счёта"), ) -> None: """Удалить счёт по ID или названию""" model = _load_model() initial = len(model.accounts) model.accounts = [ a for a in model.accounts if str(a.id) != identifier and a.name != identifier ] if len(model.accounts) < initial: _save_model(model) console.print(f"[green]OK[/green] Счёт '{identifier}' удалён") else: console.print(f"[red]Счёт '{identifier}' не найден[/red]") raise typer.Exit(1) # ── transaction ──────────────────────────────────────────────── @app.command() def transaction_add( account: str = typer.Option(..., "--account", "-a", help="ID или название счёта"), amount: float = typer.Option(..., "--amount", "-m", help="Сумма (доход/расход)"), category: str = typer.Option("", "--category", "-c", help="Категория"), description: str = typer.Option("", "--description", "-d", help="Описание"), date: str = typer.Option("", "--date", "-D", help="Дата (YYYY-MM-DD)"), ) -> None: """Добавить транзакцию""" model = _load_model() account_id = _resolve_account_id(model, account) if not account_id: console.print(f"[red]Счёт '{account}' не найден[/red]") raise typer.Exit(1) txn = Transaction( date=date or "", account=account_id, category=category, amount=amount, description=description, ) model.transactions.append(txn) _save_model(model) kind = "доход" if amount >= 0 else "расход" console.print(f"[green]OK[/green] Транзакция '{kind}' на {abs(amount):.2f} (счёт: {account})") @app.command(name="transaction-list") def transaction_list() -> None: """Список транзакций""" model = _load_model() if not model.transactions: console.print("[yellow]Транзакции не добавлены[/yellow]") return table = Table(title="Транзакции") table.add_column("ID", style="dim") table.add_column("Дата") table.add_column("Счёт") table.add_column("Категория") table.add_column("Сумма", justify="right") table.add_column("Описание") for t in model.transactions: account_name = _account_name(model, t.account) table.add_row( str(t.id)[:8], t.date, account_name, t.category, f"{t.amount:,.2f}", t.description, ) console.print(table) @app.command(name="transaction-remove") def transaction_remove( identifier: str = typer.Argument(..., help="ID транзакции"), ) -> None: """Удалить транзакцию по ID""" model = _load_model() initial = len(model.transactions) model.transactions = [t for t in model.transactions if str(t.id) != identifier] if len(model.transactions) < initial: _save_model(model) console.print(f"[green]OK[/green] Транзакция '{identifier}' удалена") else: console.print(f"[red]Транзакция '{identifier}' не найдена[/red]") raise typer.Exit(1) # ── recurring ────────────────────────────────────────────────── @app.command() def recurring_add( amount: float = typer.Option(..., "--amount", "-m", help="Сумма"), category: str = typer.Option("", "--category", "-c", help="Категория"), frequency: str = typer.Option("monthly", "--frequency", "-f", help="Периодичность"), start_date: str = typer.Option("", "--start", "-s", help="Дата начала (YYYY-MM-DD)"), end_date: str = typer.Option("", "--end", "-e", help="Дата окончания (YYYY-MM-DD)"), ) -> None: """Добавить регулярный платёж""" model = _load_model() rc = RecurringCashflow( start_date=start_date or "", end_date=end_date or "", frequency=frequency, amount=amount, category=category, ) model.recurring.append(rc) _save_model(model) console.print(f"[green]OK[/green] Регулярный платёж на {abs(amount):.2f} ({frequency})") @app.command(name="recurring-list") def recurring_list() -> None: """Список регулярных платежей""" model = _load_model() if not model.recurring: console.print("[yellow]Регулярные платежи не добавлены[/yellow]") return table = Table(title="Регулярные платежи") table.add_column("ID", style="dim") table.add_column("Начало") table.add_column("Конец") table.add_column("Период") table.add_column("Сумма", justify="right") table.add_column("Категория") for r in model.recurring: row = (str(r.id)[:8], r.start_date, r.end_date, r.frequency, f"{r.amount:,.2f}", r.category) table.add_row(*row) console.print(table) @app.command(name="recurring-remove") def recurring_remove( identifier: str = typer.Argument(..., help="ID регулярного платежа"), ) -> None: """Удалить регулярный платёж по ID""" model = _load_model() initial = len(model.recurring) model.recurring = [r for r in model.recurring if str(r.id) != identifier] if len(model.recurring) < initial: _save_model(model) console.print(f"[green]OK[/green] Регулярный платёж '{identifier}' удалён") else: console.print(f"[red]Регулярный платёж '{identifier}' не найден[/red]") raise typer.Exit(1) # ── asset ────────────────────────────────────────────────────── @app.command() def asset_add( name: str = typer.Option(..., "--name", "-n", help="Название актива"), value: float = typer.Option(0.0, "--value", "-v", help="Стоимость"), growth_rate: float = typer.Option(0.0, "--growth", "-g", help="Годовой рост (%)"), ) -> None: """Добавить актив""" model = _load_model() asset = Asset(name=name, value=value, growth_rate=growth_rate) model.assets.append(asset) _save_model(model) console.print(f"[green]OK[/green] Актив '{name}' добавлен (стоимость: {value})") @app.command(name="asset-list") def asset_list() -> None: """Список активов""" model = _load_model() if not model.assets: console.print("[yellow]Активы не добавлены[/yellow]") return table = Table(title="Активы") table.add_column("ID", style="dim") table.add_column("Название", style="cyan") table.add_column("Стоимость", justify="right") table.add_column("Рост (%)", justify="right") for a in model.assets: table.add_row(str(a.id)[:8], a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%") console.print(table) @app.command(name="asset-remove") def asset_remove( identifier: str = typer.Argument(..., help="ID или название актива"), ) -> None: """Удалить актив по ID или названию""" model = _load_model() initial = len(model.assets) model.assets = [ a for a in model.assets if str(a.id) != identifier and a.name != identifier ] if len(model.assets) < initial: _save_model(model) console.print(f"[green]OK[/green] Актив '{identifier}' удалён") else: console.print(f"[red]Актив '{identifier}' не найден[/red]") raise typer.Exit(1) # ── liability ────────────────────────────────────────────────── @app.command() def liability_add( name: str = typer.Option(..., "--name", "-n", help="Название"), balance: float = typer.Option(0.0, "--balance", "-b", help="Остаток долга"), interest: float = typer.Option(0.0, "--interest", "-i", help="Процентная ставка (%)"), payment: float = typer.Option(0.0, "--payment", "-p", help="Ежемесячный платёж"), ) -> None: """Добавить обязательство""" model = _load_model() liability = Liability(name=name, balance=balance, interest=interest, payment=payment) model.liabilities.append(liability) _save_model(model) console.print(f"[green]OK[/green] Обязательство '{name}' добавлено (долг: {balance})") @app.command(name="liability-list") def liability_list() -> None: """Список обязательств""" model = _load_model() if not model.liabilities: console.print("[yellow]Обязательства не добавлены[/yellow]") return table = Table(title="Обязательства") table.add_column("ID", style="dim") table.add_column("Название", style="cyan") table.add_column("Долг", justify="right") table.add_column("Ставка", justify="right") table.add_column("Платёж", justify="right") for li in model.liabilities: b, i, p = f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}" row = (str(li.id)[:8], li.name, b, i, p) table.add_row(*row) console.print(table) @app.command(name="liability-remove") def liability_remove( identifier: str = typer.Argument(..., help="ID или название обязательства"), ) -> None: """Удалить обязательство по ID или названию""" model = _load_model() initial = len(model.liabilities) model.liabilities = [ li for li in model.liabilities if str(li.id) != identifier and li.name != identifier ] if len(model.liabilities) < initial: _save_model(model) console.print(f"[green]OK[/green] Обязательство '{identifier}' удалено") else: console.print(f"[red]Обязательство '{identifier}' не найдено[/red]") raise typer.Exit(1) # ── helpers ──────────────────────────────────────────────────── def _resolve_account_id(model: FinancialModel, identifier: str) -> str | None: for a in model.accounts: if str(a.id) == identifier or a.name == identifier: return str(a.id) return None def _account_name(model: FinancialModel, account_id: str) -> str: for a in model.accounts: if str(a.id) == account_id: return a.name return account_id[:8]