Add tax category "O" (outside scope of tax) for third-country B2B services#407
Conversation
+ 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>
…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.
GUI verification — contract formDriven 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 { value: "0", disabled: true }Saving persists Contract detail now reads the category rather than inferring it from the rate: |
GUI verification — settings and invoiceSettings — the new 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 VAT is shown as |
Rendered PDF, and the XML extracted back out of itBoth PDFs below were produced by the running app, not by a test fixture. Outside scope (category
|
| 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:
|
Should we wait with merging for a while and leave this here as an example for how a PR should look like? |
… 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>
|
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 |
…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>








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 categoryO, a distinct category fromZ.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-EN16931rather than relying on secondary sources, which disagree on two points. Theflag="fatal"rules for categoryO:CategoryCodeO(category, rate)VAregistrations omittedRateApplicablePercent0ExemptionReasonorExemptionReasonCodeVATEX-EU-O+ textOnever mixes with another categoryBR-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
TaxCategoryenum (S/Z/O, values are the UNTDID codes) onContractandInvoiceItem. The item snapshots the contract's category, so editing a contract cannot retroactively alter an issued invoice.User.tax_number, emitted as CII schemeFCon outside-scope invoices.(category, rate)— a zero-rated and an outside-scope line both sit at 0% and previously would have collapsed into one wrongApplicableTradeTaxgroup.—instead of0 %in the VAT column, suppressed VAT total row, and a localized legal note (en/de/es).Two things reviewers should know
1.
@validatesis dead code onSQLModel(table=True)classes here. Pydantic's__setattr__overwrites whatever the SQLAlchemy validator returns, so the pre-existing_normalize_vat_ratehook never fired — which is why_validated_savecallsnormalize_vat_rateby hand. Both hooks are replaced by an explicitvalidate_vat()invoked on write paths, in the same style as the existingvalidate_pricing(). A per-attribute hook would also have wrongly rejected switching a contract fromOback toS, sincesave_from_dictassigns 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 staysZ— promoting it toOwould create a mixed-category invoice violating BR-O-11/12. An unresolvable client country keeps the historicalZreading rather than silently reclassifying.Verification
main.tsc --noEmitis clean."O"over JSON-RPC → invoice created → PDF rendered →factur-x.xmlextracted back out of the resulting PDF and checked against BR-O-02/05/09/10.Note for reviewers on the merge
maingained8ec5efc5316d(add task table) with the samedown_revisionas this branch's migration, which left two Alembic heads —alembic upgrade headfailed on a fresh database.mainis merged in here and the tax-category migration is re-chained onto8ec5efc5316d, 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 onmain.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 intest_einvoice.pyinto a real conformance gate.Checklist
just dev). — the built Electron app was launched and clicked through against a fresh database with demo data; see the screenshots in the comments.just test). — 485 passed, 1 skipped.just precommit). — the pinnedblack==22.6.0cannot run on Python 3.14 (asyncio.get_event_loop()removal); new code was formatted with current black instead.tuttle/model.py), I generated and reviewed an Alembic migration (just migrate "<msg>").🤖 Generated with Claude Code