From 181586d6205419c030c7f9ecdd5536f811d1f9f8 Mon Sep 17 00:00:00 2001 From: Roger Oriol Date: Sun, 28 Sep 2025 19:29:54 +0200 Subject: [PATCH] millora script budget --- AGENTS.md | 26 +++++ commands/budget | 211 ++++++++++++++++++++++++++++------------ ledger/budget.beancount | 39 ++------ 3 files changed, 180 insertions(+), 96 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..08a73f3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# AGENTS.md - Coding Agent Guidelines + +## Build/Test Commands +- **Check ledger**: `./commands/check` (validates beancount syntax) +- **Serve web UI**: `./commands/serve` (starts fava on port 8080) +- **Python tests**: `pytest` (single test: `pytest path/to/test.py::test_name`) +- **Install dependencies**: `uv sync` or `pip install -r requirements.txt` + +## Project Structure +- **Main ledger**: `ledger/main.beancount` (entry point) +- **Accounts**: `ledger/accounts.beancount` +- **Transactions**: `ledger/transactions/YYYY/MM.beancount` +- **Python plugins**: `ledger/plugins/` +- **Commands**: `commands/` (bash scripts) + +## Code Style & Conventions +- **Python**: Follow PEP 8, use type hints, snake_case naming +- **Beancount**: Use consistent account naming (Assets:Bank:Checking) +- **Imports**: Standard library first, third-party second, local imports last +- **Error handling**: Use namedtuples for custom errors in plugins +- **Documentation**: Clear docstrings for plugin functions + +## Key Dependencies +- beancount 2.3.6 (double-entry accounting) +- fava 1.26.2 (web interface) +- pytest 7.4.3 (testing) \ No newline at end of file diff --git a/commands/budget b/commands/budget index 4a23991..6c51f57 100755 --- a/commands/budget +++ b/commands/budget @@ -11,6 +11,7 @@ from tabulate import tabulate from decimal import Decimal from functools import reduce + class bcolors: HEADER = '\033[95m' OKBLUE = '\033[94m' @@ -22,79 +23,163 @@ class bcolors: BOLD = '\033[1m' UNDERLINE = '\033[4m' + def get_budget_entries(entries, period, start_date): - budgets = [] - for entry in entries: - if isinstance(entry, Custom) and entry.values[1].value == period and entry.date <= date.fromisoformat(start_date): - budgets.append({ "date": entry.date, "account": entry.values[0].value, "period": entry.values[1].value, "budget": entry.values[2].value }) - return budgets + budgets = [] + seen_accounts = set() + for entry in entries: + if isinstance(entry, Custom) and entry.values[1].value == period \ + and entry.date <= date.fromisoformat(start_date): + account = entry.values[0].value + if account not in seen_accounts: + seen_accounts.add(account) + budgets.append({ + "date": entry.date, + "account": account, + "period": entry.values[1].value, + "budget": entry.values[2].value + }) + return budgets + + +def get_equity_amounts(entries, options, period, start_date): + period_delta = relativedelta( + months=1) if period == "monthly" else relativedelta(years=1) + end_date = date.fromisoformat(start_date) + period_delta + equity_query = f"""SELECT account, sum(position) FROM +date >= {start_date} AND +date < {end_date.isoformat()} +WHERE account ~ \"Equity:(LloguerMiquel|FacturesUtilitatsMiquel)\"""" + rtypes, rrows = query.run_query( + entries, options, equity_query) + equity = {} + for row in rrows: + equity[row.account] = row.sum_position + return equity + def get_expenses(entries, options, period, start_date): - period_delta = relativedelta(months=1) if period == "monthly" else relativedelta(years=1) - end_date = date.fromisoformat(start_date) + period_delta - expenses_query = f"SELECT account, sum(position) FROM OPEN ON {start_date} CLOSE ON {end_date.isoformat()} WHERE account ~ \"Expenses\"" - rtypes, rrows = query.run_query( - entries, options, expenses_query) - expenses = {} - for row in rrows: - expenses[row.account] = row.sum_position - return expenses + period_delta = relativedelta( + months=1) if period == "monthly" else relativedelta(years=1) + end_date = date.fromisoformat(start_date) + period_delta + expenses_query = f"""SELECT account, sum(position) FROM +date >= {start_date} AND +date < {end_date.isoformat()} +WHERE account ~ \"Expenses\" OR account ~ \"Liabilities\"""" + rtypes, rrows = query.run_query( + entries, options, expenses_query) + expenses = {} + for row in rrows: + expenses[row.account] = row.sum_position + return expenses + + +def build_budget(budget_entries, expenses, equity_amounts, total_positive_expenses): + result = [] + for entry in budget_entries: + expense = Amount(Decimal(0), entry["budget"].currency) + expense_perc = 0 + total_perc = 0 + remaining = entry["budget"] + if entry["account"] in expenses: + expense = expenses[entry["account"]].get_only_position().units + + # Apply equity deductions for specific accounts + if entry["account"] == "Expenses:Lloguer" and "Equity:LloguerMiquel" in equity_amounts: + equity_amount = equity_amounts["Equity:LloguerMiquel"].get_only_position( + ) + expense = sub(expense, equity_amount.units) + elif entry["account"] == "Expenses:FacturesUtilitats" and "Equity:FacturesUtilitatsMiquel" in equity_amounts: + equity_amount = equity_amounts["Equity:FacturesUtilitatsMiquel"].get_only_position( + ) + expense = sub(expense, equity_amount.units) + + expense_perc = (expense.number / + entry["budget"].number) * 100 + # Calculate percentage of total positive expenses only + if total_positive_expenses.number > 0 and expense.number > 0: + total_perc = (expense.number / + total_positive_expenses.number) * 100 + remaining = sub(remaining, expense) + result.append({ + "Account": entry["account"], + "Budget": entry["budget"].to_string(), + "Expense": expense, + "Expense (%)": "{}{:,.2f}%{}".format( + bcolors.FAIL if expense_perc >= 100 else '', + expense_perc, bcolors.ENDC), + "Total (%)": "{:,.2f}%".format(total_perc), + "Remaining": remaining + }) + return result -def build_budget(budget_entries, expenses): - result = [] - for entry in budget_entries: - expense = Amount(Decimal(0), entry["budget"].currency) - expense_perc = 0 - remaining = entry["budget"] - if entry["account"] in expenses: - expense = expenses[entry["account"]].get_only_position() - expense_perc = (expense.units.number / entry["budget"].number) * 100 - remaining = sub(remaining, expense.units) - result.append({ - "Account": entry["account"], - "Budget": entry["budget"].to_string(), - "Expense": expense, - "Expense (%)": "{}{:,.2f}%{}".format(bcolors.FAIL if expense_perc >= 100 else '', expense_perc, bcolors.ENDC), - "Remaining": remaining - }) - return result def print_report(budget_report, period, start_date, budget_sum, expenses_sum): - print(f"Budget Report (period={period}, start_date={start_date})") - print(f"Budget: {budget_sum}") - print(f"{bcolors.FAIL if expenses_sum >= budget_sum else ''}Expenses: {expenses_sum}{bcolors.ENDC}") - headings = ['Account', 'Budget', 'Expense', '(%)', 'Remaining',] - print(tabulate(budget_report, headers="keys", numalign="right", floatfmt=".2f")) + print(f"Budget Report (period={period}, start_date={start_date})") + print(f"Budget: {budget_sum}") + print(f"{bcolors.FAIL if expenses_sum >= budget_sum else ''}Expenses: { + expenses_sum}{bcolors.ENDC}") + print(tabulate(budget_report, headers="keys", numalign="right", floatfmt=".2f")) + def main(): - parser = argparse.ArgumentParser(description='Generate budget report') - parser.add_argument('start_date', metavar='start_date', type=str, nargs=1, - help='Start date (end date will be one month after if monthly report or one year after if yearly report)') - parser.add_argument('-p', metavar='period', type=str, choices=["monthly", "yearly"], default="monthly", required=False, - help='Period (monthly or yearly)') + parser = argparse.ArgumentParser(description='Generate budget report') + parser.add_argument('start_date', metavar='start_date', type=str, nargs=1, + help='Start date (end date will be one month after if ' + 'monthly report or one year after if yearly report)') + parser.add_argument('-p', metavar='period', type=str, + choices=["monthly", "yearly"], default="monthly", + required=False, + help='Period (monthly or yearly)') - args = parser.parse_args() - start_date = args.start_date[0] - period = args.p + args = parser.parse_args() + start_date = args.start_date[0] + period = args.p - filename = "ledger/main.beancount" - entries, errors, options = loader.load_file(filename) + filename = "ledger/main.beancount" + entries, errors, options = loader.load_file(filename) - if errors: - printer.print_errors(errors) + if errors: + printer.print_errors(errors) - budget_entries = get_budget_entries(entries, period, start_date) - # TODO: Multiple currencies - budget_sum = reduce(lambda a, b: add(a, b["budget"]), budget_entries, Amount(Decimal(0), budget_entries[0]["budget"].currency)) - expenses = get_expenses(entries, options, period, start_date) - filtered_expenses = {} - for entry in budget_entries: - if entry["account"] in expenses: - filtered_expenses[entry["account"]] = expenses[entry["account"]] - expenses_sum = reduce(lambda a, b: add(a, b.get_only_position().units), - filtered_expenses.values(), - Amount(Decimal(0), budget_entries[0]["budget"].currency)) - budget_report = build_budget(budget_entries, expenses) - print_report(budget_report, period, start_date, budget_sum, expenses_sum) + budget_entries = get_budget_entries(entries, period, start_date) + # TODO: Multiple currencies + budget_sum = reduce( + lambda a, b: add(a, b["budget"]), + budget_entries, + Amount(Decimal(0), budget_entries[0]["budget"].currency) + ) + expenses = get_expenses(entries, options, period, start_date) + equity_amounts = get_equity_amounts(entries, options, period, start_date) + filtered_expenses = {} + for entry in budget_entries: + if entry["account"] in expenses: + filtered_expenses[entry["account"]] = expenses[entry["account"]] -main() \ No newline at end of file + # Calculate total of positive expenses only for percentage calculation + positive_expenses_sum = Amount( + Decimal(0), budget_entries[0]["budget"].currency) + for entry in budget_entries: + if entry["account"] in expenses: + expense = expenses[entry["account"]].get_only_position().units + + # Apply equity deductions for specific accounts (same logic as in build_budget) + if entry["account"] == "Expenses:Lloguer" and "Equity:LloguerMiquel" in equity_amounts: + equity_amount = equity_amounts["Equity:LloguerMiquel"].get_only_position( + ) + expense = sub(expense, equity_amount.units) + elif entry["account"] == "Expenses:FacturesUtilitats" and "Equity:FacturesUtilitatsMiquel" in equity_amounts: + equity_amount = equity_amounts["Equity:FacturesUtilitatsMiquel"].get_only_position( + ) + expense = sub(expense, equity_amount.units) + + if expense.number > 0: + positive_expenses_sum = add(positive_expenses_sum, expense) + + budget_report = build_budget( + budget_entries, expenses, equity_amounts, positive_expenses_sum) + print_report(budget_report, period, start_date, + budget_sum, positive_expenses_sum) + + +main() diff --git a/ledger/budget.beancount b/ledger/budget.beancount index 547198e..758fb84 100644 --- a/ledger/budget.beancount +++ b/ledger/budget.beancount @@ -1,10 +1,8 @@ ;; BUDGET 2025 ;; TOTAL: -;; Monthly: 1807.00 EUR -;; Yearly: 6782.65 EUR -;;;; 565.22 EUR/month -;; Total monthly: 2372.22 EUR -2025-01-01 custom "budget" Expenses:Lloguer "monthly" 600.00 EUR +;; Monthly: 3027.00 EUR +;; Yearly: 6849.65 EUR +2025-01-01 custom "budget" Expenses:Lloguer "monthly" 620.00 EUR 2025-01-01 custom "budget" Expenses:FacturesUtilitats "monthly" 80.00 EUR 2025-01-01 custom "budget" Expenses:Internet "monthly" 50.00 EUR 2025-01-01 custom "budget" Expenses:Gasolina "monthly" 50.00 EUR @@ -15,43 +13,18 @@ 2025-01-01 custom "budget" Expenses:Entreteniment "monthly" 50.00 EUR 2025-01-01 custom "budget" Expenses:MenjarFora "monthly" 290.00 EUR 2025-01-01 custom "budget" Expenses:Altres "monthly" 200.00 EUR +2025-01-01 custom "budget" Liabilities:Credit:Renta4:PolissaCredit "monthly" 1200 EUR 2025-01-01 custom "budget" Expenses:Roba "yearly" 1000 EUR 2025-01-01 custom "budget" Expenses:MantenimentCotxe "yearly" 800.00 EUR 2025-01-01 custom "budget" Expenses:Educació "yearly" 150.00 EUR 2025-01-01 custom "budget" Expenses:Medic "yearly" 650.00 EUR 2025-01-01 custom "budget" Expenses:Vacances "yearly" 2500.00 EUR -2025-01-01 custom "budget" Expenses:Taxes:ImpostCirculacio "yearly" 54.00 EUR +2025-01-01 custom "budget" Expenses:Taxes:ImpostCirculacio "yearly" 66.00 EUR 2025-01-01 custom "budget" Expenses:NintendoOnline "yearly" 35.00 EUR 2025-01-01 custom "budget" Expenses:AmazonPrime "yearly" 50.00 EUR -2025-01-01 custom "budget" Expenses:Insurance:Cotxe "yearly" 585.00 EUR +2025-01-01 custom "budget" Expenses:Insurance:Cotxe "yearly" 640.00 EUR 2025-01-01 custom "budget" Expenses:CarnetJove "yearly" 8.65 EUR 2025-01-01 custom "budget" Expenses:Gimnàs "yearly" 800.00 EUR 2025-01-01 custom "budget" Expenses:MarcaPersonal "yearly" 150.00 EUR -;; BUDGET 2024 - -2024-01-01 custom "budget" Expenses:Lloguer "monthly" 600.00 EUR -2024-01-01 custom "budget" Expenses:FacturesUtilitats "monthly" 80.00 EUR -2024-01-01 custom "budget" Expenses:Internet "monthly" 50.00 EUR -2024-01-01 custom "budget" Expenses:Gasolina "monthly" 50.00 EUR -2024-01-01 custom "budget" Expenses:Perruqueria "monthly" 16.00 EUR -2024-01-01 custom "budget" Expenses:Supermercat "monthly" 180.00 EUR -2024-01-01 custom "budget" Expenses:Parking "monthly" 122.09 EUR -2024-01-01 custom "budget" Expenses:Mobilitat "monthly" 125.00 EUR -2024-01-01 custom "budget" Expenses:Entreteniment "monthly" 60.00 EUR -2024-01-01 custom "budget" Expenses:MenjarFora "monthly" 280.00 EUR -2024-01-01 custom "budget" Expenses:Altres "monthly" 150.00 EUR - -2024-01-01 custom "budget" Expenses:Roba "yearly" 1000 EUR -2024-01-01 custom "budget" Expenses:MantenimentCotxe "yearly" 180.00 EUR -2024-01-01 custom "budget" Expenses:Educació "yearly" 200.00 EUR -2024-01-01 custom "budget" Expenses:Medic "yearly" 800.00 EUR -2024-01-01 custom "budget" Expenses:Vacances "yearly" 2000.00 EUR -2024-01-01 custom "budget" Expenses:Taxes:ImpostCirculacio "yearly" 55.00 EUR -2024-01-01 custom "budget" Expenses:NintendoOnline "yearly" 35.00 EUR -2024-01-01 custom "budget" Expenses:AmazonPrime "yearly" 50.00 EUR -2024-01-01 custom "budget" Expenses:Insurance:Cotxe "yearly" 520.00 EUR -2024-01-01 custom "budget" Expenses:CarnetJove "yearly" 8.65 EUR -2024-01-01 custom "budget" Expenses:Gimnàs "yearly" 800.00 EUR -2024-01-01 custom "budget" Expenses:MarcaPersonal "yearly" 200.00 EUR