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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@ Schema lives in `tuttle/model.py`. Migrations are Alembic in `tuttle/migrations/
Any change to a SQLModel class requires `just migrate "<msg>"` + reviewing the
generated revision for rename-as-drop+add traps. See
`.cursor/rules/schema-migrations.mdc` and `tuttle/migrations/README.md`.

## Verification

For big new features, add a new contract and project to the Harry tuttle user for demonstration purposes.

### UI
- Test UI changes using playwright electron. https://playwright.dev/docs/api/class-electron
- launch the electron app and provide screenshots as artefacts in PR comments to be reviewed.
Comment thread
clstaudt marked this conversation as resolved.
10 changes: 10 additions & 0 deletions design_docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Design docs

One document per design or architecture decision, written before the code and kept
after it. Each states the problem, the decision taken, and what was ruled out — so
that a human or an agent picking up the area later does not re-litigate it.

Not a changelog and not API reference; those live in `docs/`.

- [mixed-currency.md](mixed-currency.md) — invoicing in a currency other than the one
you are taxed in ([#401](https://github.com/tuttle-dev/tuttle/discussions/401)).
178 changes: 178 additions & 0 deletions design_docs/mixed-currency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Mixed-currency support

Discussion: [#401](https://github.com/tuttle-dev/tuttle/discussions/401). Related: #396, #400.

## The problem

Tuttle assumes a freelancer invoices in the currency of the country they are taxed
in. The common real case breaks that assumption: taxed in Germany (EUR), invoicing
US clients in USD.

Today this does not merely lack support — it produces two contradictory wrong
answers at the same time:

- **Revenue KPIs mix currencies.** `compute_kpis` (`tuttle/kpi.py:84`) sums
`inv.total` over all invoices with no currency check. `KPISummary.to_dict`
(`tuttle/kpi.py:39`) then formats that sum with `tax_currency`. A $10,000 USD
invoice is displayed as €10,000.
- **Tax and salary KPIs silently drop them.** `compute_vat_reserves` and
`compute_spendable_income` (`tuttle/tax_reserves.py:136`, `:263`) and
`monthly_spendable_breakdown` (`tuttle/kpi.py:254`) skip every invoice whose
contract currency differs from the tax system's currency.

So foreign revenue inflates the dashboard and vanishes from spendable income
simultaneously. This is the salary confusion reported in #396/#400.

A third, separate defect: `einvoice.py:187` sets the document currency from the
contract but never emits BT-6 (tax currency code) or BT-111 (VAT amount in
accounting currency). EN16931 requires both when the invoice currency differs from
the VAT currency, so a foreign-currency invoice currently produces non-conformant XML.

## Scope decision: no VAT on foreign-currency invoices

The model already distinguishes this case. `TaxCategory.outside_scope` (`model.py:431`)
exists for exactly it: B2B services to a non-EU recipient, where the place of supply
is the recipient's country under § 3a Abs. 2 UStG. Such a supply is not taxable in
Germany, so a USD invoice to a US business **carries no German VAT at all**.

| Case | VAT on the foreign-currency invoice | What conversion must do |
| --- | --- | --- |
| B2B, non-EU client — `outside_scope` | none | Convert totals for revenue, income tax, dashboard. VAT reserve is zero before and after conversion. |
| B2B, EU client — reverse charge, `zero_rated` | 0% | Same. (ZM / UStVA reporting is a separate feature, unrelated to currency.) |
| Place of supply is Germany (B2C etc.) | domestic VAT, e.g. 19% | Real VAT-in-two-currencies: convert the VAT amount, resolve rounding, emit a nonzero BT-111. |

**Only the third row needs VAT conversion machinery, and it is out of scope.** For the
first two — which is what freelance B2B work actually looks like — converted VAT is
arithmetic on zeros. This is what makes the whole feature small: the fix in
`tax_reserves.py` is not "convert VAT reserves", it is "stop skipping foreign
invoices". They contribute converted revenue to the income-tax base and nothing to the
VAT reserve.

Rather than build for the third row, **guard against it**: a contract with a nonzero
`VAT_rate` *and* a currency other than the tax currency would produce quietly wrong
numbers. Detect that combination and warn instead of guessing. If someone hits the
warning, we will know the case is real before spending a week on it.

## Design

**Invoices stay in their contract currency. Only aggregates convert.**

The invoice is a legal document stating what the client owes; it is USD and remains
USD in the PDF, the e-invoice, the invoice list, and the timeline. Conversion to the
user's primary currency happens exclusively where values from multiple invoices are
summed: dashboard KPIs, tax reserves, spendable income, forecasting.

### The rate is a function, not a column

The **ECB monthly average for the month of the invoice date**. Under § 16 Abs. 6
UStG the binding rate is the monthly *Umsatzsteuer-Umrechnungskurs* published by the
BMF, which is derived from ECB reference rates — so the legally required rate and the
sensible default coincide.

A closed month's average never changes, so `rate(currency, month)` is already
deterministic: last year's tax figures cannot move under us. That means **no
`Invoice.fx_rate` column, no migration, and no backfill** — converting on read is
just as stable as freezing a value, and is a function instead of a schema.

Source: `frankfurter.dev` (free, no API key, ECB data, supports date-range averages).
Fetched rates are cached in the existing `app_db` key/value store, which is what makes
the app work offline for months it has already seen. A month with no cached rate and
no network stays unconverted and is flagged — it does not silently count as zero.

Pinning to the *invoice date* means VAT (supply date) and income tax (Zufluss /
payment date) use the same rate. Deliberate simplification; revisit only if the drift
is shown to matter.

**When the column earns its place:** the first time someone must override a rate (a
tax advisor insists on a different one), or wants to see on the invoice which rate was
applied. Add `Invoice.fx_rate` then, nullable, falling back to the function. Not
before.

### Conversion fee: salary only, never tax

The ~1% bank/Wise spread proposed in #401 must **not** reduce taxable revenue — the
taxable amount is the ECB-converted figure. But it does reduce what actually lands in
the account, so it belongs in the "what can I spend" estimate. Applied in
`compute_spendable_income` only. This is the tax-precision vs. salary-estimate
distinction raised in the discussion.

### Settings: a new "Currency conversion" section

Both keys live in the existing `app_db` key/value store via `SettingsIntent` — no
schema change — and get their own fieldset in
`ui/src/components/settings/SettingsView.tsx`, sibling to **Tax & Legal**:

| Key | Default | Meaning |
| --- | --- | --- |
| `currency.primary` | `get_tax_system(operating_country).currency` | Currency for dashboard, tax, and salary figures. EUR, GBP, USD for now. |
| `currency.fx_haircut` | `1.0` (%) | Bank/exchange spread deducted from the salary estimate only. |

Defaulting `currency.primary` from the operating country's tax system means
preselection by country is free and needs no country→currency table.

The section carries a short explainer, because for most users it is inert:

> These settings only matter if you invoice in a currency other than the one you are
> taxed in — for example a USD invoice to a US client while being taxed in Germany.
> Invoices always stay in their own currency; this is how those amounts are converted
> for your dashboard, tax reserves, and salary.
>
> The exchange rate is the ECB monthly average for the invoice's month, which is the
> rate German tax law requires (§ 16 Abs. 6 UStG). The conversion fee is subtracted
> from the salary estimate only — it never reduces your taxable revenue.

If `currency.primary` equals every contract currency in use, the whole section is a
no-op and the numbers are identical to today's.

### Display

Invoice amounts always render in their native currency — in the list, the timeline,
the PDF, and the e-invoice. Converted aggregates are prefixed with `≈`, because they
are approximate.

The **invoice detail view** is the one place both currencies appear together. When the
invoice currency differs from `currency.primary`, show three extra rows:

| Row | Example |
| --- | --- |
| Invoice currency | `USD` |
| Exchange rate | `1 USD = 0.9174 EUR` (ECB monthly average, Mar 2026) |
| Amount in primary currency | `≈ €9,174.00` |

Hidden entirely when the two currencies match, so single-currency users never see them.

This is the "which rate was applied?" question, and it does **not** bring back
`Invoice.fx_rate`: the detail view calls `rate(currency, invoice_month)` like every
other consumer and gets the same deterministic answer. Showing the rate is a read, not
a reason to store it.

## Plan

1. **E-invoice conformance** (independent, ships alone). Emit BT-6 and BT-111 in
`einvoice.py` when contract currency ≠ tax currency. With a zero-VAT category the
accounting-currency VAT amount is `0.00`, so this is small — but it is a
conformance bug today, regardless of the dashboard work.
2. **Settings.** New "Currency conversion" fieldset with `currency.primary` (defaulted
from the operating country's tax system) and `currency.fx_haircut`, plus the
explainer.
3. **FX rate module.** `rate(currency, month)` against frankfurter.dev, cached in
`app_db`. One small module, no schema change, no new heavy dependency.
4. **Convert the aggregates.** Delete the skip branches in `tuttle/kpi.py` and
`tuttle/tax_reserves.py`; convert invoice totals through `rate()` instead.
Converted VAT is zero for every in-scope case, so no VAT rounding rules are needed.
Same call adds the currency / rate / converted-amount rows to the invoice detail
view.
5. **Salary haircut.** `currency.fx_haircut` applied in `compute_spendable_income`.
6. **Guard the unsupported cases.** Warn on a contract with nonzero `VAT_rate` and a
currency other than the tax currency, and on a month whose rate could not be
resolved.

## Out of scope

- **Domestic VAT on a foreign-currency invoice** (third row above) — converted VAT
amounts, VAT rounding rules, nonzero BT-111. Guarded with a warning instead.
- **Per-invoice stored rate and manual override** (`Invoice.fx_rate`) — the monthly
average is deterministic, so nothing needs freezing until someone must override it.
- Currencies beyond EUR, GBP, USD.
- Per-client or per-project currency defaults.
- Rate pinned to payment date as well as invoice date.
8 changes: 8 additions & 0 deletions tuttle/app/contracts/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ def _validated_save(self, contract: Contract) -> IntentResult:
error_msg=str(e),
log_message=f"ContractsIntent._validated_save: {e}",
)
try:
contract.validate_currency()
except ValueError as e:
return IntentResult(
was_intent_successful=False,
error_msg=str(e),
log_message=f"ContractsIntent._validated_save: {e}",
)
try:
contract.validate_vat()
except ValueError as e:
Expand Down
16 changes: 0 additions & 16 deletions tuttle/app/core/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
"""Utility functions shared across the application."""

from typing import List, Tuple

import base64

import pycountry

from .formatting import fmt_currency # noqa: F401 — re-export


Expand All @@ -23,15 +19,3 @@ def image_to_base64(image_path):
with open(image_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
return encoded_string


def get_currencies() -> List[Tuple[str, str, str]]:
"""Returns a list of available currencies sorted alphabetically"""
currencies = []
currency_list = list(pycountry.currencies)
for currency in currency_list:
abbreviation = currency.alpha_3
symbol = currency.symbol if hasattr(currency, "symbol") else None
currencies.append((currency.name, abbreviation, symbol))
currencies.sort(key=lambda tup: tup[1])
return currencies
8 changes: 6 additions & 2 deletions tuttle/app/dashboard/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ def get_monthly_revenue(self, n_months: int = 12) -> IntentResult:
"""Get monthly revenue breakdown for the last n months."""
try:
invoices = self.query(Invoice)
data = monthly_revenue_breakdown(invoices, n_months=n_months)
data = monthly_revenue_breakdown(
invoices, n_months=n_months, country=self._get_country()
)
return IntentResult(was_intent_successful=True, data=data)
except Exception as e:
return IntentResult(
Expand Down Expand Up @@ -95,7 +97,9 @@ def get_monthly_chart_data(self, n_months: int = 12) -> IntentResult:
try:
invoices = self.query(Invoice)
country = self._get_country()
revenue = monthly_revenue_breakdown(invoices, n_months=n_months)
revenue = monthly_revenue_breakdown(
invoices, n_months=n_months, country=country
)
spendable = monthly_spendable_breakdown(
invoices, country=country, n_months=n_months
)
Expand Down
7 changes: 7 additions & 0 deletions tuttle/app/imports/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ..core.abstractions import SQLModelDataSourceMixin
from ..core.intent_result import IntentResult
from ...data_dir import get_data_dir
from ...fx import primary_currency
from ...model import (
Address,
Contact,
Expand Down Expand Up @@ -370,13 +371,19 @@ def _finalize_contract(entity: Contract, provided: dict) -> None:
becomes the single guard: it requires the value column for the chosen
type and clears the other, so an ambiguous contract can never be
committed regardless of what the LLM extracted.

A document that names no currency falls back to the primary one rather
than failing the import; a currency it does name must be one we can convert.
"""
if "type" not in provided:
entity.type = (
ContractType.fixed_price
if entity.fixed_price and not entity.rate
else ContractType.time_based
)
if not entity.currency:
entity.currency = primary_currency()
entity.validate_currency()
entity.validate_pricing()


Expand Down
17 changes: 11 additions & 6 deletions tuttle/app/salary/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from ..core.abstractions import SQLModelDataSourceMixin, Intent
from ..core.intent_result import IntentResult

from ...fx import primary_currency, validate_currency_code
from ...model import Invoice, RecurringExpense, User
from ...tax import get_tax_system
from ...tax_reserves import compute_effective_salary

from .data_source import SalaryDataSource
Expand All @@ -27,10 +27,8 @@ def _get_country(self) -> str:
return "Germany"

def _get_tax_currency(self, country: str) -> str:
try:
return get_tax_system(country).currency
except NotImplementedError:
return "EUR"
"""The currency aggregates are shown in (settings, default: tax system)."""
return primary_currency(country)

def get_effective_salary(self) -> IntentResult:
"""Compute the effective salary range."""
Expand Down Expand Up @@ -68,6 +66,14 @@ def get_expenses(self) -> IntentResult:

def save_expense(self, expense: RecurringExpense) -> IntentResult:
"""Persist a new or updated recurring expense."""
try:
expense.currency = validate_currency_code(expense.currency)
except ValueError as e:
return IntentResult(
was_intent_successful=False,
error_msg=str(e),
log_message=f"SalaryIntent.save_expense: {e}",
)
return self._data_source.save_expense(expense)

def save_expense_from_dict(self, data: dict) -> IntentResult:
Expand Down Expand Up @@ -105,4 +111,3 @@ def get_field_requirements(self) -> IntentResult:
"label": label,
}
return IntentResult(was_intent_successful=True, data=fields)

32 changes: 32 additions & 0 deletions tuttle/app/settings/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,44 @@

from ..core.intent_result import IntentResult
from ...app_db import AppDatabase
from ...fx import fx_haircut, primary_currency, supported_currencies


class SettingsIntent:
def __init__(self):
self._app_db = AppDatabase()

# -- Currency conversion ------------------------------------------------

def get_currency(self, country: str | None = None) -> IntentResult:
"""Currency-conversion settings.

The primary currency defaults to the active user's operating country
(only used until they save an explicit ``currency.primary``); callers
may still pass ``country`` to override.
"""
return IntentResult(
was_intent_successful=True,
data={
"primary": primary_currency(country or self._active_operating_country()),
"fx_haircut": str(fx_haircut()),
"supported": list(supported_currencies()),
},
)

def _active_operating_country(self) -> str:
from ..auth.data_source import UserDataSource

try:
return UserDataSource().get_user().operating_country or "Germany"
except Exception:
return "Germany"

def save_currency(self, primary: str, fx_haircut: str) -> IntentResult:
self._app_db.set_setting("currency.primary", primary)
self._app_db.set_setting("currency.fx_haircut", str(fx_haircut))
return IntentResult(was_intent_successful=True, data=None)

def get(self, key: str) -> IntentResult:
return IntentResult(
was_intent_successful=True,
Expand Down
Loading
Loading