Source code for aeat.domain.transactions._raw_transaction
"""Strict raw transaction boundary models for ingest.Defines the upstream-immutable records every transaction parser mustemit, before they are wrapped in:class:`domain.transactions.Transaction`:- :class:`RawTransaction` -- the verbatim per-row record.- :class:`RawProvenance` -- the source-file metadata pinned to each row.- :class:`SourceFormat` -- closed taxonomy of supported input formats."""from__future__importannotationsfromcollections.abcimportMappingfromdatetimeimportdate,datetimefromdecimalimportDecimalfromenumimportStrEnumfrompathlibimportPathfromtypesimportMappingProxyTypefrompydanticimportBaseModel,Field,field_serializer,field_validatorfrom...coreimportSTRICT_FROZEN_CONFIGas_STRICT_FROZENfrom...core.errorsimportCoreValidationErrorfrom...core.timeimportvalidate_utc_awarefrom._errorsimportTransactionValidationError
[docs]classSourceFormat(StrEnum):"""Closed taxonomy of supported raw-transaction input formats. Attributes: CSV: Bank statement CSV export. XLSX: Bank statement Excel workbook. OFX: Open Financial Exchange feed. PDF: PDF statement (parsed text layer). MANUAL: Hand-entered transaction. """CSV="csv"XLSX="xlsx"OFX="ofx"PDF="pdf"MANUAL="manual"
[docs]classRawProvenance(BaseModel):"""Per-row provenance pinned to one :class:`RawTransaction`. Attributes: source_path: Basename of the source file (the filename only, never a resolved absolute path). The file's content identity is carried by ``source_sha256``; storing only the basename keeps provenance human-readable without baking a host-specific absolute path into the persisted and exported record (which would leak the operator's directory layout and mutate across operating systems on rehydration). source_sha256: 64-character lowercase hex SHA-256 digest of the source file. source_row_index: One-based row index within the source file. source_format: Closed :class:`SourceFormat` discriminator. ingested_at: Timezone-aware UTC timestamp of the ingest run. provider_name: Non-blank logical name of the upstream financial provider. """model_config=_STRICT_FROZENsource_path:Pathsource_sha256:str=Field(min_length=64,max_length=64)source_row_index:int=Field(ge=1)source_format:SourceFormatingested_at:datetimeprovider_name:str=Field(min_length=1)@field_validator("source_path")@classmethoddef_basename_source_path(cls,value:Path)->Path:"""Reduce ``source_path`` to its basename. The persisted/exported record must not carry a resolved absolute path: it would leak the operator's directory layout and, because the prior ``.resolve()`` re-ran on rehydration, mutate a POSIX-authored path into a malformed Windows path (and vice versa), breaking strict cross-OS roundtrip equality. The basename is OS-neutral and idempotent: extracting it at ingest runs on the authoring host where the separator matches, and re-validating a stored bare filename is a no-op on every platform. """name=value.name# A path with no filename component (e.g. a bare directory) keeps its# string form rather than collapsing to an empty name.returnPath(name)ifnameelsevalue@field_validator("source_sha256")@classmethoddef_normalize_sha256(cls,value:str)->str:"""Lowercase, strip, and assert ``source_sha256`` is 64 hex chars."""normalized=value.strip().lower()iflen(normalized)!=64orany(charnotin"0123456789abcdef"forcharinnormalized):raiseTransactionValidationError("source_sha256 must be a 64-character lowercase hex digest")returnnormalized@field_validator("ingested_at")@classmethoddef_require_aware_timestamp(cls,value:datetime)->datetime:"""Reject naive timestamps; ingest must record UTC offsets."""try:returnvalidate_utc_aware(value)exceptCoreValidationErrorasexc:raiseTransactionValidationError(str(exc))fromexc@field_validator("provider_name")@classmethoddef_trim_provider_name(cls,value:str)->str:"""Trim ``provider_name``; reject the empty string."""trimmed=value.strip()ifnottrimmed:raiseTransactionValidationError("provider_name must not be blank")returntrimmed
[docs]classRawTransaction(BaseModel):"""Verbatim per-row transaction record emitted by an ingest parser. Attributes: provider_transaction_id: Provider-assigned native identifier; never normalised beyond a strip + non-blank check. This is the bank/feed's own id for the row, distinct from the content-addressed :attr:`domain.transactions.Transaction.transaction_id` hash the domain derives from it. booked_date: Date the transaction posted to the account. value_date: Optional value date; falls back to ``booked_date`` when ``None``. amount: Non-negative magnitude :class:`decimal.Decimal` in :attr:`currency`. Flow direction is carried solely by :attr:`domain.transactions.Transaction.direction`; the sign is never stored on the amount. currency: Three-letter ISO 4217 currency code, uppercase. counterparty: Optional counterparty descriptor; trimmed and collapsed to ``None`` when blank. description: Non-blank narrative. provenance: Per-row :class:`RawProvenance` metadata. raw_fields: Frozen mapping of original source columns to stringified values, preserved verbatim for audit. """model_config=_STRICT_FROZENprovider_transaction_id:str=Field(min_length=1)booked_date:datevalue_date:date|None=Noneamount:Decimalcurrency:str=Field(min_length=3,max_length=3)counterparty:str|None=Nonedescription:str=Field(min_length=1)provenance:RawProvenanceraw_fields:Mapping[str,str]@field_validator("provider_transaction_id","description")@classmethoddef_reject_blank_strings(cls,value:str)->str:"""Trim and reject blank strings on identifier / narrative fields."""trimmed=value.strip()ifnottrimmed:raiseTransactionValidationError("field must not be blank")returntrimmed@field_validator("amount")@classmethoddef_reject_negative_amount(cls,value:Decimal)->Decimal:"""Reject a negative ``amount``; the stored magnitude is non-negative. Flow direction is carried solely by :attr:`domain.transactions.Transaction.direction`; the sign is never stored on the amount. This gate fires on both the import and the manual construction paths because every transaction wraps one :class:`RawTransaction`. """ifvalue<Decimal("0"):raiseTransactionValidationError("amount must be a non-negative magnitude; flow is carried by direction, not by sign",)returnvalue@field_validator("currency")@classmethoddef_normalize_currency(cls,value:str)->str:"""Uppercase and assert ``currency`` is a three-letter ISO 4217 code."""normalized=value.strip().upper()iflen(normalized)!=3ornotnormalized.isalpha():raiseTransactionValidationError("currency must be a three-letter ISO 4217 code")returnnormalized@field_validator("counterparty")@classmethoddef_normalize_counterparty(cls,value:str|None)->str|None:"""Trim ``counterparty`` and collapse blank strings to ``None``."""ifvalueisNone:returnNonetrimmed=value.strip()returntrimmedorNone@field_validator("raw_fields")@classmethoddef_freeze_raw_fields(cls,value:Mapping[str,str])->Mapping[str,str]:"""Freeze ``raw_fields`` into an immutable mapping with stringified entries."""returnMappingProxyType({str(key):str(raw)forkey,rawinvalue.items()})@field_serializer("raw_fields")def_serialize_raw_fields(self,value:Mapping[str,str])->dict[str,str]:"""Serialise the immutable mapping back to a JSON-friendly dict."""returndict(value)@propertydefdisplay_counterparty(self)->str:"""Return :attr:`counterparty` coerced to an empty string when absent. CSV importers may produce :class:`RawTransaction` rows whose counterparty column is blank; :func:`_normalize_counterparty` collapses those to ``None`` so the domain model carries the true absent signal. The CLI ledger surface (list / view / payable / collectible) renders the field through a typed payload that expects ``str`` rather than ``str | None`` so the display layer can keep its column contract uniform. Routing the coercion through this property removes three identical ``raw.counterparty or ""`` call-site repeats and centralises the decision so future display tweaks (placeholder strings, ellipses) land in one place. """returnself.counterpartyor""