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
|
# Exchange rates: frankfurter = free, keyless, central-bank rates
|
||||||
AR_FX_PROVIDER=frankfurter
|
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.
|
# 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).
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import json
|
import json
|
||||||
from collections import defaultdict
|
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
|
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,
|
def _daily_rows(db: OrmSession, session_id: int, marketplace: str,
|
||||||
frm: dt.date | None, to: dt.date | None) -> list[tuple[dt.date, float, int]]:
|
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.
|
"""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")
|
frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to")
|
||||||
rows = _daily_rows(db, session_id, mkt, frm, to)
|
rows = _daily_rows(db, session_id, mkt, frm, to)
|
||||||
|
|
||||||
# Both currencies: USD is converted at each TRANSACTION DATE's rate (a daily override
|
# Both currencies: USD is converted at the rate EFFECTIVE ON EACH TRANSACTION DATE —
|
||||||
# when one exists, the marketplace month rate otherwise). The opening balance has no
|
# that date's fixing (auto-fetched from the provider at processing), the previous
|
||||||
# transaction date, so it converts at the month rate — the closing's official rate.
|
# 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)
|
month_rate, daily = _fx_for(db, session_id, mkt)
|
||||||
|
effective = _effective_rate(month_rate, daily)
|
||||||
|
|
||||||
def rate_of(d: dt.date | None) -> float:
|
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:
|
def new_bucket(key: str, label: str) -> dict:
|
||||||
return {"key": key, "label": label, "revenue": 0.0,
|
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[1] += amount
|
||||||
slot[2] += 1
|
slot[2] += 1
|
||||||
|
|
||||||
|
effective = _effective_rate(month_rate, daily)
|
||||||
rows = []
|
rows = []
|
||||||
tot_local = tot_usd = 0.0
|
tot_local = tot_usd = 0.0
|
||||||
for d in sorted(per):
|
for d in sorted(per):
|
||||||
revenue, payout, n = per[d]
|
revenue, payout, n = per[d]
|
||||||
local = revenue + payout
|
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
|
usd = local * rate
|
||||||
tot_local += local
|
tot_local += local
|
||||||
tot_usd += usd
|
tot_usd += usd
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,15 @@ def fetch_month_end_rates(session_id: int, db: OrmSession = Depends(db_dep)) ->
|
||||||
|
|
||||||
class DailyFetchIn(BaseModel):
|
class DailyFetchIn(BaseModel):
|
||||||
marketplace: str | None = None # default: every non-USD marketplace in the closing
|
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_from: dt.date | None = None # default: the closing's earliest dated transaction
|
||||||
date_to: dt.date | None = None # default: month-end
|
date_to: dt.date | None = None # default: month-end (or the latest transaction)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{session_id}/fx/fetch-daily")
|
@router.post("/{session_id}/fx/fetch-daily")
|
||||||
def fetch_daily_rates(session_id: int, body: DailyFetchIn | None = None,
|
def fetch_daily_rates(session_id: int, body: DailyFetchIn | None = None,
|
||||||
db: OrmSession = Depends(db_dep)) -> dict:
|
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)
|
s = get_session_or_404(session_id, db)
|
||||||
ensure_editable(s)
|
ensure_editable(s)
|
||||||
body = body or DailyFetchIn()
|
body = body or DailyFetchIn()
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,10 @@ def email_enabled() -> bool:
|
||||||
FX_PROVIDER = os.environ.get("AR_FX_PROVIDER", "frankfurter").strip().lower()
|
FX_PROVIDER = os.environ.get("AR_FX_PROVIDER", "frankfurter").strip().lower()
|
||||||
FX_API_KEY = os.environ.get("AR_FX_API_KEY", "")
|
FX_API_KEY = os.environ.get("AR_FX_API_KEY", "")
|
||||||
FX_TIMEOUT_S = float(os.environ.get("AR_FX_TIMEOUT_S", "15"))
|
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:
|
def ensure_dirs() -> None:
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,11 @@ class FxProviderRate(Base):
|
||||||
|
|
||||||
|
|
||||||
class FxRateDaily(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"
|
__tablename__ = "fx_rates_daily"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
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 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.
|
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
|
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
|
manually"). DEFAULT_FX_USD is never written silently — the existing merge in jobs.py is
|
||||||
already the fallback and C5 already flags unconfirmed defaults.
|
already the fallback and C5 already flags unconfirmed defaults.
|
||||||
|
|
@ -32,6 +37,7 @@ import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
from sqlalchemy import func as sa_func
|
||||||
from sqlalchemy.orm import Session as OrmSession
|
from sqlalchemy.orm import Session as OrmSession
|
||||||
|
|
||||||
from ..config import FX_API_KEY, FX_PROVIDER, FX_TIMEOUT_S
|
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()}
|
"rate_date": session.month_end_date.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
def seed_daily_fx(db: OrmSession, session: models.Session, marketplace: str | None = None,
|
def _transaction_span(db: OrmSession, session_id: int) -> tuple[dt.date | None, dt.date | None]:
|
||||||
date_from: dt.date | None = None, date_to: dt.date | None = None) -> dict:
|
"""Earliest/latest dated transaction of the closing ((None, None) when nothing is dated)."""
|
||||||
"""Fill fx_rates_daily from the provider for a date range (defaults: the whole month).
|
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),
|
def _d(v):
|
||||||
marked source=provider so hand-entered rows are distinguishable."""
|
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:
|
if session.month_end_date is None:
|
||||||
raise FxProviderError("Set the month-end date first.")
|
raise FxProviderError("Set the month-end date first.")
|
||||||
date_to = date_to or session.month_end_date
|
month_end = session.month_end_date
|
||||||
date_from = date_from or session.month_end_date.replace(day=1)
|
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:
|
if date_from > date_to:
|
||||||
raise FxProviderError("date_from is after 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)
|
rate_date=day)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
existing[(mkt, day)] = 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.rate = round(rate, 6)
|
||||||
row.source = provider.name
|
row.source = provider.name
|
||||||
saved += 1
|
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(),
|
return {"saved": saved, "date_from": date_from.isoformat(),
|
||||||
"date_to": date_to.isoformat(), "provider": provider.name,
|
"date_to": date_to.isoformat(), "provider": provider.name,
|
||||||
"marketplaces": sorted({m for m, _ in targets})}
|
"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 time
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from ..config import FX_AUTO_DAILY
|
||||||
from ..core.pipeline import process
|
from ..core.pipeline import process
|
||||||
from ..core.i18n import CURRENCY_BY_REGION, DEFAULT_FX_USD, currency_for_region, default_fx_for_region
|
from ..core.i18n import CURRENCY_BY_REGION, DEFAULT_FX_USD, currency_for_region, default_fx_for_region
|
||||||
from ..db import models
|
from ..db import models
|
||||||
|
|
@ -134,6 +135,26 @@ def run_processing(session_id: int) -> None:
|
||||||
source="default (Jan-26 workbook)"))
|
source="default (Jan-26 workbook)"))
|
||||||
db.commit()
|
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).
|
# Journal-entry decomposition (separate pass; part of the close).
|
||||||
try:
|
try:
|
||||||
progress("Building journal entry", 0.98)
|
progress("Building journal entry", 0.98)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ import pytest
|
||||||
_TEST_DATA_DIR = Path(tempfile.mkdtemp(prefix="ar-aging-tests-"))
|
_TEST_DATA_DIR = Path(tempfile.mkdtemp(prefix="ar-aging-tests-"))
|
||||||
os.environ["AR_DATA_DIR"] = str(_TEST_DATA_DIR)
|
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", "")
|
_PROD_DB = os.environ.get("MYSQL_DATABASE", "")
|
||||||
TEST_DB_NAME = os.environ.get("AR_TEST_MYSQL_DATABASE", "ar_aging_pytest")
|
TEST_DB_NAME = os.environ.get("AR_TEST_MYSQL_DATABASE", "ar_aging_pytest")
|
||||||
os.environ["MYSQL_DATABASE"] = TEST_DB_NAME
|
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.
|
# The local-currency figures are untouched by the daily override.
|
||||||
assert per["2026-01-15"]["balance"] == per["2026-01-15"]["balance_usd"] - 500.0
|
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()
|
@pytest.fixture()
|
||||||
def fake_frankfurter(monkeypatch):
|
def fake_frankfurter(monkeypatch):
|
||||||
"""Replace the HTTP layer with a fixture: 1 USD = 0.85 EUR on 2029-06-29 (Friday)."""
|
"""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:
|
def fake_get(url: str) -> dict:
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
|
calls["urls"].append(url)
|
||||||
if ".." in url: # time-series request
|
if ".." in url: # time-series request
|
||||||
return {"base": "USD", "rates": {
|
return {"base": "USD", "rates": {
|
||||||
"2029-06-28": {"EUR": 0.86},
|
"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)
|
assert all(row.source == "frankfurter" for row in rows)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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** |
|
| `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 |
|
| `opening_balances` | Opening AR per marketplace, with source (manual / carried-forward) and reason |
|
||||||
| `fx_rates` | Month FX rate + currency per marketplace |
|
| `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 |
|
| `reserves` | Net Closing Balance per marketplace and account |
|
||||||
| `journal_entries` | The GL decomposition JSON (primary + `per_marketplace`) and entry number |
|
| `journal_entries` | The GL decomposition JSON (primary + `per_marketplace`) and entry number |
|
||||||
| `finance_control` | Finance's control-sheet amounts, tolerance, sign-off and comments |
|
| `finance_control` | Finance's control-sheet amounts, tolerance, sign-off and comments |
|
||||||
|
|
|
||||||
|
|
@ -314,8 +314,11 @@ export default function ArLedger() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p className="px-4 py-3 text-xs text-subink border-t border-line">
|
<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
|
Daily rates are fetched from the FX provider automatically when the closing is
|
||||||
shown here so the conversion is auditable.
|
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>
|
</p>
|
||||||
</Section>
|
</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 }) {
|
function FetchDailyRates({ id, mkt }: { id: number; mkt: string }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { locked } = useClosing();
|
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>
|
<span className="text-xs text-bad">{(fetchDaily.error as Error).message}</span>
|
||||||
)}
|
)}
|
||||||
<button className="btn-ghost" disabled={fetchDaily.isPending || locked}
|
<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()}>
|
onClick={() => fetchDaily.mutate()}>
|
||||||
{fetchDaily.isPending ? <Loader2 size={15} className="animate-spin" /> : <CloudDownload size={15} />}
|
{fetchDaily.isPending ? <Loader2 size={15} className="animate-spin" /> : <CloudDownload size={15} />}
|
||||||
Fetch daily rates
|
Fetch daily rates
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue