diff --git a/openspec/changes/budget-feat-313-excel-export/design.md b/openspec/changes/budget-feat-313-excel-export/design.md index f15f515..9f1c231 100644 --- a/openspec/changes/budget-feat-313-excel-export/design.md +++ b/openspec/changes/budget-feat-313-excel-export/design.md @@ -33,6 +33,7 @@ That function sums conversions/expenses per *budget*, across all budgets a custo **2. Converted-expense figure blends real allocation rates with `estimated_exchange_rate` for the unsatisfied remainder, computed per report line then summed per budget line.** For each report line: sum `allocation.amount_allocated × (conversion.donor_amount ÷ conversion.local_amount)` across its allocations (real rate, since `amount_allocated` is in `local_currency`), then add `(report_line.amount − Σallocation.amount_allocated) ÷ estimated_exchange_rate` for any remainder. This mirrors the existing precedent of using `estimated_exchange_rate` as an approximate stand-in (donor dashboard, budget-line toggle) rather than inventing a new conversion rule. A budget line is flagged "includes estimate" in the export if any of its report lines have a non-zero unsatisfied remainder. *Alternative considered*: omit the unsatisfied remainder entirely (leave it unconverted/blank). Rejected per explicit product decision — donors expect the dashboard total to foot to something close to the full spend, and GrandFlow already accepts approximate figures elsewhere rather than showing gaps. +*Superseded by Decision 14*: the blend still happens, but no longer as a Python rollup — Sheet 3 now carries the estimated-rate fallback per row, and Sheet 2 sums it via an Excel `SUMIF` instead of `get_budget_line_expense_rollups`/`_compute_converted_expense` (both removed). **3. Workbook generated synchronously in the request/response cycle via `openpyxl.Workbook()`, streamed with `StreamingResponse` (mirroring `attachment_routes.py`), not written to storage first.** A single budget's data (lines, ledger, report lines) is small; no need for the async/background pattern used elsewhere (e.g. Celery) for larger jobs. @@ -76,6 +77,11 @@ Donors don't reason in local currency (per user feedback), so every comparison f *Deferred*: the reference file's "Avg rate" idea (a single blended rate to normalize the Funding Ledger and replace its per-row literals with formulas) is explicitly punted to group 3 (List of Expenses), per the user: "leave it for now, we will come back to this, whenever we build list of expenses page." *Verification*: the implementation was diff-checked cell-by-cell against the reference file (matching except for the user's own placeholder/typo cells and float-precision artifacts from hand-typing) and round-tripped through `soffice --headless` to confirm the formulas compute, not just parse — see Decision 5's same verification approach. +**14. Sheet 3's unallocated remainder uses `estimated_exchange_rate` (flagged), and Sheet 2's expense/converted-expense columns are `SUMIF` formulas reading Sheet 3, not Python rollups.** +Revised after real-data testing on group 3 surfaced two problems: an expense with zero allocations showed no footnote explaining its blank cells, and Sheet 2 highlighted a cell with no visible reason why. Root cause of the second: Sheet 2 and Sheet 3 computed the same blended figure two different ways (a Python rollup vs. per-allocation rows), so Sheet 3 could show real numbers next to a blank rate while Sheet 2 silently blended in an estimate. Fix: Sheet 3's remainder row (design.md's "not yet allocated" case) now uses `budget.estimated_exchange_rate` as its rate when the budget has one, same convention as Sheet 1/2 (`local ÷ rate = donor`), flagged with a red `*` (row) and a footnote explaining the estimate; when the budget has no `estimated_exchange_rate` at all, the row still has no rate (no fabrication from nothing). Sheet 2 then reads Sheet 3 directly — each budget line's row carries a hidden `budget_line_id` (Sheet 2 column H, Sheet 3 column I) and its Expenses/Total-Expenses cells are `=SUMIF('List of Expenses'!$I:$I,$H,'List of Expenses'!$E:$E resp. $F:$F)` — instead of `get_budget_line_expense_rollups`/`_compute_converted_expense` (both removed from `excel_export_crud.py`/`excel_export_service.py`). Matching by a hidden id column, not the visible description text, avoids two same-named budget lines silently double-counting. `_build_expense_rows` (module-level, was `ExpenseListSheet._build_rows`) now builds this row list once per export and feeds both sheets, so they can no longer disagree. A `Category` column was also added to Sheet 3 (after Date), since the sheet otherwise has no way to tell two identically-named budget lines in different categories apart when scanning it directly. +*Alternative considered*: keep Sheet 3's remainder blank and have Sheet 2 keep its own Python blend. Rejected — that's the two-sources-of-truth setup that caused the bug; a single computed row list removes the possibility of the sheets disagreeing. +*Verification*: round-tripped through `soffice --headless`, confirming the `SUMIF` formulas compute the same blended total the old Python rollup did. + ## Risks / Trade-offs - [A budget with many report lines/allocations could make generation slow] → Out of scope for a "simple export" of one budget; single-budget expense volume in practice is small (tens to low hundreds of lines), revisit only if real usage shows otherwise. diff --git a/openspec/changes/budget-feat-313-excel-export/specs/budget-excel-export/spec.md b/openspec/changes/budget-feat-313-excel-export/specs/budget-excel-export/spec.md index a8b1361..dbf0b78 100644 --- a/openspec/changes/budget-feat-313-excel-export/specs/budget-excel-export/spec.md +++ b/openspec/changes/budget-feat-313-excel-export/specs/budget-excel-export/spec.md @@ -63,7 +63,7 @@ For each budget line (and category subtotal), Sheet 2 SHALL show: total expenses - **THEN** its expense and converted-expense figures show zero, and its deviation equals its full budgeted amount ### Requirement: Sheet 3 — List of Expenses with per-allocation sublines -The workbook's third sheet SHALL list every report-line expense across all of the budget's reports, one row per report line when its full amount is covered by a single currency-conversion lot, or one row per allocation (subline) when a report line's amount is funded by more than one lot — each subline row carrying that allocation's own conversion date, converted amount, and implied rate. +The workbook's third sheet SHALL list every report-line expense across all of the budget's reports, one row per report line when its full amount is covered by a single currency-conversion lot, or one row per allocation (subline) when a report line's amount is funded by more than one lot — each subline row carrying that allocation's own conversion date, converted amount, and implied rate. Each row SHALL also show the expense's budget-line category. #### Scenario: Expense funded by a single lot - **WHEN** a report-line expense is fully allocated to exactly one currency-conversion lot @@ -73,9 +73,13 @@ The workbook's third sheet SHALL list every report-line expense across all of th - **WHEN** a report-line expense straddles more than one currency-conversion lot (e.g., one receipt converted across four separate events) - **THEN** Sheet 3 shows one row per allocation, each with its own conversion date and implied rate, and the rows' amounts sum to the expense's full amount -#### Scenario: Expense not yet allocated -- **WHEN** a report-line expense has no currency-conversion allocation at all -- **THEN** Sheet 3 shows it as a single row with its local-currency amount and no conversion date or rate +#### Scenario: Expense not yet allocated, budget has an estimated rate +- **WHEN** a report-line expense has no currency-conversion allocation at all, and the budget has an `estimated_exchange_rate` +- **THEN** Sheet 3 shows it as a single row with its local-currency amount, no conversion date, and the budget's estimated rate — flagged (a marker in place of the conversion date, and a footnote) as using the estimate rather than a recorded conversion + +#### Scenario: Expense not yet allocated, no estimated rate available +- **WHEN** a report-line expense has no currency-conversion allocation at all, and the budget has no `estimated_exchange_rate` +- **THEN** Sheet 3 shows it as a single row with its local-currency amount and no conversion date, rate, or converted amount, flagged with a footnote explaining the omission ### Requirement: Available export templates for a budget The system SHALL provide an endpoint listing the export templates a given viewer may use for a given budget: the system default, every template owned by the viewer's organisation, and every template owned by the budget's funder that is marked shared with grantees. Each entry SHALL identify whether it is the system default, the viewer's own, or a funder's. diff --git a/openspec/changes/budget-feat-313-excel-export/tasks.md b/openspec/changes/budget-feat-313-excel-export/tasks.md index cab0d56..6296819 100644 --- a/openspec/changes/budget-feat-313-excel-export/tasks.md +++ b/openspec/changes/budget-feat-313-excel-export/tasks.md @@ -35,12 +35,12 @@ _Generated by `scripts/flow.py sync budget-feat-313-excel-export` — do not edi - [x] 2.3 Implement the converted-expense calculation in `excel_export_service.py`: real per-allocation rate for allocated amounts plus `estimated_exchange_rate` for any unsatisfied remainder, flagging a line as "includes estimate" when a remainder exists; verify with a unit test covering fully-allocated (no flag), partially-allocated (flagged, blended figure), and fully-unallocated (fully estimated, flagged) cases - [x] 2.4 Implement Sheet 2 as a `DashboardSheet(_SheetWriter)` subclass: an income section (one row per `CurrencyConversion` for the budget — converted date, donor amount, local amount, implied rate — plus a total row); verify with a unit test using a budget with one funding receipt and multiple conversions, asserting one row per conversion — **superseded**: revised into the Funding Ledger/Report Summary/donor-currency-first design in Decision 13 after a user-supplied reference file; the income-section shape described here is not what shipped - [x] 2.5 Add `DashboardSheet`'s per-budget-line and category-subtotal rows (expenses local, expenses converted, deviation), reusing Sheet 1's category grouping/order and applying the estimated-portion cell style (italic + fill, per design.md Decision 4) where flagged; verify with a unit test asserting column values and that flagged cells carry the style — **superseded**: see Decision 13; deviation is donor-currency (Original − Total Expenses), not local-currency -- [ ] 2.6 Run backend lint/tests clean for `services/budget`; PR merged (`Closes` this group's sub-issue) +- [x] 2.6 Run backend lint/tests clean for `services/budget`; PR merged (`Closes` this group's sub-issue) ## 3. Sheet 3 — List of Expenses with per-allocation sublines — depends on 1 — Issue #321 -- [ ] 3.1 Extend `excel_export_crud.py` with a query returning every report line across all of the budget's reports, each with its ordered list of allocations (or none) -- [ ] 3.2 Implement Sheet 3 as an `ExpenseListSheet(_SheetWriter)` subclass (see design.md Decision 12): one row per report line when zero or one allocation exists, one row per allocation (subline) when a report line has more than one, each subline carrying its own conversion date/rate/converted amount; verify with a unit test covering an unallocated expense, a single-lot expense, and a multi-lot expense (asserting the multi-lot rows sum to the expense's full amount) +- [x] 3.1 Extend `excel_export_crud.py` with a query returning every report line across all of the budget's reports, each with its ordered list of allocations (or none) +- [x] 3.2 Implement Sheet 3 as an `ExpenseListSheet(_SheetWriter)` subclass (see design.md Decision 12): one row per report line when zero or one allocation exists, one row per allocation (subline) when a report line has more than one, each subline carrying its own conversion date/rate/converted amount; verify with a unit test covering an unallocated expense, a single-lot expense, and a multi-lot expense (asserting the multi-lot rows sum to the expense's full amount) — **extended** (design.md Decision 14, after real-data testing): added a Category column; an unallocated remainder now uses `estimated_exchange_rate` (flagged) instead of being left blank when the budget has one; Sheet 2's expense columns now `SUMIF` this sheet via a hidden budget-line-id column instead of a separate Python rollup - [ ] 3.3 Run backend lint/tests clean for `services/budget`; PR merged (`Closes` this group's sub-issue) ## 4. Frontend export button — depends on 1, 2, 3 — Issue #322 diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index 3723202..948bdf5 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -65,9 +65,13 @@ for area in "${!areas[@]}"; do case "$area" in ai|budget|chat|users) echo "== services/$area ==" - run black --check "services/$area/app" - run mypy "services/$area/app" - run flake8 "services/$area/app" + # Run from the service dir like CI: from the repo root, app.* imports don't + # resolve and mypy.ini's ignore_missing_imports silently turns them into Any. + pushd "services/$area" > /dev/null + run black --check ./app + run mypy ./app + run flake8 ./app + popd > /dev/null ;; worker) echo "== services/worker ==" diff --git a/services/budget/app/crud/excel_export_crud.py b/services/budget/app/crud/excel_export_crud.py index 18e3b5b..47cb659 100644 --- a/services/budget/app/crud/excel_export_crud.py +++ b/services/budget/app/crud/excel_export_crud.py @@ -1,80 +1,95 @@ +from datetime import date from dataclasses import dataclass, field from uuid import UUID -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.budget import BudgetLineModel from app.models.currency_ledger import CurrencyConversionModel, ReportLineConversionAllocationModel -from app.models.report import ReportLineModel +from app.models.report import ReportLineModel, ReportModel @dataclass -class AllocationRollup: - """One report line's expense funded by one currency-conversion lot.""" +class ReportLineAllocationDetail: + """One allocation (subline) of a report-line expense: its own conversion + date/amounts, oldest-first (FIFO consumption order).""" amount_allocated: float + converted_at: date conversion_donor_amount: float conversion_local_amount: float @dataclass -class BudgetLineExpenseRollup: - """A budget line's total local-currency expenses plus funding allocations.""" +class ReportLineExpense: + """One report-line expense with its ordered currency-conversion allocations.""" + report_line_id: UUID budget_line_id: UUID - total_local_amount: float - allocations: list[AllocationRollup] = field(default_factory=list) + description: str | None + amount: float + expense_date: date + allocations: list[ReportLineAllocationDetail] = field(default_factory=list) -async def get_budget_line_expense_rollups( +async def get_report_line_expenses( session: AsyncSession, budget_id: UUID -) -> dict[UUID, BudgetLineExpenseRollup]: - """One rollup per budget line of `budget_id`, keyed by budget_line_id.""" - totals_sq = ( +) -> list[ReportLineExpense]: + """Every report-line expense across all of `budget_id`'s reports, oldest + expense first, each with its ordered (oldest-first) allocations.""" + lines_result = await session.execute( select( - ReportLineModel.budget_line_id.label("budget_line_id"), - func.sum(ReportLineModel.amount).label("total_local_amount"), + ReportLineModel.id, + ReportLineModel.budget_line_id, + ReportLineModel.description, + ReportLineModel.amount, + ReportLineModel.expense_date, ) - .group_by(ReportLineModel.budget_line_id) - .subquery() - ) - totals_result = await session.execute( - select(BudgetLineModel.id, func.coalesce(totals_sq.c.total_local_amount, 0.0)) - .outerjoin(totals_sq, totals_sq.c.budget_line_id == BudgetLineModel.id) - .where(BudgetLineModel.budget_id == budget_id) + .join(ReportModel, ReportModel.id == ReportLineModel.report_id) + .where(ReportModel.budget_id == budget_id) + .order_by(ReportLineModel.expense_date, ReportLineModel.id) ) - rollups = { - line_id: BudgetLineExpenseRollup(budget_line_id=line_id, total_local_amount=total) - for line_id, total in totals_result.all() - } - if not rollups: - return rollups + expenses: dict[UUID, ReportLineExpense] = {} + for report_line_id, budget_line_id, description, amount, expense_date in lines_result.all(): + expenses[report_line_id] = ReportLineExpense( + report_line_id=report_line_id, + budget_line_id=budget_line_id, + description=description, + amount=amount or 0.0, + expense_date=expense_date, + ) + if not expenses: + return [] allocations_result = await session.execute( select( - ReportLineModel.budget_line_id, + ReportLineConversionAllocationModel.report_line_id, ReportLineConversionAllocationModel.amount_allocated, + CurrencyConversionModel.converted_at, CurrencyConversionModel.donor_amount, CurrencyConversionModel.local_amount, ) - .join( - ReportLineModel, - ReportLineModel.id == ReportLineConversionAllocationModel.report_line_id, - ) .join( CurrencyConversionModel, CurrencyConversionModel.id == ReportLineConversionAllocationModel.conversion_id, ) - .where(ReportLineModel.budget_line_id.in_(rollups.keys())) + .where(ReportLineConversionAllocationModel.report_line_id.in_(expenses.keys())) + .order_by(CurrencyConversionModel.converted_at, CurrencyConversionModel.id) ) - for budget_line_id, amount_allocated, donor_amount, local_amount in allocations_result.all(): - rollups[budget_line_id].allocations.append( - AllocationRollup( + for ( + report_line_id, + amount_allocated, + converted_at, + donor_amount, + local_amount, + ) in allocations_result.all(): + expenses[report_line_id].allocations.append( + ReportLineAllocationDetail( amount_allocated=amount_allocated, + converted_at=converted_at, conversion_donor_amount=donor_amount, conversion_local_amount=local_amount, ) ) - return rollups + return list(expenses.values()) diff --git a/services/budget/app/services/excel_export_service.py b/services/budget/app/services/excel_export_service.py index cae1e8c..0afc767 100644 --- a/services/budget/app/services/excel_export_service.py +++ b/services/budget/app/services/excel_export_service.py @@ -10,10 +10,7 @@ from app.crud.budget_category_crud import list_budget_categories from app.crud.budget_line_crud import list_budget_lines from app.crud.currency_conversion_crud import FLOAT_EPSILON, list_currency_conversions -from app.crud.excel_export_crud import ( - BudgetLineExpenseRollup, - get_budget_line_expense_rollups, -) +from app.crud.excel_export_crud import ReportLineExpense, get_report_line_expenses from app.crud.funding_receipt_crud import list_funding_receipts from app.models.budget import BudgetCategoryModel, BudgetLineModel, BudgetModel from app.models.currency_ledger import CurrencyConversionModel, FundingReceiptModel @@ -23,6 +20,7 @@ SHEET1_TITLE = "Original Budget" SHEET2_TITLE = "Budget vs. Report Dashboard" +SHEET3_TITLE = "List of Expenses" _BOLD = Font(bold=True) _AUDIT_FONT = Font(italic=True, size=9, color="808080") _TOTAL_FILL = PatternFill(start_color="D9D9D9", end_color="D9D9D9", fill_type="solid") @@ -30,6 +28,8 @@ _BOTTOM_BORDER = Border(bottom=Side(style="thin")) _ESTIMATE_CELL_FONT = Font(italic=True) _ESTIMATE_CELL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") +_FOOTNOTE_STAR_FONT = Font(italic=True, size=9, color="FF0000") +_ROW_FLAG_FONT = Font(color="FF0000") _DESCRIPTION_COL_WIDTH = 26.63 _EXTRA_COL_WIDTH = 20.0 _AMOUNT_COL_WIDTH = 17.77 @@ -41,6 +41,18 @@ _DATE_FORMAT = "yyyy-mm-dd" _PERCENT_FORMAT = "0.0%" _DASHBOARD_COL_WIDTHS = {1: 26.63, 2: 18.0, 3: 21.0, 4: 21.0, 5: 23.0, 6: 18.0} +_EXPENSE_DATE_COL_WIDTH = 12.0 +_RATE_COL_WIDTH = 12.0 +_EXPENSE_LIST_COL_WIDTHS = { + 1: _EXPENSE_DATE_COL_WIDTH, + 2: _DESCRIPTION_COL_WIDTH, + 3: _DESCRIPTION_COL_WIDTH, + 4: _DESCRIPTION_COL_WIDTH, + 5: _AMOUNT_COL_WIDTH, + 6: _ESTIMATE_COL_WIDTH, + 7: _RATE_COL_WIDTH, + 8: _EXPENSE_DATE_COL_WIDTH, +} async def export_budget_workbook_service( @@ -53,7 +65,7 @@ async def export_budget_workbook_service( lines = await list_budget_lines(db, budget_id=budget_id, limit=None) conversions = await list_currency_conversions(db, budget_id=budget_id) receipts = await list_funding_receipts(db, budget_id=budget_id) - rollups = await get_budget_line_expense_rollups(db, budget_id=budget_id) + expenses = await get_report_line_expenses(db, budget_id=budget_id) organisation_name = None try: organisation_name = (await get_customer_cached(budget.owner_id)).get("name") @@ -80,7 +92,7 @@ async def export_budget_workbook_service( lines, conversions=conversions, receipts=receipts, - rollups=rollups, + expenses=expenses, organisation_name=organisation_name, donor_name=donor_name, exported_by=exported_by, @@ -94,14 +106,14 @@ def generate_budget_export_workbook( lines: list[BudgetLineModel], conversions: list[CurrencyConversionModel] | None = None, receipts: list[FundingReceiptModel] | None = None, - rollups: dict[UUID, BudgetLineExpenseRollup] | None = None, + expenses: list[ReportLineExpense] | None = None, organisation_name: str | None = None, donor_name: str | None = None, exported_by: str | None = None, exported_at: datetime | None = None, ) -> bytes: - """Builds the export workbook for one budget. Sheet 3 (List of Expenses) - lands in group 3; group 6 makes sheet selection template-driven.""" + """Builds the export workbook for one budget. Group 6 makes sheet + selection template-driven.""" wb = Workbook() sheet1 = wb.active sheet1.title = SHEET1_TITLE @@ -109,6 +121,10 @@ def generate_budget_export_workbook( sheet1, budget, categories, lines, organisation_name, donor_name, exported_by, exported_at ).write() + expense_rows = _build_expense_rows( + lines, categories, expenses or [], budget.estimated_exchange_rate + ) + sheet2 = wb.create_sheet(SHEET2_TITLE) DashboardSheet( sheet2, @@ -119,7 +135,18 @@ def generate_budget_export_workbook( conversions or [], categories, lines, - rollups or {}, + expense_rows, + exported_by, + exported_at, + ).write() + + sheet3 = wb.create_sheet(SHEET3_TITLE) + ExpenseListSheet( + sheet3, + budget, + organisation_name, + donor_name, + expense_rows, exported_by, exported_at, ).write() @@ -139,31 +166,78 @@ def _period_label(start_date: date | None, duration_months: int | None) -> str | @dataclass -class LineExpenseConversion: - converted_donor_amount: float +class _ExpenseRow: + expense_date: date + budget_line_id: UUID | None + budget_line_description: str | None + category_name: str | None + description: str | None + amount: float + rate: float | None is_estimated: bool + conversion_date: date | None + +def _usable_rate(rate: float | None) -> float | None: + # A 0/negative rate would divide by zero or flip signs in Sheet 3's =E/G. + return rate if rate and rate > 0 else None + + +def _implied_rate(allocation) -> float | None: + if not allocation.conversion_donor_amount or not allocation.conversion_local_amount: + return None + return _usable_rate(allocation.conversion_local_amount / allocation.conversion_donor_amount) -def _compute_converted_expense( - rollup: BudgetLineExpenseRollup, estimated_exchange_rate: float -) -> LineExpenseConversion: - """Real per-allocation rate for allocated amounts, `estimated_exchange_rate` - for any unsatisfied remainder (design.md Decision 2).""" - allocated_total = 0.0 - converted = 0.0 - for allocation in rollup.allocations: - allocated_total += allocation.amount_allocated - if allocation.conversion_local_amount: - converted += ( - allocation.amount_allocated - * allocation.conversion_donor_amount - / allocation.conversion_local_amount + +def _build_expense_rows( + lines: list[BudgetLineModel], + categories: list[BudgetCategoryModel], + expenses: list[ReportLineExpense], + estimated_exchange_rate: float | None, +) -> list[_ExpenseRow]: + """One row per allocation, plus one (estimated-rate-flagged) row per remainder.""" + lines_by_id = {line.id: line for line in lines} + category_names = {category.id: category.name for category in categories} + estimated_rate = _usable_rate(estimated_exchange_rate) + rows: list[_ExpenseRow] = [] + for expense in expenses: + budget_line = lines_by_id.get(expense.budget_line_id) + budget_line_description = budget_line.description if budget_line else None + category_id = budget_line.category_id if budget_line else None + category_name = category_names.get(category_id) if category_id else None + allocated_total = 0.0 + for allocation in expense.allocations: + allocated_total += allocation.amount_allocated + rows.append( + _ExpenseRow( + expense_date=expense.expense_date, + budget_line_id=expense.budget_line_id, + budget_line_description=budget_line_description, + category_name=category_name, + description=expense.description, + amount=allocation.amount_allocated, + rate=_implied_rate(allocation), + is_estimated=False, + conversion_date=allocation.converted_at, + ) + ) + remainder = expense.amount - allocated_total + # Rows must sum to expense.amount: keep refunds, zero amounts and over-allocation. + if not expense.allocations or abs(remainder) > FLOAT_EPSILON: + rows.append( + _ExpenseRow( + expense_date=expense.expense_date, + budget_line_id=expense.budget_line_id, + budget_line_description=budget_line_description, + category_name=category_name, + description=expense.description, + amount=remainder, + rate=estimated_rate, + is_estimated=estimated_rate is not None, + conversion_date=None, + ) ) - remainder = rollup.total_local_amount - allocated_total - is_estimated = remainder > FLOAT_EPSILON - if is_estimated: - converted += remainder / estimated_exchange_rate - return LineExpenseConversion(converted_donor_amount=converted, is_estimated=is_estimated) + return rows def _group_lines_by_category( @@ -209,11 +283,15 @@ def __init__( budget: BudgetModel, organisation_name: str | None, donor_name: str | None, + exported_by: str | None = None, + exported_at: datetime | None = None, ) -> None: self.ws = ws self.budget = budget self.organisation_name = organisation_name self.donor_name = donor_name + self.exported_by = exported_by + self.exported_at = exported_at def _write_header(self) -> str: """Writes the header block (rows 1-6) and returns the rate cell ref for formulas.""" @@ -257,6 +335,11 @@ def _set_cell(self, row: int, column: int, value, number_format: str): cell.number_format = number_format return cell + def _write_audit_footer(self, row: int) -> None: + audit_line = _audit_line(self.exported_by, self.exported_at) + cell = self.ws.cell(row=row, column=1, value=audit_line) + cell.font = _AUDIT_FONT + class OriginalBudgetSheet(_SheetWriter): """Sheet 1 — Original Budget: header block, Budget Summary, Detailed Budget, footer.""" @@ -272,11 +355,9 @@ def __init__( exported_by: str | None, exported_at: datetime | None, ) -> None: - super().__init__(ws, budget, organisation_name, donor_name) + super().__init__(ws, budget, organisation_name, donor_name, exported_by, exported_at) self.categories = categories self.lines = lines - self.exported_by = exported_by - self.exported_at = exported_at def write(self) -> None: budget = self.budget @@ -558,10 +639,7 @@ def _write_footer( ws.cell(row=contact_row, column=1).border = _TOP_BORDER ws.cell(row=contact_row + 1, column=1, value="Project contact person") - audit_cell = ws.cell( - row=plan["audit_row"], column=1, value=_audit_line(self.exported_by, self.exported_at) - ) - audit_cell.font = _AUDIT_FONT + self._write_audit_footer(plan["audit_row"]) class DashboardSheet(_SheetWriter): @@ -577,18 +655,16 @@ def __init__( conversions: list[CurrencyConversionModel], categories: list[BudgetCategoryModel], lines: list[BudgetLineModel], - rollups: dict[UUID, BudgetLineExpenseRollup], + expense_rows: list["_ExpenseRow"], exported_by: str | None, exported_at: datetime | None, ) -> None: - super().__init__(ws, budget, organisation_name, donor_name) + super().__init__(ws, budget, organisation_name, donor_name, exported_by, exported_at) self.receipts = receipts self.conversions = conversions self.categories = categories self.lines = lines - self.rollups = rollups - self.exported_by = exported_by - self.exported_at = exported_at + self.expense_rows = expense_rows def write(self) -> None: self._set_column_widths() @@ -608,7 +684,7 @@ def write(self) -> None: received_total = sum(receipt.amount for receipt in self.receipts) converted_total = sum(conversion.donor_amount for conversion in self.conversions) local_converted_total = sum(conversion.local_amount for conversion in self.conversions) - local_expenses_total = sum(self._rollup_for(line).total_local_amount for line in self.lines) + local_expenses_total = sum(row.amount for row in self.expense_rows) self._write_approved_block(plan, approved_total) self._write_balance_block( @@ -620,23 +696,21 @@ def write(self) -> None: ) self._write_ledger(plan, events) self._write_report_summary(plan, ordered_category_ids, category_names) - self._write_detail( + estimated_line_ids = {row.budget_line_id for row in self.expense_rows if row.is_estimated} + has_estimated_cells = self._write_detail( plan, ordered_category_ids, category_names, category_lines, bool(estimated_exchange_rate), + estimated_line_ids, ) - self._write_footer(plan) + self._write_footer(plan, has_estimated_cells) def _set_column_widths(self) -> None: for column, width in _DASHBOARD_COL_WIDTHS.items(): self.ws.column_dimensions[get_column_letter(column)].width = width - - def _rollup_for(self, line: BudgetLineModel) -> BudgetLineExpenseRollup: - return self.rollups.get( - line.id, BudgetLineExpenseRollup(budget_line_id=line.id, total_local_amount=0.0) - ) + self.ws.column_dimensions[get_column_letter(8)].hidden = True def _ledger_events( self, @@ -889,13 +963,20 @@ def _write_report_summary(self, plan: dict, ordered_category_ids, category_names self._apply_box_border(total_row, total_row, 6) def _write_detail( - self, plan: dict, ordered_category_ids, category_names, category_lines, has_rate: bool - ) -> None: + self, + plan: dict, + ordered_category_ids, + category_names, + category_lines, + has_rate: bool, + estimated_line_ids: set, + ) -> bool: ws = self.ws budget = self.budget donor_fmt = self._currency_format(budget.actual_currency) local_fmt = self._currency_format(budget.local_currency) estimated_exchange_rate = budget.estimated_exchange_rate + has_estimated_cells = False for category_id in ordered_category_ids: header_row = plan["detail_category_header_rows"][category_id] @@ -904,18 +985,29 @@ def _write_detail( line_rows = plan["detail_line_rows"][category_id] for line, row in zip(category_lines[category_id], line_rows): - rollup = self._rollup_for(line) planned = line.amount or 0.0 ws.cell(row=row, column=1, value=line.description) self._set_cell(row, 3, planned, local_fmt) - self._set_cell(row, 4, rollup.total_local_amount, local_fmt) + ws.cell(row=row, column=8, value=str(line.id)) + self._set_cell( + row, + 4, + f"=SUMIF('{SHEET3_TITLE}'!$I:$I,$H{row},'{SHEET3_TITLE}'!$E:$E)", + local_fmt, + ) if has_rate and estimated_exchange_rate: self._set_cell(row, 2, planned / estimated_exchange_rate, donor_fmt) - conversion = _compute_converted_expense(rollup, estimated_exchange_rate) - cell = self._set_cell(row, 5, conversion.converted_donor_amount, donor_fmt) - if conversion.is_estimated: + cell = self._set_cell( + row, + 5, + f"=SUMIF('{SHEET3_TITLE}'!$I:$I,$H{row},'{SHEET3_TITLE}'!$F:$F)", + donor_fmt, + ) + if line.id in estimated_line_ids: cell.font = _ESTIMATE_CELL_FONT cell.fill = _ESTIMATE_CELL_FILL + ws.cell(row=row, column=7, value="*").font = _ROW_FLAG_FONT + has_estimated_cells = True self._set_cell(row, 6, f"=B{row}-E{row}", donor_fmt) if line_rows: self._apply_box_border(line_rows[0], line_rows[-1], 6) @@ -940,7 +1032,9 @@ def _write_detail( self._bold_row(subtotal_row) self._apply_box_border(subtotal_row, subtotal_row, 6) - def _write_footer(self, plan: dict) -> None: + return has_estimated_cells + + def _write_footer(self, plan: dict, has_estimated_cells: bool = False) -> None: ws = self.ws donor_fmt = self._currency_format(self.budget.actual_currency) @@ -964,11 +1058,174 @@ def _write_footer(self, plan: dict) -> None: ws.cell(row=label_row, column=1, value="Authorised Signatory") ws.cell(row=label_row, column=4, value="Project Contact Person") - audit_cell = ws.cell( - row=plan["audit_row"], column=1, value=_audit_line(self.exported_by, self.exported_at) - ) - audit_cell.font = _AUDIT_FONT + if has_estimated_cells: + legend_row = plan["audit_row"] - 2 + ws.cell(row=legend_row, column=1, value="*").font = _FOOTNOTE_STAR_FONT + legend_cell = ws.cell( + row=legend_row, + column=2, + value=( + "Highlighted amount includes an estimate — the budget's estimated " + "exchange rate applied to the portion not yet linked to a currency conversion." + ), + ) + legend_cell.font = _AUDIT_FONT + + self._write_audit_footer(plan["audit_row"]) @staticmethod def _column_header(label: str, currency: str | None) -> str: return f"{label} ({currency})" if currency else label + + +class ExpenseListSheet(_SheetWriter): + """Sheet 3 — List of Expenses: one row per expense, or per allocation subline (Decision 12).""" + + def __init__( + self, + ws, + budget: BudgetModel, + organisation_name: str | None, + donor_name: str | None, + rows: list["_ExpenseRow"], + exported_by: str | None, + exported_at: datetime | None, + ) -> None: + super().__init__(ws, budget, organisation_name, donor_name, exported_by, exported_at) + self.rows = rows + + def write(self) -> None: + self._set_column_widths() + rows = self.rows + needs_footnote = self._needs_rate_footnote(rows) + plan = self._plan(len(rows), needs_footnote) + self._write_table(plan, rows, needs_footnote) + self._write_footer(plan) + + def _set_column_widths(self) -> None: + for column, width in _EXPENSE_LIST_COL_WIDTHS.items(): + self.ws.column_dimensions[get_column_letter(column)].width = width + self.ws.column_dimensions[get_column_letter(9)].hidden = True + + @staticmethod + def _needs_rate_footnote(rows: list["_ExpenseRow"]) -> bool: + """True whenever a row is estimated or lacks a rate entirely.""" + return any(row.is_estimated or row.rate is None for row in rows) + + def _plan(self, row_count: int, needs_footnote: bool = False) -> dict: + header_row = 1 + data_start_row = header_row + 1 + last_row = data_start_row + row_count - 1 if row_count else header_row + total_row = last_row + 1 if row_count else header_row + 1 + if needs_footnote: + footnote_row = total_row + 2 + audit_row = footnote_row + 2 + else: + footnote_row = None + audit_row = total_row + 2 + return { + "header_row": header_row, + "data_start_row": data_start_row, + "last_row": last_row, + "total_row": total_row, + "footnote_row": footnote_row, + "audit_row": audit_row, + } + + def _write_table(self, plan: dict, rows: list["_ExpenseRow"], needs_footnote: bool) -> None: + ws = self.ws + budget = self.budget + local_fmt = self._currency_format(budget.local_currency) + donor_fmt = self._currency_format(budget.actual_currency) + + header_row = plan["header_row"] + ws.cell(row=header_row, column=1, value="Date") + ws.cell(row=header_row, column=2, value="Category") + ws.cell(row=header_row, column=3, value="Budget Line") + ws.cell(row=header_row, column=4, value="Description") + ws.cell( + row=header_row, + column=5, + value=f"Amount ({budget.local_currency})" if budget.local_currency else "Amount", + ) + ws.cell( + row=header_row, + column=6, + value=( + f"Converted Amount ({budget.actual_currency})" + if budget.actual_currency + else "Converted Amount" + ), + ) + ws.cell(row=header_row, column=7, value="Exchange rate") + ws.cell(row=header_row, column=8, value="Exchange date") + ws.cell(row=header_row, column=9, value="Budget Line ID") + self._bold_row(header_row) + + row = plan["data_start_row"] - 1 + for expense_row in rows: + row += 1 + ws.cell(row=row, column=1, value=expense_row.expense_date).number_format = _DATE_FORMAT + ws.cell(row=row, column=2, value=expense_row.category_name) + ws.cell(row=row, column=3, value=expense_row.budget_line_description) + ws.cell(row=row, column=4, value=expense_row.description) + self._set_cell(row, 5, expense_row.amount, local_fmt) + if expense_row.rate is not None: + self._set_cell(row, 7, expense_row.rate, _RATE_FORMAT) + self._set_cell(row, 6, f"=E{row}/G{row}", donor_fmt) + if expense_row.conversion_date is not None: + ws.cell(row=row, column=8, value=expense_row.conversion_date).number_format = ( + _DATE_FORMAT + ) + elif expense_row.is_estimated or expense_row.rate is None: + ws.cell(row=row, column=8, value="*").font = _ROW_FLAG_FONT + ws.cell( + row=row, + column=9, + value=str(expense_row.budget_line_id) if expense_row.budget_line_id else None, + ) + + if rows: + self._apply_box_border(plan["data_start_row"], plan["last_row"], 8) + + total_row = plan["total_row"] + ws.cell(row=total_row, column=1, value="Total") + if rows: + first_row, last_row = plan["data_start_row"], plan["last_row"] + self._set_cell(total_row, 5, f"=SUM(E{first_row}:E{last_row})", local_fmt) + if any(expense_row.rate is not None for expense_row in rows): + self._set_cell(total_row, 6, f"=SUM(F{first_row}:F{last_row})", donor_fmt) + # A blended rate is only meaningful when every row has one; otherwise + # it'd divide all-local (E) by only-rated (F), fabricating a rate. + if all(expense_row.rate is not None for expense_row in rows): + self._set_cell(total_row, 7, f"=E{total_row}/F{total_row}", _RATE_FORMAT) + else: + self._set_cell(total_row, 5, 0.0, local_fmt) + self._bold_row(total_row) + + if needs_footnote: + estimated = sum(1 for expense_row in rows if expense_row.is_estimated) + no_rate = sum(1 for expense_row in rows if expense_row.rate is None) + currency_suffix = f" to {budget.actual_currency}" if budget.actual_currency else "" + ws.cell(row=plan["footnote_row"], column=1, value="*").font = _FOOTNOTE_STAR_FONT + if estimated: + text = ( + "Amount and exchange rate estimated using the budget's estimated " + f"exchange rate — {estimated} of {len(rows)} expense row(s) not yet " + f"linked to a currency conversion{currency_suffix}." + ) + else: + omitted = ( + "Exchange rate" + if no_rate < len(rows) + else ("Converted amount and exchange rate") + ) + text = ( + f"{omitted} omitted — {no_rate} of {len(rows)} expense row(s) " + f"not yet converted{currency_suffix}." + ) + text_cell = ws.cell(row=plan["footnote_row"], column=2, value=text) + text_cell.font = _AUDIT_FONT + + def _write_footer(self, plan: dict) -> None: + self._write_audit_footer(plan["audit_row"]) diff --git a/services/budget/tests/test_excel_export_crud.py b/services/budget/tests/test_excel_export_crud.py index 2aa15a5..424c330 100644 --- a/services/budget/tests/test_excel_export_crud.py +++ b/services/budget/tests/test_excel_export_crud.py @@ -2,7 +2,7 @@ import pytest -from app.crud.excel_export_crud import get_budget_line_expense_rollups +from app.crud.excel_export_crud import get_report_line_expenses from app.models.budget import BudgetLineModel from app.models.currency_ledger import ReportLineConversionAllocationModel from tests.factories.budget import BudgetFactory @@ -44,13 +44,11 @@ async def _seed_report_line(db, report_id, budget_line_id, amount): ) -async def _seed_conversion(db, budget_id, donor_amount, local_amount): - return await _persist( - db, - CurrencyConversionFactory.build( - budget_id=budget_id, donor_amount=donor_amount, local_amount=local_amount - ), - ) +async def _seed_conversion(db, budget_id, donor_amount, local_amount, converted_at=None): + kwargs = {"budget_id": budget_id, "donor_amount": donor_amount, "local_amount": local_amount} + if converted_at is not None: + kwargs["converted_at"] = converted_at + return await _persist(db, CurrencyConversionFactory.build(**kwargs)) async def _seed_allocation(db, report_line_id, conversion_id, amount_allocated): @@ -63,79 +61,107 @@ async def _seed_allocation(db, report_line_id, conversion_id, amount_allocated): @pytest.mark.anyio -class TestGetBudgetLineExpenseRollups: - async def test_fully_allocated_line(self, db): +class TestGetReportLineExpenses: + async def test_unallocated_expense_has_no_allocations(self, db): budget = await _seed_budget(db) report = await _seed_report(db, budget.id) line = await _seed_budget_line(db, budget.id) report_line = await _seed_report_line(db, report.id, line.id, amount=100.0) - conversion = await _seed_conversion(db, budget.id, donor_amount=50.0, local_amount=100.0) - await _seed_allocation(db, report_line.id, conversion.id, amount_allocated=100.0) - rollups = await get_budget_line_expense_rollups(db, budget.id) + expenses = await get_report_line_expenses(db, budget.id) - rollup = rollups[line.id] - assert rollup.total_local_amount == 100.0 - assert len(rollup.allocations) == 1 - assert rollup.allocations[0].amount_allocated == 100.0 - assert rollup.allocations[0].conversion_donor_amount == 50.0 - assert rollup.allocations[0].conversion_local_amount == 100.0 + assert len(expenses) == 1 + expense = expenses[0] + assert expense.report_line_id == report_line.id + assert expense.budget_line_id == line.id + assert expense.amount == 100.0 + assert expense.allocations == [] - async def test_partially_allocated_line(self, db): + async def test_single_lot_expense_has_one_allocation(self, db): budget = await _seed_budget(db) report = await _seed_report(db, budget.id) line = await _seed_budget_line(db, budget.id) report_line = await _seed_report_line(db, report.id, line.id, amount=100.0) - conversion = await _seed_conversion(db, budget.id, donor_amount=30.0, local_amount=60.0) - await _seed_allocation(db, report_line.id, conversion.id, amount_allocated=60.0) + conversion = await _seed_conversion(db, budget.id, donor_amount=50.0, local_amount=100.0) + await _seed_allocation(db, report_line.id, conversion.id, amount_allocated=100.0) - rollups = await get_budget_line_expense_rollups(db, budget.id) + expenses = await get_report_line_expenses(db, budget.id) - rollup = rollups[line.id] - assert rollup.total_local_amount == 100.0 - assert len(rollup.allocations) == 1 - assert rollup.allocations[0].amount_allocated == 60.0 + assert len(expenses) == 1 + allocations = expenses[0].allocations + assert len(allocations) == 1 + assert allocations[0].amount_allocated == 100.0 + assert allocations[0].converted_at == conversion.converted_at + assert allocations[0].conversion_donor_amount == 50.0 + assert allocations[0].conversion_local_amount == 100.0 - async def test_zero_expense_line_has_no_allocations(self, db): + async def test_multi_lot_allocations_ordered_oldest_first_and_sum_to_full_amount(self, db): budget = await _seed_budget(db) + report = await _seed_report(db, budget.id) line = await _seed_budget_line(db, budget.id) - - rollups = await get_budget_line_expense_rollups(db, budget.id) - - rollup = rollups[line.id] - assert rollup.total_local_amount == 0.0 - assert rollup.allocations == [] - - async def test_no_budget_lines_returns_empty(self, db): - budget = await _seed_budget(db) - - rollups = await get_budget_line_expense_rollups(db, budget.id) - - assert rollups == {} - - async def test_multi_lot_allocations_sum_to_full_expense(self, db): + report_line = await _seed_report_line(db, report.id, line.id, amount=150.0) + older = await _seed_conversion( + db, budget.id, donor_amount=40.0, local_amount=100.0, converted_at=date(2026, 1, 1) + ) + newer = await _seed_conversion( + db, budget.id, donor_amount=20.0, local_amount=50.0, converted_at=date(2026, 2, 1) + ) + # Seeded newer-conversion-first to prove the query orders by converted_at, not insert order. + await _seed_allocation(db, report_line.id, newer.id, amount_allocated=50.0) + await _seed_allocation(db, report_line.id, older.id, amount_allocated=100.0) + + expenses = await get_report_line_expenses(db, budget.id) + + allocations = expenses[0].allocations + assert len(allocations) == 2 + assert allocations[0].conversion_donor_amount == 40.0 # older lot first + assert allocations[1].conversion_donor_amount == 20.0 + assert sum(a.amount_allocated for a in allocations) == 150.0 + + async def test_ordered_by_expense_date(self, db): budget = await _seed_budget(db) report = await _seed_report(db, budget.id) line = await _seed_budget_line(db, budget.id) - report_line = await _seed_report_line(db, report.id, line.id, amount=150.0) - conversion1 = await _seed_conversion(db, budget.id, donor_amount=40.0, local_amount=100.0) - conversion2 = await _seed_conversion(db, budget.id, donor_amount=20.0, local_amount=50.0) - await _seed_allocation(db, report_line.id, conversion1.id, amount_allocated=100.0) - await _seed_allocation(db, report_line.id, conversion2.id, amount_allocated=50.0) + later = await _persist( + db, + ReportLineFactory.build( + report_id=report.id, + budget_line_id=line.id, + amount=10.0, + expense_date=date(2026, 3, 1), + ), + ) + earlier = await _persist( + db, + ReportLineFactory.build( + report_id=report.id, + budget_line_id=line.id, + amount=20.0, + expense_date=date(2026, 1, 1), + ), + ) + + expenses = await get_report_line_expenses(db, budget.id) + + assert [e.report_line_id for e in expenses] == [earlier.id, later.id] + + async def test_no_report_lines_returns_empty(self, db): + budget = await _seed_budget(db) - rollups = await get_budget_line_expense_rollups(db, budget.id) + expenses = await get_report_line_expenses(db, budget.id) - rollup = rollups[line.id] - assert rollup.total_local_amount == 150.0 - assert len(rollup.allocations) == 2 - assert sum(a.amount_allocated for a in rollup.allocations) == 150.0 + assert expenses == [] - async def test_budget_with_unrelated_line_is_not_mixed_in(self, db): + async def test_budget_with_unrelated_report_line_is_not_mixed_in(self, db): budget = await _seed_budget(db) other_budget = await _seed_budget(db) + report = await _seed_report(db, budget.id) + other_report = await _seed_report(db, other_budget.id) line = await _seed_budget_line(db, budget.id) - await _seed_budget_line(db, other_budget.id) + other_line = await _seed_budget_line(db, other_budget.id) + report_line = await _seed_report_line(db, report.id, line.id, amount=100.0) + await _seed_report_line(db, other_report.id, other_line.id, amount=100.0) - rollups = await get_budget_line_expense_rollups(db, budget.id) + expenses = await get_report_line_expenses(db, budget.id) - assert list(rollups.keys()) == [line.id] + assert [e.report_line_id for e in expenses] == [report_line.id] diff --git a/services/budget/tests/test_excel_export_service.py b/services/budget/tests/test_excel_export_service.py index 48ff8b5..81a0392 100644 --- a/services/budget/tests/test_excel_export_service.py +++ b/services/budget/tests/test_excel_export_service.py @@ -6,13 +6,15 @@ import pytest from openpyxl import Workbook, load_workbook -from app.crud.excel_export_crud import AllocationRollup, BudgetLineExpenseRollup +from app.crud.excel_export_crud import ReportLineAllocationDetail, ReportLineExpense from app.models.budget import BudgetCategoryModel, BudgetLineModel, BudgetModel from app.schemas.budget_schema import BudgetStatus from app.services.excel_export_service import ( + SHEET3_TITLE, DashboardSheet, + ExpenseListSheet, _audit_line, - _compute_converted_expense, + _build_expense_rows, _period_label, generate_budget_export_workbook, ) @@ -51,63 +53,225 @@ def test_omits_missing_parts(self): assert _audit_line(None, None) == "Generated by OpenGrantFlow" -class TestComputeConvertedExpense: - def test_fully_allocated_line_uses_only_real_rate_no_flag(self): - rollup = BudgetLineExpenseRollup( - budget_line_id=uuid4(), - total_local_amount=100.0, +class TestBuildExpenseRows: + def test_fully_allocated_uses_real_rate_not_estimated(self): + line = BudgetLineFactory.build(description="Salaries") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Rent", + amount=100.0, + expense_date=date(2026, 3, 1), allocations=[ - AllocationRollup( + ReportLineAllocationDetail( amount_allocated=100.0, + converted_at=date(2026, 2, 1), conversion_donor_amount=50.0, conversion_local_amount=100.0, ) ], ) - result = _compute_converted_expense(rollup, estimated_exchange_rate=2.0) + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=2.0) - assert result.converted_donor_amount == 50.0 - assert result.is_estimated is False + assert len(rows) == 1 + assert rows[0].amount == 100.0 + assert rows[0].rate == 2.0 + assert rows[0].is_estimated is False - def test_partially_allocated_line_blends_real_and_estimated(self): - rollup = BudgetLineExpenseRollup( - budget_line_id=uuid4(), - total_local_amount=100.0, + def test_partial_allocation_remainder_uses_estimated_rate(self): + line = BudgetLineFactory.build(description="Flights") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Trip", + amount=100.0, + expense_date=date(2026, 3, 1), allocations=[ - AllocationRollup( + ReportLineAllocationDetail( amount_allocated=60.0, + converted_at=date(2026, 2, 1), conversion_donor_amount=30.0, conversion_local_amount=60.0, ) ], ) - result = _compute_converted_expense(rollup, estimated_exchange_rate=2.0) + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=2.0) + + assert len(rows) == 2 + assert rows[0].amount == 60.0 + assert rows[0].rate == 2.0 + assert rows[0].is_estimated is False + assert rows[1].amount == 40.0 + assert rows[1].rate == 2.0 + assert rows[1].is_estimated is True + assert rows[1].conversion_date is None + + def test_fully_unallocated_uses_estimated_rate_for_full_amount(self): + line = BudgetLineFactory.build(description="Fee") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Fee", + amount=100.0, + expense_date=date(2026, 3, 1), + ) + + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=2.0) + + assert len(rows) == 1 + assert rows[0].amount == 100.0 + assert rows[0].rate == 2.0 + assert rows[0].is_estimated is True + + def test_no_estimated_rate_leaves_remainder_unrated(self): + """No fabrication when the budget itself has no rate to fall back on.""" + line = BudgetLineFactory.build(description="Fee") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Fee", + amount=100.0, + expense_date=date(2026, 3, 1), + ) + + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=None) - # 60 local -> 30 donor (real rate); remainder 40 local -> 20 donor (estimated rate) - assert result.converted_donor_amount == 50.0 - assert result.is_estimated is True + assert rows[0].rate is None + assert rows[0].is_estimated is False - def test_fully_unallocated_line_is_fully_estimated(self): - rollup = BudgetLineExpenseRollup( - budget_line_id=uuid4(), total_local_amount=100.0, allocations=[] + def test_category_name_looked_up_from_budget_line(self): + category = BudgetCategoryFactory.build(name="Travel") + line = BudgetLineFactory.build( + description="Flights", category=category, category_id=category.id + ) + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Trip", + amount=100.0, + expense_date=date(2026, 3, 1), ) - result = _compute_converted_expense(rollup, estimated_exchange_rate=2.0) + rows = _build_expense_rows([line], [category], [expense], estimated_exchange_rate=None) - assert result.converted_donor_amount == 50.0 - assert result.is_estimated is True + assert rows[0].category_name == "Travel" - def test_zero_expense_line_is_not_flagged(self): - rollup = BudgetLineExpenseRollup( - budget_line_id=uuid4(), total_local_amount=0.0, allocations=[] + def test_refund_keeps_its_negative_row(self): + line = BudgetLineFactory.build(description="Fee") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Refund", + amount=-500.0, + expense_date=date(2026, 3, 1), ) - result = _compute_converted_expense(rollup, estimated_exchange_rate=2.0) + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=2.0) + + assert len(rows) == 1 + assert rows[0].amount == -500.0 + assert rows[0].rate == 2.0 + assert rows[0].is_estimated is True + + def test_zero_amount_unallocated_expense_keeps_its_row(self): + line = BudgetLineFactory.build(description="Fee") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Waived", + amount=0.0, + expense_date=date(2026, 3, 1), + ) - assert result.converted_donor_amount == 0.0 - assert result.is_estimated is False + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=None) + + assert len(rows) == 1 + assert rows[0].amount == 0.0 + + def test_over_allocation_adds_negative_remainder_so_rows_sum_to_amount(self): + line = BudgetLineFactory.build(description="Fee") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Fee", + amount=100.0, + expense_date=date(2026, 3, 1), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=120.0, + converted_at=date(2026, 2, 1), + conversion_donor_amount=60.0, + conversion_local_amount=120.0, + ) + ], + ) + + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=2.0) + + assert [row.amount for row in rows] == [120.0, -20.0] + assert sum(row.amount for row in rows) == 100.0 + + def test_fully_allocated_expense_adds_no_remainder_row(self): + line = BudgetLineFactory.build(description="Fee") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Fee", + amount=100.0, + expense_date=date(2026, 3, 1), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=100.0, + converted_at=date(2026, 2, 1), + conversion_donor_amount=50.0, + conversion_local_amount=100.0, + ) + ], + ) + + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=2.0) + + assert len(rows) == 1 + + def test_zero_estimated_rate_leaves_remainder_unrated(self): + line = BudgetLineFactory.build(description="Fee") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Fee", + amount=100.0, + expense_date=date(2026, 3, 1), + ) + + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=0.0) + + assert rows[0].rate is None + assert rows[0].is_estimated is False + + def test_zero_local_amount_conversion_leaves_allocation_unrated(self): + line = BudgetLineFactory.build(description="Fee") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Fee", + amount=100.0, + expense_date=date(2026, 3, 1), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=100.0, + converted_at=date(2026, 2, 1), + conversion_donor_amount=50.0, + conversion_local_amount=0.0, + ) + ], + ) + + rows = _build_expense_rows([line], [], [expense], estimated_exchange_rate=2.0) + + assert len(rows) == 1 + assert rows[0].rate is None class TestDashboardSheet: @@ -116,7 +280,7 @@ def _write( budget, categories=(), lines=(), - rollups=None, + expenses=(), receipts=(), conversions=(), exported_by=None, @@ -124,6 +288,9 @@ def _write( ): wb = Workbook() ws = wb.active + expense_rows = _build_expense_rows( + list(lines), list(categories), list(expenses), budget.estimated_exchange_rate + ) DashboardSheet( ws, budget, @@ -133,7 +300,7 @@ def _write( list(conversions), list(categories), list(lines), - rollups or {}, + expense_rows, exported_by, exported_at, ).write() @@ -156,12 +323,16 @@ def test_full_layout_with_rate(self): description="Salaries", amount=1000.0, ) - rollup = BudgetLineExpenseRollup( + expense = ReportLineExpense( + report_line_id=uuid4(), budget_line_id=line.id, - total_local_amount=100.0, + description="Payroll", + amount=100.0, + expense_date=date(2026, 2, 1), allocations=[ - AllocationRollup( + ReportLineAllocationDetail( amount_allocated=100.0, + converted_at=date(2026, 1, 20), conversion_donor_amount=50.0, conversion_local_amount=100.0, ) @@ -177,7 +348,7 @@ def test_full_layout_with_rate(self): budget, categories=[category], lines=[line], - rollups={line.id: rollup}, + expenses=[expense], receipts=[receipt], conversions=[conversion], exported_by="exporter@example.com", @@ -237,7 +408,17 @@ def test_full_layout_with_rate(self): ) assert rows[26][0] == "Personnel" - assert rows[27][:6] == ("Salaries", 500.0, 1000.0, 100.0, 50.0, "=B28-E28") + assert ws.cell(row=28, column=1).value == "Salaries" + assert ws.cell(row=28, column=2).value == 500.0 + assert ws.cell(row=28, column=3).value == 1000.0 + assert ws.cell(row=28, column=4).value == ( + f"=SUMIF('{SHEET3_TITLE}'!$I:$I,$H28,'{SHEET3_TITLE}'!$E:$E)" + ) + assert ws.cell(row=28, column=5).value == ( + f"=SUMIF('{SHEET3_TITLE}'!$I:$I,$H28,'{SHEET3_TITLE}'!$F:$F)" + ) + assert ws.cell(row=28, column=6).value == "=B28-E28" + assert ws.cell(row=28, column=8).value == str(line.id) assert rows[28][:6] == ( "Subtotal", "=SUM(B28:B28)", @@ -274,25 +455,112 @@ def test_partially_allocated_line_is_styled_as_estimate(self): description="Flights", amount=500.0, ) - rollup = BudgetLineExpenseRollup( + expense = ReportLineExpense( + report_line_id=uuid4(), budget_line_id=line.id, - total_local_amount=100.0, + description="Trip", + amount=100.0, + expense_date=date(2026, 3, 1), allocations=[ - AllocationRollup( + ReportLineAllocationDetail( amount_allocated=60.0, + converted_at=date(2026, 2, 1), conversion_donor_amount=30.0, conversion_local_amount=60.0, ) ], ) - ws = self._write(budget, categories=[category], lines=[line], rollups={line.id: rollup}) + ws = self._write(budget, categories=[category], lines=[line], expenses=[expense]) - # real 60/2 rate=30 + estimated remainder 40/2=20 -> 50.0; row = header(23)+1+1+1(line)=26 + # real 60 local/30 donor (rate 2.0) + estimated remainder 40/2.0 -> blended donor total converted_cell = ws.cell(row=26, column=5) - assert converted_cell.value == 50.0 + assert converted_cell.value == ( + f"=SUMIF('{SHEET3_TITLE}'!$I:$I,$H26,'{SHEET3_TITLE}'!$F:$F)" + ) assert converted_cell.font.italic is True assert converted_cell.fill.fgColor.rgb == "00FFF2CC" + assert ws.cell(row=26, column=7).value == "*" + assert ws.cell(row=26, column=7).font.color.rgb == "00FF0000" + + def test_estimate_flag_adds_footnote_above_audit_line(self): + budget = BudgetFactory.build( + local_currency="GBP", actual_currency="USD", estimated_exchange_rate=2.0 + ) + category = BudgetCategoryFactory.build(budget=budget, budget_id=budget.id, name="Travel") + line = BudgetLineFactory.build( + budget=budget, + budget_id=budget.id, + category=category, + category_id=category.id, + description="Flights", + amount=500.0, + ) + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Trip", + amount=100.0, + expense_date=date(2026, 3, 1), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=60.0, + converted_at=date(2026, 2, 1), + conversion_donor_amount=30.0, + conversion_local_amount=60.0, + ) + ], + ) + + ws = self._write(budget, categories=[category], lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[42][:2] == ( + "*", + "Highlighted amount includes an estimate — the budget's estimated exchange rate " + "applied to the portion not yet linked to a currency conversion.", + ) + assert ws.cell(row=43, column=1).font.color.rgb == "00FF0000" + assert ws.cell(row=43, column=2).font.color.rgb == "00808080" + assert rows[44][0] == "Generated by OpenGrantFlow" + + def test_no_estimate_flag_omits_footnote(self): + """Fully-allocated line: no estimate anywhere, so the row/footnote stay absent + and the audit line keeps its original (unshifted) position.""" + budget = BudgetFactory.build( + local_currency="GBP", actual_currency="USD", estimated_exchange_rate=2.0 + ) + category = BudgetCategoryFactory.build(budget=budget, budget_id=budget.id, name="Travel") + line = BudgetLineFactory.build( + budget=budget, + budget_id=budget.id, + category=category, + category_id=category.id, + description="Flights", + amount=500.0, + ) + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Trip", + amount=100.0, + expense_date=date(2026, 3, 1), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=100.0, + converted_at=date(2026, 2, 1), + conversion_donor_amount=50.0, + conversion_local_amount=100.0, + ) + ], + ) + + ws = self._write(budget, categories=[category], lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert ws.cell(row=26, column=7).value is None + assert rows[42] == (None,) * 8 + assert rows[44][0] == "Generated by OpenGrantFlow" def test_no_rate_leaves_donor_columns_blank(self): budget = BudgetFactory.build( @@ -312,7 +580,9 @@ def test_no_rate_leaves_donor_columns_blank(self): rows = list(ws.iter_rows(values_only=True)) assert rows[7][:2] == ("Approved Total", None) - assert rows[25][:6] == ("Flights", None, 500.0, 0.0, None, None) + assert rows[25][:3] == ("Flights", None, 500.0) + assert rows[25][3] == f"=SUMIF('{SHEET3_TITLE}'!$I:$I,$H26,'{SHEET3_TITLE}'!$E:$E)" + assert rows[25][4:6] == (None, None) def test_no_categories_or_ledger_events_falls_back_to_header_plus_one(self): budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") @@ -349,7 +619,7 @@ def test_tz_aware_confirmed_at_does_not_break_save(self): ) wb = Workbook() ws = wb.active - DashboardSheet(ws, budget, "Org", "Donor", [], [], [], [], {}, None, None).write() + DashboardSheet(ws, budget, "Org", "Donor", [], [], [], [], [], None, None).write() buf = io.BytesIO() wb.save(buf) @@ -357,6 +627,390 @@ def test_tz_aware_confirmed_at_does_not_break_save(self): assert reloaded.cell(row=9, column=2).value == datetime(2026, 1, 10) +class TestExpenseListSheet: + def _write( + self, budget, categories=(), lines=(), expenses=(), exported_by=None, exported_at=None + ): + wb = Workbook() + ws = wb.active + rows = _build_expense_rows( + list(lines), list(categories), list(expenses), budget.estimated_exchange_rate + ) + ExpenseListSheet( + ws, budget, "Test Org", "Test Donor", rows, exported_by, exported_at + ).write() + return ws + + def test_unallocated_expense_has_no_conversion_info(self): + budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") + line = BudgetLineFactory.build(budget=budget, budget_id=budget.id, description="Travel") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="June rent", + amount=100.0, + expense_date=date(2026, 6, 1), + ) + + ws = self._write(budget, lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[0] == ( + "Date", + "Category", + "Budget Line", + "Description", + "Amount (GBP)", + "Converted Amount (USD)", + "Exchange rate", + "Exchange date", + "Budget Line ID", + ) + assert rows[1] == ( + date(2026, 6, 1), None, "Travel", "June rent", 100.0, None, None, "*", str(line.id) + ) + assert rows[2] == ("Total", None, None, None, "=SUM(E2:E2)", None, None, None, None) + assert rows[3] == (None,) * 9 + assert rows[4] == ( + "*", + "Converted amount and exchange rate omitted — 1 of 1 expense row(s) " + "not yet converted to USD.", + None, + None, + None, + None, + None, + None, + None, + ) + assert rows[5] == (None,) * 9 + assert rows[6][0] == "Generated by OpenGrantFlow" + + def test_single_lot_expense_shows_its_rate_and_date(self): + budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") + line = BudgetLineFactory.build(budget=budget, budget_id=budget.id, description="Travel") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Flight", + amount=100.0, + expense_date=date(2026, 6, 2), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=100.0, + converted_at=date(2026, 5, 30), + conversion_donor_amount=50.0, + conversion_local_amount=100.0, + ) + ], + ) + + ws = self._write(budget, lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[1] == ( + date(2026, 6, 2), + None, + "Travel", + "Flight", + 100.0, + "=E2/G2", + 2.0, + date(2026, 5, 30), + str(line.id), + ) + assert rows[2] == ( + "Total", None, None, None, "=SUM(E2:E2)", "=SUM(F2:F2)", "=E3/F3", None, None + ) + assert ws.cell(row=2, column=1).border.top.style == "thin" + assert ws.cell(row=2, column=8).border.bottom.style == "thin" + + def test_multi_lot_expense_rows_sum_to_full_amount(self): + budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") + line = BudgetLineFactory.build(budget=budget, budget_id=budget.id, description="Travel") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Hotel", + amount=150.0, + expense_date=date(2026, 6, 3), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=100.0, + converted_at=date(2026, 5, 1), + conversion_donor_amount=40.0, + conversion_local_amount=100.0, + ), + ReportLineAllocationDetail( + amount_allocated=50.0, + converted_at=date(2026, 5, 15), + conversion_donor_amount=20.0, + conversion_local_amount=50.0, + ), + ], + ) + + ws = self._write(budget, lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[1] == ( + date(2026, 6, 3), + None, + "Travel", + "Hotel", + 100.0, + "=E2/G2", + 2.5, + date(2026, 5, 1), + str(line.id), + ) + assert rows[2] == ( + date(2026, 6, 3), + None, + "Travel", + "Hotel", + 50.0, + "=E3/G3", + 2.5, + date(2026, 5, 15), + str(line.id), + ) + assert rows[1][4] + rows[2][4] == expense.amount + assert rows[3] == ( + "Total", None, None, None, "=SUM(E2:E3)", "=SUM(F2:F3)", "=E4/F4", None, None + ) + + def test_single_partial_allocation_adds_unrated_remainder_row(self): + """A single allocation short of the full amount must not be shown as if it + covered the whole expense — the gap gets its own unrated row instead.""" + budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") + line = BudgetLineFactory.build(budget=budget, budget_id=budget.id, description="Travel") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Taxi", + amount=150.0, + expense_date=date(2026, 6, 4), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=60.0, + converted_at=date(2026, 5, 20), + conversion_donor_amount=30.0, + conversion_local_amount=60.0, + ) + ], + ) + + ws = self._write(budget, lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[1] == ( + date(2026, 6, 4), + None, + "Travel", + "Taxi", + 60.0, + "=E2/G2", + 2.0, + date(2026, 5, 20), + str(line.id), + ) + assert rows[2] == ( + date(2026, 6, 4), None, "Travel", "Taxi", 90.0, None, None, "*", str(line.id) + ) + assert rows[1][4] + rows[2][4] == expense.amount + assert rows[3] == ( + "Total", None, None, None, "=SUM(E2:E3)", "=SUM(F2:F3)", None, None, None + ) + assert rows[4] == (None,) * 9 + assert rows[5] == ( + "*", + "Exchange rate omitted — 1 of 2 expense row(s) not yet converted to USD.", + None, + None, + None, + None, + None, + None, + None, + ) + assert rows[6] == (None,) * 9 + assert rows[7][0] == "Generated by OpenGrantFlow" + assert ws.cell(row=3, column=8).font.color.rgb == "00FF0000" + assert ws.cell(row=6, column=1).font.color.rgb == "00FF0000" + assert ws.cell(row=6, column=2).font.color.rgb == "00808080" + + def test_single_partial_allocation_remainder_uses_estimated_rate_when_available(self): + budget = BudgetFactory.build( + local_currency="GBP", actual_currency="USD", estimated_exchange_rate=2.0 + ) + line = BudgetLineFactory.build(budget=budget, budget_id=budget.id, description="Travel") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Taxi", + amount=150.0, + expense_date=date(2026, 6, 4), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=60.0, + converted_at=date(2026, 5, 20), + conversion_donor_amount=30.0, + conversion_local_amount=60.0, + ) + ], + ) + + ws = self._write(budget, lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[2] == ( + date(2026, 6, 4), None, "Travel", "Taxi", 90.0, "=E3/G3", 2.0, "*", str(line.id) + ) + assert rows[3] == ( + "Total", None, None, None, "=SUM(E2:E3)", "=SUM(F2:F3)", "=E4/F4", None, None + ) + assert rows[5] == ( + "*", + "Amount and exchange rate estimated using the budget's estimated exchange rate " + "— 1 of 2 expense row(s) not yet linked to a currency conversion to USD.", + None, + None, + None, + None, + None, + None, + None, + ) + assert ws.cell(row=3, column=8).font.color.rgb == "00FF0000" # star marker, no real date + + def test_multi_allocation_partial_adds_unrated_remainder_row(self): + budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") + line = BudgetLineFactory.build(budget=budget, budget_id=budget.id, description="Travel") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Conference", + amount=300.0, + expense_date=date(2026, 6, 5), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=100.0, + converted_at=date(2026, 5, 1), + conversion_donor_amount=40.0, + conversion_local_amount=100.0, + ), + ReportLineAllocationDetail( + amount_allocated=80.0, + converted_at=date(2026, 5, 15), + conversion_donor_amount=32.0, + conversion_local_amount=80.0, + ), + ], + ) + + ws = self._write(budget, lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[1][4] == 100.0 + assert rows[2][4] == 80.0 + assert rows[3] == ( + date(2026, 6, 5), None, "Travel", "Conference", 120.0, None, None, "*", str(line.id) + ) + assert rows[1][4] + rows[2][4] + rows[3][4] == expense.amount + assert rows[4] == ( + "Total", None, None, None, "=SUM(E2:E4)", "=SUM(F2:F4)", None, None, None + ) + assert rows[5] == (None,) * 9 + assert rows[6] == ( + "*", + "Exchange rate omitted — 1 of 3 expense row(s) not yet converted to USD.", + None, + None, + None, + None, + None, + None, + None, + ) + assert rows[7] == (None,) * 9 + assert rows[8][0] == "Generated by OpenGrantFlow" + + def test_category_column_shows_budget_lines_category(self): + budget = BudgetFactory.build(local_currency="GBP", actual_currency="USD") + category = BudgetCategoryFactory.build(budget=budget, budget_id=budget.id, name="Travel") + line = BudgetLineFactory.build( + budget=budget, + budget_id=budget.id, + category=category, + category_id=category.id, + description="Flights", + ) + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Trip", + amount=100.0, + expense_date=date(2026, 6, 1), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=100.0, + converted_at=date(2026, 5, 30), + conversion_donor_amount=50.0, + conversion_local_amount=100.0, + ) + ], + ) + + ws = self._write(budget, categories=[category], lines=[line], expenses=[expense]) + rows = list(ws.iter_rows(values_only=True)) + + assert rows[1][1] == "Travel" + + def test_no_expenses_still_writes_header_total_and_audit_line(self): + budget = BudgetFactory.build(local_currency="GBP") + + ws = self._write(budget, exported_by="exporter@example.com") + rows = list(ws.iter_rows(values_only=True)) + + assert rows[0][0] == "Date" + assert rows[1] == ("Total", None, None, None, 0.0, None, None, None, None) + assert rows[2] == (None,) * 9 + assert rows[3][0] == "Generated by OpenGrantFlow · exporter@example.com" + + @pytest.mark.parametrize( + "estimated_rate, local_amount", + [(0.0, 100.0), (2.0, 0.0)], + ids=["zero_estimated_rate", "zero_local_amount_conversion"], + ) + def test_unusable_rate_writes_no_division_formula(self, estimated_rate, local_amount): + budget = BudgetFactory.build( + local_currency="GBP", actual_currency="USD", estimated_exchange_rate=estimated_rate + ) + line = BudgetLineFactory.build(budget=budget, budget_id=budget.id, description="Travel") + expense = ReportLineExpense( + report_line_id=uuid4(), + budget_line_id=line.id, + description="Flight", + amount=100.0, + expense_date=date(2026, 6, 2), + allocations=[ + ReportLineAllocationDetail( + amount_allocated=60.0, + converted_at=date(2026, 5, 30), + conversion_donor_amount=30.0, + conversion_local_amount=local_amount, + ) + ], + ) + + ws = self._write(budget, lines=[line], expenses=[expense]) + + for converted, rate in ws.iter_rows(min_row=2, min_col=6, max_col=7, values_only=True): + if isinstance(converted, str) and "/G" in converted: + assert isinstance(rate, float) and rate > 0 + + class TestGenerateBudgetExportWorkbook: def test_returns_valid_xlsx_roundtrip(self): budget = BudgetFactory.build() @@ -364,7 +1018,11 @@ def test_returns_valid_xlsx_roundtrip(self): data = generate_budget_export_workbook(budget, [], []) wb = load_workbook(io.BytesIO(data)) - assert wb.sheetnames == ["Original Budget", "Budget vs. Report Dashboard"] + assert wb.sheetnames == [ + "Original Budget", + "Budget vs. Report Dashboard", + "List of Expenses", + ] def test_full_layout_with_rate(self): budget = BudgetFactory.build( @@ -651,7 +1309,11 @@ async def test_owner_can_export(self, db, make_client): "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) wb = load_workbook(io.BytesIO(response.content)) - assert wb.sheetnames == ["Original Budget", "Budget vs. Report Dashboard"] + assert wb.sheetnames == [ + "Original Budget", + "Budget vs. Report Dashboard", + "List of Expenses", + ] async def test_funder_can_export(self, db, make_client): budget = await _make_budget(db, funding_customer_id=FUNDER_ID)