~/posts/verifactu-hash-chain-go

verifactu in go: a sha-256 hash chain is spain's answer to invoice fraud

implementing spain's anti-fraud e-invoicing as an append-only chained-hash ledger, modelled in the pure domain layer

for years the spanish way to skim a business was the software de doble uso — a till program with a quiet second set of books. you ring up a sale, the customer walks off with their receipt, and then, later, the invoice simply… isn’t there anymore. the record gets deleted, edited, renumbered. the tax office (the aeat) shows up, asks for invoice 47, and invoice 47 was never born. good luck proving a negative.

spain’s answer, live and mandatory, is delightfully low-tech and i mean that as a compliment: make every invoice cryptographically point at the one before it, so deleting number 47 leaves a hole that screams.

i run a subscription product in spain — a go saas, the business side of my split stack — and when i wired up billing i had to implement this. it’s called verifactu, and once you strip the acronyms away it is a poor-man’s blockchain that the regulator actually mandates: an append-only, per-issuer hash chain, sha-256, no distributed anything. no tokens, no consensus, no gas. just crypto/sha256 and the discipline to never break the link.

this post is how it lives in my codebase, and — because the rule around here is i don’t get to pretend — an honest account of what this buys you and what it very much does not.

the tax office wants a blockchain (sort of)

the law is real decreto 1007/2023 ↗ , fleshed out by orden hac/1177/2024 ↗ . i am not a lawyer and you should not take tax advice from a blog written in lowercase, but the two engineering-relevant sentences are these.

article 12 of the reglamento says your billing software must add “una huella o «hash»” to each record. article 8 says records must be “encadenados de manera que pueda verificarse su rastro siguiendo su secuencia” — chained so the trail can be verified by following the sequence. the ministerial order then gets specific: article 13 ↗ pins the algorithm to sha-256, and article 7 says each record must carry “los primeros 64 caracteres de la huella o «hash» del registro de facturación inmediatamente anterior” — the first 64 characters of the previous record’s hash. sha-256 in hex is exactly 64 characters, so: the whole previous hash, baked into the next record.

that’s a linked list where every next pointer is a cryptographic commitment to everything that came before. that’s the entire idea. everything below is me typing it into go.

here’s one record. it carries the invoice fields the tax office cares about, plus the two that make it a chain: PreviousHash and its own Hash.

app/internal/domain/verifactu.go

go
// VerifactuRecord represents a single invoice record in the Verifactu hash chain.
// Each record contains the SHA-256 hash linking it to the previous record for the same NIF.
type VerifactuRecord struct {
	ID              string
	SellerID        string
	InvoiceNumber   string
	NifEmisor       string // Issuer NIF/CIF
	NifDestinatario string // Recipient NIF (optional for simplified invoices)
	FechaExpedicion time.Time
	SerieFactura    string
	NumeroFactura   string
	BaseImponible   float64 // in cents
	CuotaIVA        float64 // in cents
	TipoImpositivo  float64 // 21.00
	TotalFactura    float64 // in cents
	PreviousHash    string  // Hash of the previous record for this NIF (empty for first)
	Hash            string  // SHA-256 hash of this record
	QRContent       string  // URL for the verification QR code
	VerifactuMode   string  // "VERIFACTU" or "NO_VERIFACTU"
	SubmittedToAEAT bool
	SubmittedAt     *time.Time
	CreatedAt       time.Time
}

NifEmisor is the issuer’s tax id — a NIF for a person, a CIF for a company. it’s the partition key of the whole scheme: the chain is per-issuer, not global. one seller, one unbroken sequence. keep an eye on that, because it’s where the sharp edges live later.

VerifactuMode is the fork in the regulation. VERIFACTU mode means you stream each record to the aeat as you issue it; the state travels on SubmittedToAEAT / SubmittedAt. NO_VERIFACTU mode means you keep the records locally under stricter software requirements and hand them over on request. the hash chain is mandatory either way — the mode only decides who gets a live copy.

the hash is the whole point, and it lives in the pure domain

here’s the beat i care about most, and if you’ve read my hexagonal post you know why. watch what this file imports:

app/internal/domain/verifactu.go

go
import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"time"
)

crypto/sha256, encoding/hex, fmt, time. the whole cryptographic heart of a regulatory requirement, and there is not one infrastructure import in sight. no database. no http. no framework. no third-party fiscal sdk with a per-call price. the crypto/sha256 package is stdlib, it implements fips 180-4, and Sum256 is a one-liner that turns a byte slice into a 32-byte array. that’s all a hash chain needs.

this is not an accident of laziness, it’s the point. a regulatory invariant — the chain must not break — is a domain rule, not a plumbing detail. so it lives in the domain layer where nothing can reach around it. the function is pure: same fields in, same 64 hex chars out, no clock, no i/o, trivially unit-testable.

app/internal/domain/verifactu.go

go
// ComputeHash computes the SHA-256 hash for this record, chaining with the previous hash.
// Hash = SHA-256(previousHash + nifEmisor + invoiceNumber + fechaExpedicion + baseImponible + cuotaIVA + totalFactura)
func (v *VerifactuRecord) ComputeHash() string {
	data := fmt.Sprintf("%s|%s|%s|%s|%.2f|%.2f|%.2f",
		v.PreviousHash,
		v.NifEmisor,
		v.InvoiceNumber,
		v.FechaExpedicion.Format("2006-01-02"),
		v.BaseImponible,
		v.CuotaIVA,
		v.TotalFactura,
	)
	h := sha256.Sum256([]byte(data))
	return hex.EncodeToString(h[:])
}

the PreviousHash goes first in the string. that’s what makes it a chain and not just a per-row checksum: this record’s hash is a function of the previous record’s hash, which was a function of the one before it, all the way back to the first invoice this seller ever issued (whose PreviousHash is the empty string). change any field in any historical record and every hash downstream of it changes. the hole screams.

the pipe delimiter matters more than it looks. "12|34" and "1|234" must not collide, so you need an unambiguous separator between fields — otherwise two different invoices could serialise to the same bytes and you’ve handed a forger a free collision. this is the boring, load-bearing part of hashing anything structured, and it is the part people skip.

money is not a float (except when i let it be)

look back at that struct. BaseImponible, CuotaIVA, TotalFactura are all float64 with a // in cents comment. the tax rate defaults to spain’s standard 21% iva, set at the fiscal-profile layer:

app/internal/domain/fiscaldata.go

go
if !e.VATRateSet && e.VATRate == 0 {
	e.VATRate = 21.00
}

that VATRateSet flag is doing real work: it lets me tell an explicit 0% (a genuinely exempt sale) apart from an absent rate that should fall back to the 21% default. 0 the value and 0 the “unset” are different facts, and a plain float can’t hold that distinction — hence the companion bool. that’s the kind of thing you only learn by getting a VAT return wrong once.

the amounts are integer cents: 10000 is €100.00, and you’ll see the qr code below divide by 100 to render euros. storing money in minor units is correct. storing those minor units in a float64 is a smell, and i’ll own it in the honest section — it works today only because every value that flows in is already a whole number of cents, so the float is carrying an integer that happens to have .00 glued to it. that is a bet, not a design.

chaining the records: who holds the previous hash?

the domain knows how to hash. it doesn’t know what came before — that’s a lookup, which means infrastructure, which means the application layer. this is the step that turns a pure function into a live chain:

app/internal/application/verifactu.go

go
// get previous hash in chain for this NIF
previousHash := ""
latest, err := u.repo.GetLatestByNIF(ctx, nifEmisor)
if err != nil {
	return err
}
if latest != nil {
	previousHash = latest.Hash
}

now := time.Now()
record := &domain.VerifactuRecord{
	// ...
	NifEmisor:       nifEmisor,
	FechaExpedicion: now,
	BaseImponible:   base,
	CuotaIVA:        iva,
	TipoImpositivo:  fiscalData.VATRate,
	TotalFactura:    total,
	PreviousHash:    previousHash,
	VerifactuMode:   "VERIFACTU",
	CreatedAt:       now,
}

// ... record.Validate()

// compute hash chain
record.Hash = record.ComputeHash()
record.QRContent = record.GenerateQRContent()

read the whole invoice ledger and it’s just: fetch the latest record for this NIF, take its hash, hang the new record off it, seal. first invoice? GetLatestByNIF returns nothing, PreviousHash stays empty, the chain is born. the service is also idempotent — it checks for an existing record by source invoice id first — because a billing webhook will be delivered twice and minting the same invoice into the chain twice would corrupt the sequence permanently.

the qr the tax office scans

every verifactu invoice prints a qr code, and it’s not decoration — it points at an aeat endpoint the customer can hit to verify the invoice was declared. building that url is, again, pure domain string-formatting:

app/internal/domain/verifactu.go

go
// GenerateQRContent generates the AEAT verification URL for the QR code.
func (v *VerifactuRecord) GenerateQRContent() string {
	return fmt.Sprintf("https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=%s&numserie=%s&fecha=%s&importe=%.2f",
		v.NifEmisor,
		v.InvoiceNumber,
		v.FechaExpedicion.Format("02-01-2006"),
		v.TotalFactura/100,
	)
}

note TotalFactura/100 — cents back to euros for a human-facing amount — and the 02-01-2006 layout, go’s cursed reference date rendering as spain’s dd-mm-yyyy. this is the one place the chain touches the outside world, and it does it by handing the aeat exactly what it needs to recompute and confirm.

the honest part: this proves tamper-evidence, not tamper-proofing

now the bill, because a hash chain wears a lot of blockchain cosplay and i refuse to sell it as more than it is.

a single actor who owns the store can rewrite the entire chain. this is the big one. if you can edit invoice 47, nothing stops you from re-hashing 47, then 48, then 49, all the way to the tip. the math is cheap; sha-256 of a few hundred bytes is nanoseconds. the chain is not immutable — it’s append-only by convention, enforced by my own code and a database i control. what makes it actually anti-fraud is not the chain alone, it’s the chain plus an external witness: in VERIFACTU mode the aeat already has a copy of each hash as you issued it, so a later rewrite no longer matches what they hold. take the witness away and a hash chain in a database you own is a diary with a lock you also own. it is tamper-evident, not tamper-proof, and the difference is the whole ballgame.

the ordering is a per-NIF race waiting to happen. here’s how i find “the previous record”:

app/internal/ports/postgresql/verifactu.go

go
query := `SELECT * FROM billing.verifactu_records WHERE nif_emisor = $1 ORDER BY created_at DESC LIMIT 1`

ORDER BY created_at DESC LIMIT 1. two invoices for the same seller finalising in the same instant can both read the same latest record, both point at it, and now the chain forks — two records claiming the same parent. i have idempotency on invoice id but no database-level guarantee that a given PreviousHash is used exactly once per NIF. the correct fix is a serialising lock per issuer or a unique constraint on (nif_emisor, previous_hash), and the fact that i’m writing this sentence means it’s on the list and not in the code. sorting by created_at also quietly assumes wall-clock insertion order equals fiscal sequence order, which is true right up until it isn’t.

my hashed field set is a simplification of the letter of the law. i chain over issuer NIF, invoice number, date, base, iva and total. the orden hac/1177/2024 ↗ specifies the exact fields, formatting and the record-generation timestamp that must go into the official huella. “close enough” is not a phrase the aeat recognises — if the field set or ordering doesn’t match theirs byte-for-byte, their recomputation won’t reproduce my hash and the verification fails. this is the part i’d harden first before calling it done-done.

and yes, money is in floats. see above. it survives on the fact that every input is already whole cents. the day a currency-conversion or a split-fee introduces a fraction, %.2f will round it and the hash will silently commit to the rounded value. integers or a decimal type, before that day, not after.

none of these are bugs in the sense of “it crashes.” they’re the honest distance between a hash chain and a compliance guarantee. the chain is necessary. it is nowhere near sufficient on its own.

lessons learned

  • the regulator asked for the boring version on purpose. no distributed ledger, no signatures-on-signatures — a sha-256 linked list plus a trusted third party who already holds your hashes. that’s a system a solo dev can actually ship correctly, which is presumably why it’s the one that became law.
  • a compliance rule is a domain invariant, so put it in the domain. the hash chain touching zero infrastructure isn’t architectural vanity; it’s what let me unit-test the single most legally load-bearing function in the whole product with a struct literal and no database in the room.
  • name the gap between evidence and proof out loud. the most dangerous thing i could do is believe my own qr code. the chain catches an honest mistake and a lazy fraud; it catches a determined one only because someone else is holding the receipts.

next: the other half of this — actually streaming those records to the aeat’s soap endpoint from go, xades signatures and all, and discovering that the pure, testable domain stops exactly where the government’s wsdl begins.

letters to the editor

no letters yet. the editor is patient.