first ruggedy draft

This commit is contained in:
2026-06-28 13:48:15 +02:00
parent c916f64fcb
commit 1053f6a2a3
78 changed files with 20905 additions and 2 deletions

View File

@@ -0,0 +1,41 @@
from .enums import (
AccountType,
EventObservationRelation,
FinancialEventStatus,
FinancialEventType,
ImportConnectionStatus,
ImportConnectionType,
ObservationStatus,
PaymentLineStatus,
PaymentStatus,
PlanningStatus,
ProjectStatus,
ReconciliationStatus,
RecurringCommitmentStatus,
RecurringInterval,
RelationType,
SettlementStatus,
SourceType,
VisibilityMode,
)
__all__ = [
"AccountType",
"EventObservationRelation",
"FinancialEventStatus",
"FinancialEventType",
"ImportConnectionStatus",
"ImportConnectionType",
"ObservationStatus",
"PaymentLineStatus",
"PaymentStatus",
"PlanningStatus",
"ProjectStatus",
"ReconciliationStatus",
"RecurringCommitmentStatus",
"RecurringInterval",
"RelationType",
"SettlementStatus",
"SourceType",
"VisibilityMode",
]

View File

@@ -0,0 +1,182 @@
from enum import StrEnum
class AccountType(StrEnum):
CHECKING = "checking"
SHARED_CHECKING = "shared_checking"
PAYPAL = "paypal"
CREDIT_CARD = "credit_card"
KLARNA = "klarna"
CASH = "cash"
LIABILITY = "liability"
SAVINGS = "savings"
OTHER = "other"
class VisibilityMode(StrEnum):
PRIVATE = "private"
SHARED_SUMMARY = "shared_summary"
SHARED_FULL = "shared_full"
SETTLEMENT_ONLY = "settlement_only"
class SourceType(StrEnum):
MANUAL = "manual"
BANK_CSV = "bank_csv"
FINTS = "fints"
PSD2_AGGREGATOR = "psd2_aggregator"
PAYPAL_API = "paypal_api"
PAYPAL_CSV = "paypal_csv"
PAYPAL_EMAIL = "paypal_email"
KLARNA_CSV = "klarna_csv"
KLARNA_EMAIL = "klarna_email"
ORDER_CONFIRMATION_EMAIL = "order_confirmation_email"
IMAP_EMAIL = "imap_email"
PDF_INVOICE = "pdf_invoice"
RECEIPT_PHOTO = "receipt_photo"
LOCAL_AGENT = "local_agent"
API = "api"
class ObservationStatus(StrEnum):
NEW = "new"
PARSED = "parsed"
CANDIDATE_CREATED = "candidate_created"
IGNORED = "ignored"
DUPLICATE_SOURCE = "duplicate_source"
ERROR = "error"
class FinancialEventType(StrEnum):
EXPENSE = "expense"
INCOME = "income"
TRANSFER = "transfer"
LIABILITY_PAYMENT = "liability_payment"
PROMISED_PAYMENT = "promised_payment"
REFUND = "refund"
REIMBURSEMENT = "reimbursement"
FEE = "fee"
INTEREST = "interest"
ADJUSTMENT = "adjustment"
class FinancialEventStatus(StrEnum):
CANDIDATE = "candidate"
ANNOUNCED = "announced"
OUTSTANDING = "outstanding"
CONFIRMED = "confirmed"
BOOKED = "booked"
SETTLED = "settled"
CANCELLED = "cancelled"
IGNORED = "ignored"
class EventObservationRelation(StrEnum):
EVIDENCE_FOR = "evidence_for"
DUPLICATE_SOURCE = "duplicate_source"
IMPORTED_FROM = "imported_from"
PARSER_SOURCE = "parser_source"
STATEMENT_SOURCE = "statement_source"
class RelationType(StrEnum):
DUPLICATE_OF = "duplicate_of"
SAME_ECONOMIC_EVENT_AS = "same_economic_event_as"
SETTLES = "settles"
FUNDS = "funds"
REIMBURSES = "reimburses"
REFUNDS = "refunds"
REVERSES = "reverses"
REPLACES_PENDING_AUTHORIZATION = "replaces_pending_authorization"
PART_OF_STATEMENT = "part_of_statement"
class ProjectStatus(StrEnum):
IDEA = "idea"
PLANNING = "planning"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
ARCHIVED = "archived"
class PlanningStatus(StrEnum):
IDEA = "idea"
ESTIMATED = "estimated"
COMMITTED = "committed"
CANCELLED = "cancelled"
class PaymentStatus(StrEnum):
UNPAID = "unpaid"
PARTIALLY_PAID = "partially_paid"
PAID = "paid"
REFUNDED = "refunded"
PARTIALLY_REFUNDED = "partially_refunded"
class ReconciliationStatus(StrEnum):
NO_EVIDENCE = "no_evidence"
EMAIL_SEEN = "email_seen"
RECEIPT_SEEN = "receipt_seen"
BANK_MATCHED = "bank_matched"
STATEMENT_MATCHED = "statement_matched"
MANUALLY_CONFIRMED = "manually_confirmed"
class SettlementStatus(StrEnum):
NOT_SHARED = "not_shared"
UNSETTLED = "unsettled"
PARTIALLY_SETTLED = "partially_settled"
SETTLED = "settled"
IGNORED = "ignored"
class PaymentLineStatus(StrEnum):
EXPECTED = "expected"
SCHEDULED = "scheduled"
PAID = "paid"
RECONCILED = "reconciled"
FAILED = "failed"
CANCELLED = "cancelled"
class RecurringInterval(StrEnum):
MONTHLY = "monthly"
YEARLY = "yearly"
QUARTERLY = "quarterly"
WEEKLY = "weekly"
CUSTOM = "custom"
class RecurringCommitmentStatus(StrEnum):
ACTIVE = "active"
PAUSED = "paused"
CANCELLED = "cancelled"
UNKNOWN = "unknown"
class PromisedPaymentStatus(StrEnum):
EXPECTED = "expected"
SCHEDULED = "scheduled"
DEBITED = "debited"
RECONCILED = "reconciled"
FAILED = "failed"
CANCELLED = "cancelled"
class ImportConnectionType(StrEnum):
FINTS = "fints"
PSD2_AGGREGATOR = "psd2_aggregator"
IMAP = "imap"
PAYPAL_API = "paypal_api"
CSV_FOLDER = "csv_folder"
LOCAL_FOLDER = "local_folder"
LOCAL_AGENT = "local_agent"
class ImportConnectionStatus(StrEnum):
ACTIVE = "active"
NEEDS_AUTH = "needs_auth"
ERROR = "error"
DISABLED = "disabled"

View File

@@ -0,0 +1,8 @@
from .fingerprints import compute_normalized_transaction_fingerprint, compute_raw_observation_hash
from .scoring import score_event_match
__all__ = [
"compute_normalized_transaction_fingerprint",
"compute_raw_observation_hash",
"score_event_match",
]

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import hashlib
import json
import re
from datetime import date, datetime
from decimal import Decimal
from typing import Any
def _stable_default(value: Any) -> str:
if isinstance(value, datetime | date):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
return str(value)
def _stable_json(value: Any) -> str:
return json.dumps(
value, default=_stable_default, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
def compute_raw_observation_hash(
*,
source_type: str,
source_reference: str | None = None,
raw_text: str | None = None,
raw_payload: dict[str, Any] | None = None,
) -> str:
payload = {
"source_reference": source_reference,
"source_type": source_type,
"raw_payload": raw_payload or {},
"raw_text": raw_text or "",
}
return hashlib.sha256(_stable_json(payload).encode("utf-8")).hexdigest()
def normalize_counterparty(value: str | None) -> str:
if not value:
return ""
compact = re.sub(r"[^a-z0-9]+", " ", value.lower())
return re.sub(r"\s+", " ", compact).strip()
def compute_normalized_transaction_fingerprint(
*,
amount: Decimal | str | int | float,
currency: str,
event_date: date | str,
merchant: str | None = None,
counterparty: str | None = None,
external_id: str | None = None,
) -> str:
normalized = {
"amount": str(Decimal(str(amount)).quantize(Decimal("0.01"))),
"currency": currency.upper(),
"event_date": event_date.isoformat() if isinstance(event_date, date) else event_date,
"party": normalize_counterparty(merchant or counterparty),
"external_id": external_id or "",
}
return hashlib.sha256(_stable_json(normalized).encode("utf-8")).hexdigest()

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from datetime import date
from decimal import Decimal
from difflib import SequenceMatcher
from .fingerprints import normalize_counterparty
def _date_distance(a: date, b: date) -> int:
return abs((a - b).days)
def score_event_match(
*,
amount_a: Decimal,
amount_b: Decimal,
currency_a: str,
currency_b: str,
date_a: date,
date_b: date,
party_a: str | None,
party_b: str | None,
) -> float:
if currency_a.upper() != currency_b.upper():
return 0.0
amount_score = (
1.0
if amount_a == amount_b
else max(0.0, 1.0 - (abs(amount_a - amount_b) / max(abs(amount_a), Decimal("1"))))
)
days = _date_distance(date_a, date_b)
date_score = 1.0 if days == 0 else max(0.0, 1.0 - (days / 14))
party_score = SequenceMatcher(
None,
normalize_counterparty(party_a),
normalize_counterparty(party_b),
).ratio()
return round((amount_score * 0.45) + (date_score * 0.30) + (party_score * 0.25), 4)