feat: batch expense entry in QuickAdd, backed by an atomic batch endpoint

Recording several expenses meant opening QuickAdd, filling the Expense flow,
saving, and repeating — re-picking the account each time. And since no
QuickAdd flow has a date field (all ten hardcode today), catching up on
Saturday's spending on Monday was not possible at all.

Adds a Batch Expenses flow where the account and date are chosen once and
rows carry amount + category + description.

POST /api/transactions/batch

  Writes every row in one commit via db.add_all, following the existing
  create_split_expense shape — all rows land or none do. This is what earns
  a new endpoint over looping POST /api/transactions client-side, where a
  failure halfway leaves a half-saved batch with no rollback.

  Two conventions differ from the rest of the API, both deliberate:

  - Items carry POSITIVE amounts and the sign is derived server-side from
    the batch type. A mixed-sign batch becomes unrepresentable rather than
    merely discouraged.
  - type is Literal["expense", "income"], not the six-value
    TransactionCreate Literal. transfer and settlement are two-leg writes;
    batching them would emit orphan legs with no linked_account_id, which is
    the balance corruption invariant #1 exists to prevent.

  Category ids are validated up front so a bad row returns 422 naming the
  row, rather than an IntegrityError surfacing as a 500.

Frontend

  BatchExpenseFlow renders a vertical stack rather than a wide grid — the
  QuickAdd sheet is a max-w-md bottom sheet. Per-row category uses the
  compact select from the Transactions edit row; a full emoji grid per row
  would be unusably tall. Rows left entirely blank are ignored; a row with
  text that will not parse names itself in the error.

  Because the write is atomic, a failed save leaves the rows in place to
  correct and resubmit. Saving dispatches fin:transaction-added exactly once,
  so Dashboard, Transactions, Spending and Portfolio each refetch once rather
  than once per row.

  Extracts parseAmount() into lib/utils.ts for the ~20x duplicated
  parseFloat(s.replace(',', '.')) idiom. Used by the new flow only — the
  existing ten flows are left alone.

Tests: 47 passing, up from 37. Ten new cover the atomicity property
specifically — a batch rejected for a bad category, an unknown account, a
negative amount, or a two-leg type must leave zero rows written.

Verified live against a running API: a 3-row back-dated batch returns 201
with amounts stored negative, balance moves exactly 100.00 -> 52.40, an
income batch stores +900.00, and a rejected batch adds nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
This commit is contained in:
EislM0203andClaude Opus 5 committed 2026-08-22 21:57:26 +00:00
1 parent 439a15264b
commit 3eb5fdbe55
10 files changed
+455

No files matched your search

+2
View File
@@ -83,6 +83,8 @@ cli/
`Transaction` belongs to one account and has a `type`: `expense`, `income`, `transfer`, `splitwise_pay`, `splitwise_receive`, or `settlement`. Transfers and settlements always produce two linked rows — a debit on one account and a credit on another — connected via `linked_account_id`. The `fee` column is included in balance calculations (`amount - fee`). For Splitwise pay transactions, `splitwise_partner_share` records the partner's portion (used in spending calculations to show only the user's net share).
`POST /api/transactions/batch` writes several single-leg rows sharing one account, date and currency in a single commit — all rows land or none do. It is the one write endpoint whose items carry **positive** amounts: the sign is derived server-side from the batch `type`, which makes a mixed-sign batch unrepresentable. Its `type` is restricted to `expense`/`income` for the same reason the MCP `create_transaction` tool is — a batched `transfer` would emit orphan legs (invariant #1). The UI entry point is the `Batch Expenses` QuickAdd flow.
`StockTransaction` records buy/sell/rsu_vest events and drives `Holding` state. Holdings are never edited directly — they are recomputed from the full stock transaction history on any delete, and incrementally updated on insert.
`PriceCache` holds the latest fetched price per symbol (one row, upserted). `NetWorthSnapshot` holds one row per calendar day with the total EUR value and a JSON breakdown by account.
+31
View File
@@ -159,6 +159,37 @@ def create_transaction(
return db_transaction
def create_transactions_batch(
db: Session, req: schemas.BatchTransactionRequest
) -> list[models.Transaction]:
"""Write every row of a batch in a single commit — all land or none do.
Items carry positive amounts and the sign is derived from req.type, so a
batch cannot mix money in with money out. Only single-leg types are
accepted (see BatchTransactionRequest), so this never produces the orphan
legs that invariant #1 exists to prevent.
"""
sign = -1 if req.type == "expense" else 1
rows = [
models.Transaction(
date=req.date,
account_id=req.account_id,
amount=sign * item.amount,
currency=req.currency,
type=req.type,
category_id=item.category_id,
description=item.description,
fee=item.fee,
)
for item in req.items
]
db.add_all(rows)
db.commit()
for row in rows:
db.refresh(row)
return rows
def update_transaction(
db: Session, transaction_id: int, txn: schemas.TransactionUpdate
) -> models.Transaction | None:
+16
View File
@@ -28,6 +28,22 @@ def create_transaction(
return crud.create_transaction(db, transaction)
@router.post("/batch", response_model=list[schemas.TransactionRead], status_code=201)
def create_transactions_batch(
req: schemas.BatchTransactionRequest, db: Session = Depends(get_db)
):
if crud.get_account(db, req.account_id) is None:
raise HTTPException(status_code=404, detail="Account not found")
# Validate category ids up front. The FK would reject them anyway, but as
# an IntegrityError on commit — a 500 that does not say which row is wrong.
for i, item in enumerate(req.items):
if item.category_id is not None and crud.get_category(db, item.category_id) is None:
raise HTTPException(
status_code=422, detail=f"Row {i + 1}: category {item.category_id} not found"
)
return crud.create_transactions_batch(db, req)
@router.put("/{transaction_id}", response_model=schemas.TransactionRead)
def update_transaction(transaction_id: int, txn: schemas.TransactionUpdate, db: Session = Depends(get_db)):
existing = crud.get_transaction(db, transaction_id)
+25
View File
@@ -137,6 +137,31 @@ class SplitwiseIPaidRequest(BaseModel):
description: str = "Splitwise: I paid"
class BatchTransactionItem(BaseModel):
"""One row of a batch. Amount is positive; the sign comes from the batch type."""
amount: float = Field(gt=0)
category_id: Optional[int] = None
description: Optional[str] = Field(default=None, max_length=500)
fee: float = 0
class BatchTransactionRequest(BaseModel):
"""Several single-leg transactions sharing an account, date and currency.
`type` is restricted to the single-leg kinds. `transfer` and `settlement`
always need two linked legs, and emitting them here would create orphans
that corrupt balances (CLAUDE.md invariant #1) — those go through
/transfer and /settlement instead.
"""
account_id: int
date: date
currency: str = "EUR"
type: Literal["expense", "income"] = "expense"
items: list[BatchTransactionItem] = Field(min_length=1, max_length=100)
class TransactionRead(TransactionBase):
model_config = ConfigDict(from_attributes=True)
+3
View File
@@ -169,6 +169,9 @@ class FinClient:
def create_settlement(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions/settlement", json=data)
def create_transactions_batch(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions/batch", json=data)
def create_split_expense(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions/split-expense", json=data)
+160
View File
@@ -0,0 +1,160 @@
"""Batch transaction entry — POST /api/transactions/batch.
Drives the real app through FinClient, same as the rest of the e2e suite.
The property that earns this endpoint over a client-side loop is atomicity,
so most of these tests assert that a rejected batch writes *nothing*.
"""
import pytest
from fin_cli.client import FinClientError
def _bank(client, name="Checking"):
return client.create_account({"name": name, "type": "bank", "currency": "EUR"})
def _category(client, name="Groceries"):
return client.create_category({"name": name, "color": "#22c55e", "icon": "X"})
def _batch(account_id, items, **overrides):
payload = {"account_id": account_id, "date": "2026-08-22", "items": items}
payload.update(overrides)
return payload
class TestBatchSuccess:
def test_writes_every_row_in_one_call(self, client):
acct = _bank(client)
cat = _category(client)
created = client.create_transactions_batch(
_batch(
acct["id"],
[
{"amount": 12.40, "category_id": cat["id"], "description": "Rewe"},
{"amount": 4.20, "description": "coffee"},
{"amount": 31.00, "category_id": cat["id"]},
],
)
)
assert len(created) == 3
stored = client.list_transactions(account_id=acct["id"])
assert len(stored) == 3
assert {t["description"] for t in stored} == {"Rewe", "coffee", None}
def test_expense_amounts_are_stored_negative(self, client):
"""Items are sent positive; the sign is derived from the batch type."""
acct = _bank(client)
created = client.create_transactions_batch(
_batch(acct["id"], [{"amount": 12.40}, {"amount": 4.20}])
)
assert sorted(t["amount"] for t in created) == [-12.40, -4.20]
assert all(t["type"] == "expense" for t in created)
def test_income_batch_stores_positive(self, client):
acct = _bank(client)
created = client.create_transactions_batch(
_batch(acct["id"], [{"amount": 900.0}], type="income")
)
assert created[0]["amount"] == 900.0
assert created[0]["type"] == "income"
def test_shared_date_and_account_apply_to_every_row(self, client):
acct = _bank(client)
created = client.create_transactions_batch(
_batch(acct["id"], [{"amount": 1.0}, {"amount": 2.0}], date="2026-01-15")
)
assert all(t["date"] == "2026-01-15" for t in created)
assert all(t["account_id"] == acct["id"] for t in created)
def test_batch_moves_the_account_balance_by_the_total(self, client):
"""Balance is SUM(amount - fee) over the account (CLAUDE.md invariant #5)."""
acct = client.create_account(
{"name": "Checking", "type": "bank", "currency": "EUR", "initial_balance": 100.0}
)
client.create_transactions_batch(
_batch(acct["id"], [{"amount": 12.40}, {"amount": 4.20}, {"amount": 31.00}])
)
txns = client.list_transactions(account_id=acct["id"])
balance = sum(t["amount"] - t["fee"] for t in txns)
assert balance == pytest.approx(100.0 - 47.60)
class TestBatchIsAtomic:
"""A rejected batch must leave the ledger untouched."""
def test_unknown_category_writes_nothing(self, client):
acct = _bank(client)
with pytest.raises(FinClientError) as exc:
client.create_transactions_batch(
_batch(
acct["id"],
[
{"amount": 12.40},
{"amount": 4.20, "category_id": 99999},
{"amount": 31.00},
],
)
)
assert exc.value.status_code == 422
assert client.list_transactions(account_id=acct["id"]) == []
def test_unknown_account_writes_nothing(self, client):
with pytest.raises(FinClientError) as exc:
client.create_transactions_batch(_batch(99999, [{"amount": 12.40}]))
assert exc.value.status_code == 404
assert client.list_transactions() == []
def test_one_bad_amount_rejects_the_whole_batch(self, client):
acct = _bank(client)
with pytest.raises(FinClientError) as exc:
client.create_transactions_batch(
_batch(acct["id"], [{"amount": 12.40}, {"amount": -5.0}])
)
assert exc.value.status_code == 422
assert client.list_transactions(account_id=acct["id"]) == []
class TestBatchInvariantGuards:
def test_two_leg_types_are_refused(self, client):
"""A batch must never emit a transfer/settlement leg.
POST /api/transactions accepts all six transaction types, so an
unrestricted batch endpoint could write single legs with no
linked_account_id — orphans that corrupt balances and that
paired-delete can never clean up (CLAUDE.md invariant #1).
"""
acct = _bank(client)
for bad_type in ("transfer", "settlement", "splitwise_pay", "splitwise_receive"):
with pytest.raises(FinClientError) as exc:
client.create_transactions_batch(
_batch(acct["id"], [{"amount": 10.0}], type=bad_type)
)
assert exc.value.status_code == 422, f"{bad_type} was not rejected"
assert client.list_transactions(account_id=acct["id"]) == []
def test_empty_batch_is_refused(self, client):
acct = _bank(client)
with pytest.raises(FinClientError) as exc:
client.create_transactions_batch(_batch(acct["id"], []))
assert exc.value.status_code == 422
+11
View File
@@ -136,6 +136,17 @@ export const api = {
request<Transaction[]>('/transactions/splitwise-i-paid', { method: 'POST', body: JSON.stringify(data) }),
createSplitExpense: (data: { bank_account_id: number; receivable_account_id: number; total_amount: number; partner_share: number; currency?: string; date: string; category_id?: number; description?: string }) =>
request<Transaction[]>('/transactions/split-expense', { method: 'POST', body: JSON.stringify(data) }),
// Items carry positive amounts; the backend derives the sign from `type`.
// One commit server-side, so a rejected batch writes nothing.
createTransactionBatch: (data: {
account_id: number
date: string
currency?: string
type?: 'expense' | 'income'
items: { amount: number; category_id?: number; description?: string; fee?: number }[]
}) => request<Transaction[]>('/transactions/batch', {
method: 'POST', body: JSON.stringify(data),
}),
reconcileAccounts: (data: {
bank_account_id: number; other_account_id: number;
amount: number; date: string; description?: string;
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { Account, Category } from '@/api/client'
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import ExpenseFlow from './flows/ExpenseFlow'
import BatchExpenseFlow from './flows/BatchExpenseFlow'
import IncomeFlow from './flows/IncomeFlow'
import SplitwiseIPaidFlow from './flows/SplitwiseIPaidFlow'
import SplitwiseShePayedFlow from './flows/SplitwiseShePayedFlow'
@@ -14,6 +15,7 @@ import SplitExpenseFlow from './flows/SplitExpenseFlow'
export type FlowType =
| 'expense'
| 'batch_expense'
| 'split_expense'
| 'income'
| 'splitwise_i_paid'
@@ -35,6 +37,7 @@ function buildFlows(accounts: Account[]): { id: FlowType; label: string; descrip
const hasPayable = accounts.some(a => a.type === 'payable' && a.is_active)
const flows: { id: FlowType; label: string; description: string }[] = [
{ id: 'expense', label: 'Expense', description: 'Record a personal expense' },
{ id: 'batch_expense', label: 'Batch Expenses', description: 'Enter several expenses at once' },
{ id: 'income', label: 'Income', description: 'Record money received' },
{ id: 'splitwise_i_paid', label: 'Splitwise: I Paid', description: 'You paid, she owes her share' },
{ id: 'splitwise_she_paid', label: 'Splitwise: She Paid', description: 'She paid, you owe your share' },
@@ -94,6 +97,8 @@ function SelectedFlow({ flow, accounts, categories, onSuccess, onBack, initialAc
switch (flow) {
case 'expense':
return <ExpenseFlow {...props} />
case 'batch_expense':
return <BatchExpenseFlow {...props} />
case 'split_expense':
return <SplitExpenseFlow {...props} />
case 'income':
@@ -0,0 +1,195 @@
import { useState } from 'react'
import { api, Account, Category } from '@/api/client'
import { Input } from '@/components/ui/input'
import { parseAmount, todayLocalISO } from '@/lib/utils'
interface BatchExpenseFlowProps {
accounts: Account[]
categories: Category[]
onSuccess: () => void
onBack: () => void
initialAccountId?: number
}
type Row = { amountStr: string; categoryId: number | null; description: string }
const emptyRow = (): Row => ({ amountStr: '', categoryId: null, description: '' })
export default function BatchExpenseFlow({
accounts,
categories,
onSuccess,
onBack,
initialAccountId,
}: BatchExpenseFlowProps) {
const bankAccounts = accounts.filter(a => a.type === 'bank' && a.is_active)
const expenseCategories = categories.filter(c => c.type === 'expense')
const [rows, setRows] = useState<Row[]>([emptyRow()])
const [selectedAccountId, setSelectedAccountId] = useState<number>(
initialAccountId ?? bankAccounts[0]?.id ?? 0
)
const [dateStr, setDateStr] = useState(todayLocalISO())
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const selectedAccount = bankAccounts.find(a => a.id === selectedAccountId)
const currency = selectedAccount?.currency ?? 'EUR'
const updateRow = (i: number, patch: Partial<Row>) =>
setRows(rs => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)))
const addRow = () => setRows(rs => [...rs, emptyRow()])
const removeRow = (i: number) => setRows(rs => rs.filter((_, idx) => idx !== i))
const parsed = rows.map(r => ({ row: r, amount: parseAmount(r.amountStr) }))
const valid = parsed.filter(p => p.amount !== null)
const total = valid.reduce((sum, p) => sum + (p.amount ?? 0), 0)
const fmt = (n: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n)
const handleSave = async () => {
// A row left completely blank is just an unused slot; a row with text that
// will not parse is a mistake worth naming.
const bad = parsed.findIndex(p => p.amount === null && p.row.amountStr.trim() !== '')
if (bad !== -1) { setError(`Row ${bad + 1}: enter a valid amount`); return }
if (valid.length === 0) { setError('Add at least one row'); return }
if (!selectedAccountId) { setError('Select an account'); return }
setSaving(true)
setError(null)
try {
await api.createTransactionBatch({
account_id: selectedAccountId,
date: dateStr,
currency,
type: 'expense',
items: valid.map(p => ({
amount: p.amount as number,
category_id: p.row.categoryId ?? undefined,
description: p.row.description || undefined,
})),
})
onSuccess()
} catch (e) {
// The write is atomic, so nothing was saved — leave the rows in place
// for the user to fix and resubmit.
setError(e instanceof Error ? e.message : 'Failed to save')
} finally {
setSaving(false)
}
}
return (
<div className="p-4 space-y-4">
<div className="flex items-center gap-2 mb-2">
<button onClick={onBack} className="text-muted-foreground text-sm">← Back</button>
<h2 className="text-base font-semibold">Batch Expenses</h2>
</div>
{bankAccounts.length === 0 && (
<p className="text-sm text-yellow-600 bg-yellow-50 rounded-lg p-2">
No bank accounts found. Please add one in Settings.
</p>
)}
{/* Account — shared by every row */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Account</label>
<div className="flex flex-col gap-1">
{bankAccounts.map(acc => (
<button
key={acc.id}
onClick={() => setSelectedAccountId(acc.id)}
className={`text-left px-3 py-2 rounded-lg border text-sm ${
selectedAccountId === acc.id
? 'border-primary bg-primary/10'
: 'border-border bg-background'
}`}
>
{acc.name}
</button>
))}
</div>
</div>
{/* Date — shared by every row */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Date</label>
<Input type="date" value={dateStr} onChange={e => setDateStr(e.target.value)} />
</div>
{/* Rows */}
<div className="space-y-2">
<label className="text-xs text-muted-foreground block">Expenses</label>
{rows.map((row, i) => (
<div key={i} className="rounded-lg border border-border p-2 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-5 shrink-0">{i + 1}.</span>
<Input
type="text"
inputMode="decimal"
placeholder="0.00"
value={row.amountStr}
onChange={e => updateRow(i, { amountStr: e.target.value })}
/>
{rows.length > 1 && (
<button
onClick={() => removeRow(i)}
aria-label={`Remove row ${i + 1}`}
className="text-muted-foreground text-lg leading-none px-2 shrink-0"
>
×
</button>
)}
</div>
<div className="flex gap-2 pl-7">
<select
className="flex-1 min-w-0 border rounded px-2 py-1 text-sm bg-background"
value={row.categoryId ?? ''}
onChange={e =>
updateRow(i, { categoryId: e.target.value ? Number(e.target.value) : null })
}
>
<option value="">Uncategorized</option>
{expenseCategories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<input
className="flex-1 min-w-0 border rounded px-2 py-1 text-sm bg-background"
type="text"
placeholder="Description"
value={row.description}
onChange={e => updateRow(i, { description: e.target.value })}
/>
</div>
</div>
))}
<button
onClick={addRow}
className="w-full py-2 rounded-lg border border-dashed border-border text-sm text-muted-foreground"
>
+ Add row
</button>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total</span>
<span className="font-semibold">{fmt(total)}</span>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
onClick={handleSave}
disabled={saving || bankAccounts.length === 0}
className="w-full py-3 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50"
>
{saving
? 'Saving...'
: `Save ${valid.length} expense${valid.length === 1 ? '' : 's'}`}
</button>
</div>
)
}
+7
View File
@@ -9,3 +9,10 @@ export function todayLocalISO(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// Accepts both 12.40 and the German 12,40. Returns null for anything that is
// not a usable positive amount, so callers can report which row is wrong.
export function parseAmount(input: string): number | null {
const n = parseFloat(input.replace(',', '.'))
return Number.isFinite(n) && n > 0 ? n : null
}