You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

220 lines
7.4 KiB

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