chore: add gitignore entries, commit CLAUDE.md, README, and lockfile

This commit is contained in:
Markus Eisl
2026-07-01 08:21:04 +02:00
parent 9eadff1c28
commit 98ddc978bd
5 changed files with 4384 additions and 1 deletions
+4 -1
View File
@@ -1,2 +1,5 @@
.superpowers/
**/__pycache__/
**/__pycache__/
api/fin.db
web/dist/
web/node_modules/
+88
View File
@@ -0,0 +1,88 @@
# fin — CLAUDE.md
Personal finance webapp for tracking net worth, transactions, and a stock portfolio. Single-user, self-hosted, no auth.
## Tech stack
- **Backend**: Python 3.12, FastAPI, SQLAlchemy (sync), SQLite (WAL mode), Alembic, APScheduler, yfinance, httpx
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui (Radix UI primitives), Recharts, react-router-dom v6
- **Infra**: Two Docker containers — `api` (uvicorn, port 8000) behind `web` (nginx, port 80). Nginx proxies `/api/*` to the API container. SQLite DB lives in a named volume at `/data/fin.db`.
## How to run
```bash
docker compose up --build # full stack
```
No separate dev server setup exists. The Dockerfile CMD runs `alembic upgrade head` before starting uvicorn, so migrations apply automatically on container start.
## Project structure
```
api/
app/
main.py # FastAPI app, lifespan (APScheduler start/stop)
models.py # SQLAlchemy ORM models
schemas.py # Pydantic v2 request/response schemas
crud.py # All DB read/write logic (no business logic in routers)
database.py # Engine, SessionLocal, WAL pragma, get_db()
routers/ # One file per resource; thin — validates, calls crud/services
services/
net_worth.py # calculate_net_worth() — reads price_cache + holdings
price_fetcher.py # yfinance + frankfurter.app, upserts price_cache
scheduler.py # APScheduler: daily midnight job (fetch prices + snapshot)
alembic/
versions/ # Migration chain: a1b2c3d4e5f6 → b2c3d4e5f6a7
web/
src/
api/client.ts # Single API layer — all fetch calls go through api.* here
pages/ # Dashboard, Transactions, Spending, Portfolio, History, Settings
components/
QuickAdd/flows/ # One component per transaction type (Expense, Transfer, etc.)
layout/ # Shell layout with nav
ui/ # shadcn/ui components (Button, Card, Dialog, Input, Select…)
```
## Backend conventions
- **Sync only**: SQLAlchemy is configured with a synchronous engine (`create_engine`, `SessionLocal`). Never introduce `async def` endpoints or `async with Session`. FastAPI handles the thread pool.
- **Layer separation**: Routers do input validation and HTTP error mapping. `crud.py` owns all DB mutations. `services/` contains logic that spans multiple tables or calls external APIs. No cross-layer imports in the wrong direction.
- **Schemas**: Pydantic v2. Read schemas use `model_config = ConfigDict(from_attributes=True)`. Update schemas have all fields `Optional` and use `model_dump(exclude_unset=True)`.
- **Migrations**: Schema changes go through Alembic. Generate with `alembic revision --autogenerate -m "description"`, review output, then commit. Never ALTER tables by hand or call `Base.metadata.create_all()` as a migration strategy (it is used only for first-boot convenience in `lifespan`).
- **Prices**: All prices stored in `price_cache` (one row per symbol, upserted). `EUR/USD` is the FX rate. Ticker prices are USD. The scheduler refreshes at midnight; on-demand refresh via `POST /api/prices/refresh`.
## Frontend conventions
- **Single API layer**: All backend calls go through `web/src/api/client.ts`. Do not call `fetch()` directly in pages or components — add a function to `api` in client.ts instead.
- **Event bus**: When a QuickAdd flow saves successfully, it calls `onSuccess()`, which the QuickAdd parent dispatches as `window.dispatchEvent(new CustomEvent('fin:transaction-added'))`. Pages that need to refresh listen for this event in a `useEffect`. Do not break this pattern — it is the only cross-component refresh mechanism.
- **UI components**: Use existing shadcn/ui components from `web/src/components/ui/`. Do not add new UI libraries. New Radix primitives can be scaffolded following the existing pattern in `components.json`.
- **No global state manager**: Data fetching is local to each page via `useState` + `useEffect`. Shared state (accounts, categories) is re-fetched per page.
## Data model
`Account` is the top-level entity with a `type` field: `bank`, `investment`, `rsu`, `splitwise`, or `receivable`. Bank accounts track cash via `Transaction` rows. Investment and RSU accounts track equity via `Holding` rows (one per ticker, unique on `account_id + ticker`). A Splitwise account records shared-expense balances.
`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).
`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.
## Key invariants — never break these
1. **Atomic two-leg writes**: `create_transfer` and `create_settlement` in crud.py use `db.add_all([leg1, leg2]); db.commit()`. Both legs must be written in a single transaction. Deleting one leg of a transfer without the other corrupts balances.
2. **Holding recompute on delete**: `delete_stock_transaction` calls `recompute_holding_from_stock_transactions` after the delete, before `db.commit()`. Skipping this leaves holdings inconsistent with the transaction log.
3. **WAL mode**: `PRAGMA journal_mode=WAL` is applied on every new connection (both the main engine and the scheduler's private engine). The scheduler runs in a background thread and must not share the request-scoped session.
4. **Non-root Docker user**: The API Dockerfile creates user `app` (uid 1000) and runs as that user. Do not revert to root.
5. **Balance formula**: For bank/splitwise/receivable accounts, balance = `SUM(amount - fee)` over all transactions. Do not sum `amount` alone.
6. **Spending logic**: `splitwise_pay` expenses are counted at `amount + splitwise_partner_share` (the user's net share), not the full `amount`. This is intentional — see `get_monthly_spending` in crud.py.
## What NOT to do
- Do not add `async` to SQLAlchemy sessions or use `AsyncSession`. The engine is synchronous.
- Do not edit the SQLite file directly or run raw DDL. All schema changes go through Alembic.
- Do not add new columns to models without a matching Alembic migration.
- Do not bypass `api/client.ts` for API calls from the frontend.
- Do not delete a single leg of a transfer or settlement — delete both or implement a paired-delete helper.
- Do not add authentication/multi-user features without understanding the single-user assumptions baked into net worth calculation and account filtering.
- Do not call `fetch_all_prices` synchronously from a request handler on the hot path — it does blocking HTTP calls and sleeps for retries.
+38
View File
@@ -0,0 +1,38 @@
# fin
A self-hosted, single-user personal net worth tracker. fin aggregates bank accounts, investment portfolios, Splitwise balances, and receivables into a single EUR-denominated view, with daily automated snapshots and live stock prices.
## Features
- **Dashboard** — current net worth broken down by account group, plus recent transactions
- **Spending** — monthly expense breakdown by category with a pie chart
- **Portfolio** — investment account holdings with live USD prices (via yfinance), P&L per position, and EUR conversion
- **History** — net worth over time as a line chart; snapshots taken automatically at midnight
- **Transactions** — full transaction log across all accounts
- **Quick-add flows** — mobile-friendly sheet for logging expenses, transfers, Splitwise splits/settlements, and stock trades
- **Settings** — manage accounts (bank, investment, RSU, Splitwise, receivable) and spending categories; manual balance override per account
- **Multi-currency** — EUR base currency; exchange rates via Frankfurter API
## Quick Start
```bash
git clone <repo>
cd fin
docker compose up -d
```
The app is available at `http://localhost`.
## Environment / Config
Set in `docker-compose.yml` under the `api` service:
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | `sqlite:////data/fin.db` | SQLite path inside the container |
Data is persisted in a named Docker volume (`sqlite_data`). No other configuration is required.
## Architecture
The backend is a Python FastAPI app backed by SQLite via SQLAlchemy; APScheduler runs a nightly job to fetch stock prices (yfinance) and currency rates (Frankfurter) and record a net worth snapshot. The frontend is a React + TypeScript SPA served by Nginx, communicating with the API through the same Nginx container via reverse proxy.
@@ -0,0 +1,575 @@
# Implementation Plan: Investment & Income Features
Date: 2026-07-01
App: `fin` personal finance webapp
This plan covers four features:
1. Fractional shares — total-first investment buy
2. Trade currency selector (EUR/USD)
3. Cross-currency transfers
4. Income tracking (new QuickAdd flow + spending summary earned/net)
Each task is small and independently implementable. Frontend and backend
tasks are separated where they touch different layers. **No Alembic
migration is required for any task in this plan** — all needed columns
(`StockTransaction.shares` Float, `StockTransaction.currency`,
`Transaction.currency`, `Transaction.type='income'`) already exist.
Conventions to respect (from CLAUDE.md):
- All frontend API calls go through `web/src/api/client.ts` — never `fetch()` in a component.
- SQLAlchemy is sync only — no `async def`, no `AsyncSession`.
- No raw DDL, no new columns without a migration (none needed here).
- No `Co-Authored-By` trailer in commits.
- Balance formula for cash accounts is `SUM(amount - fee)`.
---
## Feature 1 — Fractional shares (total-first investment buy)
**Goal:** For `buy` trades on `investment`-type accounts (NOT `rsu`), replace
the "Shares" input with two inputs: "Total invested" + "Price per share".
Compute `shares = totalInvested / pricePerShare` (fractional float). `sell`
and `rsu_vest` keep the current share-quantity entry. Frontend only — no
backend, schema, or migration changes.
### Task 1: StockTradeFlow — total-first buy mode for investment accounts
**Files to change:**
- `web/src/components/QuickAdd/flows/StockTradeFlow.tsx`
**Changes:**
1. After computing `selectedAccountId`, derive the selected account object and
whether the total-first mode is active:
```ts
const selectedAccount = investmentAccounts.find(a => a.id === selectedAccountId)
const isTotalFirstBuy = tradeType === 'buy' && selectedAccount?.type === 'investment'
```
(Note: `rsu` accounts stay in share-entry mode even for `buy`.)
2. Add a new state for the total-invested input:
```ts
const [totalInvestedStr, setTotalInvestedStr] = useState('')
```
3. Replace the single derived `shares` computation. Compute `shares`
conditionally:
```ts
const price = parseFloat(priceStr) || 0
const fee = parseFloat(feeStr) || 0
const totalInvested = parseFloat(totalInvestedStr) || 0
const shares = isTotalFirstBuy
? (price > 0 ? totalInvested / price : 0)
: (parseFloat(sharesStr) || 0)
const totalCost = isTotalFirstBuy
? totalInvested + fee
: shares * price + fee
```
Keep `sharesStr` state for the non-total-first path.
4. In the JSX, render the "Shares" input only when `!isTotalFirstBuy`. When
`isTotalFirstBuy` is true, render a "Total Invested" input above the
"Price per Share" input, bound to `totalInvestedStr`:
```tsx
{isTotalFirstBuy ? (
<div>
<label className="text-xs text-muted-foreground mb-1 block">Total Invested ({currency})</label>
<Input type="text" inputMode="decimal" placeholder="0.00"
value={totalInvestedStr} onChange={e => setTotalInvestedStr(e.target.value)} />
</div>
) : (
<div>
<label className="text-xs text-muted-foreground mb-1 block">Shares</label>
<Input type="text" inputMode="decimal" placeholder="0.00"
value={sharesStr} onChange={e => setSharesStr(e.target.value)} />
</div>
)}
```
(The "Price per Share" input block stays as-is and is always shown.)
5. Update validation in `handleSave`. In total-first mode, validate
`totalInvested > 0` instead of `shares` directly:
```ts
if (isTotalFirstBuy) {
if (!totalInvested || totalInvested <= 0) { setError('Enter total invested'); return }
if (!price || price <= 0) { setError('Enter valid price per share'); return }
} else {
if (!shares || shares <= 0) { setError('Enter valid shares'); return }
if (!price || price <= 0) { setError('Enter valid price per share'); return }
}
```
The rest of `handleSave` already sends the computed `shares` — no change to
the API call bodies beyond using the conditionally-computed `shares`/`totalCost`.
6. Update the "Total: …" preview footer to display when
`(isTotalFirstBuy ? totalInvested > 0 : shares > 0) && price > 0`, showing
`totalCost` and, in total-first mode, the computed fractional share count,
e.g. `≈ 1.2345 shares`.
7. When switching `tradeType` away from `buy` (existing `onClick` handler on
the trade-type buttons), optionally clear `totalInvestedStr` for cleanliness
(not strictly required).
**No migration.** `shares` is already `Float`.
---
## Feature 2 — Trade currency selector (EUR/USD)
**Goal:** Add an EUR/USD currency selector to the stock trade form. Currently
`currency: 'USD'` is hardcoded in both `createFundedBuy` and
`createStockTransaction` calls. The `StockTransaction.currency` column already
exists; no backend change.
### Task 2: StockTradeFlow — currency selector
**Files to change:**
- `web/src/components/QuickAdd/flows/StockTradeFlow.tsx`
**Changes:**
1. Add currency state defaulting to USD:
```ts
const [currency, setCurrency] = useState<'USD' | 'EUR'>('USD')
```
2. Render a two-button toggle (styled like the existing Trade Type toggle)
below the Ticker field, above the shares/total inputs:
```tsx
<div>
<label className="text-xs text-muted-foreground mb-1 block">Currency</label>
<div className="flex gap-2">
{(['USD', 'EUR'] as const).map(c => (
<button key={c} onClick={() => setCurrency(c)}
className={`flex-1 py-2 rounded-lg border text-sm ${
currency === c ? 'border-primary bg-primary/10 font-medium' : 'border-border bg-background'
}`}>{c}</button>
))}
</div>
</div>
```
3. Replace the two hardcoded `currency: 'USD'` occurrences in `handleSave`
(inside `createFundedBuy` and `createStockTransaction`) with `currency`.
4. Update the "Price per Share (USD)" label to interpolate the selected
currency: `Price per Share ({currency})`. Update the "Total: … USD" preview
footer to use `{currency}` instead of the literal `USD`. If Task 1 is also
implemented, the "Total Invested ({currency})" label already reads from this
state.
**No backend change.** The API already accepts and persists `currency` on
`StockTransactionCreate` / `FundedBuyRequest`.
---
## Feature 3 — Cross-currency transfers
**Goal:** Allow transfers between accounts of different currencies. When source
and destination currencies differ, show two amount fields — "You send
(<from currency>)" and "They receive (<to currency>)". Same-currency transfers
behave exactly as today (single amount field). The two legs are written with
their respective amounts and currencies.
### Task 3: Backend schema — add cross-currency fields to TransferRequest
**Files to change:**
- `api/app/schemas.py`
**Changes:**
In `TransferRequest` (currently fields: `from_account_id`, `to_account_id`,
`amount`, `currency`, `fee`, `date`, `description`), add two optional fields:
```python
class TransferRequest(BaseModel):
from_account_id: int
to_account_id: int
amount: float
currency: str = "EUR"
fee: float = 0
date: date
description: str = "Transfer"
to_amount: Optional[float] = None # destination-leg amount when cross-currency
to_currency: Optional[str] = None # destination-leg currency when cross-currency
```
`Optional` and `date` are already imported at the top of the file. No migration.
### Task 4: Backend crud — cross-currency legs in create_transfer
**Files to change:**
- `api/app/crud.py`
**Changes:**
In `create_transfer`, the source leg keeps `amount=-req.amount` and
`currency=req.currency`. Compute the destination-leg values so that when
`req.to_amount` is provided they are used, otherwise fall back to the
same-currency behaviour:
```python
dest_amount = req.to_amount if req.to_amount is not None else req.amount
dest_currency = req.to_currency if req.to_currency is not None else req.currency
```
Then in the `to_leg` construction, replace `amount=req.amount` with
`amount=dest_amount` and `currency=req.currency` with `currency=dest_currency`.
Leave the source leg (`from_leg`), the atomic `db.add_all([...]); db.flush()`,
the reciprocal `linked_transaction_id` wiring, and the commit/refresh untouched
(invariant #1: atomic two-leg write).
Optionally, add validation in the router (`api/app/routers/transactions.py`,
`create_transfer` handler) so a positive `to_amount` is required to be > 0 when
present:
```python
if req.to_amount is not None and req.to_amount <= 0:
raise HTTPException(status_code=422, detail="Destination amount must be positive")
```
### Task 5: Frontend API client — extend CreateTransferParams
**Files to change:**
- `web/src/api/client.ts`
**Changes:**
Update the `createTransfer` inline parameter type (currently
`{ from_account_id: number; to_account_id: number; amount: number; currency?: string; fee?: number; date: string; description?: string }`)
to add the optional cross-currency fields:
```ts
createTransfer: (data: {
from_account_id: number; to_account_id: number; amount: number;
currency?: string; fee?: number; date: string; description?: string;
to_amount?: number; to_currency?: string;
}) =>
request<Transaction[]>('/transactions/transfer', { method: 'POST', body: JSON.stringify(data) }),
```
### Task 6: Frontend TransferFlow — dual-amount UI for cross-currency
**Files to change:**
- `web/src/components/QuickAdd/flows/TransferFlow.tsx`
**Changes:**
1. Remove the same-currency restriction on the destination filter. Change:
```ts
const destAccounts = fromAccount
? cashAccounts.filter(a => a.id !== fromAccountId && a.currency === fromAccount.currency)
: cashAccounts
```
to:
```ts
const destAccounts = fromAccount
? cashAccounts.filter(a => a.id !== fromAccountId)
: cashAccounts
```
Also remove the "No compatible destination accounts (must share the same
currency…)" empty-state message text — replace with a generic "No other
accounts available." (the `destAccounts.length === 0` branch can stay,
just update the copy).
2. Derive the destination account and cross-currency flag:
```ts
const toAccount = cashAccounts.find(a => a.id === toAccountId)
const isCrossCurrency = !!fromAccount && !!toAccount && fromAccount.currency !== toAccount.currency
```
3. Add state for the destination amount (used only in cross-currency mode):
```ts
const [toAmountStr, setToAmountStr] = useState('')
```
4. In the JSX, keep the existing single "Amount ({fromAccount?.currency})"
field but relabel it based on mode. When `isCrossCurrency`, label it
"You send ({fromAccount.currency})" and render a second field below it,
"They receive ({toAccount.currency})", bound to `toAmountStr`. When not
cross-currency, keep the current single "Amount" field and label.
Example:
```tsx
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{isCrossCurrency ? `You send (${fromAccount!.currency})` : `Amount (${fromAccount?.currency ?? 'EUR'})`}
</label>
<Input type="text" inputMode="decimal" placeholder="0.00"
value={amountStr} onChange={e => setAmountStr(e.target.value)} />
</div>
{isCrossCurrency && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">
They receive ({toAccount!.currency})
</label>
<Input type="text" inputMode="decimal" placeholder="0.00"
value={toAmountStr} onChange={e => setToAmountStr(e.target.value)} />
</div>
)}
```
5. In `handleSave`, validate the destination amount when cross-currency:
```ts
const amount = parseFloat(amountStr)
if (!amount || amount <= 0) { setError('Enter a valid amount'); return }
// ...existing from/to/self-transfer checks...
let toAmount: number | undefined
let toCurrency: string | undefined
if (isCrossCurrency) {
toAmount = parseFloat(toAmountStr)
if (!toAmount || toAmount <= 0) { setError('Enter the amount received'); return }
toCurrency = toAccount!.currency
}
```
Then pass `to_amount: toAmount` and `to_currency: toCurrency` into the
`api.createTransfer({ ... })` call alongside the existing fields. When not
cross-currency they are `undefined` and omitted from the JSON body, keeping
same-currency transfers identical to today.
**No migration.**
---
## Feature 4 — Income tracking
**Goal:** Add a new "Income" QuickAdd flow that records a positive `income`
transaction on a bank account. Extend the spending summary to report total
income earned and net (earned spent) for the period, and surface
"Earned | Spent | Net" on the Spending page.
Backend income query for a period:
`SUM(amount) WHERE type='income' AND account NOT IN (payable, receivable)`.
### Task 7: Frontend — new IncomeFlow component
**Files to change (new file):**
- `web/src/components/QuickAdd/flows/IncomeFlow.tsx`
**Changes:**
Create `IncomeFlow.tsx` modeled on `ExpenseFlow.tsx`. Same props interface
(`{ accounts, categories, onSuccess, onBack }`). Fields: amount, bank account
picker, description (optional), category (optional). Date is today in local
time.
Key differences from ExpenseFlow:
- Heading: "Income".
- Amount label: "Amount (EUR)".
- On save, post a **positive** amount with `type: 'income'`:
```ts
await api.createTransaction({
date: today,
account_id: selectedAccountId,
amount: Math.abs(amount), // positive
currency: 'EUR',
type: 'income',
category_id: selectedCategoryId ?? undefined,
description: description || undefined,
fee: 0,
})
```
- Use local-timezone date. Note `ExpenseFlow` uses
`new Date().toISOString().split('T')[0]` which is UTC. Per the feature spec
("date: local timezone"), compute the local date instead:
```ts
const d = new Date()
const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
```
- Category picker is optional (reuse ExpenseFlow's grid). Keep it — income can
be categorized (e.g. "Salary") if categories exist, but selection is not
required.
Reuse the same Tailwind/shadcn markup patterns as ExpenseFlow (Input,
button styling, error line, disabled-while-saving).
### Task 8: Frontend — register IncomeFlow in QuickAddSheet
**Files to change:**
- `web/src/components/QuickAdd/QuickAddSheet.tsx`
**Changes:**
1. Import the new flow: `import IncomeFlow from './flows/IncomeFlow'`.
2. Add `'income'` to the `FlowType` union.
3. In `buildFlows`, add an entry (place it right after `expense`):
```ts
{ id: 'income', label: 'Income', description: 'Record money received' },
```
4. In `SelectedFlow`'s `switch`, add:
```tsx
case 'income':
return <IncomeFlow {...props} />
```
The event-bus refresh (`onSuccess` → `fin:transaction-added`) is handled by the
existing parent wiring — no change needed.
### Task 9: Backend — income totals in get_monthly_spending and get_spending_summary
**Files to change:**
- `api/app/crud.py`
**Changes:**
1. In `get_monthly_spending`, after computing `overall_total` and before the
`return`, add an income query for the same `[_start, _end)` window,
respecting the optional `account_id` filter and excluding payable/receivable
accounts:
```python
income_query = db.query(
func.sum(models.Transaction.amount)
).join(
models.Account, models.Transaction.account_id == models.Account.id
).filter(
models.Transaction.date >= _start,
models.Transaction.date < _end,
models.Transaction.type == 'income',
models.Account.type.notin_(["payable", "receivable"]),
)
if account_id is not None:
income_query = income_query.filter(models.Transaction.account_id == account_id)
income_total = income_query.scalar() or 0.0
```
Extend the return dict:
```python
return {
'month': month,
'total': overall_total,
'income_total': income_total,
'categories': category_data,
}
```
2. In `get_spending_summary`, accumulate income across the iterated months.
Add `income_total = 0.0` next to `grand_total = 0.0`, then inside the month
loop add `income_total += ms['income_total']`. Include both per-period and
grand values in the output:
- In the per-period append, add `'income_total': ms['income_total']`.
- In the final return dict, add:
```python
'income_total': income_total,
'net_total': income_total - grand_total,
```
Final return dict shape:
```python
return {
'mode': mode,
'periods': periods,
'grand_total': grand_total,
'income_total': income_total,
'net_total': income_total - grand_total,
'categories': aggregated_categories,
}
```
**No migration.** The `income` type already exists on `Transaction.type`; the
opening-balance transactions created by `create_account` also use
`type='income'` — note that these will be counted as income. This matches the
spec (`SUM WHERE type='income'`); flag to the user if opening balances should
be excluded (they are not, per the given query).
### Task 10: Frontend — income types in API client
**Files to change:**
- `web/src/api/client.ts`
**Changes:**
Extend the read types to match the new backend response fields (optional for
backward safety, though the backend always returns them after Task 9):
```ts
export interface SpendingData {
month: string; total: number; income_total: number; categories: SpendingCategory[];
}
export interface SpendingSummaryPeriod {
period_label: string
total: number
income_total: number
categories: SpendingCategory[]
}
export interface SpendingSummaryData {
mode: string
periods: SpendingSummaryPeriod[]
grand_total: number
income_total: number
net_total: number
categories: SpendingCategory[]
}
```
No change to the `getSpending` / `getSpendingSummary` call signatures.
### Task 11: Frontend — Earned | Spent | Net on Spending page
**Files to change:**
- `web/src/pages/Spending.tsx`
**Changes:**
Render an "Earned | Spent | Net" strip in both the monthly and the
yearly/custom views, below the chart/summary total.
1. Monthly view: inside the `mode === 'monthly' && !loading && data && data.total > 0`
block (after the chart `</div>`, before the category list), add a three-cell
row driven by `data`:
```tsx
<div className="grid grid-cols-3 border-b text-center">
<div className="py-3">
<div className="text-xs text-muted-foreground">Earned</div>
<div className="font-medium text-green-600">{eurFormatter.format(data.income_total)}</div>
</div>
<div className="py-3">
<div className="text-xs text-muted-foreground">Spent</div>
<div className="font-medium text-red-600">{eurFormatter.format(data.total)}</div>
</div>
<div className="py-3">
<div className="text-xs text-muted-foreground">Net</div>
<div className="font-medium">{eurFormatter.format(data.income_total - data.total)}</div>
</div>
</div>
```
Note: the monthly view is only rendered when `data.total > 0`. If income
should show even when spending is zero, relax the guard to
`data && (data.total > 0 || data.income_total > 0)` and adjust the
empty-state condition accordingly (optional; flag to user).
2. Yearly/custom view: inside the `!loading && mode !== 'monthly' && summary`
block, add the same three-cell row driven by `summary.income_total`,
`summary.grand_total`, and `summary.net_total` (place it near the top of the
block, before the bar/pie charts or right after them).
Use `text-green-600` for earned, `text-red-600` for spent, and default color
for net (or conditionally green/red on `net >= 0`).
---
## Suggested implementation order
1. Feature 2, Task 2 (currency selector) — smallest, no cross-task deps.
2. Feature 1, Task 1 (total-first buy) — same file as Task 2; do after or
together to avoid churn in `StockTradeFlow.tsx`.
3. Feature 3, Tasks 3 → 4 → 5 → 6 (schema, crud, client, UI).
4. Feature 4, Tasks 7 → 8 (IncomeFlow + register), then 9 → 10 → 11
(backend totals, client types, Spending UI).
## Verification
- Backend: no migration to run. Restart the API container (or rely on the
Dockerfile CMD) — `alembic upgrade head` is a no-op for these changes.
- Manual checks:
- Stock trade: investment `buy` shows Total Invested + Price; computed
fractional shares persist; `rsu` `buy` and `sell` still show Shares.
- Currency toggle: a EUR trade persists `currency='EUR'` on the stock
transaction.
- Cross-currency transfer: from EUR account to USD account writes a EUR
debit leg and a USD credit leg with the entered amounts; same-currency
transfer unchanged (single amount field).
- Income: new flow posts positive `income`; Spending page shows
Earned/Spent/Net for monthly and yearly/custom modes.
## Notes / open questions to confirm with user
- Opening-balance transactions use `type='income'`; per the given query they
will be counted in "Earned". Confirm whether that is desired.
- Cross-currency transfers do not auto-fetch or store an FX rate on the legs
(`exchange_rate_used` left null); user enters both amounts manually. Confirm
this is acceptable vs. pre-filling from the live EUR/USD rate.
+3679
View File
File diff suppressed because it is too large Load Diff