Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

Building a SAF-T Validation Pipeline for PT-Localized ERPs (So Accounting Teams Stop Hating You)

The problem isn't translation, it's validation If you've ever shipped an ERP module to the Portuguese market, you've probably had a finance user file a ticket that boils down to: "the screen says one thing, the exporte

The problem isn't translation, it's validation

If you've ever shipped an ERP module to the Portuguese market, you've probably had a finance user file a ticket that boils down to: "the screen says one thing, the exported file says another." That's not a copywriting bug. That's a localization pipeline missing a validation layer.

There's a good write-up on the linguistic side of this problem in M21Global's article on ERP localization for Portugal, which covers why terms like "withholding tax" or "self-billing" can't be translated literally without breaking SAF-T compliance. That article is aimed at the terminology itself. This one is for the people who have to build and automate the pipeline that catches those errors before they hit production.

If your i18n workflow treats tax strings the same way it treats a "Submit" button label, you will ship mismatches. Here's how to build a pipeline that doesn't.

Why tax strings need a different pipeline than UI strings

Most i18n tooling (i18next, FormatJS, gettext, you name it) is built around the assumption that a string is a string. Translators get a key, a source string, and context. For a "Cancel" button, that's enough.

For SAF-T-related fields, it's not, because:

  • The displayed label and the exported XML field name are two different strings that must stay semantically linked
  • The same source term ("Tax Payable") can map to different Portuguese terms depending on which SAF-T table it belongs to
  • A translator fixing a typo in one module has no visibility into whether that string is referenced by three other modules exporting the same SAF-T block

This means plain translation memory isn't enough. You need a constrained glossary that's enforced programmatically, not just recommended in a style guide PDF nobody reads.

Step 1: Separate your tax glossary from your general translation memory

Don't let generic TM tools (Phrase, Lokalise, Crowdin, whatever you use) freely suggest alternate phrasings for fiscal terms. Lock them.

A simple approach: maintain a separate, versioned glossary file as the single source of truth, and validate every PR against it in CI.

// pt-PT-tax-glossary.json
{
  "withholding_tax": {
    "en": "Withholding Tax",
    "pt-PT": "RetenΓ§Γ£o na Fonte",
    "saf_t_field": "WithholdingTaxAmount",
    "locked": true
  },
  "self_billing": {
    "en": "Self-billing",
    "pt-PT": "AutofaturaΓ§Γ£o",
    "saf_t_field": "SelfBillingIndicator",
    "locked": true
  },
  "credit_note": {
    "en": "Credit Note",
    "pt-PT": "Nota de CrΓ©dito",
    "saf_t_field": "CreditNote",
    "locked": true
  }
}

This file becomes your enforcement layer, not just documentation.

Step 2: Write a linter that checks glossary compliance

A basic Node script can catch drift before it ever reaches a reviewer:

const fs = require('fs');

const glossary = JSON.parse(fs.readFileSync('pt-PT-tax-glossary.json'));
const translations = JSON.parse(fs.readFileSync('locales/pt-PT/invoicing.json'));

let errors = [];

for (const [key, entry] of Object.entries(glossary)) {
  if (!entry.locked) continue;

  const actual = translations[key];
  if (actual && actual !== entry['pt-PT']) {
    errors.push(
      `Mismatch on "${key}": expected "${entry['pt-PT']}", found "${actual}"`
    );
  }
}

if (errors.length) {
  console.error('Glossary violations found:\n' + errors.join('\n'));
  process.exit(1);
}

console.log('All locked tax terms match the glossary.');

Run this as a required check in CI on every PR that touches locales/pt-PT/. It won't catch nuance, but it will catch a translator (or an LLM-assisted translation step) quietly reintroducing "ano financeiro" where it should say "exercΓ­cio fiscal".

Step 3: Cross-check UI labels against SAF-T export field names

The riskiest gap is between what's rendered on screen and what's written into the exported XML. Build a small integration test that walks your SAF-T XSD or schema and confirms every exported field has a corresponding, glossary-approved UI label.

const { parseStringPromise } = require('xml2js');

async function validateSafTLabels(xmlSample, glossary) {
  const parsed = await parseStringPromise(xmlSample);
  const exportedFields = extractFieldNames(parsed); // your own extraction logic

  const glossaryFields = Object.values(glossary).map(e => e.saf_t_field);

  const unmapped = exportedFields.filter(f => !glossaryFields.includes(f));

  if (unmapped.length) {
    console.warn('SAF-T fields with no glossary mapping:', unmapped);
  }
}

This won't replace a human accountant reviewing the output, but it turns an invisible mismatch into a visible warning during your test suite run, long before a client's finance team finds it manually.

Step 4: Treat formatting as data, not display logic

Decimal separators, date order, and currency symbol placement aren't cosmetic for PT-PT. Don't bury these in component-level formatting logic scattered across the codebase. Centralize it:

const ptPTFormatter = new Intl.NumberFormat('pt-PT', {
  style: 'currency',
  currency: 'EUR',
});

const ptPTDate = new Intl.DateTimeFormat('pt-PT'); // dd/mm/yyyy by default

Using Intl with the correct locale tag handles most of this automatically, but it's worth writing a snapshot test that locks in the expected output (1 234,56 €) so a future refactor doesn't silently regress it back to US formatting.

Step 5: Add a human review gate, but make it cheap to trigger

Automated checks catch structural drift, not whether a term is the one a Portuguese certified accountant (contabilista certificado) actually recognizes. For that you still need human review, but you can make it targeted instead of blanket.

Flag only the strings that:

  • Are newly added to the locked glossary
  • Touch a SAF-T-exported field
  • Appear in error/validation messages tied to a fiscal obligation (e.g. "NIF invΓ‘lido")

Routing only these high-risk strings to a specialist reviewer, instead of sending the whole interface for full review every release, keeps the cost of accuracy manageable without skipping it.

Wrap-up

SAF-T compliance failures rarely come from translators being careless. They come from pipelines that don't treat tax terminology as a constrained, versioned, testable asset. If you're building or maintaining an ERP for the Portuguese market, the terminology research from the M21Global article is a solid starting glossary. The part that's on you is wiring that glossary into CI, so a wrong term never reaches a finance user's screen in the first place.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.