Compare commits
2
Commits
43c90dcd43
...
ec21078bf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec21078bf8 | ||
|
|
548d640231 |
No files matched your search
@@ -58,7 +58,7 @@ cli/
|
||||
- **Name resolution**: `find_account` / `find_category` map a name to the integer id every other tool takes (exact match wins, else substring). Accounts and categories are also published as the resources `fin://accounts` and `fin://categories`. There is no `get_openapi_spec` tool — the tools already cover the API and the spec is too large to be worth a tool slot.
|
||||
- **Transport**: `FIN_MCP_TRANSPORT=stdio` (default, for clients that spawn the process) or `streamable-http`, which binds `FIN_MCP_HOST:FIN_MCP_PORT` and serves MCP at `FIN_MCP_PATH` (default `/mcp`). HTTP mode sets `stateless_http` so no session affinity is required.
|
||||
- **Deployment**: its own image (`cli/Dockerfile`) and its own Deployment + ClusterIP Service in k8s — it is not mounted into the API app. Reference manifest: `deploy/k8s/fin-mcp.yaml`. In-cluster endpoint is `http://fin-mcp.apps.svc.cluster.local:8000/mcp`.
|
||||
- **Tool params are typed**: enum-valued arguments use `Literal`, so the MCP client rejects bad values before the call. In particular `create_transaction` accepts only `expense`/`income` — the API itself accepts all six transaction types, and writing a `transfer` row through it would create an orphan leg with no `linked_account_id` (invariant #1). Two-leg writes must go through `create_transfer` / `create_settlement` / `create_split_expense` / `create_splitwise_i_paid`.
|
||||
- **Tool params are typed**: enum-valued arguments use `Literal`, so the MCP client rejects bad values before the call. In particular `create_transaction` accepts only `expense`/`income` — `POST /api/transactions` also accepts `splitwise_pay`/`splitwise_receive`, and rejects `transfer`/`settlement`, which would create an orphan leg with no `linked_account_id` (invariant #1). Two-leg writes must go through `create_transfer` / `create_settlement` / `create_split_expense` / `create_splitwise_i_paid`.
|
||||
|
||||
**Security**: `fin-mcp` has no auth of its own — it inherits whatever `FinClient` uses. With writes enabled it must stay unreachable from outside the cluster (no HTTPRoute) unless `FIN_API_TOKEN` is set on both the API and the MCP server.
|
||||
|
||||
@@ -81,7 +81,7 @@ cli/
|
||||
|
||||
`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).
|
||||
`Transaction` belongs to one account and has a `type`: `expense`, `income`, `transfer`, `splitwise_pay`, `splitwise_receive`, `settlement`, or `adjustment`. `adjustment` is a single-leg correction written only by the server — opening balances on account creation and `POST /api/accounts/{id}/adjust-balance`. It moves the balance but is excluded from spending and income, because it is neither consumption nor earnings. 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).
|
||||
|
||||
**Idempotency**: every write that moves money accepts an optional `client_ref` — `POST /api/transactions`, `/batch`, `/transfer` and `/settlement`. Every row of one write carries the same key, and the creators check `crud.find_by_client_ref` before writing, so retrying a timed-out request replays the original rows instead of double-booking. The index is deliberately **not** unique (a transfer writes two rows under one key); duplicate suppression is a check-then-write, which is sound because SQLite serialises writers and the scheduler never writes transactions. A NULL `client_ref` means "no idempotency requested" and never collides.
|
||||
|
||||
@@ -98,7 +98,7 @@ cli/
|
||||
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.
|
||||
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. Spending and income select rows by `type`, never by description: anything that is not real consumption or earnings (transfers, settlements, adjustments) gets its own type so those queries skip it.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""retype balance adjustments and opening balances as 'adjustment'
|
||||
|
||||
Revision ID: j0e1f2a3b4c5
|
||||
Revises: i9d0e1f2a3b4
|
||||
Create Date: 2026-09-30
|
||||
|
||||
POST /api/accounts/{id}/adjust-balance wrote its correction row as 'income'
|
||||
(delta > 0) or 'expense' (delta < 0), so every downward correction showed up
|
||||
as uncategorized spending and every upward one as income. Account creation
|
||||
wrote the opening balance as 'income' too, kept out of the income total only
|
||||
by a description filter. Neither is earnings or consumption — both are
|
||||
corrections to the ledger. New rows are written as 'adjustment'; this
|
||||
migration retypes the existing rows the same way.
|
||||
|
||||
Matched on the exact generated descriptions, only on unlinked income/expense
|
||||
rows. Balances are unaffected: SUM(amount - fee) ignores type.
|
||||
|
||||
Downgrade restores the old typing from the sign of the amount, which is exactly
|
||||
how the rows were originally typed.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "j0e1f2a3b4c5"
|
||||
down_revision: Union[str, None] = "i9d0e1f2a3b4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().execute(sa.text(
|
||||
"UPDATE transactions SET type = 'adjustment' "
|
||||
"WHERE type IN ('income', 'expense') "
|
||||
"AND linked_account_id IS NULL "
|
||||
"AND description IN ('Balance adjustment', 'Opening balance')"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().execute(sa.text(
|
||||
"UPDATE transactions "
|
||||
"SET type = CASE WHEN amount < 0 THEN 'expense' ELSE 'income' END "
|
||||
"WHERE type = 'adjustment'"
|
||||
))
|
||||
+3
-2
@@ -31,9 +31,11 @@ def create_account(db: Session, account: schemas.AccountCreate) -> models.Accoun
|
||||
db.add(db_account)
|
||||
db.flush() # assign the id for the opening transaction
|
||||
if initial and initial > 0:
|
||||
# 'adjustment', not 'income': an opening balance is money that was
|
||||
# already there, not money earned this month.
|
||||
opening = models.Transaction(
|
||||
account_id=db_account.id,
|
||||
type="income",
|
||||
type="adjustment",
|
||||
amount=initial,
|
||||
fee=0,
|
||||
currency=db_account.currency,
|
||||
@@ -799,7 +801,6 @@ def get_monthly_spending(
|
||||
models.Transaction.date < _end,
|
||||
models.Transaction.type == 'income',
|
||||
models.Account.type.notin_(["payable", "receivable"]),
|
||||
models.Transaction.description != 'Opening balance',
|
||||
)
|
||||
if account_id is not None:
|
||||
income_q = income_q.filter(models.Transaction.account_id == account_id)
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ class Transaction(Base):
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
currency = Column(Text, nullable=False, default="EUR")
|
||||
type = Column(Text, nullable=False) # expense|income|transfer|splitwise_pay|splitwise_receive|settlement
|
||||
type = Column(Text, nullable=False) # expense|income|transfer|splitwise_pay|splitwise_receive|settlement|adjustment
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
fee = Column(Float, nullable=False, default=0)
|
||||
|
||||
+12
-21
@@ -62,27 +62,18 @@ def adjust_balance(account_id: int, req: BalanceAdjustRequest, db: Session = Dep
|
||||
if abs(delta) < 0.001:
|
||||
raise HTTPException(status_code=422, detail="Balance already matches target")
|
||||
|
||||
if delta > 0:
|
||||
txn = models.Transaction(
|
||||
account_id=account_id,
|
||||
type="income",
|
||||
amount=delta,
|
||||
fee=0,
|
||||
currency=account.currency,
|
||||
description="Balance adjustment",
|
||||
date=_date.today(),
|
||||
)
|
||||
else:
|
||||
# Expense: store as negative amount so SUM(amount - fee) reduces the balance
|
||||
txn = models.Transaction(
|
||||
account_id=account_id,
|
||||
type="expense",
|
||||
amount=delta, # negative, matches the sign convention used throughout crud.py
|
||||
fee=0,
|
||||
currency=account.currency,
|
||||
description="Balance adjustment",
|
||||
date=_date.today(),
|
||||
)
|
||||
# Typed 'adjustment', not income/expense: a correction to the ledger is
|
||||
# neither earnings nor consumption, so spending and income must not see it.
|
||||
# The signed delta still moves SUM(amount - fee) onto the target.
|
||||
txn = models.Transaction(
|
||||
account_id=account_id,
|
||||
type="adjustment",
|
||||
amount=delta,
|
||||
fee=0,
|
||||
currency=account.currency,
|
||||
description="Balance adjustment",
|
||||
date=_date.today(),
|
||||
)
|
||||
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ class TransactionBase(BaseModel):
|
||||
account_id: int
|
||||
amount: float
|
||||
currency: str = "EUR"
|
||||
type: Literal["expense", "income", "transfer", "splitwise_pay", "splitwise_receive", "settlement"]
|
||||
type: Literal["expense", "income", "transfer", "splitwise_pay", "splitwise_receive", "settlement", "adjustment"]
|
||||
category_id: Optional[int] = None
|
||||
description: Optional[str] = Field(default=None, max_length=500)
|
||||
fee: float = 0
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Balance corrections — the 'adjustment' transaction type.
|
||||
|
||||
A balance adjustment or opening balance moves the account balance but is
|
||||
neither earnings nor consumption, so it must stay out of the spending and
|
||||
income totals. Before this type existed, a downward adjustment was written as
|
||||
an 'expense' and surfaced as uncategorized spending.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[2] / "api"
|
||||
|
||||
|
||||
def _bank(client, **extra):
|
||||
return client.create_account({"name": "Checking", "type": "bank", "currency": "EUR", **extra})
|
||||
|
||||
|
||||
def _balance(client, account_id):
|
||||
return sum(t["amount"] - t["fee"] for t in client.list_transactions(account_id=account_id))
|
||||
|
||||
|
||||
def _month():
|
||||
return date.today().strftime("%Y-%m")
|
||||
|
||||
|
||||
class TestAdjustBalance:
|
||||
def test_downward_adjustment_is_not_spending(self, client):
|
||||
acct = _bank(client, initial_balance=1000)
|
||||
|
||||
txn = client.adjust_account_balance(acct["id"], 700)
|
||||
|
||||
assert txn["type"] == "adjustment"
|
||||
assert txn["amount"] == -300
|
||||
assert _balance(client, acct["id"]) == 700
|
||||
spending = client.get_spending_month(_month())
|
||||
assert spending["total"] == 0
|
||||
assert spending["categories"] == []
|
||||
|
||||
def test_upward_adjustment_is_not_income(self, client):
|
||||
acct = _bank(client)
|
||||
|
||||
txn = client.adjust_account_balance(acct["id"], 250)
|
||||
|
||||
assert txn["type"] == "adjustment"
|
||||
assert _balance(client, acct["id"]) == 250
|
||||
assert client.get_spending_month(_month())["income_total"] == 0
|
||||
|
||||
def test_real_spending_still_counts_next_to_an_adjustment(self, client):
|
||||
acct = _bank(client, initial_balance=1000)
|
||||
client.create_transaction({
|
||||
"account_id": acct["id"], "date": date.today().isoformat(),
|
||||
"amount": -40, "type": "expense", "description": "Rewe",
|
||||
})
|
||||
|
||||
client.adjust_account_balance(acct["id"], 900)
|
||||
|
||||
assert client.get_spending_month(_month())["total"] == 40
|
||||
assert _balance(client, acct["id"]) == 900
|
||||
|
||||
|
||||
class TestOpeningBalance:
|
||||
def test_opening_balance_is_an_adjustment_and_not_income(self, client):
|
||||
acct = _bank(client, initial_balance=500)
|
||||
|
||||
[opening] = client.list_transactions(account_id=acct["id"])
|
||||
assert opening["type"] == "adjustment"
|
||||
assert opening["description"] == "Opening balance"
|
||||
assert client.get_spending_month(_month())["income_total"] == 0
|
||||
|
||||
|
||||
def _alembic(db_path, *args):
|
||||
env = {**os.environ, "DATABASE_URL": f"sqlite:///{db_path}"}
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", *args],
|
||||
cwd=API_DIR, env=env, check=True, capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
class TestMigration:
|
||||
def test_retypes_generated_rows_only_and_downgrades_by_sign(self, tmp_path):
|
||||
db = tmp_path / "mig.db"
|
||||
_alembic(db, "upgrade", "i9d0e1f2a3b4")
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(
|
||||
"INSERT INTO accounts (id, name, type, currency, is_active) "
|
||||
"VALUES (1, 'Checking', 'bank', 'EUR', 1), (2, 'Savings', 'bank', 'EUR', 1)"
|
||||
)
|
||||
rows = [
|
||||
# id, type, amount, description, linked_account_id
|
||||
(1, "income", 500, "Opening balance", None),
|
||||
(2, "expense", -300, "Balance adjustment", None),
|
||||
(3, "income", 20, "Balance adjustment", None),
|
||||
(4, "expense", -40, "Rewe", None),
|
||||
(5, "income", 2000, "Salary", None),
|
||||
(6, "transfer", -100, "Balance adjustment", 2),
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT INTO transactions (id, account_id, type, amount, fee, currency, description, "
|
||||
"linked_account_id, date) VALUES (?, 1, ?, ?, 0, 'EUR', ?, ?, '2026-09-01')",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
_alembic(db, "upgrade", "head")
|
||||
types = dict(sqlite3.connect(db).execute("SELECT id, type FROM transactions"))
|
||||
assert types == {
|
||||
1: "adjustment", 2: "adjustment", 3: "adjustment",
|
||||
4: "expense", 5: "income", 6: "transfer",
|
||||
}
|
||||
|
||||
_alembic(db, "downgrade", "i9d0e1f2a3b4")
|
||||
types = dict(sqlite3.connect(db).execute("SELECT id, type FROM transactions"))
|
||||
assert types == {r[0]: r[1] for r in rows}
|
||||
@@ -75,7 +75,7 @@ class TestAccounts:
|
||||
txns = client.list_transactions(account_id=acct["id"])
|
||||
assert len(txns) == 1
|
||||
assert txns[0]["amount"] == 250.0
|
||||
assert txns[0]["type"] == "income"
|
||||
assert txns[0]["type"] == "adjustment"
|
||||
|
||||
|
||||
class TestTransactions:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Design: `adjustment` transaction type
|
||||
|
||||
Date: 2026-09-30
|
||||
|
||||
## Overview
|
||||
|
||||
1. Stop balance corrections from counting as spending or income.
|
||||
2. Replace the description-string filter that kept opening balances out of income with a type.
|
||||
|
||||
## 1. Balance adjustments
|
||||
|
||||
`POST /api/accounts/{id}/adjust-balance` wrote its delta as `income` (delta > 0) or `expense` (delta < 0). `get_monthly_spending` counts every negative `expense` row, so a downward correction appeared as uncategorized spending, and an upward one as income.
|
||||
|
||||
**Change**: the row is written as `type = 'adjustment'` with the signed delta. Balances are unaffected — `SUM(amount - fee)` in `net_worth.py` and in the adjust endpoint ignores `type`. Spending (`type IN ('expense','splitwise_receive')` / `'splitwise_pay'`) and income (`type = 'income'`) select by type, so they skip it with no query change.
|
||||
|
||||
## 2. Opening balances
|
||||
|
||||
`crud.create_account` wrote the opening balance as `income`, and the income query excluded it with `description != 'Opening balance'`. It is now written as `adjustment` and that filter is removed.
|
||||
|
||||
## Schema
|
||||
|
||||
No DDL — `transactions.type` is free `Text`. `TransactionBase.type` (the read schema) gains `adjustment`. `TransactionCreate` does **not**: adjustments are written only by the server, so `POST /api/transactions` cannot create one.
|
||||
|
||||
## Migration `j0e1f2a3b4c5`
|
||||
|
||||
Data-only. Retypes rows where `type IN ('income','expense')`, `linked_account_id IS NULL`, and `description IN ('Balance adjustment','Opening balance')` — the exact generated descriptions. Must not touch linked legs or user-entered rows. Downgrade restores the typing from the sign of `amount`, which is how the rows were originally typed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Moving the adjust-balance write from the router into `crud.py` (architecture-contract weak point #2).
|
||||
- Frontend: `Transaction.type` is a plain `string` in `client.ts`, and adjustment rows stay editable like any unlinked row.
|
||||
+37
-4
@@ -10,9 +10,42 @@ export function todayLocalISO(): string {
|
||||
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.
|
||||
// Accepts both 12.40 and the German 12,40, which is what the de-DE formatting
|
||||
// used throughout the app trains you to type. Returns null when the input is
|
||||
// not a number at all. Zero and negatives are valid — a target balance or a
|
||||
// payable can legitimately be either.
|
||||
//
|
||||
// Pair this with <input type="text" inputMode="decimal">, never
|
||||
// <input type="number">: a number input silently reports value === "" for
|
||||
// anything it considers invalid, including a decimal comma, so the bad input
|
||||
// never reaches the parser and the caller sees an empty field instead.
|
||||
export function parseDecimal(input: string): number | null {
|
||||
const t = input.trim()
|
||||
if (t === '') return null
|
||||
|
||||
const lastComma = t.lastIndexOf(',')
|
||||
const lastDot = t.lastIndexOf('.')
|
||||
let normalised: string
|
||||
if (lastComma !== -1 && lastDot !== -1) {
|
||||
// Both separators present, so the rightmost one is the decimal point and
|
||||
// the other is thousands grouping. Without this, "1.000,50" would parse
|
||||
// as 1 — a silent 1000x error on a balance field.
|
||||
normalised = lastComma > lastDot
|
||||
? t.replace(/\./g, '').replace(',', '.')
|
||||
: t.replace(/,/g, '')
|
||||
} else {
|
||||
normalised = t.replace(',', '.')
|
||||
}
|
||||
|
||||
// Number() rather than parseFloat(): parseFloat stops at the first bad
|
||||
// character, so "12.5abc" would quietly become 12.5.
|
||||
const n = Number(normalised)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
// Same, but rejects zero and negatives — for fields that must be a 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
|
||||
const n = parseDecimal(input)
|
||||
return n !== null && n > 0 ? n : null
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { api, Account, Category } from '@/api/client'
|
||||
import { parseDecimal } from '@/lib/utils'
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate()
|
||||
@@ -90,10 +91,16 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
const saveAdjustBalance = async () => {
|
||||
if (!adjustingAccount || !adjustTargetBalance) return
|
||||
if (!adjustingAccount) return
|
||||
// Zero and negative targets are legitimate, so parseDecimal, not parseAmount.
|
||||
const target = parseDecimal(adjustTargetBalance)
|
||||
if (target === null) {
|
||||
setMutationError('Enter a target balance, e.g. 1234.56')
|
||||
return
|
||||
}
|
||||
setMutationError(null)
|
||||
try {
|
||||
await api.adjustAccountBalance(adjustingAccount.id, parseFloat(adjustTargetBalance))
|
||||
await api.adjustAccountBalance(adjustingAccount.id, target)
|
||||
setAdjustingAccount(null)
|
||||
setAdjustTargetBalance('')
|
||||
window.dispatchEvent(new CustomEvent('fin:transaction-added'))
|
||||
@@ -263,8 +270,8 @@ export default function Settings() {
|
||||
value={adjustTargetBalance}
|
||||
onChange={e => setAdjustTargetBalance(e.target.value)}
|
||||
placeholder="Target balance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
Reference in new issue
Block a user