diff --git a/.gitignore b/.gitignore index 3d91040..ba74e45 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ *.xls *.csv *.tsv +Test Files/ Amazon Transactions reports*/ Accounts Receivable*/ !ar-aging-app/backend/tests/fixtures/*.xlsx diff --git a/ar-aging-app/backend/app/api/routes/analytics.py b/ar-aging-app/backend/app/api/routes/analytics.py index 36524ae..b9af691 100644 --- a/ar-aging-app/backend/app/api/routes/analytics.py +++ b/ar-aging-app/backend/app/api/routes/analytics.py @@ -177,10 +177,20 @@ 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. + month_rate, daily = _fx_for(db, session_id, mkt) + + def rate_of(d: dt.date | None) -> float: + return daily.get(d, (month_rate, ""))[0] if d else month_rate + def new_bucket(key: str, label: str) -> dict: return {"key": key, "label": label, "revenue": 0.0, "payouts_received": 0.0, "payouts_in_transit": 0.0, - "bank_dated": 0.0, "rows": 0} + "bank_dated": 0.0, "rows": 0, + "revenue_usd": 0.0, "payouts_received_usd": 0.0, + "payouts_in_transit_usd": 0.0} # Revenue buckets by transaction date; payouts by their EFFECTIVE date — the bank # receipt's date when Finance entered one, Amazon's transfer date otherwise. @@ -189,6 +199,7 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity: key, label = _bucket(d, granularity) b = buckets.setdefault(key, new_bucket(key, label)) b["revenue"] += revenue + b["revenue_usd"] += revenue * rate_of(d) b["rows"] += n for d, amount, received, bank_dated in _payout_events(db, s, mkt): if frm and (d is None or d < frm): @@ -199,16 +210,21 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity: b = buckets.setdefault(key, new_bucket(key, label)) if amount: b["payouts_received" if received else "payouts_in_transit"] += amount + b["payouts_received_usd" if received else "payouts_in_transit_usd"] += \ + amount * rate_of(d) if bank_dated: b["bank_dated"] += amount b["rows"] += 1 opening = mv["opening"] running = opening + opening_usd = opening * month_rate + running_usd = opening_usd out = [] for key in sorted(buckets): b = buckets[key] running += b["revenue"] + b["payouts_received"] + running_usd += b["revenue_usd"] + b["payouts_received_usd"] out.append({ "key": b["key"], "label": b["label"], "revenue": round(b["revenue"], 2), @@ -217,6 +233,10 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity: "bank_dated": round(b["bank_dated"], 2), # payout amounts placed by bank date "rows": b["rows"], "balance": round(running, 2), + "revenue_usd": round(b["revenue_usd"], 2), + "payouts_received_usd": round(b["payouts_received_usd"], 2), + "payouts_in_transit_usd": round(b["payouts_in_transit_usd"], 2), + "balance_usd": round(running_usd, 2), }) filtered = bool(frm or to) @@ -235,6 +255,12 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity: "session_closing": mv["closing"], "filtered": filtered, "in_transit_total": round(sum(p["payouts_in_transit"] for p in out), 2), + "month_rate": month_rate, + "opening_usd": round(opening_usd, 2), + # Roll-forward valued at transaction-date rates; differs from closing × month rate + # whenever daily overrides exist — that spread is the FX effect of the month. + "closing_usd": round(running_usd, 2), + "in_transit_total_usd": round(sum(p["payouts_in_transit_usd"] for p in out), 2), } @@ -251,7 +277,7 @@ def fx_daily(session_id: int, marketplace: str | None = None, date_from: str | None = None, date_to: str | None = None, db: OrmSession = Depends(db_dep)) -> dict: """Per-date local value, the USD rate applied, and the USD equivalent.""" - get_session_or_404(session_id, db) + s = get_session_or_404(session_id, db) mv = _movement_for(db, session_id, marketplace) if not mv.get("available"): return {"available": False} @@ -260,11 +286,27 @@ def fx_daily(session_id: int, marketplace: str | None = None, month_rate, daily = _fx_for(db, session_id, mkt) frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to") - rows = [] - tot_local = tot_usd = 0.0 - for d, revenue, payout, n in _daily_rows(db, session_id, mkt, frm, to): + # Revenue by transaction date; payouts by their EFFECTIVE date (bank receipt when one + # was entered, Amazon's transfer date otherwise) — the same placement the ledger uses, + # so this table converts exactly the movement the ledger shows. + per: dict[dt.date, list[float]] = defaultdict(lambda: [0.0, 0.0, 0]) # revenue, payouts, rows + for d, revenue, n in _daily_rows(db, session_id, mkt, frm, to): if d is None: continue + slot = per[d] + slot[0] += revenue + slot[2] += n + for d, amount, _received, _bank_dated in _payout_events(db, s, mkt): + if d is None or (frm and d < frm) or (to and d > to): + continue + slot = per[d] + slot[1] += amount + slot[2] += 1 + + 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")) usd = local * rate @@ -272,7 +314,7 @@ def fx_daily(session_id: int, marketplace: str | None = None, tot_usd += usd rows.append({ "date": d.isoformat(), "local": round(local, 2), "rate": rate, - "usd": round(usd, 2), "source": source, "rows": n, + "usd": round(usd, 2), "source": source, "rows": int(n), "revenue": round(revenue, 2), "payouts": round(payout, 2), }) return { diff --git a/ar-aging-app/backend/app/core/calamine_reader.py b/ar-aging-app/backend/app/core/calamine_reader.py index 38dd85a..90e16f9 100644 --- a/ar-aging-app/backend/app/core/calamine_reader.py +++ b/ar-aging-app/backend/app/core/calamine_reader.py @@ -32,6 +32,7 @@ class CalamineReader: self.header_row = 0 # 1-based (Excel) self.column_mapping: ColumnMapping | None = None self._field_to_idx: dict[str, int] = {} + self._sum_field_idx: dict[str, list[int]] = {} self.file_meta = FileMeta(filename=self.filename) # -- lifecycle -- @@ -98,11 +99,17 @@ class CalamineReader: self._field_to_idx = { fld: _letter_to_idx(col) for col, fld in mapping.col_to_field.items() } + # Extra amount columns folded into an already-mapped field (ColumnMapping.sum_cols). + self._sum_field_idx = { + fld: [_letter_to_idx(col) for col, _hdr in cols] + for fld, cols in mapping.sum_cols.items() + } self.file_meta.data_sheet = name self.file_meta.header_row = self.header_row self.file_meta.unmapped_headers = mapping.unmapped self.file_meta.missing_required = mapping.missing_required self.file_meta.duplicate_fields = mapping.duplicate_fields + self.file_meta.summed_fields = mapping.sum_cols self.file_meta.sheet_last_row = self._safe_height(name) # control C1 return mapping @@ -126,10 +133,15 @@ class CalamineReader: self.detect() assert self._sheet is not None idx_map = self._field_to_idx + sum_map = self._sum_field_idx if only_fields is not None: idx_map = {f: i for f, i in idx_map.items() if f in only_fields} + sum_map = {f: v for f, v in sum_map.items() if f in only_fields} items = list(idx_map.items()) + sum_items = list(sum_map.items()) mapped_idx = set(self._field_to_idx.values()) + for _idxs in self._sum_field_idx.values(): + mapped_idx.update(_idxs) unmapped_sums = self.file_meta.unmapped_amount_sums hdr = self.header_row # 1-based; data starts at hdr+1 (Excel) => row index hdr (0-based) min_d: date | None = None @@ -153,6 +165,12 @@ class CalamineReader: # this reader emitted trailing blank rows the other reader dropped. if v not in (None, ""): has_value = True + for fld, idxs in sum_items: + for i in idxs: + v = row[i] if i < len(row) else None + if v not in (None, ""): + rec[fld] = (rec.get(fld) or 0.0) + _conv_cal(fld, v) + has_value = True if not has_value: self.file_meta.blank_rows_skipped += 1 continue diff --git a/ar-aging-app/backend/app/core/column_map.py b/ar-aging-app/backend/app/core/column_map.py index 477feeb..bffb089 100644 --- a/ar-aging-app/backend/app/core/column_map.py +++ b/ar-aging-app/backend/app/core/column_map.py @@ -346,6 +346,11 @@ class ColumnMapping: # Only the first is used, so the second column's amounts would vanish from the journal. # Surfaced as an error rather than silently demoted to `unmapped`. duplicate_fields: dict[str, list[tuple[str, str]]] = field(default_factory=dict) + # Extra AMOUNT columns folded into an already-mapped field: field -> [(col, header)]. + # Amazon splits one concept across columns in some schemas (AU: "sales tax collected" + # + "low value goods", both inside the row `total`), so readers SUM these instead of + # dropping them. + sum_cols: dict[str, list[tuple[str, str]]] = field(default_factory=dict) header_row: int = 0 @property @@ -379,11 +384,18 @@ def build_mapping( m.col_to_field[col] = fld m.field_to_col[fld] = col elif fld: - # Collision: first column wins and this one is dropped. Record both so the close - # can raise, instead of quietly excluding a whole amount column. - first_col = m.field_to_col[fld] - m.duplicate_fields.setdefault(fld, [(first_col, "")]).append((col, str(text))) - m.unmapped[col] = str(text) + if FIELD_KIND.get(fld) == "amount": + # A second amount column for the same concept (AU "low value goods" next + # to "sales tax collected") — readers ADD it into the field, because the + # row `total` includes both and dropping it fails control C2. + m.sum_cols.setdefault(fld, []).append((col, str(text))) + else: + # Collision on a non-amount field: first column wins and this one is + # dropped. Record both so the close can raise, instead of quietly + # excluding a whole column. + first_col = m.field_to_col[fld] + m.duplicate_fields.setdefault(fld, [(first_col, "")]).append((col, str(text))) + m.unmapped[col] = str(text) elif text and str(text).strip(): m.unmapped[col] = str(text) m.missing_required = [f for f in REQUIRED_FIELDS if f not in m.field_to_col] diff --git a/ar-aging-app/backend/app/core/csv_reader.py b/ar-aging-app/backend/app/core/csv_reader.py index 16c619c..5a4a424 100644 --- a/ar-aging-app/backend/app/core/csv_reader.py +++ b/ar-aging-app/backend/app/core/csv_reader.py @@ -25,6 +25,17 @@ _CURRENCY_RE = re.compile( ) +# Amazon's localized reports are not all ASCII: Sweden writes negatives with a real +# MINUS SIGN (U+2212, "\u221278 690,40") and several locales group thousands with +# non-breaking / narrow spaces. float() rejects U+2212, and the fallback below would +# silently turn the cell into 0.0 \u2014 which dropped every negative amount (fees, taxes, +# transfers) of an entire Swedish month while the positives kept adding up. +_AMOUNT_CLEANUP = str.maketrans({ + "\u2212": "-", "\u2010": "-", "\u2011": "-", "\u2013": "-", # minus / dash variants + "\u00a0": None, "\u202f": None, "\u2009": None, " ": None, # space variants +}) + + def parse_amount(raw) -> float: """Parse Amazon amount cells, including European '1.234,56' / '13,49' forms.""" if raw is None or raw == "": @@ -33,7 +44,7 @@ def parse_amount(raw) -> float: return 0.0 if isinstance(raw, (int, float)): return float(raw) - s = str(raw).strip().replace("\u00a0", "").replace(" ", "") + s = str(raw).strip().translate(_AMOUNT_CLEANUP) if not s: return 0.0 # European: decimal comma, optional thousands dots / spaces. @@ -93,6 +104,7 @@ class CsvReader: self.header_row = 0 # 1-based, matching Excel readers self.column_mapping: ColumnMapping | None = None self._field_to_idx: dict[str, int] = {} + self._sum_field_idx: dict[str, list[int]] = {} self.file_meta = FileMeta(filename=self.filename) def open(self) -> None: @@ -172,11 +184,17 @@ class CsvReader: self._field_to_idx = { fld: _col_to_idx(col) for col, fld in mapping.col_to_field.items() } + # Extra amount columns folded into an already-mapped field (ColumnMapping.sum_cols). + self._sum_field_idx = { + fld: [_col_to_idx(col) for col, _hdr in cols] + for fld, cols in mapping.sum_cols.items() + } self.file_meta.data_sheet = self.sheet_name self.file_meta.header_row = self.header_row self.file_meta.unmapped_headers = mapping.unmapped self.file_meta.missing_required = mapping.missing_required self.file_meta.duplicate_fields = mapping.duplicate_fields + self.file_meta.summed_fields = mapping.sum_cols self.file_meta.sheet_last_row = len(self._rows) return mapping @@ -185,10 +203,15 @@ class CsvReader: self.detect() assert self._rows is not None idx_map = self._field_to_idx + sum_map = self._sum_field_idx if only_fields is not None: idx_map = {f: i for f, i in idx_map.items() if f in only_fields} + sum_map = {f: v for f, v in sum_map.items() if f in only_fields} items = list(idx_map.items()) + sum_items = list(sum_map.items()) mapped_idx = set(self._field_to_idx.values()) + for _idxs in self._sum_field_idx.values(): + mapped_idx.update(_idxs) unmapped_sums = self.file_meta.unmapped_amount_sums hdr = self.header_row min_d: date | None = None @@ -211,6 +234,12 @@ class CsvReader: rec[fld] = _convert(fld, v) if v not in (None, ""): has_value = True + for fld, idxs in sum_items: + for i in idxs: + v = row[i] if i < len(row) else None + if v not in (None, ""): + rec[fld] = (rec.get(fld) or 0.0) + parse_amount(v) + has_value = True if not has_value: self.file_meta.blank_rows_skipped += 1 continue diff --git a/ar-aging-app/backend/app/core/xlsx_reader.py b/ar-aging-app/backend/app/core/xlsx_reader.py index 675cbaf..0e1a431 100644 --- a/ar-aging-app/backend/app/core/xlsx_reader.py +++ b/ar-aging-app/backend/app/core/xlsx_reader.py @@ -69,6 +69,9 @@ class FileMeta: missing_required: list[str] = field(default_factory=list) # canonical field -> the columns that both claimed it (only the first is used) duplicate_fields: dict[str, list] = field(default_factory=dict) + # canonical AMOUNT field -> extra [(col, header)] whose amounts were ADDED into it + # (Amazon splits one concept across columns, e.g. AU "low value goods" tax). + summed_fields: dict[str, list] = field(default_factory=dict) # Finance-added translation/helper header rows found below the real header and skipped. helper_rows_skipped: int = 0 # column-letter -> Σ of numeric values seen in columns with NO mapped field. @@ -253,6 +256,7 @@ class TransactionReader: self.file_meta.unmapped_headers = mapping.unmapped self.file_meta.missing_required = mapping.missing_required self.file_meta.duplicate_fields = mapping.duplicate_fields + self.file_meta.summed_fields = mapping.sum_cols self.file_meta.sheet_last_row = self._declared_last_row(part) return mapping @@ -284,6 +288,9 @@ class TransactionReader: assert self.column_mapping is not None and self._zip is not None shared = self._shared_strings() col_to_field = self.column_mapping.col_to_field + # Extra amount columns folded into an already-mapped field (see ColumnMapping.sum_cols). + sum_col_to_field = {c: f for f, cols in self.column_mapping.sum_cols.items() + for c, _hdr in cols} want = only_fields min_d: date | None = None max_d: date | None = None @@ -303,6 +310,14 @@ class TransactionReader: for col, val in cells.items(): fld = col_to_field.get(col) if not fld: + sfld = sum_col_to_field.get(col) + if sfld is not None: + # Cells iterate in column order, so the field's primary column has + # already been converted (when present) — add, don't assign. + if (want is None or sfld in want) and val not in (None, ""): + rec[sfld] = (rec.get(sfld) or 0.0) + _convert(sfld, val) + has_value = True + continue # No amount is silently excluded: sum numeric data in unmapped columns. if val not in (None, ""): try: diff --git a/ar-aging-app/backend/app/services/store.py b/ar-aging-app/backend/app/services/store.py index 5f19cee..db79f4f 100644 --- a/ar-aging-app/backend/app/services/store.py +++ b/ar-aging-app/backend/app/services/store.py @@ -287,6 +287,13 @@ def _exceptions_from(result: ProcessResult) -> list[dict]: f"used, so the others are excluded from every total — " f"correct the header mapping before relying on this close."), "source": m.filename}) + for fld, cols in (getattr(m, "summed_fields", None) or {}).items(): + cols_txt = ", ".join(f"{c}{f' ({t})' if t else ''}" for c, t in cols) + out.append({"category": "summed_column_mapping", "severity": "info", + "detail": (f"Column(s) {cols_txt} were added into '{fld}' — Amazon " + f"splits this concept across columns and the row total " + f"includes both."), + "source": m.filename}) for col, s in (getattr(m, "unmapped_amount_sums", None) or {}).items(): if abs(s) > 0.005: out.append({"category": "unmapped_amounts", "severity": "error", diff --git a/ar-aging-app/backend/tests/test_csv_reader.py b/ar-aging-app/backend/tests/test_csv_reader.py index be39323..ad02f16 100644 --- a/ar-aging-app/backend/tests/test_csv_reader.py +++ b/ar-aging-app/backend/tests/test_csv_reader.py @@ -3,6 +3,8 @@ from __future__ import annotations from pathlib import Path +import pytest + from app.core.csv_reader import CsvReader, parse_amount from app.core.readers import make_reader @@ -16,6 +18,47 @@ def test_parse_amount_european(): assert parse_amount("") == 0.0 +def test_parse_amount_unicode_minus_and_spaces(): + """Sweden writes negatives with U+2212 and groups thousands with (narrow) NBSP. + + float() rejects both, and the parser's fallback returned 0.0 — every negative + kronor amount (fees, taxes, transfers) of the Jan-2026 file silently vanished + while the positives kept adding up (control C2 caught the +165,810.53 drift).""" + assert parse_amount("−35,70") == -35.70 # −35,70 + assert parse_amount("−78 690,40") == -78690.40 # −78 690,40 (NBSP) + assert parse_amount("−1 234,56") == -1234.56 # narrow NBSP thousands + assert parse_amount("1 234,56") == 1234.56 + assert parse_amount("–6,24") == -6.24 # en dash used as minus + + +def test_second_amount_column_is_summed_not_dropped(tmp_path: Path): + """Australia carries BOTH 'sales tax collected' and 'low value goods' (LVIG GST); + the row `total` includes both. Dropping the second column failed control C2 by its + sum. Amount-field collisions are summed; only non-amount collisions stay errors.""" + body = ( + '"preamble"\n' + '"date/time","settlement ID","type","order ID","sales tax collected",' + '"low value goods","total"\n' + '"1 Jan 2026 00:00:00 UTC","123","Order","o-1","10,00","-6,60","3,40"\n' + ) + p = tmp_path / "2026JanMonthlyTransaction.csv" + p.write_text(body, encoding="utf-8-sig") + + reader = CsvReader(str(p)) + mapping = reader.detect() + assert mapping.sum_cols == {"sales_tax_collected": [("F", "low value goods")]} + assert not mapping.duplicate_fields + assert "F" not in mapping.unmapped + + rows = list(reader.iter_records()) + assert len(rows) == 1 + assert rows[0]["sales_tax_collected"] == pytest.approx(3.40) # 10.00 + (-6.60) + assert rows[0]["total"] == 3.40 + assert reader.file_meta.summed_fields == mapping.sum_cols + assert not reader.file_meta.unmapped_amount_sums + reader.close() + + def test_make_reader_routes_csv(tmp_path: Path): p = tmp_path / "sample.csv" p.write_text( diff --git a/ar-aging-app/backend/tests/test_fx_dashboard.py b/ar-aging-app/backend/tests/test_fx_dashboard.py new file mode 100644 index 0000000..050220c --- /dev/null +++ b/ar-aging-app/backend/tests/test_fx_dashboard.py @@ -0,0 +1,78 @@ +""" +Dual-currency dashboard figures. + +The AR Ledger shows every movement in the marketplace's local currency AND in USD, +converted at each TRANSACTION DATE's exchange 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. + +Also the regression for the fx-daily endpoint: after payouts moved out of _daily_rows +into _payout_events, fx_daily still unpacked 4-tuples and crashed on every session with +data — the "Daily exchange rates" table never rendered. + +Fixture dates (make_amazon_xlsx, USA, month-end 2026-01-31, lag 2 → cutoff Jan 29): + revenue: Jan 5 +1000 · Jan 10 +300 · Jan 15 +2000 · Jan 20 +80 · Jan 31 +500 + payouts: Jan 6 −1000 (received) · Jan 12 −300 (received) · Jan 30 −2000 (in transit) + → closing = 3880 − 1300 = 2580 +""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.api.main import app +from app.db.database import init_db +from tests.test_payout_receipts import _fresh + + +def test_fx_daily_no_longer_crashes_and_covers_payout_dates(): + init_db() + with TestClient(app) as c: + sid = _fresh(c, "fx daily regression") + r = c.get(f"/api/sessions/{sid}/fx-daily") + assert r.status_code == 200 + data = r.json() + assert data["available"] is True + + by_date = {row["date"]: row for row in data["rows"]} + # Revenue sits on its transaction date… + assert by_date["2026-01-15"]["revenue"] == 2000.0 + # …and payouts on their effective date, in the same table. + assert by_date["2026-01-06"]["payouts"] == -1000.0 + assert by_date["2026-01-30"]["payouts"] == -2000.0 + + # USA converts 1:1 — USD total equals local total = 3880 − 3300 net movement. + assert data["month_rate"] == 1.0 + assert data["total_local"] == 580.0 + assert data["total_usd"] == data["total_local"] + + +def test_ledger_detail_shows_usd_at_transaction_date_rates(): + init_db() + with TestClient(app) as c: + sid = _fresh(c, "dual currency ledger") + # Daily override for the big revenue day; every other date uses the month rate. + assert c.put(f"/api/sessions/{sid}/fx-daily", json=[ + {"marketplace": "USA", "rate_date": "2026-01-15", "rate": 1.25}, + ]).status_code == 200 + + d = c.get(f"/api/sessions/{sid}/ledger-detail").json() + per = {p["key"]: p for p in d["periods"]} + + # Jan 15's revenue converts at ITS OWN day's rate… + assert per["2026-01-15"]["revenue"] == 2000.0 + assert per["2026-01-15"]["revenue_usd"] == 2500.0 + # …every other date at the month rate (1.0 for USA). + assert per["2026-01-05"]["revenue_usd"] == per["2026-01-05"]["revenue"] == 1000.0 + assert per["2026-01-06"]["payouts_received_usd"] == -1000.0 + assert per["2026-01-30"]["payouts_in_transit_usd"] == -2000.0 + + # Opening has no transaction date → month rate; the USD running balance then + # absorbs the daily-rate spread: closing_usd = closing + 2000 × (1.25 − 1). + assert d["month_rate"] == 1.0 + assert d["opening_usd"] == 0.0 + assert d["closing"] == 2580.0 + assert d["closing_usd"] == 3080.0 + assert d["in_transit_total_usd"] == -2000.0 + + # The local-currency figures are untouched by the daily override. + assert per["2026-01-15"]["balance"] == per["2026-01-15"]["balance_usd"] - 500.0 diff --git a/ar-aging-app/frontend/src/api/client.ts b/ar-aging-app/frontend/src/api/client.ts index ae9e8d1..640fa4b 100644 --- a/ar-aging-app/frontend/src/api/client.ts +++ b/ar-aging-app/frontend/src/api/client.ts @@ -279,6 +279,9 @@ export interface FinanceSummaryT extends BlockableT { export interface LedgerPeriodT { key: string; label: string; revenue: number; payouts_received: number; payouts_in_transit: number; rows: number; balance: number; + /** USD equivalents, converted at each transaction date's FX rate. */ + revenue_usd: number; payouts_received_usd: number; + payouts_in_transit_usd: number; balance_usd: number; } export interface LedgerDetailT { available: boolean; @@ -286,6 +289,13 @@ export interface LedgerDetailT { granularity?: string; date_from?: string | null; date_to?: string | null; opening?: number; periods?: LedgerPeriodT[]; closing?: number; session_closing?: number; filtered?: boolean; in_transit_total?: number; + /** The marketplace month rate; the opening balance converts at this rate. */ + month_rate?: number; + opening_usd?: number; + /** Roll-forward valued at transaction-date rates — differs from closing × month rate + * whenever daily overrides exist. */ + closing_usd?: number; + in_transit_total_usd?: number; } export interface FxDailyRowT { @@ -563,7 +573,8 @@ export const api = { putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) => req(`/sessions/${id}/reserves`, { method: "PUT", body: JSON.stringify(items) }), getFx: (id: number) => - req<{ marketplace: string; currency: string; rate: number }[]>(`/sessions/${id}/fx`), + req<{ marketplace: string; currency: string; rate: number; + source: string; rate_date: string | null }[]>(`/sessions/${id}/fx`), putFx: (id: number, items: { marketplace: string; currency: string; rate: number }[]) => req(`/sessions/${id}/fx`, { method: "PUT", body: JSON.stringify(items) }), diff --git a/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx b/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx index c6605c3..517df67 100644 --- a/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx +++ b/ar-aging-app/frontend/src/pages/closing/ArLedger.tsx @@ -54,6 +54,13 @@ export default function ArLedger() { const mkt = mv.marketplace ?? "USA"; const cur = mv.currency ?? "USD"; const m = (v: number | null | undefined, dp = 0) => money(v, cur, dp); + // USD-reporting marketplaces would just repeat every figure — show the pair only when + // the local currency actually differs. + const dual = cur !== "USD"; + const inUsd = (v: number | null | undefined, dp = 2) => + dual && v != null ? ( +
In-transit payouts of {m(mv.in_transit_payouts)} remain
in receivable (not yet cleared).
@@ -165,7 +180,8 @@ export default function ArLedger() {
{/* ---------------- date-filtered movement ---------------- */}
- Review the rates on the Settings tab first — confirming records who accepted them and when.
+ {dirty
+ ? "Save the corrected rates first, then confirm them."
+ : "Correct any rate that changed, then confirm — confirming records who accepted these rates and when."}
{(confirmFx.error as Error).message}
+ {((confirmFx.error || saveFx.error) as Error).message}
+
{(detail?.periods ?? []).map((p) => (
Opening
- {m(detail?.opening)}
+
+ {m(detail?.opening)}
+ {inUsd(detail?.opening_usd)}
+
))}
{p.label}
{p.rows.toLocaleString()}
- {acct(p.revenue)}
+
+ {acct(p.revenue)}
+ {inUsd(p.revenue_usd)}
+
{p.payouts_received ? acct(p.payouts_received) : ""}
+ {p.payouts_received ? inUsd(p.payouts_received_usd) : null}
{p.payouts_in_transit ? acct(p.payouts_in_transit) : ""}
+ {p.payouts_in_transit ? inUsd(p.payouts_in_transit_usd) : null}
+
+
+ {m(p.balance)}
+ {inUsd(p.balance_usd)}
- {m(p.balance)}
diff --git a/ar-aging-app/frontend/src/pages/closing/Controls.tsx b/ar-aging-app/frontend/src/pages/closing/Controls.tsx
index c6027d2..7f1abe6 100644
--- a/ar-aging-app/frontend/src/pages/closing/Controls.tsx
+++ b/ar-aging-app/frontend/src/pages/closing/Controls.tsx
@@ -23,27 +23,45 @@ export default function Controls() {
const qc = useQueryClient();
const [who, setWho] = useState("");
+ const [edits, setEdits] = useStateClosing
- {m(detail?.closing)}
+
+ {m(detail?.closing)}
+ {inUsd(detail?.closing_usd)}
+
+
+
+
+
+ {(fx ?? []).map((r) => (
+ Marketplace Currency
+ Rate → USD Source
+
+
+ ))}
+ {!fx?.length && (
+ {r.marketplace}
+ {r.currency}
+
+
+ setEdits((p) => ({ ...p, [r.marketplace]: e.target.value }))} />
+
+ {r.source}
+
+ )}
+
+ No rates yet — process the closing first.