Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,4 @@ frontend-typescript/tsconfig.tsbuildinfo
uploads/
*.db
.codex/
frontend-typescript/tsconfig.tsbuildinfo
4 changes: 4 additions & 0 deletions openspec/changes/budget-excel-export/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
schema: spec-driven
created: 2026-09-20
author: Norair Arutshyan
priority: medium
42 changes: 42 additions & 0 deletions openspec/changes/budget-excel-export/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Design

## Context

See proposal.md - Why. Relevant existing data model (all in `services/budget/app/models/`):
- `BudgetModel`/`BudgetLineModel`/`BudgetCategoryModel` (`budget.py`): budget lines store `amount` in the budget's `local_currency`; `actual_currency` is the donor's currency; `estimated_exchange_rate` (local ÷ donor, e.g. AMD per EUR) is a planning-time estimate, never a stored per-line figure — existing code (donor dashboard, budget-line currency toggle) already treats it as a derived, render-time-only conversion, never persisted as a second amount.
- `FundingReceiptModel`/`CurrencyConversionModel`/`ReportLineConversionAllocationModel` (`currency_ledger.py`): a receipt (donor currency landed) and a conversion (one real bank FX event, rate = `local_amount ÷ donor_amount`) are **not linked 1:1** — only aggregate-balanced. A report-line expense (`ReportLineModel.amount`, in `local_currency`) is allocated FIFO across unconsumed conversion lots (`currency_ledger_services.allocate_fifo_service`), producing zero or more allocation rows per expense.
- No existing per-budget-line planned-vs-actual rollup query exists; `dashboard_crud.budget_breakdown` is the closest precedent but aggregates cross-budget, not per-line.
- `openpyxl==3.1.5` is already a dependency (currently import-only, via `excel_import_service.py`); no new package needed for writing.
- `attachment_routes.py` already establishes the `StreamingResponse` file-download pattern for this service.

## Goals / Non-Goals

**Goals:**
- Generate one workbook per request, entirely from live data, with no new tables or stored artifacts.
- Reuse the currency-ledger's real allocation data wherever it exists; only fall back to the planning-time estimate for the genuinely unresolved gap, and mark that gap visibly.

**Non-Goals:**
- Donor-template-shaped round-trip export (deferred; see proposal).
- Caching, background generation, or emailing the file — synchronous request/response only, matching the size of a single budget's data.
- Any change to how receipts, conversions, or allocations are recorded (`ledger-budget`'s in-flight edit/delete/reset work is unrelated and unaffected).

## Decisions

**1. New per-budget-line rollup query, not a reuse of `dashboard_crud.budget_breakdown`.**
That function sums conversions/expenses per *budget*, across all budgets a customer owns — the export needs the same shape of number (converted, spent) but grouped by *budget_line_id* within one budget, plus the allocation-level rate detail `budget_breakdown` never needed. New function(s) live in a new `services/budget/app/crud/excel_export_crud.py`, following the same subquery pattern (`group_by` + `outerjoin`) rather than N+1 per-line queries.

**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.

**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.

**4. Sheet 2's estimated-portion flag is a cell style (e.g. italic + light fill + a legend row), not a separate column.**
Keeps the sheet's column count matching the user's I/J/K scope instead of doubling columns for a rare partial-estimate case.

## 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.
- [`estimated_exchange_rate` unset on an older or draft budget leaves Sheet 1's donor-currency column and Sheet 2's deviation column blank for that budget] → Matches existing GrandFlow convention (donor dashboard already excludes rather than fabricates); the export's local-currency figures are still fully populated.
- [Category subtotal rows in Sheet 2 need the same rollup as Sheet 1's category grouping] → Reuse the same category-grouping logic/order between Sheet 1 and Sheet 2 rather than deriving it twice, to avoid the two sheets silently disagreeing on category order or membership.
32 changes: 32 additions & 0 deletions openspec/changes/budget-excel-export/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Proposal

## Why

A grantee owner has no way to hand a single budget's numbers to a donor, auditor, or board as a spreadsheet — every figure (budget lines, real-currency ledger, expenses) currently only exists inside the app. Donors overwhelmingly expect a multi-sheet Excel report (per the existing `KTK 2012.xls` example this proposal is grounded in), and GrandFlow already has all the underlying data (`budget_lines`, `funding_receipts`, `currency_conversions`, `report_lines`, and their FIFO allocations) — it just isn't exportable yet.

## What Changes

- Add `GET /budgets/{budget_id}/export.xlsx` (budget service): generates and streams a 3-sheet workbook for one budget, on demand (not stored), using `openpyxl` (already a dependency).
- **Sheet 1 — Original Budget**: budget lines grouped by category with subtotals, mirroring the example's layout. Each line shows its amount in the budget's `local_currency` plus a derived donor-currency estimate column (`amount ÷ estimated_exchange_rate`), consistent with how GrandFlow already treats `estimated_exchange_rate` elsewhere as an approximate, non-stored conversion (donor dashboard, budget-line toggle).
- **Sheet 2 — Budget vs. Report Dashboard**: per budget-line (and category subtotal) rows with only 3 result columns (matching the example's `I`/`J`/`K`; its interim/final-report date-range split in `E–H` is out of scope): Total Expenses (`local_currency`, direct sum of `report_lines.amount`), Total Expenses Converted (donor currency — real ledger rate for the portion covered by `ReportLineConversionAllocation`, falling back to `estimated_exchange_rate` for any unsatisfied remainder, with that estimated portion visually flagged so it's never mistaken for a real bank rate), and Deviation (budgeted-in-donor-currency minus converted actual). An income section lists every recorded `CurrencyConversion` as its own row (date, donor amount, local amount, implied rate) rather than one row per receipt, since one receipt can convert across several dates/rates.
- **Sheet 3 — List of Expenses**: one row per report-line expense; an expense whose payment was funded by more than one currency-conversion lot gets one row per allocation (subline), each carrying its own conversion date, rate, and converted amount, directly mirroring the real `ReportLineConversionAllocation` data with no aggregation.
- Frontend: an "Export to Excel" button on the single-budget view that downloads the generated file.
- Wire the new route into all three gateway configs (`nginx-dev.conf`, `nginx.conf`, `Caddyfile`).

**Explicitly out of scope**: regenerating a budget back into a *donor's own* Excel layout (the round-trip feature already deferred by the archived `budget-export-from-excel` — actually an import feature — proposal). This is a new, fixed GrandFlow-authored export format, not a donor-template replay.

## Capabilities

### New Capabilities
- `budget-excel-export`: backend generation of the 3-sheet workbook from a budget's lines, ledger, and report-line data.
- `budget-excel-export-ui`: the frontend entry point (button + download) that triggers the export on a single-budget view.

### Modified Capabilities
(none — this only reads existing `budget-currency-ledger`, `budget-reports`, and `budget-categories` data; no requirement in those specs changes)

## Impact

- **Backend**: new `services/budget/app/services/excel_export_service.py` (workbook generation), new per-budget-line rollup query (join `budget_lines`→`report_lines`, no existing precedent — closest is `dashboard_crud.budget_breakdown`, which is cross-budget, not per-line), new route in `budget_routes.py` (`GET /{budget_id}/export.xlsx`), reusing the `StreamingResponse` pattern from `attachment_routes.py`.
- **Frontend**: one button + fetch/download handler on the budget detail view; no new page.
- **Gateway**: route addition to `nginx-dev.conf`, `nginx.conf`, `Caddyfile`.
- **No schema/migration changes** — pure read of existing tables.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Spec Delta

## Purpose

Gives a budget owner or funder viewer a visible way to trigger and download the single-budget Excel export from the budget detail view.

## ADDED Requirements

### Requirement: Export button on budget detail view
The frontend SHALL show an "Export to Excel" action on the single-budget detail view, visible to anyone with read access to that budget (owner or funder), that downloads the generated workbook via `GET /budgets/{budget_id}/export.xlsx`.

#### Scenario: Owner triggers export
- **WHEN** the budget owner clicks "Export to Excel" on their budget's detail view
- **THEN** the frontend requests the export endpoint and saves the returned file with a filename derived from the budget's name

#### Scenario: Export unavailable to unauthorized viewers
- **WHEN** a user without read access to the budget views a page that would otherwise show the button
- **THEN** the frontend does not show the "Export to Excel" action

### Requirement: Export failure feedback
The frontend SHALL show an inline error, without navigating away from the budget detail view, when the export request fails.

#### Scenario: Backend error surfaced
- **WHEN** the export endpoint returns an error
- **THEN** the frontend shows an inline error message and the user remains on the budget detail view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Spec Delta

## Purpose

Lets a budget owner (or funder viewer) generate a 3-sheet Excel workbook of a single budget's plan, real-currency ledger, and expense history, for sharing outside the app.

## ADDED Requirements

### Requirement: Single-budget Excel export endpoint
The system SHALL provide an endpoint that generates and streams a `.xlsx` workbook for exactly one budget, computed on demand from current data (not persisted or cached), accessible to anyone permitted to view that budget (owner or funder, matching existing budget-read authorization).

#### Scenario: Owner exports a budget
- **WHEN** the budget's owner requests the export for a budget they own
- **THEN** the system returns a `.xlsx` file streamed in the response, reflecting the budget's current lines, ledger, and report data

#### Scenario: Funder views a funded budget's export
- **WHEN** a funder who funds the budget (`funding_customer_id`) requests its export
- **THEN** the system returns the same workbook a viewer with read access would see

#### Scenario: Unauthorized user is rejected
- **WHEN** a user who is neither the budget's owner nor its funder requests the export
- **THEN** the system rejects the request without generating a workbook

### Requirement: Sheet 1 — Original Budget
The workbook's first sheet SHALL list every budget line grouped by category, with a subtotal row per category and a grand-total row, each line showing its amount in the budget's `local_currency` and a derived estimate in the budget's `actual_currency` (`amount ÷ estimated_exchange_rate`) when `estimated_exchange_rate` is set.

#### Scenario: Categories and subtotals shown
- **WHEN** the budget has lines across more than one category
- **THEN** Sheet 1 groups lines under their category, with a subtotal per category and a grand total for the whole budget

#### Scenario: No donor-currency estimate available
- **WHEN** the budget has no `estimated_exchange_rate` set
- **THEN** Sheet 1 shows the local-currency amount only, leaving the donor-currency estimate column blank for that budget rather than showing a fabricated figure

### Requirement: Sheet 2 — Budget vs. Report Dashboard, income section
The workbook's second sheet SHALL open with an income section listing every recorded currency conversion for the budget as its own row (converted date, donor-currency amount, local-currency amount, implied rate), plus a total row, rather than one row per funding receipt.

#### Scenario: Multiple conversions from one receipt
- **WHEN** a budget has one funding receipt but several currency conversions recorded against it
- **THEN** the income section lists each conversion as a separate row with its own date and implied rate, not one blended row

### Requirement: Sheet 2 — Budget vs. Report Dashboard, expense columns
For each budget line (and category subtotal), Sheet 2 SHALL show: total expenses in `local_currency` (sum of that line's report-line amounts); total expenses converted to `actual_currency`, using the real per-allocation conversion rate for the portion covered by a `CurrencyConversion` and the budget's `estimated_exchange_rate` for any unsatisfied remainder; and a deviation column (budgeted amount converted to `actual_currency` via `estimated_exchange_rate`, minus the converted total expenses). The workbook SHALL visually distinguish a converted-expense figure that includes an estimated (not real-rate) portion from one derived entirely from real conversions.

#### Scenario: Expense fully covered by real conversions
- **WHEN** a budget line's report-line expenses are fully covered by allocated currency conversions
- **THEN** its converted-expense figure uses only real per-allocation rates, with no estimated-rate flag

#### Scenario: Expense partially unconverted
- **WHEN** a budget line has report-line expenses whose allocation to currency-conversion lots is incomplete
- **THEN** its converted-expense figure combines the real-rate portion with the `estimated_exchange_rate`-derived portion for the remainder, and is flagged as including an estimate

#### Scenario: No report-line expenses yet
- **WHEN** a budget line has no report-line expenses recorded
- **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.

#### Scenario: Expense funded by a single lot
- **WHEN** a report-line expense is fully allocated to exactly one currency-conversion lot
- **THEN** Sheet 3 shows it as a single row with that lot's date and rate

#### Scenario: Expense funded by multiple lots
- **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
Loading
Loading