Skip to content

feat(currency): mixed-currency support — design and implementation#419

Open
aaronspring wants to merge 11 commits into
tuttle-dev:mainfrom
aaronspring:docs/mixed-currency
Open

feat(currency): mixed-currency support — design and implementation#419
aaronspring wants to merge 11 commits into
tuttle-dev:mainfrom
aaronspring:docs/mixed-currency

Conversation

@aaronspring

@aaronspring aaronspring commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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:

  • Revenue KPIs mix currencies. compute_kpis sums inv.total with no currency check, then formats the sum as the tax currency. A $10,000 invoice displays as €10,000.
  • Tax and salary KPIs drop them. compute_vat_reserves, compute_spendable_income, and monthly_spendable_breakdown skip 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.py never 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 no Invoice.fx_rate, no migration, no backfill. Rates come from frankfurter.dev and are cached in the existing app_db key/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_scope already 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) and currency.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:

  1. E-invoice conformanceeinvoice.py emits BT-6 (TaxCurrencyCode) and BT-111 (VAT total in the accounting currency) when the invoice currency differs from the tax currency. drafthorse already had tax_total_other_currency for exactly this. Validated against the EN16931 XSD.
  2. Settings — "Currency conversion" fieldset with currency.primary and currency.fx_haircut, plus the explainer. No schema change.
  3. FX rate module — new tuttle/fx.py (~130 lines): rate() / convert() against frankfurter.dev, ECB monthly average, cached in app_db. Stdlib urllib, no new dependency.
  4. Aggregates converted — the currency skip branches in kpi.py and tax_reserves.py are gone, replaced by convert_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.
  5. Salary haircutcurrency.fx_haircut applied in compute_spendable_income, after the tax reserve, so it can never reduce taxable revenue.
  6. Guards — a contract with a nonzero VAT rate and a foreign currency (the case ruled out above) warns instead of guessing at converted VAT.

Also: the demo user gets a US client billed in USD, and create_fake_invoice now 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 --noEmit clean. Screenshots of the running app in the comment below.

🤖 Generated with Claude Code

aaronspring and others added 2 commits July 14, 2026 15:15
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>
@aaronspring

Copy link
Copy Markdown
Contributor Author

@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?

@clstaudt

Copy link
Copy Markdown
Contributor

@aaronspring A design docs folder that documents design and architecture decisions - both human-readable and agent-readable - would be a good addition.

aaronspring and others added 6 commits July 14, 2026 15:19
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>
@aaronspring

Copy link
Copy Markdown
Contributor Author

Verification — Electron app, Harry Tuttle demo user

The 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 alongside

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

Invoice detail

Currency USD · Exchange rate 1 USD = 0.8567 EUR (ECB monthly average, May 2026) · Converted total ≈ €856.69. The rate comes from rate(currency, invoice_month) — the same function every other consumer calls, which is why showing it needs no stored column.

Dashboard — converted, not mixed and not dropped

Dashboard

Before 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" section

Settings

Both keys live in the existing app_db key/value store, so there is no schema change. If currency.primary equals every contract currency in use, the section is inert and the numbers are identical to today's.

Checks

  • 494 passed, 1 skipped (pytest tuttle_tests), tsc --noEmit clean.
  • The foreign-currency e-invoice validates against the EN16931 XSD with BT-6 and BT-111 present (TestForeignCurrency).
  • Driving it live caught two bugs unit tests would have missed: frankfurter.dev returns 403 to urllib's default User-Agent, and it backfills the previous month's last business day into a date range — which would have quietly polluted every monthly average.

Screenshots live on the assets/mixed-currency branch of the fork, not in this PR's diff.

@aaronspring aaronspring changed the title docs: design for mixed-currency support feat(currency): mixed-currency support — design and implementation Jul 14, 2026
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
)
Comment on lines +584 to +591
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

too verbose?

@clstaudt clstaudt Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. And too German?

Comment thread AGENTS.md
Comment on lines +32 to +33
- 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

belongs to #406

…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>
@aaronspring aaronspring requested a review from clstaudt July 14, 2026 16:08

@clstaudt clstaudt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aaronspring

Copy link
Copy Markdown
Contributor Author

I would keep it somewhere in settings. Maybe make currency within user setting collapsed or new currency settings (a bit empty for now)

@clstaudt

Copy link
Copy Markdown
Contributor

Maybe a "Locale" settings group, different from the user's profile data?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants