cadrumo.application.aggregation.invoice_retencion module

Route a received invoice’s retención into the one per-perceptor store.

Retención arithmetic is identical on both kinds of invoice and means opposite things. On an ISSUED invoice the payer withholds from what they owe the taxpayer and remits it on the taxpayer’s account: the taxpayer is the retenido and the amount is a CREDIT against the pago fraccionado (RIRPF art. 110.3.a). On a RECEIVED invoice from a resident professional the taxpayer is the obligated retenedor and the amount is a LIABILITY settled to AEAT through Modelo 111 and its annual summary.

Those two destinations already exist and neither is here. The credit reaches the M130/M100 retenciones casilla through the renta income ledger. The liability’s canonical home is the per-perceptor retención store behind the retenciones_aggregation binding family, whose committed M111 bindings already read it. This module is the projection into that store and nothing else: it builds the same RetencionObservation the operator-declared path builds, so a received invoice becomes one more observation in the one store rather than a second retención path with its own totals to reconcile.

The scheme is not inferred. Which registry-owned retención scheme a payment falls under is a legal fact about the perceptor’s activity, not a property of the invoice. An invoice record carries no field that settles it. So the caller declares it, and this module refuses to guess: choosing a scheme here would file a figure under a clave the taxpayer never asserted.

The production caller is the modelo aggregate CLI: an operator (or the LLM operator on their behalf) declares (invoice, scheme) pairs via --received-invoice-retencion, and merge_manual_and_routed_retencion_observations() unions the resulting observations with any hand-typed --retencion-observation rows into the ONE set the CLI passes to persist_retencion_observations – never two separate persist calls for one window, since that write is set-replace.

See also

retenciones

The observation type, the aggregators, and the source-kind taxonomy.

retencion_observations_repository

The encrypted per-perceptor store this projection feeds through persist_retencion_observations, the one shared write path every producer calls.

IvaRetencionRole

The declared per-(category, kind) role this module routes on, rather than re-deriving the direction from the invoice kind.

class InvoiceRetencionProjectionDefect(*values)[source]

Bases: StrEnum

Why one invoice’s retención did not become a store observation.

Every member EXCLUDES the invoice from the retenciones store and is reported rather than dropped. None of them is a refusal to record the invoice itself – the record stays in the catalogue and stays editable.

NOT_A_RETENEDOR_LIABILITY

The Axis-A role for this invoice is not a liability the taxpayer owes.

Most often the invoice is issued, so its retención is the taxpayer’s CREDIT: routing it here would declare them retenedor for money withheld FROM them, inverting the direction of a tax liability. It is not a missing figure; it belongs to the income side and is already carried there. The same member covers a (category, kind) pair whose role is NONE or UNKNOWN – neither is a liability this store may assert.

IVA_TREATMENT_UNDECLARED

No IVA category is declared, so the retención role cannot be read.

The role is per (category, kind) pair, so without a category there is no row to consult and the direction of the amount is unknown. Guessing it from the kind alone is exactly the re-derivation this module refuses.

NO_RETENCION_DECLARED

The invoice declares no withheld amount, or declares it as zero.

Nothing to route. Most received invoices are in this state legitimately.

NON_RESIDENT_SUPPLIER

The supplier is not Spanish-resident, so Modelo 111 is the wrong home.

The retenedor obligation on payments to non-residents runs through the IRNR surface, not the IRPF per-perceptor family this store feeds. Excluding is the conservative direction: it surfaces the invoice for the operator rather than filing a figure under a modelo that does not govern it.

FX_UNRESOLVED

A non-euro invoice carries no resolved conversion rate.

The euro base and retención are unknown, and the store holds euro figures.

MISSING_COUNTERPARTY_TAX_ID

A factura simplificada declared a retención but names no perceptor.

Modelo 111 requires the perceptor’s NIF; an invoice may legitimately carry no counterparty_tax_id (RD 1619/2012 art. 6.1.d), but a supplier withholding tax is one of the cases that article makes the tax id mandatory for regardless, so this exclusion should be rare in practice.

class InvoiceRetencionProjection(**data)[source]

Bases: BaseModel

The verdict for one invoice: a store observation, or why there is none.

observation and defects are mutually exclusive and jointly exhaustive, enforced below, so a reader never has to interpret a null.

Variables:
  • invoice_id – The record this verdict is about.

  • observation – The routed observation, present iff the invoice routes.

  • defects – Why it did not route, non-empty iff it did not.

Parameters:

data (Any)

invoice_id: InvoiceId
observation: RetencionObservation | None
defects: tuple[InvoiceRetencionProjectionDefect, ...]
class InvoiceRetencionRouting(**data)[source]

Bases: BaseModel

Both outcome classes for a set of invoices, in one object.

The excluded half travels with the routed half so a caller cannot persist what routed without holding what did not – an excluded retención is a liability the taxpayer may still owe, and losing it silently is the failure this whole surface exists to prevent.

Variables:
  • observations – The observations to persist into the per-perceptor store.

  • excluded – Verdicts that did not route and must be surfaced.

Parameters:

data (Any)

observations: tuple[RetencionObservation, ...]
excluded: tuple[InvoiceRetencionProjection, ...]
class InvoiceRetencionRouteRequest(**data)[source]

Bases: BaseModel

One operator-declared (invoice, scheme) pair to route at aggregation time.

The wire shape the modelo aggregate CLI parses --received-invoice-retencion JSON into. The scheme is supplied here rather than read off the invoice for the same reason project_received_invoice_retencion() never infers it: it is a legal fact about the perceptor’s activity the invoice record does not carry.

Parameters:

data (Any)

invoice_id: InvoiceId
scheme: RetencionScheme
project_received_invoice_retencion(invoice, *, scheme)[source]

Project one received invoice’s declared retención into a store observation.

Never raises for an unroutable invoice: it comes back carrying defects, which is the outcome the caller surfaces.

Parameters:
  • invoice (Invoice) – An already-valid invoice record.

  • scheme (RetencionScheme) – The declared retención scheme. A legal fact about the perceptor’s activity that the invoice does not carry, so it is supplied rather than inferred.

Return type:

InvoiceRetencionProjection

Returns:

The verdict, carrying either the routed observation or the defects that excluded it.

route_invoice_retenciones(entries)[source]

Route many invoices, keeping the excluded ones alongside the routed ones.

Parameters:

entries (Iterable[tuple[Invoice, RetencionScheme]]) – Pairs of an invoice and its declared retención scheme.

Return type:

InvoiceRetencionRouting

Returns:

The observations to persist and the verdicts that did not route.

merge_manual_and_routed_retencion_observations(manual_observations, routed_observations)[source]

Union hand-typed and invoice-routed observations for one persist call.

persist_retencion_observations is a SET-REPLACE write: whatever this returns becomes the entire per-perceptor window for one (modelo, filing_year, period), so a caller must union every source before persisting rather than call it once per source. Rather than pick a winner when the operator has also hand-typed an observation this module would independently route from the same invoice, the union refuses – a bound value has one writer, and a silent pick would either double-count the invoice’s retención in the per-perceptor rollup or silently drop whichever side lost.

Parameters:
Return type:

tuple[RetencionObservation, ...]

Returns:

The union of both sequences, manual observations first.

Raises:

AggregationValidationError – A manual observation shares its (source_kind, source_object_id) identity with a routed one – the same invoice was both hand-typed and auto-routed in one call.