Convert at the transaction date's FX rate, auto-fetched from the API
Deploy to S3 / deploy (push) Successful in 28s
Details
Deploy to S3 / deploy (push) Successful in 28s
Details
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 <noreply@anthropic.com>main
parent
c2e1840f5b
commit
f46fc69562
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -314,8 +314,11 @@ export default function ArLedger() {
|
|||
</table>
|
||||
</div>
|
||||
<p className="px-4 py-3 text-xs text-subink border-t border-line">
|
||||
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.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
|
|
@ -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 }) {
|
|||
<span className="text-xs text-bad">{(fetchDaily.error as Error).message}</span>
|
||||
)}
|
||||
<button className="btn-ghost" disabled={fetchDaily.isPending || locked}
|
||||
title="Fetch the month's official daily rates. Hand-entered overrides are replaced for the fetched dates."
|
||||
title="Re-fetch the official daily rates for the closing's transaction dates. Hand-entered overrides are replaced for the fetched dates."
|
||||
onClick={() => fetchDaily.mutate()}>
|
||||
{fetchDaily.isPending ? <Loader2 size={15} className="animate-spin" /> : <CloudDownload size={15} />}
|
||||
Fetch daily rates
|
||||
|
|
|
|||
Loading…
Reference in New Issue