diff --git a/AGENTS.md b/AGENTS.md index 6f0c0bd4..78022eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,11 @@ Schema lives in `tuttle/model.py`. Migrations are Alembic in `tuttle/migrations/ Any change to a SQLModel class requires `just migrate ""` + reviewing the generated revision for rename-as-drop+add traps. See `.cursor/rules/schema-migrations.mdc` and `tuttle/migrations/README.md`. + +## Verification + +For big new features, add a new contract and project to the Harry tuttle user for demonstration purposes. + +### UI +- Test UI changes using playwright electron. https://playwright.dev/docs/api/class-electron +- launch the electron app and provide screenshots as artefacts in PR comments to be reviewed. \ No newline at end of file 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/design_docs/mixed-currency.md b/design_docs/mixed-currency.md new file mode 100644 index 00000000..25700db3 --- /dev/null +++ b/design_docs/mixed-currency.md @@ -0,0 +1,178 @@ +# Mixed-currency support + +Discussion: [#401](https://github.com/tuttle-dev/tuttle/discussions/401). Related: #396, #400. + +## The problem + +Tuttle assumes a freelancer invoices in the currency of the country they are taxed +in. The common real case breaks that assumption: taxed in Germany (EUR), invoicing +US clients in USD. + +Today this does not merely lack support — it produces two contradictory wrong +answers at the same time: + +- **Revenue KPIs mix currencies.** `compute_kpis` (`tuttle/kpi.py:84`) sums + `inv.total` over all invoices with no currency check. `KPISummary.to_dict` + (`tuttle/kpi.py:39`) then formats that sum with `tax_currency`. A $10,000 USD + invoice is displayed as €10,000. +- **Tax and salary KPIs silently drop them.** `compute_vat_reserves` and + `compute_spendable_income` (`tuttle/tax_reserves.py:136`, `:263`) and + `monthly_spendable_breakdown` (`tuttle/kpi.py:254`) skip every invoice whose + contract currency differs from the tax system's currency. + +So foreign revenue inflates the dashboard and vanishes from spendable income +simultaneously. This is the salary confusion reported in #396/#400. + +A third, separate defect: `einvoice.py:187` sets the document currency from the +contract but never emits BT-6 (tax currency code) or BT-111 (VAT amount in +accounting currency). EN16931 requires both when the invoice currency differs from +the VAT currency, so a foreign-currency invoice currently produces non-conformant XML. + +## Scope decision: no VAT on foreign-currency invoices + +The model already distinguishes this case. `TaxCategory.outside_scope` (`model.py:431`) +exists for exactly it: B2B services to a non-EU recipient, where the place of supply +is the recipient's country under § 3a Abs. 2 UStG. Such a supply is not taxable in +Germany, so a USD invoice to a US business **carries no German VAT at all**. + +| Case | VAT on the foreign-currency invoice | What conversion must do | +| --- | --- | --- | +| B2B, non-EU client — `outside_scope` | none | Convert totals for revenue, income tax, dashboard. VAT reserve is zero before and after conversion. | +| B2B, EU client — reverse charge, `zero_rated` | 0% | Same. (ZM / UStVA reporting is a separate feature, unrelated to currency.) | +| Place of supply is Germany (B2C etc.) | domestic VAT, e.g. 19% | Real VAT-in-two-currencies: convert the VAT amount, resolve rounding, emit a nonzero BT-111. | + +**Only the third row needs VAT conversion machinery, and it is out of scope.** For the +first two — which is what freelance B2B work actually looks like — converted VAT is +arithmetic on zeros. This is what makes the whole feature small: the fix in +`tax_reserves.py` is not "convert VAT reserves", it is "stop skipping foreign +invoices". They contribute converted revenue to the income-tax base and nothing to the +VAT reserve. + +Rather than build for the third row, **guard against it**: a contract with a nonzero +`VAT_rate` *and* a currency other than the tax currency would produce quietly wrong +numbers. Detect that combination and warn instead of guessing. If someone hits the +warning, we will know the case is real before spending a week on it. + +## Design + +**Invoices stay in their contract currency. Only aggregates convert.** + +The invoice is a legal document stating what the client owes; it is USD and remains +USD in the PDF, the e-invoice, the invoice list, and the timeline. Conversion to the +user's primary currency happens exclusively where values from multiple invoices are +summed: dashboard KPIs, tax reserves, spendable income, forecasting. + +### The rate is a function, not a column + +The **ECB monthly average for the month of the invoice date**. Under § 16 Abs. 6 +UStG the binding rate is the monthly *Umsatzsteuer-Umrechnungskurs* published by the +BMF, which is derived from ECB reference rates — so the legally required rate and the +sensible default coincide. + +A closed month's average never changes, so `rate(currency, month)` is already +deterministic: last year's tax figures cannot move under us. That means **no +`Invoice.fx_rate` column, no migration, and no backfill** — converting on read is +just as stable as freezing a value, and is a function instead of a schema. + +Source: `frankfurter.dev` (free, no API key, ECB data, supports date-range averages). +Fetched rates are cached in the existing `app_db` key/value store, which is what makes +the app work offline for months it has already seen. A month with no cached rate and +no network stays unconverted and is flagged — it does not silently count as zero. + +Pinning to the *invoice date* means VAT (supply date) and income tax (Zufluss / +payment date) use the same rate. Deliberate simplification; revisit only if the drift +is shown to matter. + +**When the column earns its place:** the first time someone must override a rate (a +tax advisor insists on a different one), or wants to see on the invoice which rate was +applied. Add `Invoice.fx_rate` then, nullable, falling back to the function. Not +before. + +### Conversion fee: salary only, never tax + +The ~1% bank/Wise spread proposed in #401 must **not** reduce taxable revenue — the +taxable amount is the ECB-converted figure. But it does reduce what actually lands in +the account, so it belongs in the "what can I spend" estimate. Applied in +`compute_spendable_income` only. This is the tax-precision vs. salary-estimate +distinction raised in the discussion. + +### Settings: a new "Currency conversion" section + +Both keys live in the existing `app_db` key/value store via `SettingsIntent` — no +schema change — and get their own fieldset in +`ui/src/components/settings/SettingsView.tsx`, sibling to **Tax & Legal**: + +| Key | Default | Meaning | +| --- | --- | --- | +| `currency.primary` | `get_tax_system(operating_country).currency` | Currency for dashboard, tax, and salary figures. EUR, GBP, USD for now. | +| `currency.fx_haircut` | `1.0` (%) | Bank/exchange spread deducted from the salary estimate only. | + +Defaulting `currency.primary` from the operating country's tax system means +preselection by country is free and needs no country→currency table. + +The section carries a short explainer, because for most users it is inert: + +> These settings only matter if you invoice in a currency other than the one you are +> taxed in — for example a USD invoice to a US client while being taxed in Germany. +> Invoices always stay in their own currency; this is how those amounts are converted +> for your dashboard, tax reserves, and salary. +> +> The exchange rate is the ECB monthly average for the invoice's month, which is the +> rate German tax law requires (§ 16 Abs. 6 UStG). The conversion fee is subtracted +> from the salary estimate only — it never reduces your taxable revenue. + +If `currency.primary` equals every contract currency in use, the whole section is a +no-op and the numbers are identical to today's. + +### Display + +Invoice amounts always render in their native currency — in the list, the timeline, +the PDF, and the e-invoice. Converted aggregates are prefixed with `≈`, because they +are approximate. + +The **invoice detail view** is the one place both currencies appear together. When the +invoice currency differs from `currency.primary`, show three extra rows: + +| Row | Example | +| --- | --- | +| Invoice currency | `USD` | +| Exchange rate | `1 USD = 0.9174 EUR` (ECB monthly average, Mar 2026) | +| Amount in primary currency | `≈ €9,174.00` | + +Hidden entirely when the two currencies match, so single-currency users never see them. + +This is the "which rate was applied?" question, and it does **not** bring back +`Invoice.fx_rate`: the detail view calls `rate(currency, invoice_month)` like every +other consumer and gets the same deterministic answer. Showing the rate is a read, not +a reason to store it. + +## Plan + +1. **E-invoice conformance** (independent, ships alone). Emit BT-6 and BT-111 in + `einvoice.py` when contract currency ≠ tax currency. With a zero-VAT category the + accounting-currency VAT amount is `0.00`, so this is small — but it is a + conformance bug today, regardless of the dashboard work. +2. **Settings.** New "Currency conversion" fieldset with `currency.primary` (defaulted + from the operating country's tax system) and `currency.fx_haircut`, plus the + explainer. +3. **FX rate module.** `rate(currency, month)` against frankfurter.dev, cached in + `app_db`. One small module, no schema change, no new heavy dependency. +4. **Convert the aggregates.** Delete the skip branches in `tuttle/kpi.py` and + `tuttle/tax_reserves.py`; convert invoice totals through `rate()` instead. + Converted VAT is zero for every in-scope case, so no VAT rounding rules are needed. + Same call adds the currency / rate / converted-amount rows to the invoice detail + view. +5. **Salary haircut.** `currency.fx_haircut` applied in `compute_spendable_income`. +6. **Guard the unsupported cases.** Warn on a contract with nonzero `VAT_rate` and a + currency other than the tax currency, and on a month whose rate could not be + resolved. + +## Out of scope + +- **Domestic VAT on a foreign-currency invoice** (third row above) — converted VAT + amounts, VAT rounding rules, nonzero BT-111. Guarded with a warning instead. +- **Per-invoice stored rate and manual override** (`Invoice.fx_rate`) — the monthly + average is deterministic, so nothing needs freezing until someone must override it. +- Currencies beyond EUR, GBP, USD. +- Per-client or per-project currency defaults. +- Rate pinned to payment date as well as invoice date. 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/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/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 21a122e3..e31d8a1e 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, validate_currency_code from ...model import Invoice, RecurringExpense, User -from ...tax import get_tax_system from ...tax_reserves import compute_effective_salary from .data_source import SalaryDataSource @@ -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.""" @@ -68,6 +66,14 @@ def get_expenses(self) -> IntentResult: def save_expense(self, expense: RecurringExpense) -> IntentResult: """Persist a new or updated recurring expense.""" + try: + expense.currency = validate_currency_code(expense.currency) + except ValueError as e: + return IntentResult( + was_intent_successful=False, + error_msg=str(e), + log_message=f"SalaryIntent.save_expense: {e}", + ) return self._data_source.save_expense(expense) def save_expense_from_dict(self, data: dict) -> IntentResult: @@ -105,4 +111,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..a5655a61 100644 --- a/tuttle/app/settings/intent.py +++ b/tuttle/app/settings/intent.py @@ -2,12 +2,44 @@ from ..core.intent_result import IntentResult from ...app_db import AppDatabase +from ...fx import fx_haircut, primary_currency, supported_currencies class SettingsIntent: def __init__(self): self._app_db = AppDatabase() + # -- Currency conversion ------------------------------------------------ + + def get_currency(self, country: str | None = None) -> IntentResult: + """Currency-conversion settings. + + The primary currency defaults to the active user's operating country + (only used until they save an explicit ``currency.primary``); callers + may still pass ``country`` to override. + """ + return IntentResult( + was_intent_successful=True, + data={ + "primary": primary_currency(country or self._active_operating_country()), + "fx_haircut": str(fx_haircut()), + "supported": list(supported_currencies()), + }, + ) + + def _active_operating_country(self) -> str: + from ..auth.data_source import UserDataSource + + try: + return UserDataSource().get_user().operating_country or "Germany" + except Exception: + return "Germany" + + def save_currency(self, primary: str, fx_haircut: str) -> IntentResult: + self._app_db.set_setting("currency.primary", primary) + self._app_db.set_setting("currency.fx_haircut", str(fx_haircut)) + return IntentResult(was_intent_successful=True, data=None) + def get(self, key: str) -> IntentResult: return IntentResult( was_intent_successful=True, 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/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/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..c4514118 --- /dev/null +++ b/tuttle/fx.py @@ -0,0 +1,186 @@ +"""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__) + +# 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 + + +# -- 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. + + 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 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. + + 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 caches (tests, and after a settings change).""" + _rate.cache_clear() + supported_currencies.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..5b429c6f 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): @@ -671,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``. @@ -917,6 +924,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 +1164,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/conftest.py b/tuttle_tests/conftest.py index a54d300f..c61a285a 100644 --- a/tuttle_tests/conftest.py +++ b/tuttle_tests/conftest.py @@ -3,10 +3,40 @@ 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): + """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(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(): 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..af27cb62 --- /dev/null +++ b/tuttle_tests/test_fx.py @@ -0,0 +1,144 @@ +"""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") + ) + + +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 + + 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/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
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..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"; @@ -99,13 +99,26 @@ const SCHEME_EXAMPLES: Record = { plain: "01", }; -type Tab = "profile" | "branding" | "invoicing" | "llm" | "system" | "debug"; +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" | "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 }, ]; @@ -913,6 +926,10 @@ export function SettingsView() { )} + {tab === "local" && ( + + )} + {tab === "system" && ( )} @@ -1067,6 +1084,82 @@ const THEME_OPTIONS: { id: ThemeChoice; label: string; icon: typeof Sun }[] = [ { id: "system", label: "System", icon: Laptop }, ]; +// --------------------------------------------------------------------------- +// 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); + + 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"; + + 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();