Finance-Accounts/ar-aging-app/backend/tests/test_fx_service.py

284 lines
12 KiB
Python

"""FX rate service: orientation (the #1 risk), confirmation withdrawal, caching, failure.
The provider is mocked — no network in tests. Frankfurter with base=USD returns LOCAL per
USD; the app stores USD per LOCAL (usd = local * rate), so the service must invert."""
from __future__ import annotations
import datetime as dt
import pytest
from fastapi.testclient import TestClient
from app.api.main import app
from app.db import models
from app.db.database import SessionLocal, init_db
from app.services import fx_service
@pytest.fixture()
def fake_frankfurter(monkeypatch):
"""Replace the HTTP layer with a fixture: 1 USD = 0.85 EUR on 2029-06-29 (Friday)."""
calls = {"n": 0, "urls": []}
def fake_get(url: str) -> dict:
calls["n"] += 1
calls["urls"].append(url)
if ".." in url: # time-series request
return {"base": "USD", "rates": {
"2029-06-28": {"EUR": 0.86},
"2029-06-29": {"EUR": 0.85},
}}
return {"base": "USD", "date": "2029-06-29", "rates": {"EUR": 0.85}}
monkeypatch.setattr(fx_service, "_http_get_json", fake_get)
return calls
def _session_with_fx(c, name: str, month_end: str) -> int:
sid = c.post("/api/sessions", json={"name": name, "month_end_date": month_end,
"allow_duplicate": True}).json()["id"]
db = SessionLocal()
try:
db.add(models.FxRate(session_id=sid, marketplace="Germany", currency="EUR",
rate=1.185665, source="default (Jan-26 workbook)",
confirmed_by="Old Confirmer", confirmed_month="2026-01"))
db.add(models.FxRate(session_id=sid, marketplace="USA", currency="USD", rate=1.0,
source="default"))
db.commit()
finally:
db.close()
return sid
def test_fetch_inverts_to_usd_per_local_and_clears_confirmation(fake_frankfurter):
init_db()
with TestClient(app) as c:
sid = _session_with_fx(c, "fx orient", "2029-06-30")
r = c.post(f"/api/sessions/{sid}/fx/fetch")
assert r.status_code == 200, r.text
body = r.json()
de = next(u for u in body["updated"] if u["marketplace"] == "Germany")
# 1 USD = 0.85 EUR -> 1 EUR = 1/0.85 USD. The inverse (0.85) would mis-state
# every EUR receivable — this assertion pins the orientation.
assert de["rate"] == pytest.approx(1 / 0.85, abs=1e-6)
usa = next(u for u in body["updated"] if u["marketplace"] == "USA")
assert usa["rate"] == 1.0
assert "frankfurter" in body["source"]
assert "2029-06-29" in body["source"] # the provider's banking day
rows = c.get(f"/api/sessions/{sid}/fx").json()
de_row = next(x for x in rows if x["marketplace"] == "Germany")
assert "frankfurter" in de_row["source"]
db = SessionLocal()
try:
fx = db.query(models.FxRate).filter_by(session_id=sid,
marketplace="Germany").first()
# A fetched rate is a suggestion: the old confirmation no longer applies (C5).
assert fx.confirmed_by == "" and fx.confirmed_month == ""
finally:
db.close()
def test_second_fetch_is_served_from_cache(fake_frankfurter):
init_db()
with TestClient(app) as c:
sid = _session_with_fx(c, "fx cache", "2029-06-30")
assert c.post(f"/api/sessions/{sid}/fx/fetch").status_code == 200
n_after_first = fake_frankfurter["n"]
r = c.post(f"/api/sessions/{sid}/fx/fetch")
assert r.status_code == 200
assert fake_frankfurter["n"] == n_after_first # no second HTTP call
assert "(cached)" in r.json()["source"]
def test_provider_failure_is_a_502_never_a_silent_default(monkeypatch):
init_db()
def boom(url: str) -> dict:
raise fx_service.FxProviderError("provider down")
monkeypatch.setattr(fx_service, "_http_get_json", boom)
with TestClient(app) as c:
sid = _session_with_fx(c, "fx down", "2029-08-31")
r = c.post(f"/api/sessions/{sid}/fx/fetch")
assert r.status_code == 502
assert "manually" in r.json()["detail"]
# The stored rate is untouched — not overwritten with anything.
de = next(x for x in c.get(f"/api/sessions/{sid}/fx").json()
if x["marketplace"] == "Germany")
assert de["rate"] == 1.185665
def test_fetch_without_processing_explains_the_precondition():
init_db()
with TestClient(app) as c:
sid = c.post("/api/sessions", json={"name": "fx bare", "month_end_date": "2029-09-30",
"allow_duplicate": True}).json()["id"]
r = c.post(f"/api/sessions/{sid}/fx/fetch")
assert r.status_code == 502
assert "process" in r.json()["detail"].lower()
def test_daily_fetch_fills_fx_rates_daily(fake_frankfurter):
init_db()
with TestClient(app) as c:
sid = _session_with_fx(c, "fx daily", "2029-06-30")
r = c.post(f"/api/sessions/{sid}/fx/fetch-daily",
json={"date_from": "2029-06-28", "date_to": "2029-06-29"})
assert r.status_code == 200, r.text
assert r.json()["saved"] == 2 # two banking days, EUR only
db = SessionLocal()
try:
rows = db.query(models.FxRateDaily).filter_by(
session_id=sid, marketplace="Germany").all()
by_date = {row.rate_date: row for row in rows}
assert by_date[dt.date(2029, 6, 29)].rate == pytest.approx(1 / 0.85, abs=1e-6)
assert by_date[dt.date(2029, 6, 28)].rate == pytest.approx(1 / 0.86, abs=1e-6)
assert all(row.source == "frankfurter" for row in rows)
finally:
db.close()
def test_daily_fetch_defaults_to_the_transaction_span(fake_frankfurter):
"""No explicit range → the provider is asked for the span the files actually cover
(earliest dated transaction through month-end), so pre-month rows convert at their
own date's rate too."""
init_db()
with TestClient(app) as c:
sid = _session_with_fx(c, "fx daily span", "2029-06-30")
db = SessionLocal()
try:
db.add(models.Transaction(session_id=sid, marketplace="Germany",
posted_date=dt.date(2029, 5, 20), total=100.0))
db.add(models.Transaction(session_id=sid, marketplace="Germany",
posted_date=dt.date(2029, 6, 12), total=50.0))
db.commit()
finally:
db.close()
r = c.post(f"/api/sessions/{sid}/fx/fetch-daily", json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["date_from"] == "2029-05-20" # earliest dated transaction
assert body["date_to"] == "2029-06-30" # through month-end
series_url = next(u for u in fake_frankfurter["urls"] if ".." in u)
assert "2029-05-20..2029-06-30" in series_url
def test_auto_seed_preserves_manual_daily_overrides(fake_frankfurter):
"""The automatic post-processing seed refreshes provider rows but never clobbers a
rate a person typed; only the explicit Fetch button replaces manual overrides."""
init_db()
with TestClient(app) as c:
sid = _session_with_fx(c, "fx auto manual", "2029-06-30")
db = SessionLocal()
try:
db.add(models.FxRateDaily(session_id=sid, marketplace="Germany",
rate_date=dt.date(2029, 6, 29), rate=2.0,
source="manual"))
db.commit()
s = db.get(models.Session, sid)
out = fx_service.auto_seed_daily_fx(db, s)
assert "error" not in out
rows = {r.rate_date: r for r in db.query(models.FxRateDaily).filter_by(
session_id=sid, marketplace="Germany")}
assert rows[dt.date(2029, 6, 29)].rate == 2.0 # manual kept
assert rows[dt.date(2029, 6, 29)].source == "manual"
assert rows[dt.date(2029, 6, 28)].rate == pytest.approx(1 / 0.86, abs=1e-6)
assert rows[dt.date(2029, 6, 28)].source == "frankfurter"
finally:
db.close()
def test_auto_seed_is_advisory_when_the_provider_is_down(monkeypatch):
init_db()
def boom(url: str) -> dict:
raise fx_service.FxProviderError("provider down")
monkeypatch.setattr(fx_service, "_http_get_json", boom)
with TestClient(app) as c:
sid = _session_with_fx(c, "fx auto down", "2029-06-30")
db = SessionLocal()
try:
s = db.get(models.Session, sid)
out = fx_service.auto_seed_daily_fx(db, s) # must not raise
assert "provider down" in out["error"]
finally:
db.close()
def test_auto_seed_skips_usd_only_closings(monkeypatch):
"""A USD-only close has nothing to fetch — no HTTP request, no warning."""
init_db()
def no_network(url: str) -> dict:
raise AssertionError(f"unexpected FX fetch for a USD-only closing: {url}")
monkeypatch.setattr(fx_service, "_http_get_json", no_network)
with TestClient(app) as c:
sid = c.post("/api/sessions", json={"name": "fx usd only",
"month_end_date": "2029-06-30",
"allow_duplicate": True}).json()["id"]
db = SessionLocal()
try:
db.add(models.FxRate(session_id=sid, marketplace="USA", currency="USD",
rate=1.0, source="default"))
db.commit()
s = db.get(models.Session, sid)
out = fx_service.auto_seed_daily_fx(db, s)
assert out.get("skipped")
finally:
db.close()
def test_processing_auto_seeds_daily_rates_from_the_api(tmp_path, monkeypatch):
"""End-to-end: processing fetches the provider's daily fixings for the file's span,
and the daily FX table converts each date at the rate effective on it — the exact
fixing when one exists, the previous banking day's fixing otherwise."""
init_db()
from app.services import jobs
from tests.test_multimarket import _make_dutch_file
monkeypatch.setattr(jobs, "FX_AUTO_DAILY", True)
urls: list[str] = []
def fake_get(url: str) -> dict:
urls.append(url)
assert ".." in url, "auto-seed must use a single series request"
return {"base": "USD", "rates": {
"2026-01-02": {"EUR": 0.8},
"2026-01-15": {"EUR": 0.9},
}}
monkeypatch.setattr(fx_service, "_http_get_json", fake_get)
with TestClient(app) as c:
sid = c.post("/api/sessions", json={
"name": "auto daily fx", "reporting_month": "2026-01",
"month_end_date": "2026-01-31", "clearing_lag_days": 2,
"allow_duplicate": True}).json()["id"]
path = tmp_path / "Netherlands Amazon Transactions January, 2026.xlsx"
_make_dutch_file(str(path))
with open(path, "rb") as fh:
assert c.post(f"/api/sessions/{sid}/files",
files={"files": (path.name, fh)}).status_code == 200
assert c.post(f"/api/sessions/{sid}/process").status_code == 200
assert c.get(f"/api/sessions/{sid}/status").json()["status"] in (
"processed", "blocked") # blocked = unconfirmed C5, fine
# One series request, widened to the whole reporting month.
assert any("2026-01-01..2026-01-31" in u for u in urls)
fxd = c.get(f"/api/sessions/{sid}/fx-daily?marketplace=Netherlands").json()
by_date = {r["date"]: r for r in fxd["rows"]}
# Jan 2 converts at Jan 2's fixing (1 USD = 0.80 EUR → 1.25 USD per EUR)…
assert by_date["2026-01-02"]["rate"] == pytest.approx(1.25, abs=1e-6)
assert by_date["2026-01-02"]["source"] == "frankfurter"
# …Jan 6 has no fixing, so the previous banking day's rate is in effect…
assert by_date["2026-01-06"]["rate"] == pytest.approx(1.25, abs=1e-6)
assert "2026-01-02" in by_date["2026-01-06"]["source"]
# …and Jan 20 carries Jan 15's fixing.
assert by_date["2026-01-20"]["rate"] == pytest.approx(1 / 0.9, abs=1e-6)
assert "2026-01-15" in by_date["2026-01-20"]["source"]