fix isuses

main
Talha Ahmed 2026-08-18 19:37:57 +05:00
parent 60c4489415
commit c322437599
14 changed files with 385 additions and 23 deletions

1
.gitignore vendored
View File

@ -20,6 +20,7 @@
*.xls *.xls
*.csv *.csv
*.tsv *.tsv
Test Files/
Amazon Transactions reports*/ Amazon Transactions reports*/
Accounts Receivable*/ Accounts Receivable*/
!ar-aging-app/backend/tests/fixtures/*.xlsx !ar-aging-app/backend/tests/fixtures/*.xlsx

View File

@ -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") 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
# 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: def new_bucket(key: str, label: str) -> dict:
return {"key": key, "label": label, "revenue": 0.0, return {"key": key, "label": label, "revenue": 0.0,
"payouts_received": 0.0, "payouts_in_transit": 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 # Revenue buckets by transaction date; payouts by their EFFECTIVE date — the bank
# receipt's date when Finance entered one, Amazon's transfer date otherwise. # 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) key, label = _bucket(d, granularity)
b = buckets.setdefault(key, new_bucket(key, label)) b = buckets.setdefault(key, new_bucket(key, label))
b["revenue"] += revenue b["revenue"] += revenue
b["revenue_usd"] += revenue * rate_of(d)
b["rows"] += n b["rows"] += n
for d, amount, received, bank_dated in _payout_events(db, s, mkt): for d, amount, received, bank_dated in _payout_events(db, s, mkt):
if frm and (d is None or d < frm): 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)) b = buckets.setdefault(key, new_bucket(key, label))
if amount: if amount:
b["payouts_received" if received else "payouts_in_transit"] += 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: if bank_dated:
b["bank_dated"] += amount b["bank_dated"] += amount
b["rows"] += 1 b["rows"] += 1
opening = mv["opening"] opening = mv["opening"]
running = opening running = opening
opening_usd = opening * month_rate
running_usd = opening_usd
out = [] out = []
for key in sorted(buckets): for key in sorted(buckets):
b = buckets[key] b = buckets[key]
running += b["revenue"] + b["payouts_received"] running += b["revenue"] + b["payouts_received"]
running_usd += b["revenue_usd"] + b["payouts_received_usd"]
out.append({ out.append({
"key": b["key"], "label": b["label"], "key": b["key"], "label": b["label"],
"revenue": round(b["revenue"], 2), "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 "bank_dated": round(b["bank_dated"], 2), # payout amounts placed by bank date
"rows": b["rows"], "rows": b["rows"],
"balance": round(running, 2), "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) filtered = bool(frm or to)
@ -235,6 +255,12 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
"session_closing": mv["closing"], "session_closing": mv["closing"],
"filtered": filtered, "filtered": filtered,
"in_transit_total": round(sum(p["payouts_in_transit"] for p in out), 2), "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, date_from: str | None = None, date_to: str | None = None,
db: OrmSession = Depends(db_dep)) -> dict: db: OrmSession = Depends(db_dep)) -> dict:
"""Per-date local value, the USD rate applied, and the USD equivalent.""" """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) mv = _movement_for(db, session_id, marketplace)
if not mv.get("available"): if not mv.get("available"):
return {"available": False} 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) month_rate, daily = _fx_for(db, session_id, mkt)
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 = [] # Revenue by transaction date; payouts by their EFFECTIVE date (bank receipt when one
tot_local = tot_usd = 0.0 # was entered, Amazon's transfer date otherwise) — the same placement the ledger uses,
for d, revenue, payout, n in _daily_rows(db, session_id, mkt, frm, to): # 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: if d is None:
continue 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 local = revenue + payout
rate, source = daily.get(d, (month_rate, "month rate")) rate, source = daily.get(d, (month_rate, "month rate"))
usd = local * rate usd = local * rate
@ -272,7 +314,7 @@ def fx_daily(session_id: int, marketplace: str | None = None,
tot_usd += usd tot_usd += usd
rows.append({ rows.append({
"date": d.isoformat(), "local": round(local, 2), "rate": rate, "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), "revenue": round(revenue, 2), "payouts": round(payout, 2),
}) })
return { return {

View File

@ -32,6 +32,7 @@ class CalamineReader:
self.header_row = 0 # 1-based (Excel) self.header_row = 0 # 1-based (Excel)
self.column_mapping: ColumnMapping | None = None self.column_mapping: ColumnMapping | None = None
self._field_to_idx: dict[str, int] = {} self._field_to_idx: dict[str, int] = {}
self._sum_field_idx: dict[str, list[int]] = {}
self.file_meta = FileMeta(filename=self.filename) self.file_meta = FileMeta(filename=self.filename)
# -- lifecycle -- # -- lifecycle --
@ -98,11 +99,17 @@ class CalamineReader:
self._field_to_idx = { self._field_to_idx = {
fld: _letter_to_idx(col) for col, fld in mapping.col_to_field.items() 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.data_sheet = name
self.file_meta.header_row = self.header_row self.file_meta.header_row = self.header_row
self.file_meta.unmapped_headers = mapping.unmapped self.file_meta.unmapped_headers = mapping.unmapped
self.file_meta.missing_required = mapping.missing_required self.file_meta.missing_required = mapping.missing_required
self.file_meta.duplicate_fields = mapping.duplicate_fields 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 self.file_meta.sheet_last_row = self._safe_height(name) # control C1
return mapping return mapping
@ -126,10 +133,15 @@ class CalamineReader:
self.detect() self.detect()
assert self._sheet is not None assert self._sheet is not None
idx_map = self._field_to_idx idx_map = self._field_to_idx
sum_map = self._sum_field_idx
if only_fields is not None: if only_fields is not None:
idx_map = {f: i for f, i in idx_map.items() if f in only_fields} 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()) items = list(idx_map.items())
sum_items = list(sum_map.items())
mapped_idx = set(self._field_to_idx.values()) 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 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) hdr = self.header_row # 1-based; data starts at hdr+1 (Excel) => row index hdr (0-based)
min_d: date | None = None min_d: date | None = None
@ -153,6 +165,12 @@ class CalamineReader:
# this reader emitted trailing blank rows the other reader dropped. # this reader emitted trailing blank rows the other reader dropped.
if v not in (None, ""): if v not in (None, ""):
has_value = True 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: if not has_value:
self.file_meta.blank_rows_skipped += 1 self.file_meta.blank_rows_skipped += 1
continue continue

View File

@ -346,6 +346,11 @@ class ColumnMapping:
# Only the first is used, so the second column's amounts would vanish from the journal. # 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`. # Surfaced as an error rather than silently demoted to `unmapped`.
duplicate_fields: dict[str, list[tuple[str, str]]] = field(default_factory=dict) 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 header_row: int = 0
@property @property
@ -379,8 +384,15 @@ def build_mapping(
m.col_to_field[col] = fld m.col_to_field[col] = fld
m.field_to_col[fld] = col m.field_to_col[fld] = col
elif fld: elif fld:
# Collision: first column wins and this one is dropped. Record both so the close if FIELD_KIND.get(fld) == "amount":
# can raise, instead of quietly excluding a whole amount column. # 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] first_col = m.field_to_col[fld]
m.duplicate_fields.setdefault(fld, [(first_col, "")]).append((col, str(text))) m.duplicate_fields.setdefault(fld, [(first_col, "")]).append((col, str(text)))
m.unmapped[col] = str(text) m.unmapped[col] = str(text)

View File

@ -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: def parse_amount(raw) -> float:
"""Parse Amazon amount cells, including European '1.234,56' / '13,49' forms.""" """Parse Amazon amount cells, including European '1.234,56' / '13,49' forms."""
if raw is None or raw == "": if raw is None or raw == "":
@ -33,7 +44,7 @@ def parse_amount(raw) -> float:
return 0.0 return 0.0
if isinstance(raw, (int, float)): if isinstance(raw, (int, float)):
return float(raw) return float(raw)
s = str(raw).strip().replace("\u00a0", "").replace(" ", "") s = str(raw).strip().translate(_AMOUNT_CLEANUP)
if not s: if not s:
return 0.0 return 0.0
# European: decimal comma, optional thousands dots / spaces. # European: decimal comma, optional thousands dots / spaces.
@ -93,6 +104,7 @@ class CsvReader:
self.header_row = 0 # 1-based, matching Excel readers self.header_row = 0 # 1-based, matching Excel readers
self.column_mapping: ColumnMapping | None = None self.column_mapping: ColumnMapping | None = None
self._field_to_idx: dict[str, int] = {} self._field_to_idx: dict[str, int] = {}
self._sum_field_idx: dict[str, list[int]] = {}
self.file_meta = FileMeta(filename=self.filename) self.file_meta = FileMeta(filename=self.filename)
def open(self) -> None: def open(self) -> None:
@ -172,11 +184,17 @@ class CsvReader:
self._field_to_idx = { self._field_to_idx = {
fld: _col_to_idx(col) for col, fld in mapping.col_to_field.items() 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.data_sheet = self.sheet_name
self.file_meta.header_row = self.header_row self.file_meta.header_row = self.header_row
self.file_meta.unmapped_headers = mapping.unmapped self.file_meta.unmapped_headers = mapping.unmapped
self.file_meta.missing_required = mapping.missing_required self.file_meta.missing_required = mapping.missing_required
self.file_meta.duplicate_fields = mapping.duplicate_fields 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) self.file_meta.sheet_last_row = len(self._rows)
return mapping return mapping
@ -185,10 +203,15 @@ class CsvReader:
self.detect() self.detect()
assert self._rows is not None assert self._rows is not None
idx_map = self._field_to_idx idx_map = self._field_to_idx
sum_map = self._sum_field_idx
if only_fields is not None: if only_fields is not None:
idx_map = {f: i for f, i in idx_map.items() if f in only_fields} 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()) items = list(idx_map.items())
sum_items = list(sum_map.items())
mapped_idx = set(self._field_to_idx.values()) 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 unmapped_sums = self.file_meta.unmapped_amount_sums
hdr = self.header_row hdr = self.header_row
min_d: date | None = None min_d: date | None = None
@ -211,6 +234,12 @@ class CsvReader:
rec[fld] = _convert(fld, v) rec[fld] = _convert(fld, v)
if v not in (None, ""): if v not in (None, ""):
has_value = True 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: if not has_value:
self.file_meta.blank_rows_skipped += 1 self.file_meta.blank_rows_skipped += 1
continue continue

View File

@ -69,6 +69,9 @@ class FileMeta:
missing_required: list[str] = field(default_factory=list) missing_required: list[str] = field(default_factory=list)
# canonical field -> the columns that both claimed it (only the first is used) # canonical field -> the columns that both claimed it (only the first is used)
duplicate_fields: dict[str, list] = field(default_factory=dict) 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. # Finance-added translation/helper header rows found below the real header and skipped.
helper_rows_skipped: int = 0 helper_rows_skipped: int = 0
# column-letter -> Σ of numeric values seen in columns with NO mapped field. # 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.unmapped_headers = mapping.unmapped
self.file_meta.missing_required = mapping.missing_required self.file_meta.missing_required = mapping.missing_required
self.file_meta.duplicate_fields = mapping.duplicate_fields 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) self.file_meta.sheet_last_row = self._declared_last_row(part)
return mapping return mapping
@ -284,6 +288,9 @@ class TransactionReader:
assert self.column_mapping is not None and self._zip is not None assert self.column_mapping is not None and self._zip is not None
shared = self._shared_strings() shared = self._shared_strings()
col_to_field = self.column_mapping.col_to_field 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 want = only_fields
min_d: date | None = None min_d: date | None = None
max_d: date | None = None max_d: date | None = None
@ -303,6 +310,14 @@ class TransactionReader:
for col, val in cells.items(): for col, val in cells.items():
fld = col_to_field.get(col) fld = col_to_field.get(col)
if not fld: 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. # No amount is silently excluded: sum numeric data in unmapped columns.
if val not in (None, ""): if val not in (None, ""):
try: try:

View File

@ -287,6 +287,13 @@ def _exceptions_from(result: ProcessResult) -> list[dict]:
f"used, so the others are excluded from every total — " f"used, so the others are excluded from every total — "
f"correct the header mapping before relying on this close."), f"correct the header mapping before relying on this close."),
"source": m.filename}) "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(): for col, s in (getattr(m, "unmapped_amount_sums", None) or {}).items():
if abs(s) > 0.005: if abs(s) > 0.005:
out.append({"category": "unmapped_amounts", "severity": "error", out.append({"category": "unmapped_amounts", "severity": "error",

View File

@ -3,6 +3,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
from app.core.csv_reader import CsvReader, parse_amount from app.core.csv_reader import CsvReader, parse_amount
from app.core.readers import make_reader from app.core.readers import make_reader
@ -16,6 +18,47 @@ def test_parse_amount_european():
assert parse_amount("") == 0.0 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("1234,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): def test_make_reader_routes_csv(tmp_path: Path):
p = tmp_path / "sample.csv" p = tmp_path / "sample.csv"
p.write_text( p.write_text(

View File

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

View File

@ -279,6 +279,9 @@ export interface FinanceSummaryT extends BlockableT {
export interface LedgerPeriodT { export interface LedgerPeriodT {
key: string; label: string; revenue: number; payouts_received: number; key: string; label: string; revenue: number; payouts_received: number;
payouts_in_transit: number; rows: number; balance: 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 { export interface LedgerDetailT {
available: boolean; available: boolean;
@ -286,6 +289,13 @@ export interface LedgerDetailT {
granularity?: string; date_from?: string | null; date_to?: string | null; granularity?: string; date_from?: string | null; date_to?: string | null;
opening?: number; periods?: LedgerPeriodT[]; closing?: number; opening?: number; periods?: LedgerPeriodT[]; closing?: number;
session_closing?: number; filtered?: boolean; in_transit_total?: 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 { export interface FxDailyRowT {
@ -563,7 +573,8 @@ export const api = {
putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) => putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) =>
req(`/sessions/${id}/reserves`, { method: "PUT", body: JSON.stringify(items) }), req(`/sessions/${id}/reserves`, { method: "PUT", body: JSON.stringify(items) }),
getFx: (id: number) => 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 }[]) => putFx: (id: number, items: { marketplace: string; currency: string; rate: number }[]) =>
req(`/sessions/${id}/fx`, { method: "PUT", body: JSON.stringify(items) }), req(`/sessions/${id}/fx`, { method: "PUT", body: JSON.stringify(items) }),

View File

@ -54,6 +54,13 @@ export default function ArLedger() {
const mkt = mv.marketplace ?? "USA"; const mkt = mv.marketplace ?? "USA";
const cur = mv.currency ?? "USD"; const cur = mv.currency ?? "USD";
const m = (v: number | null | undefined, dp = 0) => money(v, cur, dp); 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 ? (
<div className="text-[11px] leading-tight text-subink">{money(v, "USD", dp)}</div>
) : null;
const opening = openings?.find((o) => o.marketplace === mkt); const opening = openings?.find((o) => o.marketplace === mkt);
const diff = mv.difference_vs_settlement ?? 0; const diff = mv.difference_vs_settlement ?? 0;
const reconciled = Math.abs(diff) < 1; const reconciled = Math.abs(diff) < 1;
@ -100,6 +107,14 @@ export default function ArLedger() {
<span className="font-semibold text-primary">= Closing receivable</span> <span className="font-semibold text-primary">= Closing receivable</span>
<span className="num text-lg font-semibold text-primary">{m(mv.closing)}</span> <span className="num text-lg font-semibold text-primary">{m(mv.closing)}</span>
</div> </div>
{dual && detail?.month_rate != null && (
<div className="flex items-center justify-between text-xs text-subink">
<span>in USD @ month rate {num(detail.month_rate, 6)}</span>
<span className="num font-medium">
{money((mv.closing ?? 0) * detail.month_rate, "USD", 2)}
</span>
</div>
)}
<p className="text-xs text-subink pt-2"> <p className="text-xs text-subink pt-2">
In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain
in receivable (not yet cleared). in receivable (not yet cleared).
@ -165,7 +180,8 @@ export default function ArLedger() {
{/* ---------------- date-filtered movement ---------------- */} {/* ---------------- date-filtered movement ---------------- */}
<Section title="Movement by date" <Section title="Movement by date"
subtitle="Daily, weekly or monthly view of the same ledger. Unfiltered, the running balance ends at the closing receivable."> subtitle={`Daily, weekly or monthly view of the same ledger. Unfiltered, the running balance ends at the closing receivable.${
dual ? " USD figures (grey) are converted at each transaction date's exchange rate." : ""}`}>
<div className="p-4 flex flex-wrap items-end gap-3 border-b border-line"> <div className="p-4 flex flex-wrap items-end gap-3 border-b border-line">
<div className="flex gap-1 p-1 rounded-xl bg-neutralbg"> <div className="flex gap-1 p-1 rounded-xl bg-neutralbg">
{(["day", "week", "month"] as Gran[]).map((g) => ( {(["day", "week", "month"] as Gran[]).map((g) => (
@ -213,26 +229,40 @@ export default function ArLedger() {
<tr className="bg-neutralbg/50 font-medium"> <tr className="bg-neutralbg/50 font-medium">
<td className="td">Opening</td> <td className="td">Opening</td>
<td className="td" /><td className="td" /><td className="td" /><td className="td" /> <td className="td" /><td className="td" /><td className="td" /><td className="td" />
<td className="td text-right num">{m(detail?.opening)}</td> <td className="td text-right num">
{m(detail?.opening)}
{inUsd(detail?.opening_usd)}
</td>
</tr> </tr>
{(detail?.periods ?? []).map((p) => ( {(detail?.periods ?? []).map((p) => (
<tr key={p.key}> <tr key={p.key}>
<td className="td">{p.label}</td> <td className="td">{p.label}</td>
<td className="td text-right num text-xs text-subink">{p.rows.toLocaleString()}</td> <td className="td text-right num text-xs text-subink">{p.rows.toLocaleString()}</td>
<td className="td text-right num">{acct(p.revenue)}</td> <td className="td text-right num">
{acct(p.revenue)}
{inUsd(p.revenue_usd)}
</td>
<td className="td text-right num text-bad"> <td className="td text-right num text-bad">
{p.payouts_received ? acct(p.payouts_received) : ""} {p.payouts_received ? acct(p.payouts_received) : ""}
{p.payouts_received ? inUsd(p.payouts_received_usd) : null}
</td> </td>
<td className="td text-right num text-warn"> <td className="td text-right num text-warn">
{p.payouts_in_transit ? acct(p.payouts_in_transit) : ""} {p.payouts_in_transit ? acct(p.payouts_in_transit) : ""}
{p.payouts_in_transit ? inUsd(p.payouts_in_transit_usd) : null}
</td>
<td className="td text-right num font-medium">
{m(p.balance)}
{inUsd(p.balance_usd)}
</td> </td>
<td className="td text-right num font-medium">{m(p.balance)}</td>
</tr> </tr>
))} ))}
<tr className="bg-primary-soft/50 font-semibold"> <tr className="bg-primary-soft/50 font-semibold">
<td className="td text-primary">Closing</td> <td className="td text-primary">Closing</td>
<td className="td" /><td className="td" /><td className="td" /><td className="td" /> <td className="td" /><td className="td" /><td className="td" /><td className="td" />
<td className="td text-right num text-primary">{m(detail?.closing)}</td> <td className="td text-right num text-primary">
{m(detail?.closing)}
{inUsd(detail?.closing_usd)}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -23,27 +23,45 @@ export default function Controls() {
const qc = useQueryClient(); const qc = useQueryClient();
const [who, setWho] = useState(""); const [who, setWho] = useState("");
const [edits, setEdits] = useState<Record<string, string>>({});
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["controls", id], queryFn: () => api.controls(id), queryKey: ["controls", id], queryFn: () => api.controls(id),
}); });
const fxFailing = (data?.controls ?? []).some((r) => r.key === "C5" && r.status === "fail");
const { data: fx } = useQuery({
queryKey: ["fx", id], queryFn: () => api.getFx(id), enabled: fxFailing,
});
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["controls", id] }); qc.invalidateQueries({ queryKey: ["controls", id] });
qc.invalidateQueries({ queryKey: ["session", id] }); qc.invalidateQueries({ queryKey: ["session", id] });
qc.invalidateQueries({ queryKey: ["summary", id] }); qc.invalidateQueries({ queryKey: ["summary", id] });
qc.invalidateQueries({ queryKey: ["sessions"] }); qc.invalidateQueries({ queryKey: ["sessions"] });
qc.invalidateQueries({ queryKey: ["fx", id] });
}; };
const rerun = useMutation({ mutationFn: () => api.runControls(id), onSuccess: invalidate }); const rerun = useMutation({ mutationFn: () => api.runControls(id), onSuccess: invalidate });
const confirmFx = useMutation({ const confirmFx = useMutation({
mutationFn: () => api.confirmAllFx(id, who.trim()), onSuccess: invalidate, mutationFn: () => api.confirmAllFx(id, who.trim()), onSuccess: invalidate,
}); });
// Saving marks the rates source=manual and (by design) withdraws any prior confirmation
// for a changed rate — the person then confirms the corrected value below.
const saveFx = useMutation({
mutationFn: () => api.putFx(id, (fx ?? []).map((r) => ({
marketplace: r.marketplace, currency: r.currency,
rate: Number(edits[r.marketplace] ?? r.rate),
}))),
onSuccess: () => { setEdits({}); invalidate(); },
});
if (isLoading) return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading controls</div>; if (isLoading) return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading controls</div>;
if (!data?.available) if (!data?.available)
return <EmptyState title="No controls have run yet." return <EmptyState title="No controls have run yet."
hint="Process the closing — the month-end controls run automatically at the end of processing." />; hint="Process the closing — the month-end controls run automatically at the end of processing." />;
const fxFailing = data.controls.some((r) => r.key === "C5" && r.status === "fail"); const dirty = Object.entries(edits).some(
([m, v]) => Number(v) !== (fx ?? []).find((r) => r.marketplace === m)?.rate);
const invalid = Object.values(edits).some((v) => !Number.isFinite(Number(v)) || Number(v) <= 0);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -76,23 +94,60 @@ export default function Controls() {
{fxFailing && ( {fxFailing && (
<Section title="Confirm exchange rates" <Section title="Confirm exchange rates"
subtitle={`Control C5 requires a rate confirmed for ${session.reporting_month ?? "this month"}. Seeded defaults are a January-2026 snapshot and are treated as missing.`}> subtitle={`Control C5 requires a rate confirmed for ${session.reporting_month ?? "this month"}. Seeded defaults are a January-2026 snapshot and are treated as missing.`}>
<div className="overflow-x-auto border-b border-line">
<table className="w-full">
<thead><tr>
<th className="th">Marketplace</th><th className="th">Currency</th>
<th className="th text-right">Rate USD</th><th className="th">Source</th>
</tr></thead>
<tbody>
{(fx ?? []).map((r) => (
<tr key={r.marketplace}>
<td className="td font-medium">{r.marketplace}</td>
<td className="td">{r.currency}</td>
<td className="td text-right">
<input className="input num w-36 py-1 text-right"
value={edits[r.marketplace] ?? String(r.rate)}
onChange={(e) =>
setEdits((p) => ({ ...p, [r.marketplace]: e.target.value }))} />
</td>
<td className="td text-xs text-subink">{r.source}</td>
</tr>
))}
{!fx?.length && (
<tr><td className="td text-subink" colSpan={4}>No rates yet process the closing first.</td></tr>
)}
</tbody>
</table>
</div>
<div className="p-4 flex flex-wrap items-end gap-3"> <div className="p-4 flex flex-wrap items-end gap-3">
{dirty && (
<button className="btn-ghost" disabled={invalid || saveFx.isPending}
onClick={() => saveFx.mutate()}>
{saveFx.isPending ? <Spinner /> : null} Save corrected rates
</button>
)}
<label className="text-sm"> <label className="text-sm">
<span className="block text-xs font-medium text-subink mb-1">Confirmed by</span> <span className="block text-xs font-medium text-subink mb-1">Confirmed by</span>
<input className="input" placeholder="Your name" value={who} <input className="input" placeholder="Your name" value={who}
onChange={(e) => setWho(e.target.value)} /> onChange={(e) => setWho(e.target.value)} />
</label> </label>
<button className="btn-primary" disabled={!who.trim() || confirmFx.isPending} <button className="btn-primary"
disabled={!who.trim() || dirty || confirmFx.isPending}
onClick={() => confirmFx.mutate()}> onClick={() => confirmFx.mutate()}>
{confirmFx.isPending ? <Spinner /> : <CheckCircle2 size={15} />} {confirmFx.isPending ? <Spinner /> : <CheckCircle2 size={15} />}
Confirm all rates for {session.reporting_month ?? "this month"} Confirm all rates for {session.reporting_month ?? "this month"}
</button> </button>
<p className="text-xs text-subink flex-1 min-w-[220px]"> <p className="text-xs text-subink flex-1 min-w-[220px]">
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."}
</p> </p>
</div> </div>
{confirmFx.isError && ( {(confirmFx.isError || saveFx.isError) && (
<p className="px-4 pb-4 text-sm text-bad">{(confirmFx.error as Error).message}</p> <p className="px-4 pb-4 text-sm text-bad">
{((confirmFx.error || saveFx.error) as Error).message}
</p>
)} )}
</Section> </Section>
)} )}

3
ar-aging-app/start.bat Normal file
View File

@ -0,0 +1,3 @@
@echo off
rem Double-click launcher: starts backend (8010) + frontend (5174) and opens the app.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start.ps1"

18
ar-aging-app/start.ps1 Normal file
View File

@ -0,0 +1,18 @@
# Launch the A/R Aging app on Windows.
# Ports 8010/5174 because the defaults (8000/5173) are used by the ATS project.
# Run with: powershell -ExecutionPolicy Bypass -File start.ps1
$app = $PSScriptRoot
$python = "$env:LOCALAPPDATA\anaconda3\envs\Talha\python.exe"
Start-Process powershell -ArgumentList @(
"-NoExit", "-Command",
"cd '$app\backend'; & '$python' -m uvicorn app.api.main:app --host 127.0.0.1 --port 8010 --reload"
)
Start-Process powershell -ArgumentList @(
"-NoExit", "-Command",
"cd '$app\frontend'; `$env:VITE_API_PROXY = 'http://localhost:8010'; npx vite --port 5174 --strictPort"
)
Start-Sleep -Seconds 4
Start-Process "http://localhost:5174"