cadrumo.application.ledger.identity_roles module

Which identifier on the page is the counterparty’s – decided, never guessed.

This module exists because of a measured defect, not a hypothetical one. Scanning a document for the first checksum-valid tax identifier produces a confident wrong answer whenever the real supplier’s identifier fails its own check character: validation correctly rejects the true one, the scan walks on, and the next valid identifier on the page – a bank’s, a gestoría’s, the taxpayer’s own – is returned as the supplier. Nothing fails. The record names a real, valid, entirely unrelated entity, and no downstream check can tell.

First-match is the defect. The fix is not a better scan order: any total order over the candidates is still a guess wearing a ranking. When role evidence does not pick exactly one candidate, the resolution is AMBIGUOUS with every candidate surfaced, and the operator – who has the document – decides.

Two exclusions are applied before candidacy, and both are load-bearing:

The taxpayer’s own identifier is never a counterparty candidate. It appears on every invoice in the corpus, on both directions, so leaving it in the pool means the most frequently occurring identifier on the page competes for the role it can never hold. Comparison is on the normalised form, so a printed B-1234567-4 does not evade exclusion against a stored B12345674.

That exclusion asks an IDENTITY question, never a validity one, and routes through same_tax_identifier() – the same predicate counterparty_is_the_filer() uses, so a document cannot evade one while being caught by the other. Routing it through canonical_identity_token() instead makes it a validity question, and then a filer whose stored identifier is foreign, or Spanish with a bad control character, excludes nothing at all. The strict form remains correct for verifying a candidate; the two are not interchangeable.

A checksum failure is recorded, not silently dropped. An identifier that fails its control character is a real fact about the document – it is what makes the true supplier invisible to a validating scan – so it surfaces as an IDENTITY_UNVERIFIED finding rather than vanishing. That is the difference between “we could not verify the supplier” and “we found a supplier”, and the operator needs the first when the first is true.

An ABSENT counterparty identifier is not a failed role. A document that prints no counterparty identifier at all, or prints only the filer’s own, states no role for this resolver to get wrong: a factura simplificada may legitimately omit the recipient’s NIF, and an ordinary domestic ticket identifies no customer. Those raise no ROLE_UNRESOLVED. An UNVERIFIABLE one still does, because that is the measured defect above. The distinction is not cosmetic: every discrepancy kind blocks confirmation by construction, so a blocker firing across the legitimate population is one an operator learns to clear unread – and then clears on the checksum case too.

Withholding the finding never asserts that the role is fine. The resolution carries resolved=None under an UNANCHORED envelope whose note says the document stated nothing, so the absence reads as “not asked” at every consumer.

EU identifiers count. A Spanish-only check silently discards every intra-EU counterparty, which is precisely the Modelo 349 population – the filing that exists to report them. Validation therefore routes through the repository’s established EU IVA authority (core.identity) rather than a local Spain-shaped test.

See also

FieldProvenance

The envelope a resolution produces.

ground_ambiguous_candidates()

The sanctioned constructor for the ambiguous outcome.

DraftDiscrepancyFinding

Carrier for the unverified-identity and unresolved-role findings.

class IdentityCandidate(**data)[source]

Bases: BaseModel

One tax identifier observed on the document, with where it was read.

Variables:
  • value – The identifier exactly as printed, separators and all.

  • anchor – The verbatim printed form, when it differs from the value (a label-prefixed occurrence, say). Defaults to the value itself.

  • country_code – ISO country the identifier is stated under, when the document says. None means unstated, which is read as Spain only because that is what an unqualified identifier on a Spanish invoice means – never inferred from the identifier’s own shape.

  • role_evidence – What on the page suggests this identifier belongs to the counterparty, if anything. Empty means no role evidence at all.

Parameters:

data (Any)

value: str
anchor: str | None
country_code: str | None
role_evidence: str
property printed_anchor: str

Return the anchor to record, defaulting to the value as printed.

class IdentityRoleResolution(**data)[source]

Bases: BaseModel

The outcome of resolving which candidate holds the counterparty role.

Variables:
  • provenance – The envelope for the counterparty identifier field.

  • resolved – The canonical identifier when exactly one candidate survived, None otherwise. None on an ambiguous resolution is the point: there is no value to carry forward, and a caller that wants one must take it from the operator.

  • findings – Deterministic findings raised while resolving – unverified identifiers, and an unresolved role.

Parameters:

data (Any)

provenance: FieldProvenance
resolved: str | None
findings: tuple[DraftDiscrepancyFinding, ...]
canonical_identity_token(value, *, country_code=None, operation)[source]

Return the canonical form of value, or None when it does not verify.

Routes Spanish identifiers through the AEAT control-character algorithm and non-Spanish ones through the EU IVA format authority. Returning None rather than raising keeps this usable as a filter: a document carrying a malformed identifier is an ordinary case the caller reports, not an exception it must catch per candidate.

An absent country asks the identifier’s own prefix before assuming Spain. An intra-community IVA number leads with its Member State’s prefix, so a document printing SE556677889901 states its country in the number itself – and that is exactly the document whose address block often states none. Defaulting the absence to Spain measured such a number against the AEAT control-character rule, which it cannot satisfy, so a perfectly well-formed foreign identifier yielded no verdict at all. The country the default assumed is the very thing the establishment ladder exists to determine, and the ladder’s own first rung already reads this prefix.

The prefix is read through country_code_for_printed_tax_identifier(), the single authority on which country a printed number names, rather than by matching two leading letters here: the prefix alone is not evidence, and that authority requires the body to match the structure its own prefix claims.

A SUPPLIED country still decides, unchanged. A caller that names one is asserting it, and a supplied country disagreeing with the printed prefix is a disagreement to surface rather than to resolve silently in the prefix’s favour – so only the absence position moves.

Parameters:
  • value (str) – The identifier as printed.

  • country_code (str | None) – ISO country it is stated under. When None, the identifier’s own prefix answers; an identifier carrying no recognisable prefix takes the Spanish path, which is right because a bare B-CIF is what an unqualified Spanish invoice prints.

  • operation (PinnedAuthorityOperation) – Caller-owned pinned authority operation for the country catalogue used to interpret a printed identifier.

Return type:

str | None

Returns:

The canonical identifier, or None when it fails verification.

resolve_counterparty_identity(*, field, candidates, taxpayer_tax_id, origin, operation)[source]

Resolve which observed identifier holds the counterparty role.

Never returns a first match. Exactly one surviving candidate resolves; two or more resolve to AMBIGUOUS carrying every one of them; none resolves to UNANCHORED with an unresolved-role finding.

Parameters:
  • field (str) – Name of the draft field being resolved.

  • candidates (tuple[IdentityCandidate, ...]) – Every tax identifier observed on the document, in document order. Order is preserved for display only and is never used to break a tie – breaking a tie by position is first-match under another name.

  • taxpayer_tax_id (str | None) – This profile’s own identifier, excluded from candidacy. Never required to be checksum-valid or Spanish – exclusion is an identity comparison. None or blank means unknown, which weakens the resolution and is reported in the note rather than passed over.

  • origin (FieldOrigin) – How the candidates were obtained.

  • operation (PinnedAuthorityOperation) – Caller-owned pinned authority operation for identifier country interpretation.

Return type:

IdentityRoleResolution

Returns:

The resolution, carrying the envelope, any resolved value, and every deterministic finding raised on the way.