Skip to content

Commit 98cba2f

Browse files
wesmclaude
andauthored
Display currency symbol in column header, do not put currency symbols in amounts, support non-USD YNAB plans (#24)
This also adjusts some column display widths for better (subjective) readability (eventually these can be made configurable via config.yaml). --------- Co-authored-by: Claude <[email protected]>
1 parent d2e1eb4 commit 98cba2f

File tree

9 files changed

+146
-68
lines changed

9 files changed

+146
-68
lines changed

moneyflow/app_controller.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,16 @@ def _get_display_labels(self) -> dict:
136136
def _get_column_config(self) -> dict:
137137
"""Get column configuration from backend, with safe fallback to defaults."""
138138
try:
139-
return self.data_manager.mm.get_column_config()
139+
config = self.data_manager.mm.get_column_config()
140+
# Add currency symbol to config
141+
config["currency_symbol"] = self.data_manager.mm.get_currency_symbol()
142+
return config
140143
except (AttributeError, Exception):
141144
# Fallback to default widths if backend doesn't support it
142145
return {
143-
"merchant_width_pct": 25,
144-
"account_width_pct": 30,
146+
"merchant_width_pct": 40,
147+
"account_width_pct": 40,
148+
"currency_symbol": "$",
145149
}
146150

147151
def refresh_view(self, force_rebuild: bool = True) -> None:

moneyflow/backend_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class BackendConfig:
1919
# Field display names
2020
merchant_field_name: str = "Merchant" # Can be "Item" for Amazon
2121

22+
# Currency symbol (displayed in column headers)
23+
currency_symbol: str = "$" # Default to USD
24+
2225
# Available grouping modes (in order for cycling with 'g' key)
2326
grouping_modes: list[str] = field(
2427
default_factory=lambda: ["merchant", "category", "group", "account"]

moneyflow/backends/amazon.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,10 @@ def get_column_config(self) -> Dict[str, Any]:
154154
155155
Returns:
156156
Dictionary with column width percentages:
157-
- merchant_width_pct: 33 (30% wider than default 25)
158-
- account_width_pct: 30 (wider for Order IDs)
157+
- merchant_width_pct: 60 (wider for Item Names)
158+
- account_width_pct: 30 (Order IDs are small)
159159
"""
160-
return {
161-
"merchant_width_pct": 33, # 30% wider than default (25 * 1.3 ≈ 33)
162-
"account_width_pct": 30, # Wider for Order IDs
163-
}
160+
return {"merchant_width_pct": 60, "account_width_pct": 30}
164161

165162
async def login(
166163
self,

moneyflow/backends/base.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,29 @@ def get_column_config(self) -> Dict[str, Any]:
210210
211211
Example:
212212
>>> backend.get_column_config()
213-
{'merchant_width_pct': 25, 'account_width_pct': 30}
213+
{'merchant_width_pct': 40, 'account_width_pct': 40}
214214
"""
215215
return {
216-
"merchant_width_pct": 25, # Default 25% width
217-
"account_width_pct": 30, # Default 30% width (wider for long account names)
216+
"merchant_width_pct": 40, # Default 40% width
217+
"account_width_pct": 40, # Default 40% width
218218
}
219219

220+
def get_currency_symbol(self) -> str:
221+
"""
222+
Get the currency symbol for this backend.
223+
224+
Returns currency symbol to display in column headers (e.g., "$", "€", "£").
225+
Defaults to "$" (USD). Backends can override to fetch from API if available.
226+
227+
Returns:
228+
Currency symbol as string (e.g., "$", "€", "£")
229+
230+
Example:
231+
>>> backend.get_currency_symbol()
232+
'$'
233+
"""
234+
return "$" # Default to USD
235+
220236
def delete_session(self) -> None:
221237
"""
222238
Delete saved session data.

moneyflow/backends/ynab.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class YNABBackend(FinanceBackend):
2121
def __init__(self):
2222
"""Initialize the YNAB backend."""
2323
self.client = YNABClient()
24+
self._currency_symbol: Optional[str] = None
2425

2526
async def login(
2627
self,
@@ -152,6 +153,15 @@ async def get_all_merchants(self) -> List[str]:
152153
"""
153154
return self.client.get_all_merchants()
154155

156+
def get_currency_symbol(self) -> str:
157+
"""
158+
Get the currency symbol from YNAB budget settings.
159+
160+
Returns:
161+
Currency symbol (e.g., "$", "€", "£") from budget's currency_format
162+
"""
163+
return self.client.currency_symbol
164+
155165
def clear_auth(self) -> None:
156166
"""
157167
Clear all authentication state and close the API client.

moneyflow/formatters.py

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -57,28 +57,30 @@ class ViewPresenter:
5757
@staticmethod
5858
def format_amount(amount: float, for_table: bool = False) -> Union[str, Text]:
5959
"""
60-
Format dollar amount with sign outside dollar sign.
60+
Format amount with sign and thousands separators.
61+
62+
Currency symbol is shown in column header, not in cells, to reduce visual noise.
6163
6264
Args:
63-
amount: The dollar amount to format
65+
amount: The amount to format
6466
for_table: If True, return Rich Text with right justification for tables
6567
6668
Returns:
67-
Formatted string like "-$1,234.56" or "+$5,000.00"
69+
Formatted string like "-1,234.56" or "+5,000.00" (no currency symbol)
6870
If for_table=True, returns Rich Text object with right justification
6971
Positive amounts (credits) are styled in green
7072
7173
Examples:
7274
>>> ViewPresenter.format_amount(-1234.56)
73-
'-$1,234.56'
75+
'-1,234.56'
7476
>>> ViewPresenter.format_amount(5000.00)
75-
'+$5,000.00'
77+
'+5,000.00'
7678
>>> ViewPresenter.format_amount(0.00)
77-
'+$0.00'
79+
'+0.00'
7880
"""
7981
sign = "-" if amount < 0 else "+"
8082
abs_amount = abs(amount)
81-
formatted = f"{sign}${abs_amount:,.2f}"
83+
formatted = f"{sign}{abs_amount:,.2f}"
8284

8385
if for_table:
8486
# Color positive amounts (credits) green for visual distinction
@@ -160,7 +162,7 @@ def prepare_aggregation_columns(
160162
group_by_field: The field to group by
161163
sort_by: Current sort mode
162164
sort_direction: Current sort direction
163-
column_config: Optional backend-specific column width config
165+
column_config: Backend-specific config (widths, currency_symbol)
164166
display_labels: Optional backend-specific display labels
165167
166168
Returns:
@@ -177,7 +179,8 @@ def prepare_aggregation_columns(
177179
"""
178180
# Use defaults if not provided
179181
if column_config is None:
180-
column_config = {"merchant_width_pct": 35, "account_width_pct": 30}
182+
column_config = {}
183+
181184
if display_labels is None:
182185
display_labels = {"merchant": "Merchant", "account": "Account", "accounts": "Accounts"}
183186

@@ -190,13 +193,17 @@ def prepare_aggregation_columns(
190193
}
191194
name_label = name_labels[group_by_field]
192195

196+
# Extract currency symbol from config (defaults to $ if not provided)
197+
currency_symbol = column_config.get("currency_symbol", "$") if column_config else "$"
198+
199+
# Default name width
200+
name_width = 40
201+
193202
# Get column width based on field type
194203
if group_by_field == "merchant":
195-
name_width = column_config.get("merchant_width_pct", 35) # Wider for 150 char terminals
204+
name_width = column_config.get("merchant_width_pct", name_width)
196205
elif group_by_field == "account":
197-
name_width = column_config.get("account_width_pct", 30)
198-
else:
199-
name_width = 40 # Default for category/group
206+
name_width = column_config.get("account_width_pct", name_width)
200207

201208
# Map aggregation field to sort mode
202209
field_to_sort_mode: dict[AggregationField, SortMode] = {
@@ -214,8 +221,8 @@ def prepare_aggregation_columns(
214221
amount_arrow = ViewPresenter.get_sort_arrow(sort_by, sort_direction, SortMode.AMOUNT)
215222

216223
# Build column specs
217-
# Total column label - right-aligned to match the values
218-
total_label = f"Total {amount_arrow}".strip()
224+
# Total column label - right-aligned to match the values, includes currency
225+
total_label = f"Total ({currency_symbol}) {amount_arrow}".strip()
219226
total_label_text = Text(total_label, justify="right")
220227

221228
columns: list[ColumnSpec] = [
@@ -389,7 +396,7 @@ def prepare_transaction_columns(
389396
Args:
390397
sort_by: Current sort mode
391398
sort_direction: Current sort direction
392-
column_config: Optional backend-specific column width config
399+
column_config: Backend-specific config (widths, currency_symbol)
393400
display_labels: Optional backend-specific display labels
394401
395402
Returns:
@@ -406,10 +413,17 @@ def prepare_transaction_columns(
406413
"""
407414
# Use defaults if not provided
408415
if column_config is None:
409-
column_config = {"merchant_width_pct": 25, "account_width_pct": 30}
416+
column_config = {
417+
"merchant_width_pct": 25,
418+
"account_width_pct": 30,
419+
"currency_symbol": "$",
420+
}
410421
if display_labels is None:
411422
display_labels = {"merchant": "Merchant", "account": "Account", "accounts": "Accounts"}
412423

424+
# Extract currency symbol from config
425+
currency_symbol = column_config.get("currency_symbol", "$")
426+
413427
# Get arrows for each field
414428
date_arrow = ViewPresenter.get_sort_arrow(sort_by, sort_direction, SortMode.DATE)
415429
merchant_arrow = ViewPresenter.get_sort_arrow(sort_by, sort_direction, SortMode.MERCHANT)
@@ -425,8 +439,8 @@ def prepare_transaction_columns(
425439
merchant_width = column_config.get("merchant_width_pct", 25)
426440
account_width = column_config.get("account_width_pct", 30)
427441

428-
# Amount column label - right-aligned to match the values
429-
amount_label = f"Amount {amount_arrow}".strip()
442+
# Amount column label - right-aligned to match the values, includes currency
443+
amount_label = f"Amount ({currency_symbol}) {amount_arrow}".strip()
430444
amount_label_text = Text(amount_label, justify="right")
431445

432446
columns: list[ColumnSpec] = [

moneyflow/ynab_client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def __init__(self):
2222
self.api_client: Optional[ynab.ApiClient] = None
2323
self.access_token: Optional[str] = None
2424
self.budget_id: Optional[str] = None
25+
self.currency_symbol: str = "$" # Default to USD, updated during login
2526
self._transaction_cache: Optional[List[Dict[str, Any]]] = None
2627
self._cache_params: Optional[Dict[str, Any]] = None
2728

@@ -49,7 +50,12 @@ def login(self, access_token: str) -> None:
4950
raise ValueError("No budgets found in YNAB account")
5051

5152
if not self.budget_id:
52-
self.budget_id = budgets_response.data.budgets[0].id
53+
budget = budgets_response.data.budgets[0]
54+
self.budget_id = budget.id
55+
56+
# Fetch currency symbol from budget settings
57+
if budget.currency_format and budget.currency_format.currency_symbol:
58+
self.currency_symbol = budget.currency_format.currency_symbol
5359

5460
def get_transactions(
5561
self,

tests/conftest.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
"""
77

88
from datetime import date
9+
from typing import Union
910

1011
import polars as pl
1112
import pytest
13+
from rich.text import Text
1214

1315
from moneyflow.data_manager import DataManager
16+
from moneyflow.formatters import ViewPresenter
1417
from moneyflow.state import AppState
1518
from tests.mock_backend import MockMonarchMoney
1619

@@ -19,6 +22,31 @@
1922
# ============================================================================
2023

2124

25+
def expected_amount(amount: float, for_table: bool = False) -> Union[str, Text]:
26+
"""
27+
Format expected amount for test assertions.
28+
29+
Centralizes amount formatting expectations so tests are easy to update
30+
when formatting logic changes. Currency symbol is NOT included in cell
31+
values (it's shown in column header instead).
32+
33+
Args:
34+
amount: The amount to format
35+
for_table: If True, returns Rich Text object
36+
37+
Returns:
38+
Expected formatted string (e.g., "-1,234.56", "+5,000.00")
39+
Or Rich Text object if for_table=True
40+
41+
Examples:
42+
>>> expected_amount(-1234.56)
43+
'-1,234.56'
44+
>>> expected_amount(5000.00)
45+
'+5,000.00'
46+
"""
47+
return ViewPresenter.format_amount(amount, for_table=for_table)
48+
49+
2250
def save_test_credentials(
2351
credential_manager,
2452
email: str = "[email protected]",

0 commit comments

Comments
 (0)