diff --git a/docs/superpowers/specs/2026-09-09-guided-local-import-design.md b/docs/superpowers/specs/2026-09-09-guided-local-import-design.md index 4a68e8c..c12bd2e 100644 --- a/docs/superpowers/specs/2026-09-09-guided-local-import-design.md +++ b/docs/superpowers/specs/2026-09-09-guided-local-import-design.md @@ -56,17 +56,17 @@ Each scanned file has one of these statuses: - **Ready:** every candidate can be created without a validation issue or unresolved duplicate. -- **Already imported:** every source candidate is already attached through - existing provenance; the file is skipped without financial writes. +- **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 receive -`SKIP` with `ALREADY_IMPORTED`; they produce no transaction and are not an -exception. Any potential duplicate that is not an exact existing source record -requires review and prevents that file from committing automatically. +`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 diff --git a/ingestion/guided_imports.py b/ingestion/guided_imports.py index 26c3b0b..5e7c13c 100644 --- a/ingestion/guided_imports.py +++ b/ingestion/guided_imports.py @@ -374,7 +374,9 @@ def prepare_import(*, discovered: DiscoveredFile, section_mapping: dict[str, str decisions = [] counts = {"created": 0, "matched": 0, "skipped": 0} for item in batch.items.order_by("locator"): - if item.issues == ["ALREADY_IMPORTED"]: + 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"]: diff --git a/tests/test_guided_import_workflow.py b/tests/test_guided_import_workflow.py index e345718..eaf2d7a 100644 --- a/tests/test_guided_import_workflow.py +++ b/tests/test_guided_import_workflow.py @@ -83,14 +83,18 @@ def test_exact_second_import_only_skips_and_commit_is_idempotent(source, bank): assert_no_open_preview() -def test_ambiguous_existing_duplicate_cancels_whole_file(source, bank): +def test_existing_signature_duplicate_skips_without_creating_transactions(source, bank): commit(prepare(source(), bank)) - ambiguous = prepare(source(DATA.replace(b",1,", b",3,")), bank) - assert ambiguous.state == "NEEDS_ATTENTION" - assert ambiguous.code == "REVIEW_REQUIRED" - assert Transaction.objects.count() == 2 - assert_no_open_preview() + 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(),