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:
1 parent
439a15264b
commit
3eb5fdbe55
10 files changed
+455
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user