diff --git a/pyproject.toml b/pyproject.toml index 19a1a0a..8a2bef2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "pycountry", "icloudpy", "alembic>=1.18.4", + "flet-charts>=0.81.0", ] [project.urls] diff --git a/tuttle/app/auth/intent.py b/tuttle/app/auth/intent.py index 2d952ec..621587b 100644 --- a/tuttle/app/auth/intent.py +++ b/tuttle/app/auth/intent.py @@ -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. @@ -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 ------- @@ -72,6 +75,7 @@ def create_user( email=email, phone_number=phone, address=address, + operating_country=operating_country, VAT_number="", website=website, ) @@ -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. @@ -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 @@ -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: diff --git a/tuttle/app/auth/view.py b/tuttle/app/auth/view.py index 62cc25d..67589c3 100644 --- a/tuttle/app/auth/view.py +++ b/tuttle/app/auth/view.py @@ -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 @@ -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 @@ -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, @@ -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) @@ -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, @@ -287,6 +297,7 @@ def build(self): ], ), self.country_field, + self.operating_country_field, self.form_err_control, self.submit_btn, ] @@ -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() @@ -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, @@ -354,6 +367,7 @@ def set_login_form(self): city=city, country=country, website=website, + operating_country=operating_country, ), submit_btn_label="Save Profile", ) @@ -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, @@ -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, diff --git a/tuttle/app/contracts/intent.py b/tuttle/app/contracts/intent.py index 1135ce9..fedd1d0 100644 --- a/tuttle/app/contracts/intent.py +++ b/tuttle/app/contracts/intent.py @@ -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): @@ -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 ----------------------------------------------- diff --git a/tuttle/app/contracts/view.py b/tuttle/app/contracts/view.py index 88bbc48..896c1b8 100644 --- a/tuttle/app/contracts/view.py +++ b/tuttle/app/contracts/view.py @@ -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", diff --git a/tuttle/app/core/utils.py b/tuttle/app/core/utils.py index 9c15caf..8f9e4d6 100644 --- a/tuttle/app/core/utils.py +++ b/tuttle/app/core/utils.py @@ -25,6 +25,7 @@ ) import pycountry +from babel.numbers import format_currency as _babel_format_currency from ...dev import deprecated @@ -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 @@ -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: diff --git a/tuttle/app/core/views.py b/tuttle/app/core/views.py index 9f02be0..a9626f1 100644 --- a/tuttle/app/core/views.py +++ b/tuttle/app/core/views.py @@ -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(), @@ -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( @@ -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, ), ) diff --git a/tuttle/app/dashboard/intent.py b/tuttle/app/dashboard/intent.py index 499b46e..ac1287c 100644 --- a/tuttle/app/dashboard/intent.py +++ b/tuttle/app/dashboard/intent.py @@ -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 @@ -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( @@ -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: diff --git a/tuttle/app/dashboard/view.py b/tuttle/app/dashboard/view.py index 29fab37..a256423 100644 --- a/tuttle/app/dashboard/view.py +++ b/tuttle/app/dashboard/view.py @@ -4,9 +4,15 @@ and financial goal tracking using only Flet controls (no browser). """ +import threading from decimal import Decimal +import flet_charts as fch from flet import ( + Border, + BorderRadius, + BorderSide, + Colors, Column, Container, CrossAxisAlignment, @@ -14,30 +20,21 @@ Icons, MainAxisAlignment, Padding, + ProgressRing, ResponsiveRow, Row, ScrollMode, Text, - TextAlign, TextStyle, ) from ..core.abstractions import TView, TViewParams from ..core import views +from ..core.utils import fmt_currency from ..res import colors, dimens, fonts, res_utils from .intent import DashboardIntent -def _fmt_currency(value, symbol="€") -> str: - """Format a Decimal or float as a currency string.""" - if value is None: - return "—" - v = float(value) - if abs(v) >= 1000: - return f"{symbol}{v:,.0f}" - return f"{symbol}{v:,.2f}" - - def _fmt_pct(value) -> str: if value is None: return "—" @@ -60,7 +57,7 @@ def __init__( super().__init__( bgcolor=colors.bg_surface, border_radius=dimens.RADIUS_LG, - padding=Padding.all(dimens.SPACE_STD), + padding=Padding.all(dimens.SPACE_SM), col={"xs": 12, "sm": 6, "md": 4, "lg": 3}, content=Column( spacing=dimens.SPACE_XS, @@ -91,59 +88,7 @@ def __init__( ) -# ── Revenue Bar (native) ───────────────────────────────────── - - -_BAR_CHART_HEIGHT = 180 - - -class _VerticalBar(Column): - """A single vertical bar in the revenue chart.""" - - def __init__( - self, label: str, value: float, max_value: float, is_forecast: bool = False - ): - ratio = min(value / max_value, 1.0) if max_value > 0 else 0 - bar_height = max(ratio * _BAR_CHART_HEIGHT, 2) if ratio > 0 else 0 - empty_height = _BAR_CHART_HEIGHT - bar_height - bar_color = colors.accent if not is_forecast else colors.accent_muted - value_text = _fmt_currency(value) if value > 0 else "" - - super().__init__( - expand=True, - horizontal_alignment=CrossAxisAlignment.CENTER, - spacing=0, - controls=[ - # Value label above bar - Text( - value_text, - size=fonts.CAPTION_SIZE - 1, - color=colors.text_muted if is_forecast else colors.text_secondary, - text_align=TextAlign.CENTER, - ), - # Empty space above bar - Container(height=empty_height), - # The bar itself - Container( - height=bar_height, - bgcolor=bar_color, - border_radius=dimens.RADIUS_SM, - expand=True, - ), - # Month label below bar - Container( - padding=Padding.only(top=dimens.SPACE_XXS), - content=Text( - label, - size=fonts.CAPTION_SIZE, - color=colors.text_muted - if is_forecast - else colors.text_secondary, - text_align=TextAlign.CENTER, - ), - ), - ], - ) +_BAR_CHART_HEIGHT = 260 # ── Project Budget Row ──────────────────────────────────────── @@ -227,7 +172,7 @@ def _section_header(title: str, icon=None) -> Container: ) ) return Container( - padding=Padding.only(top=dimens.SPACE_LG, bottom=dimens.SPACE_SM), + padding=Padding.only(top=dimens.SPACE_MD, bottom=dimens.SPACE_XS), content=Row(spacing=dimens.SPACE_XS, controls=controls), ) @@ -250,13 +195,31 @@ def build(self): self._kpi_row = ResponsiveRow( spacing=dimens.SPACE_SM, run_spacing=dimens.SPACE_SM ) + self._tax_row = ResponsiveRow( + spacing=dimens.SPACE_SM, run_spacing=dimens.SPACE_SM + ) self._revenue_section = Column(spacing=0) self._budget_section = Column(spacing=0) self._goals_section = Column(spacing=0) + self._spinner = ProgressRing( + width=32, height=32, stroke_width=3, color=colors.accent + ) + self._content = Column( + spacing=dimens.SPACE_XS, + visible=False, + controls=[ + views.Spacer(sm_space=True), + self._kpi_row, + self._tax_row, + self._revenue_section, + self._budget_section, + self._goals_section, + ], + ) self.controls = [ Container( - padding=Padding.all(dimens.SPACE_MD), + padding=Padding.all(dimens.SPACE_STD), content=Column( spacing=dimens.SPACE_XS, controls=[ @@ -266,11 +229,11 @@ def build(self): color=colors.text_primary, weight=fonts.BOLDER_FONT, ), - views.Spacer(sm_space=True), - self._kpi_row, - self._revenue_section, - self._budget_section, - self._goals_section, + Row( + alignment=MainAxisAlignment.CENTER, + controls=[self._spinner], + ), + self._content, ], ), ) @@ -288,11 +251,20 @@ def parent_intent_listener(self, intent: str, data=None): self._load_data() def _load_data(self): - """Fetch all dashboard data and rebuild controls.""" + """Kick off data loading in a background thread to keep the UI responsive.""" + self._spinner.visible = True + self._content.visible = False + self.update_self() + threading.Thread(target=self._load_data_sync, daemon=True).start() + + def _load_data_sync(self): + """Fetch all dashboard data and rebuild controls (runs off-UI-thread).""" self._load_kpis() - self._load_revenue_chart() + self._load_monthly_chart() self._load_project_budgets() self._load_goals() + self._spinner.visible = False + self._content.visible = True self.update_self() # ── KPI cards ───────────────────────────────────────────── @@ -304,28 +276,29 @@ def _load_kpis(self): return kpis = result.data + tc = kpis.tax_currency cards = [ _KPICard( "Revenue (YTD)", - _fmt_currency(kpis.total_revenue_ytd), + fmt_currency(kpis.total_revenue_ytd, tc), Icons.TRENDING_UP, colors.success if kpis.total_revenue_ytd > 0 else colors.text_primary, ), _KPICard( "Outstanding", - _fmt_currency(kpis.outstanding_amount), + fmt_currency(kpis.outstanding_amount, tc), Icons.ACCOUNT_BALANCE_WALLET_OUTLINED, colors.warning if kpis.outstanding_amount > 0 else colors.text_primary, ), _KPICard( "Overdue", - _fmt_currency(kpis.overdue_amount), + fmt_currency(kpis.overdue_amount, tc), Icons.WARNING_AMBER_ROUNDED, colors.danger if kpis.overdue_amount > 0 else colors.text_primary, ), _KPICard( "Eff. Hourly Rate", - _fmt_currency(kpis.effective_hourly_rate, "€") + fmt_currency(kpis.effective_hourly_rate, tc) if kpis.effective_hourly_rate else "—", Icons.SPEED, @@ -358,66 +331,186 @@ def _load_kpis(self): ] self._kpi_row.controls.extend(cards) + tax_cards = [ + _KPICard( + "VAT Reserve", + fmt_currency(kpis.vat_reserve, tc), + Icons.ACCOUNT_BALANCE, + colors.warning if kpis.vat_reserve > 0 else colors.text_primary, + ), + _KPICard( + "Est. Income Tax", + fmt_currency(kpis.income_tax_reserve, tc), + Icons.CALCULATE_OUTLINED, + colors.warning if kpis.income_tax_reserve > 0 else colors.text_primary, + ), + _KPICard( + "Spendable Income", + fmt_currency(kpis.spendable_income, tc), + Icons.SAVINGS_OUTLINED, + colors.success if kpis.spendable_income > 0 else colors.danger, + ), + ] + self._tax_row.controls.clear() + self._tax_row.controls.extend(tax_cards) + # ── Revenue chart ───────────────────────────────────────── - def _load_revenue_chart(self): - self._revenue_section.controls.clear() + def _build_chart_legend_chip(self, label: str, color: str) -> Container: + return Container( + bgcolor=colors.bg_input, + border_radius=dimens.RADIUS_PILL, + padding=Padding.symmetric( + horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XXS + ), + content=Row( + spacing=dimens.SPACE_XXS, + controls=[ + Container( + width=8, + height=8, + bgcolor=color, + border_radius=dimens.RADIUS_PILL, + ), + Text( + label, + size=fonts.CAPTION_SIZE, + color=colors.text_secondary, + ), + ], + ), + ) - result = self.intent.get_monthly_revenue(n_months=12) + def _load_monthly_chart(self): + self._revenue_section.controls.clear() + result = self.intent.get_monthly_chart_data(n_months=12) if not result.was_intent_successful or not result.data: return - months = result.data - if not months: - return - - # Collect all bar data (history + forecast) to compute a shared max - bar_data = [] - for m in months: - rev = float(m["revenue"]) - if rev > 0: - # "YYYY-MM" → "MM\n'YY" - year, mon = m["month"].split("-") - label = f"{mon}\n'{year[2:]}" - bar_data.append((label, rev, False)) - - # Forecast - forecast_result = self.intent.get_revenue_curve(forecast_months=6) - if forecast_result.was_intent_successful and forecast_result.data is not None: - df = forecast_result.data - forecast_rows = df[df["is_forecast"].eq(True)] - for _, row in forecast_rows.iterrows(): - month_dt = row["month"] - if hasattr(month_dt, "strftime"): - label = month_dt.strftime("%m") + "*\n'" + month_dt.strftime("%y") - else: - s = str(month_dt) - label = s[5:7] + "*\n'" + s[2:4] - bar_data.append((label, float(row["revenue"]), True)) - - if not bar_data: + revenue_by_month = {m["month"]: m for m in result.data["revenue"]} + spendable_by_month = {m["month"]: m for m in result.data["spendable"]} + month_keys = sorted( + set(revenue_by_month.keys()) & set(spendable_by_month.keys()) + ) + if not month_keys: return - max_val = max(v for _, v, _ in bar_data) - if max_val == 0: - max_val = 1 + groups = [] + bottom_labels = [] + max_val = 0.0 + for idx, mk in enumerate(month_keys): + y, m = mk.split("-") + label = f"{m}/{y[2:]}" + rev = float(revenue_by_month[mk]["revenue"]) + sp = float(spendable_by_month[mk]["spendable"]) + max_val = max(max_val, abs(rev), abs(sp)) + + sp_color = colors.success if sp >= 0 else colors.danger + groups.append( + fch.BarChartGroup( + x=idx, + rods=[ + fch.BarChartRod( + from_y=0, + to_y=rev, + width=16, + color=colors.accent, + tooltip=fch.BarChartRodTooltip( + f"Revenue: {fmt_currency(rev)}", + text_style=TextStyle(color=Colors.WHITE, size=13), + ), + border_radius=2, + ), + fch.BarChartRod( + from_y=0, + to_y=sp, + width=16, + color=sp_color, + tooltip=fch.BarChartRodTooltip( + f"Spendable: {fmt_currency(sp)}", + text_style=TextStyle(color=Colors.WHITE, size=13), + ), + border_radius=2, + ), + ], + ) + ) + bottom_labels.append( + fch.ChartAxisLabel( + value=idx, + label=Container( + Text( + label, size=fonts.CAPTION_SIZE, color=colors.text_secondary + ), + padding=Padding.only(top=4), + ), + ) + ) - bars = [ - _VerticalBar(label, value, max_val, is_forecast=fc) - for label, value, fc in bar_data - ] + chart = fch.BarChart( + expand=True, + height=_BAR_CHART_HEIGHT, + interactive=True, + max_y=max_val * 1.1 if max_val > 0 else 100, + min_y=0, + groups=groups, + group_spacing=8, + tooltip=fch.BarChartTooltip( + bgcolor=Colors.with_opacity(0.9, Colors.GREY_900), + border_radius=BorderRadius.all(8), + padding=Padding.symmetric(horizontal=12, vertical=8), + ), + border=Border( + bottom=BorderSide(width=1, color=colors.border), + left=BorderSide(width=1, color=colors.border), + ), + horizontal_grid_lines=fch.ChartGridLines( + color=colors.border, width=0.5, dash_pattern=[4, 4] + ), + left_axis=fch.ChartAxis(label_size=50), + bottom_axis=fch.ChartAxis(label_size=30, labels=bottom_labels), + ) self._revenue_section.controls = [ - _section_header("Monthly Revenue", Icons.BAR_CHART), + Container( + padding=Padding.only(top=dimens.SPACE_MD, bottom=dimens.SPACE_XS), + content=Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=CrossAxisAlignment.CENTER, + controls=[ + Row( + spacing=dimens.SPACE_XS, + controls=[ + Icon( + Icons.BAR_CHART, + size=dimens.MD_ICON_SIZE, + color=colors.text_secondary, + ), + Text( + "Monthly Revenue vs Spendable Income (Est.)", + size=fonts.HEADLINE_4_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT, + ), + ], + ), + Row( + spacing=dimens.SPACE_XXS, + controls=[ + self._build_chart_legend_chip("Revenue", colors.accent), + self._build_chart_legend_chip( + "Spendable", colors.success + ), + ], + ), + ], + ), + ), Container( bgcolor=colors.bg_surface, border_radius=dimens.RADIUS_LG, padding=Padding.all(dimens.SPACE_STD), - content=Row( - spacing=dimens.SPACE_XXS, - vertical_alignment=CrossAxisAlignment.END, - controls=list(bars), - ), + content=chart, ), ] @@ -466,11 +559,12 @@ def _load_goals(self): if not goals: return - # For each goal, show progress toward target based on YTD revenue kpi_result = self.intent.get_kpis() ytd_revenue = Decimal(0) + tc = "EUR" if kpi_result.was_intent_successful and kpi_result.data: ytd_revenue = kpi_result.data.total_revenue_ytd + tc = kpi_result.data.tax_currency goal_rows = [] for goal in goals: @@ -486,7 +580,7 @@ def _load_goals(self): status_text = ( "Reached!" if goal.is_reached - else f"{_fmt_currency(ytd_revenue)} / {_fmt_currency(goal.target_amount)}" + else f"{fmt_currency(ytd_revenue, tc)} / {fmt_currency(goal.target_amount, tc)}" ) goal_rows.append( @@ -517,7 +611,7 @@ def _load_goals(self): alignment=MainAxisAlignment.SPACE_BETWEEN, controls=[ Text( - f"Target: {_fmt_currency(goal.target_amount)} by {goal.target_date.strftime('%b %Y')}", + f"Target: {fmt_currency(goal.target_amount, tc)} by {goal.target_date.strftime('%b %Y')}", size=fonts.CAPTION_SIZE, color=colors.text_muted, ), diff --git a/tuttle/app/home/view.py b/tuttle/app/home/view.py index fef25c6..e056e6a 100644 --- a/tuttle/app/home/view.py +++ b/tuttle/app/home/view.py @@ -37,6 +37,7 @@ from ..core.status_bar import StatusBarManager from ..dashboard.view import DashboardView from ..invoicing.view import InvoicingListView +from ..tax.view import TaxView from ..projects.view import ProjectsListView from ..res import colors, dimens, fonts, res_utils, theme from ..timetracking.view import TimeTrackingView @@ -200,12 +201,13 @@ def __init__(self, params: TViewParams): class InsightsMenuHandler: - """Manages home's insights-menu items (dashboard, KPIs).""" + """Manages home's insights-menu items (dashboard, KPIs, tax).""" def __init__(self, params: TViewParams): super().__init__() self.menu_title = "Insights" self.dashboard_view = DashboardView(params) + self.tax_view = TaxView(params) self.items = [ views.NavigationMenuItem( index=0, @@ -214,6 +216,13 @@ def __init__(self, params: TViewParams): selected_icon=utils.TuttleComponentIcons.dashboard_selected_icon, destination=self.dashboard_view, ), + views.NavigationMenuItem( + index=1, + label="Tax & Reserves", + icon=utils.TuttleComponentIcons.tax_icon, + selected_icon=utils.TuttleComponentIcons.tax_selected_icon, + destination=self.tax_view, + ), ] diff --git a/tuttle/app/preferences/intent.py b/tuttle/app/preferences/intent.py index fdb8a0a..5adc9bc 100644 --- a/tuttle/app/preferences/intent.py +++ b/tuttle/app/preferences/intent.py @@ -48,8 +48,6 @@ def get_preferences(self) -> IntentResult: ) if item.value == PreferencesStorageKeys.theme_mode_key.value: preferences.theme_mode = preference_item_result.data - elif item.value == PreferencesStorageKeys.default_currency_key.value: - preferences.default_currency = preference_item_result.data elif item.value == PreferencesStorageKeys.cloud_acc_id_key.value: preferences.cloud_acc_id = preference_item_result.data elif item.value == PreferencesStorageKeys.cloud_provider_key.value: @@ -74,10 +72,6 @@ def save_preferences(self, preferences: Preferences) -> IntentResult: PreferencesStorageKeys.cloud_provider_key, preferences.cloud_acc_provider, ) - self.set_preference_key_value_pair( - PreferencesStorageKeys.default_currency_key, - preferences.default_currency, - ) self.set_preference_key_value_pair( PreferencesStorageKeys.language_key, preferences.language, diff --git a/tuttle/app/preferences/model.py b/tuttle/app/preferences/model.py index 1522bf1..9f25193 100644 --- a/tuttle/app/preferences/model.py +++ b/tuttle/app/preferences/model.py @@ -7,7 +7,6 @@ class Preferences: theme_mode: str = "" cloud_acc_id: str = "" cloud_acc_provider: str = "" - default_currency: str = "" language: str = "" @@ -17,7 +16,6 @@ class PreferencesStorageKeys(Enum): theme_mode_key = "preferred_theme_mode" cloud_acc_id_key = "preferred_cloud_acc_id" cloud_provider_key = "preferred_cloud_acc_provider" - default_currency_key = "preferred_default_currency" language_key = "preferred_language" def __str__(self) -> str: diff --git a/tuttle/app/preferences/view.py b/tuttle/app/preferences/view.py index 2c63044..4051cc5 100644 --- a/tuttle/app/preferences/view.py +++ b/tuttle/app/preferences/view.py @@ -55,20 +55,8 @@ def __init__( self.on_theme_changed_callback = on_theme_changed_callback self.on_reset_app_callback = on_reset_app_callback self.preferences: Optional[Preferences] = None - self.currencies = [] self.pop_up_handler = None - def set_available_currencies(self): - self.currencies = [ - abbreviation for (name, abbreviation, symbol) in utils.get_currencies() - ] - self.currencies_control.update_dropdown_items(self.currencies) - - def on_currency_selected(self, e): - if not self.preferences: - return - self.preferences.default_currency = e.control.value - def on_cloud_account_id_changed(self, e): if not self.preferences: return @@ -85,7 +73,6 @@ def refresh_preferences_items(self): self.theme_control.update_value(self.preferences.theme_mode) self.cloud_provider_control.update_value(self.preferences.cloud_acc_provider) self.cloud_account_id_control.value = self.preferences.cloud_acc_id - self.currencies_control.update_value(self.preferences.default_currency) self.languages_control.update_value(self.preferences.language) def on_theme_changed(self, e): @@ -192,11 +179,6 @@ def build(self): hint="Your cloud account name", on_change=self.on_cloud_account_id_changed, ) - self.currencies_control = views.TDropDown( - label="Default Currency", - on_change=self.on_currency_selected, - items=self.currencies, - ) self.languages_control = views.TDropDown( label="Language", on_change=self.on_language_selected, @@ -252,7 +234,6 @@ def build(self): self._make_tab_content( [ self.languages_control, - self.currencies_control, ] ), ], @@ -293,7 +274,6 @@ def did_mount(self): self.mounted = True self.loading_indicator.visible = True self.update_self() - self.set_available_currencies() result: IntentResult = self.intent.get_preferences() if result.was_intent_successful: self.preferences = result.data diff --git a/tuttle/app/res/dimens.py b/tuttle/app/res/dimens.py index da9f2bd..b08912f 100644 --- a/tuttle/app/res/dimens.py +++ b/tuttle/app/res/dimens.py @@ -11,16 +11,16 @@ SPACE_XXS = 4 SPACE_XS = 8 SPACE_SM = 12 -SPACE_STD = 16 -SPACE_MD = 20 -SPACE_LG = 24 -SPACE_XL = 32 +SPACE_STD = 14 +SPACE_MD = 18 +SPACE_LG = 22 +SPACE_XL = 30 SPACE_XXL = 48 # ── Layout chrome ──────────────────────────────────────────── -TOOLBAR_HEIGHT = 48 -FOOTER_HEIGHT = 26 # status bar — slightly taller for interactive widgets -SIDEBAR_WIDTH = 240 # wider for data tree +TOOLBAR_HEIGHT = 44 +FOOTER_HEIGHT = 24 # compact status bar height +SIDEBAR_WIDTH = 224 # slightly narrower for denser shell SIDEBAR_COLLAPSED_WIDTH = 48 ACTIVITY_BAR_WIDTH = 48 TITLEBAR_HEIGHT = 38 @@ -34,18 +34,18 @@ RADIUS_PILL = 999 # ── Icon sizes ─────────────────────────────────────────────── -ICON_SIZE = 18 +ICON_SIZE = 17 SM_ICON_SIZE = 14 -MD_ICON_SIZE = 20 -LG_ICON_SIZE = 28 +MD_ICON_SIZE = 18 +LG_ICON_SIZE = 26 # ── Clickable targets ─────────────────────────────────────── CLICKABLE_PILL_HEIGHT = 28 -CLICKABLE_STD_HEIGHT = 36 +CLICKABLE_STD_HEIGHT = 34 # ── Cards ──────────────────────────────────────────────────── CARD_MAX_EXTENT = 420 -CARD_SPACING = 20 # more breathing room +CARD_SPACING = 18 CARD_BORDER_WIDTH = 0 # borderless cards — rely on bg contrast # ── Status bar items ───────────────────────────────────────── diff --git a/tuttle/app/res/fonts.py b/tuttle/app/res/fonts.py index d6920f6..2c4f0e6 100644 --- a/tuttle/app/res/fonts.py +++ b/tuttle/app/res/fonts.py @@ -18,19 +18,19 @@ # ── Font sizes ─────────────────────────────────────────────── -HEADLINE_0_SIZE = 32 # hero / splash titles -HEADLING_1_SIZE = 28 # page titles -HEADLINE_2_SIZE = 22 # section titles -HEADLINE_3_SIZE = 18 # sub-section titles -HEADLINE_4_SIZE = 15 # card titles, toolbar headings -BODY_1_SIZE = 14 # primary body text (up from 13) -BODY_2_SIZE = 13 # secondary body text (up from 12) -SUBTITLE_1_SIZE = 15 # emphasized labels -SUBTITLE_2_SIZE = 13 # secondary labels -BUTTON_SIZE = 13 # button text +HEADLINE_0_SIZE = 30 # hero / splash titles +HEADLING_1_SIZE = 26 # page titles +HEADLINE_2_SIZE = 20 # section titles +HEADLINE_3_SIZE = 17 # sub-section titles +HEADLINE_4_SIZE = 14 # card titles, toolbar headings +BODY_1_SIZE = 13 # primary body text +BODY_2_SIZE = 12 # secondary body text +SUBTITLE_1_SIZE = 14 # emphasized labels +SUBTITLE_2_SIZE = 12 # secondary labels +BUTTON_SIZE = 12 # button text OVERLINE_SIZE = 11 # overline / section headers CAPTION_SIZE = 11 # captions, helper text -STATUS_BAR_SIZE = 12 # status bar text +STATUS_BAR_SIZE = 11 # status bar text # ── Font weights ───────────────────────────────────────────── BOLD_FONT = FontWeight.W_600 # semi-bold for crisper hierarchy diff --git a/tuttle/app/tax/__init__.py b/tuttle/app/tax/__init__.py new file mode 100644 index 0000000..b620e19 --- /dev/null +++ b/tuttle/app/tax/__init__.py @@ -0,0 +1,2 @@ +from .intent import TaxIntent +from .view import TaxView diff --git a/tuttle/app/tax/intent.py b/tuttle/app/tax/intent.py new file mode 100644 index 0000000..07e05c4 --- /dev/null +++ b/tuttle/app/tax/intent.py @@ -0,0 +1,149 @@ +"""Business logic for the Tax view.""" + +from decimal import Decimal + +from ..core.abstractions import SQLModelDataSourceMixin, Intent +from ..core.intent_result import IntentResult + +from ...model import Invoice, User +from ...tax import get_tax_system +from ...tax_reserves import ( + compute_spendable_income, + compute_income_tax_reserve, + quarterly_vat_breakdown, +) + + +class TaxIntent(SQLModelDataSourceMixin, Intent): + """Gathers tax-related data for the freelance tax planning view.""" + + 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_tax_currency(self, country: str) -> str: + """Return the ISO 4217 currency for the country's tax system.""" + try: + return get_tax_system(country).currency + except NotImplementedError: + return "EUR" + + def get_spendable_income(self) -> IntentResult: + """Compute spendable income breakdown.""" + try: + invoices = self.query(Invoice) + country = self._get_country() + currency = self._get_tax_currency(country) + spending = compute_spendable_income(invoices, country, currency=currency) + data = {"spending": spending, "currency": currency} + return IntentResult(was_intent_successful=True, data=data) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to compute spendable income.", + log_message=f"TaxIntent.get_spendable_income: {e}", + exception=e, + ) + + def get_income_tax_estimate(self) -> IntentResult: + """Get detailed income tax estimate with bracket info.""" + try: + import datetime as _dt + + invoices = self.query(Invoice) + country = self._get_country() + currency = self._get_tax_currency(country) + spending = compute_spendable_income(invoices, country, currency=currency) + tax_reserve = compute_income_tax_reserve(spending.net_revenue_ytd, country) + + days_elapsed = max( + (_dt.date.today() - _dt.date.today().replace(month=1, day=1)).days, 1 + ) + annualized = float(spending.net_revenue_ytd) * 365 / days_elapsed + + try: + tax_system = get_tax_system(country) + bracket_data = self._compute_bracket_data( + tax_system, Decimal(str(annualized)) + ) + country_supported = True + except NotImplementedError: + bracket_data = [] + country_supported = False + + data = { + "tax_reserve": tax_reserve, + "annualized_income": Decimal(str(round(annualized, 2))), + "brackets": bracket_data, + "country": country, + "country_supported": country_supported, + "currency": currency, + } + return IntentResult(was_intent_successful=True, data=data) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to compute income tax estimate.", + log_message=f"TaxIntent.get_income_tax_estimate: {e}", + exception=e, + ) + + def _compute_bracket_data(self, tax_system, annualized_income: Decimal) -> list: + """Build bracket visualization data from the tax system's zone data.""" + zones = tax_system.bracket_info + income_f = float(annualized_income) + allowance = tax_system.params.basic_allowance + brackets = [] + prev_end = 0 + for zone in zones: + up_to = zone["up_to"] + ztype = zone.get("type") + if ztype == "zero": + start = 0 + end = up_to + elif ztype in ("quadratic", "linear") and "reference_offset" in zone: + # German-style zones with explicit reference offsets + start = zone["reference_offset"] + end = up_to if up_to is not None else start + 100000 + else: + # Marginal bracket zones: display bracket range offset by allowance + start = prev_end + allowance if not brackets else prev_end + end = (up_to + allowance) if up_to is not None else start + 100000 + brackets.append( + { + "label": zone["label"], + "start": start, + "end": end, + "is_current": start + <= income_f + < (end if up_to is not None else float("inf")), + } + ) + prev_end = end + return brackets + + def get_quarterly_vat(self, year: int | None = None) -> IntentResult: + """Get quarterly VAT breakdown.""" + try: + invoices = self.query(Invoice) + country = self._get_country() + currency = self._get_tax_currency(country) + quarters = quarterly_vat_breakdown(invoices, year=year, currency=currency) + data = {"quarters": quarters, "currency": currency} + return IntentResult(was_intent_successful=True, data=data) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to compute quarterly VAT.", + log_message=f"TaxIntent.get_quarterly_vat: {e}", + exception=e, + ) diff --git a/tuttle/app/tax/view.py b/tuttle/app/tax/view.py new file mode 100644 index 0000000..56c85bd --- /dev/null +++ b/tuttle/app/tax/view.py @@ -0,0 +1,574 @@ +"""Tax planning view — Flet-native tax reserve breakdown. + +Sections: +1. Revenue Waterfall: gross → minus VAT → minus income tax → spendable +2. Quarterly VAT table +3. Income Tax Brackets visualization +""" + +from decimal import Decimal + +from flet import ( + Column, + Container, + Divider, + Icon, + Icons, + MainAxisAlignment, + Padding, + Row, + ScrollMode, + Text, + TextAlign, +) + +from ..core.abstractions import TView, TViewParams +from ..core import views +from ..core.utils import fmt_currency +from ..res import colors, dimens, fonts, res_utils +from .intent import TaxIntent + + +def _fmt_pct(value) -> str: + if value is None: + return "—" + return f"{float(value) * 100:.1f}%" + + +# ── Waterfall Bar ───────────────────────────────────────────── + + +class _WaterfallItem(Container): + """A single item in the revenue waterfall.""" + + def __init__( + self, + label: str, + amount: Decimal, + total: Decimal, + bar_color: str, + is_total: bool = False, + currency: str = "EUR", + ): + ratio = float(abs(amount) / total) if total > 0 else 0 + bar_width_pct = max(ratio, 0.02) # minimum visible width + + sign = "" if amount >= 0 or is_total else "−" + display_amount = abs(amount) + + super().__init__( + padding=Padding.symmetric(vertical=dimens.SPACE_XS), + content=Column( + spacing=2, + controls=[ + Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Text( + label, + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT if is_total else None, + ), + Text( + f"{sign}{fmt_currency(display_amount, currency)}", + size=fonts.BODY_1_SIZE, + color=bar_color, + weight=fonts.BOLD_FONT if is_total else None, + ), + ], + ), + Container( + height=8 if not is_total else 10, + bgcolor=colors.border, + border_radius=dimens.RADIUS_SM, + content=Row( + spacing=0, + controls=[ + Container( + expand=int(max(bar_width_pct * 100, 1)), + height=8 if not is_total else 10, + bgcolor=bar_color, + border_radius=dimens.RADIUS_SM, + ), + Container( + expand=int(max((1 - bar_width_pct) * 100, 0)) + ), + ], + ), + ), + ], + ), + ) + + +# ── Section Header (reused pattern) ────────────────────────── + + +def _section_header(title: str, icon=None) -> Container: + ctrl = [] + if icon: + ctrl.append(Icon(icon, size=dimens.MD_ICON_SIZE, color=colors.text_secondary)) + ctrl.append( + Text( + title, + size=fonts.HEADLINE_4_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT, + ) + ) + return Container( + padding=Padding.only(top=dimens.SPACE_LG, bottom=dimens.SPACE_SM), + content=Row(spacing=dimens.SPACE_XS, controls=ctrl), + ) + + +# ── Main Tax View ──────────────────────────────────────────── + + +class TaxView(TView, Column): + """Tax planning & reserve tracking view.""" + + def __init__(self, params: TViewParams): + TView.__init__(self, params) + Column.__init__(self) + self.intent = TaxIntent() + self.scroll = ScrollMode.AUTO + self.spacing = 0 + self.expand = True + + def build(self): + self._waterfall_section = Column(spacing=0) + self._vat_section = Column(spacing=0) + self._bracket_section = Column(spacing=0) + + self.controls = [ + Container( + padding=Padding.all(dimens.SPACE_MD), + content=Column( + spacing=dimens.SPACE_XS, + controls=[ + Text( + "Tax & Reserves", + size=fonts.HEADLING_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLDER_FONT, + ), + Text( + "How much of your revenue can you actually spend?", + size=fonts.BODY_1_SIZE, + color=colors.text_muted, + ), + views.Spacer(sm_space=True), + self._waterfall_section, + self._vat_section, + self._bracket_section, + ], + ), + ) + ] + + def did_mount(self): + self.mounted = True + self._load_data() + + def on_resume_after_back_pressed(self): + self._load_data() + + def parent_intent_listener(self, intent: str, data=None): + if intent == res_utils.RELOAD_INTENT: + self._load_data() + + def _load_data(self): + self._load_waterfall() + self._load_quarterly_vat() + self._load_bracket_info() + self.update_self() + + # ── Revenue waterfall ───────────────────────────────────── + + def _load_waterfall(self): + self._waterfall_section.controls.clear() + result = self.intent.get_spendable_income() + if not result.was_intent_successful or result.data is None: + return + + s = result.data["spending"] + currency = result.data.get("currency", "EUR") + gross = s.gross_revenue_ytd + if gross <= 0: + self._waterfall_section.controls = [ + _section_header("Revenue Breakdown (YTD)", Icons.WATERFALL_CHART), + Container( + bgcolor=colors.bg_surface, + border_radius=dimens.RADIUS_LG, + padding=Padding.all(dimens.SPACE_STD), + content=Text( + "No revenue data yet.", + size=fonts.BODY_1_SIZE, + color=colors.text_muted, + ), + ), + ] + return + + items = [ + _WaterfallItem( + "Gross Revenue", gross, gross, colors.accent, currency=currency + ), + _WaterfallItem( + "VAT (to remit)", + s.vat_reserve, + gross, + colors.warning, + currency=currency, + ), + _WaterfallItem( + "Est. Income Tax + Soli", + s.income_tax_reserve, + gross, + colors.warning, + currency=currency, + ), + ] + + spendable_color = colors.success if s.spendable > 0 else colors.danger + items.append( + _WaterfallItem( + "= Spendable Income", + s.spendable, + gross, + spendable_color, + is_total=True, + currency=currency, + ) + ) + + # Effective tax rate summary + total_deductions = s.vat_reserve + s.income_tax_reserve + effective_rate = float(total_deductions / gross) if gross > 0 else 0 + + self._waterfall_section.controls = [ + _section_header("Revenue Breakdown (YTD)", Icons.WATERFALL_CHART), + Container( + bgcolor=colors.bg_surface, + border_radius=dimens.RADIUS_LG, + padding=Padding.all(dimens.SPACE_STD), + content=Column( + spacing=0, + controls=[ + *items, + Container(height=dimens.SPACE_SM), + Divider(color=colors.border, height=1), + Container(height=dimens.SPACE_SM), + Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Text( + "Effective reserve rate", + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + ), + Text( + f"{effective_rate:.1%} of gross revenue", + size=fonts.CAPTION_SIZE, + color=colors.text_secondary, + ), + ], + ), + ], + ), + ), + ] + + # ── Quarterly VAT ───────────────────────────────────────── + + def _load_quarterly_vat(self): + self._vat_section.controls.clear() + result = self.intent.get_quarterly_vat() + if not result.was_intent_successful or not result.data: + return + + quarters = result.data["quarters"] + currency = result.data.get("currency", "EUR") + + # Table header + header = Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Container( + width=60, + content=Text( + "Quarter", + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=fonts.BOLD_FONT, + ), + ), + Container( + width=100, + content=Text( + "Period", + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=fonts.BOLD_FONT, + ), + ), + Container( + width=60, + content=Text( + "Invoices", + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=fonts.BOLD_FONT, + text_align=TextAlign.RIGHT, + ), + ), + Container( + expand=True, + content=Text( + "VAT Collected", + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=fonts.BOLD_FONT, + text_align=TextAlign.RIGHT, + ), + ), + ], + ) + + # Table rows + rows = [header, Divider(color=colors.border, height=1)] + total_vat = Decimal(0) + for q in quarters: + total_vat += q["vat_collected"] + period = ( + f"{q['period_start'].strftime('%b')} – {q['period_end'].strftime('%b')}" + ) + vat_color = colors.warning if q["vat_collected"] > 0 else colors.text_muted + rows.append( + Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Container( + width=60, + content=Text( + q["quarter"], + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT, + ), + ), + Container( + width=100, + content=Text( + period, + size=fonts.BODY_2_SIZE, + color=colors.text_secondary, + ), + ), + Container( + width=60, + content=Text( + str(q["invoice_count"]), + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + text_align=TextAlign.RIGHT, + ), + ), + Container( + expand=True, + content=Text( + fmt_currency(q["vat_collected"], currency), + size=fonts.BODY_1_SIZE, + color=vat_color, + text_align=TextAlign.RIGHT, + ), + ), + ], + ) + ) + + # Total row + rows.append(Divider(color=colors.border, height=1)) + rows.append( + Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Text( + "Total", + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT, + ), + Text( + fmt_currency(total_vat, currency), + size=fonts.BODY_1_SIZE, + color=colors.warning if total_vat > 0 else colors.text_muted, + weight=fonts.BOLD_FONT, + ), + ], + ) + ) + + self._vat_section.controls = [ + _section_header("Quarterly VAT", Icons.RECEIPT_LONG_OUTLINED), + Container( + bgcolor=colors.bg_surface, + border_radius=dimens.RADIUS_LG, + padding=Padding.all(dimens.SPACE_STD), + content=Column(spacing=dimens.SPACE_XS, controls=rows), + ), + ] + + # ── Income tax brackets ─────────────────────────────────── + + def _load_bracket_info(self): + self._bracket_section.controls.clear() + result = self.intent.get_income_tax_estimate() + if not result.was_intent_successful or result.data is None: + return + + data = result.data + tax_reserve = data["tax_reserve"] + annualized = data["annualized_income"] + brackets = data["brackets"] + country = data["country"] + country_supported = data.get("country_supported", True) + currency = data.get("currency", "EUR") + + summary_items = [ + ( + "Annualized Income", + fmt_currency(annualized, currency), + colors.text_primary, + ), + ] + if country_supported: + summary_items += [ + ( + "Estimated Income Tax", + fmt_currency(tax_reserve.estimated_annual_tax, currency), + colors.warning, + ), + ( + "Solidarity Surcharge", + fmt_currency(tax_reserve.solidarity_surcharge, currency), + colors.warning, + ), + ( + "Total Annual Reserve", + fmt_currency(tax_reserve.total_annual_reserve, currency), + colors.warning, + ), + ( + "Effective Tax Rate", + _fmt_pct(tax_reserve.effective_rate), + colors.text_secondary, + ), + ] + + summary_rows = [] + for label, value, color in summary_items: + summary_rows.append( + Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Text( + label, + size=fonts.BODY_1_SIZE, + color=colors.text_secondary, + ), + Text( + value, + size=fonts.BODY_1_SIZE, + color=color, + weight=fonts.BOLD_FONT, + ), + ], + ) + ) + + # Bracket visualization + bracket_rows = [] + for b in brackets: + is_current = b["is_current"] + bg = colors.accent if is_current else colors.bg_surface + text_color = colors.text_inverse if is_current else colors.text_secondary + label_weight = fonts.BOLD_FONT if is_current else None + + range_start = fmt_currency(b["start"], currency) + range_end = fmt_currency(b["end"], currency) + range_text = f"{range_start} – {range_end}" + indicator = " ◄ You are here" if is_current else "" + + bracket_rows.append( + Container( + bgcolor=bg, + border_radius=dimens.RADIUS_SM, + padding=Padding.symmetric( + horizontal=dimens.SPACE_SM, + vertical=dimens.SPACE_XS, + ), + content=Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Text( + b["label"], + size=fonts.BODY_2_SIZE, + color=text_color, + weight=label_weight, + ), + Text( + range_text + indicator, + size=fonts.BODY_2_SIZE, + color=text_color, + ), + ], + ), + ) + ) + + bracket_controls = [ + *summary_rows, + ] + if brackets: + bracket_controls += [ + Container(height=dimens.SPACE_SM), + Text( + "Tax Brackets", + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=fonts.BOLD_FONT, + ), + *bracket_rows, + ] + elif not country_supported: + bracket_controls.append( + Container( + padding=Padding.only(top=dimens.SPACE_SM), + content=Text( + f"Income tax estimation is not yet available for {country}. " + "VAT reserves are still tracked above.", + size=fonts.BODY_2_SIZE, + color=colors.text_muted, + italic=True, + ), + ) + ) + + self._bracket_section.controls = [ + _section_header( + f"Income Tax Estimate ({country})", + Icons.CALCULATE_OUTLINED, + ), + Container( + bgcolor=colors.bg_surface, + border_radius=dimens.RADIUS_LG, + padding=Padding.all(dimens.SPACE_STD), + content=Column( + spacing=dimens.SPACE_SM, + controls=bracket_controls, + ), + ), + ] diff --git a/tuttle/kpi.py b/tuttle/kpi.py index 16e7d9e..925d5b7 100644 --- a/tuttle/kpi.py +++ b/tuttle/kpi.py @@ -4,7 +4,9 @@ from decimal import Decimal from typing import List, Optional, NamedTuple -from .model import Contract, Invoice, Project +from .model import Contract, Invoice, Project, User +from .tax import get_tax_system +from .tax_reserves import compute_spendable_income class KPISummary(NamedTuple): @@ -20,12 +22,18 @@ class KPISummary(NamedTuple): active_contracts: int unpaid_invoices: int overdue_invoices: int + # Tax reserves + vat_reserve: Decimal + income_tax_reserve: Decimal + spendable_income: Decimal + tax_currency: str = "EUR" def compute_kpis( invoices: List[Invoice], contracts: List[Contract], projects: List[Project], + country: str = "Germany", ) -> KPISummary: """Compute business KPIs from current data.""" today = datetime.date.today() @@ -86,6 +94,24 @@ def compute_kpis( if available_hours > 0: utilization_rate = float(total_hours / available_hours) + # Tax reserves — resolve currency from the tax system + tax_currency = "EUR" + try: + tax_system = get_tax_system(country) + tax_currency = tax_system.currency + except NotImplementedError: + pass + + try: + spending = compute_spendable_income(invoices, country, currency=tax_currency) + vat_reserve = spending.vat_reserve + income_tax_reserve = spending.income_tax_reserve + spendable_income = spending.spendable + except NotImplementedError: + vat_reserve = Decimal(0) + income_tax_reserve = Decimal(0) + spendable_income = Decimal(0) + return KPISummary( total_revenue=total_revenue, total_revenue_ytd=total_revenue_ytd, @@ -97,6 +123,10 @@ def compute_kpis( active_contracts=active_contracts, unpaid_invoices=unpaid_invoices, overdue_invoices=overdue_invoices, + vat_reserve=vat_reserve, + income_tax_reserve=income_tax_reserve, + spendable_income=spendable_income, + tax_currency=tax_currency, ) @@ -129,6 +159,106 @@ def monthly_revenue_breakdown( return sorted(months.values(), key=lambda x: x["month"]) +def monthly_spendable_breakdown( + invoices: List[Invoice], + country: str = "Germany", + n_months: int = 12, + deductions: Decimal = Decimal(0), +) -> list: + """Estimate monthly spendable income after VAT and income-tax true-up. + + For each month bucket this returns: + - gross_revenue: invoiced amount including VAT + - vat_due: VAT to reserve for that month + - net_revenue: gross_revenue - vat_due + - income_tax_true_up: monthly delta in YTD tax reserve estimate + - spendable: net_revenue - income_tax_true_up + """ + today = datetime.date.today() + start = (today - datetime.timedelta(days=30 * n_months)).replace(day=1) + + months = {} + current = start + while current <= today: + key = current.strftime("%Y-%m") + months[key] = { + "month": key, + "gross_revenue": Decimal(0), + "vat_due": Decimal(0), + "net_revenue": Decimal(0), + "income_tax_true_up": Decimal(0), + "spendable": Decimal(0), + "invoice_count": 0, + } + current = (current + datetime.timedelta(days=32)).replace(day=1) + + # Resolve currency from the tax system. Non-matching invoice currencies are + # skipped to avoid mixing values in the spendable estimate. + currency = None + try: + currency = get_tax_system(country).currency + except NotImplementedError: + pass + + for inv in invoices: + if inv.cancelled: + continue + if currency and inv.contract and inv.contract.currency not in (currency, None): + continue + key = inv.date.strftime("%Y-%m") + if key in months: + months[key]["gross_revenue"] += inv.total + months[key]["vat_due"] += inv.VAT_total + months[key]["invoice_count"] += 1 + + sorted_keys = sorted(months.keys()) + cumulative_net_ytd = Decimal(0) + previous_ytd_reserve = Decimal(0) + + for key in sorted_keys: + m = months[key] + m["net_revenue"] = m["gross_revenue"] - m["vat_due"] + year, month = key.split("-") + month_start = datetime.date(int(year), int(month), 1) + if month_start.year == today.year: + cumulative_net_ytd += m["net_revenue"] + month_end = (month_start + datetime.timedelta(days=32)).replace( + day=1 + ) - datetime.timedelta(days=1) + as_of = min(month_end, today) + year_start = as_of.replace(month=1, day=1) + days_elapsed = max((as_of - year_start).days, 1) + days_in_year = 365 + + annualized_income = ( + (cumulative_net_ytd - deductions) * days_in_year / days_elapsed + ) + if annualized_income <= 0: + ytd_reserve = Decimal(0) + else: + try: + tax_system = get_tax_system(country, date=as_of) + annual_tax = tax_system.income_tax(annualized_income) + annual_soli = tax_system.solidarity_surcharge(annual_tax) + total_annual = annual_tax + annual_soli + ytd_reserve = (total_annual * days_elapsed / days_in_year).quantize( + Decimal("0.01") + ) + except NotImplementedError: + ytd_reserve = Decimal(0) + + m["income_tax_true_up"] = ytd_reserve - previous_ytd_reserve + previous_ytd_reserve = ytd_reserve + else: + # Keep non-current-year months neutral so the chart remains stable + # when showing a rolling window that crosses year boundaries. + m["income_tax_true_up"] = Decimal(0) + + m["spendable"] = m["net_revenue"] - m["income_tax_true_up"] + + return [months[k] for k in sorted_keys] + + def project_budget_status( projects: List[Project], ) -> list: diff --git a/tuttle/migrations/versions/2026_03_13_0003_add_user_operating_country.py b/tuttle/migrations/versions/2026_03_13_0003_add_user_operating_country.py new file mode 100644 index 0000000..25b3fc8 --- /dev/null +++ b/tuttle/migrations/versions/2026_03_13_0003_add_user_operating_country.py @@ -0,0 +1,34 @@ +"""add user operating_country + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-03-13 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "0003" +down_revision: Union[str, None] = "0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "user", + sa.Column( + "operating_country", + sa.String(), + nullable=False, + server_default="Germany", + ), + ) + + +def downgrade() -> None: + op.drop_column("user", "operating_country") diff --git a/tuttle/model.py b/tuttle/model.py index bf53b24..eccce17 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -131,6 +131,10 @@ class User(SQLModel, table=True): back_populates="users", sa_relationship_kwargs={"lazy": "subquery"}, ) + operating_country: str = Field( + default="Germany", + description="Country whose tax system and currency the freelancer operates under.", + ) VAT_number: Optional[str] = Field( default=None, description="Value Added Tax number of the user, legally required for invoices.", diff --git a/tuttle/tax.py b/tuttle/tax.py index 922f311..9932089 100644 --- a/tuttle/tax.py +++ b/tuttle/tax.py @@ -1,47 +1,283 @@ -"""Functionality related to taxation.""" +"""Data-driven tax calculation engine. +Tax systems are stored as JSON data packages in ``tuttle/tax_data//``. +Each file covers one year and contains all formula parameters (bracket +thresholds, polynomial coefficients, surcharge rates, VAT rates). +The engine loads the right year's data for any given date, so historical +invoices are taxed with the rules that were actually in force. + +Adding a new year: copy the JSON file, update the coefficients. +Adding a new country: create a new subdirectory with its own JSON files. +""" + +from __future__ import annotations + +import datetime +import json from decimal import Decimal -from unittest.loader import VALID_MODULE_NAME +from pathlib import Path +from typing import Optional +# ── Data package directory ──────────────────────────────────── -def income_tax(taxable_income: Decimal, country: str) -> Decimal: - """[summary] +_DATA_DIR = Path(__file__).parent / "tax_data" - Args: - taxable_income (Decimal): [description] - country (str): [description] - Returns: - Decimal: [description] - """ - if country == "Germany": - return income_tax_germany(taxable_income) - else: +# ── TaxParams: one loaded year for one country ─────────────── + + +class TaxParams: + """Tax parameters loaded from a single JSON data package.""" + + def __init__(self, data: dict): + self._data = data + self.country: str = data["country"] + self.aliases: list[str] = data.get("country_aliases", []) + self.year: int = data["year"] + self.formula_type: str = data["formula_type"] + self.source: str = data.get("source", "") + self.currency: str = data["currency"] + + it = data["income_tax"] + self.basic_allowance = it["basic_allowance"] + self.zones: list[dict] = it["zones"] + + soli = data.get("solidarity_surcharge", {}) + self.soli_rate: float = soli.get("rate", 0.0) + + vat = data.get("vat", {}) + self.vat_standard: float = vat.get("standard_rate", 0.0) + self.vat_reduced: float = vat.get("reduced_rate", 0.0) + + +# ── Registry: country -> {year -> TaxParams} ───────────────── + +# Maps canonical country name → {year → TaxParams} +_REGISTRY: dict[str, dict[int, TaxParams]] = {} +# Maps alias → canonical name +_ALIAS_MAP: dict[str, str] = {} + + +def _load_all() -> None: + """Scan tax_data/ and populate the registry (once).""" + if _REGISTRY: + return + if not _DATA_DIR.is_dir(): + return + for country_dir in sorted(_DATA_DIR.iterdir()): + if not country_dir.is_dir() or country_dir.name.startswith("_"): + continue + for json_path in sorted(country_dir.glob("*.json")): + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + params = TaxParams(data) + canonical = params.country + _REGISTRY.setdefault(canonical, {})[params.year] = params + _ALIAS_MAP[canonical] = canonical + for alias in params.aliases: + _ALIAS_MAP[alias] = canonical + + +def _resolve_country(country: str) -> str: + """Resolve a country name or alias to its canonical name.""" + _load_all() + canonical = _ALIAS_MAP.get(country) + if canonical is None: raise NotImplementedError( - f"income tax formula for {country} not yet implemented" + f"Tax system for '{country}' not yet implemented. " + f"Supported: {', '.join(sorted(_ALIAS_MAP.keys()))}" ) + return canonical -def income_tax_germany(taxable_income: Decimal) -> Decimal: - """Income tax formula for Germany. +def _get_params(country: str, year: int) -> TaxParams: + """Get TaxParams for a country and year. + + Falls back to the closest available year if the exact year isn't found. + """ + canonical = _resolve_country(country) + years = _REGISTRY[canonical] + if year in years: + return years[year] + # Fall back: nearest year (prefer most recent past year) + available = sorted(years.keys()) + best = available[-1] # latest available as default + for y in available: + if y <= year: + best = y + return years[best] + + +def supported_countries() -> list[str]: + """Return list of supported country names.""" + _load_all() + return sorted(_REGISTRY.keys()) + + +def available_years(country: str) -> list[int]: + """Return sorted list of years available for a country.""" + canonical = _resolve_country(country) + return sorted(_REGISTRY[canonical].keys()) + + +# ── TaxSystem: computes taxes from TaxParams ────────────────── + + +class TaxSystem: + """Tax calculation engine driven by a TaxParams data package. + + Instantiate via ``get_tax_system(country, year_or_date)``. + """ + + def __init__(self, params: TaxParams): + self.params = params + + @property + def country(self) -> str: + return self.params.country + + @property + def year(self) -> int: + return self.params.year + + @property + def currency(self) -> str: + """ISO 4217 currency code for this tax system (e.g. "EUR", "USD").""" + return self.params.currency + + @property + def bracket_info(self) -> list[dict]: + """Zone data for UI visualization.""" + return self.params.zones + + # ── Income tax ──────────────────────────────────────────── + + def income_tax(self, taxable_income: Decimal) -> Decimal: + """Compute income tax using the formula type from the data package.""" + if self.params.formula_type == "german_progressive": + return self._german_progressive(float(taxable_income)) + elif self.params.formula_type == "marginal_brackets": + return self._marginal_brackets(float(taxable_income)) + elif self.params.formula_type == "flat_rate": + return self._flat_rate(float(taxable_income)) + raise NotImplementedError(f"Unknown formula type: {self.params.formula_type}") + + def _german_progressive(self, ti: float) -> Decimal: + """§32a EStG formula with zone parameters from data.""" + for zone in self.params.zones: + up_to = zone["up_to"] + if up_to is not None and ti > up_to: + continue + ztype = zone["type"] + if ztype == "zero": + return Decimal(0) + elif ztype == "quadratic": + ref = zone["reference_offset"] + div = zone["divisor"] + a = zone["a"] + b = zone["b"] + c = zone.get("c", 0) + y = (ti - ref) / div + tax = (a * y + b) * y + c + return Decimal(str(round(tax))) + elif ztype == "linear": + rate = zone["rate"] + offset = zone["offset"] + tax = rate * ti + offset + return Decimal(str(round(tax))) + return Decimal(0) + + def _marginal_brackets(self, ti: float) -> Decimal: + """Standard marginal bracket formula (used by most countries). + + Each zone defines a marginal rate applied only to the income + within that bracket. The personal allowance is handled as a + zone with rate 0. + """ + ti = max(ti - self.params.basic_allowance, 0) + total_tax = 0.0 + prev_limit = 0.0 + for zone in self.params.zones: + rate = zone["rate"] + up_to = zone["up_to"] + if up_to is None: + # Top bracket — all remaining income + taxable_in_zone = max(ti - prev_limit, 0) + else: + bracket_width = up_to - prev_limit + taxable_in_zone = min(max(ti - prev_limit, 0), bracket_width) + total_tax += taxable_in_zone * rate + if up_to is not None and ti <= up_to: + break + prev_limit = up_to if up_to is not None else prev_limit + return Decimal(str(round(total_tax))) + + def _flat_rate(self, ti: float) -> Decimal: + """Flat-rate income tax (e.g. Estonia). + + A single rate applied to all income above the basic allowance. + """ + taxable = max(ti - self.params.basic_allowance, 0) + rate = self.params.zones[0]["rate"] + return Decimal(str(round(taxable * rate))) + + # ── Solidarity surcharge ────────────────────────────────── + + def solidarity_surcharge(self, income_tax_amount: Decimal) -> Decimal: + """Surcharge on income tax (e.g. 5.5% Solidaritätszuschlag).""" + if income_tax_amount <= 0 or self.params.soli_rate == 0: + return Decimal(0) + surcharge = income_tax_amount * Decimal(str(self.params.soli_rate)) + return surcharge.quantize(Decimal("0.01")) + + def total_tax(self, taxable_income: Decimal) -> Decimal: + """Income tax + all surcharges.""" + it = self.income_tax(taxable_income) + soli = self.solidarity_surcharge(it) + return it + soli + + # ── VAT ─────────────────────────────────────────────────── + + def vat_rate_standard(self) -> Decimal: + return Decimal(str(self.params.vat_standard)) + + def vat_rate_reduced(self) -> Decimal: + return Decimal(str(self.params.vat_reduced)) + + +# ── Public factory ──────────────────────────────────────────── + + +def get_tax_system( + country: str, + date: Optional[datetime.date] = None, +) -> TaxSystem: + """Get a TaxSystem for the given country and date. Args: - taxable_income (Decimal): [description] + country: Country name or alias (e.g. "Germany", "Deutschland"). + date: Date determining which year's tax rules apply. + Defaults to today. - Returns: - Decimal: [description] + Raises: + NotImplementedError: If the country has no data packages. """ - ti = taxable_income - if ti <= 9408: - tax = 0 - elif 9408 < ti <= 14532: - tax = (0.14 + (ti - 9408) * 972.87 * 1e-8) * (ti - 9408) - elif 14532 < ti <= 57051: - tax = (0.2397 + (ti - 14532) * 212.02 * 1e-8) * (ti - 14532) + 972.79 - elif 57051 < ti <= 270500: - tax = (0.42 * ti) - 8963.74 - else: - tax = 0.45 * ti - 17078.74 - tax = round(tax) - return tax + if date is None: + date = datetime.date.today() + params = _get_params(country, date.year) + return TaxSystem(params) + + +# ── Backward-compatible API ─────────────────────────────────── + + +def income_tax(taxable_income: Decimal, country: str) -> Decimal: + """Compute income tax for a given country (current year). Legacy wrapper.""" + system = get_tax_system(country) + return system.income_tax(taxable_income) + + +def income_tax_germany(taxable_income: Decimal) -> Decimal: + """Income tax using the current German tariff. Legacy wrapper.""" + return get_tax_system("Germany").income_tax(taxable_income) diff --git a/tuttle/tax_data/__init__.py b/tuttle/tax_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tuttle/tax_data/brazil/2024.json b/tuttle/tax_data/brazil/2024.json new file mode 100644 index 0000000..31eee83 --- /dev/null +++ b/tuttle/tax_data/brazil/2024.json @@ -0,0 +1,50 @@ +{ + "country": "Brazil", + "country_aliases": ["Brasil"], + "year": 2024, + "formula_type": "marginal_brackets", + "source": "Receita Federal — IRPF 2024 (tabela mensal × 12)", + "currency": "BRL", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "Isento", + "up_to": 26963.20, + "rate": 0.0 + }, + { + "label": "7.5%", + "up_to": 33919.80, + "rate": 0.075 + }, + { + "label": "15%", + "up_to": 45012.60, + "rate": 0.15 + }, + { + "label": "22.5%", + "up_to": 55976.16, + "rate": 0.225 + }, + { + "label": "27.5%", + "up_to": null, + "rate": 0.275 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate solidarity surcharge in Brazil" + }, + + "vat": { + "standard_rate": 0.0, + "reduced_rate": 0.0, + "comment": "Brazil has no single federal VAT; uses ICMS/ISS/IPI — not modeled here" + } +} diff --git a/tuttle/tax_data/brazil/2025.json b/tuttle/tax_data/brazil/2025.json new file mode 100644 index 0000000..d47920e --- /dev/null +++ b/tuttle/tax_data/brazil/2025.json @@ -0,0 +1,50 @@ +{ + "country": "Brazil", + "country_aliases": ["Brasil"], + "year": 2025, + "formula_type": "marginal_brackets", + "source": "Receita Federal — IRPF 2025 (tabela mensal × 12, updated brackets)", + "currency": "BRL", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "Isento", + "up_to": 28559.70, + "rate": 0.0 + }, + { + "label": "7.5%", + "up_to": 33919.80, + "rate": 0.075 + }, + { + "label": "15%", + "up_to": 45012.60, + "rate": 0.15 + }, + { + "label": "22.5%", + "up_to": 55976.16, + "rate": 0.225 + }, + { + "label": "27.5%", + "up_to": null, + "rate": 0.275 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate solidarity surcharge in Brazil" + }, + + "vat": { + "standard_rate": 0.0, + "reduced_rate": 0.0, + "comment": "Brazil has no single federal VAT; uses ICMS/ISS/IPI — not modeled here" + } +} diff --git a/tuttle/tax_data/brazil/2026.json b/tuttle/tax_data/brazil/2026.json new file mode 100644 index 0000000..9de0ea5 --- /dev/null +++ b/tuttle/tax_data/brazil/2026.json @@ -0,0 +1,50 @@ +{ + "country": "Brazil", + "country_aliases": ["Brasil"], + "year": 2026, + "formula_type": "marginal_brackets", + "source": "Receita Federal — IRPF 2026 (projected, same upper brackets as 2025)", + "currency": "BRL", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "Isento", + "up_to": 28559.70, + "rate": 0.0 + }, + { + "label": "7.5%", + "up_to": 33919.80, + "rate": 0.075 + }, + { + "label": "15%", + "up_to": 45012.60, + "rate": 0.15 + }, + { + "label": "22.5%", + "up_to": 55976.16, + "rate": 0.225 + }, + { + "label": "27.5%", + "up_to": null, + "rate": 0.275 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate solidarity surcharge in Brazil" + }, + + "vat": { + "standard_rate": 0.0, + "reduced_rate": 0.0, + "comment": "Brazil has no single federal VAT; uses ICMS/ISS/IPI — not modeled here" + } +} diff --git a/tuttle/tax_data/estonia/2024.json b/tuttle/tax_data/estonia/2024.json new file mode 100644 index 0000000..30e1b55 --- /dev/null +++ b/tuttle/tax_data/estonia/2024.json @@ -0,0 +1,29 @@ +{ + "country": "Estonia", + "country_aliases": ["Eesti"], + "year": 2024, + "formula_type": "flat_rate", + "source": "Tulumaksuseadus (Income Tax Act) 2024", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 7848, + "zones": [ + { + "label": "22%", + "up_to": null, + "rate": 0.22 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No solidarity surcharge in Estonia" + }, + + "vat": { + "standard_rate": 0.22, + "reduced_rate": 0.09 + } +} diff --git a/tuttle/tax_data/estonia/2025.json b/tuttle/tax_data/estonia/2025.json new file mode 100644 index 0000000..8ab50c7 --- /dev/null +++ b/tuttle/tax_data/estonia/2025.json @@ -0,0 +1,29 @@ +{ + "country": "Estonia", + "country_aliases": ["Eesti"], + "year": 2025, + "formula_type": "flat_rate", + "source": "Tulumaksuseadus (Income Tax Act) 2025", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 7848, + "zones": [ + { + "label": "22%", + "up_to": null, + "rate": 0.22 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No solidarity surcharge in Estonia" + }, + + "vat": { + "standard_rate": 0.22, + "reduced_rate": 0.09 + } +} diff --git a/tuttle/tax_data/estonia/2026.json b/tuttle/tax_data/estonia/2026.json new file mode 100644 index 0000000..de388ad --- /dev/null +++ b/tuttle/tax_data/estonia/2026.json @@ -0,0 +1,29 @@ +{ + "country": "Estonia", + "country_aliases": ["Eesti"], + "year": 2026, + "formula_type": "flat_rate", + "source": "Tulumaksuseadus (Income Tax Act) 2026", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 7848, + "zones": [ + { + "label": "22%", + "up_to": null, + "rate": 0.22 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No solidarity surcharge in Estonia" + }, + + "vat": { + "standard_rate": 0.22, + "reduced_rate": 0.09 + } +} diff --git a/tuttle/tax_data/france/2024.json b/tuttle/tax_data/france/2024.json new file mode 100644 index 0000000..7fa4e5f --- /dev/null +++ b/tuttle/tax_data/france/2024.json @@ -0,0 +1,49 @@ +{ + "country": "France", + "country_aliases": [], + "year": 2024, + "formula_type": "marginal_brackets", + "source": "Barème progressif 2024 (revenus 2023) — Article 197 CGI", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "0%", + "up_to": 11294, + "rate": 0.0 + }, + { + "label": "11%", + "up_to": 28797, + "rate": 0.11 + }, + { + "label": "30%", + "up_to": 82341, + "rate": 0.30 + }, + { + "label": "41%", + "up_to": 177106, + "rate": 0.41 + }, + { + "label": "45%", + "up_to": null, + "rate": 0.45 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate solidarity surcharge; social contributions (CSG/CRDS) are handled separately" + }, + + "vat": { + "standard_rate": 0.20, + "reduced_rate": 0.055 + } +} diff --git a/tuttle/tax_data/france/2025.json b/tuttle/tax_data/france/2025.json new file mode 100644 index 0000000..70a6f9a --- /dev/null +++ b/tuttle/tax_data/france/2025.json @@ -0,0 +1,49 @@ +{ + "country": "France", + "country_aliases": [], + "year": 2025, + "formula_type": "marginal_brackets", + "source": "Barème progressif 2025 (revenus 2024) — Article 197 CGI", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "0%", + "up_to": 11497, + "rate": 0.0 + }, + { + "label": "11%", + "up_to": 29315, + "rate": 0.11 + }, + { + "label": "30%", + "up_to": 83823, + "rate": 0.30 + }, + { + "label": "41%", + "up_to": 180294, + "rate": 0.41 + }, + { + "label": "45%", + "up_to": null, + "rate": 0.45 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate solidarity surcharge; social contributions (CSG/CRDS) are handled separately" + }, + + "vat": { + "standard_rate": 0.20, + "reduced_rate": 0.055 + } +} diff --git a/tuttle/tax_data/france/2026.json b/tuttle/tax_data/france/2026.json new file mode 100644 index 0000000..a40a987 --- /dev/null +++ b/tuttle/tax_data/france/2026.json @@ -0,0 +1,49 @@ +{ + "country": "France", + "country_aliases": [], + "year": 2026, + "formula_type": "marginal_brackets", + "source": "Barème progressif 2026 (revenus 2025) — Article 197 CGI", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "0%", + "up_to": 11497, + "rate": 0.0 + }, + { + "label": "11%", + "up_to": 29315, + "rate": 0.11 + }, + { + "label": "30%", + "up_to": 83823, + "rate": 0.30 + }, + { + "label": "41%", + "up_to": 180294, + "rate": 0.41 + }, + { + "label": "45%", + "up_to": null, + "rate": 0.45 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate solidarity surcharge; social contributions (CSG/CRDS) are handled separately" + }, + + "vat": { + "standard_rate": 0.20, + "reduced_rate": 0.055 + } +} diff --git a/tuttle/tax_data/germany/2024.json b/tuttle/tax_data/germany/2024.json new file mode 100644 index 0000000..3d1e6f7 --- /dev/null +++ b/tuttle/tax_data/germany/2024.json @@ -0,0 +1,62 @@ +{ + "country": "Germany", + "country_aliases": ["Deutschland"], + "year": 2024, + "formula_type": "german_progressive", + "source": "§32a EStG 2024", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 11604, + "zones": [ + { + "label": "Tax-free zone", + "up_to": 11604, + "type": "zero" + }, + { + "label": "Zone 2 (14%–24%)", + "up_to": 17005, + "type": "quadratic", + "reference_offset": 11604, + "divisor": 10000, + "a": 922.98, + "b": 1400 + }, + { + "label": "Zone 3 (24%–42%)", + "up_to": 66760, + "type": "quadratic", + "reference_offset": 17005, + "divisor": 10000, + "a": 181.19, + "b": 2397, + "c": 991.21 + }, + { + "label": "Zone 4 (42%)", + "up_to": 277825, + "type": "linear", + "rate": 0.42, + "offset": -10636.31 + }, + { + "label": "Zone 5 (45%)", + "up_to": null, + "type": "linear", + "rate": 0.45, + "offset": -18971.56 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.055, + "comment": "5.5% of income tax" + }, + + "vat": { + "standard_rate": 0.19, + "reduced_rate": 0.07 + } +} diff --git a/tuttle/tax_data/germany/2025.json b/tuttle/tax_data/germany/2025.json new file mode 100644 index 0000000..89dda3e --- /dev/null +++ b/tuttle/tax_data/germany/2025.json @@ -0,0 +1,62 @@ +{ + "country": "Germany", + "country_aliases": ["Deutschland"], + "year": 2025, + "formula_type": "german_progressive", + "source": "§32a EStG 2025", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 12084, + "zones": [ + { + "label": "Tax-free zone", + "up_to": 12084, + "type": "zero" + }, + { + "label": "Zone 2 (14%–24%)", + "up_to": 17430, + "type": "quadratic", + "reference_offset": 12084, + "divisor": 10000, + "a": 932.30, + "b": 1400 + }, + { + "label": "Zone 3 (24%–42%)", + "up_to": 68430, + "type": "quadratic", + "reference_offset": 17430, + "divisor": 10000, + "a": 176.63, + "b": 2397, + "c": 1015.42 + }, + { + "label": "Zone 4 (42%)", + "up_to": 277825, + "type": "linear", + "rate": 0.42, + "offset": -10733.49 + }, + { + "label": "Zone 5 (45%)", + "up_to": null, + "type": "linear", + "rate": 0.45, + "offset": -19082.74 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.055, + "comment": "5.5% of income tax" + }, + + "vat": { + "standard_rate": 0.19, + "reduced_rate": 0.07 + } +} diff --git a/tuttle/tax_data/germany/2026.json b/tuttle/tax_data/germany/2026.json new file mode 100644 index 0000000..fdc84cd --- /dev/null +++ b/tuttle/tax_data/germany/2026.json @@ -0,0 +1,62 @@ +{ + "country": "Germany", + "country_aliases": ["Deutschland"], + "year": 2026, + "formula_type": "german_progressive", + "source": "§32a EStG 2026 (Steuerfortentwicklungsgesetz)", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 12096, + "zones": [ + { + "label": "Tax-free zone", + "up_to": 12096, + "type": "zero" + }, + { + "label": "Zone 2 (14%–24%)", + "up_to": 17443, + "type": "quadratic", + "reference_offset": 12096, + "divisor": 10000, + "a": 932.30, + "b": 1400 + }, + { + "label": "Zone 3 (24%–42%)", + "up_to": 68480, + "type": "quadratic", + "reference_offset": 17443, + "divisor": 10000, + "a": 176.63, + "b": 2397, + "c": 1015.84 + }, + { + "label": "Zone 4 (42%)", + "up_to": 277825, + "type": "linear", + "rate": 0.42, + "offset": -10739.80 + }, + { + "label": "Zone 5 (45%)", + "up_to": null, + "type": "linear", + "rate": 0.45, + "offset": -19089.05 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.055, + "comment": "5.5% of income tax" + }, + + "vat": { + "standard_rate": 0.19, + "reduced_rate": 0.07 + } +} diff --git a/tuttle/tax_data/spain/2024.json b/tuttle/tax_data/spain/2024.json new file mode 100644 index 0000000..d2c01a0 --- /dev/null +++ b/tuttle/tax_data/spain/2024.json @@ -0,0 +1,54 @@ +{ + "country": "Spain", + "country_aliases": ["España", "Espana"], + "year": 2024, + "formula_type": "marginal_brackets", + "source": "IRPF 2024 — Ley del IRPF (state + general autonomous community rates)", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 5550, + "zones": [ + { + "label": "19%", + "up_to": 12450, + "rate": 0.19 + }, + { + "label": "24%", + "up_to": 20200, + "rate": 0.24 + }, + { + "label": "30%", + "up_to": 35200, + "rate": 0.30 + }, + { + "label": "37%", + "up_to": 60000, + "rate": 0.37 + }, + { + "label": "45%", + "up_to": 300000, + "rate": 0.45 + }, + { + "label": "47%", + "up_to": null, + "rate": 0.47 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No solidarity surcharge in Spain" + }, + + "vat": { + "standard_rate": 0.21, + "reduced_rate": 0.10 + } +} diff --git a/tuttle/tax_data/spain/2025.json b/tuttle/tax_data/spain/2025.json new file mode 100644 index 0000000..29aa352 --- /dev/null +++ b/tuttle/tax_data/spain/2025.json @@ -0,0 +1,54 @@ +{ + "country": "Spain", + "country_aliases": ["España", "Espana"], + "year": 2025, + "formula_type": "marginal_brackets", + "source": "IRPF 2025 — Ley del IRPF (state + general autonomous community rates)", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 5550, + "zones": [ + { + "label": "19%", + "up_to": 12450, + "rate": 0.19 + }, + { + "label": "24%", + "up_to": 20200, + "rate": 0.24 + }, + { + "label": "30%", + "up_to": 35200, + "rate": 0.30 + }, + { + "label": "37%", + "up_to": 60000, + "rate": 0.37 + }, + { + "label": "45%", + "up_to": 300000, + "rate": 0.45 + }, + { + "label": "47%", + "up_to": null, + "rate": 0.47 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No solidarity surcharge in Spain" + }, + + "vat": { + "standard_rate": 0.21, + "reduced_rate": 0.10 + } +} diff --git a/tuttle/tax_data/spain/2026.json b/tuttle/tax_data/spain/2026.json new file mode 100644 index 0000000..b1765af --- /dev/null +++ b/tuttle/tax_data/spain/2026.json @@ -0,0 +1,54 @@ +{ + "country": "Spain", + "country_aliases": ["España", "Espana"], + "year": 2026, + "formula_type": "marginal_brackets", + "source": "IRPF 2026 — Ley del IRPF (state + general autonomous community rates)", + "currency": "EUR", + + "income_tax": { + "basic_allowance": 5550, + "zones": [ + { + "label": "19%", + "up_to": 12450, + "rate": 0.19 + }, + { + "label": "24%", + "up_to": 20200, + "rate": 0.24 + }, + { + "label": "30%", + "up_to": 35200, + "rate": 0.30 + }, + { + "label": "37%", + "up_to": 60000, + "rate": 0.37 + }, + { + "label": "45%", + "up_to": 300000, + "rate": 0.45 + }, + { + "label": "47%", + "up_to": null, + "rate": 0.47 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No solidarity surcharge in Spain" + }, + + "vat": { + "standard_rate": 0.21, + "reduced_rate": 0.10 + } +} diff --git a/tuttle/tax_data/sweden/2024.json b/tuttle/tax_data/sweden/2024.json new file mode 100644 index 0000000..5f89f91 --- /dev/null +++ b/tuttle/tax_data/sweden/2024.json @@ -0,0 +1,39 @@ +{ + "country": "Sweden", + "country_aliases": ["Sverige"], + "year": 2024, + "formula_type": "marginal_brackets", + "source": "Skatteverket 2024 — kommunalskatt 32.28% avg + statlig inkomstskatt", + "currency": "SEK", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "0% (grundavdrag)", + "up_to": 24238, + "rate": 0.0 + }, + { + "label": "~32% (kommunalskatt)", + "up_to": 598500, + "rate": 0.3228 + }, + { + "label": "~52% (kommunal + statlig)", + "up_to": null, + "rate": 0.5228 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate surcharge; state tax is included in brackets" + }, + + "vat": { + "standard_rate": 0.25, + "reduced_rate": 0.12 + } +} diff --git a/tuttle/tax_data/sweden/2025.json b/tuttle/tax_data/sweden/2025.json new file mode 100644 index 0000000..207e45b --- /dev/null +++ b/tuttle/tax_data/sweden/2025.json @@ -0,0 +1,39 @@ +{ + "country": "Sweden", + "country_aliases": ["Sverige"], + "year": 2025, + "formula_type": "marginal_brackets", + "source": "Skatteverket 2025 — kommunalskatt 32.37% avg + statlig inkomstskatt", + "currency": "SEK", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "0% (grundavdrag)", + "up_to": 24900, + "rate": 0.0 + }, + { + "label": "~32% (kommunalskatt)", + "up_to": 613900, + "rate": 0.3237 + }, + { + "label": "~52% (kommunal + statlig)", + "up_to": null, + "rate": 0.5237 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate surcharge; state tax is included in brackets" + }, + + "vat": { + "standard_rate": 0.25, + "reduced_rate": 0.12 + } +} diff --git a/tuttle/tax_data/sweden/2026.json b/tuttle/tax_data/sweden/2026.json new file mode 100644 index 0000000..91e29d1 --- /dev/null +++ b/tuttle/tax_data/sweden/2026.json @@ -0,0 +1,39 @@ +{ + "country": "Sweden", + "country_aliases": ["Sverige"], + "year": 2026, + "formula_type": "marginal_brackets", + "source": "Skatteverket 2026 — projected, same parameters as 2025", + "currency": "SEK", + + "income_tax": { + "basic_allowance": 0, + "zones": [ + { + "label": "0% (grundavdrag)", + "up_to": 25500, + "rate": 0.0 + }, + { + "label": "~32% (kommunalskatt)", + "up_to": 625000, + "rate": 0.3237 + }, + { + "label": "~52% (kommunal + statlig)", + "up_to": null, + "rate": 0.5237 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No separate surcharge; state tax is included in brackets" + }, + + "vat": { + "standard_rate": 0.25, + "reduced_rate": 0.12 + } +} diff --git a/tuttle/tax_data/united_states/2024.json b/tuttle/tax_data/united_states/2024.json new file mode 100644 index 0000000..f691a81 --- /dev/null +++ b/tuttle/tax_data/united_states/2024.json @@ -0,0 +1,60 @@ +{ + "country": "United States", + "country_aliases": ["US", "USA", "United States of America"], + "year": 2024, + "formula_type": "marginal_brackets", + "source": "IRS Revenue Procedure 2023-34 — 2024 federal income tax brackets (single filer)", + "currency": "USD", + + "income_tax": { + "basic_allowance": 14600, + "zones": [ + { + "label": "10%", + "up_to": 11600, + "rate": 0.10 + }, + { + "label": "12%", + "up_to": 47150, + "rate": 0.12 + }, + { + "label": "22%", + "up_to": 100525, + "rate": 0.22 + }, + { + "label": "24%", + "up_to": 191950, + "rate": 0.24 + }, + { + "label": "32%", + "up_to": 243725, + "rate": 0.32 + }, + { + "label": "35%", + "up_to": 609350, + "rate": 0.35 + }, + { + "label": "37%", + "up_to": null, + "rate": 0.37 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No federal solidarity surcharge; state taxes vary" + }, + + "vat": { + "standard_rate": 0.0, + "reduced_rate": 0.0, + "comment": "US has no federal VAT; sales tax varies by state" + } +} diff --git a/tuttle/tax_data/united_states/2025.json b/tuttle/tax_data/united_states/2025.json new file mode 100644 index 0000000..0cb9164 --- /dev/null +++ b/tuttle/tax_data/united_states/2025.json @@ -0,0 +1,60 @@ +{ + "country": "United States", + "country_aliases": ["US", "USA", "United States of America"], + "year": 2025, + "formula_type": "marginal_brackets", + "source": "IRS Revenue Procedure 2024-40 — 2025 federal income tax brackets (single filer)", + "currency": "USD", + + "income_tax": { + "basic_allowance": 15000, + "zones": [ + { + "label": "10%", + "up_to": 11925, + "rate": 0.10 + }, + { + "label": "12%", + "up_to": 48475, + "rate": 0.12 + }, + { + "label": "22%", + "up_to": 103350, + "rate": 0.22 + }, + { + "label": "24%", + "up_to": 197300, + "rate": 0.24 + }, + { + "label": "32%", + "up_to": 250525, + "rate": 0.32 + }, + { + "label": "35%", + "up_to": 626350, + "rate": 0.35 + }, + { + "label": "37%", + "up_to": null, + "rate": 0.37 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No federal solidarity surcharge; state taxes vary" + }, + + "vat": { + "standard_rate": 0.0, + "reduced_rate": 0.0, + "comment": "US has no federal VAT; sales tax varies by state" + } +} diff --git a/tuttle/tax_data/united_states/2026.json b/tuttle/tax_data/united_states/2026.json new file mode 100644 index 0000000..20bbdf5 --- /dev/null +++ b/tuttle/tax_data/united_states/2026.json @@ -0,0 +1,60 @@ +{ + "country": "United States", + "country_aliases": ["US", "USA", "United States of America"], + "year": 2026, + "formula_type": "marginal_brackets", + "source": "IRS — 2026 federal income tax brackets (single filer, projected)", + "currency": "USD", + + "income_tax": { + "basic_allowance": 15000, + "zones": [ + { + "label": "10%", + "up_to": 11925, + "rate": 0.10 + }, + { + "label": "12%", + "up_to": 48475, + "rate": 0.12 + }, + { + "label": "22%", + "up_to": 103350, + "rate": 0.22 + }, + { + "label": "24%", + "up_to": 197300, + "rate": 0.24 + }, + { + "label": "32%", + "up_to": 250525, + "rate": 0.32 + }, + { + "label": "35%", + "up_to": 626350, + "rate": 0.35 + }, + { + "label": "37%", + "up_to": null, + "rate": 0.37 + } + ] + }, + + "solidarity_surcharge": { + "rate": 0.0, + "comment": "No federal solidarity surcharge; state taxes vary" + }, + + "vat": { + "standard_rate": 0.0, + "reduced_rate": 0.0, + "comment": "US has no federal VAT; sales tax varies by state" + } +} diff --git a/tuttle/tax_reserves.py b/tuttle/tax_reserves.py new file mode 100644 index 0000000..80b63c5 --- /dev/null +++ b/tuttle/tax_reserves.py @@ -0,0 +1,246 @@ +"""Tax reserve calculations for freelancers. + +Computes how much of the freelancer's revenue must be set aside for +VAT payments and estimated income tax, yielding the actual spendable income. +""" + +import datetime +import logging +from decimal import Decimal +from typing import List, NamedTuple, Optional + +from .model import Invoice +from .tax import get_tax_system + +logger = logging.getLogger(__name__) + + +class VATReserve(NamedTuple): + """VAT collected that must be remitted to the tax authority.""" + + vat_collected: Decimal # total VAT on invoices in the period + invoice_count: int # number of invoices considered + period_start: datetime.date + period_end: datetime.date + + +class IncomeTaxReserve(NamedTuple): + """Estimated income tax reserve for the current year.""" + + estimated_annual_tax: Decimal # full-year income tax estimate + solidarity_surcharge: Decimal # full-year soli estimate + total_annual_reserve: Decimal # tax + soli + ytd_reserve: Decimal # prorated to current date + effective_rate: Decimal # total_annual_reserve / annualized_income + + +class SpendableIncome(NamedTuple): + """What the freelancer can actually spend.""" + + gross_revenue_ytd: Decimal # total invoiced amount (incl. VAT) + net_revenue_ytd: Decimal # gross minus VAT + vat_reserve: Decimal # VAT to set aside + income_tax_reserve: Decimal # estimated income tax + soli (prorated) + spendable: Decimal # net_revenue - income_tax_reserve + + +def _invoice_currency(inv: Invoice) -> Optional[str]: + """Return the ISO 4217 currency code for an invoice, or None.""" + if inv.contract and inv.contract.currency: + return inv.contract.currency + return None + + +def compute_vat_reserves( + invoices: List[Invoice], + period_start: datetime.date, + period_end: datetime.date, + currency: Optional[str] = None, +) -> VATReserve: + """Sum VAT collected on non-cancelled invoices in the given period. + + If *currency* is given, only invoices denominated in that currency are + included; others are silently skipped (a debug log is emitted). + """ + vat_total = Decimal(0) + count = 0 + skipped = 0 + for inv in invoices: + if inv.cancelled: + continue + if period_start <= inv.date <= period_end: + if currency and _invoice_currency(inv) not in (currency, None): + skipped += 1 + continue + vat_total += inv.VAT_total + count += 1 + if skipped: + logger.debug( + "compute_vat_reserves: skipped %d invoice(s) with currency != %s", + skipped, + currency, + ) + return VATReserve( + vat_collected=vat_total, + invoice_count=count, + period_start=period_start, + period_end=period_end, + ) + + +def compute_income_tax_reserve( + net_revenue_ytd: Decimal, + country: str, + deductions: Decimal = Decimal(0), +) -> IncomeTaxReserve: + """Estimate income tax reserve based on year-to-date net revenue. + + Annualizes the YTD net revenue, computes the tax on that projected + annual income, then prorates back to the current date. + """ + today = datetime.date.today() + try: + tax_system = get_tax_system(country, date=today) + except NotImplementedError: + return IncomeTaxReserve( + estimated_annual_tax=Decimal(0), + solidarity_surcharge=Decimal(0), + total_annual_reserve=Decimal(0), + ytd_reserve=Decimal(0), + effective_rate=Decimal(0), + ) + year_start = today.replace(month=1, day=1) + days_elapsed = max((today - year_start).days, 1) + days_in_year = 365 + + # Annualize: project YTD revenue to full year + annualized_income = (net_revenue_ytd - deductions) * days_in_year / days_elapsed + + if annualized_income <= 0: + return IncomeTaxReserve( + estimated_annual_tax=Decimal(0), + solidarity_surcharge=Decimal(0), + total_annual_reserve=Decimal(0), + ytd_reserve=Decimal(0), + effective_rate=Decimal(0), + ) + + # Compute annual tax + annual_tax = tax_system.income_tax(annualized_income) + annual_soli = tax_system.solidarity_surcharge(annual_tax) + total_annual = annual_tax + annual_soli + + # Prorate to current date + ytd_reserve = (total_annual * days_elapsed / days_in_year).quantize(Decimal("0.01")) + + # Effective rate + effective_rate = ( + (total_annual / annualized_income) if annualized_income > 0 else Decimal(0) + ) + + return IncomeTaxReserve( + estimated_annual_tax=annual_tax, + solidarity_surcharge=annual_soli, + total_annual_reserve=total_annual, + ytd_reserve=ytd_reserve, + effective_rate=effective_rate.quantize(Decimal("0.0001")), + ) + + +def compute_spendable_income( + invoices: List[Invoice], + country: str, + deductions: Decimal = Decimal(0), + currency: Optional[str] = None, +) -> SpendableIncome: + """Compute spendable income: what's left after VAT and income tax reserves. + + This answers the freelancer's core question: "How much of this money is mine?" + + If *currency* is given (the tax system's native currency), only invoices + denominated in that currency are counted. If not given, the currency is + resolved automatically from the tax system for *country*. + """ + today = datetime.date.today() + year_start = today.replace(month=1, day=1) + + if currency is None: + try: + tax_system = get_tax_system(country, date=today) + currency = tax_system.currency + except NotImplementedError: + pass + + gross_ytd = Decimal(0) + vat_ytd = Decimal(0) + skipped = 0 + + for inv in invoices: + if inv.cancelled: + continue + if inv.date >= year_start: + if currency and _invoice_currency(inv) not in (currency, None): + skipped += 1 + continue + gross_ytd += inv.total + vat_ytd += inv.VAT_total + + if skipped: + logger.debug( + "compute_spendable_income: skipped %d invoice(s) with currency != %s", + skipped, + currency, + ) + + net_ytd = gross_ytd - vat_ytd + + tax_reserve = compute_income_tax_reserve(net_ytd, country, deductions) + + spendable = net_ytd - tax_reserve.ytd_reserve + + return SpendableIncome( + gross_revenue_ytd=gross_ytd, + net_revenue_ytd=net_ytd, + vat_reserve=vat_ytd, + income_tax_reserve=tax_reserve.ytd_reserve, + spendable=spendable, + ) + + +def quarterly_vat_breakdown( + invoices: List[Invoice], + year: Optional[int] = None, + currency: Optional[str] = None, +) -> list: + """VAT breakdown by quarter for the given year. + + Returns list of dicts: quarter, vat_collected, invoice_count, period_start, period_end. + """ + if year is None: + year = datetime.date.today().year + + quarters = [] + for q in range(1, 5): + start_month = (q - 1) * 3 + 1 + end_month = q * 3 + period_start = datetime.date(year, start_month, 1) + if end_month == 12: + period_end = datetime.date(year, 12, 31) + else: + period_end = datetime.date(year, end_month + 1, 1) - datetime.timedelta( + days=1 + ) + + reserve = compute_vat_reserves( + invoices, period_start, period_end, currency=currency + ) + quarters.append( + { + "quarter": f"Q{q}", + "vat_collected": reserve.vat_collected, + "invoice_count": reserve.invoice_count, + "period_start": period_start, + "period_end": period_end, + } + ) + return quarters diff --git a/tuttle_tests/test_app_start.py b/tuttle_tests/test_app_start.py index 20faa3c..e07031f 100644 --- a/tuttle_tests/test_app_start.py +++ b/tuttle_tests/test_app_start.py @@ -407,6 +407,15 @@ def test_build_edit_content_new(self): assert isinstance(controls, list) +class _StubContractIntent: + """Minimal stand-in for ContractIntent used in tests without a database.""" + + def get_default_currency(self): + from tuttle.app.core.intent_result import IntentResult + + return IntentResult(was_intent_successful=True, data="EUR") + + class TestContractSidePanel: """Exercise ContractSidePanel construction and content building.""" @@ -417,7 +426,7 @@ def _make_panel(self): on_close=_noop, on_save=_noop, on_delete=_noop, - intent=None, + intent=_StubContractIntent(), client_storage=None, on_edit_requested=_noop, ) diff --git a/tuttle_tests/test_dashboard.py b/tuttle_tests/test_dashboard.py index 1fdb3e5..cccae5c 100644 --- a/tuttle_tests/test_dashboard.py +++ b/tuttle_tests/test_dashboard.py @@ -25,7 +25,12 @@ revenue_history, revenue_curve, ) -from tuttle.kpi import compute_kpis, monthly_revenue_breakdown, project_budget_status +from tuttle.kpi import ( + compute_kpis, + monthly_revenue_breakdown, + monthly_spendable_breakdown, + project_budget_status, +) # ── Fixtures ────────────────────────────────────────────────── @@ -275,6 +280,24 @@ def test_empty_data(self): assert kpis.active_projects == 0 assert kpis.active_contracts == 0 + def test_tax_reserves_populated(self, paid_invoice, active_contract, project): + kpis = compute_kpis([paid_invoice], [active_contract], [project]) + assert kpis.vat_reserve >= 0 + assert kpis.income_tax_reserve >= 0 + assert kpis.spendable_income <= kpis.total_revenue_ytd + assert kpis.tax_currency == "EUR" + + def test_spendable_less_than_gross(self, paid_invoice, active_contract, project): + kpis = compute_kpis([paid_invoice], [active_contract], [project]) + if kpis.total_revenue_ytd > 0: + assert kpis.spendable_income < kpis.total_revenue_ytd + + def test_empty_data_tax_reserves(self): + kpis = compute_kpis([], [], []) + assert kpis.vat_reserve == 0 + assert kpis.income_tax_reserve == 0 + assert kpis.spendable_income == 0 + class TestMonthlyRevenueBreakdown: def test_empty_invoices(self): @@ -288,6 +311,111 @@ def test_with_invoices(self, paid_invoice): total = sum(float(m["revenue"]) for m in result) assert total > 0 + def test_cancelled_invoices_excluded(self, paid_invoice): + paid_invoice.cancelled = True + result = monthly_revenue_breakdown([paid_invoice], n_months=3) + total = sum(float(m["revenue"]) for m in result) + assert total == 0 + + def test_n_months_controls_bucket_count(self): + result_3 = monthly_revenue_breakdown([], n_months=3) + result_12 = monthly_revenue_breakdown([], n_months=12) + assert len(result_3) < len(result_12) + + def test_months_are_sorted(self, paid_invoice): + result = monthly_revenue_breakdown([paid_invoice], n_months=6) + keys = [m["month"] for m in result] + assert keys == sorted(keys) + + +class TestMonthlySpendableBreakdown: + def test_with_invoices(self, paid_invoice): + result = monthly_spendable_breakdown( + [paid_invoice], country="Germany", n_months=3 + ) + assert isinstance(result, list) + assert len(result) > 0 + for month in result: + assert month["net_revenue"] == month["gross_revenue"] - month["vat_due"] + assert ( + month["spendable"] == month["net_revenue"] - month["income_tax_true_up"] + ) + + def test_empty_invoices(self): + result = monthly_spendable_breakdown([], country="Germany", n_months=3) + assert isinstance(result, list) + assert len(result) > 0 + for month in result: + assert month["gross_revenue"] == 0 + assert month["vat_due"] == 0 + assert month["net_revenue"] == 0 + + def test_true_up_deltas_reconcile(self, paid_invoice, unpaid_invoice): + today = datetime.date.today() + paid_invoice.date = today.replace(day=1) + unpaid_invoice.date = ( + today.replace(day=1) - datetime.timedelta(days=32) + ).replace(day=1) + result = monthly_spendable_breakdown( + [paid_invoice, unpaid_invoice], + country="Germany", + n_months=4, + ) + total_true_up = sum( + month["income_tax_true_up"] + for month in result + if month["month"].startswith(str(today.year)) + ) + assert total_true_up >= 0 + + def test_cancelled_invoices_excluded(self, paid_invoice): + paid_invoice.cancelled = True + result = monthly_spendable_breakdown( + [paid_invoice], country="Germany", n_months=3 + ) + for month in result: + assert month["gross_revenue"] == 0 + assert month["vat_due"] == 0 + + def test_spendable_less_than_net(self, paid_invoice): + """Spendable should be <= net after income tax is subtracted.""" + today = datetime.date.today() + paid_invoice.date = today.replace(day=1) + result = monthly_spendable_breakdown( + [paid_invoice], country="Germany", n_months=3 + ) + this_month = [m for m in result if m["month"] == today.strftime("%Y-%m")] + if this_month and this_month[0]["net_revenue"] > 0: + assert this_month[0]["spendable"] <= this_month[0]["net_revenue"] + + def test_spain(self, paid_invoice): + """Spendable breakdown works with a non-Germany country.""" + result = monthly_spendable_breakdown( + [paid_invoice], country="Spain", n_months=3 + ) + assert isinstance(result, list) + assert len(result) > 0 + for month in result: + assert month["net_revenue"] == month["gross_revenue"] - month["vat_due"] + + def test_non_current_year_months_have_zero_tax(self, paid_invoice): + """Months in previous years should have zero income_tax_true_up.""" + today = datetime.date.today() + paid_invoice.date = datetime.date(today.year - 1, 6, 15) + result = monthly_spendable_breakdown( + [paid_invoice], country="Germany", n_months=18 + ) + for month in result: + if not month["month"].startswith(str(today.year)): + assert month["income_tax_true_up"] == 0 + + def test_months_sorted(self, paid_invoice): + result = monthly_spendable_breakdown( + [paid_invoice], country="Germany", n_months=6 + ) + keys = [m["month"] for m in result] + assert keys == sorted(keys) + class TestProjectBudgetStatus: def test_project_without_volume(self, project): diff --git a/tuttle_tests/test_tax.py b/tuttle_tests/test_tax.py index 051f512..5c74b4e 100644 --- a/tuttle_tests/test_tax.py +++ b/tuttle_tests/test_tax.py @@ -1,6 +1,526 @@ +"""Tests for data-driven tax system and tax reserve calculations.""" + +import datetime +from decimal import Decimal + +import pytest + from tuttle import tax +from tuttle.tax import TaxSystem, get_tax_system, supported_countries, available_years +from tuttle.tax_reserves import ( + compute_income_tax_reserve, + compute_spendable_income, + compute_vat_reserves, + quarterly_vat_breakdown, +) +from tuttle.kpi import monthly_spendable_breakdown +from tuttle.model import ( + Address, + Client, + Contact, + Contract, + Invoice, + InvoiceItem, + Project, +) + + +# ── Fixtures ────────────────────────────────────────────────── + + +@pytest.fixture +def german_tax(): + """Current-year German tax system.""" + return get_tax_system("Germany") + + +@pytest.fixture +def german_tax_2024(): + return get_tax_system("Germany", date=datetime.date(2024, 6, 1)) + + +@pytest.fixture +def german_tax_2025(): + return get_tax_system("Germany", date=datetime.date(2025, 6, 1)) + + +@pytest.fixture +def german_tax_2026(): + return get_tax_system("Germany", date=datetime.date(2026, 6, 1)) + + +def _make_invoice(date, items_data, cancelled=False): + """Create a minimal Invoice with InvoiceItems for testing. + + items_data: list of (quantity, unit_price, vat_rate) tuples. + """ + contact = Contact(name="Test Contact", email="test@example.com") + client = Client(name="Test Client", invoicing_contact=contact) + contract = Contract( + title="Test Contract", + client=client, + rate=Decimal("100"), + currency="EUR", + unit="hour", + units_per_workday=8, + term_of_payment=30, + VAT_rate=Decimal("0.19"), + ) + project = Project(title="Test Project", tag="test", contract=contract) + invoice = Invoice( + number=f"INV-{date.isoformat()}", + date=date, + contract=contract, + project=project, + cancelled=cancelled, + sent=True, + paid=True, + ) + for qty, price, vat_rate in items_data: + InvoiceItem( + invoice=invoice, + start_date=date, + end_date=date, + quantity=qty, + unit="hour", + unit_price=Decimal(str(price)), + VAT_rate=Decimal(str(vat_rate)), + description="Test item", + ) + return invoice + + +# ── TaxSystem framework ────────────────────────────────────── + + +class TestTaxSystemFramework: + def test_get_tax_system_germany(self): + system = get_tax_system("Germany") + assert isinstance(system, TaxSystem) + assert system.country == "Germany" + + def test_get_tax_system_deutschland(self): + system = get_tax_system("Deutschland") + assert isinstance(system, TaxSystem) + assert system.country == "Germany" + + def test_get_tax_system_unsupported(self): + with pytest.raises(NotImplementedError, match="not yet implemented"): + get_tax_system("Narnia") + + def test_supported_countries(self): + countries = supported_countries() + assert "Germany" in countries + + def test_available_years(self): + years = available_years("Germany") + assert 2024 in years + assert 2025 in years + assert 2026 in years + + def test_year_selection(self): + """Different dates yield different year parameters.""" + sys_2024 = get_tax_system("Germany", date=datetime.date(2024, 7, 1)) + sys_2026 = get_tax_system("Germany", date=datetime.date(2026, 7, 1)) + assert sys_2024.year == 2024 + assert sys_2026.year == 2026 + # Basic allowance increased 2024 → 2026 + assert sys_2024.params.basic_allowance < sys_2026.params.basic_allowance + + def test_year_fallback(self): + """Year without data falls back to nearest available.""" + sys = get_tax_system("Germany", date=datetime.date(2030, 1, 1)) + # Should get the latest available year + assert sys.year == max(available_years("Germany")) + + +# ── German income tax ──────────────────────────────────────── + + +class TestGermanIncomeTax: + def test_zero_income(self, german_tax): + assert german_tax.income_tax(Decimal(0)) == 0 + + def test_below_basic_allowance(self, german_tax_2026): + """Income below basic allowance (12,096€ for 2026) → 0 tax.""" + assert german_tax_2026.income_tax(Decimal("10000")) == 0 + assert german_tax_2026.income_tax(Decimal("12096")) == 0 + + def test_just_above_basic_allowance(self, german_tax): + """Income just above basic allowance → small tax.""" + allowance = german_tax.params.basic_allowance + tax_amount = german_tax.income_tax(Decimal(str(allowance + 200))) + assert tax_amount > 0 + assert tax_amount < 100 + + def test_zone2(self, german_tax): + """Income in zone 2 → moderate tax.""" + tax_amount = german_tax.income_tax(Decimal("15000")) + assert tax_amount > 0 + assert tax_amount < 2000 + + def test_zone3(self, german_tax): + """Income in zone 3 → progressive tax.""" + tax_amount = german_tax.income_tax(Decimal("50000")) + assert 10000 < tax_amount < 15000 + + def test_zone4(self, german_tax): + """Income in zone 4 → 42% marginal rate.""" + tax_amount = german_tax.income_tax(Decimal("100000")) + assert 25000 < tax_amount < 35000 + + def test_zone5(self, german_tax): + """Income above top threshold → 45% marginal rate.""" + tax_amount = german_tax.income_tax(Decimal("300000")) + assert tax_amount > 100000 + + def test_progressive_nature(self, german_tax): + """Higher income → higher effective rate.""" + rate_30k = german_tax.income_tax(Decimal("30000")) / 30000 + rate_80k = german_tax.income_tax(Decimal("80000")) / 80000 + rate_200k = german_tax.income_tax(Decimal("200000")) / 200000 + assert rate_30k < rate_80k < rate_200k + + def test_backward_compat_function(self): + """Legacy income_tax() and income_tax_germany() still work.""" + amount1 = tax.income_tax(Decimal("50000"), "Germany") + amount2 = tax.income_tax_germany(Decimal("50000")) + assert amount1 == amount2 + + def test_different_years_different_tax(self): + """Tax for same income should differ between years due to allowance changes.""" + income = Decimal("40000") + tax_2024 = get_tax_system("Germany", datetime.date(2024, 1, 1)).income_tax( + income + ) + tax_2026 = get_tax_system("Germany", datetime.date(2026, 1, 1)).income_tax( + income + ) + # Higher basic allowance in 2026 means slightly less tax + assert tax_2026 < tax_2024 + + +# ── Solidarity surcharge ────────────────────────────────────── + + +class TestSolidaritySurcharge: + def test_zero_tax(self, german_tax): + assert german_tax.solidarity_surcharge(Decimal(0)) == 0 + + def test_standard_case(self, german_tax): + tax_amount = Decimal("10000") + soli = german_tax.solidarity_surcharge(tax_amount) + assert soli == Decimal("550.00") + + def test_total_tax(self, german_tax): + """total_tax = income_tax + soli.""" + income = Decimal("50000") + it = german_tax.income_tax(income) + soli = german_tax.solidarity_surcharge(it) + total = german_tax.total_tax(income) + assert total == it + soli + + +# ── VAT ─────────────────────────────────────────────────────── + + +class TestGermanVAT: + def test_standard_rate(self, german_tax): + assert german_tax.vat_rate_standard() == Decimal("0.19") + + def test_reduced_rate(self, german_tax): + assert german_tax.vat_rate_reduced() == Decimal("0.07") + + +# ── VAT reserves ────────────────────────────────────────────── + + +class TestVATReserves: + def test_basic_vat_reserve(self): + invoices = [ + _make_invoice(datetime.date(2026, 1, 15), [(10, 100, 0.19)]), + _make_invoice(datetime.date(2026, 2, 15), [(5, 200, 0.19)]), + ] + result = compute_vat_reserves( + invoices, + datetime.date(2026, 1, 1), + datetime.date(2026, 3, 31), + ) + assert result.vat_collected > 0 + assert result.invoice_count == 2 + + def test_excludes_cancelled(self): + invoices = [ + _make_invoice(datetime.date(2026, 1, 15), [(10, 100, 0.19)]), + _make_invoice(datetime.date(2026, 2, 15), [(5, 200, 0.19)], cancelled=True), + ] + result = compute_vat_reserves( + invoices, + datetime.date(2026, 1, 1), + datetime.date(2026, 3, 31), + ) + assert result.invoice_count == 1 + + def test_excludes_out_of_period(self): + invoices = [ + _make_invoice(datetime.date(2025, 12, 15), [(10, 100, 0.19)]), + _make_invoice(datetime.date(2026, 1, 15), [(5, 200, 0.19)]), + ] + result = compute_vat_reserves( + invoices, + datetime.date(2026, 1, 1), + datetime.date(2026, 3, 31), + ) + assert result.invoice_count == 1 + + def test_empty_invoices(self): + result = compute_vat_reserves( + [], datetime.date(2026, 1, 1), datetime.date(2026, 3, 31) + ) + assert result.vat_collected == Decimal(0) + assert result.invoice_count == 0 + + +# ── Income tax reserve ──────────────────────────────────────── + + +class TestIncomeTaxReserve: + def test_basic_reserve(self): + result = compute_income_tax_reserve(Decimal("40000"), "Germany") + assert result.estimated_annual_tax > 0 + assert result.solidarity_surcharge > 0 + assert result.total_annual_reserve > result.estimated_annual_tax + assert result.ytd_reserve > 0 + assert 0 < result.effective_rate < 1 + + def test_zero_revenue(self): + result = compute_income_tax_reserve(Decimal(0), "Germany") + assert result.estimated_annual_tax == 0 + assert result.ytd_reserve == 0 + + def test_negative_revenue(self): + result = compute_income_tax_reserve(Decimal("-5000"), "Germany") + assert result.estimated_annual_tax == 0 + + def test_ytd_is_prorated(self): + """YTD reserve should be less than annual reserve.""" + result = compute_income_tax_reserve(Decimal("50000"), "Germany") + assert result.ytd_reserve <= result.total_annual_reserve + + +# ── Spendable income ────────────────────────────────────────── + + +class TestSpendableIncome: + def test_basic_spendable(self): + today = datetime.date.today() + invoices = [ + _make_invoice(today.replace(day=1), [(100, 100, 0.19)]), + ] + result = compute_spendable_income(invoices, "Germany") + assert result.gross_revenue_ytd > 0 + assert result.vat_reserve > 0 + assert result.net_revenue_ytd == result.gross_revenue_ytd - result.vat_reserve + assert result.spendable < result.net_revenue_ytd + assert result.spendable == result.net_revenue_ytd - result.income_tax_reserve + + def test_spendable_excludes_cancelled(self): + today = datetime.date.today() + invoices = [ + _make_invoice(today.replace(day=1), [(100, 100, 0.19)]), + _make_invoice(today.replace(day=1), [(50, 100, 0.19)], cancelled=True), + ] + result = compute_spendable_income(invoices, "Germany") + # Only the non-cancelled invoice should count + expected_gross = invoices[0].total + assert result.gross_revenue_ytd == expected_gross + + def test_spendable_excludes_previous_year(self): + today = datetime.date.today() + invoices = [ + _make_invoice(datetime.date(today.year - 1, 6, 1), [(100, 100, 0.19)]), + _make_invoice(today.replace(day=1), [(50, 100, 0.19)]), + ] + result = compute_spendable_income(invoices, "Germany") + # Only this year's invoice should count + assert result.gross_revenue_ytd == invoices[1].total + + def test_monthly_spendable_breakdown_includes_vat_subtraction(self): + today = datetime.date.today() + invoices = [ + _make_invoice(today.replace(day=1), [(20, 100, 0.19)]), + ] + monthly = monthly_spendable_breakdown(invoices, country="Germany", n_months=2) + this_month = [m for m in monthly if m["month"] == today.strftime("%Y-%m")][0] + assert this_month["gross_revenue"] > 0 + assert this_month["vat_due"] > 0 + assert ( + this_month["net_revenue"] + == this_month["gross_revenue"] - this_month["vat_due"] + ) + assert ( + this_month["spendable"] + == this_month["net_revenue"] - this_month["income_tax_true_up"] + ) + + +# ── Quarterly VAT breakdown ────────────────────────────────── + + +class TestQuarterlyVAT: + def test_four_quarters(self): + invoices = [ + _make_invoice(datetime.date(2026, 1, 15), [(10, 100, 0.19)]), + _make_invoice(datetime.date(2026, 5, 15), [(20, 100, 0.19)]), + _make_invoice(datetime.date(2026, 9, 15), [(30, 100, 0.19)]), + ] + result = quarterly_vat_breakdown(invoices, year=2026) + assert len(result) == 4 + assert result[0]["quarter"] == "Q1" + assert result[0]["invoice_count"] == 1 + assert result[1]["quarter"] == "Q2" + assert result[1]["invoice_count"] == 1 + assert result[2]["quarter"] == "Q3" + assert result[2]["invoice_count"] == 1 + assert result[3]["quarter"] == "Q4" + assert result[3]["invoice_count"] == 0 + + def test_defaults_to_current_year(self): + result = quarterly_vat_breakdown([], year=None) + assert len(result) == 4 + assert result[0]["period_start"].year == datetime.date.today().year + + +# ── Spanish tax system (validates marginal_brackets formula) ── + + +@pytest.fixture +def spanish_tax(): + """Current-year Spanish tax system.""" + return get_tax_system("Spain") + + +class TestSpanishTaxSystem: + def test_get_tax_system_spain(self): + system = get_tax_system("Spain") + assert isinstance(system, TaxSystem) + assert system.country == "Spain" + + def test_get_tax_system_espana_alias(self): + system = get_tax_system("España") + assert system.country == "Spain" + + def test_available_years_spain(self): + years = available_years("Spain") + assert 2024 in years + assert 2025 in years + assert 2026 in years + + def test_supported_countries_includes_spain(self): + countries = supported_countries() + assert "Spain" in countries + assert "Germany" in countries + + +class TestSpanishIncomeTax: + def test_zero_income(self, spanish_tax): + assert spanish_tax.income_tax(Decimal(0)) == 0 + + def test_below_personal_allowance(self, spanish_tax): + """Income below mínimo personal (5,550€) → 0 tax.""" + assert spanish_tax.income_tax(Decimal("5000")) == 0 + assert spanish_tax.income_tax(Decimal("5550")) == 0 + + def test_first_bracket(self, spanish_tax): + """Income in first bracket (19% on income above 5,550€ up to 12,450+5,550).""" + # 10,000€ income → 4,450€ taxable at 19% = 845.50 → rounds to 846 + tax_amount = spanish_tax.income_tax(Decimal("10000")) + assert tax_amount > 0 + assert tax_amount < 1000 + + def test_manual_calculation_20000(self, spanish_tax): + """Manual check: 20,000€ income, personal allowance 5,550€. + Taxable = 14,450€ + First 12,450€ at 19% = 2,365.50 + Next 2,000€ at 24% = 480.00 + Total = 2,845.50 → 2846 + """ + tax_amount = spanish_tax.income_tax(Decimal("20000")) + assert 2800 < tax_amount < 2900 + + def test_manual_calculation_50000(self, spanish_tax): + """Manual check: 50,000€ income, personal allowance 5,550€. + Taxable = 44,450€ + First 12,450€ at 19% = 2,365.50 + Next 7,750€ (20,200-12,450) at 24% = 1,860.00 + Next 15,000€ (35,200-20,200) at 30% = 4,500.00 + Next 9,250€ (44,450-35,200) at 37% = 3,422.50 + Total = 12,148.00 → 12148 + """ + tax_amount = spanish_tax.income_tax(Decimal("50000")) + assert 12100 < tax_amount < 12200 + + def test_top_bracket(self, spanish_tax): + """Very high income hits the 47% top marginal rate.""" + tax_amount = spanish_tax.income_tax(Decimal("500000")) + assert tax_amount > 200000 + + def test_progressive_nature(self, spanish_tax): + """Higher income → higher effective rate.""" + rate_20k = spanish_tax.income_tax(Decimal("20000")) / 20000 + rate_60k = spanish_tax.income_tax(Decimal("60000")) / 60000 + rate_200k = spanish_tax.income_tax(Decimal("200000")) / 200000 + assert rate_20k < rate_60k < rate_200k + + def test_no_solidarity_surcharge(self, spanish_tax): + """Spain has no solidarity surcharge.""" + tax_amount = spanish_tax.income_tax(Decimal("50000")) + soli = spanish_tax.solidarity_surcharge(tax_amount) + assert soli == 0 + # total_tax equals income_tax for Spain + assert spanish_tax.total_tax(Decimal("50000")) == tax_amount + + +class TestSpanishVAT: + def test_standard_rate(self, spanish_tax): + assert spanish_tax.vat_rate_standard() == Decimal("0.21") + + def test_reduced_rate(self, spanish_tax): + assert spanish_tax.vat_rate_reduced() == Decimal("0.10") + + +class TestSpanishReserves: + def test_income_tax_reserve_spain(self): + result = compute_income_tax_reserve(Decimal("30000"), "Spain") + assert result.estimated_annual_tax > 0 + assert result.solidarity_surcharge == 0 + assert result.total_annual_reserve == result.estimated_annual_tax + assert 0 < result.effective_rate < 1 + + def test_spendable_income_spain(self): + today = datetime.date.today() + invoices = [ + _make_invoice(today.replace(day=1), [(80, 100, 0.21)]), + ] + result = compute_spendable_income(invoices, "Spain") + assert result.gross_revenue_ytd > 0 + assert result.vat_reserve > 0 + assert result.spendable < result.net_revenue_ytd + +class TestCrossCountryComparison: + def test_different_tax_for_same_income(self): + """Germany and Spain produce different tax for the same income.""" + income = Decimal("50000") + german = get_tax_system("Germany").income_tax(income) + spanish = get_tax_system("Spain").income_tax(income) + # Both should be positive but different + assert german > 0 + assert spanish > 0 + assert german != spanish -def test_income_tax(): - taxable_income = 42000 - income_tax = tax.income_tax_germany(taxable_income) + def test_different_vat_rates(self): + """Germany 19% vs Spain 21%.""" + german = get_tax_system("Germany") + spanish = get_tax_system("Spain") + assert german.vat_rate_standard() < spanish.vat_rate_standard() diff --git a/uv.lock b/uv.lock index 76dc926..8e61840 100644 --- a/uv.lock +++ b/uv.lock @@ -544,7 +544,7 @@ name = "cryptography" version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ @@ -707,6 +707,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/db/417852ec9a2afe6bbd7fd1c797707fa3403b4880a75e132c44e272b9e519/flet-0.81.0-py3-none-any.whl", hash = "sha256:2bc22d9ddf65b30a836fff43455968a1531f0673a41c1c89eec494816ed68676", size = 540464, upload-time = "2026-02-24T18:16:30.214Z" }, ] +[[package]] +name = "flet-charts" +version = "0.81.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flet" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/40/a9515782d5422372fe2078edd01c535e230b863dff362314290854a278c6/flet_charts-0.81.0.tar.gz", hash = "sha256:28711bd04fba6322462d9043cb89b092addb79da2cde7f01eafc94ea66a27370", size = 51685, upload-time = "2026-02-24T18:17:58.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/b4/11a1c54190c03a6b2c09227ff8a071c65b2b9a001ffd03d12eaea1fea76f/flet_charts-0.81.0-py3-none-any.whl", hash = "sha256:b4952eed8812b16e971a1a8e1962090df88348d13de1502adb07fa37d14fe7cc", size = 74482, upload-time = "2026-02-24T18:17:59.44Z" }, +] + [[package]] name = "fonttools" version = "4.61.1" @@ -2734,8 +2746,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -2975,6 +2987,7 @@ dependencies = [ { name = "babel" }, { name = "faker" }, { name = "flet" }, + { name = "flet-charts" }, { name = "icloudpy" }, { name = "ics" }, { name = "loguru" }, @@ -3014,6 +3027,7 @@ requires-dist = [ { name = "babel" }, { name = "faker" }, { name = "flet", specifier = ">=0.81.0,<0.82.0" }, + { name = "flet-charts", specifier = ">=0.81.0" }, { name = "icloudpy" }, { name = "ics" }, { name = "loguru" },