fix isuses
parent
60c4489415
commit
c322437599
|
|
@ -20,6 +20,7 @@
|
|||
*.xls
|
||||
*.csv
|
||||
*.tsv
|
||||
Test Files/
|
||||
Amazon Transactions reports*/
|
||||
Accounts Receivable*/
|
||||
!ar-aging-app/backend/tests/fixtures/*.xlsx
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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) }),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<div className="text-[11px] leading-tight text-subink">{money(v, "USD", dp)}</div>
|
||||
) : null;
|
||||
const opening = openings?.find((o) => o.marketplace === mkt);
|
||||
const diff = mv.difference_vs_settlement ?? 0;
|
||||
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="num text-lg font-semibold text-primary">{m(mv.closing)}</span>
|
||||
</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">
|
||||
In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain
|
||||
in receivable (not yet cleared).
|
||||
|
|
@ -165,7 +180,8 @@ export default function ArLedger() {
|
|||
|
||||
{/* ---------------- date-filtered movement ---------------- */}
|
||||
<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="flex gap-1 p-1 rounded-xl bg-neutralbg">
|
||||
{(["day", "week", "month"] as Gran[]).map((g) => (
|
||||
|
|
@ -213,26 +229,40 @@ export default function ArLedger() {
|
|||
<tr className="bg-neutralbg/50 font-medium">
|
||||
<td className="td">Opening</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>
|
||||
{(detail?.periods ?? []).map((p) => (
|
||||
<tr key={p.key}>
|
||||
<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">{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">
|
||||
{p.payouts_received ? acct(p.payouts_received) : ""}
|
||||
{p.payouts_received ? inUsd(p.payouts_received_usd) : null}
|
||||
</td>
|
||||
<td className="td text-right num text-warn">
|
||||
{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 className="td text-right num font-medium">{m(p.balance)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-primary-soft/50 font-semibold">
|
||||
<td className="td text-primary">Closing</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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -23,27 +23,45 @@ export default function Controls() {
|
|||
const qc = useQueryClient();
|
||||
const [who, setWho] = useState("");
|
||||
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
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 = () => {
|
||||
qc.invalidateQueries({ queryKey: ["controls", id] });
|
||||
qc.invalidateQueries({ queryKey: ["session", id] });
|
||||
qc.invalidateQueries({ queryKey: ["summary", id] });
|
||||
qc.invalidateQueries({ queryKey: ["sessions"] });
|
||||
qc.invalidateQueries({ queryKey: ["fx", id] });
|
||||
};
|
||||
const rerun = useMutation({ mutationFn: () => api.runControls(id), onSuccess: invalidate });
|
||||
const confirmFx = useMutation({
|
||||
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 (!data?.available)
|
||||
return <EmptyState title="No controls have run yet."
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -76,23 +94,60 @@ export default function Controls() {
|
|||
{fxFailing && (
|
||||
<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.`}>
|
||||
<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">
|
||||
{dirty && (
|
||||
<button className="btn-ghost" disabled={invalid || saveFx.isPending}
|
||||
onClick={() => saveFx.mutate()}>
|
||||
{saveFx.isPending ? <Spinner /> : null} Save corrected rates
|
||||
</button>
|
||||
)}
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs font-medium text-subink mb-1">Confirmed by</span>
|
||||
<input className="input" placeholder="Your name" value={who}
|
||||
onChange={(e) => setWho(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn-primary" disabled={!who.trim() || confirmFx.isPending}
|
||||
<button className="btn-primary"
|
||||
disabled={!who.trim() || dirty || confirmFx.isPending}
|
||||
onClick={() => confirmFx.mutate()}>
|
||||
{confirmFx.isPending ? <Spinner /> : <CheckCircle2 size={15} />}
|
||||
Confirm all rates for {session.reporting_month ?? "this month"}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
{confirmFx.isError && (
|
||||
<p className="px-4 pb-4 text-sm text-bad">{(confirmFx.error as Error).message}</p>
|
||||
{(confirmFx.isError || saveFx.isError) && (
|
||||
<p className="px-4 pb-4 text-sm text-bad">
|
||||
{((confirmFx.error || saveFx.error) as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue