Skip to content

Commit 28eeaaa

Browse files
committed
Add recurring expense management feature
- Introduced RecurringExpense model to represent regular operating expenses for freelancers. - Implemented data access layer (SalaryDataSource) for CRUD operations on recurring expenses. - Added SalaryIntent to handle business logic for computing effective salary, incorporating recurring expenses. - Created SalaryView for displaying and managing effective salary planning, including a user-friendly interface. - Updated navigation to include SalaryView and associated icons for better user experience. - Added migration script to create the recurring expense table in the database.
1 parent b95769a commit 28eeaaa

9 files changed

Lines changed: 817 additions & 1 deletion

File tree

tuttle/app/core/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ class TuttleComponentIcons(Enum):
152152
profile_photo_selected_icon = Icons.PHOTO_ROUNDED
153153
timeline_icon = Icons.TIMELINE_OUTLINED
154154
timeline_selected_icon = Icons.TIMELINE_ROUNDED
155+
salary_icon = Icons.SAVINGS_OUTLINED
156+
salary_selected_icon = Icons.SAVINGS_ROUNDED
155157

156158
def __str__(self) -> str:
157159
return str(self.value)

tuttle/app/home/view.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ..core.status_bar import StatusBarManager
3333
from ..dashboard.view import DashboardView
3434
from ..invoicing.view import InvoicingListView
35+
from ..salary.view import SalaryView
3536
from ..tax.view import TaxView
3637
from ..timeline.view import TimelineView
3738
from ..projects.view import ProjectsListView
@@ -126,6 +127,7 @@ def __init__(self, params: TViewParams):
126127
self.dashboard_view = DashboardView(params)
127128
self.timeline_view = TimelineView(params)
128129
self.tax_view = TaxView(params)
130+
self.salary_view = SalaryView(params)
129131
self.items = [
130132
views.NavigationMenuItem(
131133
index=0,
@@ -148,6 +150,13 @@ def __init__(self, params: TViewParams):
148150
selected_icon=utils.TuttleComponentIcons.tax_selected_icon,
149151
destination=self.tax_view,
150152
),
153+
views.NavigationMenuItem(
154+
index=3,
155+
label="Salary",
156+
icon=utils.TuttleComponentIcons.salary_icon,
157+
selected_icon=utils.TuttleComponentIcons.salary_selected_icon,
158+
destination=self.salary_view,
159+
),
151160
]
152161

153162

tuttle/app/salary/__init__.py

Whitespace-only changes.

tuttle/app/salary/data_source.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Data-access layer for the Salary feature."""
2+
3+
from typing import List, Optional
4+
5+
from loguru import logger
6+
import sqlmodel
7+
8+
from ..core.abstractions import SQLModelDataSourceMixin
9+
from ..core.intent_result import IntentResult
10+
from ...model import RecurringExpense
11+
12+
13+
class SalaryDataSource(SQLModelDataSourceMixin):
14+
"""CRUD operations for RecurringExpense records."""
15+
16+
def __init__(self):
17+
super().__init__()
18+
19+
def get_all_expenses(self) -> IntentResult[List[RecurringExpense]]:
20+
try:
21+
expenses = self.query(RecurringExpense)
22+
return IntentResult(was_intent_successful=True, data=expenses)
23+
except Exception as ex:
24+
return IntentResult(
25+
was_intent_successful=False,
26+
error_msg="Failed to load recurring expenses.",
27+
log_message=f"SalaryDataSource.get_all_expenses: {ex}",
28+
exception=ex,
29+
)
30+
31+
def save_expense(self, expense: RecurringExpense) -> IntentResult[RecurringExpense]:
32+
try:
33+
with self.create_session() as session:
34+
session.add(expense)
35+
session.commit()
36+
session.refresh(expense)
37+
return IntentResult(was_intent_successful=True, data=expense)
38+
except Exception as ex:
39+
return IntentResult(
40+
was_intent_successful=False,
41+
error_msg="Failed to save recurring expense.",
42+
log_message=f"SalaryDataSource.save_expense: {ex}",
43+
exception=ex,
44+
)
45+
46+
def delete_expense_by_id(self, expense_id: int) -> IntentResult:
47+
try:
48+
with self.create_session() as session:
49+
expense = session.exec(
50+
sqlmodel.select(RecurringExpense).where(
51+
RecurringExpense.id == expense_id
52+
)
53+
).one_or_none()
54+
if expense is None:
55+
return IntentResult(
56+
was_intent_successful=False,
57+
error_msg=f"Expense with id={expense_id} not found.",
58+
)
59+
session.delete(expense)
60+
session.commit()
61+
return IntentResult(was_intent_successful=True)
62+
except Exception as ex:
63+
return IntentResult(
64+
was_intent_successful=False,
65+
error_msg="Failed to delete recurring expense.",
66+
log_message=f"SalaryDataSource.delete_expense_by_id: {ex}",
67+
exception=ex,
68+
)

tuttle/app/salary/intent.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Business logic for the Salary feature."""
2+
3+
from decimal import Decimal
4+
5+
from ..core.abstractions import SQLModelDataSourceMixin, Intent
6+
from ..core.intent_result import IntentResult
7+
8+
from ...model import Invoice, RecurringExpense, User
9+
from ...tax import get_tax_system
10+
from ...tax_reserves import compute_effective_salary
11+
12+
from .data_source import SalaryDataSource
13+
14+
15+
class SalaryIntent(SQLModelDataSourceMixin, Intent):
16+
"""Gathers and processes data for the Effective Salary view."""
17+
18+
def __init__(self):
19+
SQLModelDataSourceMixin.__init__(self)
20+
self._data_source = SalaryDataSource()
21+
22+
def _get_country(self) -> str:
23+
try:
24+
users = self.query(User)
25+
if users and users[0].operating_country:
26+
return users[0].operating_country
27+
except Exception:
28+
pass
29+
return "Germany"
30+
31+
def _get_tax_currency(self, country: str) -> str:
32+
try:
33+
return get_tax_system(country).currency
34+
except NotImplementedError:
35+
return "EUR"
36+
37+
def get_effective_salary(self) -> IntentResult:
38+
"""Compute the effective salary range."""
39+
try:
40+
invoices = self.query(Invoice)
41+
expenses_result = self._data_source.get_all_expenses()
42+
expenses = (
43+
expenses_result.data if expenses_result.was_intent_successful else []
44+
)
45+
46+
country = self._get_country()
47+
currency = self._get_tax_currency(country)
48+
49+
salary = compute_effective_salary(
50+
invoices=invoices,
51+
expenses=expenses,
52+
country=country,
53+
currency=currency,
54+
)
55+
return IntentResult(
56+
was_intent_successful=True,
57+
data={"salary": salary, "currency": currency},
58+
)
59+
except Exception as e:
60+
return IntentResult(
61+
was_intent_successful=False,
62+
error_msg="Failed to compute effective salary.",
63+
log_message=f"SalaryIntent.get_effective_salary: {e}",
64+
exception=e,
65+
)
66+
67+
def get_expenses(self) -> IntentResult:
68+
"""Return all recurring expenses."""
69+
return self._data_source.get_all_expenses()
70+
71+
def save_expense(self, expense: RecurringExpense) -> IntentResult:
72+
"""Persist a new or updated recurring expense."""
73+
return self._data_source.save_expense(expense)
74+
75+
def delete_expense(self, expense_id: int) -> IntentResult:
76+
"""Remove a recurring expense by id."""
77+
return self._data_source.delete_expense_by_id(expense_id)

0 commit comments

Comments
 (0)