From 664510c879da7e1ba1dc291c9f481462154c1798 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 15:15:11 +0200 Subject: [PATCH 01/13] docs: design for mixed-currency support Lays out the problem behind #396/#400: revenue KPIs sum invoice totals across currencies while tax and salary KPIs silently drop non-matching ones, so foreign revenue inflates the dashboard and vanishes from spendable income at the same time. Proposes converting only aggregates (invoices stay in their contract currency), freezing an ECB monthly-average rate on the invoice, and a new "Currency conversion" settings section. Refs #401 Co-Authored-By: Claude Opus 4.8 --- docs/MIXED_CURRENCY.md | 134 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/MIXED_CURRENCY.md diff --git a/docs/MIXED_CURRENCY.md b/docs/MIXED_CURRENCY.md new file mode 100644 index 00000000..49413203 --- /dev/null +++ b/docs/MIXED_CURRENCY.md @@ -0,0 +1,134 @@ +# 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 USD invoice carrying German VAT currently produces +non-conformant XML. + +## 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. + +### Freeze the rate on the invoice + +Add a nullable `Invoice.fx_rate` (Numeric) column, populated once when the invoice +is created and never recomputed. The alternative — converting on the fly from a live +rate table — would make last year's tax figures change every time the app is opened, +which is unusable for anything that gets filed. This is the only schema migration +the feature needs. + +### Which rate + +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. + +Source: `frankfurter.dev` (free, no API key, ECB data, supports date-range averages). +Cached in the existing `app_db` key/value settings store. On fetch failure or +offline, fall back to a manual rate field on the invoice, prefilled where a cached +value exists. + +Pinning to the *invoice date* means VAT (supply date) and income tax (Zufluss / +payment date) use the same rate. This is a deliberate simplification: one rate per +invoice, one column. Revisit only if the drift is shown to matter. + +### 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 + +Individual invoices always render in their native currency. Converted aggregates are +marked as approximate (`≈` or a footnote), because they are. + +## Plan + +1. **E-invoice conformance** (independent, ships alone). Emit BT-6 and BT-111 in + `einvoice.py` when contract currency ≠ tax currency. This is a correctness 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.** Fetch the ECB monthly average, cache it, expose a manual + override. One small module, no new heavy dependency. +4. **Migration + capture.** `Invoice.fx_rate`, populated at invoice creation; + backfill existing invoices lazily on first read rather than in a batch job. +5. **Convert the aggregates.** Replace the currency filters in `tuttle/kpi.py` and + `tuttle/tax_reserves.py` with conversion via `fx_rate`. Delete the skip branches. +6. **Salary haircut.** `currency.fx_haircut` applied in `compute_spendable_income`. + +### Check before building step 5 + +For a German freelancer with US B2B clients, the place of supply is the US: no German +VAT, reverse charge, 0% rate. If that is the setup, the VAT-reserve path is mostly +inert and the work collapses to income tax plus dashboard display. Confirm before +building VAT-in-two-currencies machinery that never runs. + +## Out of scope + +- Currencies beyond EUR, GBP, USD. +- Batch backfill of historical rates (lazy on first view instead). +- Per-client or per-project currency defaults. +- Rate pinned to payment date as well as invoice date. From cfc8c49d762c3a2a20e22f14dbcb6ced9a615def Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 15:18:54 +0200 Subject: [PATCH 02/13] docs(mixed-currency): rule out VAT on foreign-currency invoices B2B services to a non-EU recipient are outside the scope of German VAT (TaxCategory.outside_scope already models this), so converted VAT is zero for every in-scope case. Drops VAT-in-two-currencies from the plan and guards the unsupported case with a warning instead. Co-Authored-By: Claude Opus 4.8 --- docs/MIXED_CURRENCY.md | 49 +++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/docs/MIXED_CURRENCY.md b/docs/MIXED_CURRENCY.md index 49413203..3cd6bf3c 100644 --- a/docs/MIXED_CURRENCY.md +++ b/docs/MIXED_CURRENCY.md @@ -26,8 +26,32 @@ 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 USD invoice carrying German VAT currently produces -non-conformant XML. +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 @@ -106,8 +130,9 @@ marked as approximate (`≈` or a footnote), because they are. ## Plan 1. **E-invoice conformance** (independent, ships alone). Emit BT-6 and BT-111 in - `einvoice.py` when contract currency ≠ tax currency. This is a correctness bug - today, regardless of the dashboard work. + `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. @@ -115,19 +140,17 @@ marked as approximate (`≈` or a footnote), because they are. override. One small module, no new heavy dependency. 4. **Migration + capture.** `Invoice.fx_rate`, populated at invoice creation; backfill existing invoices lazily on first read rather than in a batch job. -5. **Convert the aggregates.** Replace the currency filters in `tuttle/kpi.py` and - `tuttle/tax_reserves.py` with conversion via `fx_rate`. Delete the skip branches. +5. **Convert the aggregates.** Delete the skip branches in `tuttle/kpi.py` and + `tuttle/tax_reserves.py`; convert invoice totals via `fx_rate` instead. Converted + VAT is zero for every in-scope case, so no VAT rounding rules are needed. 6. **Salary haircut.** `currency.fx_haircut` applied in `compute_spendable_income`. - -### Check before building step 5 - -For a German freelancer with US B2B clients, the place of supply is the US: no German -VAT, reverse charge, 0% rate. If that is the setup, the VAT-reserve path is mostly -inert and the work collapses to income tax plus dashboard display. Confirm before -building VAT-in-two-currencies machinery that never runs. +7. **Guard the unsupported case.** Warn on a contract with nonzero `VAT_rate` and a + currency other than the tax currency. ## 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. - Currencies beyond EUR, GBP, USD. - Batch backfill of historical rates (lazy on first view instead). - Per-client or per-project currency defaults. From 65fd59d7948c9eb5cfa71b17e901f1e5e523ff81 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 15:20:55 +0200 Subject: [PATCH 03/13] docs: move design doc to design_docs/ Per review on #419: design and architecture decisions get their own folder, human- and agent-readable, kept after the code lands rather than discarded with the PR. Co-Authored-By: Claude Opus 4.8 --- design_docs/README.md | 10 ++++++++++ .../MIXED_CURRENCY.md => design_docs/mixed-currency.md | 0 2 files changed, 10 insertions(+) create mode 100644 design_docs/README.md rename docs/MIXED_CURRENCY.md => design_docs/mixed-currency.md (100%) diff --git a/design_docs/README.md b/design_docs/README.md new file mode 100644 index 00000000..29583571 --- /dev/null +++ b/design_docs/README.md @@ -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)). diff --git a/docs/MIXED_CURRENCY.md b/design_docs/mixed-currency.md similarity index 100% rename from docs/MIXED_CURRENCY.md rename to design_docs/mixed-currency.md From b270636ca07d5c0d1c2cba6eeebf38200c567136 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 15:22:40 +0200 Subject: [PATCH 04/13] docs(mixed-currency): drop the fx_rate column from the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A closed month's ECB average never changes, so rate(currency, month) is already deterministic — the column, its migration, the capture hook and the lazy backfill were all defending against figures moving under us, which cannot happen. Convert on read from a cached rate table instead. Add Invoice.fx_rate the first time someone must override a rate. Co-Authored-By: Claude Opus 4.8 --- design_docs/mixed-currency.md | 54 ++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/design_docs/mixed-currency.md b/design_docs/mixed-currency.md index 3cd6bf3c..06bb3463 100644 --- a/design_docs/mixed-currency.md +++ b/design_docs/mixed-currency.md @@ -62,29 +62,31 @@ USD in the PDF, the e-invoice, the invoice list, and the timeline. Conversion to user's primary currency happens exclusively where values from multiple invoices are summed: dashboard KPIs, tax reserves, spendable income, forecasting. -### Freeze the rate on the invoice - -Add a nullable `Invoice.fx_rate` (Numeric) column, populated once when the invoice -is created and never recomputed. The alternative — converting on the fly from a live -rate table — would make last year's tax figures change every time the app is opened, -which is unusable for anything that gets filed. This is the only schema migration -the feature needs. - -### Which rate +### 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). -Cached in the existing `app_db` key/value settings store. On fetch failure or -offline, fall back to a manual rate field on the invoice, prefilled where a cached -value exists. +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. This is a deliberate simplification: one rate per -invoice, one column. Revisit only if the drift is shown to matter. +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 @@ -125,7 +127,7 @@ no-op and the numbers are identical to today's. ### Display Individual invoices always render in their native currency. Converted aggregates are -marked as approximate (`≈` or a footnote), because they are. +prefixed with `≈`, because they are approximate. ## Plan @@ -136,22 +138,22 @@ marked as approximate (`≈` or a footnote), because they are. 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.** Fetch the ECB monthly average, cache it, expose a manual - override. One small module, no new heavy dependency. -4. **Migration + capture.** `Invoice.fx_rate`, populated at invoice creation; - backfill existing invoices lazily on first read rather than in a batch job. -5. **Convert the aggregates.** Delete the skip branches in `tuttle/kpi.py` and - `tuttle/tax_reserves.py`; convert invoice totals via `fx_rate` instead. Converted - VAT is zero for every in-scope case, so no VAT rounding rules are needed. -6. **Salary haircut.** `currency.fx_haircut` applied in `compute_spendable_income`. -7. **Guard the unsupported case.** Warn on a contract with nonzero `VAT_rate` and a - currency other than the tax currency. +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. +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. -- Batch backfill of historical rates (lazy on first view instead). - Per-client or per-project currency defaults. - Rate pinned to payment date as well as invoice date. From ee7c3ca8f721b563e2b4b2ed1667708a25d2c552 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 15:27:03 +0200 Subject: [PATCH 05/13] docs(mixed-currency): show currency, rate and converted amount on the invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invoice detail view is where "which rate was applied?" gets answered. Three rows, hidden when the invoice currency matches the primary one. Does not bring back Invoice.fx_rate — the view calls rate(currency, month) like every other consumer. Showing the rate is a read, not a reason to store it. Co-Authored-By: Claude Opus 4.8 --- design_docs/mixed-currency.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/design_docs/mixed-currency.md b/design_docs/mixed-currency.md index 06bb3463..25700db3 100644 --- a/design_docs/mixed-currency.md +++ b/design_docs/mixed-currency.md @@ -126,8 +126,25 @@ no-op and the numbers are identical to today's. ### Display -Individual invoices always render in their native currency. Converted aggregates are -prefixed with `≈`, because they are approximate. +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 @@ -143,6 +160,8 @@ prefixed with `≈`, because they are approximate. 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 From bf99e443effd38fe8506aeba3bd4e372b64bf622 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 15:33:09 +0200 Subject: [PATCH 06/13] UI verification AGENTS.md --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6f0c0bd4..1ef699d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,9 @@ Schema lives in `tuttle/model.py`. Migrations are Alembic in `tuttle/migrations/ Any change to a SQLModel class requires `just migrate ""` + reviewing the generated revision for rename-as-drop+add traps. See `.cursor/rules/schema-migrations.mdc` and `tuttle/migrations/README.md`. + +## Verification + +### UI +Test UI changes using playwright electron. https://playwright.dev/docs/api/class-electron +Provide screenshots as artefacts in PR comments to be reviewed. \ No newline at end of file From a15524d6b480162122332af9d37603abd381c6a4 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 16:00:59 +0200 Subject: [PATCH 07/13] feat(currency): convert foreign-currency invoices in aggregates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tuttle assumed the invoicing currency equals the tax currency. A USD invoice was both summed into EUR revenue KPIs unconverted and skipped entirely by the tax and salary reserves, so foreign revenue inflated the dashboard and vanished from spendable income at the same time. Invoices stay in their contract currency; only aggregates convert, at the ECB monthly average for the invoice's month (§ 16 Abs. 6 UStG). The rate is a function, not a column: a closed month's average never changes, so nothing needs freezing — no schema change, no migration, no backfill. - tuttle/fx.py: rate()/convert() against frankfurter.dev, cached in app_db so months already seen work offline; an unresolvable rate leaves the invoice out and warns rather than counting it as zero. - kpi.py, tax_reserves.py: drop the currency skip branches, convert instead. - einvoice.py: emit BT-6 and BT-111 when invoice currency differs from the tax currency — EN16931 requires both, so foreign-currency XML was non-conformant. - Settings: "Currency conversion" section (currency.primary, currency.fx_haircut). The FX spread reduces the salary estimate only, never taxable revenue. - Invoice detail: currency, rate, and converted total, hidden when they match. - Guard the unsupported case (domestic VAT on a foreign-currency invoice) with a warning instead of guessing at converted VAT. - demo: a US client billed in USD, and fake invoices now inherit the contract's VAT rate/category instead of hardcoding 19%. Refs #401, #396, #400. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 6 +- tuttle/app/dashboard/intent.py | 8 +- tuttle/app/salary/intent.py | 9 +- tuttle/app/settings/intent.py | 19 +++ tuttle/app/tax/intent.py | 8 +- tuttle/demo.py | 105 +++++++++++- tuttle/einvoice.py | 43 +++++ tuttle/fx.py | 149 ++++++++++++++++++ tuttle/kpi.py | 54 +++---- tuttle/model.py | 49 +++++- tuttle/tax_reserves.py | 117 +++++++++----- tuttle_tests/test_einvoice.py | 35 ++++ tuttle_tests/test_fx.py | 117 ++++++++++++++ ui/src/components/invoicing/InvoicingView.tsx | 7 + ui/src/components/settings/SettingsView.tsx | 65 +++++++- 15 files changed, 694 insertions(+), 97 deletions(-) create mode 100644 tuttle/fx.py create mode 100644 tuttle_tests/test_fx.py diff --git a/AGENTS.md b/AGENTS.md index 1ef699d5..78022eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ generated revision for rename-as-drop+add traps. See ## 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 -Provide screenshots as artefacts in PR comments to be reviewed. \ No newline at end of file +- 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. \ No newline at end of file diff --git a/tuttle/app/dashboard/intent.py b/tuttle/app/dashboard/intent.py index 96bbd177..9f78a9e4 100644 --- a/tuttle/app/dashboard/intent.py +++ b/tuttle/app/dashboard/intent.py @@ -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( @@ -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 ) diff --git a/tuttle/app/salary/intent.py b/tuttle/app/salary/intent.py index 21a122e3..f56cd8f8 100644 --- a/tuttle/app/salary/intent.py +++ b/tuttle/app/salary/intent.py @@ -3,8 +3,8 @@ from ..core.abstractions import SQLModelDataSourceMixin, Intent from ..core.intent_result import IntentResult +from ...fx import primary_currency from ...model import Invoice, RecurringExpense, User -from ...tax import get_tax_system from ...tax_reserves import compute_effective_salary from .data_source import SalaryDataSource @@ -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.""" @@ -105,4 +103,3 @@ def get_field_requirements(self) -> IntentResult: "label": label, } return IntentResult(was_intent_successful=True, data=fields) - diff --git a/tuttle/app/settings/intent.py b/tuttle/app/settings/intent.py index fe09f3a1..7e6ffd82 100644 --- a/tuttle/app/settings/intent.py +++ b/tuttle/app/settings/intent.py @@ -2,12 +2,31 @@ from ..core.intent_result import IntentResult from ...app_db import AppDatabase +from ...fx import SUPPORTED_CURRENCIES, fx_haircut, primary_currency class SettingsIntent: def __init__(self): self._app_db = AppDatabase() + # -- Currency conversion ------------------------------------------------ + + def get_currency(self, country: str = "Germany") -> IntentResult: + """Currency-conversion settings, defaulted from the operating country.""" + return IntentResult( + was_intent_successful=True, + data={ + "primary": primary_currency(country), + "fx_haircut": str(fx_haircut()), + "supported": list(SUPPORTED_CURRENCIES), + }, + ) + + 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, diff --git a/tuttle/app/tax/intent.py b/tuttle/app/tax/intent.py index 035242cc..544db7af 100644 --- a/tuttle/app/tax/intent.py +++ b/tuttle/app/tax/intent.py @@ -7,6 +7,7 @@ from ..core.intent_result import IntentResult from ..timetracking.data_source import TimeTrackingDataFrameSource +from ...fx import primary_currency from ...model import Invoice, Project, RecurringExpense, User from ...tax import get_tax_system, supported_countries from ...tax_reserves import ( @@ -34,11 +35,8 @@ def _get_country(self) -> str: 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" + """The currency aggregates are shown in (settings, default: tax system).""" + return primary_currency(country) def get_spendable_income(self, year: int | None = None) -> IntentResult: """Compute spendable income breakdown.""" diff --git a/tuttle/demo.py b/tuttle/demo.py index 61ef0983..b7da7e26 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -28,6 +28,7 @@ FinancialGoal, Invoice, InvoiceItem, + TaxCategory, Timesheet, TimeTrackingItem, Project, @@ -35,7 +36,6 @@ User, ) - # --------------------------------------------------------------------------- # Curated heating-repair domain data # --------------------------------------------------------------------------- @@ -356,6 +356,7 @@ def create_fake_invoice( _HEATING_INVOICE_ITEMS, k=min(number_of_items, len(_HEATING_INVOICE_ITEMS)), ) + contract = project.contract for desc, unit in used_items: unit_price = abs(round(numpy.random.normal(75, 20), 2)) item_end = inv_date - timedelta(days=random.randint(1, 10)) @@ -367,7 +368,10 @@ def create_fake_invoice( unit=unit, unit_price=Decimal(unit_price), description=desc, - VAT_rate=Decimal("0.19"), + # Follow the contract: a USD contract to a US client is outside the + # scope of German VAT, so its items must not carry 19%. + VAT_rate=contract.VAT_rate, + VAT_category=contract.VAT_category, invoice=invoice, ) @@ -406,6 +410,91 @@ def create_fake_invoice( return invoice +def create_usd_security_data(user: User) -> tuple[Project, Invoice, ClientContact]: + """A US client billed in USD — the mixed-currency case, end to end.""" + contact = Contact( + first_name="Dana", + last_name="Reyes", + email="reyes@ductworksecurity.com", + company="Ductwork Security Inc.", + address=Address( + street="Market Street", + number="1200", + postal_code="94103", + city="San Francisco", + country="United States", + ), + ) + client = Client(name="Ductwork Security Inc.", invoicing_contact=contact) + + contract = Contract( + title="IT Security Review – Ductwork Security Inc.", + client=client, + signature_date=datetime.date.today() - timedelta(days=120), + start_date=datetime.date.today() - timedelta(days=110), + rate=Decimal("1000.00"), + currency="USD", + # Outside the scope of German VAT: B2B service to a non-EU recipient. + VAT_rate=Decimal("0"), + VAT_category=TaxCategory.outside_scope, + unit=TimeUnit.day, + units_per_workday=8, + volume=10, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + + project = Project( + title="IT Security Review", + tag="#itsecurity", + description="Security review of the pneumatic ductwork control systems", + is_completed=False, + start_date=contract.start_date, + end_date=datetime.date.today() + timedelta(days=60), + contract=contract, + ) + + inv_date = datetime.date.today() - timedelta(days=45) + invoice = Invoice( + number=f"{inv_date.strftime('%Y-%m-%d')}-{next(invoice_number_counter)}", + date=inv_date, + sent=True, + paid=True, + cancelled=False, + contract=contract, + project=project, + rendered=True, + ) + InvoiceItem( + start_date=inv_date - timedelta(days=30), + end_date=inv_date - timedelta(days=2), + quantity=1, + unit="unit", + unit_price=Decimal("1000.00"), + description="IT security review", + VAT_rate=Decimal("0"), + VAT_category=TaxCategory.outside_scope, + invoice=invoice, + ) + + try: + rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=get_data_dir() / "Invoices", + only_final=True, + ) + logger.info("✅ rendered USD invoice for IT Security Review") + except Exception as ex: + logger.error(f"❌ Error rendering USD invoice: {ex}") + + return ( + project, + invoice, + ClientContact(client=client, contact=contact, role="invoicing"), + ) + + def create_heating_data( user: User, n: int = 4, @@ -559,9 +648,17 @@ def create_heating_data( ) projects.append(project) + # -- one US client invoiced in USD ---------------------------------------- + # Harry is taxed in Germany but bills this one in dollars: the mixed-currency + # case. B2B to a non-EU recipient, so the supply is outside the scope of + # German VAT (§ 3a Abs. 2 UStG). + us_project, us_invoice, us_client_contact = create_usd_security_data(user) + projects.append(us_project) + client_contacts.append(us_client_contact) + today = datetime.date.today() - invoices = [] - for i, project in enumerate(projects): + invoices = [us_invoice] + for i, project in enumerate(projects[:-1]): if i < 2: # First two invoices: sent but unpaid, dated 30+ days ago → overdue inv_date = today - timedelta(days=random.randint(30, 60)) diff --git a/tuttle/einvoice.py b/tuttle/einvoice.py index 70cbcb02..5d502086 100644 --- a/tuttle/einvoice.py +++ b/tuttle/einvoice.py @@ -13,7 +13,9 @@ from drafthorse.models.tradelines import LineItem from drafthorse.pdf import attach_xml +from .fx import convert from .model import Invoice, TaxCategory, User, normalize_tax_category +from .tax import get_tax_system # -- Helpers ------------------------------------------------------------------ @@ -79,6 +81,34 @@ def _rate_to_percent(vat_rate: Decimal) -> Decimal: VATEX_OUTSIDE_SCOPE_REASON = "Not subject to VAT" +def _tax_currency(user: User) -> str: + """The currency VAT is accounted in — the operating country's tax currency.""" + try: + return get_tax_system(user.operating_country or "Germany").currency + except NotImplementedError: + return "EUR" + + +def _vat_in_tax_currency( + invoice: Invoice, total_tax: Decimal, currency: str, tax_currency: str +) -> Decimal: + """BT-111 — the invoice's VAT total expressed in the accounting currency. + + For the cases Tuttle supports (reverse charge, outside scope) a foreign- + currency invoice carries no VAT, so this is zero and needs no rate at all. + """ + if not total_tax: + return Decimal("0.00") + converted = convert(total_tax, currency, tax_currency, invoice.date) + if converted is None: + raise ValueError( + f"Invoice {invoice.number or invoice.id} carries VAT in {currency} but " + f"the {currency}/{tax_currency} rate for {invoice.date:%Y-%m} could not " + "be resolved, so BT-111 cannot be computed." + ) + return converted + + # -- Schema name mapping ------------------------------------------------------ ZUGFERD_SCHEMAS = { @@ -274,6 +304,19 @@ def build_zugferd_document( doc.trade.settlement.monetary_summation.allowance_total = Decimal("0.00") doc.trade.settlement.monetary_summation.tax_basis_total = line_total_sum doc.trade.settlement.monetary_summation.tax_total = (total_tax, currency) + + # BT-6 / BT-111: when the invoice currency differs from the VAT accounting + # currency, EN16931 requires the tax currency code and the VAT total in it. + tax_currency = _tax_currency(user) + if not is_minimum and tax_currency != currency: + doc.trade.settlement.tax_currency_code = tax_currency + doc.trade.settlement.monetary_summation.tax_total_other_currency.add( + ( + _vat_in_tax_currency(invoice, total_tax, currency, tax_currency), + tax_currency, + ) + ) + doc.trade.settlement.monetary_summation.grand_total = grand_total doc.trade.settlement.monetary_summation.due_amount = due_amount diff --git a/tuttle/fx.py b/tuttle/fx.py new file mode 100644 index 00000000..f39e82ec --- /dev/null +++ b/tuttle/fx.py @@ -0,0 +1,149 @@ +"""Currency conversion for aggregates. + +Invoices stay in their contract currency; only sums across invoices convert. +The rate is the ECB monthly average for the month of the invoice date — the +rate German tax law prescribes (§ 16 Abs. 6 UStG) and a value that never +changes once the month is closed, so it needs no column and no migration. + +Source: frankfurter.dev (ECB data, no API key). Closed months are cached in +``app_db`` so the app keeps working offline. A month that can be resolved +neither from cache nor from the network yields ``None`` — never zero. +""" + +import datetime +import json +import logging +import urllib.error +import urllib.request +from decimal import Decimal +from functools import lru_cache +from typing import Optional + +from .app_db import AppDatabase +from .tax import get_tax_system + +logger = logging.getLogger(__name__) + +SUPPORTED_CURRENCIES = ("EUR", "GBP", "USD") + +_API = "https://api.frankfurter.dev/v1" +_TIMEOUT = 5.0 + + +# -- Settings ----------------------------------------------------------------- + + +def primary_currency(country: str = "Germany") -> str: + """The currency all aggregates are expressed in. + + Defaults to the currency of the operating country's tax system, which is + why preselection by country needs no country→currency table. + """ + setting = AppDatabase().get_setting("currency.primary") + if setting: + return setting + try: + return get_tax_system(country).currency + except NotImplementedError: + return "EUR" + + +def fx_haircut() -> Decimal: + """Bank/exchange spread in percent, deducted from the salary estimate only. + + Never reduces taxable revenue — the taxable amount is the ECB-converted one. + """ + raw = AppDatabase().get_setting("currency.fx_haircut") + try: + return Decimal(raw) if raw else Decimal("1.0") + except (ArithmeticError, ValueError): + return Decimal("1.0") + + +# -- Rates -------------------------------------------------------------------- + + +def _month_range(month: datetime.date) -> tuple[datetime.date, datetime.date]: + start = month.replace(day=1) + next_month = (start + datetime.timedelta(days=32)).replace(day=1) + return start, next_month - datetime.timedelta(days=1) + + +def _fetch_monthly_average(base: str, quote: str, month: datetime.date) -> Decimal: + """Average the ECB daily rates published for *month*.""" + start, end = _month_range(month) + url = f"{_API}/{start}..{end}?base={base}&symbols={quote}" + # The API rejects urllib's default User-Agent with 403. + request = urllib.request.Request(url, headers={"User-Agent": "tuttle"}) + with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp: + payload = json.load(resp) + # When the range starts on a non-trading day the API backfills the previous + # business day, which can belong to the previous month — drop those. + daily = [ + Decimal(str(day[quote])) + for iso, day in payload["rates"].items() + if quote in day and iso.startswith(f"{start:%Y-%m}") + ] + if not daily: + raise ValueError(f"no {base}/{quote} rates published for {start:%Y-%m}") + return sum(daily) / len(daily) + + +# ponytail: a failed lookup stays cached for the session; restart (or call +# clear_cache) after coming back online. Retry-on-reconnect if that annoys anyone. +@lru_cache(maxsize=256) +def _rate(base: str, quote: str, month_key: str) -> Optional[Decimal]: + month = datetime.date.fromisoformat(f"{month_key}-01") + db = AppDatabase() + cache_key = f"fx.rate.{base}.{quote}.{month_key}" + + cached = db.get_setting(cache_key) + if cached: + return Decimal(cached) + + try: + avg = _fetch_monthly_average(base, quote, month) + except (urllib.error.URLError, OSError, ValueError, KeyError, TimeoutError) as e: + logger.warning( + "No exchange rate for %s/%s in %s (%s); amounts in %s are left " + "unconverted rather than counted as zero.", + base, + quote, + month_key, + e, + base, + ) + return None + + # A closed month's average is final; the running month's is not, so only + # the closed one is worth persisting. + if _month_range(month)[1] < datetime.date.today(): + db.set_setting(cache_key, str(avg)) + return avg + + +def rate(base: str, quote: str, month: datetime.date) -> Optional[Decimal]: + """ECB monthly average: how many *quote* units one *base* unit buys. + + Returns ``None`` when the rate cannot be resolved (no cache, no network). + """ + if base == quote: + return Decimal(1) + return _rate(base, quote, f"{month:%Y-%m}") + + +def convert( + amount: Decimal, base: str, quote: str, on: datetime.date +) -> Optional[Decimal]: + """Convert *amount* from *base* to *quote* at the rate for the month of *on*.""" + if base == quote: + return amount + r = rate(base, quote, on) + if r is None: + return None + return (Decimal(amount) * r).quantize(Decimal("0.01")) + + +def clear_cache() -> None: + """Drop the in-process rate cache (tests, and after a settings change).""" + _rate.cache_clear() diff --git a/tuttle/kpi.py b/tuttle/kpi.py index db1f0928..6e4317ec 100644 --- a/tuttle/kpi.py +++ b/tuttle/kpi.py @@ -6,11 +6,12 @@ from pandas import DataFrame +from .fx import primary_currency from .model import Contract, Invoice, Project from .time import TimeUnit from .timetracking import sum_hours_by_tag from .tax import get_tax_system -from .tax_reserves import compute_spendable_income +from .tax_reserves import compute_spendable_income, convert_invoice from .app.core.formatting import fmt_currency @@ -67,11 +68,13 @@ def compute_kpis( *time_data* (the calendar DataFrame) is the source of truth for hours worked. It drives ``effective_hourly_rate`` and ``utilization_rate``. - Revenue metrics come from invoices. + Revenue metrics come from invoices, converted into the primary currency. """ today = datetime.date.today() year_start = today.replace(month=1, day=1) + currency = primary_currency(country) + # Revenue metrics (from invoices) total_revenue = Decimal(0) total_revenue_ytd = Decimal(0) @@ -84,7 +87,10 @@ def compute_kpis( for inv in invoices: if inv.cancelled: continue - inv_total = inv.total + converted = convert_invoice(inv, currency) + if converted is None: + continue + inv_total = converted[0] if inv.paid: total_revenue += inv_total @@ -132,16 +138,8 @@ 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) + spending = compute_spendable_income(invoices, country, currency=currency) vat_reserve = spending.vat_reserve income_tax_reserve = spending.income_tax_reserve spendable_income = spending.spendable @@ -164,20 +162,22 @@ def compute_kpis( vat_reserve=vat_reserve, income_tax_reserve=income_tax_reserve, spendable_income=spendable_income, - tax_currency=tax_currency, + tax_currency=currency, ) def monthly_revenue_breakdown( invoices: List[Invoice], n_months: int = 12, + country: str = "Germany", ) -> list: - """Revenue breakdown by month for the last n_months. + """Revenue breakdown by month for the last n_months, in the primary currency. Returns a list of dicts with keys: month, revenue, pipeline, invoice_count. ``revenue`` is paid invoices; ``pipeline`` is sent-but-unpaid invoices. """ today = datetime.date.today() + currency = primary_currency(country) start = (today - datetime.timedelta(days=30 * n_months)).replace(day=1) months = {} @@ -198,11 +198,14 @@ def monthly_revenue_breakdown( key = inv.date.strftime("%Y-%m") if key not in months: continue + converted = convert_invoice(inv, currency) + if converted is None: + continue if inv.paid: - months[key]["revenue"] += inv.total + months[key]["revenue"] += converted[0] months[key]["invoice_count"] += 1 elif inv.sent: - months[key]["pipeline"] += inv.total + months[key]["pipeline"] += converted[0] return sorted(months.values(), key=lambda x: x["month"]) @@ -240,23 +243,20 @@ def monthly_spendable_breakdown( } 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 + # Foreign-currency invoices are converted into the primary currency at the + # ECB monthly average, not dropped. + currency = primary_currency(country) for inv in invoices: if inv.cancelled or not inv.paid: 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 + converted = convert_invoice(inv, currency) + if converted is None: + continue + months[key]["gross_revenue"] += converted[0] + months[key]["vat_due"] += converted[1] months[key]["invoice_count"] += 1 sorted_keys = sorted(months.keys()) diff --git a/tuttle/model.py b/tuttle/model.py index 7de2941b..dcfd649a 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -38,6 +38,7 @@ from pathlib import Path from .app.core.formatting import fmt_currency +from .fx import convert, primary_currency, rate from .data_dir import get_data_dir from .dev import deprecated from .time import ContractType, Cycle, TimeUnit @@ -310,22 +311,18 @@ def print_address(self, address_only: bool = False): return "" if address_only: - return textwrap.dedent( - f""" + return textwrap.dedent(f""" {self.address.street} {self.address.number} {self.address.postal_code} {self.address.city} - {self.address.country}""" - ) + {self.address.country}""") - return textwrap.dedent( - f""" + return textwrap.dedent(f""" {self.name} {self.company} {self.address.street} {self.address.number} {self.address.postal_code} {self.address.city} {self.address.country} - """ - ) + """) class ClientContact(SQLModel, table=True): @@ -917,6 +914,9 @@ class Invoice(RpcMixin, SQLModel, table=True): "sum_formatted", "vat_total_formatted", "total_formatted", + "currency", + "fx_rate_formatted", + "total_primary_formatted", "pdf_path", "is_reminder", "reminder_chain_head_id", @@ -1154,6 +1154,39 @@ def total_formatted(self) -> str: currency = self.contract.currency if self.contract else "EUR" return fmt_currency(self.total, currency) + @property + def currency(self) -> str: + return self.contract.currency if self.contract else "EUR" + + @property + def fx_rate_formatted(self) -> Optional[str]: + """The rate applied when converting this invoice, e.g. "1 USD = 0.9174 EUR". + + None when the invoice is already in the primary currency (the usual + case), so the UI shows nothing. + """ + primary = primary_currency() + if self.currency == primary: + return None + r = rate(self.currency, primary, self.date) + if r is None: + return None + return ( + f"1 {self.currency} = {r:.4f} {primary} " + f"(ECB monthly average, {self.date:%b %Y})" + ) + + @property + def total_primary_formatted(self) -> Optional[str]: + """This invoice's total in the primary currency, prefixed "≈" (approximate).""" + primary = primary_currency() + if self.currency == primary: + return None + converted = convert(self.total, self.currency, primary, self.date) + if converted is None: + return None + return f"≈ {fmt_currency(converted, primary)}" + @property def pdf_path(self) -> Optional[str]: if not self.rendered: diff --git a/tuttle/tax_reserves.py b/tuttle/tax_reserves.py index 1495ece2..48a7e2e6 100644 --- a/tuttle/tax_reserves.py +++ b/tuttle/tax_reserves.py @@ -12,6 +12,7 @@ from pandas import DataFrame +from .fx import convert, fx_haircut from .model import Invoice, Project, RecurringExpense from .tax import get_tax_system from .time import Cycle, TimeUnit @@ -48,13 +49,14 @@ class SpendableIncome(NamedTuple): taxable_profit: Decimal # net_revenue - business_expenses vat_reserve: Decimal # VAT to set aside income_tax_reserve: Decimal # estimated income tax + soli - spendable: Decimal # taxable_profit - income_tax_reserve + spendable: Decimal # taxable_profit - income_tax_reserve - conversion_fee # breakdown by income source received_gross: Decimal # paid invoices, gross received_net: Decimal # paid invoices, net of VAT outstanding_gross: Decimal # sent but unpaid, gross outstanding_net: Decimal # sent but unpaid, net of VAT planned_revenue: Decimal # calendar-derived future revenue (net) + conversion_fee: Decimal = Decimal(0) # FX spread on foreign-currency revenue def _invoice_currency(inv: Invoice) -> Optional[str]: @@ -64,6 +66,38 @@ def _invoice_currency(inv: Invoice) -> Optional[str]: return None +def convert_invoice( + inv: Invoice, currency: Optional[str] +) -> Optional[tuple[Decimal, Decimal]]: + """Invoice (total, VAT) expressed in *currency*. + + Returns ``None`` when the invoice is in another currency and the ECB rate + for its month cannot be resolved — the caller must leave it out and say so, + never count it as zero. + """ + inv_currency = _invoice_currency(inv) + if not currency or inv_currency in (currency, None): + return inv.total, inv.VAT_total + + if inv.VAT_total: + # The design's third row: place of supply is domestic, so the invoice + # carries VAT in a foreign currency. Converting it needs rounding rules + # we deliberately do not have — warn instead of guessing. + logger.warning( + "Invoice %s is in %s and carries VAT (%s). VAT on foreign-currency " + "invoices is not supported; the converted VAT figure is an estimate.", + inv.number or inv.id, + inv_currency, + inv.VAT_total, + ) + + total = convert(inv.total, inv_currency, currency, inv.date) + vat = convert(inv.VAT_total, inv_currency, currency, inv.date) + if total is None or vat is None: + return None + return total, vat + + def compute_planned_revenue( projects: List[Project], time_data: Optional[DataFrame] = None, @@ -71,9 +105,9 @@ def compute_planned_revenue( ) -> Decimal: """Net revenue expected from future calendar events in the current year. - Converts planned hours to revenue via each project's contract rate. - Only includes future events (today onward) within the current year. - If *currency* is given, only projects with matching currency are counted. + Converts planned hours to revenue via each project's contract rate, and + contracts in a foreign currency into *currency* at the current month's rate + (future revenue has no invoice date to pin a rate to). """ if time_data is None or time_data.empty: return Decimal(0) @@ -104,13 +138,17 @@ def compute_planned_revenue( if not project: continue contract = project.contract - if currency and contract.currency not in (currency, None): - continue if not contract.rate: continue unit_hours = contract.units_per_workday if contract.unit == TimeUnit.day else 1 billable_units = Decimal(str(hours)) / Decimal(str(unit_hours)) - total += billable_units * contract.rate + revenue = billable_units * contract.rate + if currency and contract.currency not in (currency, None): + converted = convert(revenue, contract.currency, currency, today) + if converted is None: + continue + revenue = converted + total += revenue return total.quantize(Decimal("0.01")) @@ -123,27 +161,21 @@ def compute_vat_reserves( ) -> 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). + Invoices in another currency are converted to *currency* at the ECB monthly + average. For the supported cases (reverse charge, outside scope) their VAT + is zero before and after conversion. """ 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 + converted = convert_invoice(inv, currency) + if converted is None: continue - vat_total += inv.VAT_total + vat_total += converted[1] 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, @@ -254,28 +286,24 @@ def compute_spendable_income( received_vat = Decimal(0) outstanding_gross = Decimal(0) outstanding_vat = Decimal(0) - skipped = 0 + foreign_net = Decimal(0) # converted net revenue that must be exchanged for inv in invoices: if inv.cancelled: continue if year_start <= inv.date <= year_end: - if currency and _invoice_currency(inv) not in (currency, None): - skipped += 1 + converted = convert_invoice(inv, currency) + if converted is None: continue + gross, vat = converted + if _invoice_currency(inv) not in (currency, None): + foreign_net += gross - vat if inv.paid: - received_gross += inv.total - received_vat += inv.VAT_total + received_gross += gross + received_vat += vat else: - outstanding_gross += inv.total - outstanding_vat += inv.VAT_total - - if skipped: - logger.debug( - "compute_spendable_income: skipped %d invoice(s) with currency != %s", - skipped, - currency, - ) + outstanding_gross += gross + outstanding_vat += vat received_net = received_gross - received_vat outstanding_net = outstanding_gross - outstanding_vat @@ -301,9 +329,7 @@ def compute_spendable_income( + 1, 1, ) - biz_expenses_ytd = (monthly_exp * months_elapsed).quantize( - Decimal("0.01") - ) + biz_expenses_ytd = (monthly_exp * months_elapsed).quantize(Decimal("0.01")) else: biz_expenses_ytd = Decimal(0) @@ -313,7 +339,11 @@ def compute_spendable_income( taxable_profit, country, deductions, year=year ) - spendable = taxable_profit - tax_reserve.ytd_reserve + # The bank/Wise spread never reduces taxable revenue — only what lands in + # the account, so it is subtracted after the tax reserve, not before. + conversion_fee = (foreign_net * fx_haircut() / 100).quantize(Decimal("0.01")) + + spendable = taxable_profit - tax_reserve.ytd_reserve - conversion_fee return SpendableIncome( gross_revenue_ytd=gross_ytd, @@ -328,6 +358,7 @@ def compute_spendable_income( outstanding_gross=outstanding_gross, outstanding_net=outstanding_net, planned_revenue=planned, + conversion_fee=conversion_fee, ) @@ -406,14 +437,16 @@ def compute_effective_salary( continue if inv.date < year_start: continue - if currency and _invoice_currency(inv) not in (currency, None): + converted = convert_invoice(inv, currency) + if converted is None: continue + gross, vat = converted if inv.paid: - gross_paid += inv.total - vat_paid += inv.VAT_total + gross_paid += gross + vat_paid += vat else: - gross_outstanding += inv.total - vat_outstanding += inv.VAT_total + gross_outstanding += gross + vat_outstanding += vat net_paid = gross_paid - vat_paid net_all = net_paid + (gross_outstanding - vat_outstanding) diff --git a/tuttle_tests/test_einvoice.py b/tuttle_tests/test_einvoice.py index 0f3c58f6..ac7d6e77 100644 --- a/tuttle_tests/test_einvoice.py +++ b/tuttle_tests/test_einvoice.py @@ -484,3 +484,38 @@ def test_zero_rated_and_outside_scope_lines_do_not_share_a_breakdown(self): for b in breakdowns } assert categories == {"Z", "O"} + + +# -- Foreign-currency invoices: BT-6 / BT-111 --------------------------------- + + +class TestForeignCurrency: + """EN16931 BR-53: with a tax currency code (BT-6), BT-111 must be present.""" + + @staticmethod + def _usd_invoice() -> Invoice: + """A USD invoice to a US client — outside the scope of German VAT.""" + invoice = _make_outside_scope_invoice() + invoice.contract.currency = "USD" + return invoice + + def test_euro_invoice_emits_neither_bt6_nor_a_second_tax_total(self): + xml_str = serialize_zugferd_xml( + _make_invoice(), _make_user(), profile="EN16931", validate=True + ).decode("utf-8") + assert "TaxCurrencyCode" not in xml_str + assert len(re.findall(r"USD" in xml_str + assert "EUR" in xml_str + # Outside the scope of VAT: zero VAT, so BT-111 is 0.00 and needs no rate. + assert ( + '0.00' in xml_str + ) diff --git a/tuttle_tests/test_fx.py b/tuttle_tests/test_fx.py new file mode 100644 index 00000000..accfba6e --- /dev/null +++ b/tuttle_tests/test_fx.py @@ -0,0 +1,117 @@ +"""Mixed-currency aggregates: a USD invoice must count, converted, exactly once.""" + +import datetime +from decimal import Decimal + +import pytest + +from tuttle import fx +from tuttle.kpi import compute_kpis +from tuttle.model import Client, Contract, Invoice, InvoiceItem, TaxCategory +from tuttle.tax_reserves import compute_spendable_income +from tuttle.time import Cycle, TimeUnit + + +@pytest.fixture(autouse=True) +def stub_rates(monkeypatch): + """Fixed USD→EUR rate, no network, no app.db.""" + monkeypatch.setattr(fx, "primary_currency", lambda country="Germany": "EUR") + monkeypatch.setattr(fx, "fx_haircut", lambda: Decimal("1.0")) + monkeypatch.setattr( + fx, + "rate", + lambda base, quote, month: ( + Decimal(1) if base == quote else Decimal("0.9") if base == "USD" else None + ), + ) + monkeypatch.setattr( + fx, + "convert", + lambda amount, base, quote, on: ( + Decimal(amount) + if base == quote + else (Decimal(amount) * Decimal("0.9")).quantize(Decimal("0.01")) + ), + ) + # kpi/tax_reserves bound these at import time. + import tuttle.kpi + import tuttle.tax_reserves + + monkeypatch.setattr(tuttle.kpi, "primary_currency", fx.primary_currency) + monkeypatch.setattr(tuttle.tax_reserves, "convert", fx.convert) + monkeypatch.setattr(tuttle.tax_reserves, "fx_haircut", fx.fx_haircut) + + +def _invoice(currency: str, amount: str, vat_rate="0") -> Invoice: + category = ( + TaxCategory.outside_scope if Decimal(vat_rate) == 0 else TaxCategory.standard + ) + contract = Contract( + title=f"{currency} contract", + client=Client(name="US Client"), + rate=Decimal(amount), + currency=currency, + VAT_rate=Decimal(vat_rate), + VAT_category=category, + unit=TimeUnit.hour, + units_per_workday=8, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + today = datetime.date.today() + invoice = Invoice( + number="1", date=today.replace(month=1, day=15), contract=contract + ) + invoice.items = [ + InvoiceItem( + quantity=1, + unit="hour", + unit_price=Decimal(amount), + description="work", + VAT_rate=Decimal(vat_rate), + VAT_category=category, + ) + ] + invoice.paid = True + return invoice + + +def test_foreign_invoice_is_converted_not_dropped_or_mixed(): + invoices = [_invoice("EUR", "1000"), _invoice("USD", "10000")] + + kpis = compute_kpis(invoices, [], [], country="Germany") + + # 1000 EUR + 10000 USD * 0.9 = 10000 EUR — neither 11000 (mixed) nor 1000 (dropped). + assert kpis.total_revenue == Decimal("10000") + assert kpis.tax_currency == "EUR" + + +def test_conversion_fee_hits_salary_but_not_the_tax_base(): + spending = compute_spendable_income( + [_invoice("USD", "10000")], "Germany", currency="EUR" + ) + + assert spending.gross_revenue_ytd == Decimal("9000.00") # ECB rate, no haircut + assert spending.taxable_profit == Decimal("9000.00") + assert spending.conversion_fee == Decimal("90.00") # 1% of the converted net + assert ( + spending.spendable + == spending.taxable_profit - spending.income_tax_reserve - Decimal("90.00") + ) + + +def test_unresolvable_rate_leaves_the_invoice_out_rather_than_zeroing_it(monkeypatch): + import tuttle.tax_reserves + + monkeypatch.setattr( + tuttle.tax_reserves, "convert", lambda amount, base, quote, on: None + ) + spending = compute_spendable_income( + [_invoice("EUR", "1000"), _invoice("USD", "10000")], "Germany", currency="EUR" + ) + + assert spending.gross_revenue_ytd == Decimal("1000") + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/ui/src/components/invoicing/InvoicingView.tsx b/ui/src/components/invoicing/InvoicingView.tsx index 07c1c3c6..36699967 100644 --- a/ui/src/components/invoicing/InvoicingView.tsx +++ b/ui/src/components/invoicing/InvoicingView.tsx @@ -927,6 +927,13 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog } label="Contract" value={deepStr(invoice, "contract.title") || "—"} /> } label="Currency" value={str(invoice, "currency") || "EUR"} /> } label="Tax" value={taxTreatment(invoiceTaxCategory, invoiceVatRate)} /> + {/* Only shown when the invoice currency differs from the primary one. */} + {str(invoice, "fx_rate_formatted") && ( + } label="Exchange rate" value={str(invoice, "fx_rate_formatted")} /> + )} + {str(invoice, "total_primary_formatted") && ( + } label="Converted total" value={str(invoice, "total_primary_formatted")} /> + )} {isRem && num(invoice, "reminder_fee") > 0 && ( } label="Reminder Fee" value={String(num(invoice, "reminder_fee"))} /> )} diff --git a/ui/src/components/settings/SettingsView.tsx b/ui/src/components/settings/SettingsView.tsx index 36995acf..8f05e658 100644 --- a/ui/src/components/settings/SettingsView.tsx +++ b/ui/src/components/settings/SettingsView.tsx @@ -99,6 +99,18 @@ const SCHEME_EXAMPLES: Record = { plain: "01", }; +interface CurrencySettings { + primary: string; + fx_haircut: string; + supported: string[]; +} + +const DEFAULT_CURRENCY: CurrencySettings = { + primary: "EUR", + fx_haircut: "1.0", + supported: ["EUR", "GBP", "USD"], +}; + type Tab = "profile" | "branding" | "invoicing" | "llm" | "system" | "debug"; const TABS: { id: Tab; label: string; icon: typeof User }[] = [ @@ -149,7 +161,9 @@ export function SettingsView() { const [addingNote, setAddingNote] = useState(false); const invoicingLoadRef = useRef(0); - useEffect(() => { loadConfig(); loadProfile(); loadSupportedCountries(); loadSavedNotes(); }, []); + const [currency, setCurrency] = useState({ ...DEFAULT_CURRENCY }); + + useEffect(() => { loadConfig(); loadProfile(); loadSupportedCountries(); loadSavedNotes(); loadCurrency(); }, []); useEffect(() => { if (tab === "invoicing") loadInvoicingPrefs(); @@ -273,10 +287,20 @@ export function SettingsView() { }, }; const res = await rpc("users.update_profile", payload); + if (res.ok) { + await rpc("settings.save_currency", { primary: currency.primary, fx_haircut: currency.fx_haircut }); + } setProfileStatus(res.ok ? { type: "success", msg: "Profile saved." } : { type: "error", msg: res.error || "Failed to save profile." }); setProfileSaving(false); } + // -- Currency conversion -------------------------------------------------- + + async function loadCurrency() { + const res = await rpc("settings.get_currency", { country: profile.operating_country }); + if (res.ok && res.data) setCurrency({ ...DEFAULT_CURRENCY, ...res.data }); + } + async function handleResetDemo() { if (!confirm("This will delete all demo data and recreate it from scratch. Continue?")) return; setResettingDemo(true); @@ -554,6 +578,45 @@ export function SettingsView() { +
+ Currency conversion +

+ 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. +

+
+
+ + +

Dashboard, tax, and salary figures are shown in this currency.

+
+
+ + setCurrency((c) => ({ ...c, fx_haircut: e.target.value }))} + /> +

Bank/exchange spread, deducted from the salary estimate only.

+
+
+
+
Bank Account
From 0601b4db7b6b2dd6144013b753ea29bc815f6f93 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 16:25:45 +0200 Subject: [PATCH 08/13] fix(app_db): create app tables on connect, isolate tests from ~/.tuttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tuttle.fx reads currency.primary from app.db, but app_setting was only created by ensure() on the app's startup path — so pytest on a fresh $HOME failed with "no such table: app_setting". ensure() now runs from __init__, and is scoped to the two app tables (SQLModel's metadata is shared with tuttle.model, so an unscoped create_all mirrored every per-user business table into app.db. Tests now point TUTTLE_DATA_DIR at tmp_path: they were reading the developer's real settings and caching fetched fx rates into the production app.db. Co-Authored-By: Claude Opus 4.8 EOF ) --- tuttle/app_db.py | 15 ++++++++++++--- tuttle_tests/conftest.py | 7 +++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tuttle/app_db.py b/tuttle/app_db.py index aa2e850f..577ff1e1 100644 --- a/tuttle/app_db.py +++ b/tuttle/app_db.py @@ -96,11 +96,20 @@ def __init__(self, app_dir: Optional[Path] = None): "connect", lambda dbapi_conn, _: dbapi_conn.execute("PRAGMA foreign_keys = ON"), ) + self.ensure() def ensure(self): - """Create tables if they don't exist.""" - RegisteredUser.metadata.create_all(self._engine) - AppSetting.metadata.create_all(self._engine) + """Create the app tables if they don't exist. + + Called from __init__ so any reader (e.g. tuttle.fx asking for the + primary currency) works before the app has run its startup path. + Scoped to the two app tables: SQLModel's metadata is shared with + tuttle.model, and an unscoped create_all would mirror every + per-user business table into app.db. + """ + _app_metadata.create_all( + self._engine, tables=[RegisteredUser.__table__, AppSetting.__table__] + ) def _session(self) -> Session: return Session(self._engine, expire_on_commit=False) diff --git a/tuttle_tests/conftest.py b/tuttle_tests/conftest.py index a54d300f..e685d071 100644 --- a/tuttle_tests/conftest.py +++ b/tuttle_tests/conftest.py @@ -8,6 +8,13 @@ from tuttle.model import Project, Client, Address, Contact, User, BankAccount, Contract +@pytest.fixture(autouse=True) +def isolated_data_dir(tmp_path, monkeypatch): + """Keep tests out of the real ~/.tuttle — they must not read the + developer's settings or write fx rate caches into production app.db.""" + monkeypatch.setenv("TUTTLE_DATA_DIR", str(tmp_path / "tuttle-data")) + + @pytest.fixture def demo_contact(): return Contact( From beb24feba7e7316be1b42023e53447bb6772d153 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Tue, 14 Jul 2026 16:47:50 +0200 Subject: [PATCH 09/13] feat(currency): source the currency list from the rate provider, validate on write The two currency dropdowns were hand-maintained, disagreed with each other, and disagreed with what we can actually convert: settings offered EUR/GBP/USD, contracts offered EUR/USD/GBP/CHF, and nothing validated Contract.currency at all. A code outside the ECB set silently fails to convert and drops out of every aggregate. Both dropdowns now read supported_currencies(), which fetches the rate provider's own list and falls back to the ECB set frozen in SUPPORTED_CURRENCIES when offline. validate_currency_code() rejects anything else on the three write paths that persist a currency: contract save, document import, recurring expense. It is an explicit call rather than a pydantic validator because those do not run on SQLModel(table=True) classes, as validate_pricing/validate_vat already document. Also stubs fx in conftest -- the suite was making real calls to frankfurter.dev. Co-Authored-By: Claude Opus 4.8 --- tuttle/app/contracts/intent.py | 8 ++++ tuttle/app/core/utils.py | 16 -------- tuttle/app/imports/intent.py | 7 ++++ tuttle/app/salary/intent.py | 10 ++++- tuttle/app/settings/intent.py | 4 +- tuttle/fx.py | 41 +++++++++++++++++++- tuttle/model.py | 10 +++++ tuttle_tests/conftest.py | 23 +++++++++++ tuttle_tests/test_fx.py | 27 +++++++++++++ ui/src/components/business/ContractsView.tsx | 18 ++++++--- 10 files changed, 137 insertions(+), 27 deletions(-) diff --git a/tuttle/app/contracts/intent.py b/tuttle/app/contracts/intent.py index 981119c6..5d10a244 100644 --- a/tuttle/app/contracts/intent.py +++ b/tuttle/app/contracts/intent.py @@ -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: diff --git a/tuttle/app/core/utils.py b/tuttle/app/core/utils.py index bee46030..39ae5645 100644 --- a/tuttle/app/core/utils.py +++ b/tuttle/app/core/utils.py @@ -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 @@ -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 diff --git a/tuttle/app/imports/intent.py b/tuttle/app/imports/intent.py index 7d799a26..adb4d1c2 100644 --- a/tuttle/app/imports/intent.py +++ b/tuttle/app/imports/intent.py @@ -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, @@ -370,6 +371,9 @@ 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 = ( @@ -377,6 +381,9 @@ def _finalize_contract(entity: Contract, provided: dict) -> None: 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() diff --git a/tuttle/app/salary/intent.py b/tuttle/app/salary/intent.py index f56cd8f8..e31d8a1e 100644 --- a/tuttle/app/salary/intent.py +++ b/tuttle/app/salary/intent.py @@ -3,7 +3,7 @@ from ..core.abstractions import SQLModelDataSourceMixin, Intent from ..core.intent_result import IntentResult -from ...fx import primary_currency +from ...fx import primary_currency, validate_currency_code from ...model import Invoice, RecurringExpense, User from ...tax_reserves import compute_effective_salary @@ -66,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: diff --git a/tuttle/app/settings/intent.py b/tuttle/app/settings/intent.py index 7e6ffd82..1a977c94 100644 --- a/tuttle/app/settings/intent.py +++ b/tuttle/app/settings/intent.py @@ -2,7 +2,7 @@ from ..core.intent_result import IntentResult from ...app_db import AppDatabase -from ...fx import SUPPORTED_CURRENCIES, fx_haircut, primary_currency +from ...fx import fx_haircut, primary_currency, supported_currencies class SettingsIntent: @@ -18,7 +18,7 @@ def get_currency(self, country: str = "Germany") -> IntentResult: data={ "primary": primary_currency(country), "fx_haircut": str(fx_haircut()), - "supported": list(SUPPORTED_CURRENCIES), + "supported": list(supported_currencies()), }, ) diff --git a/tuttle/fx.py b/tuttle/fx.py index f39e82ec..c4514118 100644 --- a/tuttle/fx.py +++ b/tuttle/fx.py @@ -24,7 +24,13 @@ logger = logging.getLogger(__name__) -SUPPORTED_CURRENCIES = ("EUR", "GBP", "USD") +# The ECB reference set as published on 2026-07-14; used when the API is +# unreachable. supported_currencies() prefers the live list. +SUPPORTED_CURRENCIES = ( + "AUD", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "EUR", "GBP", "HKD", + "HUF", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", + "NZD", "PHP", "PLN", "RON", "SEK", "SGD", "THB", "TRY", "USD", "ZAR", +) _API = "https://api.frankfurter.dev/v1" _TIMEOUT = 5.0 @@ -33,6 +39,20 @@ # -- Settings ----------------------------------------------------------------- +@lru_cache(maxsize=1) +def supported_currencies() -> tuple[str, ...]: + """Currencies the rate source publishes rates for.""" + request = urllib.request.Request( + f"{_API}/currencies", headers={"User-Agent": "tuttle"} + ) + try: + with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp: + return tuple(sorted(json.load(resp))) + except (urllib.error.URLError, OSError, ValueError, TimeoutError) as e: + logger.warning("Currency list unavailable (%s); using the built-in set.", e) + return SUPPORTED_CURRENCIES + + def primary_currency(country: str = "Germany") -> str: """The currency all aggregates are expressed in. @@ -48,6 +68,22 @@ def primary_currency(country: str = "Germany") -> str: return "EUR" +def validate_currency_code(code: str) -> str: + """Normalise an ISO 4217 code and reject one we cannot convert. + + Aggregates convert foreign-currency amounts at the ECB monthly average, so + a code the rate source does not publish would never convert and would drop + out of every total. Rejecting it on write beats discovering it in a sum. + """ + normalized = (code or "").strip().upper() + supported = supported_currencies() + if normalized not in supported: + raise ValueError( + f"Unsupported currency {code!r}. Supported: {', '.join(supported)}." + ) + return normalized + + def fx_haircut() -> Decimal: """Bank/exchange spread in percent, deducted from the salary estimate only. @@ -145,5 +181,6 @@ def convert( def clear_cache() -> None: - """Drop the in-process rate cache (tests, and after a settings change).""" + """Drop the in-process caches (tests, and after a settings change).""" _rate.cache_clear() + supported_currencies.cache_clear() diff --git a/tuttle/model.py b/tuttle/model.py index dcfd649a..5b429c6f 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -668,6 +668,16 @@ def get_status(self, default: str = "All") -> str: # default return default + def validate_currency(self) -> None: + """Normalise the currency code in place and reject an unsupported one. + + Explicit, like ``validate_pricing``, for the same reason: pydantic + validators do not run on ``SQLModel(table=True)`` classes. + """ + from .fx import validate_currency_code + + self.currency = validate_currency_code(self.currency) + def validate_pricing(self) -> None: """Enforce that the contract's pricing matches its ``type``. diff --git a/tuttle_tests/conftest.py b/tuttle_tests/conftest.py index e685d071..c61a285a 100644 --- a/tuttle_tests/conftest.py +++ b/tuttle_tests/conftest.py @@ -3,10 +3,16 @@ import pytest from pathlib import Path import datetime +from decimal import Decimal import tuttle +from tuttle import fx from tuttle.model import Project, Client, Address, Contact, User, BankAccount, Contract +# Every non-EUR amount in the suite converts at this rate. A fixed number keeps +# assertions stable; the real ECB average would make them depend on the day. +STUB_FX_RATE = Decimal("0.9") + @pytest.fixture(autouse=True) def isolated_data_dir(tmp_path, monkeypatch): @@ -15,6 +21,23 @@ def isolated_data_dir(tmp_path, monkeypatch): monkeypatch.setenv("TUTTLE_DATA_DIR", str(tmp_path / "tuttle-data")) +@pytest.fixture(autouse=True) +def offline_fx(monkeypatch): + """Cut the two paths in ``fx`` that reach the network. + + The suite must not depend on frankfurter.dev being up, and a rate cached + against one test's app.db must not leak into the next, so the caches are + cleared before each test — while the real functions are still in place. + """ + fx.clear_cache() + monkeypatch.setattr(fx, "supported_currencies", lambda: fx.SUPPORTED_CURRENCIES) + monkeypatch.setattr( + fx, + "_fetch_monthly_average", + lambda base, quote, month: STUB_FX_RATE, + ) + + @pytest.fixture def demo_contact(): return Contact( diff --git a/tuttle_tests/test_fx.py b/tuttle_tests/test_fx.py index accfba6e..af27cb62 100644 --- a/tuttle_tests/test_fx.py +++ b/tuttle_tests/test_fx.py @@ -100,6 +100,33 @@ def test_conversion_fee_hits_salary_but_not_the_tax_base(): ) +class TestCurrencyValidation: + """A currency we cannot convert must be refused on write, not in a sum.""" + + @pytest.fixture(autouse=True) + def stub_supported(self, monkeypatch): + monkeypatch.setattr(fx, "supported_currencies", lambda: ("EUR", "GBP", "USD")) + + @pytest.mark.parametrize("code,expected", [("usd", "USD"), (" eur ", "EUR")]) + def test_normalizes_case_and_whitespace(self, code, expected): + assert fx.validate_currency_code(code) == expected + + @pytest.mark.parametrize("code", ["XYZ", "", None, "BTC"]) + def test_rejects_a_currency_without_rates(self, code): + with pytest.raises(ValueError, match="Unsupported currency"): + fx.validate_currency_code(code) + + def test_contract_validation_writes_the_normalized_code_back(self): + contract = Contract(title="c", currency="usd", start_date=datetime.date.today()) + contract.validate_currency() + assert contract.currency == "USD" + + def test_contract_rejects_an_unconvertible_currency(self): + contract = Contract(title="c", currency="XYZ", start_date=datetime.date.today()) + with pytest.raises(ValueError, match="Unsupported currency"): + contract.validate_currency() + + def test_unresolvable_rate_leaves_the_invoice_out_rather_than_zeroing_it(monkeypatch): import tuttle.tax_reserves diff --git a/ui/src/components/business/ContractsView.tsx b/ui/src/components/business/ContractsView.tsx index d30b5205..fad506a4 100644 --- a/ui/src/components/business/ContractsView.tsx +++ b/ui/src/components/business/ContractsView.tsx @@ -38,6 +38,7 @@ export function ContractsView() { const [deleteError, setDeleteError] = useState(null); const [saveError, setSaveError] = useState(null); const [defaultCurrency, setDefaultCurrency] = useState("EUR"); + const [currencies, setCurrencies] = useState(["EUR", "USD", "GBP", "CHF"]); const [parsedContracts, setParsedContracts] = useState([]); const [parsing, setParsing] = useState(false); const [parseError, setParseError] = useState(null); @@ -48,10 +49,11 @@ export function ContractsView() { async function load() { setLoading(true); - const [res, clRes, curRes] = await Promise.all([ + const [res, clRes, curRes, supRes] = await Promise.all([ rpc("contracts.get_all"), rpc>("contracts.get_all_clients"), rpc("contracts.get_default_currency"), + rpc<{ supported: string[] }>("settings.get_currency"), ]); if (res.ok && res.data) { setContracts(res.data); @@ -63,6 +65,7 @@ export function ContractsView() { } if (clRes.ok && clRes.data) setClients(clRes.data); if (curRes.ok && curRes.data) setDefaultCurrency(curRes.data); + if (supRes.ok && supRes.data?.supported?.length) setCurrencies(supRes.data.supported); setLoading(false); } @@ -207,9 +210,9 @@ export function ContractsView() { onDiscard={discardContract} onUpdate={updateParsedContract} onClose={() => setMode("view")} /> ) : mode === "create" ? ( - setMode("view")} error={saveError} /> + setMode("view")} error={saveError} /> ) : mode === "edit" && selected ? ( - setMode("view")} error={saveError} /> + setMode("view")} error={saveError} /> ) : selected ? ( setMode("edit")} @@ -423,10 +426,11 @@ interface ContractFormData { type PricingMode = "time_based" | "fixed_price"; -function ContractForm({ contract, clients, defaultCurrency, onSave, onCancel, error }: { +function ContractForm({ contract, clients, defaultCurrency, currencies, onSave, onCancel, error }: { contract?: Entity; clients: Record; defaultCurrency: string; + currencies: string[]; onSave: (data: ContractFormData) => void; onCancel: () => void; error?: string | null; @@ -473,6 +477,8 @@ function ContractForm({ contract, clients, defaultCurrency, onSave, onCancel, er const isFixed = pricingMode === "fixed_price"; const clientList = Object.values(clients); + // Keep an existing contract's currency selectable even if it left the list. + const currencyOptions = currencies.includes(form.currency) ? currencies : [form.currency, ...currencies]; function update(field: K, value: ContractFormData[K]) { setForm((prev) => ({ ...prev, [field]: value })); @@ -572,7 +578,7 @@ function ContractForm({ contract, clients, defaultCurrency, onSave, onCancel, er
@@ -601,7 +607,7 @@ function ContractForm({ contract, clients, defaultCurrency, onSave, onCancel, er
From c7c9617525af3edc57ec74ff6bf939c5a3787f64 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Wed, 15 Jul 2026 08:19:41 +0200 Subject: [PATCH 10/13] refactor(currency): move currency settings from Profile to System tab Primary currency and conversion fee are stored app-wide in app_db, not per user, yet sat under the per-user Profile tab and saved on profile save. Move them to the System tab (local/machine settings) with their own save button, matching where the values actually live. Co-Authored-By: Claude Opus 4.8 --- ui/src/components/settings/SettingsView.tsx | 119 +++++++++++--------- 1 file changed, 67 insertions(+), 52 deletions(-) diff --git a/ui/src/components/settings/SettingsView.tsx b/ui/src/components/settings/SettingsView.tsx index 8f05e658..da3c63d2 100644 --- a/ui/src/components/settings/SettingsView.tsx +++ b/ui/src/components/settings/SettingsView.tsx @@ -161,9 +161,7 @@ export function SettingsView() { const [addingNote, setAddingNote] = useState(false); const invoicingLoadRef = useRef(0); - const [currency, setCurrency] = useState({ ...DEFAULT_CURRENCY }); - - useEffect(() => { loadConfig(); loadProfile(); loadSupportedCountries(); loadSavedNotes(); loadCurrency(); }, []); + useEffect(() => { loadConfig(); loadProfile(); loadSupportedCountries(); loadSavedNotes(); }, []); useEffect(() => { if (tab === "invoicing") loadInvoicingPrefs(); @@ -287,20 +285,10 @@ export function SettingsView() { }, }; const res = await rpc("users.update_profile", payload); - if (res.ok) { - await rpc("settings.save_currency", { primary: currency.primary, fx_haircut: currency.fx_haircut }); - } setProfileStatus(res.ok ? { type: "success", msg: "Profile saved." } : { type: "error", msg: res.error || "Failed to save profile." }); setProfileSaving(false); } - // -- Currency conversion -------------------------------------------------- - - async function loadCurrency() { - const res = await rpc("settings.get_currency", { country: profile.operating_country }); - if (res.ok && res.data) setCurrency({ ...DEFAULT_CURRENCY, ...res.data }); - } - async function handleResetDemo() { if (!confirm("This will delete all demo data and recreate it from scratch. Continue?")) return; setResettingDemo(true); @@ -578,45 +566,6 @@ export function SettingsView() {
-
- Currency conversion -

- 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. -

-
-
- - -

Dashboard, tax, and salary figures are shown in this currency.

-
-
- - setCurrency((c) => ({ ...c, fx_haircut: e.target.value }))} - /> -

Bank/exchange spread, deducted from the salary estimate only.

-
-
-
-
Bank Account
@@ -1135,6 +1084,25 @@ function SystemTab() { const { choice, setChoice } = useTheme(); const [checking, setChecking] = useState(false); + const [currency, setCurrency] = useState({ ...DEFAULT_CURRENCY }); + const [currencySaving, setCurrencySaving] = useState(false); + + useEffect(() => { + rpc("settings.get_currency").then((res) => { + if (res.ok && res.data) setCurrency({ ...DEFAULT_CURRENCY, ...res.data }); + }); + }, []); + + async function handleSaveCurrency() { + setCurrencySaving(true); + const res = await rpc("settings.save_currency", { primary: currency.primary, fx_haircut: currency.fx_haircut }); + showMessage(res.ok ? "Currency settings saved." : res.error || "Failed to save currency settings.", { type: res.ok ? "success" : "error" }); + setCurrencySaving(false); + } + + const inputCls = "w-full px-3 py-2 rounded-md text-sm bg-bg-card text-primary border border-border-subtle outline-none focus:border-accent transition-colors placeholder:text-muted"; + const labelCls = "block text-xs text-tertiary mb-1"; + useEffect(() => { const offs = [ window.tuttle?.onUpdateNotAvailable?.(() => { @@ -1181,6 +1149,53 @@ function SystemTab() {
+
+ Currency conversion +

+ 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. +

+
+
+ + +

Dashboard, tax, and salary figures are shown in this currency.

+
+
+ + setCurrency((c) => ({ ...c, fx_haircut: e.target.value }))} + /> +

Bank/exchange spread, deducted from the salary estimate only.

+
+
+ +
+

About Tuttle

Version {__APP_VERSION__}

From 56259ef7fdb859c8818974b0c4e0d25abe47bf66 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Wed, 15 Jul 2026 08:29:35 +0200 Subject: [PATCH 11/13] refactor(currency): condense the currency-conversion explainer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (too verbose): fold the two-paragraph explainer into one tight sentence that still names the ECB monthly average, § 16 Abs. 6 UStG, and the salary-only nature of the conversion fee. Co-Authored-By: Claude Opus 4.8 --- ui/src/components/settings/SettingsView.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/ui/src/components/settings/SettingsView.tsx b/ui/src/components/settings/SettingsView.tsx index da3c63d2..7e0c539f 100644 --- a/ui/src/components/settings/SettingsView.tsx +++ b/ui/src/components/settings/SettingsView.tsx @@ -1152,14 +1152,9 @@ function SystemTab() {
Currency conversion

- 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. + Only matters if you invoice in a currency other than the one you're taxed in. Invoices keep their own + currency; this converts them for your dashboard, tax, and salary at the ECB monthly average + (§ 16 Abs. 6 UStG). The conversion fee reduces the salary estimate only, never your taxable revenue.

From ef473abad0aff17258f7bf80b254741bea5639bf Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Wed, 15 Jul 2026 08:37:04 +0200 Subject: [PATCH 12/13] feat(currency): default primary currency from the active user's country The Local settings tab reads currency settings without passing a country, so derive the default server-side from the active user's operating country (only used until an explicit currency.primary is saved). Keeps the tab decoupled from the user profile while still preselecting sensibly. Co-Authored-By: Claude Opus 4.8 --- tuttle/app/settings/intent.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tuttle/app/settings/intent.py b/tuttle/app/settings/intent.py index 1a977c94..a5655a61 100644 --- a/tuttle/app/settings/intent.py +++ b/tuttle/app/settings/intent.py @@ -11,17 +11,30 @@ def __init__(self): # -- Currency conversion ------------------------------------------------ - def get_currency(self, country: str = "Germany") -> IntentResult: - """Currency-conversion settings, defaulted from the operating country.""" + 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), + "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)) From 3380b6635d05c420c45455bedf87fe0da9254436 Mon Sep 17 00:00:00 2001 From: Aaron Spring Date: Wed, 15 Jul 2026 08:37:04 +0200 Subject: [PATCH 13/13] refactor(currency): give currency settings their own Local tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the currency-conversion section out of System into a dedicated "Local" tab — machine-wide, region-specific settings shared across all users on the computer, distinct from System (theme, updates, version). Co-Authored-By: Claude Opus 4.8 --- ui/src/components/settings/SettingsView.tsx | 116 ++++++++++++-------- 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/ui/src/components/settings/SettingsView.tsx b/ui/src/components/settings/SettingsView.tsx index 7e0c539f..b263de12 100644 --- a/ui/src/components/settings/SettingsView.tsx +++ b/ui/src/components/settings/SettingsView.tsx @@ -11,7 +11,7 @@ */ import { useEffect, useRef, useState } from "react"; -import { Settings, RefreshCw, Save, CheckCircle2, AlertCircle, User, Bot, FileText, RotateCcw, Trash2, AlertTriangle, Monitor, Info, Image as ImageIcon, X, Sun, Moon, Laptop, Palette, Terminal, Clipboard, Check } from "lucide-react"; +import { Settings, RefreshCw, Save, CheckCircle2, AlertCircle, User, Bot, FileText, RotateCcw, Trash2, AlertTriangle, Monitor, Globe, Info, Image as ImageIcon, X, Sun, Moon, Laptop, Palette, Terminal, Clipboard, Check } from "lucide-react"; import { useTheme, type ThemeChoice } from "../../hooks/useTheme"; import { rpc } from "../../api/rpc"; import type { Entity } from "../../api/types"; @@ -111,13 +111,14 @@ const DEFAULT_CURRENCY: CurrencySettings = { supported: ["EUR", "GBP", "USD"], }; -type Tab = "profile" | "branding" | "invoicing" | "llm" | "system" | "debug"; +type Tab = "profile" | "branding" | "invoicing" | "llm" | "local" | "system" | "debug"; const TABS: { id: Tab; label: string; icon: typeof User }[] = [ { id: "profile", label: "Profile", icon: User }, { id: "branding", label: "Branding", icon: Palette }, { id: "invoicing", label: "Invoicing", icon: FileText }, { id: "llm", label: "AI / LLM", icon: Bot }, + { id: "local", label: "Local", icon: Globe }, { id: "system", label: "System", icon: Monitor }, { id: "debug", label: "Debug", icon: Terminal }, ]; @@ -925,6 +926,10 @@ export function SettingsView() { )} + {tab === "local" && ( + + )} + {tab === "system" && ( )} @@ -1079,11 +1084,12 @@ const THEME_OPTIONS: { id: ThemeChoice; label: string; icon: typeof Sun }[] = [ { id: "system", label: "System", icon: Laptop }, ]; -function SystemTab() { - const { log, showMessage } = useStatusBar(); - const { choice, setChoice } = useTheme(); - const [checking, setChecking] = useState(false); +// --------------------------------------------------------------------------- +// Local tab — machine-wide, region-specific settings (not tied to a user) +// --------------------------------------------------------------------------- +function LocalTab() { + const { showMessage } = useStatusBar(); const [currency, setCurrency] = useState({ ...DEFAULT_CURRENCY }); const [currencySaving, setCurrencySaving] = useState(false); @@ -1103,6 +1109,62 @@ function SystemTab() { const inputCls = "w-full px-3 py-2 rounded-md text-sm bg-bg-card text-primary border border-border-subtle outline-none focus:border-accent transition-colors placeholder:text-muted"; const labelCls = "block text-xs text-tertiary mb-1"; + return ( +
+

+ Machine-wide settings shared across all users on this computer, not stored in any one profile. +

+ +
+ Currency conversion +

+ Only matters if you invoice in a currency other than the one you're taxed in. Invoices keep their own + currency; this converts them for your dashboard, tax, and salary at the ECB monthly average + (§ 16 Abs. 6 UStG). The conversion fee reduces the salary estimate only, never your taxable revenue. +

+
+
+ + +

Dashboard, tax, and salary figures are shown in this currency.

+
+
+ + setCurrency((c) => ({ ...c, fx_haircut: e.target.value }))} + /> +

Bank/exchange spread, deducted from the salary estimate only.

+
+
+ +
+
+ ); +} + +function SystemTab() { + const { log, showMessage } = useStatusBar(); + const { choice, setChoice } = useTheme(); + const [checking, setChecking] = useState(false); + useEffect(() => { const offs = [ window.tuttle?.onUpdateNotAvailable?.(() => { @@ -1149,48 +1211,6 @@ function SystemTab() {
-
- Currency conversion -

- Only matters if you invoice in a currency other than the one you're taxed in. Invoices keep their own - currency; this converts them for your dashboard, tax, and salary at the ECB monthly average - (§ 16 Abs. 6 UStG). The conversion fee reduces the salary estimate only, never your taxable revenue. -

-
-
- - -

Dashboard, tax, and salary figures are shown in this currency.

-
-
- - setCurrency((c) => ({ ...c, fx_haircut: e.target.value }))} - /> -

Bank/exchange spread, deducted from the salary estimate only.

-
-
- -
-

About Tuttle

Version {__APP_VERSION__}