cadrumo.domain.transactions.models module

Strict immutable transaction-catalogue boundary models.

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.id_resolution.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 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.spending_category.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.

  • concepto_ingreso – Operator-declared income concept (ConceptoIngreso) for base-inclusion purposes. RD 439/2007 art. 110.1.c) fixes the agrarian pago fraccionado on the volumen de ingresos … excluidas las subvenciones de capital y las indemnizaciones, and the distinction runs INSIDE subsidies – a subvención corriente counts and a subvención de capital does not – so no amount, category or counterparty on the row can settle it. None means ordinary income and is INCLUDED, because an unmarked receipt is far more likely to be ordinary than exceptional and defaulting the other way would drop real income out of a declared volume.

  • tipo_actividad – Operator-declared Modelo 036 tipo de actividad (TipoActividad) the row’s activity income belongs to. Present so a return that splits casillas by activity can route each row to the right one – Modelo 131 carries the estimación-objetiva volume in casilla 01 and the agrarian volume in casilla 08, and without this axis the same rows would feed both and double-count. It also selects the RIRPF art. 95 retención partition through the registry correspondence in tipo_actividad_partitions. None for a row whose activity is undeclared or that carries no activity income at all; an aggregation that needs the split must treat None as unknown rather than as any particular activity.

  • 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 the registry-declared domestic-exempt category; None preserves the broad exempt category with no sub-article distinction.

  • counterparty_country –

    Where the counterparty is ESTABLISHED – an address fact, ISO 3166-1 alpha-2. It answers establishment-flavoured questions only and is barred from identification-keyed gates. None means establishment was not recorded, never that the party is established nowhere and above all never that it is outside the Union.

    Stored as the raw code rather than as a Member State enum, which is the whole point of the field: an enum closed over the Member States can say “established in Germany” and cannot say “established in the United States”, so absence was the only representation a third country had. A gate reading that absence as third-country establishment was reading “not recorded” as “outside the Union” – and on the issued side outside the Union is export treatment, zero-rated, so an unrecorded establishment silently exempted a supply. The code is handed to territorial_scope_for_country(), which answers from the closed vocabulary and refuses a code that names no country, so XX establishes nothing rather than establishing an export.

    This mirrors Invoice, which has always stored the country and derived the Member State. The two models disagreeing about how one fact is held is what let the ledger path lose a distinction the invoice path kept.

  • counterparty_eu_member_state – The Member State the counterparty is established in, DERIVED from counterparty_country and None for every country outside the Union. Not a stored fact: one establishment cannot be recorded twice without the two copies eventually disagreeing.

  • counterparty_identification_state –

    Which Member State IVA-IDENTIFIES the counterparty, read from the prefix of the IVA number it trades under. A different fact from establishment and never derived from it: a Spanish-established acquirer can hold a German IVA number, and a German-established one can purchase under a Spanish NIF-IVA. This is the operative fact for the Ley 37/1992 art. 25 exemption, so the aggregation gate requires it non-ES when iva_category is the registry-declared intra-community-supply category.

    None means the identification was not established, never that the party is identified nowhere and above all never that it is identified in Spain – a decision needing it refuses with a review item rather than falling back to the country.

  • 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.

  • operation_date –

    The LIVA art. 75 devengo date – when the operation legally occurred, as distinct from when the bank moved. Optional on every regime and REQUIRED whenever cash_accounting_treatment is not NONE, so the aggregator never silently reuses a bank movement date as the legal devengo projection.

    Available to the general regime, not only to criterio de caja. Under art. 75 the cuota is devengada when the operation occurs regardless of collection, so for a general-regime row the operation date is the legally CONTROLLING one and the movement date is the proxy; the criterio-de-caja regime is the one where collection timing governs and this date is the informational counterpart. Restricting the field to cash accounting therefore withheld it from the only regime the law binds to it, leaving an invoice issued in one quarter and paid in another to declare its IVA in the quarter of payment.

    None means the row makes no distinction, and the movement date stands as the operation date – correct whenever an operation is settled on the day it occurs, which is the ordinary case.

  • 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:

data (Any)

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
tipo_actividad: TipoActividad | None
concepto_ingreso: ConceptoIngreso | 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
deduction_fact_kind: IvaDeductionFactKind | None
deduction_provenance: IvaDeductionClassificationProvenance | None
investment_asset_id: str | None
rectifies_ledger_id: str | None
exemption_article: IvaExemptionArticle | None
counterparty_country: str | None
counterparty_identification_state: EUMemberState | None
cash_accounting_treatment: IvaCashAccountingTreatment
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
m210_income_classification: M210IncomeClassification | None
group_label: str | None
created_at: datetime
modified_at: datetime
property counterparty_eu_member_state: EUMemberState | None

Return the Member State the counterparty is established in, or None.

Derived from counterparty_country rather than stored beside it, matching Invoice. Two stored copies of one establishment fact can disagree, and the disagreement is silent.

None covers three different situations and deliberately does not distinguish them, because a Member State accessor is the wrong place to: no country was recorded, the country is outside the Union, or the code names no country at all. A caller that must tell those apart asks territorial_scope_for_country() for the territory and stated_country_code_status() for why nothing resolved. Reading this None as “outside the Union” is exactly the inference that let an unrecorded establishment zero-rate a supply.

class BucketTransactionRef(**data)[source]

Bases: BaseModel

A transaction identifier qualified by its owning profile bucket.

Parameters:

data (Any)

bucket_id: BucketId
transaction_id: TransactionId
existing_transaction_import_fingerprints(transaction)[source]

Return every canonical re-import fingerprint for a stored transaction.

Imported rows carry their parse-boundary, direction-qualified fingerprint verbatim. Older or hand-entered rows may not have that stamp, so their current raw payload is projected once with the stored direction as a deterministic fallback. Preview diagnostics and the persisting import path must use this exact projection: a directionless fallback would incorrectly collapse otherwise distinct incoming and outgoing movements.

Return type:

frozenset[str]

Parameters:

transaction (Transaction)

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:

data (Any)

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 OutOfWindowTransactionIndexEntry(**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 period-first partition contract: 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:

data (Any)

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

Bases: BaseModel

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

Carries only the excluded-row count and the filing-date span covered by those rows. It never carries decrypted transaction facts.

Parameters:

data (Any)

count: int
min_filing_date: date
max_filing_date: date
classmethod from_index_entries(index_entries)[source]

Build a summary from plaintext date-index entries, or None when empty.

Return type:

Optional[Self]

Parameters:

index_entries (Iterable[OutOfWindowTransactionIndexEntry])

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 (OutOfWindowTransactionIndexEntry 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 index entries, 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 aeat-ledger-contract): both cases return an identical partition shape, so a caller cannot observe which path served it except through this flag and through latency.

Parameters:

data (Any)

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