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
179 lines
6.3 KiB
Python
179 lines
6.3 KiB
Python
"""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),
|
|
))
|