Finance-Accounts/ar-aging-app/backend/tests/test_engine_unit.py

193 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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