429 lines
17 KiB
Python
429 lines
17 KiB
Python
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]
|