Skip to content

Add tax category "O" (outside scope of tax) for third-country B2B services#407

Merged
clstaudt merged 13 commits into
tuttle-dev:mainfrom
aaronspring:feat/390-tax-category-outside-scope
Jul 13, 2026
Merged

Add tax category "O" (outside scope of tax) for third-country B2B services#407
clstaudt merged 13 commits into
tuttle-dev:mainfrom
aaronspring:feat/390-tax-category-outside-scope

Conversation

@aaronspring

@aaronspring aaronspring commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #390.

Tuttle derived the EN16931 tax category from the VAT rate alone (tuttle/einvoice.py: 0 → "Z", else "S"). A German Freiberufler invoicing a US client for B2B services therefore emitted a zero-rated invoice, when under §3a (2) UStG / Art. 44 VAT Directive the supply is outside the scope of German VAT — UNTDID 5305 category O, a distinct category from Z.

Tax category is now an explicit field on the contract, snapshotted onto each invoice item, driving both the Factur-X XML and the human-readable PDF.

What the schematron actually requires

I pulled the official CII schematron from ConnectingEurope/eInvoicing-EN16931 rather than relying on secondary sources, which disagree on two points. The flag="fatal" rules for category O:

Rule Requirement Implementation
BR-O-01 Exactly one VAT breakdown with CategoryCode O Breakdown keyed by (category, rate)
BR-O-02 No seller VAT id, seller tax-representative VAT id, or buyer VAT id VA registrations omitted
BR-O-05 Invoice line carries no RateApplicablePercent Element left unset
BR-O-09 Breakdown tax amount is 0
BR-O-10 Breakdown carries ExemptionReason or ExemptionReasonCode VATEX-EU-O + text
BR-O-11/12 O never mixes with another category Guarded at invoice creation

BR-O-02 is why this PR also adds User.tax_number (Steuernummer): §14 UStG still requires an identifier on the invoice, and CII carries the Steuernummer under a different scheme (FC).

Changes

  • TaxCategory enum (S/Z/O, values are the UNTDID codes) on Contract and InvoiceItem. The item snapshots the contract's category, so editing a contract cannot retroactively alter an issued invoice.
  • User.tax_number, emitted as CII scheme FC on outside-scope invoices.
  • Tax breakdown keyed by (category, rate) — a zero-rated and an outside-scope line both sit at 0% and previously would have collapsed into one wrong ApplicableTradeTax group.
  • All 7 invoice templates: instead of 0 % in the VAT column, suppressed VAT total row, and a localized legal note (en/de/es).
  • Contract form gains a tax-category selector that zeroes and disables the VAT rate for non-standard categories; settings gains the tax number field.
  • Alembic migration backfilling categories from the VAT rate and the client's country.

Two things reviewers should know

1. @validates is dead code on SQLModel(table=True) classes here. Pydantic's __setattr__ overwrites whatever the SQLAlchemy validator returns, so the pre-existing _normalize_vat_rate hook never fired — which is why _validated_save calls normalize_vat_rate by hand. Both hooks are replaced by an explicit validate_vat() invoked on write paths, in the same style as the existing validate_pricing(). A per-attribute hook would also have wrongly rejected switching a contract from O back to S, since save_from_dict assigns attributes in payload order.

2. The migration is a semantic rewrite, not just a schema change. Existing zero-rate contracts with a non-EU client become category O, so re-rendering an already-issued invoice will produce different output than what was originally sent. A zero-rate line under a taxed contract deliberately stays Z — promoting it to O would create a mixed-category invoice violating BR-O-11/12. An unresolvable client country keeps the historical Z reading rather than silently reclassifying.

Verification

  • 485 tests pass, up from 371 on main. tsc --noEmit is clean.
  • The three load-bearing behaviours were mutation-tested: re-adding the seller VAT id, the line VAT rate, or reverting the breakdown key to rate-only each fails exactly the intended test and nothing else.
  • End-to-end through the real dispatch layer: contract saved as "O" over JSON-RPC → invoice created → PDF rendered → factur-x.xml extracted back out of the resulting PDF and checked against BR-O-02/05/09/10.
  • Driven through the running app (Electron + demo data, not a mock): contract form, settings, invoice view, and both rendered PDFs. Screenshots and the XML extracted from the app-generated PDF are in the comments below.

Note for reviewers on the merge

main gained 8ec5efc5316d (add task table) with the same down_revision as this branch's migration, which left two Alembic heads — alembic upgrade head failed on a fresh database. main is merged in here and the tax-category migration is re-chained onto 8ec5efc5316d, giving a single linear head. It was re-pointed rather than joined with a merge revision because it has not been released; the append-only rule protects revisions already on main.

Out of scope, worth separate issues: no AE (intra-EU reverse charge) category; CI validates against the XSD only, which checks none of the BR-O rules — those live in Schematron, so wiring it in would turn the hand-written assertions in test_einvoice.py into a real conformance gate.

Checklist

  • I have read the Contributing guide.
  • I have installed and tested the application for my platform (just dev). — the built Electron app was launched and clicked through against a fresh database with demo data; see the screenshots in the comments.
  • The test suite passes locally (just test). — 485 passed, 1 skipped.
  • Pre-commit hooks are installed and pass (just precommit). — the pinned black==22.6.0 cannot run on Python 3.14 (asyncio.get_event_loop() removal); new code was formatted with current black instead.
  • I have added or updated tests where appropriate.
  • I have updated the documentation / docstrings where appropriate.
  • If my change touches the schema (tuttle/model.py), I generated and reviewed an Alembic migration (just migrate "<msg>").
  • If my change affects the graphical user interface, I have provided screenshots. — contract form (standard vs. outside scope), contract detail, settings, invoice view, and both rendered PDFs; in the comments below.
  • I understand and can explain all submitted changes; if AI assistance was significant, I have disclosed it in the summary above.

🤖 Generated with Claude Code

aaronspring and others added 3 commits July 10, 2026 12:28
+ Emit EN16931-conformant category O in Factur-X: no line VAT rate (BR-O-05),
  VATEX-EU-O exemption reason (BR-O-10), zero tax amount (BR-O-09), and no
  seller/buyer VAT identifier (BR-O-02).
~ Key the tax breakdown by (category, rate) so zero-rated and outside-scope
  lines no longer collapse into one ApplicableTradeTax group.
~ Replace dead @validates hooks with an explicit validate_vat(): SQLModel
  table classes drop SQLAlchemy validator coercion, and per-attribute hooks
  break order-independent updates in save_from_dict.
+ Guard invoice creation against mixed tax categories (BR-O-11/12).
+ Migration backfills categories from VAT rate and client country.

Refs tuttle-dev#390

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…BR-O-02

  forbids the VAT number, and surfaced in settings.
+ Render outside-scope invoices correctly: em dash instead of "0 %" in the VAT
  column, suppressed VAT total row, and a localized legal note (en/de/es)
  citing §3a (2) UStG / Art. 44 VAT Directive. Applied to all 7 templates.
+ Add tax category selector to the contract form; selecting a non-standard
  category zeroes and disables the VAT rate input.
~ Fix contract edit form defaulting a 0% VAT rate to 19%.

Refs tuttle-dev#390

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  BR-O-01 (one breakdown), BR-O-02 (no seller/buyer VAT id), BR-O-05 (no line
  VAT rate), BR-O-09 (zero tax), BR-O-10 (VATEX-EU-O reason), and that Z and O
  lines never collapse into one breakdown.
+ Test the migration backfill: non-EU zero-rate contracts become O, EU ones
  stay Z, unresolvable countries keep the historical Z reading, and a 0% line
  under a taxed contract stays Z rather than mixing categories.
+ Test tax category coercion, the zero-rate invariant, order-independent
  contract updates, and DB round-tripping.
+ Test PDF rendering across all 7 templates in en/de/es.

Refs tuttle-dev#390

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aaronspring aaronspring marked this pull request as draft July 10, 2026 10:38
@aaronspring

Copy link
Copy Markdown
Contributor Author

Changing stadtwerke metropolis contract to "Outside scope of Tax" creates invoice with no mention of VAT and uses Tax ID in footer.

Screenshot 2026-07-13 at 10 38 40

…tside-scope

  invoices, across all 7 templates. BR-O-02 keeps the VAT number out of the
  embedded CII, so the printed document must not contradict it; the row is
  omitted entirely when neither identifier is set.
+ Add ui/src/api/tax.ts as the single source of truth for tax categories in the
  UI, replacing the labels and rate-formatting duplicated across views.
~ Show outside-scope supplies as "—" rather than "0 %"/"€0.00" in the invoice
  VAT column and the VAT summary tile, matching the rendered PDF.
…le), which

  landed on main with the same parent and left the branch with two Alembic
  heads, so `alembic upgrade head` failed on a fresh database.
@aaronspring

Copy link
Copy Markdown
Contributor Author

GUI verification — contract form

Driven through the real Electron app (Playwright over the packaged renderer) against a fresh DB with demo data, not a mock.

Standard rated — VAT rate editable, as before:

Outside scope of tax — selecting a non-standard category zeroes the VAT rate and disables the input. Asserted in the DOM, so it is genuinely disabled, not just dimmed:

{ value: "0", disabled: true }

Saving persists VAT_rate=0, VAT_category=outside_scope, and re-opening the form keeps 0 — this is the @validates / 0 → 19% default bug the PR describes, confirmed fixed.

Contract detail now reads the category rather than inferring it from the rate:

@aaronspring

Copy link
Copy Markdown
Contributor Author

GUI verification — settings and invoice

Settings — the new User.tax_number (Steuernummer), which BR-O-02 makes necessary: it forbids the VAT number on an outside-scope invoice, but §14 UStG still requires an identifier.

Invoice created from the outside-scope contract. The line item snapshots the category at creation, so editing the contract afterwards cannot retroactively alter an issued invoice — verified: the pre-existing invoices on that contract stayed standard.

VAT is shown as in the summary tile, the line item, and the Details tab, consistent with the rendered PDF beside it:

@aaronspring

Copy link
Copy Markdown
Contributor Author

Rendered PDF, and the XML extracted back out of it

Both PDFs below were produced by the running app, not by a test fixture.

Outside scope (category O)

instead of 0 % in the VAT column, no VAT total row, the localized legal note, and Tax No. in the footer instead of the VAT number:

The footer is load-bearing: BR-O-02 keeps the VAT number out of the embedded CII, so a PDF that still printed it would contradict its own XML. Pulling factur-x.xml back out of that exact file:

Rule Requirement Found
BR-O-01 exactly one breakdown, category O 1
BR-O-02 no seller/buyer VAT id 0 × schemeID="VA", 1 × schemeID="FC"
BR-O-05 no line VAT rate 0 × RateApplicablePercent
BR-O-09 zero tax amount <ram:CalculatedAmount>0.00</ram:CalculatedAmount>
BR-O-10 exemption reason VATEX-EU-O
<ram:ApplicableTradeTax>
  <ram:CalculatedAmount>0.00</ram:CalculatedAmount>
  <ram:TypeCode>VAT</ram:TypeCode>
  <ram:ExemptionReason>Not subject to VAT</ram:ExemptionReason>
  <ram:BasisAmount>1885.00000000000</ram:BasisAmount>
  <ram:CategoryCode>O</ram:CategoryCode>
  <ram:ExemptionReasonCode>VATEX-EU-O</ram:ExemptionReasonCode>
</ram:ApplicableTradeTax>

Standard rated (category S) — regression check

A freshly created standard invoice is the exact mirror: 19.0 % in the VAT column, VAT total row present, VAT No. in the footer, no legal note. Its XML keeps CategoryCode S, the line rate, and schemeID="VA", with no VATEX. Nothing regressed for the common case:

@aaronspring aaronspring marked this pull request as ready for review July 13, 2026 11:25
@clstaudt

Copy link
Copy Markdown
Contributor

Should we wait with merging for a while and leave this here as an example for how a PR should look like?

aaronspring and others added 3 commits July 13, 2026 13:37
… Neither is

  read anywhere: the migration deliberately pins its own copy of the country set,
  and the enum reaches the XML and the UI through .value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… inline

  <style> block and into the invoice.css they already link. The block is wrapped
  in {% if accent_color %}, so a user without an accent color got an unstyled
  legal note; the other five templates already carry the rule in their stylesheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… emits no

  VAT breakdown, only the total, so the category it was keying on was discarded
  again immediately; the (category, rate) key is only load-bearing where a
  breakdown per category is written out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aaronspring

Copy link
Copy Markdown
Contributor Author

regardless of merge, I think this could serve as an example (maybe for PR templates and listed in AGENTS.md) for how to create review artefacts, also referencing #406. unfortunately the PR grew quite large

aaronspring and others added 4 commits July 13, 2026 13:40
…fill.

  lookup() already matches alpha-2, alpha-3, name, official name and common name;
  a fuzzy guess on top of that silently reclassifies the tax category of an
  already-issued invoice, and an unresolvable country already has a defined
  behaviour (keep the historical zero-rated reading).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… instead of

  an inline lambda that re-derived the percent/fraction normalisation already
  living in ui/src/api/tax.ts. The row now reads "Standard 19%" / "Outside scope
  of tax (O)", matching the Tax row in the details panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_rate. Both

  rendered the same outside-scope invoice across the same templates to assert the
  two halves of one behaviour; the dash assertion stays conditional because
  grayshades has no per-line VAT column to dash out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  textwrap.dedent reflows and a dropped blank line are unrelated to tax
  categories and only widen the diff against main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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

@clstaudt clstaudt added this pull request to the merge queue Jul 13, 2026
Merged via the queue into tuttle-dev:main with commit 683a159 Jul 13, 2026
2 checks passed
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.

Missing tax category "O" (Outside Scope of Tax) for third-country B2B services — only numeric VAT % supported

2 participants