first ruggedy draft
This commit is contained in:
41
packages/domain/mousehold_domain/__init__.py
Normal file
41
packages/domain/mousehold_domain/__init__.py
Normal 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",
|
||||
]
|
||||
182
packages/domain/mousehold_domain/enums.py
Normal file
182
packages/domain/mousehold_domain/enums.py
Normal 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"
|
||||
8
packages/matching/mousehold_matching/__init__.py
Normal file
8
packages/matching/mousehold_matching/__init__.py
Normal 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",
|
||||
]
|
||||
64
packages/matching/mousehold_matching/fingerprints.py
Normal file
64
packages/matching/mousehold_matching/fingerprints.py
Normal 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()
|
||||
40
packages/matching/mousehold_matching/scoring.py
Normal file
40
packages/matching/mousehold_matching/scoring.py
Normal 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)
|
||||
Reference in New Issue
Block a user