aeat.domain.transactions._models module

Strict immutable transaction-catalogue boundary models.

Defines Transaction, ClassificationHistoryEntry, and TransactionCatalogue. Every model is strict + frozen + extra="forbid"; no dataclasses or bare dict[str, Any] at the boundary.

derive_transaction_id(raw)[source]

Return the stable transaction hash for one raw transaction.

This content hash is the single authority for storage, audit, and machine consumers, and it intentionally changes when an id-affecting fact is edited (an update re-derives it and records the superseded id as a previous_transaction_id on the heir’s TransactionEditLineageEntry chain). The operator-facing lineage convenience that lets an old, written-down handle still resolve to the current row through ledger history / view / track (see application.ledger.resolve_lineage_transaction_id()) is a read-side lookup layer over this authoritative id; it never freezes or re-mints the id, so the content-addressing invariant import dedup relies on is untouched.

Parameters:

raw (RawTransaction) – The upstream immutable raw transaction emitted by a provider.

Return type:

str

Returns:

A lowercase SHA-256 digest derived from the provider identity, effective value date, amount, and narrative fields.

normalise_movement_reference(value)[source]

Return a provider-agnostic normalised form of a transaction narrative.

OFX and CSV exports of the same bank movement describe it with different verbatim narratives (an OFX MEMO versus a CSV reference column), and a later manual edit may further reword the description. Cross-format and post-edit deduplication therefore cannot key on the raw narrative.

This collapses a narrative to a stable comparison token: Unicode is NFKD-decomposed and combining accents are dropped (Ó -> o), the result is lower-cased, and every run of non-alphanumeric characters is squeezed out. Two narratives that differ only in accents, casing, punctuation, or whitespace map to the same token.

Return type:

str

Parameters:

value (str)

derive_import_fingerprint(raw, *, direction=None)[source]

Return the stable cross-format import-dedup fingerprint for a raw row.

Unlike derive_transaction_id() — which keys on the provider identifier and the verbatim narrative and therefore changes when a transaction is edited or re-exported in a different file format — this fingerprint keys only on the movement identity an operator would recognise: the effective date, amount magnitude, currency, direction, and the normalised narrative (see normalise_movement_reference()).

The fingerprint is stamped onto Transaction at import time and carried verbatim through every later edit, so re-importing the same statement (or the same movements exported as a different file format) recognises the row as already present. Import callers that have parsed flow direction must pass it; callers without a parse-boundary direction receive an explicit UNSPECIFIED discriminator.

Return type:

str

Parameters:
derive_movement_day_key(raw)[source]

Return the coarse (effective date, amount) key for a raw row.

Two rows that share this key but not the full derive_import_fingerprint() are likely — but not confidently — the same movement: same day, same amount, divergent narrative. The import path uses this to warn the operator about a probable cross-format duplicate rather than silently importing it.

Return type:

str

Parameters:

raw (RawTransaction)

class DecisionProvenance(**data)[source]

Bases: BaseModel

Typed provenance for one classification decision.

Carries the classifier that decided (decided_by, in the same auto / manual / rule:<id> / llm:<model> / derived:<basis> shape as ClassificationHistoryEntry.classified_by), when it decided (decided_at), the free-text justification, an optional confidence in [0, 1], and whether the decision was a manual override of an automated classification. This is the typed replacement for the formerly dict-widened reserved ClassificationHistoryEntry.provenance payload; a persisted record must carry a typed provenance, never a bare dict[str, object].

Variables:
  • decided_by – Classifier source string in the approved auto / manual / rule:<id> / llm:<model> / derived:<basis> shape.

  • decided_at – Timezone-aware UTC timestamp of the decision.

  • reason – Free-text justification (may be empty).

  • confidence – Optional decision confidence in [0, 1].

  • manual_overrideTrue when the decision manually overrode an earlier automated classification.

Parameters:
decided_by: str
decided_at: datetime
reason: str
confidence: Decimal | None
manual_override: bool
class ClassificationHistoryEntry(**data)[source]

Bases: BaseModel

One frozen record in a transaction’s classification chain.

The confidence and provenance fields default to None and are populated by writers without a schema bump because the field list is stable; provenance is the typed DecisionProvenance record (never a bare dict).

Variables:
  • business_classification – The BusinessClassification decided at this point in the chain.

  • business_pct – Required when business_classification is BusinessClassification.MIXED; must be None otherwise. Coupling enforced via _validate_business_pct_coupling().

  • classified_at – Timezone-aware UTC timestamp of the decision.

  • classified_by – Classifier source string in the auto / manual / rule:<id> / llm:<model> / derived:<basis> shape.

  • reason – Free-text justification (may be empty).

  • category_id – Optional domain.categories.SpendingCategory foreign key.

  • notes – Free-text notes (may be empty).

  • confidence – Optional decision confidence in [0, 1].

  • provenance – Optional reserved provenance payload; the pydantic type intentionally widens to a dict so future writers can replace it with a typed record without a breaking schema change.

Parameters:
business_classification: BusinessClassification
business_pct: Decimal | None
classified_at: datetime
classified_by: str
reason: str
category_id: str | None
notes: str
confidence: Decimal | None
provenance: DecisionProvenance | None
class TransactionEvidenceProvenanceEntry(**data)[source]

Bases: BaseModel

Actor/source lineage for evidence linked to one transaction.

Parameters:
  • evidence_id (str)

  • evidence_kind (Literal['purchase_invoice_evidence', 'attachment'])

  • actor (str)

  • source_command (str)

  • linked_at (datetime)

  • bucket_event_id (str | None)

evidence_id: str
evidence_kind: Literal['purchase_invoice_evidence', 'attachment']
actor: str
source_command: str
linked_at: datetime
bucket_event_id: str | None
class TransactionEditLineageEntry(**data)[source]

Bases: BaseModel

One durable manual correction applied to a transaction row.

Parameters:
  • previous_transaction_id (Annotated[str, StringConstraints(strip_whitespace=True, to_upper=None, to_lower=None, strict=None, min_length=64, max_length=64, pattern=^[0-9a-f]{64}$, ascii_only=None)])

  • actor (str)

  • source_command (str)

  • edited_at (datetime)

  • bucket_event_id (str | None)

previous_transaction_id: TransactionId
actor: str
source_command: str
edited_at: datetime
bucket_event_id: str | None
class TransactionLifecycleLineageEntry(**data)[source]

Bases: BaseModel

One durable lifecycle transition applied to a transaction row.

Parameters:
previous_state: TransactionLifecycleState
state: TransactionLifecycleState
actor: str
source_command: str
changed_at: datetime
reason: str
bucket_event_id: str | None
class SplitLineage(**data)[source]

Bases: BaseModel

Split-lineage anchor embedded on a parent/child/merged transaction.

Variables:
  • split_group_id – Lowercase 64-char SHA-256 derived deterministically by derive_split_group_id() from the parent’s transaction_id plus the sorted child amounts and narratives. Identical inputs yield identical group ids so re-emission is idempotent by construction.

  • role – Position in the lineage — PARENT, CHILD, or MERGED.

  • sibling_transaction_ids – For PARENT, every child id; for CHILD, the parent id followed by every other child id; for MERGED, the cohort of merged child ids (the original parent id is not included — the parent has its own MERGED entry on the archived parent record). Sorted lexicographically.

Parameters:
split_group_id: str
role: SplitRole
sibling_transaction_ids: tuple[str, ...]
derive_split_group_id(*, parent_transaction_id, child_amounts, child_narratives)[source]

Deterministically derive the split_group_id for a split cohort.

Identical (parent_id, amounts, narratives) tuples yield an identical group id; this is what makes split-event re-emission idempotent. Caller is responsible for amount/narrative pairing — the function sorts amounts and narratives independently before hashing because the group id identifies the cohort, not the per-child ordering.

Parameters:
  • parent_transaction_id (str) – The 64-char SHA-256 of the parent row.

  • child_amounts (tuple[Decimal, ...]) – Per-child amounts, in any order.

  • child_narratives (tuple[str, ...]) – Per-child narrative strings, in any order.

Return type:

str

Returns:

Lowercase 64-char SHA-256 hex digest.

class Transaction(**data)[source]

Bases: BaseModel

Immutable transaction wrapper that preserves raw provenance verbatim.

Variables:
  • transaction_id – Lowercase 64-char SHA-256 derived deterministically from the wrapped raw record by derive_transaction_id(). Re-validated on every parse to detect tampering.

  • raw – The verbatim domain.transactions._raw_transaction.RawTransaction.

  • direction – Closed TransactionDirection.

  • business_classification – Current BusinessClassification decision; defaults to BusinessClassification.NOT_YET_PROCESSED.

  • business_pct – Required when business_classification is BusinessClassification.MIXED; None otherwise.

  • invoice_id – Optional invoice foreign key.

  • category_id – Optional domain.categories.SpendingCategory foreign key.

  • taxable_base – Optional IVA-exclusive base amount.

  • iva_rate – Optional IVA rate expressed as a decimal fraction.

  • iva_amount – Optional IVA amount on the row.

  • irpf_category – Optional IRPF-specific category key.

  • usage_ratio_id – Optional proportionality reference.

  • prorrata_reference – Optional IVA prorrata substrate reference.

  • art_104_tres_exclusion – Operator-declared LIVA art. 104.Tres denominator-exclusion tag. Set ONLY for the two judgment exclusions the ledger cannot infer – foreign permanent establishment (1.º) and non-habitual inmobiliario/financiero operations (4.º); the transaction boundary rejects any auto-derived member (art. 7 no-sujeta, art. 9.1.d autoconsumo, bienes-inversión disposal, direct cuotas) since those are recognised from the category / register / structure. When set, the annual prorrata volume rollup excludes this operation from both terms of the art. 104.Dos ratio; the operation’s own IVA cuota treatment is unaffected. None for every operation that is not an art. 104.Tres judgment exclusion.

  • input_classification – Operator-declared LIVA art. 106 prorrata-especial per-input use classification (InputClassification): EXCLUSIVELY_DEDUCTIBLE (regla 1.ª, deducted in full), EXCLUSIVELY_NON_DEDUCTIBLE (regla 2.ª, no deduction), or COMMON (regla 3.ª, deducted at the general percentage). Meaningful only for a purchase row in a bucket whose prorrata register regime is especial; the regime-aware aggregation routes the deducible cuota by this classification. None for rows that are not under especial or carry no per-input use declaration.

  • prorrata_sector_id – Operator-declared LIVA arts. 9.1.c / 101 differentiated sector this input belongs to. References a sector_id declared in the bucket’s prorrata register sector definitions; the sector-aware aggregation applies THAT sector’s provisional percentage to the row’s deducible cuota. None means common-use (usable across sectors), apportioned by the art. 104.Dos common percentage in a sectorized bucket; in a non-sectorized bucket None is the whole-entity default (today’s behaviour), so an unsectored taxpayer is unaffected.

  • purchase_invoice_evidence_id – Canonical purchase-invoice evidence reference attached to the row.

  • attachment_ids – Supplementary attachment references.

  • created_by – Actor that first created the manual row when known.

  • source_command – Backend/CLI command source that created the row.

  • created_event_id – Bucket event id for the create event when available.

  • evidence_provenance – Actor/source lineage for attached evidence.

  • edit_lineage – Durable edit chain for manual corrections.

  • lifecycle_state – Current active/archive/stash/split state.

  • lifecycle_lineage – Durable lifecycle transition chain.

  • split_lineage – Optional SplitLineage recording this row’s role within an N-way split cohort. None for transactions that have never been split.

  • notes – Free-text notes.

  • import_fingerprint – Stable cross-format dedup fingerprint stamped at import time (see derive_import_fingerprint()) and carried verbatim through every later edit so re-imports of the same statement — or the same movements in a different file format — are recognised as already present. None for hand-entered rows that never came from an import.

  • classified_at – Timezone-aware timestamp of the active decision (None when never classified).

  • classified_by – Classifier source string for the active decision.

  • classification_reason – Free-text reason for the active decision.

  • classification_confidence – Optional confidence in [0, 1] for the active decision.

  • classification_history – Tuple of historical ClassificationHistoryEntry records, oldest first.

  • iva_category – Explicit IVA category override. When set the aggregation layer uses this value in place of the rate-kind-derived domestic category, enabling non-domestic categories (intra-community, export, non-subject) to be expressed without a synthetic rate. None for transactions where the standard domestic rate derivation is sufficient.

  • exemption_article – Optional Ley 37/1992 Art. 20 sub-article discriminator. Valid only when iva_category is IvaCategory.DOMESTIC_EXEMPT; None preserves the broad exempt category with no sub-article distinction.

  • counterparty_eu_member_state – ISO 3166-1 alpha-2 EU member state of the counterparty. Required by the aggregation gate when iva_category is IvaCategory.INTRA_COMMUNITY_SUPPLY; rejected when the category is IvaCategory.EXPORT_THIRD_COUNTRY_ZERO_RATED. None otherwise.

  • cash_accounting_treatment – Independent criterio-de-caja axis. It never replaces iva_category: the operation remains domestic/export/intracom/etc. and this field only records whether the taxpayer’s special regime or a supplier’s special regime changes IVA timing.

  • cash_accounting_operation_date – Art. 75 general-devengo operation date for cash-accounting informational reporting. Required whenever cash_accounting_treatment is not NONE so the aggregator never silently reuses a bank movement date as the legal devengo projection.

  • cash_accounting_payment_evidence – Total or partial collection/payment events that settle affected base/cuota under LIVA arts. 163 terdecies / quinquiesdecies.

  • fx_rate – ECB reference rate applied at import time to convert raw.amount from raw.currency to EUR. The rate is expressed as a multiplier: raw.amount * fx_rate = value_in_eur. None when the native currency is EUR or when the rate was unavailable at import time.

  • value_in_eur – Pre-converted EUR-equivalent of raw.amount computed at import time as raw.amount * fx_rate, rounded to two decimal places. Aggregation layers use this field in place of raw.amount for non-EUR transactions, making casilla sums deterministic and independent of rate changes after the import date. None when the native currency is EUR or when no rate was available.

  • source_jurisdiction – ISO 3166-1 alpha-2 uppercase code identifying the regulatory source jurisdiction of the income or expense ("ES" for Spanish-source, foreign two-letter codes for foreign-source). Drives the IRNR scope filter (non-resident profiles only emit Spanish-source rows into AEAT bases) and the Art. 93 LIRPF Beckham filter (impatriado IRPF base excludes foreign-source rows). None records an explicitly unknown jurisdiction.

  • created_at – UTC-aware timestamp stamped once at construction and carried verbatim through every later edit.

  • modified_at – UTC-aware timestamp re-stamped on every mutating edit.

Parameters:
raw: RawTransaction
transaction_id: TransactionId
direction: TransactionDirection
business_classification: BusinessClassification
business_pct: Decimal | None
invoice_id: str | None
category_id: str | None
taxable_base: Decimal | None
iva_rate: Decimal | None
iva_amount: Decimal | None
recargo_amount: Decimal | None
irpf_category: str | None
usage_ratio_id: str | None
prorrata_reference: str | None
art_104_tres_exclusion: Art104TresExclusion | None
input_classification: InputClassification | None
prorrata_sector_id: str | None
purchase_invoice_evidence_id: str | None
attachment_ids: tuple[str, ...]
created_by: str | None
source_command: str | None
created_event_id: str | None
evidence_provenance: tuple[TransactionEvidenceProvenanceEntry, ...]
edit_lineage: tuple[TransactionEditLineageEntry, ...]
lifecycle_state: TransactionLifecycleState
lifecycle_lineage: tuple[TransactionLifecycleLineageEntry, ...]
split_lineage: SplitLineage | None
notes: str
import_fingerprint: str | None
classified_at: datetime | None
classified_by: str
classification_reason: str
classification_confidence: Decimal | None
classification_history: tuple[ClassificationHistoryEntry, ...]
iva_category: IvaCategory | None
exemption_article: IvaExemptionArticle | None
counterparty_eu_member_state: EUMemberState | None
cash_accounting_treatment: IvaCashAccountingTreatment
cash_accounting_operation_date: date | None
cash_accounting_payment_evidence: tuple[IvaCashAccountingPaymentEvidence, ...]
fx_rate: Decimal | None
value_in_eur: Decimal | None
rate_source: str | None
rate_date: str | None
source_jurisdiction: str | None
group_label: str | None
created_at: datetime
modified_at: datetime
class BucketTransactionRef(**data)[source]

Bases: BaseModel

A transaction identifier qualified by its owning profile bucket.

Parameters:
  • bucket_id (Annotated[str, StringConstraints(strip_whitespace=True, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=128, pattern=None, ascii_only=None)])

  • transaction_id (Annotated[str, StringConstraints(strip_whitespace=True, to_upper=None, to_lower=None, strict=None, min_length=64, max_length=64, pattern=^[0-9a-f]{64}$, ascii_only=None)])

bucket_id: BucketId
transaction_id: TransactionId
class TransactionCatalogue(**data)[source]

Bases: BaseModel

Immutable catalogue keyed by transaction_id.

transactions is a frozen types.MappingProxyType from stable transaction id to Transaction, built via from_transactions() or by passing a mapping / iterable to model_validate.

Parameters:

transactions (Mapping[str, Transaction])

transactions: Mapping[str, Transaction]
classmethod from_transactions(transactions)[source]

Build a catalogue from an iterable of transactions.

Parameters:

transactions (Iterable[Transaction | Mapping[str, object]]) – Transactions or transaction payloads to load.

Return type:

Self

Returns:

A validated immutable transaction catalogue.

get(transaction_id)[source]

Fetch one transaction by ID if present.

Parameters:

transaction_id (str) – Stable transaction identifier.

Return type:

Transaction | None

Returns:

The matching Transaction, or None when absent.

values()[source]

Iterate over catalogue Transaction records.

Return type:

Iterator[Transaction]

class OutOfWindowTransactionStub(**data)[source]

Bases: BaseModel

A catalogue transaction outside a requested date window, undecrypted.

Carries ONLY the two plaintext, non-sensitive facts a period-scoped aggregator needs to report the transaction as excluded – transaction_id and its filing_date – never any decrypted field (amount, category, counterparty, direction, business classification). This is the O2 period-first partition contract (2026-07-05-ledger-latency-budget-adr): an out-of-window row is diagnosed from the plaintext date-index fact alone, without paying the decrypt-and-validate cost, and without leaking anything the index itself does not already carry.

Parameters:
  • transaction_id (str)

  • filing_date (date)

transaction_id: str
filing_date: date
class OutOfWindowTransactionSummary(**data)[source]

Bases: BaseModel

Compact diagnostics-only summary for out-of-window catalogue rows.

Carries only the facts authorized by the 2026-07-06 diagnostic-summary amendment to the latency ADR: excluded-row count and the filing-date span covered by those rows. It never carries decrypted transaction facts.

Parameters:
  • count (int)

  • min_filing_date (date)

  • max_filing_date (date)

count: int
min_filing_date: date
max_filing_date: date
classmethod from_stubs(stubs)[source]

Build a summary from row-level plaintext stubs, or None when empty.

Return type:

Optional[Self]

Parameters:

stubs (Iterable[OutOfWindowTransactionStub])

class LedgerDatePartition(**data)[source]

Bases: BaseModel

A ledger catalogue split into an in-window and an out-of-window half.

in_window is a real, fully decrypted TransactionCatalogue scoped to [start, end] – every regulated classifier gate runs over it unchanged. out_of_window is the plaintext-only remainder (OutOfWindowTransactionStub rows): transactions the catalogue holds outside the window, reported without decryption so a caller can still surface a period-exclusion diagnostic for them.

out_of_window_summary is the compact diagnostics-channel replacement: count plus filing-date span, with no decrypted fields and no row-level allocation requirement. During the migration, callers may see either the row-level stubs, the summary, or both.

index_complete records whether the partition was served from a complete plaintext date index (True) or from a full-scan fallback after a completeness-gate mismatch (False – see ledger-participation-index-is-derived-rebuildable): both cases return an identical partition shape, so a caller cannot observe which path served it except through this flag and through latency.

Parameters:
in_window: TransactionCatalogue
out_of_window: tuple[OutOfWindowTransactionStub, ...]
out_of_window_summary: OutOfWindowTransactionSummary | None
index_complete: bool