mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user