feat: cross-account transfer detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 14:56:53 -05:00
parent a3fae3fa95
commit f538cad6ae
2 changed files with 184 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import datetime
from sqlalchemy.orm import Session
from src.models.transaction import Transaction
# Known transfer patterns
TRANSFER_PATTERNS = [
"CREDIT CRD EPAY",
"Payment Thank You",
"CAPITAL ONE TRANSFER",
"AMEX EPAYMENT",
"AMZ_STORECRD_PMT",
]
class TransferDetector:
def __init__(self, session: Session):
self.session = session
def detect(self, date_tolerance_days: int = 3) -> list[dict]:
"""Find matching transfer pairs across accounts."""
txns = self.session.query(Transaction).filter(
Transaction.is_transfer == False
).all()
# Separate positive and negative transactions
positives = [t for t in txns if float(t.amount) > 0]
negatives = [t for t in txns if float(t.amount) < 0]
pairs = []
matched_ids = set()
for neg in negatives:
if neg.id in matched_ids:
continue
# Check if this looks like a transfer
if not self._is_transfer_pattern(neg.description):
continue
neg_amount = abs(float(neg.amount))
for pos in positives:
if pos.id in matched_ids:
continue
if pos.account_id == neg.account_id:
continue # Must be different accounts
pos_amount = float(pos.amount)
if abs(pos_amount - neg_amount) > 0.01:
continue # Amounts must match
date_diff = abs((neg.date - pos.date).days)
if date_diff > date_tolerance_days:
continue # Dates must be close
pairs.append({
"outgoing_id": neg.id,
"incoming_id": pos.id,
"amount": neg_amount,
"date": neg.date,
})
matched_ids.add(neg.id)
matched_ids.add(pos.id)
break
return pairs
def mark_transfers(self) -> int:
"""Detect and mark transfer pairs."""
pairs = self.detect()
for pair in pairs:
outgoing = self.session.get(Transaction, pair["outgoing_id"])
incoming = self.session.get(Transaction, pair["incoming_id"])
if outgoing and incoming:
outgoing.is_transfer = True
incoming.is_transfer = True
outgoing.transfer_pair_id = incoming.id
incoming.transfer_pair_id = outgoing.id
self.session.commit()
return len(pairs)
def _is_transfer_pattern(self, description: str) -> bool:
desc_upper = description.upper()
return any(p.upper() in desc_upper for p in TRANSFER_PATTERNS)
+99
View File
@@ -0,0 +1,99 @@
import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from src.db import Base
from src.models import *
from src.services.transfer_detector import TransferDetector
def make_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return Session(engine)
def test_detect_matching_transfer():
session = make_session()
member = HouseholdMember(name="Andrew", relationship="self")
session.add(member)
session.flush()
checking = Account(name="Checking", institution="WF", account_type="checking", owner_id=member.id)
chase = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
session.add_all([checking, chase])
session.flush()
# Payment from checking to Chase
txn1 = Transaction(
date=datetime.date(2026, 1, 29), amount=-1461.35,
description="CHASE CREDIT CRD EPAY", raw_description="CHASE CREDIT CRD EPAY 260128 9077835526 ANDREW B CONLON",
account_id=checking.id,
)
txn2 = Transaction(
date=datetime.date(2026, 1, 28), amount=1461.35,
description="Payment Thank You - Web", raw_description="Payment Thank You - Web",
account_id=chase.id,
)
session.add_all([txn1, txn2])
session.commit()
detector = TransferDetector(session)
pairs = detector.detect()
assert len(pairs) == 1
assert pairs[0]["amount"] == 1461.35
def test_detect_marks_transfers():
session = make_session()
member = HouseholdMember(name="Andrew", relationship="self")
session.add(member)
session.flush()
checking = Account(name="Checking", institution="WF", account_type="checking", owner_id=member.id)
chase = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
session.add_all([checking, chase])
session.flush()
txn1 = Transaction(
date=datetime.date(2026, 1, 29), amount=-1461.35,
description="CHASE CREDIT CRD EPAY", account_id=checking.id,
)
txn2 = Transaction(
date=datetime.date(2026, 1, 28), amount=1461.35,
description="Payment Thank You - Web", account_id=chase.id,
)
session.add_all([txn1, txn2])
session.commit()
detector = TransferDetector(session)
detector.mark_transfers()
session.refresh(txn1)
session.refresh(txn2)
assert txn1.is_transfer is True
assert txn2.is_transfer is True
def test_no_false_positive_different_amounts():
session = make_session()
member = HouseholdMember(name="Andrew", relationship="self")
session.add(member)
session.flush()
checking = Account(name="Checking", institution="WF", account_type="checking", owner_id=member.id)
chase = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
session.add_all([checking, chase])
session.flush()
txn1 = Transaction(
date=datetime.date(2026, 1, 29), amount=-1461.35,
description="CHASE CREDIT CRD EPAY", account_id=checking.id,
)
txn2 = Transaction(
date=datetime.date(2026, 1, 28), amount=500.00,
description="Payment Thank You - Web", account_id=chase.id,
)
session.add_all([txn1, txn2])
session.commit()
detector = TransferDetector(session)
pairs = detector.detect()
assert len(pairs) == 0