feat(currency): mixed-currency support — design and implementation#419
feat(currency): mixed-currency support — design and implementation#419aaronspring wants to merge 11 commits into
Conversation
Lays out the problem behind tuttle-dev#396/tuttle-dev#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 tuttle-dev#401 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@clstaudt do we want to have a design docs folder somewhere? or is a design doc just a build artefact for the PR which isn't merged finally? |
|
@aaronspring A design docs folder that documents design and architecture decisions - both human-readable and agent-readable - would be a good addition. |
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 <noreply@anthropic.com>
Per review on tuttle-dev#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… invoice 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 <noreply@anthropic.com>
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 tuttle-dev#401, tuttle-dev#396, tuttle-dev#400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verification — Electron app, Harry Tuttle demo userThe demo user now has a US client (Ductwork Security Inc., San Francisco) with an IT Security Review project and a paid $1,000 USD invoice — B2B to a non-EU recipient, so outside the scope of German VAT. Invoice detail — invoice stays USD, conversion shown alongsideThe invoice is a legal document stating what the client owes: it stays $1,000.00 in the list, the PDF, and the e-invoice. The rate and the converted amount are shown next to it, and are hidden entirely when the invoice currency matches the primary one. Currency Dashboard — converted, not mixed and not droppedBefore this change the same data produced two contradictory wrong answers at once: the USD totals were summed into revenue as if they were euros, and skipped by the tax and salary reserves. Settings — new "Currency conversion" sectionBoth keys live in the existing Checks
Screenshots live on the |
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 <noreply@anthropic.com> EOF )
| 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. | ||
| </p> | ||
| <p className="text-xs text-secondary mt-1"> | ||
| 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. |
| - 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 |
…date 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 <noreply@anthropic.com>
clstaudt
left a comment
There was a problem hiding this comment.
Thank you for figuring out this new topic!
I wonder: Are the user profile settings the right place for currency conversion settings? Mixed-currency business is probably a special case that the majority of users won't have to deal with.
|
I would keep it somewhere in settings. Maybe make currency within user setting collapsed or new currency settings (a bit empty for now) |
|
Maybe a "Locale" settings group, different from the user's profile data? |



Mixed-currency support, per discussion #401: the design doc and its implementation. The doc landed first for review; the code follows it step for step.
Lands in a new top-level
design_docs/, as suggested in review: decisions with their rejected alternatives, kept after the code lands rather than discarded with the PR.Why
Tuttle assumes you invoice in the currency you're taxed in. For a German-taxed freelancer invoicing US clients in USD, it currently produces two contradictory wrong answers at once:
compute_kpissumsinv.totalwith no currency check, then formats the sum as the tax currency. A $10,000 invoice displays as €10,000.compute_vat_reserves,compute_spendable_income, andmonthly_spendable_breakdownskip any invoice whose contract currency differs from the tax system's.Foreign revenue inflates the dashboard and vanishes from spendable income simultaneously — the salary confusion in #396/#400.
Separately:
einvoice.pynever emits BT-6 / BT-111, so a foreign-currency invoice produces XML that doesn't conform to EN16931.Design
Invoices stay in their contract currency. Only aggregates convert. The PDF, the e-invoice, the invoice list, and the timeline stay in USD — that's what the client owes. Conversion happens only where invoices are summed: dashboard, tax reserves, salary, forecasting.
The rate is a function, not a column. The ECB monthly average for the invoice's month, which is what § 16 Abs. 6 UStG requires. A closed month's average never changes, so
rate(currency, month)is already deterministic and last year's tax figures cannot move under us — which means noInvoice.fx_rate, no migration, no backfill. Rates come from frankfurter.dev and are cached in the existingapp_dbkey/value store; a month with no cached rate and no network stays unconverted and flagged, rather than silently counting as zero. Add the column the first time someone actually has to override a rate.No VAT on foreign-currency invoices, so no VAT conversion.
TaxCategory.outside_scopealready models B2B services to a non-EU recipient: place of supply is the client's country under § 3a Abs. 2 UStG, so a USD invoice to a US business carries no German VAT. Same for EU reverse charge at 0%. Converted VAT is therefore arithmetic on zeros in every supported case, and VAT rounding rules are out of scope. The one case that would need them — place of supply in Germany, e.g. B2C — is guarded with a warning instead of built.New "Currency conversion" settings section, both keys in the existing KV store:
currency.primary(defaulted from the operating country's tax system) andcurrency.fx_haircut. The ~1% bank spread reduces the salary estimate only, never taxable revenue. The section carries an explainer, since it's inert unless you invoice in a currency other than the one you're taxed in.Implementation
All six planned steps, in one commit:
einvoice.pyemits BT-6 (TaxCurrencyCode) and BT-111 (VAT total in the accounting currency) when the invoice currency differs from the tax currency. drafthorse already hadtax_total_other_currencyfor exactly this. Validated against the EN16931 XSD.currency.primaryandcurrency.fx_haircut, plus the explainer. No schema change.tuttle/fx.py(~130 lines):rate()/convert()against frankfurter.dev, ECB monthly average, cached inapp_db. Stdliburllib, no new dependency.kpi.pyandtax_reserves.pyare gone, replaced byconvert_invoice(). An unresolvable rate leaves the invoice out and warns; it is never counted as zero. Same change adds the currency / rate / converted-total rows to the invoice detail view.currency.fx_haircutapplied incompute_spendable_income, after the tax reserve, so it can never reduce taxable revenue.Also: the demo user gets a US client billed in USD, and
create_fake_invoicenow inherits the contract's VAT rate and category instead of hardcoding 19% — it had been minting USD invoices carrying German VAT, the exact combination this design rules out.Checks: 494 passed, 1 skipped;
tsc --noEmitclean. Screenshots of the running app in the comment below.🤖 Generated with Claude Code