# Story 3.6: CSV Transaction Export

Status: review

## Story

As a user,
I want to export my transactions to a CSV file,
so that I can analyze my spending in a spreadsheet or import it into other tools.

## Acceptance Criteria

1. `GET /transactions/export.csv` returns a CSV file download with `Content-Disposition: attachment; filename="transactions.csv"` and `Content-Type: text/csv`.
2. CSV columns (in order): Date, Merchant, Amount, Category, Account, Notes.
3. Export respects all active filters from the same query params as the list view (`q`, `category_id`, `account_id`, `date_from`, `date_to`, `amount_min`, `amount_max`, `sort`, `dir`) — exports all matching rows, no pagination.
4. Rows sorted the same way as the list view (default: date desc).
5. Empty category or notes fields appear as an empty string in the CSV (not `None`).
6. An "Export CSV" link appears on the transaction list page, preserving all active filters.
7. When no transactions match filters, the CSV still returns with headers only (not an error).
8. `pytest tests/` passes — all 218 prior tests still green.

## Tasks / Subtasks

- [x] **Task 1: Write `tests/test_blueprints/test_transaction_export.py`** (TDD RED)
- [x] **Task 2: Add `GET /transactions/export.csv` route**
- [x] **Task 3: Add "Export CSV" link to `transactions/index.html`**
- [x] **Task 4: Run full suite GREEN**

## Dev Notes

### Route pattern

```python
import csv, io
from flask import make_response

@transactions_bp.route('/export.csv')
def export_csv():
    # Apply same filters as index (reuse the query-building logic)
    # No pagination — fetch all
    # Build CSV in memory with csv.writer
    si = io.StringIO()
    writer = csv.writer(si)
    writer.writerow(['Date', 'Merchant', 'Amount', 'Category', 'Account', 'Notes'])
    for txn in transactions:
        writer.writerow([
            txn.date,
            txn.merchant_normalized,
            str(txn.amount),
            txn.category.name if txn.category else '',
            txn.account.name if txn.account else '',
            txn.notes or '',
        ])
    output = make_response(si.getvalue())
    output.headers['Content-Disposition'] = 'attachment; filename="transactions.csv"'
    output.headers['Content-Type'] = 'text/csv'
    return output
```

### Export link in index.html

Pass a `csv_url` variable from the route (built from current filter_params) or build it inline in the template using the same filter params:

```html
<a href="{{ url_for('transactions.export_csv', **request.args) }}">Export CSV</a>
```

Or compute it in the route:
```python
csv_url = url_for('transactions.export_csv', **filter_params)
```

### File List

| File | Status |
|------|--------|
| `tests/test_blueprints/test_transaction_export.py` | NEW |
| `app/blueprints/transactions/routes.py` | MODIFY (add export_csv route, pass csv_url to index) |
| `app/blueprints/transactions/templates/transactions/index.html` | MODIFY (add Export CSV link) |

---

## Dev Agent Record

### Agent Model Used
claude-sonnet-4-6

### Debug Log References
None

### Completion Notes List
- 20 new tests, all green first run — zero regressions against 218 prior tests (238 passed, 1 skipped)
- Refactored `routes.py` to extract `_parse_filter_args()` and `_build_transaction_query()` helpers shared by `index` and `export_csv` — no duplication
- Export fetches all matching rows (no pagination limit); `index` still slices with OFFSET/LIMIT
- `csv_url` computed in `index` route from `filter_params` and passed to template so the Export CSV link always preserves active filters
- Missing category/notes serialize as empty string (not `None` / `"None"`)

### File List
- `tests/test_blueprints/test_transaction_export.py` — NEW (20 tests)
- `app/blueprints/transactions/routes.py` — MODIFIED (extracted helpers, added export_csv route, pass csv_url to index)
- `app/blueprints/transactions/templates/transactions/index.html` — MODIFIED (Export CSV link)

### Change Log
- 2026-05-28: Story implemented and moved to review status
