Browse Source

docs: plan local MVP workflows

master
Codex 2 months ago
parent
commit
ed008bee09
  1. 453
      docs/superpowers/plans/2026-09-07-local-mvp-workflows.md

453
docs/superpowers/plans/2026-09-07-local-mvp-workflows.md

@ -0,0 +1,453 @@
# Local MVP Workflows Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver a local server-rendered workflow for managing and categorizing transactions, viewing monthly reports, exporting user-owned data, and creating and verifying PostgreSQL backups.
**Architecture:** `finance.queries` and `finance.reports` own read behavior, existing audited finance services own every mutation, and `core` owns HTTP forms, views, URLs, and templates. A versioned export service is shared by browser and command entry points; backup commands wrap PostgreSQL 17 client tools and verify restores only against a separate explicitly marked database.
**Tech Stack:** Python 3.13, Django 5.2, PostgreSQL 17, psycopg 3, Django templates/forms, pytest and pytest-django, Docker Compose.
**Spec:** `docs/superpowers/specs/2026-09-07-local-mvp-workflows-design.md`
## Global Constraints
- All monthly reports use `posting_date`, an inclusive calendar month, and exactly one currency.
- `INCOME` is inflow; `EXPENSE` is spending; `REFUND` reduces spending in its posting month; `TRANSFER` and `OPENING` are excluded from cash flow and spending.
- Account balances include every entry through month end, including openings and transfer legs, and are never summed across currencies.
- Browser writes must call audited `finance.services` with `Actor(kind="HUMAN", identifier="local-owner")`; views never save financial models directly.
- Transactions and source evidence cannot be deleted or have immutable financial fields rewritten.
- All mutation forms use POST and Django CSRF protection.
- Export and backup artifacts must remain under ignored `exports/` and `backups/`, never overwrite existing files, and never publish partial files.
- Terminal output, HTTP errors, tests, and committed fixtures must contain only synthetic data and safe error codes.
- PostgreSQL backup clients must use major version 17 and receive passwords through environment variables, never command arguments.
- OFX, bank APIs, investments, forecasting, receipt recognition, mobile clients, hosted multi-user operation, and automatic or AI categorization remain excluded.
---
### Task 1: Query, indexing, and report services
**Files:**
- Create: `finance/queries.py`
- Create: `finance/reports.py`
- Create: `finance/migrations/0002_transaction_report_indexes.py`
- Test: `tests/finance/test_queries.py`
- Test: `tests/finance/test_reports.py`
**Interfaces:**
- Consumes: `finance.models.Account`, `Category`, `Payee`, and `Transaction`.
- Produces: `TransactionFilters.from_mapping(mapping)`, `filtered_transactions(filters)`, `available_currencies()`, `monthly_summary(month, currency)`, `spending_by_category(month, currency)`, and `account_balances(month, currency)`.
- `month` is a `datetime.date` whose day must be `1`; currency is three uppercase ASCII letters.
- Report return types are frozen dataclasses containing `Decimal` values.
- [ ] **Step 1: Write failing query tests**
```python
def test_filtered_transactions_combines_filters_and_orders_stably():
filters = TransactionFilters.from_mapping({
"date_from": "2026-01-01", "date_to": "2026-01-31",
"currency": "BRL", "uncategorized": "yes",
})
rows = list(filtered_transactions(filters))
assert [row.pk for row in rows] == [newer.pk, older.pk]
def test_invalid_filter_is_explicit():
with pytest.raises(QueryInputError) as caught:
TransactionFilters.from_mapping({"date_from": "31/01/2026"})
assert caught.value.errors == {"date_from": "invalid_date"}
```
- [ ] **Step 2: Run the query tests and confirm imports fail because `finance.queries` does not exist**
Run: `docker compose run --rm test pytest -q tests/finance/test_queries.py`
- [ ] **Step 3: Implement validated filters and deterministic query ordering**
Implement a frozen `TransactionFilters` dataclass with optional dates and UUIDs, kind, currency, and tri-state uncategorized value. Parse ISO dates and UUIDs once, return field error codes through `QueryInputError.errors`, use `select_related`, and order by `-posting_date`, `-id`.
- [ ] **Step 4: Run query tests and confirm they pass**
Run: `docker compose run --rm test pytest -q tests/finance/test_queries.py`
- [ ] **Step 5: Write failing report-semantic tests**
```python
def test_monthly_summary_counts_card_purchase_once_and_refund_in_posting_month():
result = monthly_summary(date(2026, 1, 1), "BRL")
assert result.inflow == Decimal("1000.00")
assert result.expense == Decimal("300.00")
assert result.refund == Decimal("50.00")
assert result.net_spending == Decimal("250.00")
assert result.net_activity == Decimal("750.00")
def test_spending_has_uncategorized_bucket_and_never_combines_currency():
rows = spending_by_category(date(2026, 1, 1), "BRL")
assert [(row.category_name, row.net_spending) for row in rows] == [
("Synthetic Food", Decimal("80.00")),
("Uncategorized", Decimal("20.00")),
]
```
- [ ] **Step 6: Run report tests and confirm failure because report functions are absent**
Run: `docker compose run --rm test pytest -q tests/finance/test_reports.py`
- [ ] **Step 7: Implement monthly summaries, category spending, balances, and currency choices**
Use conditional PostgreSQL aggregates with explicit `Decimal("0.00")` defaults. Validate month and currency. Group nullable categories into an explicit Uncategorized result without manufacturing a Category row. Sort category results by descending net spending then category name; sort balances by account name then UUID.
- [ ] **Step 8: Add report indexes through a migration**
Add named indexes on `Transaction(posting_date)` and `Transaction(account, posting_date)`. Do not alter financial constraints.
- [ ] **Step 9: Run focused tests and migration drift check**
Run: `docker compose run --rm test pytest -q tests/finance/test_queries.py tests/finance/test_reports.py`
Run: `docker compose run --rm manage makemigrations --check --dry-run`
- [ ] **Step 10: Commit the query and reporting slice**
```powershell
git add finance/queries.py finance/reports.py finance/migrations/0002_transaction_report_indexes.py tests/finance/test_queries.py tests/finance/test_reports.py
git diff --cached --name-only
git diff --cached
git commit -m "feat: add financial queries and monthly reports"
```
### Task 2: Local web shell and audited management workflows
**Files:**
- Create: `core/forms.py`
- Create: `core/context.py`
- Create: `core/templates/core/base.html`
- Create: `core/templates/core/transactions/list.html`
- Create: `core/templates/core/transactions/detail.html`
- Create: `core/templates/core/transactions/form.html`
- Create: `core/templates/core/references/list.html`
- Create: `core/templates/core/references/form.html`
- Create: `core/static/core/app.css`
- Modify: `core/views.py`
- Modify: `config/urls.py`
- Modify: `config/settings.py`
- Test: `tests/test_web_workflows.py`
**Interfaces:**
- Consumes: Task 1 query APIs and existing `finance.services` mutation APIs.
- Produces named URLs `home`, `transaction-list`, `transaction-detail`, `transaction-create`, `transaction-annotate`, `account-list`, `account-create`, `account-archive`, `category-list`, `category-create`, `category-archive`, `payee-list`, `payee-create`, and `payee-archive`.
- Produces forms that return validated scalar values and UUIDs but never save models.
- [ ] **Step 1: Write failing request tests for navigation, list filters, detail, and CSRF**
```python
def test_root_and_transaction_pages_are_discoverable(client):
assert client.get(reverse("home")).status_code == 200
page = client.get(reverse("transaction-list"), {"currency": "BRL"})
assert page.status_code == 200
assert "Synthetic purchase" in page.content.decode()
def test_mutation_requires_csrf():
guarded = Client(enforce_csrf_checks=True)
response = guarded.post(reverse("category-create"), {"name": "Synthetic Food", "kind": "EXPENSE"})
assert response.status_code == 403
```
- [ ] **Step 2: Run web workflow tests and confirm missing routes fail**
Run: `docker compose run --rm test pytest -q tests/test_web_workflows.py`
- [ ] **Step 3: Enable template, static, CSRF, session, and message infrastructure**
Add Django sessions and messages apps/middleware, template backend with request and message context processors, `STATIC_URL`, and signed-cookie sessions. Run the sessions migration; no financial data is stored in the session.
- [ ] **Step 4: Implement forms and request-to-domain error mapping**
Build plain `forms.Form` classes for manual transactions, annotations, accounts, categories, and payees. Query active references for choices. Convert `DomainError.code` into a fixed safe message dictionary; unknown codes map to `Unable to save this change.`
- [ ] **Step 5: Implement transaction list/detail/create/annotate views**
Use `finance.queries` for reads, Django `Paginator` with 50 rows per page, and existing finance services for writes. Derive `LOCAL_ACTOR = Actor(kind="HUMAN", identifier="local-owner")` server-side. Redirect after successful POST.
- [ ] **Step 6: Implement reference list/create/archive views**
Use services only for mutations. Archive endpoints accept POST only and render archived values as historical/inactive on list pages.
- [ ] **Step 7: Add accessible templates and minimal responsive CSS**
Provide one `h1`, associated labels, error summaries, visible focus styles, tabular headings, empty states, and navigation on every page. Keep all core workflows functional without JavaScript.
- [ ] **Step 8: Run request and existing domain tests**
Run: `docker compose run --rm test pytest -q tests/test_web_workflows.py tests/finance/test_services.py`
- [ ] **Step 9: Commit the audited web workflow slice after staged privacy inspection**
```powershell
git add config/settings.py config/urls.py core tests/test_web_workflows.py
git diff --cached --name-only
git diff --cached
git commit -m "feat: add local transaction workflows"
```
### Task 3: Dashboard and monthly report screens
**Files:**
- Create: `core/templates/core/dashboard.html`
- Create: `core/templates/core/reports/monthly.html`
- Modify: `core/forms.py`
- Modify: `core/views.py`
- Modify: `config/urls.py`
- Test: `tests/test_report_views.py`
**Interfaces:**
- Consumes: `monthly_summary`, `spending_by_category`, `account_balances`, and `available_currencies` from Task 1.
- Produces named URL `monthly-report`; `home` renders the current selected month and currency rather than transaction-management content.
- [ ] **Step 1: Write failing report-view tests**
```python
def test_monthly_report_renders_exact_totals_and_category_rows(client):
response = client.get(reverse("monthly-report"), {"month": "2026-01", "currency": "BRL"})
assert response.status_code == 200
body = response.content.decode()
assert "750.00" in body
assert "Synthetic Food" in body
def test_invalid_month_is_visible_and_not_silently_replaced(client):
response = client.get(reverse("monthly-report"), {"month": "01/2026", "currency": "BRL"})
assert response.status_code == 400
assert "invalid_month" in response.content.decode()
```
- [ ] **Step 2: Run the focused tests and confirm the route/template behavior is absent**
Run: `docker compose run --rm test pytest -q tests/test_report_views.py`
- [ ] **Step 3: Implement month/currency selection and report views**
Parse `YYYY-MM` into the first day of the month. Default to the current local calendar month and the first available currency, falling back to `BRL` for an empty database. Invalid supplied values return status 400 with field errors.
- [ ] **Step 4: Render dashboard and detailed report templates**
Show inflow, expense, refund, net spending, net activity, categorized/uncategorized rows, and per-account balances. Label negative net spending and balances without changing their sign.
- [ ] **Step 5: Run report service and view tests**
Run: `docker compose run --rm test pytest -q tests/finance/test_reports.py tests/test_report_views.py`
- [ ] **Step 6: Commit the report UI slice after staged privacy inspection**
```powershell
git add core/forms.py core/views.py core/templates/core/dashboard.html core/templates/core/reports/monthly.html config/urls.py tests/test_report_views.py
git diff --cached --name-only
git diff --cached
git commit -m "feat: add monthly finance dashboard"
```
### Task 4: Versioned portable exports
**Files:**
- Create: `finance/exports.py`
- Create: `finance/management/__init__.py`
- Create: `finance/management/commands/__init__.py`
- Create: `finance/management/commands/export_finances.py`
- Create: `core/templates/core/exports.html`
- Modify: `core/views.py`
- Modify: `config/urls.py`
- Modify: `config/settings.py`
- Modify: `compose.yaml`
- Test: `tests/finance/test_exports.py`
- Test: `tests/test_export_command.py`
- Test: `tests/test_export_view.py`
**Interfaces:**
- Consumes: finance models through read-only ordered querysets.
- Produces `build_export() -> ExportArtifact` for an in-memory snapshot and `write_export(name: str | None = None) -> ExportResult` under `settings.EXPORT_ROOT`.
- Produces `export_finances [--output NAME]` and named URLs `export-index`, `export-download`.
- [ ] **Step 1: Write failing package-contract tests**
```python
def test_export_has_versioned_manifest_and_stable_csv_contract():
artifact = build_export()
with ZipFile(BytesIO(artifact.content)) as package:
manifest = json.loads(package.read("manifest.json"))
assert manifest["format"] == "gizmoduck-export"
assert manifest["version"] == 1
assert package.read("transactions.csv").decode("utf-8").splitlines()[0] == EXPECTED_TRANSACTION_HEADER
assert manifest["files"]["transactions.csv"]["sha256"] == sha256(package.read("transactions.csv")).hexdigest()
```
- [ ] **Step 2: Run export tests and confirm the service is missing**
Run: `docker compose run --rm test pytest -q tests/finance/test_exports.py`
- [ ] **Step 3: Implement the consistent export snapshot**
Use `transaction.atomic()` and `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` before reads. Serialize all specified tables in UUID order, exact decimals, ISO dates/timestamps, lowercase booleans, and blank nullable fields. Write deterministic UTF-8 CSV content and checksums into a ZIP with `manifest.json` last.
- [ ] **Step 4: Write failing safe-file and command tests**
Cover generated names, traversal, absolute paths, symlinks, existing targets, safe stdout, temporary cleanup, and database failure before publication.
- [ ] **Step 5: Implement exclusive atomic publication and command entry point**
Resolve the real export root, accept plain `.zip` names only, create the destination exclusively, write to a same-directory private temporary file, flush and `os.fsync`, then `os.replace`. Reject an existing destination before building the financial snapshot and remove temporary files on every failure.
- [ ] **Step 6: Write failing browser download tests and implement export pages**
The GET index explains export contents. CSRF-protected POST builds the same in-memory artifact and returns it with `Content-Disposition: attachment`; the request cannot supply a server path.
- [ ] **Step 7: Mount ignored export storage and add settings**
Set `EXPORT_ROOT` from environment with `/app/exports` default. Mount `./exports:/app/exports` into `manage`; the browser download remains in memory and does not require a writable host mount.
- [ ] **Step 8: Run all export tests**
Run: `docker compose run --rm test pytest -q tests/finance/test_exports.py tests/test_export_command.py tests/test_export_view.py`
- [ ] **Step 9: Commit the export slice after checking that no generated ZIP is staged**
```powershell
git add finance/exports.py finance/management core config/settings.py config/urls.py compose.yaml tests/finance/test_exports.py tests/test_export_command.py tests/test_export_view.py
git diff --cached --name-only
git diff --cached
git commit -m "feat: add versioned financial exports"
```
### Task 5: PostgreSQL backup creation and isolated restore verification
**Files:**
- Create: `core/backup.py`
- Create: `core/management/__init__.py`
- Create: `core/management/commands/__init__.py`
- Create: `core/management/commands/backup_database.py`
- Create: `core/management/commands/verify_backup.py`
- Modify: `Dockerfile`
- Modify: `config/settings.py`
- Modify: `compose.yaml`
- Modify: `.env.example`
- Test: `tests/test_backup.py`
- Test: `tests/test_backup_commands.py`
- Test: `tests/test_backup_restore.py`
**Interfaces:**
- Produces `create_backup(name: str | None = None) -> BackupResult` and `verify_backup(name: str, target: DatabaseTarget) -> VerificationResult`.
- Produces commands `backup_database [--output NAME]` and `verify_backup --input NAME --target-name NAME --target-host HOST --target-user USER`.
- Verification requires `BACKUP_VERIFY_ALLOWED=1` and rejects the live `(DB_HOST, DB_NAME)` pair.
- [ ] **Step 1: Write failing unit tests for safe subprocess construction and manifests**
```python
def test_pg_dump_receives_password_only_in_environment(runner, settings):
create_backup("synthetic.dump")
argv, env = runner.calls[0]
assert argv[:3] == ["pg_dump", "--format=custom", "--no-owner"]
assert settings.DATABASES["default"]["PASSWORD"] not in argv
assert env["PGPASSWORD"] == settings.DATABASES["default"]["PASSWORD"]
def test_verification_rejects_live_database(settings):
with pytest.raises(BackupError, match="live_target"):
verify_backup("synthetic.dump", DatabaseTarget.from_default(settings.DATABASES["default"]))
```
- [ ] **Step 2: Run backup tests and confirm the module is absent**
Run: `docker compose run --rm test pytest -q tests/test_backup.py`
- [ ] **Step 3: Install PostgreSQL 17 clients and implement safe backup creation**
Add the PostgreSQL apt repository and pin/install `postgresql-client-17` in the application image. Build subprocess argument arrays without a shell. Use the default database settings and `PGPASSWORD`, custom format, `--no-owner`, `--no-acl`, private temporary files, digest manifest, `fsync`, exclusive destination reservation, and atomic rename.
- [ ] **Step 4: Implement manifest validation and isolated restore verification**
Require exact manifest format/version, matching size/digest, explicit allow flag, a target distinct from live host/name, and a target containing no non-system tables. Invoke `pg_restore --exit-on-error --single-transaction --no-owner --no-acl` without a shell. Validate `django_migrations` equality and manifest row counts through a separate psycopg connection to the target.
- [ ] **Step 5: Write failing command tests and implement safe command adapters**
Commands print artifact name, format version, and table counts only. Convert `BackupError.code` into `CommandError(code)` without paths, SQL, subprocess stderr, or credentials.
- [ ] **Step 6: Add backup and verification Compose configuration**
Set `BACKUP_ROOT=/app/backups`, mount `./backups:/app/backups` into `manage`, and add a profile-gated `restore-db` PostgreSQL 17 service on tmpfs. Add a `verify-backup` application service whose default database settings point to `restore-db`, with `BACKUP_VERIFY_ALLOWED=1`; keep live credentials out of that service.
- [ ] **Step 7: Write and run the synthetic backup/restore integration test**
Populate only synthetic finance, provenance, audit, and import-review rows; create a backup; restore it into the empty `restore-db`; compare migrations and per-table counts; then query the original database to prove it remains unchanged.
Run: `docker compose build`
Run: `docker compose run --rm test pytest -q tests/test_backup.py tests/test_backup_commands.py`
Run: `docker compose --profile backup run --rm verify-backup python manage.py test_backup_restore`
- [ ] **Step 8: Commit the backup slice after verifying no dumps, manifests, secrets, or generated reports are staged**
```powershell
git add Dockerfile config/settings.py compose.yaml .env.example core/backup.py core/management tests/test_backup.py tests/test_backup_commands.py tests/test_backup_restore.py
git diff --cached --name-only
git diff --cached
git commit -m "feat: add verified PostgreSQL backups"
```
### Task 6: Owner documentation and complete synthetic acceptance workflow
**Files:**
- Create: `docs/local-workflows.md`
- Create: `tests/test_mvp_acceptance.py`
- Modify: `README.md`
- Modify: `docs/development.md`
- Modify: `docs/roadmap.md`
- Modify: `.gitignore`
**Interfaces:**
- Consumes: every public workflow created by Tasks 1–5 and the existing `import_review` command.
- Produces a copy-paste local workflow from synthetic import through categorization, reports, export, backup, and disposable restore verification.
- [ ] **Step 1: Write the failing acceptance test**
The test must create an account, preview/apply/commit `tests/fixtures/synthetic/ingestion/bank.csv`, POST a category assignment through the CSRF-enabled browser flow, assert the monthly report totals, inspect a generated export manifest, create a logical backup through the injectable runner boundary, and verify that the restore-verification boundary checks migrations and table counts.
- [ ] **Step 2: Run the acceptance test and confirm any missing integration behavior**
Run: `docker compose run --rm test pytest -q tests/test_mvp_acceptance.py`
- [ ] **Step 3: Fix only integration gaps exposed by the failing acceptance test**
Keep changes within existing Task 1–5 boundaries. Add no new product behavior. Re-run the focused tests for every amended module.
- [ ] **Step 4: Document the owner workflow and recovery procedure**
Document local URLs, supported edits, report semantics, command output safety, export contents, backup creation, disposable restore verification, and deliberate live recovery. The live recovery sequence must stop `web`, create a separate safety backup, name the target database explicitly, restore with PostgreSQL 17 tools, run migrations/checks, and restart only after validation.
- [ ] **Step 5: Reconcile configuration and project status documentation**
Document the loopback PostgreSQL port currently present in Compose, link the workflow guide from README, and mark roadmap step 5 complete only after full verification. Confirm `.gitignore` covers export and backup contents plus temporary suffixes while retaining synthetic fixtures.
- [ ] **Step 6: Run the complete verification matrix**
```powershell
docker compose config --quiet
docker compose build
docker compose run --rm manage migrate
docker compose run --rm manage check
docker compose run --rm manage makemigrations --check --dry-run
docker compose run --rm manage migrate --check
docker compose run --rm test
docker compose run --rm permissions
docker compose --profile backup run --rm verify-backup python manage.py test_backup_restore
Invoke-RestMethod http://127.0.0.1:8000/health/
```
- [ ] **Step 7: Inspect all staged content for financial data and secrets, then commit**
```powershell
git add README.md docs/development.md docs/local-workflows.md docs/roadmap.md .gitignore tests/test_mvp_acceptance.py
git diff --cached --name-only
git diff --cached
git commit -m "docs: complete local MVP workflow"
```
- [ ] **Step 8: Run a final whole-branch review against this plan and resolve all critical or important findings**
Use the review package required by `superpowers:subagent-driven-development`, include all deferred-minor ledger entries, and re-run the complete verification matrix after any fix wave.
Loading…
Cancel
Save