115 Commits
Author SHA1 Message Date
EislM0203andClaude Opus 5.5 ec21078bf8 fix: balance adjustments counted as spending and income — add 'adjustment' type
POST /api/accounts/{id}/adjust-balance wrote its delta as 'expense' (down)
or 'income' (up), so every downward correction surfaced as uncategorized
spending in get_monthly_spending, and every upward one as income. Opening
balances were written as 'income' too, kept out of the income total only by
a description != 'Opening balance' filter.

Backend:
- adjust_balance (routers/accounts.py) and create_account (crud.py) now
  write type='adjustment' with the signed amount. Balances are unaffected:
  SUM(amount - fee) ignores type.
- get_monthly_spending drops the description filter; spending and income
  select purely by type.
- TransactionBase gains 'adjustment' for reads; TransactionCreate does not,
  so only the server can write one.
- Migration j0e1f2a3b4c5 retypes historical rows: unlinked income/expense
  rows with the exact generated descriptions. Downgrade restores the
  typing from the sign of the amount.

Tests: cli/tests/test_adjustment.py covers spending/income exclusion, the
balance landing on target, and the migration + downgrade on a scratch DB.
test_cli's opening-balance assertion updated to the new type. 65 passed.

Docs: CLAUDE.md type list and invariant 6; spec in
docs/superpowers/specs/2026-09-30-adjustment-transaction-type.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:10:45 +00:00
EislM0203andClaude Opus 5 548d640231 fix: balance adjustment silently did nothing on a decimal comma
Pressing Apply on Settings -> Adjust balance did nothing at all: no error, no
request, nothing in the API logs. The cause is in code untouched since
062ba52 (2026-07-01), so this predates the recent releases — it just needs
the right input to show itself.

The form only renders when adjustingAccount?.id === account.id, so
adjustingAccount is never null while Apply is visible. That leaves exactly
one way for the handler to return silently:

    if (!adjustingAccount || !adjustTargetBalance) return

i.e. adjustTargetBalance === "". And the field was <input type="number">,
which reports value === "" for anything the browser judges invalid —
including a decimal comma. Every amount in this app is rendered with
Intl.NumberFormat('de-DE'), so typing 1234,56 is the natural thing to do, and
it left the state empty and the guard swallowed the click.

Fixes both halves:

- The input is now type="text" inputMode="decimal", so what the user types
  actually reaches the handler.
- The guard reports "Enter a target balance, e.g. 1234.56" instead of
  returning silently. A control that does nothing when pressed is the worst
  possible failure mode.

Adds parseDecimal() alongside parseAmount() in lib/utils.ts. Balance targets
differ from expense rows: zero and negative are legitimate, so parseAmount's
n > 0 rule would have been wrong here.

parseDecimal is also stricter than the parseFloat(s.replace(',', '.')) idiom
used across the flows, in two ways that matter on a balance field:

- "1.000,50" parsed as 1 under the old idiom — a silent 1000x error. It now
  reads the rightmost separator as the decimal point, giving 1000.50, and
  handles "1,000.50" the same way.
- "12.5abc" quietly became 12.5 under parseFloat. Number() rejects it.

parseAmount is redefined on top of parseDecimal, so the batch expense grid
picks up both improvements.

Verified: tsc + vite build clean; the 60 backend tests still pass (untouched);
parser checked against .-decimal, ,-decimal, both-separator, negative, zero,
whitespace and trailing-garbage inputs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-23 14:00:07 +00:00
EislM0203andClaude Opus 5 43c90dcd43 fix(mcp): trust the deployed hostname under streamable-http
FastMCP enables DNS rebinding protection inside __init__ when it is built with
a loopback bind address, and create_server() takes the default 127.0.0.1:

    if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
        transport_security = TransportSecuritySettings(
            enable_dns_rebinding_protection=True,
            allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
        )

main() reassigns settings.host afterwards, which never revisits that decision,
so the allow-list stayed loopback-only. Any client reaching the server under a
real hostname got 421 Misdirected Request with "Invalid Host header". It reads
as a hang rather than a rejection, because clients retry until they time out --
bifrost reported fin as simply disconnected after exhausting its retries.

Rebuild the settings after the host is known. Protection stays on; the loopback
patterns are kept and FIN_MCP_ALLOWED_HOSTS adds to them (comma separated,
"host:*" allows any port). Unlisted hosts are still refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTuguQqazhoWUsh2GkPg9a
2026-08-23 14:23:29 +02:00
EislM0203andClaude Opus 5 631bdbb13f docs: correct tool count in the fin-mcp manifest comment
find_account/find_category were added and get_openapi_spec dropped after this
comment was written, so the counts are 15 read / 39 total, not 14 / 38.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-23 07:14:13 +00:00
EislM0203andClaude Opus 5 cb97d97d81 docs: check in the fin skill library, ignore local Claude settings
Twelve skills that had been sitting uncommitted in the working tree: the
architecture contract, accounting reference, change-control rulebook, failure
archaeology, debugging playbook, diagnostics scripts, ledger-analysis toolkit,
research frontier, run/operate runbook, build-and-env, test-coverage campaign,
and validation/QA guide.

Updates three frontier statuses to match what actually shipped today rather
than checking in a roadmap that describes closed work as open:

- A1 double-entry hardening -> partly-done. The create half is enforced; what
  remains is running the existing audit_linked_legs.py against a prod backup.
- B3 safety rails -> partly-done. Idempotency shipped, including the note that
  the index is not unique-when-not-null and why. dry_run is still open.
- D3 CSV import -> unblocked but still gated. Its stated blocker was B3's
  idempotency keys, which now exist.

Also gitignores .claude/settings.local.json, which is per-machine and was
showing up as untracked on every status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-22 22:09:22 +00:00
EislM0203andClaude Opus 5 c9a1375fb9 fix: close the orphan-leg hole and add idempotency keys (frontier A1 + B3)
Two long-standing correctness gaps, both flagged in fin-research-frontier.

A1 — POST /api/transactions could write half a linked pair

  TransactionCreate inherited the full six-value type Literal and accepted a
  free-standing linked_account_id, so any client could create a lone
  `transfer` row with no partner. That is exactly the balance corruption
  invariant #1 exists to prevent: the orphan counts toward SUM(amount - fee)
  and paired-delete can never find a partner to remove it with.

  TransactionCreate now narrows type to the single-leg kinds and rejects a
  caller-supplied linked_account_id. TransactionBase stays wide on purpose —
  reads must keep describing historical rows of every type.

  splitwise_pay deliberately stays allowed. The two-leg creator uses that
  type, but a standalone row carrying splitwise_partner_share is also a
  legitimate record, and is what `fin tx add --type splitwise_pay
  --partner-share` writes. Narrowing to expense/income would have broken it.

B3 — no idempotency, so any retry double-booked

  Migration i9d0e1f2a3b4 adds a nullable client_ref to transactions.
  POST /api/transactions, /batch, /transfer and /settlement accept it, and
  crud.find_by_client_ref is checked before writing, so retrying a timed-out
  request replays the original rows instead of booking them again. NULL means
  "no idempotency requested" and never collides.

  Deviation from the frontier's sketch, which called for unique-when-not-null:
  the index is NOT unique. A transfer writes two rows and a batch writes many,
  all under one key, so uniqueness cannot live on the column. Suppression is a
  check-then-write inside the creators instead — sound here because SQLite
  serialises writers and the scheduler never writes transactions.

  Exposed on the MCP create_transaction/create_transfer/create_settlement
  tools, since a retrying agent is the case B3 was written about. The server
  instructions no longer tell agents that writes are non-idempotent.

Tests: 60 passing, up from 47. Thirteen new cover retries collapsing to one
write across all four endpoints, differing keys staying separate, NULL keys
not colliding, and the orphan-leg guard refusing transfer/settlement/manual
linkage while the four legitimate single-leg types still work.

Migration verified both directions on a scratch DB seeded at the previous
head: existing rows survive with NULL client_ref, index is created, and
downgrade drops cleanly with rows intact. Verified live over HTTP that a
transfer submitted three times with one client_ref yields one pair of legs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-22 22:08:54 +00:00
EislM0203andClaude Opus 5 3eb5fdbe55 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
2026-08-22 21:57:26 +00:00
EislM0203andClaude Opus 5 439a15264b feat: fin-mcp as a deployable HTTP service, with typed tools and name resolution
Makes the MCP server runnable as a network service and hardens its tool
surface. Previously it was stdio-only and had never been executed — the
test file had never run, and 3 of its 5 tests failed on first invocation.

Transport and deployment

  FIN_MCP_TRANSPORT selects stdio (default, unchanged) or streamable-http,
  which binds FIN_MCP_HOST:FIN_MCP_PORT and serves MCP at FIN_MCP_PATH
  (default /mcp) with stateless_http so no session affinity is needed.

  Ships as its own image (cli/Dockerfile) and its own Deployment +
  ClusterIP Service (deploy/k8s/fin-mcp.yaml) rather than being mounted
  into the API app — matching how the other MCP servers in the cluster are
  deployed, and keeping the mcp dependency out of the api image. The
  manifest is a reference to copy into the Flux repo; it is not applied
  from here.

  The manifest deliberately has no HTTPRoute. fin-mcp has no auth of its
  own and the API runs with FIN_API_TOKEN unset, so with writes enabled
  the only thing keeping it safe is being unreachable from outside the
  cluster.

Correctness

  create_transaction accepted a free-form type string while
  POST /api/transactions accepts all six transaction types. A caller
  passing type=transfer would write a single leg with no
  linked_account_id — an orphan that corrupts balances and that
  paired-delete can never clean up. All eight enum-valued parameters are
  now Literal-typed, so the client rejects such calls before they are
  made. TestInvariantGuards locks this down.

  FinClient was constructed eagerly in create_server(), so a missing
  FIN_API_URL killed the process before the MCP handshake and surfaced to
  the client only as "server failed to start". It is now built lazily on
  first use, turning the same misconfiguration into a readable error.

Tool surface

  Adds find_account / find_category, which map a name to the integer id
  every other tool requires — exact match wins, otherwise all substring
  matches are returned so the caller can disambiguate. Publishes accounts
  and categories as the resources fin://accounts and fin://categories.
  Drops get_openapi_spec: the tools already cover the API and the spec is
  too large to justify a tool slot.

  The three update_* tools took an untyped dict[str, Any], which produced
  a schema with no properties at all; they now take explicit optional
  parameters, plus clear_* flags so the ability to null a field survives
  the change.

  Removes ~48 lines of write-warning boilerplate duplicated across 24
  tools — it lives in the server instructions once, and no longer claims
  refresh_prices can double-book. Fixes fetch_ticker_price claiming it
  blocks for minutes when the client times out at 30s, and
  deactivate_account flagging itself destructive while documenting itself
  as reversible.

Tests: 37 passing, up from 27 passing / 3 failing. The failures were
harness bugs — mcp >=1.12 returns (content, structured) from call_tool
where the helper assumed a list. Verified against a live API over both
transports: handshake, reads, writes, name resolution, resource reads,
and rejection of the transfer type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-22 21:20:39 +00:00
EislM0203andClaude Sonnet 4.6 082b6081b7 feat: fin CLI + agent skill + opt-in API bearer auth
Adds `fin` — a typer/httpx CLI for the deployed API — so Claude Code and
other agents can consume fin over HTTP without an MCP server.

Backend:
- api/app/auth.py: require_api_token dependency (no-op unless FIN_API_TOKEN
  env is set; RFC 7235 case-insensitive Bearer, constant-time compare). All
  nine routers wired; /api/health exempt for k8s probes.
- GET /api/openapi.json added as an explicit token-gated route (FastAPI's
  built-in openapi_url bypasses dependency injection).
- GET /api/transactions gains an optional `limit` query param (ge=1).

CLI (cli/):
- fin_cli/client.py: FinClient — sync httpx wrapper, zero typer/rich imports
  (MCP-ready core for a future MCP server).
- Subcommands: networth, accounts, categories, tx, transfer, settle,
  split-expense, splitwise-paid, reconcile, holdings, stock, rsu,
  spending, prices. Every command supports --json for agent use.
- fin tx add refuses transfer/settlement types to prevent single-leg writes.
- Config via FIN_API_URL (required) and FIN_API_TOKEN (optional).

Agent skill: .claude/skills/fin/SKILL.md — command map, jq patterns, and
domain invariants (two-leg atomicity, net-share splitwise math, derived
holdings, funded-buy, soft-deactivate, FIN_API_TOKEN web-UI lockout warning).

Tests: 25 e2e tests (FinClient → real routes → temp SQLite) covering auth
on/off, all key invariants (two-leg linkage, fee-in-balance, net-share math),
transfer whole-group delete, and linked-leg edit rejection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPBt3s7Jyr2nozQTrVVGA4
2026-07-03 19:22:15 +00:00
EislM0203andClaude Sonnet 4.6 e36d719cbe fix: stock trades and receivable/payable settles no longer inflate spending/income
Asset movements between own accounts (buy/sell cash leg, reconciling a
receivable or payable) were typed 'expense'/'income', so they showed up in
the spending and income metrics. They are now typed 'transfer'/'settlement',
which the spending queries already exclude.

Backend:
- funded-buy/sell bank leg now typed 'transfer' (linked to investment account)
  instead of 'expense' — buys no longer inflate spending
- FundedBuyRequest accepts type 'sell' in addition to 'buy'; the sell leg
  credits proceeds (shares × price − fee) to the bank account
- /reconcile legs retyped to 'settlement' — collecting a receivable no longer
  inflates income, paying off a payable no longer inflates spending
- RSU vest picks currency from PriceCache instead of hardcoding 'USD'
- create_account writes account + opening balance in one atomic commit
- create_split_expense links the expense row to the transfer legs so deleting
  any one of the three rows deletes the whole group
- delete_transaction follows linked_transaction_id in both directions to
  collect and delete the full linked group atomically
- splitwise-i-paid rejects partner_share >= total_amount (422)
- Migration h8c9d0e1f2a3 retypes historical rows the same way

Frontend:
- StockTradeFlow: sell now shows bank-account selector ('Credit proceeds…');
  selector shown for both buy and sell, hidden only for rsu_vest
- Portfolio Add Trade form: same buy/sell bank-account selector added,
  routes to createFundedBuy when an account is chosen
- Portfolio price-fetch handlers guard against stale ticker closures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:27:39 +00:00
EislM0203andClaude Fable 5 bc256f6c6e fix: bug hunt findings — currency derivation, local dates, atomic split expense, RSU vest unlink
Frontend:
- Derive transaction currency from the selected account instead of
  hardcoding EUR in SplitwiseIPaid/SplitwiseSettle/SplitwiseShePayed/IOwe
  flows; add currency-mismatch guards and dynamic labels (IAmOwed too)
- Replace toISOString().split('T')[0] with local-time todayLocalISO()
  helper in Dashboard, Portfolio, Spending and all QuickAdd flows —
  UTC dates booked trades/vests/settles to yesterday before ~1-2 AM
- SplitExpenseFlow now calls the new atomic /transactions/split-expense
  endpoint instead of two sequential requests that could partially fail
- Round partner_share to cents and reject 0% split client-side
- Portfolio: don't render accounts when holdings fetch failed
- client.ts: deleteAccount returns the Account, not void

Backend:
- New POST /api/transactions/split-expense writing expense + both
  transfer legs in one commit
- delete_transaction legacy fallback now matches date/type and closest
  id, so it can no longer delete a leg of a different transfer between
  the same two accounts
- delete_stock_transaction reverts any grant vested through the deleted
  transaction to pending, fixing the undeletable vested-grant deadlock
  (422 on grant delete, FK 500 on transaction delete)
- FundedBuyRequest.type constrained to Literal['buy'] (422 instead of 500)
- calculate_net_worth logs a warning before falling back to EUR/USD=1.0
- Scheduler engine applies PRAGMA foreign_keys=ON and uses proper URL
  parsing via db_config helpers
- fetch_all_prices counts only real updates; stale retry exception no
  longer logged after a clean no-data result

Infra:
- nginx: Cache-Control no-cache on index.html so deploys aren't served
  from stale browser caches
- api Dockerfile: COPY --chown=app:app

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uL1qrmNyCP8Bmybns1qTQ
2026-07-01 21:06:19 +00:00
EislM0203andClaude Fable 5 3c2766e98f chore: migrate npm dependencies from Dynatrace mirror to public registry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uL1qrmNyCP8Bmybns1qTQ
2026-07-01 21:06:19 +00:00
Markus Eisl 42308a1476 fix: remove unused hasReceivable prop and variable from Dashboard 2026-07-01 13:21:24 +02:00
markus.eisl 9aae3e8aa4 fix 2026-07-01 13:19:04 +02:00
Markus Eisl 1e2ad89d64 fix: remove Split Expense from bank card actions, too cluttered 2026-07-01 13:14:32 +02:00
Markus Eisl 37bc10159f fix: currency mismatch guard in SplitExpense, net_worth override conversion, onOpenChange state reset, preview on mode switch 2026-07-01 13:10:50 +02:00
Markus Eisl 00409f0769 fix: review findings — SplitExpense userShare guard, initialAccountId, local-time dates, Split on bank cards, Splitwise accountId 2026-07-01 13:05:54 +02:00
Markus Eisl 30edddb4cb feat: add quick-action links on account cards (Expense/Income, Trade, I Paid) 2026-07-01 12:50:43 +02:00
Markus Eisl a3d9da7313 fix: accept comma as decimal separator in all QuickAdd amount inputs 2026-07-01 12:41:16 +02:00
Markus Eisl 35988fe3b5 feat: add Split Expense QuickAdd flow (user share as expense + partner share to receivable) 2026-07-01 12:24:14 +02:00
Markus Eisl 0496a58298 feat: show native currency on dashboard cards, use account currency in expense/income flows 2026-07-01 11:29:25 +02:00
Markus Eisl c4a47c888c feat: store and display stock/ETF name from yfinance shortName 2026-07-01 11:19:52 +02:00
Markus Eisl c96f72d44d fix: wrap transaction filters instead of horizontal scroll to remove spurious scrollbar 2026-07-01 11:16:22 +02:00
Markus Eisl c5fbcf34f0 feat: display account cards in a 2-col grid (3-col on wider screens) 2026-07-01 11:09:06 +02:00
Markus Eisl 0b5ba7c76c feat: refresh prices every 5min, add API_UPSTREAM to docker-compose 2026-07-01 09:27:41 +02:00
Markus Eisl 90bfdbd0a8 fix: shorten withholding label to Tax % and narrow column 2026-07-01 09:22:44 +02:00
Markus Eisl c8351188ce fix: withholding_pct defaults to 51% in pot form, add UI field, fix hardcoded 0 2026-07-01 09:19:48 +02:00
Markus Eisl e61b254ccc fix: include payable account expenses in spending calculations 2026-07-01 09:14:07 +02:00
Markus Eisl babea72e4a feat: show unvested RSU value (after withholding) greyed out on dashboard 2026-07-01 09:12:57 +02:00
Markus Eisl bc8c8d5499 fix: auto-discover exchange suffix for European ETFs in price fetcher 2026-07-01 09:02:50 +02:00
Markus Eisl 89d241a985 chore: upgrade npm to v11 before npm ci to fix Alpine exit-handler bug 2026-07-01 08:27:47 +02:00
Markus Eisl bfe7590bb2 chore: use npm ci with lockfile in web Dockerfile for reproducible builds 2026-07-01 08:21:59 +02:00
Markus Eisl 98ddc978bd chore: add gitignore entries, commit CLAUDE.md, README, and lockfile 2026-07-01 08:21:04 +02:00
Markus Eisl 9eadff1c28 chore: remove unused categories prop from IAmOwedFlow 2026-07-01 08:19:50 +02:00
Markus Eisl 062ba524fa feat: category type toggle and two-section display in Settings 2026-07-01 08:14:21 +02:00
Markus Eisl fd26786787 feat: filter category pickers by type, add category picker to IOweFlow 2026-07-01 08:14:04 +02:00
Markus Eisl 16a19af3e2 feat: IAmOwedFlow records bank→receivable transfer instead of income transaction 2026-07-01 08:13:04 +02:00
Markus Eisl 309b8ae2da feat: add type field to Category interface in client.ts
Also passes type:'expense' default in Settings.tsx createCategory call
to satisfy the now-required type field in the Category interface.
2026-07-01 08:11:44 +02:00
Markus Eisl 07ad335090 feat: add type column to categories (expense/income) with migration 2026-07-01 08:11:02 +02:00
Markus Eisl 196bef0104 docs: implementation plan for typed categories and IAmOwedFlow redesign 2026-07-01 08:09:11 +02:00
Markus Eisl e86df27637 docs: spec for typed categories, IOweFlow category, IAmOwedFlow transfer redesign 2026-07-01 08:06:55 +02:00
Markus Eisl 0a97ad0699 feat: Earned/Spent/Net strip on Spending page 2026-07-01 07:32:14 +02:00
Markus Eisl 9a49be2886 feat: cross-currency transfer UI with FX rate pre-fill 2026-07-01 07:32:04 +02:00
Markus Eisl a72b052682 feat: client.ts cross-currency transfer types, income totals, FX rate method 2026-07-01 07:31:43 +02:00
Markus Eisl f14c5233d7 feat: total-first investment buy, trade currency selector 2026-07-01 07:30:39 +02:00
Markus Eisl b2a7c7a719 feat: cross-currency transfer backend, income totals in spending, FX rate endpoint 2026-07-01 07:30:28 +02:00
Markus Eisl ed3febad10 feat: income QuickAdd flow 2026-07-01 07:30:14 +02:00
EislM0203 d6001dc90b fixes 2026-07-01 07:10:33 +02:00
Markus Eisl 81091fc6d4 fix: remove 1.0 FX fallback — skip upsert when rate unavailable and no cache exists 2026-06-30 20:36:49 +02:00
Markus Eisl 54d64e0e63 fix: Literal type validation for transaction/account types, readable 422 error messages 2026-06-30 20:36:40 +02:00