Skip to content

Commit 6209b40

Browse files
authored
feat(budget): manage your daily expenses (N26 only) (#135)
* feat(budget): manage your daily expenses! (N26 only) * refactor(budget): move table render in separate file * feat(budget): interactively set expense fields * fix: remove default imports to budget * refactor: rearrange budget methods to fetch/render/review * fix(budget): exit review gracefully on CTRL+C * feat(budget): add command line & Assistant options * refactor: inherit N26 from SourceBaseExpense * build: add budget dependencies * build: add pytz dependency * chore: update readme with screenshots * feat: add budget summary console panel * feat: add monthly mean to summary * refactor(n26): prompt user credentials like Finary * test: disable finary tests for now * feat: hide expense table when empty
1 parent 8b9f7ee commit 6209b40

21 files changed

+1553
-137
lines changed

README.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<br>
1919
</div>
2020

21-
Finalynx is your "Finary Assistant", a command-line tool to organize your investments portfolio and get automated monthly investment recommendations based on your future life goals.
21+
Finalynx is your "Finary Assistant", a command-line (and experimental web dashboard) tool to organize your investments portfolio and get automated monthly investment recommendations based on your future life goals.
2222
This tool synchronizes with your [Finary](https://finary.com/) account to show real-time investment values.
2323

2424
Don't have Finary yet? You can sign up using my [referral link](https://finary.com/referral/f8d349c922d1e1c8f0d2) 🌹 (or through the [default](https://finary.com/signup) page).
@@ -29,6 +29,27 @@ Don't have Finary yet? You can sign up using my [referral link](https://finary.c
2929
<img src="https://raw.githubusercontent.com/MadeInPierre/finalynx/main/docs/_static/screenshot_demo_frameless.png" width="600" />
3030
</p>
3131

32+
<details>
33+
<summary>
34+
<div align="center">
35+
<strong>[Click]</strong> Additional screenshots 📸
36+
</div>
37+
</summary>
38+
39+
| Recommendations | Web dashboard |
40+
|---|---|
41+
| <img src="https://raw.githubusercontent.com/MadeInPierre/finalynx/main/docs/_static/screenshot_recommendations.png" width="600" /> | <img src="https://raw.githubusercontent.com/MadeInPierre/finalynx/main/docs/_static/screenshot_dashboard.png" width="600" /> |
42+
43+
Finalynx also includes a daily budget manager to classify your expenses and show monthly & yearly statistics:
44+
45+
<img src="https://raw.githubusercontent.com/MadeInPierre/finalynx/main/docs/_static/budget.png"/>
46+
47+
<img src="https://raw.githubusercontent.com/MadeInPierre/finalynx/main/docs/_static/budget_review.png"/>
48+
49+
Statistics and visualizations will be added soon!
50+
51+
</details>
52+
3253
## ✨ Features
3354

3455
1. **✅ Portfolio:** Organize your assets, set targets, and sync with your Finary account.
@@ -39,15 +60,6 @@ Don't have Finary yet? You can sign up using my [referral link](https://finary.c
3960

4061
You can check the [current development status](https://github.com/users/MadeInPierre/projects/4). Contributions are warmly welcome!
4162

42-
<details>
43-
<summary><strong>[Click]</strong> Additional screenshots 📸</summary>
44-
45-
| Recommendations | Web dashboard |
46-
|---|---|
47-
| <img src="https://raw.githubusercontent.com/MadeInPierre/finalynx/main/docs/_static/screenshot_recommendations.png" width="600" /> | <img src="https://raw.githubusercontent.com/MadeInPierre/finalynx/main/docs/_static/screenshot_dashboard.png" width="600" /> |
48-
49-
</details>
50-
5163
## 🚀 Installation
5264
If you don't plan on touching the code, simply run (with python >=3.10 and pip installed):
5365
```sh

docs/_static/budget.png

108 KB
Loading

docs/_static/budget_review.png

92.5 KB
Loading

finalynx/assistant.py

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import os
33
from datetime import date
4+
from typing import Any
45
from typing import List
56
from typing import Optional
67
from typing import Tuple
@@ -11,10 +12,11 @@
1112
from finalynx import Dashboard
1213
from finalynx import Fetch
1314
from finalynx import Portfolio
15+
from finalynx.budget.budget import Budget
1416
from finalynx.config import get_active_theme as TH
1517
from finalynx.config import set_active_theme
1618
from finalynx.copilot.recommendations import render_recommendations
17-
from finalynx.fetch.source_base import SourceBase
19+
from finalynx.fetch.source_base_line import SourceBaseLine
1820
from finalynx.fetch.source_finary import SourceFinary
1921
from finalynx.portfolio.bucket import Bucket
2022
from finalynx.portfolio.envelope import Envelope
@@ -79,6 +81,8 @@ def __init__(
7981
active_sources: Optional[List[str]] = None,
8082
theme: Optional[finalynx.theme.Theme] = None,
8183
sidecars: Optional[List[Sidecar]] = None,
84+
check_budget: bool = False,
85+
interactive: bool = False,
8286
ignore_argv: bool = False,
8387
):
8488
self.portfolio = portfolio
@@ -98,6 +102,8 @@ def __init__(
98102
self.export_dir = export_dir
99103
self.active_sources = active_sources if active_sources else ["finary"]
100104
self.sidecars = sidecars if sidecars else []
105+
self.check_budget = check_budget
106+
self.interactive = interactive
101107

102108
# Set the global color theme if specified
103109
if theme:
@@ -109,8 +115,9 @@ def __init__(
109115

110116
# Create the fetching manager instance
111117
self._fetch = Fetch(self.portfolio, self.clear_cache, self.ignore_orphans)
118+
self.budget = Budget()
112119

113-
def add_source(self, source: SourceBase) -> None:
120+
def add_source(self, source: SourceBaseLine) -> None:
114121
"""Register a source, either defined in your own config or from the available Finalynx sources
115122
using `from finalynx.fetch.source_any import SourceAny`."""
116123
self._fetch.add_source(source)
@@ -135,6 +142,12 @@ def _parse_args(self) -> None:
135142
self.show_data = True
136143
if args["dashboard"]:
137144
self.launch_dashboard = True
145+
if args["budget"]:
146+
self.check_budget = True
147+
if args["--interactive"]:
148+
if not self.check_budget:
149+
console.log("[red][bold]Error:[/] --interactive can only be used with budget, ignoring.")
150+
self.interactive = True
138151
if args["--format"]:
139152
self.output_format = args["--format"]
140153
if args["--sidecar"]:
@@ -184,9 +197,17 @@ def run(self) -> None:
184197
# Fetch from the online sources and process the portfolio
185198
fetched_tree = self.initialize()
186199

200+
# Fetch the budget from N26 if enabled
201+
if self.check_budget:
202+
fetched_tree.add(self.budget.fetch(self.clear_cache, self.force_signin))
203+
console.log("[bold]Tip:[/] run again with -I or --interactive review the expenses 👀")
204+
187205
# Render the console elements
188206
main_frame = self.render_mainframe()
189207
panels = self.render_panels()
208+
renders: List[Any] = [main_frame, panels]
209+
if self.check_budget:
210+
renders.append(self.budget.render_expenses())
190211

191212
# Save the current portfolio to a file. Useful for statistics later
192213
if self.enable_export:
@@ -197,13 +218,12 @@ def run(self) -> None:
197218
console.print(Panel(fetched_tree, title="Fetched data"))
198219

199220
# Display the entire portfolio and associated recommendations
200-
console.print(
201-
"\n\n",
202-
main_frame,
203-
"\n\n",
204-
panels,
205-
"\n",
206-
)
221+
for render in renders:
222+
console.print("\n\n", render)
223+
224+
# Interactive review of the budget expenses if enabled
225+
if self.check_budget and self.interactive:
226+
self.budget.interactive_review()
207227

208228
# Host a local webserver with the running dashboard
209229
if self.launch_dashboard:
@@ -250,25 +270,20 @@ def render_mainframe(self) -> Columns:
250270
def render_panels(self) -> Columns:
251271
"""Renders the default set of panels used in the default console view when calling run()."""
252272

273+
def panel(title: str, content: Any) -> Panel:
274+
return Panel(content, title=title, padding=(1, 2), expand=False, border_style=TH().PANEL)
275+
253276
# Final set of results to be displayed
254277
panels: List[ConsoleRenderable] = [
255278
Text(" "),
256-
Panel(
257-
render_recommendations(self.portfolio, self.envelopes),
258-
title="Recommendations",
259-
padding=(1, 2),
260-
expand=False,
261-
border_style=TH().PANEL,
262-
),
263-
Panel(
264-
self.render_performance_report(),
265-
title="Performance",
266-
padding=(1, 2),
267-
expand=False,
268-
border_style=TH().PANEL,
269-
),
279+
panel("Recommendations", render_recommendations(self.portfolio, self.envelopes)),
280+
panel("Performance", self.render_performance_report()),
270281
]
271282

283+
# Add the budget panel if enabled
284+
if self.check_budget:
285+
panels.append(panel("Budget", self.budget.render_summary()))
286+
272287
return Columns(panels, padding=(2, 2))
273288

274289
def render_performance_report(self) -> Tree:

finalynx/budget/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# flake8: noqa
2+
from .budget import Budget
3+
from .expense import Constraint
4+
from .expense import Expense
5+
from .expense import Period
6+
from .expense import Status

finalynx/budget/_render.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
This module contains the logic for displaying a table of expenses. It is only used
3+
internally by the `Budget` class.
4+
"""
5+
from datetime import datetime
6+
from typing import List
7+
from typing import Optional
8+
9+
from rich import box
10+
from rich.table import Table
11+
12+
from ..config import get_active_theme as TH
13+
from .expense import Constraint
14+
from .expense import Expense
15+
from .expense import Period
16+
from .expense import Status
17+
18+
19+
def _render_expenses_table(
20+
expenses: List[Expense],
21+
title: str = "",
22+
caption: str = "",
23+
focus: Optional[int] = None,
24+
) -> Table:
25+
"""Generate a rich console table from a list of expenses."""
26+
table = Table(title=title, box=box.MINIMAL, caption=caption, caption_justify="right", expand=True)
27+
table.add_column("#", justify="center", style=TH().TEXT)
28+
table.add_column("Date", justify="left", style="orange1")
29+
table.add_column("Time", justify="left", style="orange1")
30+
table.add_column("Amount", justify="right", style="red")
31+
table.add_column("Merchant", justify="left", style="cyan")
32+
table.add_column("Category", justify="left", style=TH().HINT)
33+
table.add_column("Status", justify="left", style=TH().TEXT)
34+
table.add_column("I Paid", justify="right", style="red")
35+
table.add_column("Payback", justify="left", style=TH().TEXT)
36+
table.add_column("Type", justify="left", style=TH().TEXT)
37+
table.add_column("Period", justify="left", style=TH().TEXT)
38+
table.add_column("Comment", justify="left", style=TH().HINT)
39+
40+
for i, t in enumerate(expenses):
41+
# Format date and time
42+
ts_date = t.as_datetime()
43+
day_name_str = ts_date.strftime("%A")[:3]
44+
day_nth_str = ts_date.strftime("%d")
45+
month_str = ts_date.strftime("%B")
46+
time_str = ts_date.strftime("%H:%M")
47+
date_str = f"{day_name_str} {day_nth_str} {month_str[:3]}"
48+
49+
# Format amount with colors
50+
amount_color = "" if t.amount < 0 else "[green]"
51+
amount_str = f"{amount_color}{t.amount:.2f} €"
52+
53+
# Format i_paid with colors just as amount
54+
i_paid_color = "[green]" if t.i_paid is not None and t.i_paid > 0 else ""
55+
i_paid_str = f"{i_paid_color}{t.i_paid:.2f} €" if t.i_paid is not None else ""
56+
57+
# Add a separator between months
58+
separator = False
59+
if len(expenses) > 1 and i < len(expenses) - 1:
60+
np1_month_str = datetime.utcfromtimestamp(int(expenses[i + 1].timestamp) / 1000).strftime("%B")
61+
separator = bool(month_str != np1_month_str)
62+
63+
# If focus is set, highlight the corresponding row and dim the others
64+
if focus is not None:
65+
style = "bold" if i == focus else "dim"
66+
else:
67+
style = "dim" if "(transfer)" in t.merchant_name else "none"
68+
69+
# Add the row to the table
70+
table.add_row(
71+
str(t.cell_number),
72+
date_str,
73+
time_str,
74+
amount_str,
75+
t.merchant_name,
76+
t.merchant_category[:30],
77+
str(t.status.value).capitalize() if t.status != Status.UNKNOWN else "",
78+
i_paid_str,
79+
t.payback,
80+
str(t.constraint.value).capitalize() if t.constraint != Constraint.UNKNOWN else "",
81+
str(t.period.value).capitalize() if t.period != Period.UNKNOWN else "",
82+
t.comment,
83+
style=style,
84+
end_section=separator,
85+
)
86+
87+
return table

0 commit comments

Comments
 (0)