From f46fc695626d27018a1a14f9631daaf467747cb8 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Tue, 25 Aug 2026 15:23:59 +0500 Subject: [PATCH] Convert at the transaction date's FX rate, auto-fetched from the API Processing now seeds fx_rates_daily from the provider (Frankfurter) over the closing's actual transaction span, and the AR Ledger / daily FX table convert each dated movement at the rate effective on its own date: exact fixing, else the previous banking day's fixing (weekends/holidays), else the month rate. Manual daily overrides are preserved by the auto-fetch and never carry forward. Provider outages never block the close - they surface as a warning exception. New AR_FX_AUTO_DAILY env toggle (default on; forced off in tests). Co-Authored-By: Claude Fable 5 --- ar-aging-app/.env.example | 4 + .../backend/app/api/routes/analytics.py | 45 +++++- ar-aging-app/backend/app/api/routes/fx.py | 7 +- ar-aging-app/backend/app/config.py | 4 + ar-aging-app/backend/app/db/models.py | 6 +- .../backend/app/services/fx_service.py | 73 ++++++++- ar-aging-app/backend/app/services/jobs.py | 21 +++ ar-aging-app/backend/tests/conftest.py | 6 + .../backend/tests/test_fx_dashboard.py | 27 ++++ ar-aging-app/backend/tests/test_fx_service.py | 146 +++++++++++++++++- ar-aging-app/docs/system-guide.md | 2 +- .../frontend/src/pages/closing/ArLedger.tsx | 13 +- 12 files changed, 332 insertions(+), 22 deletions(-) diff --git a/ar-aging-app/.env.example b/ar-aging-app/.env.example index 312e00a..3d09fa7 100644 --- a/ar-aging-app/.env.example +++ b/ar-aging-app/.env.example @@ -26,6 +26,10 @@ AR_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:517 # Exchange rates: frankfurter = free, keyless, central-bank rates AR_FX_PROVIDER=frankfurter +# Processing auto-fetches the provider's DAILY rates over each closing's transaction +# span, so dated movements convert at their own transaction date's rate. Set 0 to +# disable (the AR Ledger's "Fetch daily rates" button still works). +#AR_FX_AUTO_DAILY=1 # Email (optional) — enables "email me a code" for password resets. # Preferred: the company's internal Mail API (bearer token; ask Talha/IT for the values). diff --git a/ar-aging-app/backend/app/api/routes/analytics.py b/ar-aging-app/backend/app/api/routes/analytics.py index c6e6111..850a286 100644 --- a/ar-aging-app/backend/app/api/routes/analytics.py +++ b/ar-aging-app/backend/app/api/routes/analytics.py @@ -12,6 +12,7 @@ opening/payout inputs the AR Ledger uses, so every tab ties back to the Overview """ from __future__ import annotations +import bisect import datetime as dt import json from collections import defaultdict @@ -80,6 +81,34 @@ def _fx_for(db: OrmSession, session_id: int, marketplace: str) -> tuple[float, d return month_rate, daily +def _effective_rate(month_rate: float, daily: dict[dt.date, tuple[float, str]]): + """(rate, source) effective on a transaction date. + + Resolution order: + 1. that exact date's daily row (a provider fixing, or a hand-entered rate); + 2. the most recent PROVIDER fixing before it — a weekend/holiday has no fixing, + so the previous banking day's rate is still in effect. Hand-entered rates are + deliberate single-date overrides and never carry forward; + 3. the marketplace month rate (also used for undated rows and the opening balance, + which have no transaction date). + """ + fixing_dates = sorted(d for d, (_r, src) in daily.items() if (src or "") != "manual") + + def resolve(d: dt.date | None) -> tuple[float, str]: + if d is not None: + hit = daily.get(d) + if hit is not None: + return hit + i = bisect.bisect_left(fixing_dates, d) - 1 + if i >= 0: + prev = fixing_dates[i] + rate, src = daily[prev] + return rate, f"{src} {prev.isoformat()} (previous banking day)" + return month_rate, "month rate" + + return resolve + + def _daily_rows(db: OrmSession, session_id: int, marketplace: str, frm: dt.date | None, to: dt.date | None) -> list[tuple[dt.date, float, int]]: """Per-day (date, revenue_total, row_count) for one marketplace — NON-transfer rows. @@ -177,13 +206,16 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity: frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to") rows = _daily_rows(db, session_id, mkt, frm, to) - # Both currencies: USD is converted at each TRANSACTION DATE's rate (a daily override - # when one exists, the marketplace month rate otherwise). The opening balance has no - # transaction date, so it converts at the month rate — the closing's official rate. + # Both currencies: USD is converted at the rate EFFECTIVE ON EACH TRANSACTION DATE — + # that date's fixing (auto-fetched from the provider at processing), the previous + # banking day's fixing for weekends/holidays, the month rate as last resort. The + # opening balance has no transaction date, so it converts at the month rate — the + # closing's official rate. month_rate, daily = _fx_for(db, session_id, mkt) + effective = _effective_rate(month_rate, daily) def rate_of(d: dt.date | None) -> float: - return daily.get(d, (month_rate, ""))[0] if d else month_rate + return effective(d)[0] def new_bucket(key: str, label: str) -> dict: return {"key": key, "label": label, "revenue": 0.0, @@ -303,12 +335,15 @@ def fx_daily(session_id: int, marketplace: str | None = None, slot[1] += amount slot[2] += 1 + effective = _effective_rate(month_rate, daily) rows = [] tot_local = tot_usd = 0.0 for d in sorted(per): revenue, payout, n = per[d] local = revenue + payout - rate, source = daily.get(d, (month_rate, "month rate")) + # The rate effective on the transaction date; the source column discloses a + # previous-banking-day carry-forward, so the conversion stays auditable. + rate, source = effective(d) usd = local * rate tot_local += local tot_usd += usd diff --git a/ar-aging-app/backend/app/api/routes/fx.py b/ar-aging-app/backend/app/api/routes/fx.py index 3e521dd..d68d11d 100644 --- a/ar-aging-app/backend/app/api/routes/fx.py +++ b/ar-aging-app/backend/app/api/routes/fx.py @@ -29,14 +29,15 @@ def fetch_month_end_rates(session_id: int, db: OrmSession = Depends(db_dep)) -> class DailyFetchIn(BaseModel): marketplace: str | None = None # default: every non-USD marketplace in the closing - date_from: dt.date | None = None # default: first day of the reporting month - date_to: dt.date | None = None # default: month-end + date_from: dt.date | None = None # default: the closing's earliest dated transaction + date_to: dt.date | None = None # default: month-end (or the latest transaction) @router.post("/{session_id}/fx/fetch-daily") def fetch_daily_rates(session_id: int, body: DailyFetchIn | None = None, db: OrmSession = Depends(db_dep)) -> dict: - """Fill the per-date FX override table from the provider for a date range.""" + """(Re-)fetch the per-date FX table from the provider. Processing already does this + automatically; the explicit fetch also replaces hand-entered overrides.""" s = get_session_or_404(session_id, db) ensure_editable(s) body = body or DailyFetchIn() diff --git a/ar-aging-app/backend/app/config.py b/ar-aging-app/backend/app/config.py index ce93ff6..00a11cb 100644 --- a/ar-aging-app/backend/app/config.py +++ b/ar-aging-app/backend/app/config.py @@ -146,6 +146,10 @@ def email_enabled() -> bool: FX_PROVIDER = os.environ.get("AR_FX_PROVIDER", "frankfurter").strip().lower() FX_API_KEY = os.environ.get("AR_FX_API_KEY", "") FX_TIMEOUT_S = float(os.environ.get("AR_FX_TIMEOUT_S", "15")) +# Processing auto-fetches the provider's DAILY rates over the closing's transaction span, +# so dated movements convert at the rate effective on their own transaction date. Set to 0 +# to disable the automatic fetch (the AR Ledger's "Fetch daily rates" button still works). +FX_AUTO_DAILY = os.environ.get("AR_FX_AUTO_DAILY", "1").strip().lower() not in ("0", "false", "no") def ensure_dirs() -> None: diff --git a/ar-aging-app/backend/app/db/models.py b/ar-aging-app/backend/app/db/models.py index f9fbbd1..e1367af 100644 --- a/ar-aging-app/backend/app/db/models.py +++ b/ar-aging-app/backend/app/db/models.py @@ -291,7 +291,11 @@ class FxProviderRate(Base): class FxRateDaily(Base): - """Optional per-date FX override. Falls back to the marketplace's month rate.""" + """Per-date FX rate — auto-fetched from the provider at processing, hand-editable. + + Dated movements convert at the rate effective on their transaction date: this exact + date's row, else the previous banking day's provider fixing, else the month rate + (see analytics._effective_rate).""" __tablename__ = "fx_rates_daily" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) diff --git a/ar-aging-app/backend/app/services/fx_service.py b/ar-aging-app/backend/app/services/fx_service.py index a15ca14..a167209 100644 --- a/ar-aging-app/backend/app/services/fx_service.py +++ b/ar-aging-app/backend/app/services/fx_service.py @@ -18,6 +18,11 @@ Fetched rates are SUGGESTIONS: seeding writes them unconfirmed, so Control C5 st the close until a person reviews and confirms them for the reporting month — identical to the manual-entry workflow, just pre-filled with a real rate instead of the Jan-26 snapshot. +Daily rates: processing auto-fetches the provider's daily fixings across the closing's +transaction span (auto_seed_daily_fx), so every dated movement converts at the rate +effective on ITS OWN transaction date — see api/routes/analytics.py for the resolution +order (exact fixing → previous banking day's fixing → month rate). + Failure policy: a provider error raises FxProviderError (the route answers 502 "enter rates manually"). DEFAULT_FX_USD is never written silently — the existing merge in jobs.py is already the fallback and C5 already flags unconfirmed defaults. @@ -32,6 +37,7 @@ import urllib.error import urllib.parse import urllib.request +from sqlalchemy import func as sa_func from sqlalchemy.orm import Session as OrmSession from ..config import FX_API_KEY, FX_PROVIDER, FX_TIMEOUT_S @@ -257,16 +263,44 @@ def seed_session_fx(db: OrmSession, session: models.Session) -> dict: "rate_date": session.month_end_date.isoformat()} -def seed_daily_fx(db: OrmSession, session: models.Session, marketplace: str | None = None, - date_from: dt.date | None = None, date_to: dt.date | None = None) -> dict: - """Fill fx_rates_daily from the provider for a date range (defaults: the whole month). +def _transaction_span(db: OrmSession, session_id: int) -> tuple[dt.date | None, dt.date | None]: + """Earliest/latest dated transaction of the closing ((None, None) when nothing is dated).""" + lo, hi = db.query(sa_func.min(models.Transaction.posted_date), + sa_func.max(models.Transaction.posted_date)).filter( + models.Transaction.session_id == session_id, + models.Transaction.posted_date.isnot(None)).one() - Daily rows are optional per-date OVERRIDES of the month rate (analytics fx-daily), - marked source=provider so hand-entered rows are distinguishable.""" + def _d(v): + return v if (v is None or isinstance(v, dt.date)) else dt.date.fromisoformat(str(v)) + return _d(lo), _d(hi) + + +def seed_daily_fx(db: OrmSession, session: models.Session, marketplace: str | None = None, + date_from: dt.date | None = None, date_to: dt.date | None = None, + overwrite_manual: bool = True) -> dict: + """Fill fx_rates_daily from the provider. + + Default range: the span of dates the files actually contain (earliest dated + transaction through month-end, extended to any later transaction), widened to the + start of the reporting month — so every transaction converts at its own date's rate. + Clamped to a year before / a month after month-end, so one mis-parsed date can't + request a decade of history. + + Daily rows are what the ledger converts dated movements with (analytics), + marked source=provider so hand-entered rows are distinguishable. With + overwrite_manual=False (the automatic post-processing seed), rows a person typed + stay untouched; the explicit Fetch button replaces them.""" if session.month_end_date is None: raise FxProviderError("Set the month-end date first.") - date_to = date_to or session.month_end_date - date_from = date_from or session.month_end_date.replace(day=1) + month_end = session.month_end_date + if date_from is None or date_to is None: + lo, hi = _transaction_span(db, session.id) + if date_from is None: + date_from = min(lo or month_end.replace(day=1), month_end.replace(day=1)) + date_from = max(date_from, month_end - dt.timedelta(days=366)) + if date_to is None: + date_to = max(hi or month_end, month_end) + date_to = min(date_to, month_end + dt.timedelta(days=31)) if date_from > date_to: raise FxProviderError("date_from is after date_to.") @@ -297,6 +331,8 @@ def seed_daily_fx(db: OrmSession, session: models.Session, marketplace: str | No rate_date=day) db.add(row) existing[(mkt, day)] = row + elif not overwrite_manual and (row.source or "") == "manual": + continue # a person typed this rate — keep it row.rate = round(rate, 6) row.source = provider.name saved += 1 @@ -306,3 +342,26 @@ def seed_daily_fx(db: OrmSession, session: models.Session, marketplace: str | No return {"saved": saved, "date_from": date_from.isoformat(), "date_to": date_to.isoformat(), "provider": provider.name, "marketplaces": sorted({m for m, _ in targets})} + + +def auto_seed_daily_fx(db: OrmSession, session: models.Session) -> dict: + """Post-processing daily-rate fetch, so every dated movement converts at the rate + effective on its own transaction date without anyone clicking anything. + + Advisory by design — it NEVER raises: a provider outage must not fail the close + (conversion falls back to the last available fixing, then the month rate, and + jobs.py surfaces the shortfall as an exception). Hand-entered daily rates are + preserved; only provider rows are refreshed.""" + try: + rows = _session_fx_targets(db, session) + if not any((r.currency or currency_for_region(r.marketplace)) != "USD" + for r in rows): + return {"skipped": "USD-only closing", "saved": 0} + return seed_daily_fx(db, session, overwrite_manual=False) + except FxProviderError as e: + logger.warning("daily FX auto-seed failed for session %s: %s", session.id, e) + return {"error": str(e), "saved": 0} + except Exception as e: # noqa: BLE001 — advisory; never fail the close over FX + logger.exception("daily FX auto-seed crashed for session %s", session.id) + db.rollback() + return {"error": f"{type(e).__name__}: {e}", "saved": 0} diff --git a/ar-aging-app/backend/app/services/jobs.py b/ar-aging-app/backend/app/services/jobs.py index 1db3356..4dfd9b7 100644 --- a/ar-aging-app/backend/app/services/jobs.py +++ b/ar-aging-app/backend/app/services/jobs.py @@ -5,6 +5,7 @@ import logging import time import traceback +from ..config import FX_AUTO_DAILY from ..core.pipeline import process from ..core.i18n import CURRENCY_BY_REGION, DEFAULT_FX_USD, currency_for_region, default_fx_for_region from ..db import models @@ -134,6 +135,26 @@ def run_processing(session_id: int) -> None: source="default (Jan-26 workbook)")) db.commit() + # Daily FX from the provider, covering the span of dates the files actually + # contain, so every dated movement converts at the rate effective on ITS OWN + # transaction date (ledger / fx-daily). Advisory: a provider outage never blocks + # the close — conversion falls back to the last available fixing, then the month + # rate, and the shortfall is surfaced below as an exception. + if FX_AUTO_DAILY: + progress("Fetching daily FX rates", 0.97) + from .fx_service import auto_seed_daily_fx + fx_daily_out = auto_seed_daily_fx(db, session) + if fx_daily_out.get("error"): + db.add(models.Exception_( + session_id=session_id, category="fx_daily_unavailable", + severity="warning", + detail=(f"Daily exchange rates could not be fetched from the provider " + f"({fx_daily_out['error']}). Dated movements convert at " + f"previously fetched daily rates or the month rate until " + f"'Fetch daily rates' on the AR Ledger tab succeeds."), + source="fx provider")) + db.commit() + # Journal-entry decomposition (separate pass; part of the close). try: progress("Building journal entry", 0.98) diff --git a/ar-aging-app/backend/tests/conftest.py b/ar-aging-app/backend/tests/conftest.py index 612d807..32a1fb5 100644 --- a/ar-aging-app/backend/tests/conftest.py +++ b/ar-aging-app/backend/tests/conftest.py @@ -23,6 +23,12 @@ import pytest _TEST_DATA_DIR = Path(tempfile.mkdtemp(prefix="ar-aging-tests-")) os.environ["AR_DATA_DIR"] = str(_TEST_DATA_DIR) +# Processing auto-fetches daily FX rates from the provider (jobs.FX_AUTO_DAILY); tests +# must never touch the network, so the automatic fetch is forced off for the whole suite. +# The FX tests exercise seeding explicitly through a mocked HTTP layer — including one +# integration test that re-enables the flag with monkeypatch (test_fx_service.py). +os.environ["AR_FX_AUTO_DAILY"] = "0" + _PROD_DB = os.environ.get("MYSQL_DATABASE", "") TEST_DB_NAME = os.environ.get("AR_TEST_MYSQL_DATABASE", "ar_aging_pytest") os.environ["MYSQL_DATABASE"] = TEST_DB_NAME diff --git a/ar-aging-app/backend/tests/test_fx_dashboard.py b/ar-aging-app/backend/tests/test_fx_dashboard.py index 050220c..ae3a681 100644 --- a/ar-aging-app/backend/tests/test_fx_dashboard.py +++ b/ar-aging-app/backend/tests/test_fx_dashboard.py @@ -76,3 +76,30 @@ def test_ledger_detail_shows_usd_at_transaction_date_rates(): # The local-currency figures are untouched by the daily override. assert per["2026-01-15"]["balance"] == per["2026-01-15"]["balance_usd"] - 500.0 + + +def test_weekend_transactions_use_the_previous_banking_days_fixing(): + """2026-01-10 is a Saturday — no fixing is published. The rate effective on it is the + previous banking day's PROVIDER fixing (Friday the 9th), not the month rate. Manual + rates never carry forward: they are deliberate single-date overrides (which is also + why the test above sees the month rate everywhere but Jan 15).""" + init_db() + with TestClient(app) as c: + sid = _fresh(c, "weekend carry forward") + assert c.put(f"/api/sessions/{sid}/fx-daily", json=[ + {"marketplace": "USA", "rate_date": "2026-01-09", "rate": 1.5, + "source": "frankfurter"}, + ]).status_code == 200 + + d = c.get(f"/api/sessions/{sid}/ledger-detail").json() + per = {p["key"]: p for p in d["periods"]} + # Saturday's revenue converts at Friday's fixing: 300 × 1.5. + assert per["2026-01-10"]["revenue_usd"] == 450.0 + + fxd = c.get(f"/api/sessions/{sid}/fx-daily").json() + by_date = {r["date"]: r for r in fxd["rows"]} + assert by_date["2026-01-10"]["rate"] == 1.5 + assert "2026-01-09" in by_date["2026-01-10"]["source"] # carry-forward disclosed + # Dates before the first fixing still fall back to the month rate. + assert by_date["2026-01-05"]["rate"] == 1.0 + assert by_date["2026-01-05"]["source"] == "month rate" diff --git a/ar-aging-app/backend/tests/test_fx_service.py b/ar-aging-app/backend/tests/test_fx_service.py index e0adb3c..425c28e 100644 --- a/ar-aging-app/backend/tests/test_fx_service.py +++ b/ar-aging-app/backend/tests/test_fx_service.py @@ -18,10 +18,11 @@ 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} + 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}, @@ -137,3 +138,146 @@ def test_daily_fetch_fills_fx_rates_daily(fake_frankfurter): 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"] diff --git a/ar-aging-app/docs/system-guide.md b/ar-aging-app/docs/system-guide.md index 24dd8f1..0550ea5 100644 --- a/ar-aging-app/docs/system-guide.md +++ b/ar-aging-app/docs/system-guide.md @@ -313,7 +313,7 @@ via `cli.py`, which is what the integration tests exercise. | `market_payouts` | Per-marketplace received / total payouts, attributed to each settlement's **owner** | | `opening_balances` | Opening AR per marketplace, with source (manual / carried-forward) and reason | | `fx_rates` | Month FX rate + currency per marketplace | -| `fx_rates_daily` | Optional per-date FX override | +| `fx_rates_daily` | Per-date FX rates — auto-fetched from the provider at processing (hand-editable); dated movements convert at the rate effective on their transaction date (exact fixing → previous banking day's fixing → month rate) | | `reserves` | Net Closing Balance per marketplace and account | | `journal_entries` | The GL decomposition JSON (primary + `per_marketplace`) and entry number | | `finance_control` | Finance's control-sheet amounts, tolerance, sign-off and comments | diff --git a/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx b/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx index ded12e5..5312b19 100644 --- a/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx +++ b/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx @@ -314,8 +314,11 @@ export default function ArLedger() {

- Rates default to the marketplace month rate ({num(fx?.month_rate, 6)}). Every rate used is - shown here so the conversion is auditable. + Daily rates are fetched from the FX provider automatically when the closing is + processed; each movement converts at the rate effective on its transaction date — a + date without a fixing (weekend or holiday) uses the previous banking day's rate. + The month rate ({num(fx?.month_rate, 6)}) applies to the opening balance and any date + with no fetched rate. Every rate used is shown here so the conversion is auditable.

@@ -329,7 +332,9 @@ export default function ArLedger() { ); } -/** Fill the daily override table with official (ECB via Frankfurter) rates for the month. */ +/** Re-fetch the official (ECB via Frankfurter) daily rates for the closing's transaction + * dates. Processing already fetches them automatically — this button retries after an + * outage or replaces hand-entered overrides with official fixings. */ function FetchDailyRates({ id, mkt }: { id: number; mkt: string }) { const qc = useQueryClient(); const { locked } = useClosing(); @@ -348,7 +353,7 @@ function FetchDailyRates({ id, mkt }: { id: number; mkt: string }) { {(fetchDaily.error as Error).message} )}