"""Fast unit tests for the engine (synthetic data, no large files).""" from __future__ import annotations from datetime import date from app.core.dates import parse_amazon_date, parse_amazon_datetime from app.core.column_map import build_mapping, normalize_header, resolve_field from app.core.settlements import aggregate, classify from app.core.receivable import compute_receivable, classify_aging # --------------------------------------------------------------------------- dates def test_parse_amazon_dates(): assert parse_amazon_date("Jan 15, 2026 5:45:40 PM PST") == date(2026, 1, 15) assert parse_amazon_date("Jan 30, 2026 9:40:25 PM PST") == date(2026, 1, 30) assert parse_amazon_date("Dec 1, 2025 12:00:00 AM PST") == date(2025, 12, 1) assert parse_amazon_date("2026-01-31 23:59:59") == date(2026, 1, 31) assert parse_amazon_date("garbage") is None dt, tz = parse_amazon_datetime("Jan 15, 2026 5:45:40 PM PST") assert dt.hour == 17 and tz == "PST" # --------------------------------------------------------------------------- mapping def test_column_mapping_normalizes_headers(): assert normalize_header("Order-ID") == "order id" assert normalize_header(" Total ") == "total" header = [("A", "date/time"), ("B", "settlement id"), ("C", "type"), ("I", "account type"), ("AD", "total"), ("Z", "SomeUnknownCol")] m = build_mapping(header, header_row=8) assert m.is_valid assert m.field_to_col["settlement_id"] == "B" assert m.field_to_col["total"] == "AD" assert "Z" in m.unmapped def test_column_mapping_missing_required(): m = build_mapping([("A", "date/time"), ("B", "sku")], header_row=8) assert not m.is_valid assert "settlement_id" in m.missing_required assert "total" in m.missing_required def test_reference_workbook_aliases_resolve(): """Every header variant from the finance team's per-marketplace reference workbook must resolve. Doubles as a collision guard: _ALIAS_TO_FIELD is first-wins, so an alias later hijacked by an earlier field makes the expected mapping here fail.""" expected = [ # promotional rebates family ("Promotional Discounts", "promotional_rebates"), ("Total Discounts", "promotional_rebates"), ("promosyon indirimleri", "promotional_rebates"), ("Tax on Promotional Discounts", "promotional_rebates_tax"), # shipping credits family ("Shipping Credit", "shipping_credits"), ("kargo kredileri", "shipping_credits"), ("Tax on Shipping Credit", "shipping_credits_tax"), ("Tax on Shipping Credits", "shipping_credits_tax"), # gift wrap family ("Gift Wrap Credit", "gift_wrap_credits"), ("Tax on Gift Wrap Credit", "giftwrap_credits_tax"), ("Tax on Gift Wrap Credits", "giftwrap_credits_tax"), # other amount columns ("Marketplace Withheld VAT", "marketplace_withheld_tax"), ("ürün satışları", "product_sales"), ("satış ücretleri", "selling_fees"), ("Amazon Lojistik ücretleri", "fba_fees"), ("diğer işlem ücretleri", "other_transaction_fees"), ("diğer", "other"), # transaction release date translations ("Freigabedatum der Transaktion", "transaction_release_date"), ("Date de sortie de la transaction", "transaction_release_date"), ("Data di rilascio della transazione", "transaction_release_date"), ("Fecha de liberación de la transacción", "transaction_release_date"), ("Publicatiedatum van transactie", "transaction_release_date"), ("Data zrealizowania transakcji", "transaction_release_date"), ("Transaktionens utgivningsdatum", "transaction_release_date"), ("İşlem çıkış tarihi", "transaction_release_date"), # transaction status translations ("Transactiestatus", "transaction_status"), ("Status transakcji", "transaction_status"), ("İşlem durumu", "transaction_status"), # location / fulfillment variants ("Order State/Province", "order_state"), ("State/Province", "order_state"), ("Order Region/Province", "order_state"), ("Order Province/State", "order_state"), ("Order Region/Autonomous Community", "order_state"), ("sipariş durumu", "order_state"), ("Order Postal Code", "order_postal"), ("sipariş postası", "order_postal"), ("sipariş şehri", "order_city"), ("Shipping/Fulfillment", "fulfillment"), ("Fulfillment/Shipping", "fulfillment"), ("gönderim", "fulfillment"), ] for header, want in expected: assert resolve_field(header) == want, f"{header!r} -> {resolve_field(header)!r}, want {want!r}" # Pre-existing aliases that must not be hijacked by the additions above. assert resolve_field("shipping") == "shipping_credits" assert resolve_field("Transaktionsstatus") == "transaction_status" assert resolve_field("total des réductions") == "promotional_rebates" # --------------------------------------------------------------------------- aging def test_aging_bands(): assert classify_aging(None) == "Current" assert classify_aging(0) == "Current" assert classify_aging(15) == "1-30" assert classify_aging(45) == "31-60" assert classify_aging(75) == "61-90" assert classify_aging(120) == "91-Over" def test_aging_band_schemes(): from app.core.receivable import AGING_BANDS, aging_bands assert aging_bands("monthly") == AGING_BANDS # default stays the classic bands assert aging_bands("weekly") == ("Current", "1-7", "8-14", "15-21", "22-28", "29-Over") assert aging_bands("half_year") == ("Current", "1-180", "181-360", "361-540", "541-Over") assert aging_bands("yearly") == ("Current", "1-365", "366-730", "731-1095", "1096-Over") assert aging_bands("nonsense") == AGING_BANDS # unknown scheme falls back assert classify_aging(5, "weekly") == "1-7" assert classify_aging(14, "weekly") == "8-14" assert classify_aging(35, "weekly") == "29-Over" assert classify_aging(120, "half_year") == "1-180" assert classify_aging(400, "yearly") == "366-730" assert classify_aging(2000, "yearly") == "1096-Over" assert classify_aging(0, "weekly") == "Current" # --------------------------------------------------------------------------- settlements def _rec(total, ttype, acct, sid, d, mkt="USA"): return { "total": total, "txn_type": ttype, "account_type": acct, "settlement_id": sid, "_date": d, "_marketplace": mkt, "date_time": d.isoformat() if d else None, } def _scenario(): # Standard: 100 paid (payout tagged 200, Jan 6 received), # 200 receivable (payout tagged 300, Jan 30 in-transit), # 300 receivable (no payout yet). recs = [] recs += [_rec(1000.0, "Order", "Standard Orders", "100", date(2026, 1, 5))] recs += [_rec(2000.0, "Order", "Standard Orders", "200", date(2026, 1, 15))] recs += [_rec(500.0, "Order", "Standard Orders", "300", date(2026, 1, 31))] # transfers (tagged to the next/open settlement) recs += [_rec(-1000.0, "Transfer", "Standard Orders", "200", date(2026, 1, 6))] recs += [_rec(-2000.0, "Transfer", "Standard Orders", "300", date(2026, 1, 30))] # an Invoiced chain, independent recs += [_rec(300.0, "Order", "Invoiced Orders", "150", date(2026, 1, 10))] recs += [_rec(-300.0, "Transfer", "Invoiced Orders", "250", date(2026, 1, 12))] recs += [_rec(80.0, "Order", "Invoiced Orders", "250", date(2026, 1, 20))] return recs def test_classification_and_receivable(): agg = aggregate(_scenario()) cls = classify(agg, month_end=date(2026, 1, 31), clearing_lag_days=2) def status(acct, sid): return agg.settlements[("USA", acct, sid)].status assert status("Standard Orders", "100") == "paid" assert status("Standard Orders", "200") == "receivable" assert status("Standard Orders", "300") == "receivable" assert status("Invoiced Orders", "150") == "paid" assert status("Invoiced Orders", "250") == "receivable" # Jan-30 transfer is in-transit; Jan-6 transfer received. by_sid = {(t.account_type, t.settlement_id): t for t in agg.transfers} assert by_sid[("Standard Orders", "300")].received is False assert by_sid[("Standard Orders", "200")].received is True rec = compute_receivable(agg, cls) usa = rec.marketplaces["USA"] assert usa.accounts["Standard Orders"].additional_sales == 2500.0 # 2000 + 500 assert usa.accounts["Invoiced Orders"].additional_sales == 80.0 assert usa.receivable_local == 2580 # rounded total def test_clearing_lag_override_moves_boundary(): # With lag=0, the Jan-30 transfer counts as received -> settlement 300 becomes paid. agg = aggregate(_scenario()) cls = classify(agg, month_end=date(2026, 1, 31), clearing_lag_days=0) assert agg.settlements[("USA", "Standard Orders", "300")].status == "receivable" # boundary = max received tagged = 300 -> only >=300 receivable; 200 becomes paid assert agg.settlements[("USA", "Standard Orders", "200")].status == "paid" rec = compute_receivable(agg, cls) assert rec.marketplaces["USA"].accounts["Standard Orders"].additional_sales == 500.0