import csv import json from datetime import date, datetime, timezone as datetime_timezone from decimal import Decimal from hashlib import sha256 from io import BytesIO, StringIO from uuid import UUID from zipfile import ZipFile import pytest from finance.exports import build_export from finance.models import ( Account, AuditEvent, CardStatement, Category, InstallmentPlan, Payee, SourceRecord, Transaction, Transfer, ) pytestmark = pytest.mark.django_db(transaction=True) EXPECTED_FILES = [ "accounts.csv", "payees.csv", "categories.csv", "statements.csv", "installment_plans.csv", "transactions.csv", "transfers.csv", "source_records.csv", ] EXPECTED_TRANSACTION_HEADER = ( "id,account_id,amount,posting_date,occurrence_date,description,kind,payee_id," "category_id,statement_id,refund_of_id,installment_plan_id,installment_number," "created_at,updated_at" ) def rows(package, name): content = package.read(name).decode("utf-8") return list(csv.DictReader(StringIO(content))) def seed_complete_export(): account = Account.objects.create( id=UUID("10000000-0000-4000-8000-000000000002"), name="Synthetic Card", kind=Account.Kind.CREDIT_CARD, currency="BRL", archived=True, ) Account.objects.create( id=UUID("10000000-0000-4000-8000-000000000001"), name="Synthetic Bank", kind=Account.Kind.BANK, currency="USD", ) payee = Payee.objects.create( id=UUID("20000000-0000-4000-8000-000000000001"), name="Synthetic Merchant", ) category = Category.objects.create( id=UUID("30000000-0000-4000-8000-000000000001"), name="Synthetic Food", kind=Category.Kind.EXPENSE, ) statement = CardStatement.objects.create( id=UUID("40000000-0000-4000-8000-000000000001"), account=account, period_start=date(2026, 1, 1), period_end=date(2026, 1, 31), due_date=date(2026, 2, 10), statement_balance=Decimal("1234.50"), ) plan = InstallmentPlan.objects.create( id=UUID("50000000-0000-4000-8000-000000000001"), account=account, description="Synthetic appliance", total_installments=3, ) purchase = Transaction.objects.create( id=UUID("60000000-0000-4000-8000-000000000001"), account=account, amount=Decimal("-411.50"), posting_date=date(2026, 1, 12), occurrence_date=date(2026, 1, 10), description="Synthetic purchase, first part", kind=Transaction.Kind.EXPENSE, payee=payee, category=category, statement=statement, installment_plan=plan, installment_number=1, ) refund = Transaction.objects.create( id=UUID("60000000-0000-4000-8000-000000000002"), account=account, amount=Decimal("11.50"), posting_date=date(2026, 2, 1), description="", kind=Transaction.Kind.REFUND, category=category, refund_of=purchase, ) transfer_out = Transaction.objects.create( id=UUID("60000000-0000-4000-8000-000000000003"), account_id=UUID("10000000-0000-4000-8000-000000000001"), amount=Decimal("-20.00"), posting_date=date(2026, 2, 2), kind=Transaction.Kind.TRANSFER, ) transfer_in = Transaction.objects.create( id=UUID("60000000-0000-4000-8000-000000000004"), account=account, amount=Decimal("20.00"), posting_date=date(2026, 2, 2), kind=Transaction.Kind.TRANSFER, ) Transfer.objects.create( id=UUID("70000000-0000-4000-8000-000000000001"), outgoing=transfer_out, incoming=transfer_in, ) SourceRecord.objects.create( id=UUID("80000000-0000-4000-8000-000000000001"), transaction=purchase, document_hash="a" * 64, source_format=SourceRecord.Format.OUROCARD_TXT, parser_version="synthetic-v1", item_locator="line:2", ) AuditEvent.objects.create( actor_kind=AuditEvent.ActorKind.HUMAN, actor_identifier="synthetic-owner", operation="synthetic_operation", outcome=AuditEvent.Outcome.SUCCESS, ) return account, purchase, refund def test_export_has_versioned_manifest_and_stable_csv_contract(): seed_complete_export() artifact = build_export() with ZipFile(BytesIO(artifact.content)) as package: assert package.namelist() == [*EXPECTED_FILES, "manifest.json"] manifest = json.loads(package.read("manifest.json")) assert manifest["format"] == "gizmoduck-export" assert manifest["version"] == 1 assert manifest["application_version"] == "0.1.0" assert list(manifest["files"]) == EXPECTED_FILES assert package.read("transactions.csv").decode("utf-8").splitlines()[0] == EXPECTED_TRANSACTION_HEADER for name in EXPECTED_FILES: content = package.read(name) assert manifest["files"][name]["sha256"] == sha256(content).hexdigest() assert manifest["files"][name]["rows"] == len(rows(package, name)) def test_export_retains_portable_values_relationships_and_provenance(): account, purchase, refund = seed_complete_export() artifact = build_export() with ZipFile(BytesIO(artifact.content)) as package: account_rows = rows(package, "accounts.csv") assert [row["id"] for row in account_rows] == [ "10000000-0000-4000-8000-000000000001", "10000000-0000-4000-8000-000000000002", ] assert account_rows[1]["archived"] == "true" assert account_rows[1]["currency"] == "BRL" statement_row = rows(package, "statements.csv")[0] assert statement_row["statement_balance"] == "1234.50" assert statement_row["due_date"] == "2026-02-10" transaction_rows = rows(package, "transactions.csv") purchase_row = next(row for row in transaction_rows if row["id"] == str(purchase.pk)) assert purchase_row["amount"] == "-411.50" assert purchase_row["occurrence_date"] == "2026-01-10" assert purchase_row["description"] == "Synthetic purchase, first part" assert purchase_row["account_id"] == str(account.pk) assert purchase_row["installment_number"] == "1" assert "T" in purchase_row["created_at"] refund_row = next(row for row in transaction_rows if row["id"] == str(refund.pk)) assert refund_row["occurrence_date"] == "" assert refund_row["payee_id"] == "" assert refund_row["refund_of_id"] == str(purchase.pk) source_row = rows(package, "source_records.csv")[0] assert source_row == { "id": "80000000-0000-4000-8000-000000000001", "transaction_id": str(purchase.pk), "document_hash": "a" * 64, "source_format": "OUROCARD_TXT", "parser_version": "synthetic-v1", "item_locator": "line:2", "created_at": source_row["created_at"], } assert "audit_events.csv" not in package.namelist() def test_export_is_byte_stable_for_the_same_snapshot(monkeypatch): seed_complete_export() fixed_now = datetime(2026, 9, 7, 12, 30, tzinfo=datetime_timezone.utc) monkeypatch.setattr("finance.exports.timezone.now", lambda: fixed_now) first = build_export() second = build_export() assert first.filename == second.filename == "gizmoduck-export-20260907T123000Z.zip" assert first.content == second.content