Skip to content
Merged
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies = [
"pycountry",
"icloudpy",
"alembic>=1.18.4",
"flet-charts>=0.81.0",
]

[project.urls]
Expand Down
13 changes: 11 additions & 2 deletions tuttle/app/auth/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def create_user(
city: str,
country: str,
website: str,
operating_country: str = "Germany",
) -> IntentResult[Union[Type[User], None]]:
"""
Creates a user with the given details.
Expand All @@ -50,9 +51,11 @@ def create_user(
city : str
City of the user
country : str
Country of the user
Country of the residence address
website : str
"URL of the user's website."
operating_country : str
Country whose tax system the freelancer operates under.

Returns
-------
Expand All @@ -72,6 +75,7 @@ def create_user(
email=email,
phone_number=phone,
address=address,
operating_country=operating_country,
VAT_number="",
website=website,
)
Expand Down Expand Up @@ -123,6 +127,7 @@ def update_user_with_info(
city: str,
country: str,
website: str,
operating_country: Optional[str] = None,
) -> IntentResult[Optional[User]]:
"""
Updates the user with the given details.
Expand All @@ -149,9 +154,11 @@ def update_user_with_info(
city : str
City of the user
country : str
Country of the user
Country of the residence address
website : str
"URL of the user's website."
operating_country : str, optional
Country whose tax system the freelancer operates under.
Returns
-------
IntentResult
Expand All @@ -171,6 +178,8 @@ def update_user_with_info(
user.address = address
user.website = website
user.profile_photo_path = user.profile_photo_path
if operating_country is not None:
user.operating_country = operating_country
result = self._data_source.save_user(user)

if not result.was_intent_successful:
Expand Down
23 changes: 19 additions & 4 deletions tuttle/app/auth/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from ..res import dimens, fonts, image_paths, res_utils, colors, theme
from ..preferences.intent import PreferencesIntent
from ...model import User, BankAccount
from ...tax import supported_countries

from .intent import AuthIntent

Expand Down Expand Up @@ -159,6 +160,7 @@ def on_submit_btn_clicked(self, e):
address_number = self.street_number_field.value
address_city = self.city_field.value
address_country = self.country_field.value
operating_country = self.operating_country_field.value or ""
website = self.website_field.value

# validate the form data
Expand All @@ -181,12 +183,14 @@ def on_submit_btn_clicked(self, e):
or utils.is_empty_str(address_country)
or utils.is_empty_str(address_city)
):

missing_required_data_err = "Please provide your full address"
self.toggle_form_err(missing_required_data_err)

elif utils.is_empty_str(operating_country):
missing_required_data_err = "Please select your operating country"
self.toggle_form_err(missing_required_data_err)

if not missing_required_data_err:
# save user
result: IntentResult = self.on_form_submit(
title=subtitle,
name=name,
Expand All @@ -198,6 +202,7 @@ def on_submit_btn_clicked(self, e):
city=address_city,
country=address_country,
website=website,
operating_country=operating_country,
)
if not result.was_intent_successful:
self.toggle_form_err(result.error_msg)
Expand Down Expand Up @@ -260,6 +265,11 @@ def build(self):
self.country_field = views.TTextField(
label="Country",
)
self.operating_country_field = views.TDropDown(
label="Operating Country (tax jurisdiction)",
items=supported_countries(),
hint="Select the country you freelance under",
)
self.form_err_control = views.TErrorText("")
self.submit_btn = views.TPrimaryButton(
on_click=self.on_submit_btn_clicked,
Expand Down Expand Up @@ -287,6 +297,7 @@ def build(self):
],
),
self.country_field,
self.operating_country_field,
self.form_err_control,
self.submit_btn,
]
Expand All @@ -303,6 +314,8 @@ def refresh_user_info(self, user: User):
self.street_number_field.value = user.address.number
self.city_field.value = user.address.city
self.country_field.value = user.address.country
if user.operating_country:
self.operating_country_field.update_value(user.operating_country)
self.website_field.value = user.website
self.update()

Expand Down Expand Up @@ -343,7 +356,7 @@ def set_login_form(self):
on_submit_success=lambda _: self.navigate_to_route(
res_utils.HOME_SCREEN_ROUTE
),
on_form_submit=lambda title, name, email, phone, street, street_num, postal_code, city, country, website: self.intent.create_user(
on_form_submit=lambda title, name, email, phone, street, street_num, postal_code, city, country, website, operating_country="Germany": self.intent.create_user(
title=title,
name=name,
email=email,
Expand All @@ -354,6 +367,7 @@ def set_login_form(self):
city=city,
country=country,
website=website,
operating_country=operating_country,
),
submit_btn_label="Save Profile",
)
Expand Down Expand Up @@ -588,7 +602,7 @@ def on_profile_updated(self, data):
def build(self):
"""Builds the view"""
self.user_info_form = UserDataForm(
on_form_submit=lambda title, name, email, phone, street, street_num, postal_code, city, country, website: self.intent.update_user_with_info(
on_form_submit=lambda title, name, email, phone, street, street_num, postal_code, city, country, website, operating_country=None: self.intent.update_user_with_info(
title=title,
name=name,
email=email,
Expand All @@ -599,6 +613,7 @@ def build(self):
city=city,
country=country,
website=website,
operating_country=operating_country,
user=self.user_profile,
),
on_submit_success=self.on_profile_updated,
Expand Down
23 changes: 12 additions & 11 deletions tuttle/app/contracts/intent.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
from ..clients.intent import ClientsIntent
from ..contacts.intent import ContactsIntent
from ..core.abstractions import ClientStorage, CrudIntent
from ..core.abstractions import CrudIntent
from ..core.intent_result import IntentResult
from ..preferences.intent import PreferencesIntent
from ..preferences.model import PreferencesStorageKeys

from ...model import Client, Contract
from ...model import Client, Contract, User
from ...tax import get_tax_system


class ContractsIntent(CrudIntent):
Expand Down Expand Up @@ -33,13 +32,15 @@ def get_all_contacts_as_map(self):
def save_client(self, client: Client) -> IntentResult:
return self._clients_intent.save_client(client=client)

def get_preferred_currency_intent(
self, client_storage: ClientStorage
) -> IntentResult:
_preferences_intent = PreferencesIntent(client_storage=client_storage)
return _preferences_intent.get_preference_by_key(
preference_key=PreferencesStorageKeys.default_currency_key
)
def get_default_currency(self) -> IntentResult:
"""Derive default contract currency from the user's operating country."""
try:
users = self.query(User)
country = users[0].operating_country if users else "Germany"
ts = get_tax_system(country)
return IntentResult(was_intent_successful=True, data=ts.currency)
except Exception as e:
return IntentResult(was_intent_successful=True, data="EUR")

# -- Contract-specific logic -----------------------------------------------

Expand Down
9 changes: 4 additions & 5 deletions tuttle/app/contracts/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,12 +372,11 @@ def build_edit_content(self, entity: Optional[Contract]) -> list:
keyboard_type=utils.KEYBOARD_NUMBER,
)

# Currency dropdown
# Currency dropdown — default derived from operating country
preferred_currency = None
if self._client_storage:
r = self.intent.get_preferred_currency_intent(self._client_storage)
if r.was_intent_successful:
preferred_currency = r.data
r = self.intent.get_default_currency()
if r.was_intent_successful:
preferred_currency = r.data
cur_value = entity.currency if entity else preferred_currency
self._currency_field = views.TDropDown(
label="Currency",
Expand Down
16 changes: 16 additions & 0 deletions tuttle/app/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)

import pycountry
from babel.numbers import format_currency as _babel_format_currency

from ...dev import deprecated

Expand Down Expand Up @@ -139,6 +140,8 @@ class TuttleComponentIcons(Enum):
timetracking_selected_icon = Icons.TIMER_ROUNDED
invoicing_icon = Icons.RECEIPT_OUTLINED
invoicing_selected_icon = Icons.RECEIPT_ROUNDED
tax_icon = Icons.CALCULATE_OUTLINED
tax_selected_icon = Icons.CALCULATE_ROUNDED
datatable_icon = Icons.TABLE_CHART
datatable_selected_icon = Icons.TABLE_CHART_ROUNDED
profile_icon = Icons.PERSON_OUTLINE
Expand All @@ -165,6 +168,19 @@ def get_currencies() -> List[Tuple[str, str, str]]:
return currencies


def fmt_currency(value, currency: str = "EUR", locale: str = "en_US") -> str:
"""Format a numeric value as a currency string using babel.

Args:
value: Decimal, float, or int to format. None returns "---".
currency: ISO 4217 code (e.g. "EUR", "USD", "SEK").
locale: Babel locale for number formatting.
"""
if value is None:
return "—"
return _babel_format_currency(float(value), currency, locale=locale)


def toBase64(
image_path,
) -> str:
Expand Down
8 changes: 4 additions & 4 deletions tuttle/app/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ class SectionLabel(Container):
def __init__(self, title: str):
super().__init__(
padding=Padding.only(
left=dimens.SPACE_STD, top=dimens.SPACE_LG, bottom=dimens.SPACE_XS
left=dimens.SPACE_STD, top=dimens.SPACE_MD, bottom=dimens.SPACE_XXS
),
content=Text(
title.upper(),
Expand Down Expand Up @@ -826,9 +826,9 @@ def __init__(
bgcolor=bg,
border_radius=dimens.RADIUS_LG,
padding=Padding.symmetric(
horizontal=dimens.SPACE_SM + 2, vertical=dimens.SPACE_XS + 2
horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XS
),
margin=Margin.symmetric(horizontal=dimens.SPACE_XS, vertical=1),
margin=Margin.symmetric(horizontal=dimens.SPACE_XXS, vertical=1),
on_click=on_click,
on_hover=self._on_hover,
content=Row(
Expand All @@ -841,7 +841,7 @@ def __init__(
weight=fonts.BOLD_FONT if selected else None,
),
],
spacing=dimens.SPACE_SM,
spacing=dimens.SPACE_XS,
vertical_alignment=utils.CENTER_ALIGNMENT,
),
)
Expand Down
62 changes: 59 additions & 3 deletions tuttle/app/dashboard/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
from ..core.abstractions import SQLModelDataSourceMixin, Intent
from ..core.intent_result import IntentResult

from ...model import Contract, Invoice, Project, FinancialGoal
from ...kpi import compute_kpis, monthly_revenue_breakdown, project_budget_status
from ...model import Contract, Invoice, Project, FinancialGoal, User
from ...kpi import (
compute_kpis,
monthly_revenue_breakdown,
monthly_spendable_breakdown,
project_budget_status,
)
from ...forecasting import revenue_curve


Expand All @@ -15,13 +20,24 @@ class DashboardIntent(SQLModelDataSourceMixin, Intent):
def __init__(self):
SQLModelDataSourceMixin.__init__(self)

def _get_country(self) -> str:
"""Determine the user's operating country for tax purposes."""
try:
users = self.query(User)
if users and users[0].operating_country:
return users[0].operating_country
except Exception:
pass
return "Germany"

def get_kpis(self) -> IntentResult:
"""Compute KPI summary from all invoices, contracts, projects."""
try:
invoices = self.query(Invoice)
contracts = self.query(Contract)
projects = self.query(Project)
kpis = compute_kpis(invoices, contracts, projects)
country = self._get_country()
kpis = compute_kpis(invoices, contracts, projects, country=country)
return IntentResult(was_intent_successful=True, data=kpis)
except Exception as e:
return IntentResult(
Expand All @@ -45,6 +61,46 @@ def get_monthly_revenue(self, n_months: int = 12) -> IntentResult:
exception=e,
)

def get_monthly_spendable_income(self, n_months: int = 12) -> IntentResult:
"""Get monthly spendable income breakdown for the last n months."""
try:
invoices = self.query(Invoice)
country = self._get_country()
data = monthly_spendable_breakdown(
invoices,
country=country,
n_months=n_months,
)
return IntentResult(was_intent_successful=True, data=data)
except Exception as e:
return IntentResult(
was_intent_successful=False,
error_msg="Failed to load monthly spendable income.",
log_message=f"DashboardIntent.get_monthly_spendable_income: {e}",
exception=e,
)

def get_monthly_chart_data(self, n_months: int = 12) -> IntentResult:
"""Revenue + spendable in one query (avoids duplicate invoice loads)."""
try:
invoices = self.query(Invoice)
country = self._get_country()
revenue = monthly_revenue_breakdown(invoices, n_months=n_months)
spendable = monthly_spendable_breakdown(
invoices, country=country, n_months=n_months
)
return IntentResult(
was_intent_successful=True,
data={"revenue": revenue, "spendable": spendable},
)
except Exception as e:
return IntentResult(
was_intent_successful=False,
error_msg="Failed to load chart data.",
log_message=f"DashboardIntent.get_monthly_chart_data: {e}",
exception=e,
)

def get_revenue_curve(self, forecast_months: int = 6) -> IntentResult:
"""Get combined historical + forecast revenue curve."""
try:
Expand Down
Loading
Loading