Skip to content

Design decisions

Guillermo Ubeda edited this page Sep 1, 2026 · 1 revision

🇬🇧 English first · 🇪🇸 Español más abajo

Design decisions

An invoice that is subtly wrong is not rejected by your code — it is rejected weeks later by FACe, with a message that does not say which field. Every decision here trades convenience for finding out early.


1. It stops before the signature

Facturae requires an XAdES signature before submission. This library generates a valid, unsigned document and stops.

Why. Signing needs three things that do not belong here:

  1. A private key and a certificate. Where they live, how they are unlocked, and who may use them are decisions for your application's security model, not for a formatting library.
  2. A cryptography dependency. Zero dependencies is what lets this drop into an existing system without an argument about versions. A signature library brings OpenSSL bindings with it.
  3. XAdES specifically — an enveloped signature with a signing certificate, signing time and a signature policy — not plain XML-DSig. Getting it subtly wrong produces a document that parses and is rejected.

A library that generated almost a signature would be worse than one that is clear about stopping at the document. docs/signing.md says what to reach for instead.


2. Totals are computed, not fields

There is no total to fill in. See The invoice model.

Why. Because a header total that disagrees with the lines is one of the most common rejection causes, and it is entirely preventable by removing the opportunity. The cost is that you cannot express an invoice whose stated total differs from its detail — which is not a thing you should be able to express.


3. An unknown key is an error

desde_dict raises FacturaInvalida on a key it does not recognise, rather than ignoring it.

Why. The alternative fails in the worst possible way: you write "importe" where the schema wants "importe_total", the library shrugs, and you get a document that is silently missing a value. The typo is invisible precisely because nothing complained. An error at parse time points at the exact key.

The cost. You cannot pass a dictionary with extra fields of your own through this function. Strip them first — that is a one-line dictionary comprehension, and it is better than a silently wrong invoice.


4. Money is Decimal, and floats are converted via repr()

Decimal(0.1)       →  0.1000000000000000055511151231257827
Decimal(str(0.1))  →  0.1

The first is what you get from the obvious implementation. The second is what anybody actually means. Accepting a float and converting it through its string form is the compromise between "never use floats for money" (correct, and ignored) and accepting whatever the caller has.


5. Scalars are properties, breakdowns are methods

factura.total has no parentheses; factura.impuestos_repercutidos() does.

This is a genuine inconsistency if you look at it as a naming scheme, and a deliberate signal if you look at it as cost: properties are cheap reads, methods build a list. Making them look identical would hide that.

It bites exactly once, with a TypeError about a method object not being iterable. It is documented here rather than smoothed over, because smoothing it over would mean changing the API to be less informative.


6. Errors inherit from ValueError

FacturaInvalida is a ValueError. An existing except ValueError around your invoice-building code keeps catching what it should, and callers who want the detail can still catch the specific class.


7. Every CLI subcommand reads - as stdin

plantilla, validar, totales and generar all accept -.

Why. The JSON round trip only earns its keep if the tool composes. Reading stdin means the library is usable from Node, from PHP, from a shell script — build the JSON there, pipe it in, take the XML out — without anybody having to write Python.


8. autocomprobar recomputes a known invoice at runtime

The CLI recalculates a hand-written invoice (1 250,00 / 262,50 / 150,00 / 1 362,50) and fails if the numbers do not come out.

Why. Tests prove the code was correct on the machine that ran CI. They prove nothing about the copy installed on yours. CI also installs the built wheel and runs it from another directory with the source not alongside — the only job that catches a missing py.typed or a broken console-script entry point.


9. Zero dependencies

The standard library's XML support is enough. Nothing else is imported, so the library imposes no version constraints on the accounting system it lands in.



🇪🇸 Español

Decisiones de diseño

Una factura sutilmente mal no la rechaza tu código: la rechaza FACe semanas después, con un mensaje que no dice en qué campo. Todas las decisiones de aquí cambian comodidad por enterarse pronto.


1. Se detiene antes de la firma

Facturae exige una firma XAdES antes de presentarse. Esta biblioteca genera un documento válido sin firmar, y para.

Por qué. Firmar necesita tres cosas que no pintan nada aquí:

  1. Una clave privada y un certificado. Dónde viven, cómo se desbloquean y quién puede usarlos son decisiones del modelo de seguridad de tu aplicación, no de una biblioteca de formato.
  2. Una dependencia de criptografía. Cero dependencias es lo que permite que esto entre en un sistema existente sin discusión de versiones. Una biblioteca de firma se trae los enlaces de OpenSSL con ella.
  3. XAdES en concreto —firma envolvente con certificado de firma, hora de firma y política de firma—, no XML-DSig a secas. Equivocarse sutilmente produce un documento que se parsea y se rechaza.

Una biblioteca que generase casi una firma sería peor que una que deja claro que se detiene en el documento. docs/signing.md cuenta a qué recurrir en su lugar.


2. Los totales se calculan, no son campos

No hay un total que rellenar. Ver The invoice model.

Por qué. Porque un total de cabecera que discrepa de las líneas es una de las causas de rechazo más frecuentes, y se evita del todo quitando la oportunidad. El coste es que no puedes expresar una factura cuyo total declarado difiera de su detalle — que no es algo que debieras poder expresar.


3. Una clave desconocida es un error

desde_dict lanza FacturaInvalida ante una clave que no reconoce, en vez de ignorarla.

Por qué. La alternativa falla de la peor manera posible: escribes "importe" donde el esquema quiere "importe_total", la biblioteca se encoge de hombros y te quedas con un documento al que le falta un valor en silencio. La errata es invisible precisamente porque nadie protestó. Un error al interpretar la entrada señala la clave exacta.

El coste. No puedes pasar por esta función un diccionario con campos tuyos de más. Quítalos antes: es una comprensión de diccionario de una línea, y es mejor que una factura silenciosamente equivocada.


4. El dinero es Decimal, y los float se convierten por repr()

Decimal(0.1)       →  0.1000000000000000055511151231257827
Decimal(str(0.1))  →  0.1

Lo primero es lo que sale de la implementación evidente. Lo segundo es lo que cualquiera quiere decir en realidad. Aceptar un float y convertirlo a través de su forma textual es el compromiso entre «nunca uses float para dinero» —correcto, y desatendido— y aceptar lo que traiga quien llama.


5. Los escalares son propiedades y los desgloses son métodos

factura.total no lleva paréntesis; factura.impuestos_repercutidos() sí.

Es una inconsistencia real si lo miras como criterio de nombres, y una señal deliberada si lo miras como coste: las propiedades son lecturas baratas y los métodos construyen una lista. Hacer que se vieran iguales lo escondería.

Muerde exactamente una vez, con un TypeError que dice que un objeto de tipo método no es iterable. Está documentado aquí en vez de disimulado, porque disimularlo significaría cambiar la API para que informe menos.


6. Los errores heredan de ValueError

FacturaInvalida es un ValueError. Un except ValueError que ya rodeara tu código de construcción de facturas sigue capturando lo que debe, y quien quiera el detalle puede capturar la clase concreta.


7. Todos los subcomandos de la CLI leen - como entrada estándar

plantilla, validar, totales y generar aceptan -.

Por qué. El viaje de ida y vuelta por JSON solo sale a cuenta si la herramienta se combina con otras. Leer de la entrada estándar hace que la biblioteca se pueda usar desde Node, desde PHP o desde un script de shell —construyes el JSON allí, lo canalizas y sacas el XML— sin que nadie tenga que escribir Python.


8. autocomprobar recalcula una factura conocida en tiempo de ejecución

La CLI recalcula una factura escrita a mano (1.250,00 / 262,50 / 150,00 / 1.362,50) y falla si los números no salen.

Por qué. Las pruebas demuestran que el código estaba bien en la máquina que ejecutó la CI. No demuestran nada sobre la copia instalada en la tuya. La CI además instala la rueda construida y la ejecuta desde otro directorio, sin el código fuente al lado: es el único trabajo que detecta un py.typed que falta o un punto de entrada roto.


9. Cero dependencias

El soporte de XML de la biblioteca estándar es suficiente. No se importa nada más, así que la biblioteca no impone restricciones de versión al sistema contable donde aterrice.