broker-sync/broker_sync/models.py
Viktor Barzin a66ef189f6 Add SyncRecordStore for authoritative dedup
Context
-------
Wealthfolio's activity `notes` field is user-editable via the UI, so
using it as the dedup key would let a single note-edit in Wealthfolio
cause the next sync to create a duplicate. Stress-testing the plan
flagged this as the top structural risk.

This change
-----------
- SQLite-backed store at `/data/broker_sync.db` in production; keyed on
  (provider, account, external_id) so each provider's id space is
  scoped to its own account.
- `INSERT OR IGNORE` makes record() idempotent — second call with the
  same key is a no-op and preserves the original wealthfolio_activity_id
  plus first_seen timestamp.
- `filter_new()` is the integration point: provider fetches activities,
  hands them to the store, gets back only the unseen subset to submit
  to the Wealthfolio sink.
- Wealthfolio activity id returned by the API is persisted alongside
  each record so the HMRC FX reconciliation job can later PATCH the
  original activity rather than creating a new one.

Test plan
---------
## Automated
- poetry run pytest tests/test_dedup.py -v  →  6 passed
- poetry run mypy broker_sync tests  →  Success: no issues found in 6 source files
- poetry run ruff check .  →  All checks passed!

## Manual Verification
Not applicable for this layer — full end-to-end verification happens
once a provider + sink land (Phase 1 Trading212 and the auth spike).
2026-04-17 19:17:12 +00:00

104 lines
2.7 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
class AccountType(StrEnum):
ISA = "ISA"
SIPP = "SIPP"
GIA = "GIA"
LISA = "LISA"
JISA = "JISA"
WORKPLACE_PENSION = "WORKPLACE_PENSION"
class ActivityType(StrEnum):
BUY = "BUY"
SELL = "SELL"
DIVIDEND = "DIVIDEND"
INTEREST = "INTEREST"
DEPOSIT = "DEPOSIT"
WITHDRAWAL = "WITHDRAWAL"
TRANSFER_IN = "TRANSFER_IN"
TRANSFER_OUT = "TRANSFER_OUT"
CONVERSION_IN = "CONVERSION_IN"
CONVERSION_OUT = "CONVERSION_OUT"
FEE = "FEE"
TAX = "TAX"
class FxRateSource(StrEnum):
ECB_LIVE = "ECB_LIVE"
HMRC_MONTHLY = "HMRC_MONTHLY"
_QTY_PRICE_TYPES = {ActivityType.BUY, ActivityType.SELL}
_AMOUNT_TYPES = {
ActivityType.DIVIDEND,
ActivityType.INTEREST,
ActivityType.DEPOSIT,
ActivityType.WITHDRAWAL,
ActivityType.FEE,
ActivityType.TAX,
ActivityType.TRANSFER_IN,
ActivityType.TRANSFER_OUT,
ActivityType.CONVERSION_IN,
ActivityType.CONVERSION_OUT,
}
@dataclass
class Account:
id: str
name: str
account_type: AccountType
currency: str
provider: str
@dataclass
class Activity:
external_id: str
account_id: str
account_type: AccountType
date: datetime
activity_type: ActivityType
currency: str
symbol: str | None = None
quantity: Decimal | None = None
unit_price: Decimal | None = None
amount: Decimal | None = None
fee: Decimal = field(default_factory=lambda: Decimal("0"))
amount_gbp: Decimal | None = None
fx_rate_gbp: Decimal | None = None
fx_rate_source: FxRateSource | None = None
notes: str | None = None
def __post_init__(self) -> None:
if self.activity_type in _QTY_PRICE_TYPES and (self.quantity is None
or self.unit_price is None):
raise ValueError(f"{self.activity_type} requires quantity and unit_price")
if self.activity_type in _AMOUNT_TYPES and self.amount is None:
raise ValueError(f"{self.activity_type} requires amount")
def to_wealthfolio_csv_row(self) -> dict[str, str]:
return {
"date": self.date.isoformat(),
"activity_type": str(self.activity_type),
"symbol": self.symbol or "",
"quantity": _fmt(self.quantity),
"unit_price": _fmt(self.unit_price),
"currency": self.currency,
"fee": _fmt(self.fee),
"amount": _fmt(self.amount),
"notes": self.notes or "",
}
def _fmt(v: Decimal | None) -> str:
if v is None:
return ""
return format(v, "f")