"""Finance ledger reads over a month RANGE — pivot / detail / vendor picker / export, plus the HTTP surface. Why this exists (2026-07-30): the explorer could only ask about ONE month, and its vendor filter was a single exact-match text box. "These vendors, this stretch of months, how much each" — the question finance actually asks at renewal / budget time — had no answer on this surface either. Summing months IS sound here (unlike the software-billing surface): every fact is USD-normalised at its own month's frozen FX rate. What a range can mix is closed and open months, so the properties pinned below are: * a range total equals the sum of its months, and a single ``period`` still behaves exactly as before (no silent widening); * open months in the range are REPORTED (``provisional_periods``) rather than blended silently into a number that reads as final; * the ``vendor`` filter takes a LIST (exact match per value) and the picker that feeds it lists the range's real vendors — narrowed by the other filters but NOT by the vendor selection itself; * the default no-basis range still drops the hardware CASH lens (the never-double-count invariant must survive the range rewrite); * detail rows carry their own ``period`` so a multi-month drill is readable, and the range is capped rather than unbounded. """ from __future__ import annotations from decimal import Decimal import pytest from app.core.exceptions import AppException from app.domains.finance import read_service from app.domains.finance.models import FinancePeriodClose, FinanceSpendFact pytestmark = pytest.mark.asyncio P1 = "2026-03" P2 = "2026-04" P3 = "2026-05" def _fact(period: str, *, cost_type="software", basis="consumption", usd="10", vendor="Figma", department="Design", key=None): return FinanceSpendFact( fact_key=key or f"rangetest|{period}|{cost_type}|{basis}|{vendor}|{usd}", period=period, cost_type=cost_type, basis=basis, sub_category="seat", vendor=vendor, department=department, amount_native=Decimal(usd), currency_native="USD", amount_usd=Decimal(usd), fx_basis="native_usd", allocation_source="rule", source_domain=cost_type, rollup_fingerprint="rangetest", is_provisional=False, ) async def _seed_three_months(db): """Figma 10/20/30 and Zoom 5/5/5 across P1..P3, all USD consumption.""" for period, figma in ((P1, "10"), (P2, "20"), (P3, "30")): db.add(_fact(period, usd=figma, vendor="Figma")) db.add(_fact(period, usd="5", vendor="Zoom")) await db.flush() async def test_pivot_range_sums_months_and_single_period_unchanged(db_session): await _seed_three_months(db_session) ranged = await read_service.pivot( db_session, [P1, P2, P3], ["vendor"], {}, ) by = {r["vendor"]: Decimal(r["usd"]) for r in ranged["rows"]} assert by["Figma"] == Decimal("60.00") # 10 + 20 + 30 assert by["Zoom"] == Decimal("15.00") # 5 × 3 assert Decimal(ranged["total_usd"]) == Decimal("75.00") assert ranged["periods"] == [P1, P2, P3] assert (ranged["start"], ranged["end"]) == (P1, P3) # A single month — passed as a bare string, the pre-range call shape — # must still mean exactly that month. one = await read_service.pivot(db_session, P2, ["vendor"], {}) assert {r["vendor"]: Decimal(r["usd"]) for r in one["rows"]}["Figma"] == ( Decimal("20.00") ) assert one["periods"] == [P2] async def test_pivot_range_reports_unclosed_months(db_session): await _seed_three_months(db_session) db_session.add(FinancePeriodClose(period=P1, status="closed")) db_session.add(FinancePeriodClose(period=P2, status="open")) await db_session.flush() data = await read_service.pivot(db_session, [P1, P2, P3], ["vendor"], {}) # P1 closed; P2 has a row but isn't closed; P3 has no close row at all. assert data["provisional_periods"] == [P2, P3] async def test_vendor_filter_takes_a_list(db_session): await _seed_three_months(db_session) db_session.add(_fact(P2, usd="99", vendor="Atlassian")) await db_session.flush() picked = await read_service.pivot( db_session, [P1, P2, P3], ["vendor"], {"vendor": ["Figma", "Zoom"]}, ) assert {r["vendor"] for r in picked["rows"]} == {"Figma", "Zoom"} assert Decimal(picked["total_usd"]) == Decimal("75.00") # A single string still works (bookmarked links / MCP single-vendor calls). solo = await read_service.pivot( db_session, [P1, P2, P3], ["vendor"], {"vendor": "Zoom"}, ) assert Decimal(solo["total_usd"]) == Decimal("15.00") async def test_vendor_picker_lists_range_vendors_ignoring_vendor_filter( db_session, ): await _seed_three_months(db_session) db_session.add(_fact(P2, usd="99", vendor="Atlassian", cost_type="ai")) await db_session.flush() # Biggest first, and the caller's own vendor selection must NOT narrow the # option list it feeds (else the dropdown collapses to what's picked). opts = await read_service.vendors_in_range( db_session, [P1, P2, P3], {"vendor": ["Figma"]}, ) assert [v["vendor"] for v in opts["vendors"]] == ["Atlassian", "Figma", "Zoom"] assert opts["truncated"] is False # Other filters DO narrow it — that's the point of a scoped picker. software_only = await read_service.vendors_in_range( db_session, [P1, P2, P3], {"cost_type": ["software"]}, ) assert [v["vendor"] for v in software_only["vendors"]] == ["Figma", "Zoom"] async def test_range_pivot_still_excludes_hardware_cash(db_session): """The never-double-count invariant must survive the range rewrite: a no-basis range drops the hardware CASH lens (it overlaps the same asset's depreciation).""" db_session.add(_fact(P1, cost_type="hardware", basis="consumption", usd="7")) db_session.add(_fact(P2, cost_type="hardware", basis="cash", usd="300")) await db_session.flush() data = await read_service.pivot(db_session, [P1, P2], ["cost_type"], {}) by = {r["cost_type"]: Decimal(r["usd"]) for r in data["rows"]} assert by["hardware"] == Decimal("7.00") # not 307 # Explicit basis=cash still drills in. cash = await read_service.pivot( db_session, [P1, P2], ["cost_type"], {"basis": "cash"}, ) assert {r["cost_type"]: Decimal(r["usd"]) for r in cash["rows"]}["hardware"] == ( Decimal("300.00") ) async def test_detail_rows_carry_their_own_period(db_session): await _seed_three_months(db_session) data = await read_service.detail( db_session, [P1, P2, P3], {"vendor": ["Figma"]}, ) assert data["total"] == 3 assert {r["period"] for r in data["rows"]} == {P1, P2, P3} assert data["periods"] == [P1, P2, P3] async def test_range_enumeration_and_cap(db_session): assert read_service.enumerate_periods("2026-11", "2027-02") == [ "2026-11", "2026-12", "2027-01", "2027-02", ] # Endpoints in either order. assert read_service.enumerate_periods(P3, P1) == [P1, P2, P3] with pytest.raises(AppException) as exc: read_service.enumerate_periods("2020-01", "2026-01") assert exc.value.code == "RANGE_TOO_WIDE" async def test_api_range_pivot_and_vendor_multiselect( client, db_session, finance_headers, ): await _seed_three_months(db_session) await db_session.commit() r = await client.get( "/api/v1/finance/spend/pivot", params={"start": P1, "end": P3, "group_by": "vendor", "vendor": ["Figma", "Zoom"]}, headers=finance_headers, ) assert r.status_code == 200 payload = r.json()["data"] assert Decimal(payload["total_usd"]) == Decimal("75.00") assert payload["periods"] == [P1, P2, P3] assert payload["provisional_periods"] == [P1, P2, P3] # none closed picker = await client.get( "/api/v1/finance/spend/vendors", params={"start": P1, "end": P3}, headers=finance_headers, ) assert picker.status_code == 200 assert [v["vendor"] for v in picker.json()["data"]["vendors"]] == [ "Figma", "Zoom", ] # Half a range is a 400, not a silent single-month or all-history read. bad = await client.get( "/api/v1/finance/spend/pivot", params={"start": P1, "group_by": "vendor"}, headers=finance_headers, ) assert bad.status_code == 400 assert bad.json()["errors"][0]["code"] == "INCOMPLETE_RANGE" none = await client.get( "/api/v1/finance/spend/pivot", params={"group_by": "vendor"}, headers=finance_headers, ) assert none.status_code == 400 assert none.json()["errors"][0]["code"] == "PERIOD_REQUIRED" async def test_api_range_export_has_period_column( client, db_session, finance_headers, ): await _seed_three_months(db_session) await db_session.commit() res = await client.get( "/api/v1/finance/spend/export.xlsx", params={"start": P1, "end": P3, "vendor": ["Figma"]}, headers=finance_headers, ) assert res.status_code == 200 assert res.headers["x-export-truncated"] == "false" assert "finance_spend_2026-03_2026-05.xlsx" in ( res.headers["content-disposition"] ) import io from openpyxl import load_workbook ws = load_workbook(io.BytesIO(res.content)).active header = [c.value for c in ws[1]] assert header[0] == "period" months = {row[0].value for row in ws.iter_rows(min_row=2)} assert months == {P1, P2, P3} async def test_api_non_finance_role_still_refused( client, db_session, employee_headers, ): await _seed_three_months(db_session) await db_session.commit() for path in ("pivot", "vendors"): res = await client.get( f"/api/v1/finance/spend/{path}", params={"start": P1, "end": P3}, headers=employee_headers, ) assert res.status_code == 403