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, ) from cli.i18n import t app = typer.Typer(name="config", help=t("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=t("cmd.config.base_currency.arg")), ) -> None: """Показать или установить базовую валюту модели""" model = _load_model() if currency: model.base_currency = currency.upper() _save_model(model) ok = t("global.ok") msg = t("cmd.config.base_currency.ok", currency=model.base_currency) console.print(f"[green]{ok}[/green] {msg}") else: console.print(t("cmd.config.base_currency.show", currency=f"[bold]{model.base_currency}[/bold]")) # ── rate ─────────────────────────────────────────────────────── @app.command() def rate_set( from_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.from")), to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")), rate: float = typer.Argument(..., help=t("cmd.config.rate_set.arg.rate")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.rate_set.ok', fc=fc, tc=tc, rate=rate)}") @app.command(name="rate-list") def rate_list() -> None: """Список курсов валют""" model = _load_model() if not model.exchange_rates: console.print(f"[yellow]{t('cmd.config.rate_list.empty')}[/yellow]") return table = Table(title=t("table.rates.title")) table.add_column(t("table.rates.col.from"), style="cyan") table.add_column(t("table.rates.col.to"), style="cyan") table.add_column(t("table.rates.col.rate"), 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=t("cmd.config.rate_set.arg.from")), to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.rate_remove.ok', fc=fc, tc=tc)}") else: console.print(f"[red]{t('cmd.config.rate_remove.err', fc=fc, tc=tc)}[/red]") raise typer.Exit(1) # ── account ──────────────────────────────────────────────────── @app.command() def account_add( name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.account_add.opt.name")), balance: float = typer.Option(0.0, "--balance", "-b", help=t("cmd.config.account_add.opt.balance")), currency: str = typer.Option("", "--currency", "-c", help=t("cmd.config.account_add.opt.currency")), ) -> 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) ok = t("global.ok") msg = t("cmd.config.account_add.ok", name=name, balance=balance, currency=curr) console.print(f"[green]{ok}[/green] {msg}") @app.command(name="account-list") def account_list() -> None: """Список счетов""" model = _load_model() if not model.accounts: console.print(f"[yellow]{t('cmd.config.account_list.empty')}[/yellow]") return table = Table(title=t("table.accounts_config.title")) table.add_column(t("table.accounts_config.col.id"), style="dim") table.add_column(t("table.accounts_config.col.name"), style="cyan") table.add_column(t("table.accounts_config.col.currency")) table.add_column(t("table.accounts_config.col.balance"), 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=t("cmd.config.account_remove.arg")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.account_remove.ok', id=identifier)}") else: console.print(f"[red]{t('cmd.config.account_remove.err', id=identifier)}[/red]") raise typer.Exit(1) # ── transaction ──────────────────────────────────────────────── @app.command() def transaction_add( account: str = typer.Option(..., "--account", "-a", help=t("cmd.config.transaction_add.opt.account")), amount: float = typer.Option(..., "--amount", "-m", help=t("cmd.config.transaction_add.opt.amount")), category: str = typer.Option("", "--category", "-c", help=t("cmd.config.transaction_add.opt.category")), description: str = typer.Option("", "--description", "-d", help=t("cmd.config.transaction_add.opt.description")), date: str = typer.Option("", "--date", "-D", help=t("cmd.config.transaction_add.opt.date")), ) -> None: """Добавить транзакцию""" model = _load_model() account_id = _resolve_account_id(model, account) if not account_id: console.print(f"[red]{t('cmd.config.transaction_add.err', account=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 = t("cmd.config.transaction_add.kind.income") if amount >= 0 else t("cmd.config.transaction_add.kind.expense") ok = t("global.ok") msg = t("cmd.config.transaction_add.ok", kind=kind, amount=abs(amount), account=account) console.print(f"[green]{ok}[/green] {msg}") @app.command(name="transaction-list") def transaction_list() -> None: """Список транзакций""" model = _load_model() if not model.transactions: console.print(f"[yellow]{t('cmd.config.transaction_list.empty')}[/yellow]") return table = Table(title=t("table.transactions.title")) table.add_column(t("table.accounts_config.col.id"), style="dim") table.add_column(t("table.transactions.col.date")) table.add_column(t("table.accounts_config.col.name")) table.add_column(t("table.transactions.col.category")) table.add_column(t("table.transactions.col.amount"), justify="right") table.add_column(t("table.transactions.col.description")) for tx in model.transactions: account_name = _account_name(model, tx.account) table.add_row( str(tx.id)[:8], tx.date, account_name, tx.category, f"{tx.amount:,.2f}", tx.description, ) console.print(table) @app.command(name="transaction-remove") def transaction_remove( identifier: str = typer.Argument(..., help=t("cmd.config.transaction_remove.arg")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.transaction_remove.ok', id=identifier)}") else: console.print(f"[red]{t('cmd.config.transaction_remove.err', id=identifier)}[/red]") raise typer.Exit(1) # ── recurring ────────────────────────────────────────────────── @app.command() def recurring_add( amount: float = typer.Option(..., "--amount", "-m", help=t("cmd.config.recurring_add.opt.amount")), category: str = typer.Option("", "--category", "-c", help=t("cmd.config.recurring_add.opt.category")), frequency: str = typer.Option("monthly", "--frequency", "-f", help=t("cmd.config.recurring_add.opt.frequency")), start_date: str = typer.Option("", "--start", "-s", help=t("cmd.config.recurring_add.opt.start")), end_date: str = typer.Option("", "--end", "-e", help=t("cmd.config.recurring_add.opt.end")), ) -> 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) ok = t("global.ok") msg = t("cmd.config.recurring_add.ok", amount=abs(amount), frequency=frequency) console.print(f"[green]{ok}[/green] {msg}") @app.command(name="recurring-list") def recurring_list() -> None: """Список регулярных платежей""" model = _load_model() if not model.recurring: console.print(f"[yellow]{t('cmd.config.recurring_list.empty')}[/yellow]") return table = Table(title=t("table.recurring.title")) table.add_column(t("table.recurring.col.id"), style="dim") table.add_column(t("table.recurring.col.start")) table.add_column(t("table.recurring.col.end")) table.add_column(t("table.recurring.col.period")) table.add_column(t("table.recurring.col.amount"), justify="right") table.add_column(t("table.recurring.col.category")) 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=t("cmd.config.recurring_remove.arg")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.recurring_remove.ok', id=identifier)}") else: console.print(f"[red]{t('cmd.config.recurring_remove.err', id=identifier)}[/red]") raise typer.Exit(1) # ── asset ────────────────────────────────────────────────────── @app.command() def asset_add( name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.asset_add.opt.name")), value: float = typer.Option(0.0, "--value", "-v", help=t("cmd.config.asset_add.opt.value")), growth_rate: float = typer.Option(0.0, "--growth", "-g", help=t("cmd.config.asset_add.opt.growth")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.asset_add.ok', name=name, value=value)}") @app.command(name="asset-list") def asset_list() -> None: """Список активов""" model = _load_model() if not model.assets: console.print(f"[yellow]{t('cmd.config.asset_list.empty')}[/yellow]") return table = Table(title=t("table.assets.title")) table.add_column(t("table.assets.col.id"), style="dim") table.add_column(t("table.assets.col.name"), style="cyan") table.add_column(t("table.assets.col.value"), justify="right") table.add_column(t("table.assets.col.growth"), 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=t("cmd.config.asset_remove.arg")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.asset_remove.ok', id=identifier)}") else: console.print(f"[red]{t('cmd.config.asset_remove.err', id=identifier)}[/red]") raise typer.Exit(1) # ── liability ────────────────────────────────────────────────── @app.command() def liability_add( name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.liability_add.opt.name")), balance: float = typer.Option(0.0, "--balance", "-b", help=t("cmd.config.liability_add.opt.balance")), interest: float = typer.Option(0.0, "--interest", "-i", help=t("cmd.config.liability_add.opt.interest")), payment: float = typer.Option(0.0, "--payment", "-p", help=t("cmd.config.liability_add.opt.payment")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.liability_add.ok', name=name, balance=balance)}") @app.command(name="liability-list") def liability_list() -> None: """Список обязательств""" model = _load_model() if not model.liabilities: console.print(f"[yellow]{t('cmd.config.liability_list.empty')}[/yellow]") return table = Table(title=t("table.liabilities.title")) table.add_column(t("table.liabilities.col.id"), style="dim") table.add_column(t("table.liabilities.col.name"), style="cyan") table.add_column(t("table.liabilities.col.balance"), justify="right") table.add_column(t("table.liabilities.col.rate"), justify="right") table.add_column(t("table.liabilities.col.payment"), 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=t("cmd.config.liability_remove.arg")), ) -> 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]{t('global.ok')}[/green] {t('cmd.config.liability_remove.ok', id=identifier)}") else: console.print(f"[red]{t('cmd.config.liability_remove.err', id=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]