Add a generic tax ID formatter - #143
Merged
Merged
Conversation
Tax IDs were rendered as a bare code with the country shown separately in
parentheses, with a single hardcoded exception for Germany, whose USt-IdNr.
is only valid with the "DE" prefix. Germany is not alone in that: AT, CH,
FR, IE, NL, NO and SE all write the country code as part of the national
identifier. GOBL never stores the prefix in `tax_id.code`, so it has to be
put back when presenting the identity.
The new components/taxid package drives this from one table of country
rules, covering three cases:
- the prefix is part of the national identifier, so it is always joined;
- the document records a supply between two different EU member states,
in which case art. 226 of the VAT Directive requires the parties' VAT
identification numbers, which carry the prefix;
- otherwise the national form, with the country shown separately as
before.
The intra-community check needs both parties, which the party component
never sees, so the document is now placed in the rendering context.
Identifier names move out of Go and into the locales under
`org.party.labels`, keyed by country code and falling back to the generic
label, so they can be adapted per language. Only the English values are
populated; every other locale inherits them until it overrides one.
The Colombian NIT formatter moves into the new package alongside the
Swiss and Norwegian layouts, so co.FormatTaxIDCode is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
goconst flagged the national form of the Spanish tax ID, which appears in both the table-driven cases and the intra-community ones. Naming it also makes the relationship between the two forms explicit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces a centralized, rule-driven formatter for tax identities so rendered documents can present Tax IDs in the correct national or intra-community (EU cross-border) form, replacing the previous one-off Germany special case and per-template formatting logic.
Changes:
- Added a new
components/taxidpackage that formats tax identities (including country-prefix rules and a few country-specific layouts) and returns structured output for templates. - Propagated the rendered document through
context.Context(internal.WithDocument/DocumentFrom) so components likeorg.Partycan decide whether intra-community VAT formatting applies. - Moved Tax ID labels into locales (
org.party.labels.*) and updated template call sites to usetaxid.Format, regeneratingexamples/out.
Reviewed changes
Copilot reviewed 8 out of 22 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| locales/en/app.yml | Adds translatable tax-identity labels keyed by tax country code under org.party.labels.*. |
| internal/document.go | Adds WithDocument / DocumentFrom context helpers to expose the current document to sub-components. |
| goblhtml.go | Stores the extracted document in the rendering context for downstream formatting decisions. |
| components/taxid/taxid.go | Implements rule-based Tax ID formatting (prefix rules, intra-community detection, and country-specific layouts). |
| components/taxid/taxid_test.go | Adds unit tests covering formatting rules, intra-community behavior, nil handling, and label localization. |
| components/regimes/co/co.go | Removes Colombia-specific Tax ID formatter (logic moved into components/taxid). |
| components/org/party.templ | Replaces ad-hoc Tax ID label/code logic with a single taxid.Format call. |
| components/org/party_templ.go | Regenerated templ output reflecting the updated Tax ID formatting logic. |
| components/bill/lines.templ | Updates “on behalf of” seller line to use taxid.Format(...).Code instead of raw code. |
| components/bill/lines_templ.go | Regenerated templ output reflecting the updated seller Tax ID formatting. |
| examples/out/invoice-es-reverse-charge.html | Regenerated output reflecting intra-community VAT prefix formatting changes. |
| examples/out/fr-self-billed-invoice.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-invoice.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-invoice-units.fr.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-invoice-delivery.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-facturx-invoice.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-ctc-invoice-b2bint.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-ctc-invoice-b2b.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-ctc-invoice-advance.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-ctc-credit-note.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/fr-choruspro-invoice.html | Regenerated output reflecting FR prefix-as-integral formatting. |
| examples/out/de-choruspro-invoice.html | Regenerated output reflecting FR prefix-as-integral formatting in DE example set. |
Files not reviewed (2)
- components/bill/lines_templ.go: Generated file
- components/org/party_templ.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
methodofaction
added a commit
that referenced
this pull request
Aug 24, 2026
The org.party.labels.* keys added in #143 are country tax ID abbreviations — CUIT, NIF, RFC, P.IVA — which are used as they are in every language, so no locale translates them. Only the generic labels.default is translated. Exempting them by rule keeps the knownGaps table for genuinely missing translations. Also composes knownGaps from named groups so each key literal appears once, and names the locale file constants, both of which goconst was flagging. Verified with golangci-lint 2.13.1 against the repo config: 0 issues. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Tax IDs were rendered as a bare code with the country in parentheses —
NIF: (ES) B98602642— with one hardcoded exception for Germany, whose USt-IdNr. is only valid with theDEprefix.Germany is not alone. In AT, CH, DE, FR, IE, NL, NO and SE the country code is genuinely part of the national identifier. GOBL strips it on normalization (
tax.NormalizeIdentitytrims the country prefix, since the country lives intax_id.country), so it has to be put back when presenting the identity. Separately, for most EU regimes the prefix belongs to the intra-community VAT identification number rather than the domestic one — art. 226(3)(4) of the VAT Directive requires both parties' VAT identification numbers on a cross-border supply, and those always carry the prefix.What changed
New
components/taxidpackage. One table of country rules replaces the German special case:Format(ctx, tID)returnsFormatted{Label, Code, Country}, where an emptyCountrymeans the prefix belongs inside the code. Three cases:Identity.InEU. The identity itself is EU-checked too, so a non-EU third party on an intra-EU invoice keeps its national form.Local layouts live alongside: CO (
901.585.284-3), CH (CHE-123.456.789), NO (NO 123456785 MVA).Document in the rendering context.
org.Partyonly ever receives a party, so the intra-community check had nothing to work with. Addedinternal.WithDocument/internal.DocumentFrom, set once inRender. It degrades to the national form when absent, soorg.Partyrendered standalone still works.Labels are now translatable. Identifier names moved out of Go into
org.party.labelsin the locales, keyed by lowercased tax country code and falling back to the existingdefault:Keys are absolute rather than scope-relative, so they resolve wherever a tax identity is rendered.
Call sites.
party.templloses thetaxIDLabel/taxIDCode/taxIDCodeIncludesCountrytrio for a singletaxid.Formatcall. The "on behalf of" line seller inlines.templnow uses the same formatter instead of printing a bare code.Output changes
Only four distinct lines moved across the whole example set:
examples/outis regenerated.For the reviewer
co.FormatTaxIDCodewas removed fromcomponents/regimes/co. It was only used byparty.templ; the logic now lives incomponents/taxidnext to the Swiss and Norwegian layouts.LoadWithDefault(…, "en"), so nothing regressed, butGST Reg No.is English prose and a native speaker should decide its form in the other languages. Those are one-line additions per locale.GB…in practice even though it is not required. I also did not add digit grouping beyond CO, CH and NO.Tests
New
components/taxid/taxid_test.gocovers each country rule, the nil identity, the domestic / intra-EU / export / non-EU-third-party paths, and label localization (a country with no name of its own picks up the locale's generic label; a named country keeps its name across languages).go test ./...passes.🤖 Generated with Claude Code