feat: import transactions from a CSV bank statement

Fourth of the four ideas picked from the Maybe/Sure feature audit, and the
last one queued up. A 3-step wizard (account -> column mapping -> preview)
at /transactions/import, parsed entirely client-side (new lib/csv.ts —
delimiter auto-detect, 5 date formats, thousands/decimal-separator-aware
amounts) and submitted through a new all-or-nothing POST /transactions/bulk
endpoint, mirroring backup restore's validate-everything-before-touching-
anything approach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-25 02:44:30 +03:00
parent 94782ee073
commit 19f5cfa429
11 changed files with 706 additions and 8 deletions

View File

@@ -11,7 +11,14 @@ from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.tag import Tag
from app.models.transaction import Transaction
from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate
from app.schemas.transaction import (
TransactionBulkCreate,
TransactionBulkCreateResult,
TransactionCreate,
TransactionPage,
TransactionRead,
TransactionUpdate,
)
router = APIRouter(prefix="/transactions", tags=["transactions"])
@@ -154,6 +161,27 @@ async def create_transaction(payload: TransactionCreate, session: AsyncSession =
return refreshed.scalar_one()
@router.post("/bulk", response_model=TransactionBulkCreateResult, status_code=201)
async def bulk_create_transactions(
payload: TransactionBulkCreate, session: AsyncSession = Depends(get_session)
) -> TransactionBulkCreateResult:
"""CSV import lands here — see schemas.TransactionBulkCreate. All rows
are validated before any is added, so a bad row 400s the whole request
instead of leaving a half-imported statement behind."""
for item in payload.items:
await _ensure_category_matches_type(session, item.category_id, item.type)
transactions = []
for item in payload.items:
transaction = Transaction(**item.model_dump(exclude={"tag_ids"}))
transaction.tags = await _resolve_tags(session, item.tag_ids)
transactions.append(transaction)
session.add_all(transactions)
await session.commit()
return TransactionBulkCreateResult(created=len(transactions))
@router.patch("/{transaction_id}", response_model=TransactionRead)
async def update_transaction(
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)

View File

@@ -78,3 +78,16 @@ class TransactionPage(BaseModel):
total: int
page: int
page_size: int
class TransactionBulkCreate(BaseModel):
"""CSV import (see routes/transactions.py's /bulk): the frontend parses
the file and maps its columns client-side, then sends already-shaped
rows here. All-or-nothing — same failure semantics as backup restore, so
a single bad row never leaves a partial import behind."""
items: list[TransactionCreate] = Field(min_length=1, max_length=5000)
class TransactionBulkCreateResult(BaseModel):
created: int

View File

@@ -16,6 +16,44 @@ from httpx import AsyncClient
from tests.helpers import money, txn_payload as _txn
async def test_bulk_create_transactions(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
resp = await client.post(
"/transactions/bulk",
json={
"items": [
_txn(account_id, category_id=groceries, description="csv row 1", date="2026-01-01"),
_txn(account_id, category_id=groceries, description="csv row 2", date="2026-01-02"),
]
},
)
assert resp.status_code == 201
assert resp.json()["created"] == 2
listed = await client.get("/transactions", params={"year": 2026, "month": 1})
assert listed.json()["total"] == 2
async def test_bulk_create_is_all_or_nothing(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
salary = categories["Salary"]["id"]
resp = await client.post(
"/transactions/bulk",
json={
"items": [
_txn(account_id, category_id=groceries, description="good row"),
# An expense using an income category — rejected, and it
# should take the whole batch down with it.
_txn(account_id, category_id=salary, description="bad row"),
]
},
)
assert resp.status_code == 400
listed = await client.get("/transactions")
assert listed.json()["total"] == 0
async def test_create_expense_rejects_income_category(client: AsyncClient, account_id, categories):
income_category = categories["Salary"]
resp = await client.post(