Skip to content

Add a generic tax ID formatter - #143

Merged
methodofaction merged 2 commits into
mainfrom
claude/tax-id-formatter-ff016b
Aug 18, 2026
Merged

Add a generic tax ID formatter#143
methodofaction merged 2 commits into
mainfrom
claude/tax-id-formatter-ff016b

Conversation

@methodofaction

Copy link
Copy Markdown
Contributor

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 the DE prefix.

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.NormalizeIdentity trims the country prefix, since the country lives in tax_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/taxid package. One table of country rules replaces the German special case:

var rules = map[l10n.TaxCountryCode]rule{
	l10n.AT.Tax(): {prefixed: true},
	l10n.CH.Tax(): {prefixed: true, format: formatCH},
	l10n.CO.Tax(): {format: formatCO},
	...
}

Format(ctx, tID) returns Formatted{Label, Code, Country}, where an empty Country means the prefix belongs inside the code. Three cases:

  1. Prefix is part of the national identifier — AT, CH, DE, FR, IE, NL, NO, SE. Always joined.
  2. Intra-community — supplier and customer in two different EU member states, checked against the issue date via Identity.InEU. The identity itself is EU-checked too, so a non-EU third party on an intra-EU invoice keeps its national form.
  3. Otherwise — the national form, country in parentheses as before.

Local layouts live alongside: CO (901.585.284-3), CH (CHE-123.456.789), NO (NO 123456785 MVA).

Document in the rendering context. org.Party only ever receives a party, so the intra-community check had nothing to work with. Added internal.WithDocument / internal.DocumentFrom, set once in Render. It degrades to the national form when absent, so org.Party rendered standalone still works.

Labels are now translatable. Identifier names moved out of Go into org.party.labels in the locales, keyed by lowercased tax country code and falling back to the existing default:

labels:
  default: "TIN"
  ar: "CUIT"
  de: "USt-IdNr."
  ...

Keys are absolute rather than scope-relative, so they resolve wherever a tax identity is rendered.

Call sites. party.templ loses the taxIDLabel / taxIDCode / taxIDCodeIncludesCountry trio for a single taxid.Format call. The "on behalf of" line seller in lines.templ now uses the same formatter instead of printing a bare code.

Output changes

Only four distinct lines moved across the whole example set:

- NIF: (ES) B98602642      + NIF: ESB98602642        (ES→NL, intra-community)
- TIN: (NL) 000099995B57   + TIN: NL000099995B57
- TVA: (FR) 44732829320    + TVA: FR44732829320      (FR prefix is integral)
- TVA: (FR) 39356000000    + TVA: FR39356000000

examples/out is regenerated.

For the reviewer

  • Breaking: co.FormatTaxIDCode was removed from components/regimes/co. It was only used by party.templ; the logic now lives in components/taxid next to the Swiss and Norwegian layouts.
  • Only English labels are populated. Every other locale inherits them via LoadWithDefault(…, "en"), so nothing regressed, but GST Reg No. is English prose and a native speaker should decide its form in the other languages. Those are one-line additions per locale.
  • Judgement calls worth a second opinion: the prefixed set follows the "prefix is genuinely part of the national identifier" list, which puts NO in and leaves GB and GR/EL out — GB VAT numbers are often written 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.go covers 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

methodofaction and others added 2 commits August 18, 2026 15:01
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>

Copilot AI 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.

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/taxid package 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 like org.Party can decide whether intra-community VAT formatting applies.
  • Moved Tax ID labels into locales (org.party.labels.*) and updated template call sites to use taxid.Format, regenerating examples/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.

@pedrorfdez pedrorfdez left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM

@methodofaction
methodofaction merged commit 29cddef into main Aug 18, 2026
3 checks passed
@methodofaction
methodofaction deleted the claude/tax-id-formatter-ff016b branch August 18, 2026 15:40
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>
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.

3 participants