Compare commits
25 Commits
19b2bf1c90
...
50966d598b
| Author | SHA1 | Date |
|---|---|---|
|
|
50966d598b | 2 months ago |
|
|
ac86f01887 | 2 months ago |
|
|
f2a439612e | 2 months ago |
|
|
e994e505cb | 2 months ago |
|
|
2addbc9f0a | 2 months ago |
|
|
9352240b3f | 2 months ago |
|
|
f1b2c204de | 2 months ago |
|
|
bcc5db212c | 2 months ago |
|
|
9b3ca8f5ff | 2 months ago |
|
|
abdb88d3f1 | 2 months ago |
|
|
16fd900271 | 2 months ago |
|
|
27ff4012d4 | 2 months ago |
|
|
1146dfe3df | 2 months ago |
|
|
20f411e6e6 | 2 months ago |
|
|
dce6e6102d | 2 months ago |
|
|
085e83b31e | 2 months ago |
|
|
3d6b6c5f87 | 2 months ago |
|
|
f394a89127 | 2 months ago |
|
|
4701719274 | 2 months ago |
|
|
e18cae224b | 2 months ago |
|
|
ce96ff2720 | 2 months ago |
|
|
29cee17539 | 2 months ago |
|
|
4720d3053f | 2 months ago |
|
|
ccfc98c2fd | 2 months ago |
|
|
7f6fea07a0 | 2 months ago |
37 changed files with 2746 additions and 45 deletions
@ -0,0 +1,48 @@
|
||||
{% extends "core/base.html" %} |
||||
{% block title %}Imports · Gizmoduck{% endblock %} |
||||
{% block content %} |
||||
<h1>Imports</h1> |
||||
<p>Copy bank CSV or Ourocard TXT files into the local <code>imports/</code> folder, then scan and map each section to an active BRL account.</p> |
||||
<p><a href="{% url 'account-create' %}">Create an account</a> before mapping if needed.</p> |
||||
{% if error %}<p class="error-summary" role="alert">{{ error }}</p>{% endif %} |
||||
<form method="post"> |
||||
{% csrf_token %} |
||||
<button name="action" value="scan">Scan files</button> |
||||
</form> |
||||
{% if files %} |
||||
<form method="post"> |
||||
{% csrf_token %} |
||||
{% if scan_token %}<input type="hidden" name="scan_token" value="{{ scan_token }}">{% endif %} |
||||
{% if prepared_token %}<input type="hidden" name="prepared_token" value="{{ prepared_token }}">{% endif %} |
||||
<div class="table-wrap"> |
||||
<table> |
||||
<caption>Local file status</caption> |
||||
<thead><tr><th scope="col">File</th><th scope="col">Profile</th><th scope="col">Sections and accounts</th><th scope="col">Status</th><th scope="col">Counts</th></tr></thead> |
||||
<tbody> |
||||
{% for file in files %} |
||||
<tr> |
||||
<th scope="row">{{ file.name }}</th> |
||||
<td>{{ file.profile|default:"Unknown" }}</td> |
||||
<td>{% if file.form %}{% for field in file.form %}<div class="field">{{ field.label_tag }}{{ field }}</div>{% endfor %}{% else %}{% for section in file.sections %}<p>Section {{ section }}</p>{% empty %}—{% endfor %}{% endif %}</td> |
||||
<td>{{ file.state }}{% if file.code %}<p class="error">{{ file.code }}</p>{% endif %}</td> |
||||
<td>{% if file.counts != None %}{% if file.state == "READY" or file.state == "ALREADY_IMPORTED" %}<p>Planned</p>{% endif %}<p>Created: {{ file.counts.created }}</p><p>Matched: {{ file.counts.matched }}</p><p>Skipped: {{ file.counts.skipped }}</p>{% else %}—{% endif %}</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
{% if scan_token %} |
||||
<p>Preparation checks each file and its mapping. Transactions are written only when you import ready files.</p> |
||||
<button name="action" value="prepare">Prepare imports</button> |
||||
{% endif %} |
||||
{% if prepared_token %} |
||||
<p>Import the ready files and finish exact reimport skips. Files needing attention remain separate.</p> |
||||
<button name="action" value="commit">Import ready files</button> |
||||
{% endif %} |
||||
</form> |
||||
<p>For NEEDS_ATTENTION, correct the source or account mapping and scan again. REVIEW_REQUIRED needs a local manual review before importing; unsupported files must be replaced with a supported CSV or TXT export.</p> |
||||
<p>Scan and prepared selections expire after 15 minutes. Scan again after changing a file.</p> |
||||
{% elif not error %} |
||||
<p>No files listed. Add supported files to <code>imports/</code> and choose Scan files.</p> |
||||
{% endif %} |
||||
{% endblock %} |
||||
@ -0,0 +1,216 @@
|
||||
# Guided Local Imports 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:** Add an Imports page that scans the ignored local folder, maps files to accounts, and automatically commits only clean new transactions. |
||||
|
||||
**Architecture:** A new ingestion-facing service discovers safe direct files and derives partial-card date context without persisting source contents. A second orchestration service creates the existing preview, applies only deterministic `CREATE` and exact-source `SKIP` decisions, and commits each ready file independently. Server-rendered views hold signed scan/preflight state and display safe summaries. |
||||
|
||||
**Tech Stack:** Python, Django, PostgreSQL, Django signed values, existing ingestion services, pytest, Docker Compose. |
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-09-guided-local-import-design.md` |
||||
|
||||
## Global Constraints |
||||
|
||||
- Scan direct files only under `IMPORT_ROOT`; reject symlinks, traversal, unsupported inputs, and files over 10 MiB. |
||||
- Never display or commit source descriptions, amounts, source rows, raw paths, parser exceptions, or database errors in UI messages, logs, or tests. |
||||
- Support only `BANK_CSV_V1`, `OUROCARD_FULL_V1`, and `OUROCARD_PARTIAL_V1`; OFX and PDF remain unsupported. |
||||
- Use active BRL accounts for every submitted source section; never infer mapping from owner names or filenames. |
||||
- Preserve parsing, normalization, validation, duplicate detection, preview, decision, and persistence boundaries. |
||||
- Clean rows use `CREATE`; exact existing source rows use `SKIP` / `ALREADY_IMPORTED`; all other issues prevent only that file from committing. |
||||
- Partial-card context comes only from a validated creation date at text line 21; dates later than that month use the preceding year. |
||||
- Use only synthetic test data and never commit local financial documents, review files, exports, backups, or credentials. |
||||
|
||||
--- |
||||
|
||||
### Task 1: Discover safe files and derive partial-card context |
||||
|
||||
**Files:** |
||||
- Create: `ingestion/guided_imports.py` |
||||
- Modify: `ingestion/parsers.py` |
||||
- Create: `tests/test_guided_import_discovery.py` |
||||
- Modify: `tests/test_import_parsers.py` |
||||
|
||||
**Interfaces:** |
||||
- Produces `DiscoveredFile(name: str, document_hash: str, profile: str | None, sections: tuple[str, ...], code: str | None)`. |
||||
- Produces `discover_imports() -> tuple[DiscoveredFile, ...]` and `partial_context_from_creation_line(data: bytes) -> dict[str, str]`. |
||||
|
||||
- [ ] **Step 1: Write failing pure discovery tests.** |
||||
|
||||
Use `override_settings(IMPORT_ROOT=tmp_path)` and synthetic bytes. Assert direct supported files receive a profile and section list; nested files, symlinks, oversize files, PDFs, OFX, and invalid layouts return only safe code/status. Assert no result has a source-text field. |
||||
|
||||
```python |
||||
rows = discover_imports() |
||||
assert [(row.name, row.profile, row.sections, row.code) for row in rows] == [ |
||||
("bank.csv", "BANK_CSV_V1", ("1",), None), |
||||
("unknown.ofx", None, (), "UNSUPPORTED_FILE"), |
||||
] |
||||
``` |
||||
|
||||
Add parser tests for valid synthetic line 21 creation date, absent/malformed line 21, and a January creation date that resolves `31/12` to the previous year. |
||||
|
||||
- [ ] **Step 2: Verify RED.** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/test_guided_import_discovery.py tests/test_import_parsers.py` |
||||
|
||||
Expected: FAIL because the discovery module and context extractor do not exist. |
||||
|
||||
- [ ] **Step 3: Implement constrained discovery and context extraction.** |
||||
|
||||
`discover_imports` resolves `settings.IMPORT_ROOT`, iterates only `root.iterdir()`, ignores non-files and symlinks, reads no more than `MAX_BYTES + 1`, hashes data with SHA-256, and determines a profile by attempting the supported pure grammars without persisting parse output. It returns sorted filename-only results. |
||||
|
||||
```python |
||||
@dataclass(frozen=True) |
||||
class DiscoveredFile: |
||||
name: str |
||||
document_hash: str |
||||
profile: str | None |
||||
sections: tuple[str, ...] |
||||
code: str | None |
||||
``` |
||||
|
||||
Add `partial_context_from_creation_line` beside the pure parser helpers. It decodes through the existing encoding rules, reads exactly line 21, validates the approved creation-date layout, and returns an ISO `start_date`/`end_date` interval no longer than 366 days. Keep the existing explicit `parse_document(..., context=...)` contract unchanged. |
||||
|
||||
- [ ] **Step 4: Verify GREEN and commit.** |
||||
|
||||
Run: |
||||
|
||||
```powershell |
||||
docker compose run --rm test pytest -q tests/test_guided_import_discovery.py tests/test_import_parsers.py |
||||
docker compose run --rm web python manage.py check |
||||
``` |
||||
|
||||
Inspect staged content for real data, then commit: |
||||
|
||||
```powershell |
||||
git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: discover local import files" |
||||
``` |
||||
|
||||
### Task 2: Preflight and commit deterministic imports |
||||
|
||||
**Files:** |
||||
- Modify: `ingestion/guided_imports.py` |
||||
- Create: `tests/test_guided_import_workflow.py` |
||||
|
||||
**Interfaces:** |
||||
- Consumes `DiscoveredFile`, `Actor`, and `dict[str, str]` section mappings. |
||||
- Produces `PreparedImport(name, document_hash, profile, sections, state, batch_id, revision, counts, code)` and `prepare_import(...)` / `commit_prepared_import(...)`. |
||||
|
||||
- [ ] **Step 1: Write failing orchestration tests.** |
||||
|
||||
Create synthetic active BRL accounts and source bytes in a temporary import root. Test that a clean file preflights to `READY`, an exact second import preflights to `ALREADY_IMPORTED`, an ambiguous duplicate is `NEEDS_ATTENTION`, and one exceptional file does not prevent another ready file from committing. |
||||
|
||||
```python |
||||
prepared = prepare_import(discovered=bank, section_mapping={"1": str(account.id)}, actor=ACTOR) |
||||
assert prepared.state == "READY" |
||||
committed = commit_prepared_import(prepared=prepared, actor=ACTOR) |
||||
assert committed.counts["created"] == 2 |
||||
``` |
||||
|
||||
- [ ] **Step 2: Verify RED.** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/test_guided_import_workflow.py` |
||||
|
||||
Expected: FAIL because preparation and automatic decisions do not exist. |
||||
|
||||
- [ ] **Step 3: Implement one service that composes existing lifecycle calls.** |
||||
|
||||
Re-read the file and require its SHA-256 to equal `discovered.document_hash` before acting. Validate section keys exactly match and map to active BRL `Account` records. For partial files obtain validated context from Task 1. Call `create_preview`, inspect its items, and build complete decisions: |
||||
|
||||
```python |
||||
if item.issues == ["ALREADY_IMPORTED"]: |
||||
decision = {"locator": item.locator, "action": "SKIP", "reason": "ALREADY_IMPORTED"} |
||||
elif not item.issues and not item.duplicates["existing"] and not item.duplicates["within"]: |
||||
decision = {"locator": item.locator, "action": "CREATE"} |
||||
else: |
||||
cancel_preview(batch_id=batch.pk, revision=batch.revision, actor=actor) |
||||
return PreparedImport(..., state="NEEDS_ATTENTION", code="REVIEW_REQUIRED") |
||||
``` |
||||
|
||||
Apply the complete decision list only for ready/already-imported batches. `commit_prepared_import` requires the stored batch id/revision and delegates to `commit_preview`; it never writes transactions directly. Return counts and safe codes only. File changes, stale batches, domain failures, and bad mappings yield a safe exception result and cancel any unfinished preview. |
||||
|
||||
- [ ] **Step 4: Verify GREEN and commit.** |
||||
|
||||
Run: |
||||
|
||||
```powershell |
||||
docker compose run --rm test pytest -q tests/test_guided_import_workflow.py tests/test_import_workflow.py tests/test_import_concurrency.py |
||||
docker compose run --rm web python manage.py check |
||||
``` |
||||
|
||||
Inspect staged content for sensitive data, then commit: |
||||
|
||||
```powershell |
||||
git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: prepare guided imports" |
||||
``` |
||||
|
||||
### Task 3: Add the Imports page and owner workflow coverage |
||||
|
||||
**Files:** |
||||
- Modify: `config/urls.py` |
||||
- Modify: `core/context.py` |
||||
- Modify: `core/forms.py` |
||||
- Modify: `core/views.py` |
||||
- Create: `core/templates/core/imports/index.html` |
||||
- Create: `tests/test_guided_import_views.py` |
||||
- Modify: `tests/test_mvp_acceptance.py` |
||||
- Modify: `README.md` |
||||
- Modify: `docs/local-workflows.md` |
||||
|
||||
**Interfaces:** |
||||
- Route: `imports-index` at `/imports/`. |
||||
- View actions: `scan`, `prepare`, and `commit`. |
||||
- Signed payload contains only filename, hash, profile, section identifiers, prepared batch id, and revision. |
||||
|
||||
- [ ] **Step 1: Write failing web tests.** |
||||
|
||||
Use a temporary synthetic `IMPORT_ROOT`. Cover navigation, CSRF, scan results with safe filename/profile/status only, active-BRL account selectors, mapping submission, ready-file commit, exact reimport summary, one exception plus one successful file, and altered/stale signed state rejection. |
||||
|
||||
```python |
||||
response = client.post(reverse("imports-index"), {"action": "scan"}) |
||||
assert "bank.csv" in response.content.decode() |
||||
assert "synthetic description" not in response.content.decode() |
||||
``` |
||||
|
||||
- [ ] **Step 2: Verify RED.** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/test_guided_import_views.py` |
||||
|
||||
Expected: FAIL because the page, routes, forms, and signed workflow do not exist. |
||||
|
||||
- [ ] **Step 3: Implement server-rendered scan, mapping, and commit actions.** |
||||
|
||||
Add Import navigation and `imports-index`. `scan` calls `discover_imports` and signs safe discovery metadata. `prepare` revalidates it, receives one typed active-account field per discovered section, and calls `prepare_import` independently for every mapped file. `commit` accepts only signed ready batch id/revision values and calls `commit_prepared_import` per file. |
||||
|
||||
```python |
||||
token = signing.dumps(payload, salt="guided-imports") |
||||
payload = signing.loads(request.POST["scan_token"], salt="guided-imports", max_age=900) |
||||
``` |
||||
|
||||
Render only filename, profile, section label, mapping selection, state, safe code, and counts. Do not display candidate descriptions, amounts, source paths, raw exceptions, or batch audit details. Use `Actor("HUMAN", "local-owner")` and redirect after each successful POST. |
||||
|
||||
- [ ] **Step 4: Document and verify the owner path.** |
||||
|
||||
Replace the primary manual-import instructions in `README.md` and `docs/local-workflows.md` with: copy files to `imports/`, open Imports, scan, map accounts, import ready files, and resolve listed exceptions. Retain the management command as the advanced manual workflow. |
||||
|
||||
Add one synthetic MVP acceptance flow through scan, mapping, automatic import, and exact reimport skip. Run: |
||||
|
||||
```powershell |
||||
docker compose run --rm test pytest -q tests/test_guided_import_views.py tests/test_mvp_acceptance.py |
||||
docker compose build |
||||
docker compose run --rm test pytest -q |
||||
docker compose run --rm web python manage.py migrate --check |
||||
docker compose run --rm web python manage.py check |
||||
docker compose run --rm permissions |
||||
docker compose up -d web |
||||
Invoke-RestMethod http://127.0.0.1:8000/health/ | ConvertTo-Json -Compress |
||||
git diff --check |
||||
``` |
||||
|
||||
- [ ] **Step 5: Commit the UI, documentation, and acceptance slice.** |
||||
|
||||
Inspect staged paths and content for financial data and credentials, then commit: |
||||
|
||||
```powershell |
||||
git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: add guided import page" |
||||
``` |
||||
@ -0,0 +1,141 @@
|
||||
# Staged Guided Import Files 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:** Replace the unapproved ten-file scan cap with a simple incoming → staging → processed local file lifecycle. |
||||
|
||||
**Architecture:** Keep all lifecycle state in ignored local folders, not browser sessions or database tables. Before handling a file, move it from incoming to staging; after an existing atomic commit, move it to processed and overwrite the destination name. Failures return the staged file to incoming; a later scan recovers any leftover staged files first. |
||||
|
||||
**Tech Stack:** Python, Django, existing ingestion services, pytest, Docker Compose. |
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-09-guided-local-import-design.md` |
||||
|
||||
## Global Constraints |
||||
|
||||
- `imports/` is incoming, `imports/staging/` is temporary, and `imports/processed/` holds successfully committed files. |
||||
- Scan only direct incoming files; staging and processed directories are never candidates. |
||||
- Move to staging before preview, return failures to incoming, and overwrite processed names only after successful commit. |
||||
- Recover direct regular leftover staging files to incoming before each scan. |
||||
- Preserve the existing parsing, normalization, validation, duplicate, preview, and persistence boundaries. |
||||
- Use no database lifecycle tables, no browser file-count cap, and no new speculative recovery framework. |
||||
- Add exactly two synthetic regression tests: one successful lifecycle and one failure/recovery lifecycle. |
||||
- Never commit real source files, local reports, database data, backups, or credentials. |
||||
|
||||
--- |
||||
|
||||
### Task 1: Stage files around the existing guided-import lifecycle |
||||
|
||||
**Files:** |
||||
- Modify: `ingestion/guided_imports.py` |
||||
- Modify: `tests/test_guided_import_workflow.py` |
||||
|
||||
**Interfaces:** |
||||
- `discover_imports() -> tuple[DiscoveredFile, ...]` recovers stale staging entries and returns incoming entries only. |
||||
- `prepare_import(...)` accepts an incoming `DiscoveredFile`, moves it to staging, then keeps its current deterministic preview/decision behavior. |
||||
- `commit_prepared_import(...)` commits through the existing service, then moves its staged source to processed. |
||||
|
||||
- [ ] **Step 1: Add the two focused synthetic tests.** |
||||
|
||||
Extend the existing guided workflow test module with exactly these behaviors: |
||||
|
||||
```python |
||||
def test_guided_import_stages_then_processes_committed_source(source, bank): |
||||
incoming = source("bank.csv", BANK_BYTES) |
||||
prepared = prepare(incoming, bank) |
||||
committed = commit(prepared) |
||||
|
||||
assert committed.state == "COMMITTED" |
||||
assert not (incoming.parent / "bank.csv").exists() |
||||
assert (incoming.parent / "processed" / "bank.csv").read_bytes() == BANK_BYTES |
||||
|
||||
def test_guided_import_returns_failure_and_recovers_leftover_staging(source, bank): |
||||
incoming = source("bad.csv", b"unsupported") |
||||
result = prepare(incoming, bank) |
||||
|
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert (incoming.parent / "bad.csv").exists() |
||||
(incoming.parent / "staging" / "interrupted.csv").write_bytes(BANK_BYTES) |
||||
assert [row.name for row in guided_imports.discover_imports()] == ["bad.csv", "interrupted.csv"] |
||||
``` |
||||
|
||||
- [ ] **Step 2: Verify RED.** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/test_guided_import_workflow.py` |
||||
|
||||
Expected: FAIL because current imports remain in the incoming folder and no staging recovery exists. |
||||
|
||||
- [ ] **Step 3: Implement the minimal local lifecycle helpers.** |
||||
|
||||
Add private helpers in `ingestion/guided_imports.py` that use the existing safe directory-descriptor boundary to create and access `staging` and `processed` below the import root. They must reject symlinks and only operate on direct regular files. |
||||
|
||||
```python |
||||
def _recover_staging() -> None: |
||||
"""Move direct regular staging files back to incoming before discovery.""" |
||||
|
||||
def _stage(discovered: DiscoveredFile) -> DiscoveredFile: |
||||
"""Atomically rename one verified incoming file into staging.""" |
||||
|
||||
def _return_to_incoming(name: str) -> None: |
||||
"""Move a staged file back when it was not committed.""" |
||||
|
||||
def _move_to_processed(name: str) -> None: |
||||
"""Replace the processed filename after a successful commit.""" |
||||
``` |
||||
|
||||
Call `_recover_staging()` before listing incoming candidates. In `prepare_import`, stage only after validating safe discovery metadata; then read exclusively from staging and retain the staged identity in `PreparedImport`. On every `NEEDS_ATTENTION` path, return the source to incoming after cancelling an unfinished preview. In `commit_prepared_import`, call `services.commit_preview` first, then `_move_to_processed`. If the final move fails, leave the file in staging: the next discovery returns it to incoming and existing provenance prevents duplicate financial rows. |
||||
|
||||
Remove the `MAX_GUIDED_FILES` behavior and any scan-session capacity logic from the service-facing metadata; no scan count is stored or enforced. |
||||
|
||||
- [ ] **Step 4: Verify GREEN and commit.** |
||||
|
||||
Run: |
||||
|
||||
```powershell |
||||
docker compose run --rm test pytest -q tests/test_guided_import_workflow.py |
||||
docker compose run --rm web python manage.py check |
||||
``` |
||||
|
||||
Inspect staged paths and staged content for financial data or secrets, then commit: |
||||
|
||||
```powershell |
||||
git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: stage guided import files" |
||||
``` |
||||
|
||||
### Task 2: Remove the UI cap and update owner instructions |
||||
|
||||
**Files:** |
||||
- Modify: `core/views.py` |
||||
- Modify: `core/templates/core/imports/index.html` |
||||
- Modify: `tests/test_guided_import_views.py` |
||||
- Modify: `README.md` |
||||
- Modify: `docs/local-workflows.md` |
||||
|
||||
**Interfaces:** |
||||
- The Imports page continues to call `discover_imports`, `prepare_import`, and `commit_prepared_import`. |
||||
- It has no `MAX_GUIDED_FILES` check, no `SCAN_TOO_LARGE` response, and stores no per-file result list in the signed-cookie session. |
||||
|
||||
- [ ] **Step 1: Remove the count-cap branch and stale copy.** |
||||
|
||||
Delete the cap constant, `SCAN_TOO_LARGE` branch, count-specific template copy, per-file signed-cookie-session summaries, and the existing cap/cookie-capacity view tests. Render each safe per-file result list directly in the commit POST response instead of redirecting it through the session. Keep current CSRF, signed metadata, account mapping, and disclosure boundaries unchanged. |
||||
|
||||
- [ ] **Step 2: Update concise owner instructions.** |
||||
|
||||
State that files begin in `imports/`, move through `imports/staging/` while an import runs, and appear in `imports/processed/` after a successful commit. Explain that exceptions return to incoming and are safe to correct and rescan. Do not include real filenames, balances, or source content. |
||||
|
||||
- [ ] **Step 3: Verify and commit.** |
||||
|
||||
Run: |
||||
|
||||
```powershell |
||||
docker compose run --rm test pytest -q tests/test_guided_import_views.py tests/test_guided_import_workflow.py |
||||
docker compose run --rm test pytest -q |
||||
docker compose up -d web |
||||
Invoke-RestMethod http://127.0.0.1:8000/health/ | ConvertTo-Json -Compress |
||||
git diff --check |
||||
``` |
||||
|
||||
Inspect staged paths and staged content for financial data or secrets, then commit: |
||||
|
||||
```powershell |
||||
git -c user.name=Codex -c user.email=codex@localhost commit -m "docs: explain staged import files" |
||||
``` |
||||
@ -0,0 +1,200 @@
|
||||
# Category Hierarchy and Tags 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:** Add category groups and leaf categories while laying down persistence-only transaction tags. |
||||
|
||||
**Architecture:** `CategoryGroup` owns optional groups of transaction-assignable `Category` leaves. Existing categories remain ungrouped. `Tag` is separate from accounting classification and is joined to transactions but has no product behavior in this release. |
||||
|
||||
**Tech Stack:** Python, Django 5.2, PostgreSQL, pytest, Docker Compose. |
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-14-category-hierarchy-tags-design.md` |
||||
|
||||
## Global Constraints |
||||
|
||||
- Preserve every category primary key and transaction category relationship. |
||||
- Use only synthetic data; do not read or commit local YNAB data. |
||||
- Category leaves remain the only transaction-assignable classification. |
||||
- Tags have no UI, service mutation, filter, report, import, or export behavior. |
||||
- All writes use `finance.services`; run the health endpoint after implementation. |
||||
|
||||
--- |
||||
|
||||
### Task 1: Add the hierarchy and tag schema |
||||
|
||||
**Files:** |
||||
- Modify: `finance/models/reference.py`, `finance/models/ledger.py`, `finance/models/__init__.py` |
||||
- Create: `finance/migrations/0004_category_groups_and_tags.py`, `tests/finance/test_category_hierarchy_migration.py` |
||||
- Modify: `tests/finance/test_constraints.py` |
||||
|
||||
**Interfaces:** `CategoryGroup(name, archived)`, `Tag(name, archived)`, nullable `Category.group`, and `Transaction.tags` with `related_name="transactions"`. |
||||
|
||||
- [ ] **Step 1: Write failing schema tests** |
||||
|
||||
```python |
||||
def test_category_names_are_unique_within_a_group_and_tags_can_relate(account): |
||||
home = CategoryGroup.objects.create(name="Synthetic Home") |
||||
travel = CategoryGroup.objects.create(name="Synthetic Travel") |
||||
Category.objects.create(name="Synthetic Fees", kind="EXPENSE", group=home) |
||||
Category.objects.create(name="Synthetic Fees", kind="EXPENSE", group=travel) |
||||
row = post(account) |
||||
tag = Tag.objects.create(name="Synthetic Tax") |
||||
row.tags.add(tag) |
||||
assert list(row.tags.all()) == [tag] |
||||
``` |
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/finance/test_constraints.py tests/finance/test_category_hierarchy_migration.py` |
||||
|
||||
Expected: FAIL because the new models and relations do not exist. |
||||
|
||||
- [ ] **Step 3: Implement models and migration** |
||||
|
||||
Create `CategoryGroup` and `Tag` from `TimestampedModel`, both with nonblank globally unique names and `archived`. Add `Category.group = ForeignKey("finance.CategoryGroup", null=True, blank=True, on_delete=PROTECT, related_name="categories")`; replace `Category.name` global uniqueness with `UniqueConstraint(fields=["group", "name"], name="category_group_name_unique")` and conditional `UniqueConstraint(fields=["name"], condition=Q(group__isnull=True), name="category_ungrouped_name_unique")`. Add `Transaction.tags = ManyToManyField("finance.Tag", blank=True, related_name="transactions")`. Generate the migration; it must not update rows. |
||||
|
||||
- [ ] **Step 4: Run schema verification** |
||||
|
||||
Run: `docker compose run --rm manage migrate; docker compose run --rm test pytest -q tests/finance/test_constraints.py tests/finance/test_category_hierarchy_migration.py; docker compose run --rm manage makemigrations --check --dry-run` |
||||
|
||||
Expected: PASS. |
||||
|
||||
- [ ] **Step 5: Commit the schema slice** |
||||
|
||||
Run: `git add finance/models finance/migrations/0004_category_groups_and_tags.py tests/finance/test_constraints.py tests/finance/test_category_hierarchy_migration.py; git diff --cached --check; git diff --cached; git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: add category groups and tags"` |
||||
|
||||
### Task 2: Support category groups in services, choices, and reference pages |
||||
|
||||
**Files:** |
||||
- Modify: `finance/services/reference.py`, `finance/services/posting.py`, `finance/services/__init__.py`, `finance/queries.py` |
||||
- Modify: `core/forms.py`, `core/views.py`, `config/urls.py`, `core/templates/core/references/list.html`, `core/templates/core/transactions/list.html` |
||||
- Modify: `tests/finance/test_services.py`, `tests/finance/test_queries.py`, `tests/test_web_workflows.py` |
||||
|
||||
**Interfaces:** `create_category_group(*, name, actor)`, `create_category(*, name, kind, group_id=None, actor)`, `active_category_groups()`, and `category_label(category)`. |
||||
|
||||
- [ ] **Step 1: Write failing behavior tests** |
||||
|
||||
```python |
||||
def test_archived_group_blocks_new_child_assignment(): |
||||
group = create_category_group(name="Synthetic Home", actor=ACTOR) |
||||
leaf = create_category(name="Synthetic Utilities", kind="EXPENSE", group_id=group.pk, actor=ACTOR) |
||||
archive(group, actor=ACTOR) |
||||
with pytest.raises(DomainError, match="archived_reference"): |
||||
record_transaction(account_id=account.pk, amount="-1", posting_date=date(2026, 1, 1), kind="EXPENSE", category_id=leaf.pk, actor=ACTOR) |
||||
``` |
||||
|
||||
- [ ] **Step 2: Run focused tests to verify failure** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/finance/test_services.py tests/finance/test_queries.py tests/test_web_workflows.py` |
||||
|
||||
Expected: FAIL because groups cannot be created or archived. |
||||
|
||||
- [ ] **Step 3: Implement active hierarchy behavior** |
||||
|
||||
Add audited create/archive handling for `CategoryGroup`; extend category creation with optional locked active `group_id`. In posting, lock the category and then its group; reject an archived group using `DomainError("archived_reference")`. Make `active_categories()` exclude leaves with archived groups. Use `category_label` (`"Group · Leaf"` or `"Leaf"`) in form choices, filter choices, reference lists, and transaction rows. Add create/list/archive routes for groups only; add no tag route. |
||||
|
||||
- [ ] **Step 4: Run focused tests** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/finance/test_services.py tests/finance/test_queries.py tests/test_web_workflows.py` |
||||
|
||||
Expected: PASS; existing flat-category flows still pass. |
||||
|
||||
- [ ] **Step 5: Commit the workflow slice** |
||||
|
||||
Run: `git add finance/services finance/queries.py core/forms.py core/views.py config/urls.py core/templates/core/references/list.html core/templates/core/transactions/list.html tests/finance/test_services.py tests/finance/test_queries.py tests/test_web_workflows.py; git diff --cached --check; git diff --cached; git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: manage grouped categories"` |
||||
|
||||
### Task 3: Add hierarchy roll-ups to spending reports |
||||
|
||||
**Files:** |
||||
- Modify: `finance/reports.py`, `core/templates/core/dashboard.html`, `core/templates/core/reports/monthly.html` |
||||
- Modify: `tests/finance/test_reports.py`, `tests/test_report_views.py` |
||||
|
||||
**Interfaces:** `spending_by_category()` returns display rows with `group_name`, `category_name`, `is_group_total`, `expense`, `refund`, and `net_spending`. |
||||
|
||||
- [ ] **Step 1: Write a failing grouped-total test** |
||||
|
||||
```python |
||||
assert [(row.is_group_total, row.group_name, row.category_name, row.net_spending) for row in rows] == [ |
||||
(True, "Synthetic Food", None, Decimal("50.00")), |
||||
(False, "Synthetic Food", "Synthetic Groceries", Decimal("30.00")), |
||||
(False, "Synthetic Food", "Synthetic Dining", Decimal("20.00")), |
||||
] |
||||
``` |
||||
|
||||
- [ ] **Step 2: Run report tests to verify failure** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/finance/test_reports.py tests/test_report_views.py` |
||||
|
||||
Expected: FAIL because rows are flat. |
||||
|
||||
- [ ] **Step 3: Implement query-time group roll-ups** |
||||
|
||||
Aggregate by group and leaf fields, build each leaf row, and derive a group-total row by summing its leaves. Sort group totals by net spending then group name and their leaves by net spending then category name. Keep ungrouped categories and `Uncategorized` as leaf rows. Render visibly distinct group totals and indented child rows in both templates; do not change monthly summary arithmetic. |
||||
|
||||
- [ ] **Step 4: Run report verification** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/finance/test_reports.py tests/test_report_views.py tests/test_mvp_acceptance.py` |
||||
|
||||
Expected: PASS. |
||||
|
||||
- [ ] **Step 5: Commit the report slice** |
||||
|
||||
Run: `git add finance/reports.py core/templates/core/dashboard.html core/templates/core/reports/monthly.html tests/finance/test_reports.py tests/test_report_views.py; git diff --cached --check; git diff --cached; git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: roll up spending by category group"` |
||||
|
||||
### Task 4: Export category hierarchy and document the supported behavior |
||||
|
||||
**Files:** |
||||
- Modify: `finance/exports.py`, `tests/finance/test_exports.py`, `docs/financial-domain.md`, `docs/local-workflows.md` |
||||
|
||||
**Interfaces:** Add `category_groups.csv` before `categories.csv`; append `group_id` to `categories.csv`; do not export tags or transaction-tag joins. |
||||
|
||||
- [ ] **Step 1: Write failing export tests** |
||||
|
||||
```python |
||||
assert "category_groups.csv" in package.namelist() |
||||
assert rows(package, "categories.csv")[0]["group_id"] == str(group.pk) |
||||
assert "tags.csv" not in package.namelist() |
||||
assert "transaction_tags.csv" not in package.namelist() |
||||
``` |
||||
|
||||
- [ ] **Step 2: Run the export test to verify failure** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/finance/test_exports.py` |
||||
|
||||
Expected: FAIL because group data is not exported. |
||||
|
||||
- [ ] **Step 3: Implement export and documentation changes** |
||||
|
||||
Add `CategoryGroup` to export schemas ahead of categories and add `group_id` to the category fields. Update synthetic export seed data, expected member order, headers, manifest checks, and row assertions. Update the financial-domain and local-workflow guides: leaves classify transactions; groups organize/report; archiving a group disables its children for new assignments; tags are schema-only. |
||||
|
||||
- [ ] **Step 4: Run export verification** |
||||
|
||||
Run: `docker compose run --rm test pytest -q tests/finance/test_exports.py tests/test_mvp_acceptance.py` |
||||
|
||||
Expected: PASS with no tag archive members. |
||||
|
||||
- [ ] **Step 5: Commit the export slice** |
||||
|
||||
Run: `git add finance/exports.py tests/finance/test_exports.py docs/financial-domain.md docs/local-workflows.md; git diff --cached --check; git diff --cached; git -c user.name=Codex -c user.email=codex@localhost commit -m "feat: export category hierarchy"` |
||||
|
||||
### Task 5: Run complete verification and health check |
||||
|
||||
**Files:** Modify only to correct a task-scoped defect found below. |
||||
|
||||
- [ ] **Step 1: Run migrations and the relevant suite** |
||||
|
||||
Run: `docker compose build; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; docker compose run --rm manage migrate; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; docker compose run --rm test pytest -q tests/finance tests/test_web_workflows.py tests/test_report_views.py tests/test_mvp_acceptance.py; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; docker compose run --rm manage makemigrations --check --dry-run; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; docker compose run --rm manage migrate --check` |
||||
|
||||
Expected: PASS. |
||||
|
||||
- [ ] **Step 2: Start the app and check health** |
||||
|
||||
Run: `docker compose up -d web; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Invoke-RestMethod http://127.0.0.1:8000/health/ | ConvertTo-Json -Compress` |
||||
|
||||
Expected: healthy JSON response. |
||||
|
||||
- [ ] **Step 3: Perform final privacy and diff review** |
||||
|
||||
Run: `git diff --check; git status --short; git log --oneline -4` |
||||
|
||||
Expected: no whitespace errors or real financial data, with the pre-existing `AGENTS.md` edit left untouched. |
||||
@ -0,0 +1,158 @@
|
||||
# Guided Local Import Design |
||||
|
||||
## Purpose |
||||
|
||||
Let the local owner place supported financial documents in the ignored |
||||
`imports/` directory and load clean, new transactions through the web app with |
||||
minimal interaction. The owner scans the folder, selects destination accounts |
||||
where needed, imports all ready files, and receives safe status summaries. |
||||
|
||||
The workflow preserves Gizmoduck's existing review boundaries: parsing, |
||||
normalization, validation, duplicate detection, preview, decisions, and |
||||
persistence remain distinct. The UI orchestrates those boundaries; it does not |
||||
replace them or expose source contents in browser messages. |
||||
|
||||
## User workflow |
||||
|
||||
1. The owner copies supported files to the ignored `imports/` directory. |
||||
2. The owner opens **Imports** and selects **Scan imports**. |
||||
3. Gizmoduck returns interrupted staging files to the incoming folder, then |
||||
lists each safe candidate filename, detected profile, account |
||||
mapping requirements, row counts, and a safe status. |
||||
4. The owner selects an account for each bank CSV and every detected card |
||||
statement section that needs mapping. |
||||
5. The owner selects **Import ready files**. |
||||
6. Each ready file moves through staging, preview, automatic-decision, and |
||||
commit. A committed file moves to processed. The page reports created, |
||||
matched, and skipped counts. |
||||
7. Files needing attention remain uncommitted. The owner corrects the account |
||||
choice or required context, rescans, and imports when the file becomes |
||||
ready. |
||||
|
||||
## Scope and file handling |
||||
|
||||
`IMPORT_ROOT` has three local, ignored folders: incoming files at `imports/`, |
||||
temporary work at `imports/staging/`, and successful source documents at |
||||
`imports/processed/`. The scanner examines only direct files in the incoming |
||||
folder; it has no fixed file-count limit. It never follows symlinks, rejects |
||||
traversal, and applies the existing 10 MiB input limit. It supports only the |
||||
existing Banco do Brasil bank CSV and Ourocard TXT formats. OFX, PDFs, unknown |
||||
extensions, and unrecognized layouts are reported as safe exceptions and never |
||||
parsed or committed. |
||||
|
||||
Before each scan, Gizmoduck moves direct regular files left in `staging/` back |
||||
to incoming. This recovers a run interrupted between staging and its final |
||||
move. Neither `staging/` nor `processed/` is included in normal candidate |
||||
scanning. |
||||
|
||||
The scanner retains no source rows, descriptions, amounts, or review exports in |
||||
templates, messages, or logs. Those remain local source material governed by |
||||
the data policy. The web UI displays filenames, profile labels, account mapping |
||||
state, safe reason codes, and counts only. |
||||
|
||||
## Readiness and automatic decisions |
||||
|
||||
Each scanned file has one of these statuses: |
||||
|
||||
- **Ready:** every candidate can be created without a validation issue or |
||||
unresolved duplicate. |
||||
- **Already imported:** every candidate is already attached through existing provenance or |
||||
exactly matches a persisted transaction signature; the file is skipped without financial writes. |
||||
- **Needs attention:** the file has an unsupported/unrecognized profile, |
||||
malformed or unresolved candidate, missing account mapping, ambiguous |
||||
potential duplicate, or missing/invalid statement context. |
||||
|
||||
The import action creates a normal existing preview per ready file. It applies |
||||
`CREATE` to clean candidates. Exact source reimports and candidates with an exact persisted |
||||
transaction signature receive `SKIP` with `ALREADY_IMPORTED`; they produce no transaction and |
||||
are not an exception. Duplicates within the incoming file still require review and prevent that |
||||
file from committing automatically. |
||||
|
||||
An exception in one file does not prevent other ready files from committing. |
||||
Every file retains the import service's atomic commit boundary; a failed file |
||||
creates no partial transactions or provenance. All import lifecycle actions use |
||||
the local human actor and retain existing audits. |
||||
|
||||
## Local file lifecycle |
||||
|
||||
Before creating a preview, Gizmoduck atomically moves each selected incoming |
||||
file to `staging/` on the same local volume. It then reads and verifies the |
||||
staged file, prepares it through the existing import lifecycle, and commits it. |
||||
After a successful commit, it moves the staged source file to `processed/`. |
||||
An existing processed file with the same name is overwritten only at this final |
||||
successful step. |
||||
|
||||
If preparation, validation, duplicate review, commit, or a pre-commit file |
||||
operation fails, Gizmoduck moves the staged file back to incoming and reports a |
||||
safe exception. If the process stops after a database commit but before the |
||||
processed move, the next scan restores that file to incoming. Existing |
||||
provenance then prevents duplicate transactions; the automatic exact-source |
||||
skip path may move it to processed. |
||||
|
||||
## Account mapping |
||||
|
||||
The scan presents an account choice for every source section needed by the |
||||
detected profile. Bank CSV has one section. Ourocard documents may have |
||||
multiple sections and require a choice for each section. The UI uses active |
||||
BRL accounts and cards only. It never maps an owner name or filename to an |
||||
account automatically. |
||||
|
||||
The server validates that every submitted mapping corresponds to the current |
||||
scan and to an active account. Missing, archived, stale, or altered mappings |
||||
are exceptions; they cannot be committed. |
||||
|
||||
## Ourocard partial-statement dates |
||||
|
||||
`OUROCARD_PARTIAL_V1` gains a bounded context extractor. It reads and validates |
||||
the statement creation date from line 21 of the TXT document. That date supplies |
||||
the statement year and an explicit import interval. Candidate month/day values |
||||
later than the creation month belong to the preceding calendar year; remaining |
||||
values belong to the creation year. This handles statements crossing New Year |
||||
without using the computer clock or filesystem metadata. |
||||
|
||||
If line 21 is absent, malformed, ambiguous, or does not yield an interval that |
||||
resolves every candidate date uniquely, the file is **Needs attention** and is |
||||
not committed. The existing explicit parser context remains available for the |
||||
management command; this UI-only extractor supplies equivalent validated |
||||
context before invoking the existing parser contract. |
||||
|
||||
## Components |
||||
|
||||
- `ingestion` receives a folder scanner, safe profile detector, and a |
||||
UI-orchestration service that constructs existing previews and applies only |
||||
the approved automatic decisions. |
||||
- `core` receives Import navigation, scan/mapping/import forms and views, and |
||||
server-rendered status templates. |
||||
- Existing parser, normalization, validation, duplicate, persistence, audit, |
||||
and import-batch models remain their own boundaries. |
||||
- The management command remains supported for advanced/manual reviewed import |
||||
workflows. |
||||
|
||||
## Error handling and security |
||||
|
||||
All POST actions use CSRF protection and revalidate file identity, profile, |
||||
mappings, and source metadata before creating a preview or committing. The UI |
||||
returns field errors and safe status codes. It does not reveal source text, |
||||
paths outside the allowed folder, raw parser exceptions, or database errors. |
||||
Staging identity is verified before every lifecycle step; a changed staged file |
||||
returns to incoming without commitment. |
||||
|
||||
## Verification |
||||
|
||||
Tests use only synthetic fixtures and cover: |
||||
|
||||
- direct-folder scanning and supported-profile detection; |
||||
- rejected path/symlink/oversize/unsupported input without source disclosure; |
||||
- staged success moving to processed, exception return to incoming, interrupted |
||||
staging recovery, and processed-name overwrite only after a successful |
||||
commit; |
||||
- bank and multi-section card account mapping with active BRL references; |
||||
- clean automatic creation through the existing audit and import lifecycle; |
||||
- exact reimport skipping through source provenance; |
||||
- an exceptional file not blocking an unrelated ready file; |
||||
- ambiguous duplicates, malformed candidates, mapping tampering, and CSRF |
||||
rejection; |
||||
- line-21 partial-statement date extraction, invalid context, and New Year |
||||
rollover; and |
||||
- focused web tests plus full Django, migration, permission, and integration |
||||
checks. |
||||
@ -0,0 +1,124 @@
|
||||
# Category Hierarchy and Tags Design |
||||
|
||||
**Date:** 2026-09-14 |
||||
**Status:** Approved |
||||
**Milestone:** Post-MVP financial reference-data evolution |
||||
|
||||
## Goal |
||||
|
||||
Represent YNAB-style category groups and their assignable subcategories in |
||||
Gizmoduck without changing existing transaction classifications. Establish a |
||||
minimal tag data model now, without exposing tag behavior, so a later feature |
||||
can use tags without reworking the transaction schema. |
||||
|
||||
This is a prerequisite for a later, separately designed YNAB reference-data |
||||
synchronization that imports category hierarchy and payees and proposes |
||||
annotations for existing transactions. |
||||
|
||||
## Scope |
||||
|
||||
This change includes: |
||||
|
||||
- category groups, leaf categories, hierarchy-aware category management, and |
||||
hierarchy-aware spending presentation; |
||||
- a migration that preserves every existing `Category` and its transaction |
||||
references as an ungrouped leaf category; |
||||
- a `Tag` model and a zero-or-more `Transaction.tags` relationship; and |
||||
- focused migration, model, service, query, and report tests using synthetic |
||||
data only. |
||||
|
||||
It excludes YNAB parsing/import, account mapping, transaction matching, |
||||
payee synchronization, tag creation or assignment workflows, tag filters, |
||||
tag reports, and tag export. Those need their own approved designs. |
||||
|
||||
## Category hierarchy |
||||
|
||||
Add `CategoryGroup` as a UUID reference model with a nonblank unique `name`, |
||||
an `archived` flag, and timestamps. A group is organizational only: it has no |
||||
income/expense kind and cannot be assigned to a transaction. |
||||
|
||||
Add nullable `group = ForeignKey(CategoryGroup, on_delete=PROTECT, |
||||
related_name="categories")` to `Category`. `Category` remains the sole |
||||
transaction-assignable leaf and retains its required `INCOME` or `EXPENSE` |
||||
kind. Existing categories retain a null group after migration, so their |
||||
identity and all historical transaction foreign keys remain unchanged. |
||||
|
||||
Replace global category-name uniqueness with these PostgreSQL constraints: |
||||
|
||||
- `(group, name)` is unique for grouped categories; and |
||||
- an ungrouped category name is unique among ungrouped categories. |
||||
|
||||
This permits, for example, a `Fees` leaf under more than one group while |
||||
preventing ambiguous duplicates in the same group or among legacy ungrouped |
||||
categories. Group names remain globally unique. |
||||
|
||||
Archived groups and categories remain visible on historical transactions but |
||||
are unavailable for new assignments. An otherwise unarchived leaf under an |
||||
archived group is likewise unavailable for new assignment. No archive action |
||||
cascades to children or changes transaction history. |
||||
|
||||
## Application behavior |
||||
|
||||
Add public reference-data operations to create and archive category groups, |
||||
and extend category creation to accept an optional group UUID. Category lists |
||||
and creation choices present active groups with their active leaf categories, |
||||
then active ungrouped categories. Existing ungrouped categories continue to |
||||
work unchanged. |
||||
|
||||
Transaction category filtering remains a leaf-category filter. Transaction |
||||
creation, annotation, and import-review assignment continue to receive a |
||||
category UUID; their active-reference validation must reject both archived |
||||
leaves and leaves whose group is archived. |
||||
|
||||
The spending report retains its existing accounting totals. It presents rows |
||||
under category groups and includes a roll-up total for each group; ungrouped |
||||
categories and `Uncategorized` remain separate. Group totals are derived from |
||||
the leaf-category transactions at query time and do not store cached amounts. |
||||
|
||||
Exports include group identity and category-to-group relationship so a restore |
||||
or future migration can reconstruct the hierarchy. Tag tables and transaction |
||||
tag relationships are not exported until tags can be assigned through a |
||||
supported product workflow. |
||||
|
||||
## Tags |
||||
|
||||
Add `Tag` as a UUID reference model with a nonblank globally unique `name`, |
||||
an `archived` flag, and timestamps. Add a many-to-many relationship between |
||||
`Transaction` and `Tag` with a stable reverse relation. |
||||
|
||||
No current public service, web endpoint, import decision, report, filter, or |
||||
export writes or reads tags. The migration creates no tags and attaches none to |
||||
existing transactions. The relation is deliberately separate from category: |
||||
a category remains the single accounting classification, while future tags |
||||
will be optional cross-cutting labels. |
||||
|
||||
## Integrity and migration |
||||
|
||||
The schema migration adds tables and nullable relations before removing the |
||||
old global category-name uniqueness constraint, so existing data remains |
||||
valid throughout. It does not create category groups, alter category names, |
||||
or update transaction records. |
||||
|
||||
The category group foreign key uses `PROTECT`; supported application behavior |
||||
does not delete groups, categories, or tags. |
||||
Service-level active-reference checks enforce effective archival state because |
||||
the database cannot express a cross-table archived-group rule in a check |
||||
constraint. |
||||
|
||||
## Verification |
||||
|
||||
Tests will demonstrate that: |
||||
|
||||
- the migration preserves existing categories and transaction category links; |
||||
- category groups cannot be assigned to transactions, while leaf category |
||||
kind rules remain intact; |
||||
- duplicate leaf names are allowed only across distinct groups; |
||||
- archived group membership prevents new assignments without hiding history; |
||||
- grouped report totals equal the sum of their leaf totals; and |
||||
- tags can exist and relate to transactions at the database level without |
||||
changing existing category, report, or export behavior. |
||||
|
||||
Apply migrations against an existing local database and a clean database, run |
||||
the focused tests and the relevant finance suite, then start the local service |
||||
and verify its health endpoint. No real YNAB files or financial records may be |
||||
included in tests, commits, diagnostics, or generated artifacts. |
||||
@ -0,0 +1,40 @@
|
||||
# Generated manually because the container image has no write access to the workspace. |
||||
|
||||
import django.db.models.deletion |
||||
import uuid |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [("finance", "0003_reconciliationcheckpoint")] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name="Tag", |
||||
fields=[ |
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), |
||||
("created_at", models.DateTimeField(auto_now_add=True)), |
||||
("updated_at", models.DateTimeField(auto_now=True)), |
||||
("name", models.CharField(max_length=255, unique=True)), |
||||
("archived", models.BooleanField(default=False)), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name="CategoryGroup", |
||||
fields=[ |
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), |
||||
("created_at", models.DateTimeField(auto_now_add=True)), |
||||
("updated_at", models.DateTimeField(auto_now=True)), |
||||
("name", models.CharField(max_length=255, unique=True)), |
||||
("archived", models.BooleanField(default=False)), |
||||
], |
||||
), |
||||
migrations.AlterField(model_name="category", name="name", field=models.CharField(max_length=255)), |
||||
migrations.AddField(model_name="category", name="group", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="categories", to="finance.categorygroup")), |
||||
migrations.AddConstraint(model_name="categorygroup", constraint=models.CheckConstraint(condition=models.Q(("name__regex", "\\S")), name="category_group_nonblank_name")), |
||||
migrations.AddConstraint(model_name="tag", constraint=models.CheckConstraint(condition=models.Q(("name__regex", "\\S")), name="tag_nonblank_name")), |
||||
migrations.AddConstraint(model_name="category", constraint=models.UniqueConstraint(fields=("group", "name"), name="category_group_name_unique")), |
||||
migrations.AddConstraint(model_name="category", constraint=models.UniqueConstraint(condition=models.Q(("group__isnull", True)), fields=("name",), name="category_ungrouped_name_unique")), |
||||
migrations.AddField(model_name="transaction", name="tags", field=models.ManyToManyField(blank=True, related_name="transactions", to="finance.tag")), |
||||
] |
||||
@ -1,9 +1,9 @@
|
||||
from .ledger import CardStatement, InstallmentPlan, Transaction, Transfer |
||||
from .provenance import AuditEvent, SourceRecord |
||||
from .reconciliation import ReconciliationCheckpoint |
||||
from .reference import Account, Category, Payee |
||||
from .reference import Account, Category, CategoryGroup, Payee, Tag |
||||
|
||||
__all__ = [ |
||||
"Account", "AuditEvent", "CardStatement", "Category", "InstallmentPlan", |
||||
"Payee", "ReconciliationCheckpoint", "SourceRecord", "Transaction", "Transfer", |
||||
"Account", "AuditEvent", "CardStatement", "Category", "CategoryGroup", "InstallmentPlan", |
||||
"Payee", "ReconciliationCheckpoint", "SourceRecord", "Tag", "Transaction", "Transfer", |
||||
] |
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""Supported financial writes. Direct ORM mutation bypasses cross-row invariants.""" |
||||
from .base import Actor, DomainError |
||||
from .reference import create_account, create_payee, create_category, archive, create_statement, create_plan |
||||
from .reference import create_account, create_payee, create_category, create_category_group, archive, create_statement, create_plan |
||||
from .posting import record_transaction, annotate_transaction, attach_source |
||||
from .reconciliation import create_reconciliation_checkpoint |
||||
from .transfers import create_transfer, match_transfer |
||||
|
||||
__all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_reconciliation_checkpoint", "create_transfer", "match_transfer"] |
||||
__all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "create_category_group", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_reconciliation_checkpoint", "create_transfer", "match_transfer"] |
||||
|
||||
@ -0,0 +1,424 @@
|
||||
"""Safe discovery of local source documents for the guided import workflow.""" |
||||
|
||||
import ctypes |
||||
import errno |
||||
import os |
||||
import stat |
||||
from contextlib import contextmanager |
||||
from dataclasses import dataclass, replace |
||||
from hashlib import sha256 |
||||
from pathlib import Path |
||||
|
||||
from django.conf import settings |
||||
from django.core.exceptions import ValidationError |
||||
|
||||
from finance.models import Account |
||||
from finance.services import Actor, DomainError |
||||
from finance.services.base import identity |
||||
|
||||
from . import services |
||||
from .models import ImportBatch |
||||
from .parsers import MAX_BYTES, ParseError, parse_document, partial_context_from_creation_line |
||||
|
||||
|
||||
@dataclass(frozen=True) |
||||
class DiscoveredFile: |
||||
name: str |
||||
document_hash: str |
||||
profile: str | None |
||||
sections: tuple[str, ...] |
||||
code: str | None |
||||
|
||||
|
||||
_PROFILES_BY_SUFFIX = { |
||||
".csv": ("BANK_CSV_V1",), |
||||
".txt": ("OUROCARD_FULL_V1", "OUROCARD_PARTIAL_V1"), |
||||
} |
||||
|
||||
_SUPPORTS_SAFE_DIRECTORY_FDS = ( |
||||
os.name == "posix" |
||||
and os.open in os.supports_dir_fd |
||||
and os.stat in os.supports_dir_fd |
||||
and os.listdir in os.supports_fd |
||||
and hasattr(os, "O_DIRECTORY") |
||||
and hasattr(os, "O_NOFOLLOW") |
||||
) |
||||
|
||||
|
||||
def _profile_and_sections(path: Path, data: bytes) -> tuple[str | None, tuple[str, ...]]: |
||||
for profile in _PROFILES_BY_SUFFIX.get(path.suffix.casefold(), ()): |
||||
try: |
||||
result = parse_document(data, profile) |
||||
except ParseError: |
||||
continue |
||||
items = tuple(item for item in result.items if not item.errors) |
||||
if items: |
||||
return profile, tuple(dict.fromkeys(item.section for item in items)) |
||||
return None, () |
||||
|
||||
|
||||
def _unreadable(name: str) -> DiscoveredFile: |
||||
return DiscoveredFile(name, "", None, (), "UNREADABLE_FILE") |
||||
|
||||
|
||||
def _close(descriptor: int | None) -> None: |
||||
if descriptor is not None: |
||||
try: |
||||
os.close(descriptor) |
||||
except OSError: |
||||
pass |
||||
|
||||
|
||||
def _open_import_root() -> int | None: |
||||
if not _SUPPORTS_SAFE_DIRECTORY_FDS: |
||||
return None |
||||
try: |
||||
root = os.path.normpath(os.fspath(settings.IMPORT_ROOT)) |
||||
if not os.path.isabs(root): |
||||
root = os.path.abspath(root) |
||||
path = Path(root) |
||||
descriptor = os.open(path.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) |
||||
for component in path.parts[1:]: |
||||
next_descriptor = os.open( |
||||
component, |
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, |
||||
dir_fd=descriptor, |
||||
) |
||||
_close(descriptor) |
||||
descriptor = next_descriptor |
||||
if not stat.S_ISDIR(os.fstat(descriptor).st_mode): |
||||
_close(descriptor) |
||||
return None |
||||
return descriptor |
||||
except (OSError, RuntimeError, TypeError, ValueError): |
||||
_close(locals().get("descriptor")) |
||||
return None |
||||
|
||||
|
||||
def _read_file_bytes(name: str, root_descriptor: int, expected: os.stat_result) -> bytes: |
||||
descriptor = None |
||||
try: |
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0) |
||||
if no_follow := getattr(os, "O_NOFOLLOW", 0): |
||||
flags |= no_follow |
||||
descriptor = os.open(name, flags, dir_fd=root_descriptor) |
||||
opened = os.fstat(descriptor) |
||||
if not stat.S_ISREG(opened.st_mode) or not os.path.samestat(expected, opened): |
||||
raise ParseError("UNREADABLE_FILE") |
||||
with os.fdopen(descriptor, "rb", closefd=False) as stream: |
||||
data = stream.read(MAX_BYTES + 1) |
||||
except OSError: |
||||
raise ParseError("UNREADABLE_FILE") from None |
||||
finally: |
||||
_close(descriptor) |
||||
if len(data) > MAX_BYTES: |
||||
raise ParseError("INPUT_TOO_LARGE") |
||||
return data |
||||
|
||||
|
||||
def _read_discovered_file(name: str, root_descriptor: int, expected: os.stat_result) -> DiscoveredFile: |
||||
try: |
||||
data = _read_file_bytes(name, root_descriptor, expected) |
||||
except ParseError as error: |
||||
return DiscoveredFile(name, "", None, (), error.code) |
||||
document_hash = sha256(data).hexdigest() |
||||
if Path(name).suffix.casefold() not in _PROFILES_BY_SUFFIX: |
||||
return DiscoveredFile(name, document_hash, None, (), "UNSUPPORTED_FILE") |
||||
profile, sections = _profile_and_sections(Path(name), data) |
||||
if profile is None: |
||||
return DiscoveredFile(name, document_hash, None, (), "UNRECOGNIZED_LAYOUT") |
||||
return DiscoveredFile(name, document_hash, profile, sections, None) |
||||
|
||||
|
||||
@contextmanager |
||||
def _directory(name: str | None = None): |
||||
root = _open_import_root() |
||||
descriptor = None |
||||
try: |
||||
if root is None: |
||||
raise ParseError("UNREADABLE_FILE") |
||||
if name is None: |
||||
yield root |
||||
else: |
||||
try: |
||||
os.mkdir(name, mode=0o700, dir_fd=root) |
||||
except FileExistsError: |
||||
pass |
||||
descriptor = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root) |
||||
yield descriptor |
||||
except OSError: |
||||
raise ParseError("UNREADABLE_FILE") from None |
||||
finally: |
||||
_close(descriptor) |
||||
_close(root) |
||||
|
||||
|
||||
def _validate_name(name: str) -> None: |
||||
if (not isinstance(name, str) or not name or name in {".", ".."} |
||||
or any(char in name for char in ("/", "\\", "\x00", ":"))): |
||||
raise ParseError("UNREADABLE_FILE") |
||||
|
||||
|
||||
def _rename_without_replacement(name: str, source_fd: int, destination_fd: int) -> None: |
||||
# Use the Linux atomic no-replace rename where the mounted filesystem supports |
||||
# it. Docker Desktop bind mounts can reject that flag, so preserve the same |
||||
# no-overwrite guarantee by linking before removing the source. |
||||
try: |
||||
library = ctypes.CDLL(None, use_errno=True) |
||||
rename = library.renameat2 |
||||
except AttributeError: |
||||
rename = None |
||||
if rename is None: |
||||
_link_then_unlink(name, source_fd, destination_fd) |
||||
return |
||||
rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] |
||||
rename.restype = ctypes.c_int |
||||
encoded_name = os.fsencode(name) |
||||
if rename(source_fd, encoded_name, destination_fd, encoded_name, 1) == 0: # RENAME_NOREPLACE |
||||
return |
||||
if ctypes.get_errno() in {errno.EINVAL, errno.ENOSYS, errno.EOPNOTSUPP}: |
||||
_link_then_unlink(name, source_fd, destination_fd) |
||||
return |
||||
raise ParseError("UNREADABLE_FILE") |
||||
|
||||
|
||||
def _link_then_unlink(name: str, source_fd: int, destination_fd: int) -> None: |
||||
try: |
||||
os.link(name, name, src_dir_fd=source_fd, dst_dir_fd=destination_fd, follow_symlinks=False) |
||||
os.unlink(name, dir_fd=source_fd) |
||||
except OSError: |
||||
raise ParseError("UNREADABLE_FILE") from None |
||||
|
||||
def _move_file(name: str, source: str | None, destination: str | None, *, overwrite=False) -> None: |
||||
_validate_name(name) |
||||
with _directory(source) as source_fd, _directory(destination) as destination_fd: |
||||
try: |
||||
expected = os.stat(name, dir_fd=source_fd, follow_symlinks=False) |
||||
except FileNotFoundError: |
||||
if source == "staging": |
||||
return |
||||
raise |
||||
if not stat.S_ISREG(expected.st_mode): |
||||
raise ParseError("UNREADABLE_FILE") |
||||
try: |
||||
target = os.stat(name, dir_fd=destination_fd, follow_symlinks=False) |
||||
except FileNotFoundError: |
||||
pass |
||||
else: |
||||
if not overwrite or not stat.S_ISREG(target.st_mode): |
||||
raise ParseError("UNREADABLE_FILE") |
||||
if overwrite: |
||||
os.replace(name, name, src_dir_fd=source_fd, dst_dir_fd=destination_fd) |
||||
else: |
||||
_rename_without_replacement(name, source_fd, destination_fd) |
||||
|
||||
|
||||
def _recover_staging() -> None: |
||||
"""Move direct regular staging files back to incoming before discovery.""" |
||||
try: |
||||
with _directory("staging") as descriptor: |
||||
for name in os.listdir(descriptor): |
||||
try: |
||||
_return_to_incoming(name) |
||||
except ParseError: |
||||
# Leave unsafe entries or filename collisions for local attention. |
||||
continue |
||||
except ParseError: |
||||
return |
||||
|
||||
|
||||
def _stage(discovered: DiscoveredFile) -> DiscoveredFile: |
||||
"""Atomically rename one verified incoming file into staging.""" |
||||
_verified_bytes(discovered) |
||||
_move_file(discovered.name, None, "staging") |
||||
return discovered |
||||
|
||||
|
||||
def _return_to_incoming(name: str) -> None: |
||||
"""Move a staged file back when it was not committed.""" |
||||
_move_file(name, "staging", None) |
||||
|
||||
|
||||
def _move_to_processed(name: str) -> None: |
||||
"""Replace the processed filename after a successful commit.""" |
||||
_move_file(name, "staging", "processed", overwrite=True) |
||||
|
||||
|
||||
def discover_imports() -> tuple[DiscoveredFile, ...]: |
||||
"""Return safe metadata for direct regular files under ``IMPORT_ROOT``.""" |
||||
root_descriptor = _open_import_root() |
||||
if root_descriptor is None: |
||||
return () |
||||
try: |
||||
_recover_staging() |
||||
try: |
||||
entries = os.listdir(root_descriptor) |
||||
except OSError: |
||||
return () |
||||
discovered = [] |
||||
for name in entries: |
||||
try: |
||||
expected = os.stat(name, dir_fd=root_descriptor, follow_symlinks=False) |
||||
except OSError: |
||||
discovered.append(_unreadable(name)) |
||||
continue |
||||
if stat.S_ISREG(expected.st_mode): |
||||
discovered.append(_read_discovered_file(name, root_descriptor, expected)) |
||||
return tuple(sorted(discovered, key=lambda row: row.name)) |
||||
finally: |
||||
_close(root_descriptor) |
||||
|
||||
|
||||
@dataclass(frozen=True) |
||||
class PreparedImport: |
||||
name: str |
||||
document_hash: str |
||||
profile: str | None |
||||
sections: tuple[str, ...] |
||||
state: str |
||||
batch_id: str | None |
||||
revision: int | None |
||||
counts: dict[str, int] |
||||
code: str | None |
||||
staged: bool = False |
||||
|
||||
|
||||
_SAFE_CODES = frozenset({ |
||||
"UNREADABLE_FILE", "INPUT_TOO_LARGE", "UNSUPPORTED_FILE", "UNRECOGNIZED_LAYOUT", |
||||
"FILE_CHANGED", "INVALID_SECTION_MAPPING", "MISSING_CREATION_DATE", "INVALID_CREATION_DATE", |
||||
"STALE_REVISION", "STALE_PREVIEW", "BATCH_NOT_FOUND", "BATCH_NOT_PREVIEW", |
||||
"REVIEW_REQUIRED", "INVALID_PREPARED_IMPORT", |
||||
}) |
||||
|
||||
|
||||
def _verified_bytes(discovered: DiscoveredFile | PreparedImport) -> bytes: |
||||
name = discovered.name |
||||
_validate_name(name) |
||||
directory = "staging" if isinstance(discovered, PreparedImport) and discovered.staged else None |
||||
with _directory(directory) as root_descriptor: |
||||
expected = os.stat(name, dir_fd=root_descriptor, follow_symlinks=False) |
||||
if not stat.S_ISREG(expected.st_mode): |
||||
raise ParseError("UNREADABLE_FILE") |
||||
data = _read_file_bytes(name, root_descriptor, expected) |
||||
if sha256(data).hexdigest() != discovered.document_hash: |
||||
raise ParseError("FILE_CHANGED") |
||||
profile, sections = _profile_and_sections(Path(name), data) |
||||
if profile != discovered.profile or sections != discovered.sections or profile is None: |
||||
raise ParseError("FILE_CHANGED") |
||||
return data |
||||
|
||||
|
||||
def _validated_mapping(discovered, section_mapping): |
||||
if (not isinstance(section_mapping, dict) or set(section_mapping) != set(discovered.sections) |
||||
or any(not isinstance(value, str) for value in section_mapping.values())): |
||||
raise DomainError("INVALID_SECTION_MAPPING") |
||||
try: |
||||
mapping = {key: str(identity(value)) for key, value in section_mapping.items()} |
||||
except DomainError: |
||||
raise DomainError("INVALID_SECTION_MAPPING") from None |
||||
active = set(Account.objects.filter(pk__in=mapping.values(), archived=False, currency="BRL") |
||||
.values_list("pk", flat=True)) |
||||
if {str(pk) for pk in active} != set(mapping.values()): |
||||
raise DomainError("INVALID_SECTION_MAPPING") |
||||
return mapping |
||||
|
||||
|
||||
def _cancel_unfinished(batch_id, actor): |
||||
# Cancellation must use the latest revision after a stale-review failure. |
||||
for _ in range(3): |
||||
batch = ImportBatch.objects.filter(pk=batch_id, state="PREVIEW").first() |
||||
if batch is None: |
||||
return |
||||
try: |
||||
services.cancel_preview(batch_id=batch.pk, revision=batch.revision, actor=actor) |
||||
return |
||||
except DomainError as error: |
||||
if error.code != "STALE_REVISION": |
||||
raise |
||||
raise DomainError("CANCELLATION_FAILED") |
||||
|
||||
|
||||
def _attention(prepared, error, actor, *, cancel=True, return_source=True): |
||||
code = getattr(error, "code", None) |
||||
code = code if isinstance(code, str) and code in _SAFE_CODES else "IMPORT_FAILED" |
||||
if cancel and prepared.batch_id: |
||||
try: |
||||
_cancel_unfinished(prepared.batch_id, actor) |
||||
except DomainError: |
||||
code = "CANCELLATION_FAILED" |
||||
if return_source and prepared.staged: |
||||
try: |
||||
_return_to_incoming(prepared.name) |
||||
prepared = replace(prepared, staged=False) |
||||
except ParseError: |
||||
code = "UNREADABLE_FILE" |
||||
return replace(prepared, state="NEEDS_ATTENTION", counts={"created": 0, "matched": 0, "skipped": 0}, code=code) |
||||
|
||||
|
||||
def prepare_import(*, discovered: DiscoveredFile, section_mapping: dict[str, str], actor: Actor) -> PreparedImport: |
||||
"""Prepare complete deterministic decisions through the audited lifecycle.""" |
||||
prepared = PreparedImport(discovered.name, discovered.document_hash, discovered.profile, |
||||
discovered.sections, "NEEDS_ATTENTION", None, None, |
||||
{"created": 0, "matched": 0, "skipped": 0}, None) |
||||
try: |
||||
if discovered.code: |
||||
raise ParseError(discovered.code) |
||||
_stage(discovered) |
||||
prepared = replace(prepared, staged=True) |
||||
data = _verified_bytes(prepared) |
||||
mapping = _validated_mapping(discovered, section_mapping) |
||||
context = partial_context_from_creation_line(data) if discovered.profile == "OUROCARD_PARTIAL_V1" else {} |
||||
batch = services.create_preview(data=data, profile=discovered.profile, |
||||
section_mapping=mapping, context=context, actor=actor) |
||||
prepared = replace(prepared, batch_id=str(batch.pk), revision=batch.revision) |
||||
decisions = [] |
||||
counts = {"created": 0, "matched": 0, "skipped": 0} |
||||
for item in batch.items.order_by("locator"): |
||||
if item.issues == ["ALREADY_IMPORTED"] or ( |
||||
not item.issues and item.duplicates["existing"] and not item.duplicates["within"] |
||||
): |
||||
decisions.append({"locator": item.locator, "action": "SKIP", "reason": "ALREADY_IMPORTED"}) |
||||
counts["skipped"] += 1 |
||||
elif not item.issues and not item.duplicates["existing"] and not item.duplicates["within"]: |
||||
decisions.append({"locator": item.locator, "action": "CREATE"}) |
||||
counts["created"] += 1 |
||||
else: |
||||
raise DomainError("REVIEW_REQUIRED") |
||||
if not decisions: |
||||
raise DomainError("REVIEW_REQUIRED") |
||||
batch = services.apply_decisions(batch_id=batch.pk, revision=batch.revision, |
||||
decisions=decisions, actor=actor) |
||||
return replace(prepared, state="READY" if counts["created"] else "ALREADY_IMPORTED", |
||||
revision=batch.revision, counts=counts) |
||||
except (DomainError, ParseError, ValidationError) as error: |
||||
return _attention(prepared, error, actor) |
||||
|
||||
|
||||
def commit_prepared_import(*, prepared: PreparedImport, actor: Actor) -> PreparedImport: |
||||
"""Revalidate source identity and delegate the atomic commit to persistence.""" |
||||
try: |
||||
batch_id = identity(prepared.batch_id) |
||||
except DomainError: |
||||
return _attention(prepared, DomainError("INVALID_PREPARED_IMPORT"), actor, cancel=False) |
||||
try: |
||||
batch = ImportBatch.objects.filter(pk=batch_id).first() |
||||
if batch is None: |
||||
raise DomainError("BATCH_NOT_FOUND") |
||||
if batch.document_hash != prepared.document_hash or batch.profile != prepared.profile: |
||||
return _attention(prepared, DomainError("INVALID_PREPARED_IMPORT"), actor, cancel=False) |
||||
if prepared.state not in {"READY", "ALREADY_IMPORTED", "COMMITTED"} or type(prepared.revision) is not int: |
||||
raise DomainError("INVALID_PREPARED_IMPORT") |
||||
already_committed = batch.state == "COMMITTED" |
||||
if not already_committed: |
||||
_verified_bytes(prepared) |
||||
batch = services.commit_preview(batch_id=batch.pk, revision=prepared.revision, actor=actor) |
||||
if not already_committed: |
||||
try: |
||||
_move_to_processed(prepared.name) |
||||
except ParseError as error: |
||||
# Persistence succeeded; keep staging for discovery and provenance-based retry. |
||||
return _attention(prepared, error, actor, cancel=False, return_source=False) |
||||
return replace(prepared, state="COMMITTED", staged=False, revision=batch.revision, |
||||
counts={key: batch.result[key] for key in ("created", "matched", "skipped")}, code=None) |
||||
except (DomainError, ParseError, ValidationError) as error: |
||||
return _attention(prepared, error, actor) |
||||
@ -0,0 +1,41 @@
|
||||
from datetime import date |
||||
from decimal import Decimal |
||||
|
||||
import pytest |
||||
|
||||
from finance.models import Account, Category, Tag, Transaction |
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db |
||||
|
||||
|
||||
def test_ungrouped_categories_and_transaction_links_remain_valid(): |
||||
account = Account.objects.create(name="Synthetic bank", kind="BANK") |
||||
category = Category.objects.create(name="Synthetic Food", kind="EXPENSE") |
||||
transaction = Transaction.objects.create( |
||||
account=account, |
||||
amount=Decimal("-10.00"), |
||||
posting_date=date(2026, 9, 14), |
||||
kind="EXPENSE", |
||||
category=category, |
||||
) |
||||
|
||||
category.refresh_from_db() |
||||
|
||||
assert category.group_id is None |
||||
assert transaction.category_id == category.pk |
||||
|
||||
|
||||
def test_tags_can_be_attached_without_changing_category(): |
||||
account = Account.objects.create(name="Synthetic bank", kind="BANK") |
||||
transaction = Transaction.objects.create( |
||||
account=account, |
||||
amount=Decimal("-10.00"), |
||||
posting_date=date(2026, 9, 14), |
||||
kind="EXPENSE", |
||||
) |
||||
tag = Tag.objects.create(name="Synthetic Tax") |
||||
|
||||
transaction.tags.add(tag) |
||||
|
||||
assert list(transaction.tags.all()) == [tag] |
||||
@ -0,0 +1,182 @@
|
||||
"""Synthetic checks for source-safe local import discovery.""" |
||||
|
||||
import os |
||||
from hashlib import sha256 |
||||
from pathlib import Path |
||||
|
||||
from django.test import override_settings |
||||
|
||||
from ingestion import guided_imports |
||||
from ingestion.guided_imports import discover_imports |
||||
|
||||
|
||||
HEADER = b"Data,Dependencia,Historico,Documento,Tipo,Valor\n" |
||||
|
||||
|
||||
def test_discovery_returns_only_safe_metadata_for_direct_supported_files(tmp_path): |
||||
bank = HEADER + b"01/02/2026,,SYNTHETIC BANK ROW,,,12.34\n" |
||||
full = b"1 - SYNTHETIC OWNER\n" + b"15.02.2026" + b"SYNTHETIC CARD ROW".ljust(37) + b"BR 12,34 0,00\n" |
||||
nested = tmp_path / "nested" |
||||
nested.mkdir() |
||||
(tmp_path / "bank.csv").write_bytes(bank) |
||||
(tmp_path / "card.txt").write_bytes(full) |
||||
(nested / "hidden.csv").write_bytes(bank) |
||||
|
||||
with override_settings(IMPORT_ROOT=tmp_path): |
||||
rows = discover_imports() |
||||
|
||||
assert [(row.name, row.profile, row.sections, row.code) for row in rows] == [ |
||||
("bank.csv", "BANK_CSV_V1", ("1",), None), |
||||
("card.txt", "OUROCARD_FULL_V1", ("1",), None), |
||||
] |
||||
assert [row.document_hash for row in rows] == [sha256(bank).hexdigest(), sha256(full).hexdigest()] |
||||
assert all(not hasattr(row, "source_text") for row in rows) |
||||
|
||||
|
||||
def test_discovery_reports_safe_codes_for_direct_unsupported_or_invalid_files(tmp_path): |
||||
(tmp_path / "unknown.ofx").write_bytes(b"SYNTHETIC OFX") |
||||
(tmp_path / "statement.pdf").write_bytes(b"%PDF-SYNTHETIC") |
||||
(tmp_path / "invalid.txt").write_bytes(b"SYNTHETIC UNRECOGNIZED TEXT") |
||||
(tmp_path / "oversize.csv").write_bytes(b"x" * (10 * 1024 * 1024 + 1)) |
||||
|
||||
with override_settings(IMPORT_ROOT=tmp_path): |
||||
rows = discover_imports() |
||||
|
||||
assert [(row.name, row.profile, row.sections, row.code) for row in rows] == [ |
||||
("invalid.txt", None, (), "UNRECOGNIZED_LAYOUT"), |
||||
("oversize.csv", None, (), "INPUT_TOO_LARGE"), |
||||
("statement.pdf", None, (), "UNSUPPORTED_FILE"), |
||||
("unknown.ofx", None, (), "UNSUPPORTED_FILE"), |
||||
] |
||||
assert all(not hasattr(row, "source_text") for row in rows) |
||||
|
||||
|
||||
def test_discovery_ignores_symlinks_and_non_files(tmp_path): |
||||
bank = HEADER + b"01/02/2026,,SYNTHETIC BANK ROW,,,12.34\n" |
||||
target = tmp_path / "target.csv" |
||||
target.write_bytes(bank) |
||||
(tmp_path / "linked.csv").symlink_to(target) |
||||
(tmp_path / "directory.csv").mkdir() |
||||
|
||||
with override_settings(IMPORT_ROOT=tmp_path): |
||||
rows = discover_imports() |
||||
|
||||
assert [row.name for row in rows] == ["target.csv"] |
||||
|
||||
|
||||
def test_discovery_marks_a_post_listing_symlink_replacement_unreadable(tmp_path, monkeypatch): |
||||
bank = HEADER + b"01/02/2026,,SYNTHETIC BANK ROW,,,12.34\n" |
||||
candidate = tmp_path / "candidate.csv" |
||||
replacement = tmp_path / "replacement.csv" |
||||
candidate.write_bytes(bank) |
||||
replacement.write_bytes(bank) |
||||
original_open = os.open |
||||
replaced = False |
||||
|
||||
def replace_then_open(value, flags, *args, **kwargs): |
||||
nonlocal replaced |
||||
if not replaced and (value == candidate.name or Path(value) == candidate): |
||||
replaced = True |
||||
candidate.unlink() |
||||
candidate.symlink_to(replacement) |
||||
return original_open(value, flags, *args, **kwargs) |
||||
|
||||
monkeypatch.setattr(guided_imports, "os", os, raising=False) |
||||
monkeypatch.setattr(guided_imports.os, "open", replace_then_open) |
||||
|
||||
with override_settings(IMPORT_ROOT=tmp_path): |
||||
rows = discover_imports() |
||||
|
||||
assert [(row.name, row.profile, row.sections, row.code) for row in rows] == [ |
||||
("candidate.csv", None, (), "UNREADABLE_FILE"), |
||||
("replacement.csv", "BANK_CSV_V1", ("1",), None), |
||||
] |
||||
|
||||
|
||||
def test_discovery_returns_empty_results_when_listing_the_import_root_fails(tmp_path, monkeypatch): |
||||
root = tmp_path / "unavailable" |
||||
root.mkdir() |
||||
bank = HEADER + b"01/02/2026,,SYNTHETIC BANK ROW,,,12.34\n" |
||||
(root / "bank.csv").write_bytes(bank) |
||||
original_listdir = os.listdir |
||||
|
||||
def delayed_failure(value): |
||||
if isinstance(value, int): |
||||
raise OSError("SYNTHETIC LISTING FAILURE") |
||||
return original_listdir(value) |
||||
|
||||
monkeypatch.setattr(guided_imports.os, "listdir", delayed_failure) |
||||
|
||||
with override_settings(IMPORT_ROOT=root): |
||||
assert discover_imports() == () |
||||
|
||||
|
||||
def test_discovery_returns_empty_results_when_opening_the_import_root_fails(tmp_path, monkeypatch): |
||||
root = tmp_path / "unopenable" |
||||
root.mkdir() |
||||
(root / "bank.csv").write_bytes(HEADER + b"01/02/2026,,SYNTHETIC BANK ROW,,,12.34\n") |
||||
original_open = os.open |
||||
|
||||
def root_open_failure(value, flags, *args, **kwargs): |
||||
if value == os.path.sep: |
||||
raise OSError("SYNTHETIC ROOT OPEN FAILURE") |
||||
return original_open(value, flags, *args, **kwargs) |
||||
|
||||
monkeypatch.setattr(guided_imports.os, "open", root_open_failure) |
||||
|
||||
with override_settings(IMPORT_ROOT=root): |
||||
assert discover_imports() == () |
||||
|
||||
|
||||
def test_discovery_keeps_using_the_opened_root_after_the_root_path_is_replaced(tmp_path, monkeypatch): |
||||
root = tmp_path / "imports" |
||||
outside = tmp_path / "outside" |
||||
parked_root = tmp_path / "parked-imports" |
||||
root.mkdir() |
||||
outside.mkdir() |
||||
inside = HEADER + b"01/02/2026,,SYNTHETIC INSIDE ROW,,,12.34\n" |
||||
external = HEADER + b"01/02/2026,,SYNTHETIC EXTERNAL ROW,,,98.76\n" |
||||
(root / "bank.csv").write_bytes(inside) |
||||
(outside / "bank.csv").write_bytes(external) |
||||
original_iterdir = Path.iterdir |
||||
original_listdir = os.listdir |
||||
replaced = False |
||||
|
||||
def replace_root(): |
||||
nonlocal replaced |
||||
if not replaced: |
||||
replaced = True |
||||
root.rename(parked_root) |
||||
root.symlink_to(outside, target_is_directory=True) |
||||
|
||||
def replace_then_iterdir(self): |
||||
if self == root.resolve(): |
||||
replace_root() |
||||
return original_iterdir(self) |
||||
|
||||
def replace_then_listdir(value): |
||||
if isinstance(value, int): |
||||
replace_root() |
||||
return original_listdir(value) |
||||
|
||||
monkeypatch.setattr(Path, "iterdir", replace_then_iterdir) |
||||
monkeypatch.setattr(guided_imports.os, "listdir", replace_then_listdir) |
||||
|
||||
with override_settings(IMPORT_ROOT=root): |
||||
rows = discover_imports() |
||||
|
||||
assert [(row.name, row.document_hash, row.profile, row.code) for row in rows] == [ |
||||
("bank.csv", sha256(inside).hexdigest(), "BANK_CSV_V1", None), |
||||
] |
||||
|
||||
|
||||
def test_discovery_fails_closed_for_a_symlinked_import_root_parent(tmp_path): |
||||
actual_parent = tmp_path / "actual-parent" |
||||
root = actual_parent / "imports" |
||||
linked_parent = tmp_path / "linked-parent" |
||||
root.mkdir(parents=True) |
||||
(root / "bank.csv").write_bytes(HEADER + b"01/02/2026,,SYNTHETIC BANK ROW,,,12.34\n") |
||||
linked_parent.symlink_to(actual_parent, target_is_directory=True) |
||||
|
||||
with override_settings(IMPORT_ROOT=linked_parent / "imports"): |
||||
assert discover_imports() == () |
||||
@ -0,0 +1,272 @@
|
||||
"""The local owner imports synthetic files without sending source rows to the UI.""" |
||||
|
||||
from pathlib import Path |
||||
|
||||
import pytest |
||||
from django.core import signing |
||||
from django.test import Client |
||||
from django.urls import reverse |
||||
|
||||
from finance.models import AuditEvent, SourceRecord, Transaction |
||||
from finance.services import Actor, archive, create_account |
||||
from ingestion import services |
||||
from ingestion.models import ImportBatch |
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True) |
||||
ACTOR = Actor(kind="AGENT", identifier="synthetic-guided-web") |
||||
DATA = Path("tests/fixtures/synthetic/ingestion/bank.csv").read_bytes() |
||||
|
||||
|
||||
@pytest.fixture |
||||
def source(tmp_path, settings): |
||||
settings.IMPORT_ROOT = tmp_path |
||||
(tmp_path / "bank.csv").write_bytes(DATA) |
||||
return tmp_path |
||||
|
||||
|
||||
@pytest.fixture |
||||
def bank(): |
||||
return create_account(name="Synthetic web bank", kind="BANK", actor=ACTOR) |
||||
|
||||
|
||||
def scan(client): |
||||
response = client.post(reverse("imports-index"), {"action": "scan"}, follow=True) |
||||
assert response.status_code == 200 |
||||
assert not response.redirect_chain |
||||
return response |
||||
|
||||
|
||||
def prepare(client, scanned, bank, **overrides): |
||||
values = { |
||||
"action": "prepare", "scan_token": scanned.context["scan_token"], |
||||
"file-0-section_0": str(bank.pk), |
||||
} |
||||
values.update(overrides) |
||||
return client.post(reverse("imports-index"), values, follow=True) |
||||
|
||||
|
||||
def commit(client, prepared): |
||||
return client.post(reverse("imports-index"), { |
||||
"action": "commit", "prepared_token": prepared.context["prepared_token"], |
||||
}, follow=True) |
||||
|
||||
|
||||
def assert_private(response, source): |
||||
content = response.content.decode() |
||||
for forbidden in ("Synthetic purchase", "Synthetic income", "-12.34", "100.00", str(source)): |
||||
assert forbidden not in content |
||||
|
||||
|
||||
def test_navigation_opens_imports_without_automatically_scanning(client, source): |
||||
home = client.get(reverse("home")) |
||||
assert 'href="/imports/"' in home.content.decode() |
||||
response = client.get(reverse("imports-index")) |
||||
assert response.status_code == 200 |
||||
assert "Scan files" in response.content.decode() |
||||
assert "bank.csv" not in response.content.decode() |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["scan", "prepare", "commit"]) |
||||
def test_import_actions_require_csrf(source, action): |
||||
browser = Client(enforce_csrf_checks=True) |
||||
response = browser.post(reverse("imports-index"), {"action": action}) |
||||
assert response.status_code == 403 |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_scan_shows_safe_metadata_and_only_active_brl_account_options(client, source, bank): |
||||
retired = create_account(name="Synthetic archived", kind="BANK", actor=ACTOR) |
||||
archive(retired, actor=ACTOR) |
||||
create_account(name="Synthetic foreign", kind="BANK", currency="USD", actor=ACTOR) |
||||
(source / "unsupported.ofx").write_text("Synthetic private source text") |
||||
response = scan(client) |
||||
content = response.content.decode() |
||||
assert "bank.csv" in content and "BANK_CSV_V1" in content and "Section 1" in content |
||||
assert "unsupported.ofx" in content and "UNSUPPORTED_FILE" in content |
||||
assert bank.name in content |
||||
assert "Synthetic archived" not in content and "Synthetic foreign" not in content |
||||
assert "Synthetic private source text" not in content |
||||
assert_private(response, source) |
||||
assert not ImportBatch.objects.exists() |
||||
payload = signing.loads(response.context["scan_token"], salt="guided-imports-scan") |
||||
assert len(payload) == 1 |
||||
assert set(payload[0]) == {"name", "document_hash", "profile", "sections"} |
||||
|
||||
|
||||
def test_mapping_prepares_then_explicit_commit_creates_with_local_human_actor(client, source, bank): |
||||
prepared = prepare(client, scan(client), bank) |
||||
assert prepared.status_code == 200 |
||||
assert not prepared.redirect_chain |
||||
assert prepared.context["files"][0]["state"] == "READY" |
||||
assert prepared.context["files"][0]["counts"] == {"created": 2, "matched": 0, "skipped": 0} |
||||
assert not Transaction.objects.exists() |
||||
assert_private(prepared, source) |
||||
payload = signing.loads(prepared.context["prepared_token"], salt="guided-imports-prepared") |
||||
assert set(payload[0]) == {"name", "document_hash", "profile", "sections", "batch_id", "revision"} |
||||
response = commit(client, prepared) |
||||
assert not response.redirect_chain |
||||
assert response.context["files"][0]["state"] == "COMMITTED" |
||||
assert response.context["files"][0]["counts"]["created"] == 2 |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert set(Transaction.objects.values_list("account_id", flat=True)) == {bank.pk} |
||||
assert set(AuditEvent.objects.filter(operation__startswith="import_").values_list( |
||||
"actor_kind", "actor_identifier")) == {("HUMAN", "local-owner")} |
||||
assert_private(response, source) |
||||
assert not ImportBatch.objects.filter(state="PREVIEW").exists() |
||||
|
||||
|
||||
def test_scan_and_prepared_form_metadata_do_not_depend_on_session_payload(client, source, bank): |
||||
scanned = scan(client) |
||||
scan_token = scanned.context["scan_token"] |
||||
assert f'name="scan_token" value="{scan_token}"' in scanned.content.decode() |
||||
assert "scan_token" not in str(dict(client.session)) |
||||
client.cookies.pop("sessionid", None) |
||||
prepared = prepare(client, scanned, bank) |
||||
prepared_token = prepared.context["prepared_token"] |
||||
assert f'name="prepared_token" value="{prepared_token}"' in prepared.content.decode() |
||||
assert "prepared_token" not in str(dict(client.session)) |
||||
client.cookies.pop("sessionid", None) |
||||
response = commit(client, prepared) |
||||
assert response.context["files"][0]["counts"]["created"] == 2 |
||||
assert "document_hash" not in str(dict(client.session)) |
||||
assert "batch_id" not in str(dict(client.session)) |
||||
|
||||
|
||||
|
||||
def test_exact_reimport_reports_skips_without_new_transactions(client, source, bank): |
||||
commit(client, prepare(client, scan(client), bank)) |
||||
(source / "bank.csv").write_bytes(DATA) |
||||
prepared = prepare(client, scan(client), bank) |
||||
assert prepared.context["files"][0]["state"] == "ALREADY_IMPORTED" |
||||
result = commit(client, prepared) |
||||
assert result.context["files"][0]["counts"] == {"created": 0, "matched": 0, "skipped": 2} |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert not ImportBatch.objects.filter(state="PREVIEW").exists() |
||||
|
||||
|
||||
def test_multi_section_card_has_one_typed_mapping_per_section(client, source): |
||||
(source / "bank.csv").unlink() |
||||
(source / "cards.txt").write_text( |
||||
"1 - SYNTHETIC HOLDER\n" + f"15.01.2026{'Synthetic first':37}BR 12,50 0,00\n" |
||||
+ "2 - SYNTHETIC OTHER\n" + f"16.01.2026{'Synthetic second':37}BR 7,25 0,00\n" |
||||
) |
||||
first = create_account(name="Synthetic first card", kind="CREDIT_CARD", actor=ACTOR) |
||||
second = create_account(name="Synthetic second card", kind="CREDIT_CARD", actor=ACTOR) |
||||
scanned = scan(client) |
||||
assert "Section 1" in scanned.content.decode() and "Section 2" in scanned.content.decode() |
||||
assert "SYNTHETIC HOLDER" not in scanned.content.decode() |
||||
response = commit(client, prepare(client, scanned, first, **{"file-0-section_1": str(second.pk)})) |
||||
assert response.context["files"][0]["counts"]["created"] == 2 |
||||
assert set(Transaction.objects.values_list("account_id", flat=True)) == {first.pk, second.pk} |
||||
|
||||
|
||||
@pytest.mark.parametrize("mapping", ["", "bad-id", "archived", "foreign"]) |
||||
def test_invalid_mapping_is_safe_and_rejected_before_preview(client, source, bank, mapping): |
||||
scanned = scan(client) |
||||
if mapping == "archived": |
||||
archive(bank, actor=ACTOR) |
||||
mapping = str(bank.pk) |
||||
elif mapping == "foreign": |
||||
mapping = str(create_account(name="Synthetic USD", kind="BANK", currency="USD", actor=ACTOR).pk) |
||||
result = prepare(client, scanned, bank, **{"file-0-section_0": mapping}) |
||||
assert result.context["files"][0]["code"] == "INVALID_SECTION_MAPPING" |
||||
assert not ImportBatch.objects.exists() |
||||
assert_private(result, source) |
||||
|
||||
|
||||
def test_review_exception_does_not_prevent_another_file_commit(client, source, bank): |
||||
(source / "ambiguous.csv").write_bytes(DATA + DATA.splitlines(keepends=True)[1]) |
||||
prepared = prepare(client, scan(client), bank, **{"file-1-section_0": str(bank.pk)}) |
||||
assert [row["state"] for row in prepared.context["files"]] == ["NEEDS_ATTENTION", "READY"] |
||||
assert prepared.context["files"][0]["code"] == "REVIEW_REQUIRED" |
||||
result = commit(client, prepared) |
||||
assert [row["state"] for row in result.context["files"]] == ["COMMITTED"] |
||||
assert Transaction.objects.count() == 2 |
||||
assert not ImportBatch.objects.filter(state="PREVIEW").exists() |
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["prepare", "commit"]) |
||||
def test_unexpected_per_file_error_never_exposes_source_or_blocks_next_file(client, source, bank, monkeypatch, stage): |
||||
from core import views |
||||
|
||||
(source / "first.csv").write_bytes(DATA.replace(b"12.34", b"22.34")) |
||||
scanned = scan(client) |
||||
if stage == "commit": |
||||
prepared = prepare(client, scanned, bank, **{"file-1-section_0": str(bank.pk)}) |
||||
service_name = "prepare_import" if stage == "prepare" else "commit_prepared_import" |
||||
original = getattr(views.guided_imports, service_name) |
||||
|
||||
def fail_one_file(**kwargs): |
||||
row = kwargs["discovered" if stage == "prepare" else "prepared"] |
||||
if row.name == "bank.csv": |
||||
raise RuntimeError(f"{source}: Synthetic private source text 1234.56") |
||||
return original(**kwargs) |
||||
|
||||
monkeypatch.setattr(views.guided_imports, service_name, fail_one_file) |
||||
if stage == "prepare": |
||||
prepared = prepare(client, scanned, bank, **{"file-1-section_0": str(bank.pk)}) |
||||
assert prepared.context["files"][0]["code"] == "IMPORT_FAILED" |
||||
result = commit(client, prepared) |
||||
if stage == "commit": |
||||
assert [row["state"] for row in result.context["files"]] == ["NEEDS_ATTENTION", "COMMITTED"] |
||||
assert result.context["files"][0]["code"] == "IMPORT_FAILED" |
||||
else: |
||||
assert [row["state"] for row in result.context["files"]] == ["COMMITTED"] |
||||
response = prepared if stage == "prepare" else result |
||||
assert "Synthetic private source text" not in response.content.decode() |
||||
assert "1234.56" not in response.content.decode() |
||||
assert Transaction.objects.count() == 2 |
||||
assert_private(response, source) |
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["prepare", "commit"]) |
||||
@pytest.mark.parametrize("alteration", ["tamper", "expired", "wrong-stage"]) |
||||
def test_signed_workflow_rejects_altered_expired_and_wrong_stage_tokens( |
||||
client, source, bank, monkeypatch, stage, alteration |
||||
): |
||||
scanned = scan(client) |
||||
prepared = prepare(client, scanned, bank) if stage == "commit" else None |
||||
token_name = "scan_token" if stage == "prepare" else "prepared_token" |
||||
token = (prepared if prepared else scanned).context[token_name] |
||||
if alteration == "tamper": |
||||
token += "altered" |
||||
elif alteration == "expired": |
||||
import time |
||||
original_time = time.time() |
||||
monkeypatch.setattr(signing.time, "time", lambda: original_time + 901) |
||||
else: |
||||
token = scanned.context["scan_token"] if stage == "commit" else signing.dumps( |
||||
[], salt="guided-imports-prepared") |
||||
response = client.post(reverse("imports-index"), { |
||||
"action": stage, token_name: token, "file-0-section_0": str(bank.pk), |
||||
}) |
||||
assert response.status_code == 400 |
||||
assert "Scan again" in response.content.decode() |
||||
assert not Transaction.objects.exists() |
||||
assert_private(response, source) |
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["prepare", "commit"]) |
||||
def test_changed_source_rejects_previously_signed_metadata(client, source, bank, stage): |
||||
scanned = scan(client) |
||||
prepared = prepare(client, scanned, bank) if stage == "commit" else None |
||||
changed = source / "bank.csv" |
||||
if stage == "commit": |
||||
changed = source / "staging" / "bank.csv" |
||||
changed.write_bytes(DATA.replace(b"12.34", b"22.34")) |
||||
response = commit(client, prepared) if prepared else prepare(client, scanned, bank) |
||||
assert response.context["files"][0]["code"] == "FILE_CHANGED" |
||||
assert not Transaction.objects.exists() |
||||
assert not ImportBatch.objects.filter(state="PREVIEW").exists() |
||||
|
||||
|
||||
def test_stale_prepared_revision_requires_attention_without_writing(client, source, bank): |
||||
prepared = prepare(client, scan(client), bank) |
||||
batch = ImportBatch.objects.get() |
||||
services.refresh_preview(batch_id=batch.pk, revision=batch.revision, actor=ACTOR) |
||||
response = commit(client, prepared) |
||||
assert response.context["files"][0]["code"] == "STALE_REVISION" |
||||
assert not Transaction.objects.exists() |
||||
assert not ImportBatch.objects.filter(state="PREVIEW").exists() |
||||
@ -0,0 +1,362 @@
|
||||
"""Synthetic integration checks for deterministic guided imports.""" |
||||
|
||||
from dataclasses import asdict, replace |
||||
from datetime import date |
||||
from uuid import uuid4 |
||||
|
||||
import pytest |
||||
|
||||
from finance.models import Account, AuditEvent, SourceRecord, Transaction |
||||
from finance.services import Actor, DomainError, archive, create_account, record_transaction |
||||
from ingestion import guided_imports, services |
||||
from ingestion.models import ImportBatch |
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True) |
||||
ACTOR = Actor(kind="HUMAN", identifier="synthetic-guided-owner") |
||||
HEADER = "Data,Documento,Historico,Agencia,Lote,Valor\n" |
||||
ROW = "15/01/2026,1,Synthetic purchase,0,0,-12.50\n" |
||||
DATA = (HEADER + ROW + "16/01/2026,2,Synthetic second purchase,0,0,-7.25\n").encode() |
||||
|
||||
|
||||
@pytest.fixture |
||||
def bank(): |
||||
return create_account(name="Synthetic guided bank", kind="BANK", actor=ACTOR) |
||||
|
||||
|
||||
@pytest.fixture |
||||
def source(tmp_path, settings): |
||||
settings.IMPORT_ROOT = tmp_path |
||||
|
||||
def write(data=DATA, name="bank.csv"): |
||||
(tmp_path / name).write_bytes(data) |
||||
return next(row for row in guided_imports.discover_imports() if row.name == name) |
||||
|
||||
return write |
||||
|
||||
|
||||
def prepare(discovered, bank): |
||||
return guided_imports.prepare_import(discovered=discovered, section_mapping={"1": str(bank.pk)}, actor=ACTOR) |
||||
|
||||
|
||||
def commit(prepared): |
||||
return guided_imports.commit_prepared_import(prepared=prepared, actor=ACTOR) |
||||
|
||||
|
||||
def assert_no_open_preview(): |
||||
assert not ImportBatch.objects.filter(state="PREVIEW").exists() |
||||
|
||||
|
||||
def test_clean_preflight_is_inert_and_complete_commit_preserves_audit_and_provenance(source, bank): |
||||
discovered = source() |
||||
prepared = prepare(discovered, bank) |
||||
assert prepared.state == "READY" |
||||
assert prepared.code is None |
||||
assert prepared.document_hash == discovered.document_hash |
||||
assert prepared.counts == {"created": 2, "matched": 0, "skipped": 0} |
||||
assert not Transaction.objects.exists() |
||||
assert not SourceRecord.objects.exists() |
||||
batch = ImportBatch.objects.get(pk=prepared.batch_id) |
||||
assert batch.revision == prepared.revision == 2 |
||||
assert list(batch.items.values_list("decision", flat=True)) == [{"action": "CREATE"}] * 2 |
||||
result = commit(prepared) |
||||
assert result.state == "COMMITTED" |
||||
assert result.counts == {"created": 2, "matched": 0, "skipped": 0} |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert set(SourceRecord.objects.values_list("document_hash", flat=True)) == {discovered.document_hash} |
||||
assert set(AuditEvent.objects.filter(operation__startswith="import_").values_list("actor_identifier", flat=True)) == {ACTOR.identifier} |
||||
assert "Synthetic purchase" not in str(asdict(result)) |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_exact_second_import_only_skips_and_commit_is_idempotent(source, bank): |
||||
discovered = source() |
||||
first = prepare(discovered, bank) |
||||
assert commit(first).counts["created"] == 2 |
||||
assert commit(first).counts["created"] == 2 |
||||
second = prepare(source(), bank) |
||||
assert second.state == "ALREADY_IMPORTED" |
||||
assert second.counts == {"created": 0, "matched": 0, "skipped": 2} |
||||
assert all(item.decision == {"action": "SKIP", "reason": "ALREADY_IMPORTED"} |
||||
for item in ImportBatch.objects.get(pk=second.batch_id).items.all()) |
||||
assert commit(second).counts["skipped"] == 2 |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_existing_signature_duplicate_skips_without_creating_transactions(source, bank): |
||||
commit(prepare(source(), bank)) |
||||
|
||||
duplicate = prepare(source(DATA.replace(b",1,", b",3,")), bank) |
||||
|
||||
assert duplicate.state == "ALREADY_IMPORTED" |
||||
assert duplicate.counts == {"created": 0, "matched": 0, "skipped": 2} |
||||
assert all(item.decision == {"action": "SKIP", "reason": "ALREADY_IMPORTED"} |
||||
for item in ImportBatch.objects.get(pk=duplicate.batch_id).items.all()) |
||||
assert commit(duplicate).counts == {"created": 0, "matched": 0, "skipped": 2} |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert_no_open_preview() |
||||
|
||||
@pytest.mark.parametrize("data", [ |
||||
(HEADER + ROW + ROW).encode(), |
||||
(HEADER + ROW + "31/02/2026,2,Synthetic invalid date,0,0,-2.00\n").encode(), |
||||
(HEADER + ROW + "synthetic malformed row\n").encode(), |
||||
]) |
||||
def test_uncertain_or_malformed_rows_cancel_without_any_decisions(source, bank, data): |
||||
prepared = prepare(source(data), bank) |
||||
assert prepared.state == "NEEDS_ATTENTION" |
||||
assert prepared.code == "REVIEW_REQUIRED" |
||||
assert not Transaction.objects.exists() |
||||
assert all(not item.decision for item in ImportBatch.objects.get().items.all()) |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_exceptional_file_does_not_block_an_unrelated_ready_file(source, bank): |
||||
exceptional = source((HEADER + ROW + ROW).encode(), "ambiguous.csv") |
||||
ready = source() |
||||
results = [prepare(row, bank) for row in (exceptional, ready)] |
||||
assert [row.state for row in results] == ["NEEDS_ATTENTION", "READY"] |
||||
assert commit(results[1]).counts["created"] == 2 |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("mapping", [{}, {"2": "ACCOUNT"}, {"1": "ACCOUNT", "2": "ACCOUNT"}, |
||||
{"1": None}, {"1": "not-an-id"}, {"1": str(uuid4())}]) |
||||
def test_bad_mapping_is_rejected_before_preview(source, bank, mapping): |
||||
mapping = {key: str(bank.pk) if value == "ACCOUNT" else value for key, value in mapping.items()} |
||||
prepared = guided_imports.prepare_import(discovered=source(), section_mapping=mapping, actor=ACTOR) |
||||
assert prepared.state == "NEEDS_ATTENTION" |
||||
assert prepared.code == "INVALID_SECTION_MAPPING" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["archived", "currency"]) |
||||
def test_only_active_brl_accounts_can_be_mapped(source, bank, change): |
||||
if change == "archived": |
||||
archive(bank, actor=ACTOR) |
||||
else: |
||||
Account.objects.filter(pk=bank.pk).update(currency="USD") |
||||
prepared = prepare(source(), bank) |
||||
assert prepared.state == "NEEDS_ATTENTION" |
||||
assert prepared.code == "INVALID_SECTION_MAPPING" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_wrong_account_kind_requires_attention_and_cancels(source): |
||||
card = create_account(name="Synthetic card", kind="CREDIT_CARD", actor=ACTOR) |
||||
prepared = prepare(source(), card) |
||||
assert prepared.state == "NEEDS_ATTENTION" |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["prepare", "commit"]) |
||||
def test_changed_file_cannot_use_scan_hash(source, bank, tmp_path, stage): |
||||
discovered = source() |
||||
prepared = prepare(discovered, bank) if stage == "commit" else None |
||||
directory = tmp_path / "staging" if stage == "commit" else tmp_path |
||||
(directory / discovered.name).write_bytes(DATA.replace(b"-12.50", b"-13.50")) |
||||
result = commit(prepared) if prepared else prepare(discovered, bank) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "FILE_CHANGED" |
||||
assert not Transaction.objects.exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["../outside.csv", "/outside.csv", "nested/file.csv", "..\\outside.csv"]) |
||||
def test_tampered_filename_is_rejected(source, bank, name): |
||||
result = prepare(replace(source(), name=name), bank) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "UNREADABLE_FILE" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_symlink_replacement_is_rejected_before_commit(source, bank, tmp_path): |
||||
discovered = source() |
||||
prepared = prepare(discovered, bank) |
||||
original = tmp_path / "staging" / discovered.name |
||||
other = tmp_path / "other.csv" |
||||
other.write_bytes(DATA) |
||||
original.unlink() |
||||
original.symlink_to(other) |
||||
result = commit(prepared) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "UNREADABLE_FILE" |
||||
assert not Transaction.objects.exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [("profile", "OUROCARD_FULL_V1"), ("sections", ("2",))]) |
||||
def test_altered_scan_metadata_is_rejected(source, bank, field, value): |
||||
result = prepare(replace(source(), **{field: value}), bank) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "FILE_CHANGED" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_stale_revision_cancels_unfinished_preview(source, bank): |
||||
prepared = prepare(source(), bank) |
||||
services.refresh_preview(batch_id=prepared.batch_id, revision=prepared.revision, actor=ACTOR) |
||||
result = commit(prepared) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "STALE_REVISION" |
||||
assert not Transaction.objects.exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_new_duplicate_after_preparation_requires_new_review(source, bank): |
||||
prepared = prepare(source(), bank) |
||||
record_transaction(account_id=bank.pk, amount="-12.50", kind="EXPENSE", |
||||
posting_date=date(2026, 1, 15), occurrence_date=date(2026, 1, 15), |
||||
description="Synthetic purchase", actor=ACTOR) |
||||
result = commit(prepared) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "STALE_PREVIEW" |
||||
assert Transaction.objects.count() == 1 |
||||
assert not SourceRecord.objects.exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_domain_failure_is_safe_and_rolls_back_then_cancels(source, bank, monkeypatch): |
||||
prepared = prepare(source(), bank) |
||||
original = services.record |
||||
calls = 0 |
||||
|
||||
def fail_second(**kwargs): |
||||
nonlocal calls |
||||
calls += 1 |
||||
if calls == 2: |
||||
raise DomainError("Synthetic source text must never be disclosed") |
||||
return original(**kwargs) |
||||
|
||||
monkeypatch.setattr(services, "record", fail_second) |
||||
result = commit(prepared) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "IMPORT_FAILED" |
||||
assert not Transaction.objects.exists() |
||||
assert not SourceRecord.objects.exists() |
||||
assert "Synthetic source text" not in str(asdict(result)) |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_multi_section_card_creates_only_after_every_mapping_is_complete(source): |
||||
first = create_account(name="Synthetic first card", kind="CREDIT_CARD", actor=ACTOR) |
||||
second = create_account(name="Synthetic second card", kind="CREDIT_CARD", actor=ACTOR) |
||||
data = ("1 - SYNTHETIC FIRST\n" + f"15.01.2026{'Synthetic first':37}BR 12,50 0,00\n" |
||||
+ "2 - SYNTHETIC SECOND\n" + f"16.01.2026{'Synthetic second':37}BR 7,25 0,00\n").encode() |
||||
discovered = source(data, "cards.txt") |
||||
result = guided_imports.prepare_import(discovered=discovered, |
||||
section_mapping={"1": str(first.pk), "2": str(second.pk)}, actor=ACTOR) |
||||
assert result.state == "READY" |
||||
assert commit(result).counts["created"] == 2 |
||||
assert set(Transaction.objects.values_list("account_id", flat=True)) == {first.pk, second.pk} |
||||
|
||||
|
||||
def test_partial_missing_context_has_safe_code_and_no_preview(source): |
||||
card = create_account(name="Synthetic partial card", kind="CREDIT_CARD", actor=ACTOR) |
||||
discovered = source(f"31/12{'Synthetic card row':43}BR 12,50 0,00\n".encode(), "partial.txt") |
||||
result = prepare(discovered, card) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "MISSING_CREATION_DATE" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_partial_context_year_underflow_does_not_interrupt_another_ready_file(source, bank): |
||||
card = create_account(name="Synthetic boundary card", kind="CREDIT_CARD", actor=ACTOR) |
||||
data = ("\n" * 20 + "Data de emissão: 20/01/0001\n" |
||||
+ f"05/01{'Synthetic boundary row':43}BR 12,50 0,00\n").encode() |
||||
exceptional = source(data, "partial.txt") |
||||
ready = source() |
||||
results = [prepare(discovered, account) for discovered, account in ((exceptional, card), (ready, bank))] |
||||
assert results[0].state == "NEEDS_ATTENTION" |
||||
assert results[0].code == "INVALID_CREATION_DATE" |
||||
assert results[0].batch_id is None |
||||
assert results[1].state == "READY" |
||||
assert commit(results[1]).counts["created"] == 2 |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert not ImportBatch.objects.filter(profile="OUROCARD_PARTIAL_V1").exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_partial_valid_context_resolves_new_year_and_commits(source): |
||||
card = create_account(name="Synthetic partial card", kind="CREDIT_CARD", actor=ACTOR) |
||||
data = ("\n" * 20 + "Data de emissão: 20/01/2026\n" |
||||
+ f"31/12{'Synthetic prior year':43}BR 12,50 0,00\n" |
||||
+ f"05/01{'Synthetic current year':43}BR 7,25 0,00\n").encode() |
||||
result = prepare(source(data, "partial.txt"), card) |
||||
assert result.state == "READY" |
||||
batch = ImportBatch.objects.get(pk=result.batch_id) |
||||
assert batch.context == {"start_date": "2025-02-01", "end_date": "2026-01-31"} |
||||
assert commit(result).counts["created"] == 2 |
||||
assert set(Transaction.objects.values_list("posting_date", flat=True)) == {date(2025, 12, 31), date(2026, 1, 5)} |
||||
|
||||
|
||||
def test_tampered_batch_identifier_returns_safe_result_without_cancelling_another_batch(source, bank): |
||||
first = prepare(source(), bank) |
||||
different = prepare(source(DATA.replace(b"-12.50", b"-13.50"), "second.csv"), bank) |
||||
result = commit(replace(first, batch_id=different.batch_id)) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "INVALID_PREPARED_IMPORT" |
||||
assert ImportBatch.objects.get(pk=different.batch_id).state == "PREVIEW" |
||||
assert not Transaction.objects.exists() |
||||
|
||||
|
||||
def test_invalid_batch_identifier_is_safe(source, bank): |
||||
result = commit(replace(prepare(source(), bank), batch_id="invalid-id")) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "INVALID_PREPARED_IMPORT" |
||||
|
||||
|
||||
def test_altered_prepared_source_cannot_cancel_unrelated_preview(source, bank, tmp_path): |
||||
first = prepare(source(), bank) |
||||
different = prepare(source(DATA.replace(b"-12.50", b"-13.50"), "second.csv"), bank) |
||||
(tmp_path / first.name).unlink() |
||||
result = commit(replace(first, batch_id=different.batch_id)) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "INVALID_PREPARED_IMPORT" |
||||
assert ImportBatch.objects.get(pk=different.batch_id).state == "PREVIEW" |
||||
|
||||
|
||||
def test_partially_imported_source_skips_exact_rows_and_creates_only_new_rows(source, bank): |
||||
discovered = source() |
||||
batch = services.create_preview(data=DATA, profile=discovered.profile, |
||||
section_mapping={"1": str(bank.pk)}, actor=ACTOR) |
||||
first, second = batch.items.order_by("locator") |
||||
batch = services.apply_decisions(batch_id=batch.pk, revision=batch.revision, actor=ACTOR, decisions=[ |
||||
{"locator": first.locator, "action": "CREATE"}, |
||||
{"locator": second.locator, "action": "SKIP", "reason": "USER_EXCLUDED"}, |
||||
]) |
||||
services.commit_preview(batch_id=batch.pk, revision=batch.revision, actor=ACTOR) |
||||
prepared = prepare(discovered, bank) |
||||
assert prepared.state == "READY" |
||||
assert commit(prepared).counts == {"created": 1, "matched": 0, "skipped": 1} |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_guided_import_stages_then_processes_committed_source(source, bank, tmp_path): |
||||
discovered = source() |
||||
prepared = prepare(discovered, bank) |
||||
assert prepared.state == "READY" |
||||
assert not (tmp_path / "bank.csv").exists() |
||||
assert (tmp_path / "staging" / "bank.csv").read_bytes() == DATA |
||||
processed = tmp_path / "processed" |
||||
processed.mkdir(exist_ok=True) |
||||
(processed / "bank.csv").write_bytes(b"synthetic previous source") |
||||
|
||||
committed = commit(prepared) |
||||
|
||||
assert committed.state == "COMMITTED" |
||||
assert not (tmp_path / "bank.csv").exists() |
||||
assert not (tmp_path / "staging" / "bank.csv").exists() |
||||
assert (processed / "bank.csv").read_bytes() == DATA |
||||
|
||||
|
||||
def test_guided_import_returns_failure_and_recovers_leftover_staging(source, bank, tmp_path): |
||||
discovered = source(b"unsupported", "bad.csv") |
||||
result = prepare(discovered, bank) |
||||
|
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert (tmp_path / "bad.csv").exists() |
||||
staging = tmp_path / "staging" |
||||
staging.mkdir(exist_ok=True) |
||||
(staging / "interrupted.csv").write_bytes(DATA) |
||||
assert [row.name for row in guided_imports.discover_imports()] == ["bad.csv", "interrupted.csv"] |
||||
Loading…
Reference in new issue