Why I wrote it

Since 2019, invoices between Italian businesses are electronic. The seller produces an XML file in the FatturaPA format and sends it through SDI, the Sistema di Interscambio run by the Agenzia delle Entrate, which checks it and delivers it to the buyer. A file that fails a check is rejected and the invoice counts as never issued.

I am building a B2B marketplace for food businesses, and in October I decided that buyers pay sellers directly by bank transfer, with no marketplace fee in between. Card payments through a payments platform were the alternative, and too complex for what the marketplace needs. That choice moved a job onto the platform: when a seller marks a delivery as delivered, the marketplace issues the seller’s invoice on their behalf.

Most FatturaPA packages turn a JSON tree you have already filled in into XML. The totals, the VAT summary and the consistency checks stay with you, and those are exactly what SDI rejects files for. So I wrote a module that takes the business data and owns the arithmetic. I built it inside the marketplace’s API, then on 21 October moved it into its own public repository, added line discounts and foreign buyers, and published it on npm under the MIT licence.

Repository on GitHub ↗Forgejo (canonical) ↗Codeberg mirror ↗npm package ↗

Business data in, an SDI file out

The whole interface is one function. buildInvoice takes the seller, the buyer, who transmits the file, the lines and the payment, with every amount in integer euro cents: 2490 is €24.90. It returns the XML, the file name SDI expects and the totals it computed. There is no total in the input, so there is no total that can disagree with the lines.

The file name is the transmitter’s country and tax code plus a progressive number, for example IT20000000008_00001.xml. SDI rejects a file name it has already received, so the caller keeps that sequence unique, for instance with a stored counter.

import { buildInvoice } from "@lucianonerydev/fatturapa";

const { xml, fileName, totalCents } = buildInvoice({
  number: "1/A",
  date: "2026-10-20",
  seller: {
    vatNumber: "20000000008",
    name: "Macelleria Bresciana Srl",
    taxRegime: "RF01",
    address: { street: "Via Milano 12", postalCode: "25126", city: "Brescia", province: "BS" },
  },
  buyer: {
    vatNumber: "10000000009",
    name: "Trattoria Il Portico",
    address: { street: "Via Milano 10", postalCode: "25126", city: "Brescia", province: "BS" },
  },
  transmitter: { code: "20000000008", sequence: "00001" },
  lines: [{
    description: "Controfiletto di manzo",
    quantity: 3,
    unit: "kg",
    unitPriceCents: 2490,
    vatRate: 10,
    adjustments: [{ percent: 10 }],
  }],
  payment: { method: "MP05", dueDate: "2026-11-19", iban: "IT60X0542811101000000123456" },
});
// fileName: "IT20000000008_00001.xml", totalCents: 7395
The buildInvoice input for one line of beef at 24.90 euros per kilo with a 10% discount, next to the generated invoice rendered with the Agenzia delle Entrate stylesheet, showing a taxable amount of 67.23, VAT of 6.72 and a total of 73.95.
The example above, run through version 0.1.1: the input lines on the left, and the XML it returned rendered with the Agenzia delle Entrate’s own stylesheet (v1.2.3) on the right. Every total in it was computed by the library.

The full guide ↗

Rounding the way SDI rounds

SDI does not trust the totals in a file; it recomputes them. Its published list of checks spells out three. Each line’s PrezzoTotale must equal the unit price, after its discounts and surcharges applied in cascade, times the quantity, within one cent (error 00423). Each VAT rate’s ImponibileImporto must match the sum of its lines, within one euro (00422). Each summary block’s Imposta must be the rate times that taxable amount, rounded half up to the cent, again within one cent (00421).

The last rule is the one that catches hand-rolled code. VAT is computed once per rate on the summed taxable amount, not line by line. Two lines of €0.25 at 10% carry 2.5 cents of VAT each; rounded per line that is 6 cents, but SDI expects 5 on the €0.50 total. Ten such lines would be 5 cents off, five times the tolerance. computeTotals groups the lines by rate and nature and rounds each group once.

Prices, line totals and VAT never go through floating point. Amounts are integer cents, and quantities are parsed into fixed-point bigint values with eight decimals, the most the Quantita field allows. A line with cascading discounts is kept as an exact fraction and rounded only at the end: €24.90 less 10% is €22.41, less €1.00 is €21.41, and three units come to €64.23. Passing a quantity such as "1.25" as a string avoids binary floating-point surprises before the value ever reaches the library.

/** Divides and rounds half away from zero, as SDI expects for prices and VAT. */
export function divideRounded(numerator: bigint, denominator: bigint): bigint {
  if (numerator < 0n) return -divideRounded(-numerator, denominator);
  return (numerator * 2n + denominator) / (denominator * 2n);
}

/** VAT on a taxable amount, rounded to the cent. */
export function vatAmount(taxableCents: number, basisPoints: number): number {
  return Number(divideRounded(BigInt(taxableCents) * BigInt(basisPoints), 10_000n));
}

SDI checks list, v2.0 ↗amounts.ts ↗

Every problem at once

A rejected invoice that names one problem, gets fixed and is rejected again for the next one is a slow loop, and in a marketplace the person fixing it is a seller filling in a form. So validation never stops at the first error. Every check adds to a list, and buildInvoice throws a single FatturaPAError whose issues carry the path of the input field, ready to be mapped onto form fields. The block at the end of this section is the real output for an invoice with seven mistakes.

The checks cover what SDI rejects files for and what can be known from the invoice alone. Duplicate invoice numbers and file names depend on the caller’s records, so they stay with the caller.

  • Partita IVA check digit, codice fiscale with omocodia substitutions, and IBAN mod-97 checksum.
  • Recipient code and PEC: a PEC only goes with recipient code 0000000.
  • Natura present exactly when the VAT rate is 0, and the generic N2, N3 and N6 codes SDI stopped accepting in 2021 refused.
  • Flat-rate (RF19) sellers: every line at rate 0 with nature N2.2.
  • Credit and debit notes must reference the invoice they correct.
  • Dates, field lengths and character set: typographic quotes and dashes become ASCII, and characters outside Latin-1, such as emoji, are reported instead of being dropped.
FatturaPAError.issues:
  number                     must contain at least one digit
  date                       must be a valid YYYY-MM-DD date
  seller.vatNumber           is not a valid Partita IVA
  seller.address.postalCode  must be 5 digits
  buyer                      needs a vatNumber or a fiscalCode
  lines[0].nature            is required when the VAT rate is 0
  lines[0].description       contains "🍷", which SDI does not accept

identifiers.ts ↗invoice.ts ↗

The cases a marketplace runs into

Issuing for someone else has its own place in the format. With an issuer set, the file gets a TerzoIntermediarioOSoggettoEmittente block and SoggettoEmittente TZ. The invoice stays the seller’s: their Partita IVA, their numbering and their bank details. The platform needs the seller’s mandate to do it.

Foreign buyers do not fit the Italian fields, so the library fills them the way SDI expects: the foreign tax identifier with its country prefix stripped (both EL and GR for Greece), recipient code XXXXXXX and CAP 00000, with the real postal code printed before the city. SDI does not deliver those invoices, so the seller still sends the buyer a copy.

Invoices without VAT owe a €2 virtual stamp duty once their VAT-free amounts pass €77.47. By default the library sums the lines whose nature makes them subject to it and declares the duty when the threshold is crossed. It is declared, not added to the total; a seller who passes it on to the buyer adds a line for it.

  • Invoices (TD01), credit notes (TD04), debit notes (TD05) and deferred invoices (TD24).
  • Line discounts and surcharges in cascade, as percentages or per-unit amounts (ScontoMaggiorazione).
  • Purchase-order references and bank-transfer payment details.
  • allocateCents, which splits an amount such as a delivery fee across lines in proportion, with parts that always add up to the total.

Agenzia delle Entrate: stamp duty on e-invoices ↗

Checked against the official schema

The arithmetic is half of it; the other half is structure. FatturaPA elements must appear in the exact order the schema defines, so the XML is built as a small tree in that order and rendered by a few dozen lines in xml.ts, with no XML library.

npm run test:schema downloads the official FatturaPA schema and the W3C XML Signature schema it imports, points the import at the local copy, and validates generated invoices, credit notes, discounted invoices and foreign-buyer invoices with xmllint. GitHub Actions runs it with the type check and the format check on Node.js 20, 22 and 24.

The test uses schema 1.2.2. The Agenzia delle Entrate also publishes 1.2.3, which accompanies version 1.4 of the specification in force since 1 April 2025; it only adds the TD29 document type and the RF20 tax regime, and the same generated files validate against it.

npm install
npm test             # unit tests
npm run test:schema  # also validates generated files against the official XSD (needs curl and xmllint)

FatturaPA format documentation ↗The schema script ↗Tests ↗

What it does not do

It does not sign or transmit invoices. The XML goes through an SDI intermediary or your own SDI channel, and that part belongs there. It is not tax advice either: the VAT rate, the nature and the legal reference on each line are for the seller and their accountant to choose. The library checks that they are consistent, not that they are right for the sale.

In the marketplace, invoices are generated on every completed delivery, but transmission to SDI is still simulated. None of its files has been through the live system yet; that happens once a real intermediary is connected.

Where it stands

Version 0.1.1 is on npm as @lucianonerydev/fatturapa. It needs Node.js 20 or later, ships ES modules with type declarations, and has no runtime dependencies. The marketplace keeps a vendored copy, and changes land in the public repository first.

Next on the roadmap, roughly in order:

  • Withholding tax and pension funds (ritenuta d’acconto, cassa previdenziale), for professionals’ invoices.
  • Instalments, each with its own due date and amount.
  • Public-administration invoices (FPA12), with CIG, CUP and PA recipient codes.
  • Fiscal representatives and foreign sellers with an Italian VAT number.
  • Parsing received invoices into the same shapes.
  • A PDF courtesy copy, as a separate package.
npm install @lucianonerydev/fatturapa

Roadmap ↗Changelog ↗Security policy ↗