chore: add gitignore entries, commit CLAUDE.md, README, and lockfile
This commit is contained in:
+4
-1
@@ -1,2 +1,5 @@
|
||||
.superpowers/
|
||||
**/__pycache__/
|
||||
**/__pycache__/
|
||||
api/fin.db
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Generated
+3679
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user