Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions moneyflow/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def __init__(
force_refresh: bool = False,
backend: Optional[Any] = None,
config: Optional[Any] = None,
config_dir: Optional[str] = None,
):
super().__init__()
self.demo_mode = demo_mode
Expand Down Expand Up @@ -208,6 +209,7 @@ def __init__(
self.cache_manager = None # Will be set if caching is enabled
self.cache_year_filter = None # Track what filters the cache uses
self.cache_since_filter = None
self.config_dir = config_dir # Custom config directory (None = default ~/.moneyflow)
# Controller will be initialized after data_manager is ready
self.controller: Optional[AppController] = None

Expand Down Expand Up @@ -271,7 +273,10 @@ def _initialize_managers(self):
"""Initialize data manager, cache manager, and controller."""
# In demo mode, use a temp directory for merchant cache (don't pollute ~/.moneyflow)
merchant_cache_dir = "" if not self.demo_mode else "/tmp/moneyflow_demo"
self.data_manager = DataManager(self.backend, merchant_cache_dir=merchant_cache_dir)
# config_dir is already a string (or None), DataManager accepts Optional[str]
self.data_manager = DataManager(
self.backend, merchant_cache_dir=merchant_cache_dir, config_dir=self.config_dir
)

# Initialize cache manager only if user requested caching
if self.cache_path is not None:
Expand Down Expand Up @@ -330,7 +335,9 @@ async def _handle_credentials(self):
dict: Credentials dict or None if user exits
"""

cred_manager = CredentialManager()
# Convert config_dir string to Path if provided
config_path = Path(self.config_dir) if self.config_dir else None
cred_manager = CredentialManager(config_dir=config_path)

logger = get_logger(__name__)
logger.debug(f"Credentials exist: {cred_manager.credentials_exist()}")
Expand Down Expand Up @@ -445,8 +452,13 @@ async def login_operation():
# All retries exhausted
logger.error(f"Login failed after all retries: {e}", exc_info=True)
error_msg = f"Login failed: {e}"
log_path = (
f"{self.config_dir}/moneyflow.log"
if self.config_dir
else "~/.moneyflow/moneyflow.log"
)
loading_status.update(
f"❌ {error_msg}\n\nCheck ~/.moneyflow/moneyflow.log for details.\n\nPress 'q' to quit"
f"❌ {error_msg}\n\nCheck {log_path} for details.\n\nPress 'q' to quit"
)
return False

Expand Down Expand Up @@ -613,8 +625,13 @@ async def fetch_operation():
return None
except Exception as e:
logger.error(f"Data fetch failed after all retries: {e}", exc_info=True)
log_path = (
f"{self.config_dir}/moneyflow.log"
if self.config_dir
else "~/.moneyflow/moneyflow.log"
)
loading_status.update(
f"❌ Failed to load data: {e}\n\nCheck ~/.moneyflow/moneyflow.log for details.\n\nPress 'q' to quit"
f"❌ Failed to load data: {e}\n\nCheck {log_path} for details.\n\nPress 'q' to quit"
)
return None

Expand Down Expand Up @@ -1790,7 +1807,7 @@ def main():
args = parser.parse_args()

# Initialize logging (file only - Textual swallows console output anyway)
logger = setup_logging(console_output=False)
logger = setup_logging(console_output=False, config_dir=None)
logger.info("Starting moneyflow application")

# Determine start year or date range
Expand Down Expand Up @@ -1842,6 +1859,7 @@ def launch_monarch_mode(
cache: Optional[str] = None,
refresh: bool = False,
demo: bool = False,
config_dir: Optional[str] = None,
) -> None:
"""
Launch moneyflow with default backend (Monarch Money).
Expand All @@ -1853,12 +1871,15 @@ def launch_monarch_mode(
cache: Cache directory path (enables caching if provided, None to disable)
refresh: Force refresh from API, skip cache
demo: Run in demo mode with sample data
config_dir: Config directory (None = ~/.moneyflow)
"""
from datetime import date as date_type

# Initialize logging
logger = setup_logging(console_output=False)
logger = setup_logging(console_output=False, config_dir=config_dir)
logger.info("Starting moneyflow with Monarch Money backend")
if config_dir:
logger.info(f"Using custom config directory: {config_dir}")

# Determine start year or date range
start_year = None
Expand All @@ -1880,6 +1901,7 @@ def launch_monarch_mode(
demo_mode=demo,
cache_path=cache,
force_refresh=refresh,
config_dir=config_dir,
)
app.run()
except Exception:
Expand Down Expand Up @@ -1907,7 +1929,7 @@ def launch_amazon_mode(db_path: Optional[str] = None) -> None:
from moneyflow.backends.amazon import AmazonBackend

# Initialize logging
logger = setup_logging(console_output=False)
logger = setup_logging(console_output=False, config_dir=None)
logger.info("Starting moneyflow in Amazon mode")

try:
Expand Down
16 changes: 13 additions & 3 deletions moneyflow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@
@click.option(
"--demo", is_flag=True, help="Run in demo mode with sample data (no authentication required)"
)
@click.option(
"--config-dir",
type=click.Path(),
default=None,
help="Config directory (default: ~/.moneyflow). Useful for testing with isolated configs.",
)
@click.pass_context
def cli(ctx, year, since, mtd, cache, refresh, demo):
def cli(ctx, year, since, mtd, cache, refresh, demo, config_dir):
"""moneyflow - Terminal UI for personal finance management.

Run with no arguments to launch the default backend (Monarch Money).
Expand All @@ -51,8 +57,11 @@ def cli(ctx, year, since, mtd, cache, refresh, demo):
# Launch default backend (Monarch Money)
from moneyflow.app import launch_monarch_mode

# Convert cache flag to path (None if not enabled, default path if enabled)
cache_path = "~/.moneyflow/cache" if cache else None
# Convert cache flag to path (None if not enabled, respect config_dir if enabled)
if cache:
cache_path = f"{config_dir}/cache" if config_dir else "~/.moneyflow/cache"
else:
cache_path = None

launch_monarch_mode(
year=year,
Expand All @@ -61,6 +70,7 @@ def cli(ctx, year, since, mtd, cache, refresh, demo):
cache=cache_path,
refresh=refresh,
demo=demo,
config_dir=config_dir,
)


Expand Down
3 changes: 2 additions & 1 deletion moneyflow/data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ def __init__(

# Merchant cache setup
if not merchant_cache_dir:
merchant_cache_dir = str(Path.home() / ".moneyflow")
# Use config_dir if available, otherwise default to ~/.moneyflow
merchant_cache_dir = self.config_dir if self.config_dir else str(Path.home() / ".moneyflow")
self.merchant_cache_dir = Path(merchant_cache_dir)
self.merchant_cache_dir.mkdir(parents=True, exist_ok=True)
self.merchant_cache_file = self.merchant_cache_dir / "merchants.json"
Expand Down
11 changes: 8 additions & 3 deletions moneyflow/logging_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,28 @@
import logging
import sys
from pathlib import Path
from typing import Optional


def setup_logging(console_output: bool = False):
def setup_logging(console_output: bool = False, config_dir: Optional[str] = None):
"""
Configure logging to write to file.

Logs are written to ~/.moneyflow/moneyflow.log so they're not
Logs are written to ~/.moneyflow/moneyflow.log (or custom config dir) so they're not
swallowed by Textual's UI. Console output is disabled by default
to avoid interfering with the TUI.

Args:
console_output: If True, also log to console (for --dev mode)
config_dir: Optional custom config directory. If None, uses ~/.moneyflow

Returns:
Logger instance
"""
log_dir = Path.home() / ".moneyflow"
if config_dir:
log_dir = Path(config_dir).expanduser()
else:
log_dir = Path.home() / ".moneyflow"
log_dir.mkdir(exist_ok=True)
log_file = log_dir / "moneyflow.log"

Expand Down
14 changes: 11 additions & 3 deletions moneyflow/screens/credential_screens.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Credential setup and unlock screens, quit confirmation, and filter modal."""

from pathlib import Path

from textual.app import ComposeResult
from textual.containers import Container
from textual.events import Key
Expand Down Expand Up @@ -273,7 +275,9 @@ async def save_credentials(self) -> None:
# Save credentials
try:
error_label.update("💾 Saving credentials...")
cred_manager = CredentialManager()
# Get config_dir from app and pass to CredentialManager
config_path = Path(self.app.config_dir) if self.app.config_dir else None
cred_manager = CredentialManager(config_dir=config_path)
cred_manager.save_credentials(
email=email,
password=password,
Expand Down Expand Up @@ -408,7 +412,9 @@ async def unlock_credentials(self) -> None:

try:
error_label.update("🔓 Unlocking...")
cred_manager = CredentialManager()
# Get config_dir from app and pass to CredentialManager
config_path = Path(self.app.config_dir) if self.app.config_dir else None
cred_manager = CredentialManager(config_dir=config_path)
creds = cred_manager.load_credentials(encryption_password=encryption_password)

error_label.update("✅ Unlocked! Logging in...")
Expand All @@ -426,7 +432,9 @@ async def unlock_credentials(self) -> None:
async def reset_credentials(self) -> None:
"""Delete credentials and show setup screen."""
try:
cred_manager = CredentialManager()
# Get config_dir from app and pass to CredentialManager
config_path = Path(self.app.config_dir) if self.app.config_dir else None
cred_manager = CredentialManager(config_dir=config_path)
cred_manager.delete_credentials()

# Switch to setup screen
Expand Down
4 changes: 4 additions & 0 deletions moneyflow/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,10 @@ def get_filtered_df(self) -> Optional[pl.DataFrame]:

df = self.transactions_df

# Handle empty DataFrame (0 transactions) - return early to avoid column errors
if len(df) == 0:
return df

# Apply time filter
if self.start_date and self.end_date:
df = df.filter((pl.col("date") >= self.start_date) & (pl.col("date") <= self.end_date))
Expand Down