Convert at the transaction date's FX rate, auto-fetched from the API
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
Talha Ahmed 2026-08-25 15:23:59 +05:00
parent c2e1840f5b
commit f46fc69562
12 changed files with 332 additions and 22 deletions

View File

@ -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).

View File

@ -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

View File

@ -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()

View File

@ -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:

View File

@ -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)

View File

@ -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}

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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"]

View File

@ -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 |

View File

@ -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&apos;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