sync: ActualBudget Meta deposit overlay (Phase C)
Adds daily sync of Meta payroll deposits from ActualBudget into payslip_ingest.external_meta_deposits, enabling the dashboard to overlay bank deposits against payslip net_pay and surface parser drift on net. - Migration 0007: new table external_meta_deposits, unique on actualbudget_tx_id, indexed on deposit_date. - payslip_ingest.sync.actualbudget: narrow client for the jhonderson/actual-http-api sidecar (list accounts + transactions). Filters on payee regex (META|FACEBOOK, word-boundary). Idempotent upsert — ON CONFLICT DO NOTHING on actualbudget_tx_id. Surfaces clear error if the transactions endpoint is missing so the operator can switch to a SQLite-mount fallback. - CLI command: `python -m payslip_ingest sync-meta-deposits` driven by 4 env vars (ACTUALBUDGET_HTTP_API_URL, API_KEY, ENCRYPTION_PASSWORD, BUDGET_SYNC_ID). - Tests: 5 — regex positive/negative, full sync insert, idempotency, 404-endpoint failure mode. Part of: code-860
This commit is contained in:
parent
3b9c69bfd3
commit
08f28ad581
6 changed files with 492 additions and 0 deletions
179
tests/test_sync_actualbudget.py
Normal file
179
tests/test_sync_actualbudget.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Unit tests for the ActualBudget sync client."""
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
|
||||
from payslip_ingest.db import Base, ExternalMetaDeposit
|
||||
from payslip_ingest.sync.actualbudget import (
|
||||
META_PAYEE_RE,
|
||||
ActualBudgetClient,
|
||||
ActualBudgetError,
|
||||
sync_meta_deposits,
|
||||
)
|
||||
|
||||
BASE_URL = "http://budget-http-api-viktor.actualbudget.svc.cluster.local"
|
||||
BUDGET_ID = "sync-id-viktor"
|
||||
|
||||
|
||||
def test_meta_payee_regex_matches_canonical() -> None:
|
||||
assert META_PAYEE_RE.search("META PLATFORMS IRELAND")
|
||||
assert META_PAYEE_RE.search("Facebook UK Ltd")
|
||||
assert META_PAYEE_RE.search("meta")
|
||||
|
||||
|
||||
def test_meta_payee_regex_rejects_other() -> None:
|
||||
assert not META_PAYEE_RE.search("Amazon")
|
||||
assert not META_PAYEE_RE.search("Metamask") # requires word boundary
|
||||
assert not META_PAYEE_RE.search("facebookish")
|
||||
|
||||
|
||||
def _register_sqlite_now(dbapi_conn: Any, _: Any) -> None:
|
||||
"""SQLite doesn't have now() — map it to datetime('now') so the Postgres
|
||||
`server_default=text("now()")` on the ORM models works in tests."""
|
||||
dbapi_conn.create_function("now", 0, lambda: datetime.now(UTC).isoformat(" "))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory() -> AsyncIterator[async_sessionmaker[Any]]:
|
||||
engine: AsyncEngine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
from sqlalchemy import event
|
||||
event.listen(engine.sync_engine, "connect", _register_sqlite_now)
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql("ATTACH DATABASE ':memory:' AS payslip_ingest")
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _mock_accounts() -> dict[str, Any]:
|
||||
return {"data": [{"id": "account-1", "name": "Barclays Personal"}]}
|
||||
|
||||
|
||||
def _mock_transactions() -> dict[str, Any]:
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "tx-001",
|
||||
"date": "2026-03-28",
|
||||
"amount": 650000, # 6500.00 GBP in cents
|
||||
"payee_name": "META PLATFORMS IRELAND",
|
||||
"notes": "Salary March",
|
||||
},
|
||||
{
|
||||
"id": "tx-002",
|
||||
"date": "2026-02-28",
|
||||
"amount": 620000,
|
||||
"payee_name": "Facebook UK Ltd",
|
||||
"notes": None,
|
||||
},
|
||||
{
|
||||
"id": "tx-003",
|
||||
"date": "2026-03-15",
|
||||
"amount": 12000,
|
||||
"payee_name": "Tesco",
|
||||
"notes": "groceries",
|
||||
},
|
||||
{
|
||||
"id": "tx-004",
|
||||
"date": "2026-03-01",
|
||||
"amount": -5000, # outgoing
|
||||
"payee_name": "META (refund out)",
|
||||
"notes": None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_sync_meta_deposits_inserts_matches(
|
||||
session_factory: async_sessionmaker[Any],
|
||||
) -> None:
|
||||
respx.get(f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts").mock(
|
||||
return_value=httpx.Response(200, json=_mock_accounts()))
|
||||
respx.get(
|
||||
f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts/account-1/transactions").mock(
|
||||
return_value=httpx.Response(200, json=_mock_transactions()))
|
||||
|
||||
async with ActualBudgetClient(BASE_URL, "k", "pwd", BUDGET_ID) as client:
|
||||
result = await sync_meta_deposits(client, session_factory)
|
||||
|
||||
assert result.accounts_scanned == 1
|
||||
assert result.transactions_fetched == 4
|
||||
assert result.meta_deposits_matched == 2
|
||||
assert result.inserted == 2
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (await session.execute(
|
||||
select(ExternalMetaDeposit).order_by(
|
||||
ExternalMetaDeposit.deposit_date))).scalars().all()
|
||||
assert len(rows) == 2
|
||||
assert rows[0].actualbudget_tx_id == "tx-002"
|
||||
assert rows[0].deposit_date == date(2026, 2, 28)
|
||||
assert rows[0].amount == Decimal("6200.00")
|
||||
assert rows[0].payee == "Facebook UK Ltd"
|
||||
assert rows[1].actualbudget_tx_id == "tx-001"
|
||||
assert rows[1].amount == Decimal("6500.00")
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_sync_meta_deposits_is_idempotent(
|
||||
session_factory: async_sessionmaker[Any],
|
||||
) -> None:
|
||||
"""Running twice inserts each tx only once."""
|
||||
respx.get(f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts").mock(
|
||||
return_value=httpx.Response(200, json=_mock_accounts()))
|
||||
respx.get(
|
||||
f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts/account-1/transactions").mock(
|
||||
return_value=httpx.Response(200, json=_mock_transactions()))
|
||||
|
||||
async with ActualBudgetClient(BASE_URL, "k", "pwd", BUDGET_ID) as client:
|
||||
first = await sync_meta_deposits(client, session_factory)
|
||||
second = await sync_meta_deposits(client, session_factory)
|
||||
|
||||
assert first.inserted == 2
|
||||
assert second.inserted == 0
|
||||
assert second.skipped_existing == 2
|
||||
|
||||
async with session_factory() as session:
|
||||
count = len((await session.execute(
|
||||
select(ExternalMetaDeposit.id))).scalars().all())
|
||||
assert count == 2
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_sync_surfaces_missing_endpoint_error(
|
||||
session_factory: async_sessionmaker[Any],
|
||||
) -> None:
|
||||
"""404 on transactions endpoint must raise — triggers SQLite fallback."""
|
||||
respx.get(f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts").mock(
|
||||
return_value=httpx.Response(200, json=_mock_accounts()))
|
||||
respx.get(
|
||||
f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts/account-1/transactions").mock(
|
||||
return_value=httpx.Response(404))
|
||||
|
||||
async with ActualBudgetClient(BASE_URL, "k", "pwd", BUDGET_ID) as client:
|
||||
with pytest.raises(ActualBudgetError, match="endpoint not found"):
|
||||
await sync_meta_deposits(client, session_factory)
|
||||
|
||||
|
||||
async def _insert_existing_deposit(
|
||||
session_factory: async_sessionmaker[Any],
|
||||
tx_id: str,
|
||||
) -> None:
|
||||
async with session_factory() as session, session.begin():
|
||||
session.add(
|
||||
ExternalMetaDeposit(
|
||||
actualbudget_tx_id=tx_id,
|
||||
deposit_date=date(2026, 3, 28),
|
||||
amount=Decimal("6500.00"),
|
||||
payee="META",
|
||||
memo=None,
|
||||
synced_at=datetime.now(UTC),
|
||||
))
|
||||
Loading…
Add table
Add a link
Reference in a new issue