From b9ea3dd2f0bd4066b1ca2e262bcf648db5921f9a Mon Sep 17 00:00:00 2001
From: 31b4
Date: Sat, 17 Oct 2026 00:46:02 +0200
Subject: [PATCH 01/53] Make investment transfer pairs reciprocal and atomic
---
AGENTS.md | 4 +-
CHANGELOG.md | 5 +
README.md | 4 +-
.../017-investment-transfer-links.sql | 40 +++
api/src/controllers/transaction.controller.ts | 6 +-
api/src/controllers/transfer.controller.ts | 2 +-
api/src/dtos/investment-transaction.dto.ts | 1 +
api/src/errors/codes.ts | 1 +
.../mappers/investment-transaction.mapper.ts | 1 +
api/src/models/InvestmentTransaction.ts | 1 +
.../investment-transaction.repository.ts | 17 +-
.../investment-transfer.repository.ts | 139 ++++++++++
.../transaction-history.repository.ts | 2 +-
.../repositories/transaction.repository.ts | 23 --
.../investment-transaction.service.ts | 20 ++
api/src/services/transaction.service.ts | 104 ++++----
api/src/services/transfer.service.ts | 88 +++----
api/src/tests/account-locking.test.ts | 2 +-
api/src/tests/upcoming-transactions.test.ts | 2 +-
.../dashboard-module/TransactionList.test.tsx | 27 ++
.../src/components/investments-module/data.ts | 3 +
client/src/hooks/useFinanceData.ts | 4 +-
client/src/test/useFinanceData.test.ts | 12 +
client/src/test/useInvestmentData.test.ts | 8 +
mcp/README.md | 2 +
mcp/src/types.ts | 1 +
tests/integration/data-export.test.ts | 10 +-
.../investment-transfer-links.test.ts | 247 ++++++++++++++++++
tests/integration/migrations.test.ts | 46 ++++
29 files changed, 677 insertions(+), 145 deletions(-)
create mode 100644 api/migrations/017-investment-transfer-links.sql
create mode 100644 api/src/repositories/investment-transfer.repository.ts
create mode 100644 tests/integration/investment-transfer-links.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index 8084262c..d8c4138b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -123,7 +123,7 @@ The API defaults to private mode, verifying Cloudflare Access JWT signature, iss
- **Posted ledger vs. pending work:** normal transaction lists, pagination, and actual analytics use posted rows. Future-dated or explicitly pending ordinary transactions do not affect stored balances. Editing a pending transaction keeps it pending; declining marks it cancelled without a balance change.
- **Confirmation:** `TransactionRepository.confirmPendingAndApplyBalance` uses a guarded D1 batch to apply an ordinary pending row once. Linked MCP transfer review pairs use `resolvePendingTransferPair` to confirm or decline both legs together and apply both balance deltas once; other linked transfers cannot use pending confirmation. Preserve repeat/concurrent-confirmation behavior and the prohibition on confirming future dates. Date decisions use the browser's local-calendar `X-Client-Date`, with UTC fallback in the API; do not replace this with an unconditional UTC date.
- **Review drafts vs. upcoming projections:** MCP drafts have `status='pending'`, `pending_kind='mcp_review'`, and `review_source='chatgpt_mcp'`. They affect neither balances nor projections until manually confirmed in the app. Projection code must opt in with `pending_kind === 'upcoming'`, rather than include all pending rows. Keep review provenance and flags through edits and confirmation. See `client/src/lib/transaction-review.ts`.
-- **Signs and transfers:** cash income is positive and expenses are negative. A regular transfer has linked debit/credit rows; cash-to-investment transfers link a cash row to an investment transaction. Preserve both sides and account balance/quantity deltas when editing or deleting. Linked transfers are excluded from income/expense aggregates, but still matter when reconstructing account balances.
+- **Signs and transfers:** cash income is positive and expenses are negative. A regular transfer has linked debit/credit rows; cash-to-investment purchases store reciprocal `linked_transaction_id` values on the cash row and investment transaction (standalone trades keep a null link). `InvestmentTransferRepository` batches linked creation, edits, deletion, balance/quantity deltas, and audit entries with snapshot/account guards. Edit through the cash transaction; deleting either side removes the pair once. Reject inconsistent legacy pairs rather than mutating one side. `/transfers` rejects investment sources until a proper linked-sale workflow is implemented. Linked transfers are excluded from income/expense aggregates, but still matter when reconstructing account balances.
- **Investments:** `investment_transactions` is active buy/sell history; do not remove or consolidate it based on the misleading comment in migration 001. Market-priced investment balances represent share/coin quantities, while manual asset values follow their own valuation path. Distinguish holding-unit `currency`, fiat `quote_currency`, unit price, and converted cash value. API, client, and MCP have differing legacy paths; trace the actual path instead of assuming their valuation behavior is identical.
- **Archive:** `accounts.archived_at` is independent of locks/exclusions. Archive requires zero balance/holding and no pending rows; it atomically pauses source/destination schedules and records an audit entry. Restore leaves schedules paused. Keep archived accounts in historical reads/exports, omit them from active selectors/current valuations, and require restore before account/ledger mutation. Database triggers guard both Workers. Permanent account deletion blocks linked transfers to avoid orphaning the other leg.
- **Locks:** persisted `accounts.is_locked` and API lock/unlock endpoints are authoritative. Preserve source and destination lock checks before mutations and the scheduler's skip behavior. Existing coverage varies by endpoint; test the path being changed rather than assume a database-wide guarantee.
@@ -160,7 +160,7 @@ Read `mcp/README.md` for the complete tool/workflow contract. `src/index.ts` han
## Migrations and deployment
-- The schema is the ordered sequence in `api/migrations/`, currently through `016-account-archive.sql`. Budgets are retired; earlier migrations remain necessary history. Add the next sequential `NNN-description.sql`; never rewrite an applied migration.
+- The schema is the ordered sequence in `api/migrations/`, currently through `017-investment-transfer-links.sql`. Budgets are retired; earlier migrations remain necessary history. Add the next sequential `NNN-description.sql`; never rewrite an applied migration.
- Deployment tracks executed filenames in `migration_history`; local setup rebuilds and applies the full sequence. This is a custom runner, not Wrangler's built-in migration ledger. Use `IF EXISTS`/`IF NOT EXISTS` where supported, but do not assume one-time `ALTER TABLE ADD COLUMN` migrations can be replayed safely.
- Check source-revision triggers when adding/changing financial tables so forecasts become stale correctly. Validate both a fresh schema and the upgrade from the preceding schema when relevant.
- Keep `.deploy-config`, actual Wrangler configs, `.dev.vars`, `.env` credentials, local database state, and database backups out of commits and tool output. Use tracked example files for documentation. Never hardcode real account IDs, secrets, personal financial data, or deployment identifiers in tests.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24fe26f6..52f2b0a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,11 @@ Intermediate package/UI version bumps are grouped under the next published GitHu
### Changed
- Dashboard uses a shared period picker, a responsive balance/activity summary, compact transaction filters, and a combined review queue with explicit confirmation actions. Transaction rows show neutral transfers, daily cash-flow totals, and native/converted amounts; privacy masking and loading states follow the new layout.
+- Investment purchases store reciprocal links between the cash ledger and investment history. Linked creation, edits, and deletion batch both records, balances, and audit entries atomically; deletion from either side reverses the pair once. History, portfolio reads, MCP activity, and JSON exports preserve the investment-side link. Inconsistent legacy links and unsupported investment-source transfers are rejected before changing balances.
+
+### Migration and upgrade notes
+
+- Migration 017 adds the nullable investment-side cash link and backfills unambiguous active and archived history without changing financial values or source revision. Standalone trades remain unlinked; ambiguous or invalid legacy relationships are preserved for explicit repair. Apply the migration before deploying the updated API, client, or MCP.
## v3.3 — 2026-10-15
diff --git a/README.md b/README.md
index d7116165..1e88a8b2 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,9 @@ Cash income is positive and expenses are negative. Posted transactions affect ac
Future-dated ordinary transactions remain pending until confirmed. Confirmation is guarded against duplicate balance updates and cannot post a future-dated transaction. The client sends its local calendar date in `X-Client-Date` so the API and browser agree on what is due.
-Transfers link both sides of a movement and are excluded from income/expense aggregates. Cash-to-investment transfers link the cash transaction to a buy/sell record in `investment_transactions`.
+Transfers link both sides of a movement and are excluded from income/expense aggregates. Cash-to-investment purchases store reciprocal `linked_transaction_id` values on the cash transaction and its buy record in `investment_transactions`. Standalone investment records keep this field null. Linked purchase creation, edits, and deletion update both records, native cash/holding balances, and audit entries in one guarded D1 batch. Edit the pair through its cash transaction; deleting either record removes the pair and reverses both movements once.
+
+Migration 017 backfills unambiguous existing investment links without changing financial values, balances, or financial-source revision. Ambiguous or inconsistent legacy links remain unchanged and require repair before pair mutation. The `/transfers` endpoint accepts cash-to-investment purchases; investment-source transfers are rejected because that older path did not record a corresponding sale. A linked sale-entry workflow remains follow-up Investments work.
### Investments and currencies
diff --git a/api/migrations/017-investment-transfer-links.sql b/api/migrations/017-investment-transfer-links.sql
new file mode 100644
index 00000000..9dc62ad3
--- /dev/null
+++ b/api/migrations/017-investment-transfer-links.sql
@@ -0,0 +1,40 @@
+-- Cash rows already point to investment trades. Add the reverse link without
+-- rebuilding either ledger or changing any recorded amounts/quantities.
+ALTER TABLE investment_transactions ADD COLUMN linked_transaction_id TEXT REFERENCES transactions(id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_investment_transactions_cash_link
+ ON investment_transactions(linked_transaction_id) WHERE linked_transaction_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_transactions_linked_transaction_id ON transactions(linked_transaction_id);
+
+-- Link metadata is not a financial-source change. Keep the existing revision
+-- behavior for all pre-existing investment fields, including notes.
+DROP TRIGGER IF EXISTS financial_revision_investment_transactions_update;
+CREATE TRIGGER financial_revision_investment_transactions_update
+AFTER UPDATE ON investment_transactions
+WHEN OLD.id IS NOT NEW.id OR OLD.account_id IS NOT NEW.account_id OR OLD.type IS NOT NEW.type
+ OR OLD.quantity IS NOT NEW.quantity OR OLD.price IS NOT NEW.price OR OLD.total_amount IS NOT NEW.total_amount
+ OR OLD.date IS NOT NEW.date OR OLD.notes IS NOT NEW.notes OR OLD.created_at IS NOT NEW.created_at
+BEGIN
+ UPDATE financial_data_revision SET revision = revision + 1,
+ updated_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000 WHERE id = 1;
+END;
+
+-- Archived history needs the same metadata repair. Restore its read-only guard
+-- immediately after the backfill; no account or financial fields are changed.
+DROP TRIGGER IF EXISTS archived_investment_transactions_update;
+UPDATE investment_transactions AS i SET linked_transaction_id = (
+ SELECT t.id FROM transactions t JOIN accounts a ON a.id = t.account_id
+ WHERE t.linked_transaction_id = i.id AND a.type != 'investment'
+ AND t.status = 'posted' AND t.date = i.date
+ AND ((i.type = 'buy' AND t.amount < 0) OR (i.type = 'sell' AND t.amount > 0))
+)
+WHERE (SELECT COUNT(*) FROM transactions t WHERE t.linked_transaction_id = i.id) = 1
+ AND EXISTS (SELECT 1 FROM accounts a WHERE a.id = i.account_id AND a.type = 'investment')
+ AND NOT EXISTS (SELECT 1 FROM transactions t WHERE t.id = i.id)
+ AND NOT EXISTS (SELECT 1 FROM investment_transactions other JOIN transactions t ON t.id = other.id
+ WHERE t.linked_transaction_id = i.id);
+
+CREATE TRIGGER archived_investment_transactions_update
+BEFORE UPDATE ON investment_transactions
+WHEN EXISTS (SELECT 1 FROM accounts WHERE id = NEW.account_id AND archived_at IS NOT NULL)
+ OR EXISTS (SELECT 1 FROM accounts WHERE id = OLD.account_id AND archived_at IS NOT NULL)
+BEGIN SELECT RAISE(ABORT, 'ACCOUNT_ARCHIVED'); END;
diff --git a/api/src/controllers/transaction.controller.ts b/api/src/controllers/transaction.controller.ts
index 1345113a..9c6283cb 100644
--- a/api/src/controllers/transaction.controller.ts
+++ b/api/src/controllers/transaction.controller.ts
@@ -74,7 +74,7 @@ export class TransactionController {
const id = c.req.param('id')
const body = await c.req.json()
const transaction = await this.transactionService.updateTransaction(id, body, this.getClientDate(c))
- if (!(transaction.linked_transaction_id && transaction.status === 'pending' && transaction.pending_kind === 'mcp_review')) {
+ if (!transaction.auditRecorded && !(transaction.linked_transaction_id && transaction.status === 'pending' && transaction.pending_kind === 'mcp_review')) {
await new AuditRepository(c.env.DB).log('UPDATE', 'transaction', id, { amount: body.amount, category_id: body.category_id })
}
return c.json(TransactionMapper.toResponseDto(transaction))
@@ -94,8 +94,8 @@ export class TransactionController {
async delete(c: Context<{ Bindings: Bindings }>) {
try {
const id = c.req.param('id')
- await this.transactionService.deleteTransaction(id)
- await new AuditRepository(c.env.DB).log('DELETE', 'transaction', id)
+ const auditRecorded = await this.transactionService.deleteTransaction(id)
+ if (!auditRecorded) await new AuditRepository(c.env.DB).log('DELETE', 'transaction', id)
return c.json({ success: true })
} catch (error: any) {
if (error instanceof AppError || error.message?.includes('ACCOUNT_')) throw error
diff --git a/api/src/controllers/transfer.controller.ts b/api/src/controllers/transfer.controller.ts
index 87fcc521..455958d8 100644
--- a/api/src/controllers/transfer.controller.ts
+++ b/api/src/controllers/transfer.controller.ts
@@ -25,7 +25,7 @@ export class TransferController {
async create(c: Context) {
try {
- const body = await c.req.json()
+ const body = c.get('validatedBody') as CreateTransferDto
const transfer = await this.transferService.createTransfer(body)
return c.json(transfer, 201)
} catch (error: any) {
diff --git a/api/src/dtos/investment-transaction.dto.ts b/api/src/dtos/investment-transaction.dto.ts
index 1b825699..c3e2312d 100644
--- a/api/src/dtos/investment-transaction.dto.ts
+++ b/api/src/dtos/investment-transaction.dto.ts
@@ -19,5 +19,6 @@ export interface InvestmentTransactionResponseDto {
total_amount: number
date: string
notes?: string
+ linked_transaction_id: string | null
created_at: number
}
diff --git a/api/src/errors/codes.ts b/api/src/errors/codes.ts
index 1595bb98..fa3d13c8 100644
--- a/api/src/errors/codes.ts
+++ b/api/src/errors/codes.ts
@@ -21,6 +21,7 @@ export const ErrorCode = {
// Transfer errors
TRANSFER_SAME_ACCOUNT: 'TRANSFER_SAME_ACCOUNT',
+ INVESTMENT_TRANSFER_CONFLICT: 'INVESTMENT_TRANSFER_CONFLICT',
EXCHANGE_RATE_UNAVAILABLE: 'EXCHANGE_RATE_UNAVAILABLE',
// Recurring schedule errors
diff --git a/api/src/mappers/investment-transaction.mapper.ts b/api/src/mappers/investment-transaction.mapper.ts
index c8924272..3cd053ba 100644
--- a/api/src/mappers/investment-transaction.mapper.ts
+++ b/api/src/mappers/investment-transaction.mapper.ts
@@ -12,6 +12,7 @@ export class InvestmentTransactionMapper {
total_amount: transaction.total_amount,
date: transaction.date,
notes: transaction.notes,
+ linked_transaction_id: transaction.linked_transaction_id ?? null,
created_at: transaction.created_at
}
}
diff --git a/api/src/models/InvestmentTransaction.ts b/api/src/models/InvestmentTransaction.ts
index 485f8400..70713100 100644
--- a/api/src/models/InvestmentTransaction.ts
+++ b/api/src/models/InvestmentTransaction.ts
@@ -9,5 +9,6 @@ export interface InvestmentTransaction {
total_amount: number
date: string
notes?: string
+ linked_transaction_id?: string | null
created_at: number
}
diff --git a/api/src/repositories/investment-transaction.repository.ts b/api/src/repositories/investment-transaction.repository.ts
index bdc77b91..68f57cad 100644
--- a/api/src/repositories/investment-transaction.repository.ts
+++ b/api/src/repositories/investment-transaction.repository.ts
@@ -1,7 +1,12 @@
import { InvestmentTransaction } from '../models/InvestmentTransaction'
+import { InvestmentTransferRepository } from './investment-transfer.repository'
export class InvestmentTransactionRepository {
- constructor(private db: D1Database) {}
+ readonly transfers: InvestmentTransferRepository
+
+ constructor(private db: D1Database) {
+ this.transfers = new InvestmentTransferRepository(db)
+ }
async findAll(): Promise {
const { results } = await this.db.prepare(
@@ -22,9 +27,14 @@ export class InvestmentTransactionRepository {
.bind(id).first()
}
+ async findByCashTransactionId(id: string): Promise {
+ return this.db.prepare('SELECT * FROM investment_transactions WHERE linked_transaction_id = ?')
+ .bind(id).first()
+ }
+
async create(transaction: InvestmentTransaction): Promise {
await this.db.prepare(
- 'INSERT INTO investment_transactions (id, account_id, type, quantity, price, total_amount, date, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ 'INSERT INTO investment_transactions (id, account_id, type, quantity, price, total_amount, date, notes, created_at, linked_transaction_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).bind(
transaction.id,
transaction.account_id,
@@ -34,7 +44,8 @@ export class InvestmentTransactionRepository {
transaction.total_amount,
transaction.date,
transaction.notes || null,
- transaction.created_at
+ transaction.created_at,
+ transaction.linked_transaction_id ?? null
).run()
}
diff --git a/api/src/repositories/investment-transfer.repository.ts b/api/src/repositories/investment-transfer.repository.ts
new file mode 100644
index 00000000..286ede3c
--- /dev/null
+++ b/api/src/repositories/investment-transfer.repository.ts
@@ -0,0 +1,139 @@
+import type { InvestmentTransaction } from '../models/InvestmentTransaction'
+import type { Transaction } from '../models/Transaction'
+import { TransactionRepository, type RawTransactionRow } from './transaction.repository'
+
+export type InvestmentTransferUpdate = {
+ cashAccountId: string; investmentAccountId: string; cashAmount: number
+ quantity: number; price: number; date: string
+ description: string | null; notes: string | null
+}
+
+// Every statement after the claim is conditional on its unique token. D1 runs
+// the batch as one transaction: failures roll back the claim, balances, records,
+// and audit together; concurrent edits cannot apply a stale pair snapshot.
+export class InvestmentTransferRepository {
+ constructor(private db: D1Database) {}
+
+ async findCashLinks(investmentId: string): Promise {
+ const { results } = await this.db.prepare('SELECT * FROM transactions WHERE linked_transaction_id = ?')
+ .bind(investmentId).all()
+ return results.map(row => TransactionRepository.mapRow(row))
+ }
+
+ private audit(action: 'CREATE' | 'UPDATE' | 'DELETE', cashId: string, investmentId: string, now: number,
+ guard: string, values: (string | number)[], details: Record) {
+ return [['transaction', cashId], ['investment_transaction', investmentId]].map(([entity, id]) =>
+ this.db.prepare(`INSERT INTO audit_log (id, action, entity, entity_id, details, created_at)
+ SELECT ?, ?, ?, ?, ?, ? WHERE ${guard}`)
+ .bind(crypto.randomUUID(), action, entity, id,
+ JSON.stringify({ ...details, cash_transaction_id: cashId, investment_transaction_id: investmentId }), now, ...values))
+ }
+
+ private snapshot(cash: Transaction, trade: InvestmentTransaction) {
+ return { cash_account_id: cash.account_id, cash_amount: cash.amount, investment_account_id: trade.account_id,
+ type: trade.type, quantity: trade.quantity, price: trade.price, total_amount: trade.total_amount,
+ date: trade.date, description: cash.description ?? null, notes: trade.notes ?? null }
+ }
+
+ async create(cash: Transaction, trade: InvestmentTransaction, now: number): Promise {
+ const exists = 'EXISTS (SELECT 1 FROM transactions WHERE id = ? AND linked_transaction_id = ?)'
+ const guard = [cash.id, trade.id]
+ const results = await this.db.batch([
+ this.db.prepare(`INSERT INTO transactions
+ (id, account_id, amount, description, date, linked_transaction_id, status, confirmed_at, created_at, updated_at)
+ SELECT ?, ?, ?, ?, ?, ?, 'posted', ?, ?, ?
+ WHERE EXISTS (SELECT 1 FROM accounts c JOIN accounts i ON i.id = ?
+ WHERE c.id = ? AND c.type != 'investment' AND i.type = 'investment'
+ AND COALESCE(c.is_locked, 0) = 0 AND COALESCE(i.is_locked, 0) = 0
+ AND c.archived_at IS NULL AND i.archived_at IS NULL)`)
+ .bind(cash.id, cash.account_id, cash.amount, cash.description ?? null, cash.date, trade.id, now, now, now, trade.account_id, cash.account_id),
+ this.db.prepare(`INSERT INTO investment_transactions
+ (id, account_id, type, quantity, price, total_amount, date, notes, created_at, linked_transaction_id)
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE ${exists}`)
+ .bind(trade.id, trade.account_id, trade.type, trade.quantity, trade.price, trade.total_amount, trade.date, trade.notes ?? null, now, cash.id, ...guard),
+ this.db.prepare(`UPDATE accounts SET balance = balance + ?, updated_at = ? WHERE id = ? AND ${exists}`)
+ .bind(cash.amount, now, cash.account_id, ...guard),
+ this.db.prepare(`UPDATE accounts SET balance = balance + ?, updated_at = ? WHERE id = ? AND ${exists}`)
+ .bind(trade.type === 'buy' ? trade.quantity : -trade.quantity, now, trade.account_id, ...guard),
+ ...this.audit('CREATE', cash.id, trade.id, now, exists, guard, { after: this.snapshot(cash, trade) }),
+ ])
+ // D1 counts trigger writes too (for example the source revision update).
+ return results[0].meta.changes > 0
+ }
+
+ private claim(cash: Transaction, trade: InvestmentTransaction, token: string, next: Pick) {
+ return this.db.prepare(`UPDATE transactions SET status = ? WHERE id = ? AND status = 'posted'
+ AND account_id = ? AND amount = ? AND date = ? AND description IS ? AND updated_at IS ?
+ AND linked_transaction_id = ?
+ AND EXISTS (SELECT 1 FROM investment_transactions i
+ JOIN accounts old_cash ON old_cash.id = transactions.account_id
+ JOIN accounts old_investment ON old_investment.id = i.account_id
+ JOIN accounts new_cash ON new_cash.id = ?
+ JOIN accounts new_investment ON new_investment.id = ?
+ WHERE i.id = ? AND i.linked_transaction_id = transactions.id AND i.account_id = ?
+ AND i.type = ? AND i.quantity = ? AND i.price = ? AND i.total_amount = ? AND i.date = ? AND i.notes IS ?
+ AND i.date = transactions.date
+ AND ((i.type = 'buy' AND transactions.amount < 0) OR (i.type = 'sell' AND transactions.amount > 0))
+ AND old_cash.type != 'investment' AND new_cash.type != 'investment'
+ AND old_investment.type = 'investment' AND new_investment.type = 'investment'
+ AND old_cash.archived_at IS NULL AND old_investment.archived_at IS NULL
+ AND new_cash.archived_at IS NULL AND new_investment.archived_at IS NULL
+ AND COALESCE(old_cash.is_locked, 0) = 0 AND COALESCE(old_investment.is_locked, 0) = 0
+ AND COALESCE(new_cash.is_locked, 0) = 0 AND COALESCE(new_investment.is_locked, 0) = 0)
+ AND 1 = (SELECT COUNT(*) FROM transactions other WHERE other.linked_transaction_id = ?)`)
+ .bind(token, cash.id, cash.account_id, cash.amount, cash.date, cash.description ?? null, cash.updated_at ?? null,
+ trade.id, next.cashAccountId, next.investmentAccountId, trade.id, trade.account_id,
+ trade.type, trade.quantity, trade.price, trade.total_amount, trade.date, trade.notes ?? null, trade.id)
+ }
+
+ async update(cash: Transaction, trade: InvestmentTransaction, next: InvestmentTransferUpdate, now: number): Promise {
+ const token = `investment-edit:${crypto.randomUUID()}`
+ const exists = 'EXISTS (SELECT 1 FROM transactions WHERE id = ? AND status = ?)'
+ const guard = [cash.id, token]
+ const sign = trade.type === 'buy' ? 1 : -1
+ const deltas = new Map()
+ const add = (id: string, amount: number) => deltas.set(id, (deltas.get(id) ?? 0) + amount)
+ add(cash.account_id, -cash.amount)
+ add(trade.account_id, -sign * trade.quantity)
+ add(next.cashAccountId, next.cashAmount)
+ add(next.investmentAccountId, sign * next.quantity)
+ const results = await this.db.batch([
+ this.claim(cash, trade, token, next),
+ ...[...deltas].filter(([, delta]) => delta !== 0).map(([id, delta]) =>
+ this.db.prepare(`UPDATE accounts SET balance = balance + ?, updated_at = ? WHERE id = ? AND ${exists}`)
+ .bind(delta, now, id, ...guard)),
+ this.db.prepare(`UPDATE investment_transactions SET account_id = ?, quantity = ?, price = ?, total_amount = ?, date = ?, notes = ?
+ WHERE id = ? AND linked_transaction_id = ? AND ${exists}`)
+ .bind(next.investmentAccountId, next.quantity, next.price, next.quantity * next.price, next.date, next.notes, trade.id, cash.id, ...guard),
+ ...this.audit('UPDATE', cash.id, trade.id, now, exists, guard, {
+ before: this.snapshot(cash, trade),
+ after: { cash_account_id: next.cashAccountId, cash_amount: next.cashAmount, investment_account_id: next.investmentAccountId,
+ type: trade.type, quantity: next.quantity, price: next.price, total_amount: next.quantity * next.price,
+ date: next.date, description: next.description, notes: next.notes },
+ }),
+ this.db.prepare(`UPDATE transactions SET account_id = ?, amount = ?, description = ?, date = ?,
+ category_id = NULL, exclude_from_estimate = 0, updated_at = ?, status = 'posted'
+ WHERE id = ? AND status = ?`)
+ .bind(next.cashAccountId, next.cashAmount, next.description, next.date, now, ...guard),
+ ])
+ return results[0].meta.changes > 0
+ }
+
+ async delete(cash: Transaction, trade: InvestmentTransaction, now: number): Promise {
+ const token = `investment-delete:${crypto.randomUUID()}`
+ const exists = 'EXISTS (SELECT 1 FROM transactions WHERE id = ? AND status = ?)'
+ const guard = [cash.id, token]
+ const results = await this.db.batch([
+ this.claim(cash, trade, token, { cashAccountId: cash.account_id, investmentAccountId: trade.account_id }),
+ this.db.prepare(`UPDATE accounts SET balance = balance - ?, updated_at = ? WHERE id = ? AND ${exists}`)
+ .bind(cash.amount, now, cash.account_id, ...guard),
+ this.db.prepare(`UPDATE accounts SET balance = balance - ?, updated_at = ? WHERE id = ? AND ${exists}`)
+ .bind(trade.type === 'buy' ? trade.quantity : -trade.quantity, now, trade.account_id, ...guard),
+ ...this.audit('DELETE', cash.id, trade.id, now, exists, guard, { before: this.snapshot(cash, trade) }),
+ this.db.prepare(`DELETE FROM investment_transactions WHERE id = ? AND linked_transaction_id = ? AND ${exists}`)
+ .bind(trade.id, cash.id, ...guard),
+ this.db.prepare('DELETE FROM transactions WHERE id = ? AND status = ?').bind(...guard),
+ ])
+ return results[0].meta.changes > 0
+ }
+}
diff --git a/api/src/repositories/transaction-history.repository.ts b/api/src/repositories/transaction-history.repository.ts
index de710915..780e4999 100644
--- a/api/src/repositories/transaction-history.repository.ts
+++ b/api/src/repositories/transaction-history.repository.ts
@@ -9,7 +9,7 @@ type HistoryRow = HistoryPosition & { payload: string }
const cashColumns = ['id', 'account_id', 'category_id', 'amount', 'description', 'date',
'linked_transaction_id', 'exclude_from_estimate', 'status', 'pending_kind', 'review_source',
'review_batch_id', 'review_flags', 'confirmed_at', 'cancelled_at', 'created_at', 'updated_at']
-const investmentColumns = ['id', 'account_id', 'type', 'quantity', 'price', 'total_amount', 'date', 'notes', 'created_at']
+const investmentColumns = ['id', 'account_id', 'type', 'quantity', 'price', 'total_amount', 'date', 'notes', 'created_at', 'linked_transaction_id']
const jsonColumns = (columns: string[]) => columns.map(column => `'${column}', t.${column}`).join(', ')
export class TransactionHistoryRepository {
diff --git a/api/src/repositories/transaction.repository.ts b/api/src/repositories/transaction.repository.ts
index 034f8714..579bf333 100644
--- a/api/src/repositories/transaction.repository.ts
+++ b/api/src/repositories/transaction.repository.ts
@@ -151,29 +151,6 @@ export class TransactionRepository {
await this.db.prepare('DELETE FROM transactions WHERE id = ?').bind(id).run()
}
- async deleteInvestmentTransferAndRevertBalances(id: string, investmentId: string, now: number): Promise {
- // Read deltas from rows still present inside the batch. Concurrent/repeated
- // deletion cannot apply a stale balance delta, and a failed statement rolls
- // back both balances and both ledger records.
- await this.db.batch([
- this.db.prepare(`UPDATE accounts SET
- balance = balance - (SELECT amount FROM transactions WHERE id = ?), updated_at = ?
- WHERE id = (SELECT account_id FROM transactions WHERE id = ? AND linked_transaction_id = ? AND status = 'posted')`
- ).bind(id, now, id, investmentId),
- this.db.prepare(`UPDATE accounts SET balance = balance - (
- SELECT CASE WHEN type = 'buy' THEN quantity ELSE -quantity END
- FROM investment_transactions WHERE id = ?
- ), updated_at = ?
- WHERE id = (SELECT account_id FROM investment_transactions WHERE id = ?)
- AND EXISTS (SELECT 1 FROM transactions WHERE id = ? AND linked_transaction_id = ?)`
- ).bind(investmentId, now, investmentId, id, investmentId),
- this.db.prepare(`DELETE FROM investment_transactions WHERE id = ?
- AND EXISTS (SELECT 1 FROM transactions WHERE id = ? AND linked_transaction_id = ?)`
- ).bind(investmentId, id, investmentId),
- this.db.prepare('DELETE FROM transactions WHERE id = ? AND linked_transaction_id = ?').bind(id, investmentId),
- ])
- }
-
async deleteByAccountId(accountId: string): Promise {
await this.db.prepare('DELETE FROM transactions WHERE account_id = ?').bind(accountId).run()
}
diff --git a/api/src/services/investment-transaction.service.ts b/api/src/services/investment-transaction.service.ts
index 8a44460a..7c48699d 100644
--- a/api/src/services/investment-transaction.service.ts
+++ b/api/src/services/investment-transaction.service.ts
@@ -64,6 +64,26 @@ export class InvestmentTransactionService {
async deleteInvestmentTransaction(id: string): Promise {
// Get investment transaction to revert balance
const invTx = await this.investmentTransactionRepo.findById(id)
+ if (!invTx) return
+
+ const cashLinks = await this.investmentTransactionRepo.transfers.findCashLinks(id)
+ if (invTx.linked_transaction_id || cashLinks.length) {
+ if (cashLinks.length !== 1 || cashLinks[0].id !== invTx.linked_transaction_id) {
+ if (!await this.investmentTransactionRepo.findById(id)) return
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Investment transfer links are inconsistent; refresh and repair the pair before deleting.', 409)
+ }
+ for (const accountId of new Set([invTx.account_id, cashLinks[0].account_id])) {
+ const account = await this.accountRepo.findById(accountId)
+ if (!account) throw AppError.notFound('ACCOUNT_NOT_FOUND', 'Account not found')
+ if (account.archived_at != null) throw new AppError('ACCOUNT_ARCHIVED', 'Restore this account before making changes.', 409)
+ if (account.is_locked) throw new AppError('ACCOUNT_LOCKED', 'Unlock this account before making changes.', 409)
+ }
+ const applied = await this.investmentTransactionRepo.transfers.delete(cashLinks[0], invTx, Date.now())
+ if (!applied && await this.investmentTransactionRepo.findById(id)) {
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Investment transfer changed; refresh and try again.', 409)
+ }
+ return
+ }
if (invTx) {
// Revert balance by subtracting the quantity that was added
diff --git a/api/src/services/transaction.service.ts b/api/src/services/transaction.service.ts
index b54f1554..e72ac4a0 100644
--- a/api/src/services/transaction.service.ts
+++ b/api/src/services/transaction.service.ts
@@ -9,6 +9,8 @@ import { CreateTransactionDto, UpdateTransactionDto } from '../dtos/transaction.
import { fetchPriceFromYahoo } from '../utils/yahoo-finance.util'
import { PaginatedResponse, PaginationHelper, PaginationParams } from '../models/Pagination'
+type TransactionMutationResult = Transaction & { auditRecorded?: boolean }
+
export class TransactionService {
constructor(
private transactionRepo: TransactionRepository,
@@ -63,6 +65,13 @@ export class TransactionService {
this.assertAccountUnlocked(account)
+ if (dto.linked_transaction_id) {
+ if (this.resolveCreateStatus(dto, today) === 'pending') {
+ throw new Error('Linked transfers cannot be pending; use the /transfers endpoint')
+ }
+ throw AppError.validation('Create linked transfers through the /transfers endpoint so both records are saved together')
+ }
+
// For investment accounts: redirect to investment_transactions table
if (account.type === 'investment') {
const quantity = dto.amount
@@ -114,10 +123,6 @@ export class TransactionService {
const now = Date.now()
const status = this.resolveCreateStatus(dto, today)
- if (dto.linked_transaction_id && status === 'pending') {
- throw new Error('Linked transfers cannot be pending; use the /transfers endpoint')
- }
-
const transaction: Transaction = {
id,
account_id: dto.account_id,
@@ -148,7 +153,7 @@ export class TransactionService {
return transaction
}
- async updateTransaction(id: string, dto: UpdateTransactionDto, today?: string): Promise {
+ async updateTransaction(id: string, dto: UpdateTransactionDto, today?: string): Promise {
// Get old transaction to adjust balance
const oldTx = await this.transactionRepo.findById(id)
@@ -159,6 +164,10 @@ export class TransactionService {
if ((oldTx.status || 'posted') === 'cancelled') {
throw new Error('Cancelled transaction cannot be edited')
}
+ const investmentLink = await this.investmentTransactionRepo.findByCashTransactionId(id)
+ if (investmentLink && investmentLink.id !== oldTx.linked_transaction_id) {
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Investment transfer links are inconsistent; repair the pair before editing.', 409)
+ }
if (oldTx.linked_transaction_id) {
if (!this.isPosted(oldTx)) {
@@ -171,14 +180,18 @@ export class TransactionService {
}
const linkedTx = await this.transactionRepo.findById(oldTx.linked_transaction_id)
+ const linkedInvestmentTx = await this.investmentTransactionRepo.findById(oldTx.linked_transaction_id)
+ if (linkedTx && linkedInvestmentTx) {
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Linked transaction ID is ambiguous; repair the transfer before editing.', 409)
+ }
if (linkedTx) {
return await this.updateTransferPair(oldTx, linkedTx, dto)
}
- const linkedInvestmentTx = await this.investmentTransactionRepo.findById(oldTx.linked_transaction_id)
if (linkedInvestmentTx) {
return await this.updateInvestmentTransfer(oldTx, linkedInvestmentTx, dto)
}
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Linked transaction is missing; refresh and repair the transfer before editing.', 409)
}
const oldAccount = await this.accountRepo.findById(oldTx.account_id)
@@ -386,7 +399,7 @@ export class TransactionService {
outgoing: Transaction,
investmentTx: InvestmentTransaction,
dto: UpdateTransactionDto
- ): Promise {
+ ): Promise {
const fromAccountId = dto.account_id || outgoing.account_id
const toAccountId = dto.to_account_id || investmentTx.account_id
const amountFrom = Math.abs(dto.amount ?? outgoing.amount)
@@ -399,9 +412,13 @@ export class TransactionService {
if (fromAccountId === toAccountId) {
throw new Error('Cannot transfer to same account')
}
- if (amountFrom <= 0 || amountTo <= 0 || price < 0) {
+ if (![amountFrom, amountTo, price, amountTo * price].every(Number.isFinite)
+ || amountFrom <= 0 || amountTo <= 0 || price < 0) {
throw new Error('Transfer amounts and price must be valid')
}
+ if (investmentTx.linked_transaction_id !== outgoing.id) {
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Investment transfer links are inconsistent; refresh and repair the pair before editing.', 409)
+ }
const accountIds = new Set([outgoing.account_id, investmentTx.account_id, fromAccountId, toAccountId])
const accounts = new Map()
@@ -413,25 +430,8 @@ export class TransactionService {
}
const toAccount = accounts.get(toAccountId)!
- if (toAccount.type !== 'investment') {
- throw new Error('Investment transfer must target an investment account')
- }
-
- const balanceDeltas = new Map()
- const addDelta = (accountId: string, delta: number) => {
- balanceDeltas.set(accountId, (balanceDeltas.get(accountId) || 0) + delta)
- }
-
- // Revert the original cash and share movements, then apply the new ones.
- addDelta(outgoing.account_id, -outgoing.amount)
- addDelta(investmentTx.account_id, -investmentTx.quantity)
- addDelta(fromAccountId, -amountFrom)
- addDelta(toAccountId, amountTo)
-
- for (const [accountId, delta] of balanceDeltas) {
- if (delta === 0) continue
- const account = accounts.get(accountId)!
- await this.accountRepo.updateBalance(accountId, account.balance + delta, now)
+ if (toAccount.type !== 'investment' || accounts.get(fromAccountId)!.type === 'investment') {
+ throw AppError.validation('Investment transfers require a cash account and an investment holding')
}
let outgoingDescription = outgoing.description
@@ -448,34 +448,28 @@ export class TransactionService {
}
}
- await this.transactionRepo.update(outgoing.id, {
- account_id: fromAccountId,
- category_id: null,
- amount: -amountFrom,
- description: outgoingDescription,
- date,
- exclude_from_estimate: false,
- updated_at: now
- })
- await this.investmentTransactionRepo.update(investmentTx.id, {
- account_id: toAccountId,
- type: investmentTx.type,
- quantity: amountTo,
- price,
- total_amount: amountTo * price,
- date,
- notes: investmentNotes
- })
+ const applied = await this.investmentTransactionRepo.transfers.update(outgoing, investmentTx, {
+ cashAccountId: fromAccountId, investmentAccountId: toAccountId,
+ cashAmount: investmentTx.type === 'buy' ? -amountFrom : amountFrom,
+ quantity: amountTo, price, date, description: outgoingDescription ?? null, notes: investmentNotes ?? null,
+ }, now)
+ if (!applied) throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Investment transfer changed; refresh and try again.', 409)
const updated = await this.transactionRepo.findById(outgoing.id)
- return updated!
+ if (!updated) throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Investment transfer changed; refresh and try again.', 409)
+ return { ...updated, auditRecorded: true }
}
- async deleteTransaction(id: string): Promise {
+ // True means auditing was included in the pair batch, or deletion was a no-op.
+ async deleteTransaction(id: string): Promise {
// Get transaction to revert balance
const tx = await this.transactionRepo.findById(id)
if (tx) {
+ const investmentLink = await this.investmentTransactionRepo.findByCashTransactionId(id)
+ if (investmentLink && investmentLink.id !== tx.linked_transaction_id) {
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Investment transfer links are inconsistent; repair the pair before deleting.', 409)
+ }
if (tx.linked_transaction_id && tx.status === 'pending') {
throw new Error('Pending transfer review pairs must be declined together')
}
@@ -489,22 +483,28 @@ export class TransactionService {
if (tx.linked_transaction_id) {
linkedTx = await this.transactionRepo.findById(tx.linked_transaction_id)
+ const investmentTx = await this.investmentTransactionRepo.findById(tx.linked_transaction_id)
+ if (linkedTx && investmentTx) {
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Linked transaction ID is ambiguous; repair the transfer before deleting.', 409)
+ }
if (linkedTx) {
linkedAccount = await this.accountRepo.findById(linkedTx.account_id)
if (linkedAccount) {
this.assertAccountUnlocked(linkedAccount)
}
} else {
- const investmentTx = await this.investmentTransactionRepo.findById(tx.linked_transaction_id)
if (investmentTx) {
const investmentAccount = await this.accountRepo.findById(investmentTx.account_id)
if (investmentAccount) this.assertAccountUnlocked(investmentAccount)
- await this.transactionRepo.deleteInvestmentTransferAndRevertBalances(tx.id, investmentTx.id, Date.now())
- return
+ const applied = await this.investmentTransactionRepo.transfers.delete(tx, investmentTx, Date.now())
+ if (!applied && await this.transactionRepo.findById(id)) {
+ throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Investment transfer changed or has inconsistent links; refresh and try again.', 409)
+ }
+ return true
}
// Another deletion may have removed the pair after our initial read.
// Never refund a stale row or treat an orphaned transfer as cash-only.
- if (!await this.transactionRepo.findById(id)) return
+ if (!await this.transactionRepo.findById(id)) return true
throw new Error('Linked transaction not found')
}
}
@@ -532,7 +532,9 @@ export class TransactionService {
// Delete the main transaction
await this.transactionRepo.delete(id)
+ return false
}
+ return true
}
async getUpcomingTransactions(): Promise {
diff --git a/api/src/services/transfer.service.ts b/api/src/services/transfer.service.ts
index 4db1193e..7d368023 100644
--- a/api/src/services/transfer.service.ts
+++ b/api/src/services/transfer.service.ts
@@ -53,6 +53,35 @@ export class TransferService {
if (toAccount.is_locked) {
throw new Error(`Account "${toAccount.name}" is locked`)
}
+ if (fromAccount.type === 'investment') {
+ throw AppError.validation('Transfers from investment holdings require a linked sale workflow, which this endpoint does not support yet.')
+ }
+
+ if (toAccount.type === 'investment') {
+ const now = Date.now()
+ const outgoingId = crypto.randomUUID()
+ const incomingId = crypto.randomUUID()
+ const price = dto.price ?? 0
+ if (!Number.isFinite(dto.amount_to * price)) throw AppError.validation('Investment total must be finite')
+ const details = `${dto.amount_to.toFixed(8)} shares${price > 0 ? ` @ ${toAccount.quote_currency || 'USD'} ${price.toFixed(2)}/share` : ''}`
+ const note = dto.description ? ` - ${dto.description}` : ''
+ const created = await this.investmentTransactionRepo.transfers.create({
+ id: outgoingId, account_id: fromAccount.id, amount: -dto.amount_from,
+ description: `Transfer to ${toAccount.name} (${details})${note}`, date: dto.date,
+ linked_transaction_id: incomingId,
+ }, {
+ id: incomingId, account_id: toAccount.id, type: 'buy', quantity: dto.amount_to,
+ price, total_amount: dto.amount_to * price, date: dto.date,
+ notes: `Transfer from ${fromAccount.name} (${details})${note}`,
+ linked_transaction_id: outgoingId, created_at: now,
+ }, now)
+ if (!created) throw new AppError('INVESTMENT_TRANSFER_CONFLICT', 'Transfer accounts changed; refresh and try again.', 409)
+ return {
+ id: crypto.randomUUID(), outgoing_transaction_id: outgoingId, incoming_transaction_id: incomingId,
+ amount_from: dto.amount_from, amount_to: dto.amount_to, exchange_rate: dto.amount_to / dto.amount_from,
+ from_account_id: fromAccount.id, to_account_id: toAccount.id, is_investment_transfer: true,
+ }
+ }
const totalDeduction = dto.amount_from
const now = Date.now()
@@ -65,13 +94,7 @@ export class TransferService {
let outgoingDesc = `Transfer to ${toAccount.name}`
let incomingDesc = `Transfer from ${fromAccount.name}`
- if (toAccount.type === 'investment') {
- const quoteCurrency = toAccount.quote_currency || 'USD'
- const priceDetails = dto.price ? ` @ ${quoteCurrency} ${dto.price.toFixed(2)}/share` : ''
- const purchaseDetails = `${dto.amount_to.toFixed(8)} shares${priceDetails}`
- outgoingDesc += ` (${purchaseDetails})`
- incomingDesc += ` (${purchaseDetails})`
- } else if (fromAccount.currency !== toAccount.currency) {
+ if (fromAccount.currency !== toAccount.currency) {
const effectiveRate = dto.amount_to / dto.amount_from
outgoingDesc += ` (${dto.amount_to.toFixed(2)} ${toAccount.currency} @ ${effectiveRate.toFixed(4)})`
incomingDesc += ` (${dto.amount_from.toFixed(2)} ${fromAccount.currency} @ ${effectiveRate.toFixed(4)})`
@@ -99,50 +122,11 @@ export class TransferService {
now
)
- // Handle incoming transaction based on TO account type
- if (toAccount.type === 'investment') {
- // TO account is investment: save to investment_transactions
- const quantity = dto.amount_to // amount_to is the number of shares
- const pricePerUnit = dto.price || 0
- const totalAmount = quantity * pricePerUnit
-
- await this.investmentTransactionRepo.create({
- id: incomingId,
- account_id: dto.to_account_id,
- type: 'buy',
- quantity,
- price: pricePerUnit,
- total_amount: totalAmount,
- date: dto.date,
- notes: incomingDesc,
- created_at: now
- })
-
- // Update investment account balance with shares
- await this.accountRepo.updateBalance(
- dto.to_account_id,
- toAccount.balance + quantity,
- now
- )
- } else {
- // TO account is regular cash account: save to transactions
- await this.transactionRepo.create({
- id: incomingId,
- account_id: dto.to_account_id,
- amount: dto.amount_to,
- description: incomingDesc,
- date: dto.date,
-
- linked_transaction_id: outgoingId
- })
-
- // Update cash account balance
- await this.accountRepo.updateBalance(
- dto.to_account_id,
- toAccount.balance + dto.amount_to,
- now
- )
- }
+ await this.transactionRepo.create({
+ id: incomingId, account_id: dto.to_account_id, amount: dto.amount_to,
+ description: incomingDesc, date: dto.date, linked_transaction_id: outgoingId,
+ })
+ await this.accountRepo.updateBalance(dto.to_account_id, toAccount.balance + dto.amount_to, now)
return {
id: transferId,
@@ -153,7 +137,7 @@ export class TransferService {
exchange_rate: dto.amount_to / dto.amount_from,
from_account_id: dto.from_account_id,
to_account_id: dto.to_account_id,
- is_investment_transfer: toAccount.type === 'investment'
+ is_investment_transfer: false
}
}
}
diff --git a/api/src/tests/account-locking.test.ts b/api/src/tests/account-locking.test.ts
index 3db7ee87..4c961360 100644
--- a/api/src/tests/account-locking.test.ts
+++ b/api/src/tests/account-locking.test.ts
@@ -82,7 +82,7 @@ describe('account locking enforcement', () => {
const service = new TransactionService(
transactionRepo as any,
accountRepo as any,
- { create: vi.fn() } as any
+ { create: vi.fn(), findById: vi.fn(async () => null), findByCashTransactionId: vi.fn(async () => null) } as any
)
await expect(service.updateTransaction('out', {
diff --git a/api/src/tests/upcoming-transactions.test.ts b/api/src/tests/upcoming-transactions.test.ts
index 65c190ee..ad3d7930 100644
--- a/api/src/tests/upcoming-transactions.test.ts
+++ b/api/src/tests/upcoming-transactions.test.ts
@@ -56,7 +56,7 @@ function createService(transactions: Record, accounts: Reco
const service = new TransactionService(
transactionRepo as any,
accountRepo as any,
- { create: vi.fn() } as any
+ { create: vi.fn(), findById: vi.fn(async () => null), findByCashTransactionId: vi.fn(async () => null) } as any
)
return { service, transactionRepo, accountRepo }
diff --git a/client/src/components/dashboard-module/TransactionList.test.tsx b/client/src/components/dashboard-module/TransactionList.test.tsx
index 120f127d..02b7a8de 100644
--- a/client/src/components/dashboard-module/TransactionList.test.tsx
+++ b/client/src/components/dashboard-module/TransactionList.test.tsx
@@ -120,6 +120,33 @@ function mockRates(rate = 0.2) {
}
describe('TransactionList redesigned transfers', () => {
+ it('groups a reciprocal investment pair once and edits it through the cash transaction', async () => {
+ mockRates()
+ const user = userEvent.setup()
+ const added = renderTransfers({
+ accounts: [...transferAccounts, { id: 'holding', name: 'Test holding', balance: 2.125, currency: 'SHARE', quote_currency: 'EUR', type: 'investment' }],
+ transactions: [
+ { id: 'buy', account_id: 'holding', amount: 50, quantity: 0.125, price: 400,
+ linked_transaction_id: 'purchase-cash', date: '2020-01-01', description: 'Transfer from Savings' },
+ { id: 'purchase-cash', account_id: 'source', amount: -100, linked_transaction_id: 'buy',
+ date: '2020-01-01', description: 'Transfer to Test holding' },
+ ],
+ })
+ expect(screen.getByText('1 posted')).toBeInTheDocument()
+ await user.click(screen.getAllByRole('button', { name: 'Edit transfer' })[0])
+ const dialog = screen.getByRole('dialog')
+ expect(within(dialog).getByLabelText('Amount to Send (HUF)')).toHaveValue('100')
+ const quantity = within(dialog).getByLabelText('Shares to Receive')
+ expect(quantity).toHaveValue('0.125')
+ await user.clear(quantity)
+ await user.type(quantity, '0.25')
+ await user.click(within(dialog).getByRole('button', { name: 'Save changes' }))
+ await waitFor(() => expect(added).toHaveBeenCalledOnce())
+ const save = vi.mocked(apiFetch).mock.calls.find(([url, options]) => url === '/api/transactions/purchase-cash' && options?.method === 'PUT')
+ expect(save).toBeDefined()
+ expect(JSON.parse(String(save?.[1]?.body))).toMatchObject({ amount: 100, amount_to: 0.25, price: 400 })
+ })
+
it('previews posted edit deltas, preserves both native amounts when swapping, and reverts the full edit', async () => {
mockRates()
const user = userEvent.setup()
diff --git a/client/src/components/investments-module/data.ts b/client/src/components/investments-module/data.ts
index 287c3b2c..c5da7762 100644
--- a/client/src/components/investments-module/data.ts
+++ b/client/src/components/investments-module/data.ts
@@ -13,6 +13,7 @@ export type InvestmentSnapshot = {
type InvestmentTrade = {
id: string; account_id: string; type: 'buy' | 'sell'; quantity: number
price: number; total_amount: number; date: string; notes?: string | null
+ linked_transaction_id?: string | null
}
const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value)
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
@@ -25,6 +26,7 @@ const trade = (value: unknown): value is InvestmentTrade => record(value) && typ
&& typeof value.account_id === 'string' && (value.type === 'buy' || value.type === 'sell')
&& finite(value.quantity) && finite(value.price) && finite(value.total_amount) && typeof value.date === 'string'
&& (value.notes == null || typeof value.notes === 'string')
+ && (value.linked_transaction_id == null || typeof value.linked_transaction_id === 'string')
async function readArray(path: string, validate: (value: unknown) => value is T, signal: AbortSignal): Promise {
const response = await apiFetch(`${API_BASE_URL}${path}`, { throwOnError: true, signal })
@@ -58,6 +60,7 @@ export async function readInvestmentSnapshot(signal: AbortSignal): Promise item.asset_type !== 'manual' && item.symbol).map(item => item.symbol!))]
diff --git a/client/src/hooks/useFinanceData.ts b/client/src/hooks/useFinanceData.ts
index 226dea67..c8d9c8e9 100644
--- a/client/src/hooks/useFinanceData.ts
+++ b/client/src/hooks/useFinanceData.ts
@@ -71,7 +71,7 @@ const mapInvestmentTransaction = (transaction: InvestmentTransaction) => ({
date: transaction.date,
is_recurring: false,
category_id: undefined,
- linked_transaction_id: undefined,
+ linked_transaction_id: transaction.linked_transaction_id ?? undefined,
created_at: transaction.created_at,
updated_at: transaction.updated_at ?? transaction.created_at,
})
@@ -114,6 +114,7 @@ type InvestmentTransaction = {
total_amount: number
date: string
notes?: string
+ linked_transaction_id?: string | null
created_at?: number
updated_at?: number
}
@@ -122,6 +123,7 @@ const isInvestmentTransaction = (value: unknown): value is InvestmentTransaction
&& (value.type === 'buy' || value.type === 'sell') && typeof value.date === 'string'
&& isNumber(value.quantity) && isNumber(value.price) && isNumber(value.total_amount)
&& (value.notes == null || typeof value.notes === 'string')
+ && (value.linked_transaction_id == null || typeof value.linked_transaction_id === 'string')
const readJson = async (url: string): Promise => {
const response = await apiFetch(url, { throwOnError: true })
diff --git a/client/src/test/useFinanceData.test.ts b/client/src/test/useFinanceData.test.ts
index b10448f0..14978c20 100644
--- a/client/src/test/useFinanceData.test.ts
+++ b/client/src/test/useFinanceData.test.ts
@@ -70,6 +70,18 @@ const failureCases = [
] as const
describe('useFinanceData', () => {
+ it('preserves reciprocal investment links in period and complete history snapshots', async () => {
+ payloads['/accounts'] = [mockAccount, investmentAccount]
+ payloads['/investment-transactions'] = [{ ...investmentTransaction, linked_transaction_id: 'tx-1' }]
+ payloads['/transactions/date-range'] = [{ ...mockTransaction, linked_transaction_id: 'investment-tx' }]
+ payloads['/transactions'] = payloads['/transactions/date-range']
+ const { result } = setup()
+ await settled(result)
+ for (const rows of [result.current.transactions, result.current.allTransactions]) {
+ expect(rows.find(row => row.id === 'investment-tx')).toMatchObject({ linked_transaction_id: 'tx-1', quantity: 2 })
+ expect(rows.find(row => row.id === 'tx-1')).toMatchObject({ linked_transaction_id: 'investment-tx' })
+ }
+ })
it('loads only the selected period on Dashboard and fetches complete history when requested', async () => {
const { result, rerender } = renderHook(({ enabled, range }) => useFinanceData(range, 'HUF', { historyEnabled: enabled }),
{ initialProps: { enabled: false, range: dateRange } })
diff --git a/client/src/test/useInvestmentData.test.ts b/client/src/test/useInvestmentData.test.ts
index b22eebc4..cae10ddf 100644
--- a/client/src/test/useInvestmentData.test.ts
+++ b/client/src/test/useInvestmentData.test.ts
@@ -44,6 +44,14 @@ describe('portfolio snapshots across navigation', () => {
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/investment-transactions'))).toHaveLength(1)
})
+ it('retains cash links on investment records without inventing links for standalone trades', async () => {
+ payloads['/investment-transactions'] = [{ ...buy, linked_transaction_id: 'cash-purchase' }, sell]
+ const { result } = renderHook(() => useInvestmentData(true, 1))
+ await waitFor(() => expect(result.current.status.loaded).toBe(true))
+ expect(result.current.data?.transactions[0]).toMatchObject({ id: 'buy', linked_transaction_id: 'cash-purchase' })
+ expect(result.current.data?.transactions[1].linked_transaction_id).toBeUndefined()
+ })
+
it('retains data and display currency while a return visit refreshes in the background', async () => {
const { result, rerender } = renderHook(({ active }) => useInvestmentData(active, 1), { initialProps: { active: true } })
await waitFor(() => expect(result.current.status.loaded).toBe(true))
diff --git a/mcp/README.md b/mcp/README.md
index a51d0484..45ce255a 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -29,6 +29,8 @@ Finance Manager MCP Review section → edit / confirm / decline manually
To correct a mistake in an existing draft, call `list_mcp_review_drafts`, following every cursor before claiming the list is complete. It shows each ordinary draft or linked cash transfer pair once. For ordinary income/expense drafts, call `prepare_mcp_review_draft_corrections` with 1–20 edits or declines, then `apply_mcp_review_draft_corrections` after explicit confirmation of the complete before/after preview. For transfers, use `prepare_mcp_transfer_corrections` and `apply_mcp_transfer_corrections` the same way. Either transfer leg ID identifies the pair; source and destination accounts and both native amounts are shown in each preview. A transfer edit or decline always applies to both legs. Proposals expire after 24 hours; stale app or MCP edits require a fresh list and preview. A decline cancels the draft or both legs and removes the item from the active review queue. Corrections do not post transactions, change balances, or add upcoming projections.
+`get_investment_activity` includes each stored investment record's nullable `linked_transaction_id`: for a linked investment transfer it identifies the cash ledger row, whose link points back to the investment record. Standalone trades remain unlinked. This is read-only relationship metadata; MCP still cannot create or mutate investments or their cash movements.
+
For a cash-to-cash transfer, call `prepare_mcp_transfer_drafts` with 1–20 source/destination account IDs, a positive `amount` sent, dates, and optional notes. When currencies differ, also supply the positive `amount_to` received in the destination account's native currency; preparation rejects a missing destination amount. For same-currency accounts, `amount_to` can be omitted and must equal `amount` if supplied. The preview shows both named legs and currencies, their exact amounts, a display-only effective rate (destination units per source unit, rounded to 10 significant digits), and duplicate warnings. No rate service determines either amount. The amounts alone do not reveal a separate exchange fee; that would require an agreed reference rate or an explicit fee. After explicit confirmation of the whole preview, call `create_mcp_transfer_drafts` with the opaque proposal ID alone. The 24-hour proposal creates reciprocal pending MCP review rows atomically and idempotently. The app shows one review item per pair; you can edit its accounts, native amounts, date, and note while pending. Changing accounts requires both amounts to be entered. The app or MCP saves both legs together without changing balances. Confirmation in the app posts both sides and changes both balances once; declining in the app or MCP cancels both with no balance change. A future-dated pair cannot be confirmed before its date.
## Security model
diff --git a/mcp/src/types.ts b/mcp/src/types.ts
index cd028b8b..714856cd 100644
--- a/mcp/src/types.ts
+++ b/mcp/src/types.ts
@@ -105,6 +105,7 @@ export type InvestmentTransactionRow = {
total_amount: number
date: string
notes?: string | null
+ linked_transaction_id?: string | null
created_at?: number | null
}
diff --git a/tests/integration/data-export.test.ts b/tests/integration/data-export.test.ts
index 89f1373c..21c1e95a 100644
--- a/tests/integration/data-export.test.ts
+++ b/tests/integration/data-export.test.ts
@@ -21,8 +21,8 @@ it('exports every durable table and transaction state with stored links and fina
VALUES ('cancelled', 'cash', -5, '2026-01-01', 'cancelled', 3)`),
f.db.prepare(`INSERT INTO transactions (id, account_id, amount, date, linked_transaction_id)
VALUES ('purchase-cash', 'cash', -12.5, '2026-01-01', 'buy')`),
- f.db.prepare(`INSERT INTO investment_transactions (id, account_id, type, quantity, price, total_amount, date, notes, created_at)
- VALUES ('buy', 'stock', 'buy', 0.125, 100, 12.5, '2026-01-01', 'Fractional holding', 1)`),
+ f.db.prepare(`INSERT INTO investment_transactions (id, account_id, type, quantity, price, total_amount, date, notes, created_at, linked_transaction_id)
+ VALUES ('buy', 'stock', 'buy', 0.125, 100, 12.5, '2026-01-01', 'Fractional holding', 1, 'purchase-cash')`),
f.db.prepare(`INSERT INTO recurring_schedules (id, type, frequency, month, day_of_month, account_id, to_account_id, amount, amount_to, description, is_active, created_at, last_processed_date, remaining_occurrences, end_date)
VALUES ('schedule', 'transfer', 'yearly', 11, 31, 'cash', 'savings', 20, 20, 'Annual transfer', 0, 1, '2025-12-31', 2, '2028-12-31')`),
f.db.prepare(`INSERT INTO app_settings VALUES ('navigation.visible_menus', '{"dashboard":true,"analytics":false}', 2)`),
@@ -39,9 +39,9 @@ it('exports every durable table and transaction state with stored links and fina
expect(response.headers.get('cache-control')).toBe('no-store')
const exported = await response.json() as DataExport
expect(isDataExport(exported)).toBe(true)
- expect(exported).toMatchObject({ exportVersion: 1, schemaVersion: '016-account-archive', manifest: { restoreSupported: false } })
+ expect(exported).toMatchObject({ exportVersion: 1, schemaVersion: '017-investment-transfer-links', manifest: { restoreSupported: false } })
expect(new Date(exported.exportedAt).toISOString()).toBe(exported.exportedAt)
- expect(exported.migrations).toHaveLength(16)
+ expect(exported.migrations).toHaveLength(17)
for (const table of EXPORT_TABLES) {
const stored = (await f.db.prepare(`SELECT * FROM ${table} ORDER BY ${table === 'app_settings' ? 'key' : 'id'}`).all()).results
expect(exported.data[table], table).toEqual(stored)
@@ -49,7 +49,7 @@ it('exports every durable table and transaction state with stored links and fina
}
expect(exported.data.transactions).toHaveLength(6)
expect(exported.data.transactions).toContainEqual(expect.objectContaining({ id: 'review', pending_kind: 'mcp_review', review_source: 'chatgpt_mcp', review_batch_id: 'batch', review_flags: '["duplicate"]' }))
- expect(exported.data.investment_transactions).toContainEqual(expect.objectContaining({ id: 'buy', quantity: 0.125 }))
+ expect(exported.data.investment_transactions).toContainEqual(expect.objectContaining({ id: 'buy', quantity: 0.125, linked_transaction_id: 'purchase-cash' }))
expect(exported.manifest.excluded).toHaveProperty('mcp_draft_proposals')
expect(JSON.stringify(exported)).not.toContain('secret-proposal-id')
expect(JSON.stringify(exported)).not.toContain('integration-test-key')
diff --git a/tests/integration/investment-transfer-links.test.ts b/tests/integration/investment-transfer-links.test.ts
new file mode 100644
index 00000000..b4eca3c4
--- /dev/null
+++ b/tests/integration/investment-transfer-links.test.ts
@@ -0,0 +1,247 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { useFinanceWorkers } from './harness'
+import { InvestmentTransactionRepository } from '../../api/src/repositories/investment-transaction.repository'
+import { TransactionRepository } from '../../api/src/repositories/transaction.repository'
+
+type Pair = { outgoing_transaction_id: string; incoming_transaction_id: string }
+
+describe('reciprocal investment transfers', () => {
+ const f = useFinanceWorkers()
+ beforeEach(async () => {
+ await f.seed()
+ await f.db.batch([
+ f.db.prepare(`INSERT INTO accounts (id, name, type, balance, currency, quote_currency, asset_type)
+ VALUES ('holding', 'Synthetic holding', 'investment', 2, 'SHARE', 'EUR', 'stock')`),
+ f.db.prepare(`INSERT INTO accounts (id, name, type, balance, currency, quote_currency, asset_type)
+ VALUES ('other-holding', 'Other holding', 'investment', 0, 'SHARE', 'EUR', 'stock')`),
+ ])
+ })
+ const input = { from_account_id: 'cash', to_account_id: 'holding', amount_from: 100,
+ amount_to: 0.125, price: 400, date: '2026-01-15', description: 'Synthetic purchase' }
+ async function purchase() {
+ const response = await f.request('/transfers', 'POST', input)
+ expect(response.status, await response.clone().text()).toBe(201)
+ return response.json() as Promise
+ }
+ async function state() {
+ return {
+ accounts: (await f.db.prepare('SELECT * FROM accounts ORDER BY id').all()).results,
+ cash: (await f.db.prepare('SELECT * FROM transactions ORDER BY id').all()).results,
+ trades: (await f.db.prepare('SELECT * FROM investment_transactions ORDER BY id').all()).results,
+ audit: (await f.db.prepare('SELECT * FROM audit_log ORDER BY id').all()).results,
+ revision: await f.db.prepare('SELECT revision FROM financial_data_revision WHERE id = 1').first('revision'),
+ }
+ }
+
+ it('persists and exposes reciprocal IDs through API, bounded history, MCP and JSON export', async () => {
+ const pair = await purchase()
+ const trade = { id: pair.incoming_transaction_id, linked_transaction_id: pair.outgoing_transaction_id,
+ quantity: 0.125, price: 400, total_amount: 50 }
+ expect(await (await f.request('/investment-transactions')).json()).toEqual([expect.objectContaining(trade)])
+ expect(await (await f.request('/transactions/history')).json()).toMatchObject({
+ transactions: [expect.objectContaining({ id: pair.outgoing_transaction_id, amount: -100, linked_transaction_id: pair.incoming_transaction_id })],
+ investments: [expect.objectContaining(trade)],
+ })
+ expect(await f.tool('get_investment_activity', {})).toMatchObject({ activities: [expect.objectContaining(trade)] })
+ expect(await (await f.request('/export')).json()).toMatchObject({ data: {
+ investment_transactions: [expect.objectContaining(trade)],
+ transactions: [expect.objectContaining({ id: pair.outgoing_transaction_id, linked_transaction_id: pair.incoming_transaction_id })],
+ } })
+ expect(await f.balances()).toEqual([{ id: 'cash', balance: 900 }, { id: 'holding', balance: 2.125 },
+ { id: 'other-holding', balance: 0 }, { id: 'savings', balance: 200 }])
+ expect(await f.db.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'CREATE'").first('count')).toBe(2)
+ })
+
+ it.each(['investment_transactions', 'audit_log'])('rolls back pair creation if writing %s fails', async table => {
+ const before = await state()
+ await f.db.prepare(`CREATE TRIGGER reject_pair BEFORE INSERT ON ${table}
+ BEGIN SELECT RAISE(ABORT, 'injected create failure'); END`).run()
+ expect((await f.request('/transfers', 'POST', input)).status).toBeGreaterThanOrEqual(400)
+ expect(await state()).toEqual(before)
+ await f.db.prepare('DROP TRIGGER reject_pair').run()
+ await purchase()
+ })
+
+ it('adds concurrent purchases to current balances without losing a movement', async () => {
+ await Promise.all(Array.from({ length: 4 }, purchase))
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'cash'").first('balance')).toBe(600)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'holding'").first('balance')).toBe(2.5)
+ expect((await state()).trades).toHaveLength(4)
+ })
+
+ it('atomically edits both records and all four accounts using exact cash and unit amounts', async () => {
+ const pair = await purchase()
+ const response = await f.request(`/transactions/${pair.outgoing_transaction_id}`, 'PUT', {
+ account_id: 'savings', to_account_id: 'other-holding', amount: 150, amount_to: 0.25,
+ price: 500, description: 'Corrected purchase', date: '2026-01-14',
+ })
+ expect(response.status, await response.clone().text()).toBe(200)
+ expect(await f.balances()).toEqual([{ id: 'cash', balance: 1000 }, { id: 'holding', balance: 2 },
+ { id: 'other-holding', balance: 0.25 }, { id: 'savings', balance: 50 }])
+ const current = await state()
+ expect(current.cash).toEqual([expect.objectContaining({ id: pair.outgoing_transaction_id, account_id: 'savings',
+ amount: -150, date: '2026-01-14', status: 'posted', linked_transaction_id: pair.incoming_transaction_id })])
+ expect(current.trades).toEqual([expect.objectContaining({ id: pair.incoming_transaction_id, account_id: 'other-holding',
+ quantity: 0.25, price: 500, total_amount: 125, date: '2026-01-14', linked_transaction_id: pair.outgoing_transaction_id })])
+ expect(current.audit.filter(row => row.action === 'UPDATE')).toHaveLength(2)
+ expect(JSON.parse(String(current.audit.find(row => row.action === 'UPDATE')!.details))).toMatchObject({
+ before: { cash_account_id: 'cash', cash_amount: -100, quantity: 0.125, price: 400 },
+ after: { cash_account_id: 'savings', investment_account_id: 'other-holding', cash_amount: -150, quantity: 0.25, price: 500 },
+ })
+ })
+
+ it.each(['investment_transactions', 'audit_log'])('rolls back an edit if %s fails after balance updates', async table => {
+ const pair = await purchase()
+ const before = await state()
+ await f.db.prepare(`CREATE TRIGGER reject_edit BEFORE ${table === 'audit_log' ? 'INSERT' : 'UPDATE'} ON ${table}
+ BEGIN SELECT RAISE(ABORT, 'injected edit failure'); END`).run()
+ expect((await f.request(`/transactions/${pair.outgoing_transaction_id}`, 'PUT', { amount: 150, amount_to: 0.25, price: 500 })).status).toBe(500)
+ expect(await state()).toEqual(before)
+ })
+
+ it('guards concurrent and repeated edits against stale balance deltas', async () => {
+ const pair = await purchase()
+ const edit = () => f.request(`/transactions/${pair.outgoing_transaction_id}`, 'PUT', { amount: 150, amount_to: 0.25 })
+ const results = await Promise.all(Array.from({ length: 4 }, edit))
+ expect(results.some(response => response.status === 200)).toBe(true)
+ expect(results.every(response => [200, 409].includes(response.status))).toBe(true)
+ expect((await edit()).status).toBe(200)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'cash'").first('balance')).toBe(850)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'holding'").first('balance')).toBe(2.25)
+ })
+
+ it('rejects stale snapshots and accounts locked after the initial read inside the D1 batch', async () => {
+ const pair = await purchase()
+ const repository = new InvestmentTransactionRepository(f.db)
+ const cash = (await new TransactionRepository(f.db).findById(pair.outgoing_transaction_id))!
+ const trade = (await repository.findById(pair.incoming_transaction_id))!
+ const next = { cashAccountId: 'cash', investmentAccountId: 'holding', cashAmount: -150,
+ quantity: 0.25, price: 500, date: cash.date, description: cash.description ?? null, notes: trade.notes ?? null }
+ await f.request('/accounts/holding/lock', 'PATCH')
+ const locked = await state()
+ expect(await repository.transfers.update(cash, trade, next, Date.now())).toBe(false)
+ expect(await repository.transfers.delete(cash, trade, Date.now())).toBe(false)
+ expect(await state()).toEqual(locked)
+ await f.request('/accounts/holding/unlock', 'PATCH')
+ expect((await f.request(`/transactions/${pair.outgoing_transaction_id}`, 'PUT', { amount: 175, amount_to: 0.375 })).status).toBe(200)
+ const changed = await state()
+ expect(await repository.transfers.update(cash, trade, next, Date.now())).toBe(false)
+ expect(await repository.transfers.delete(cash, trade, Date.now())).toBe(false)
+ expect(await state()).toEqual(changed)
+ })
+
+ it.each(['cash', 'holding', 'savings', 'other-holding'])('rejects edits with locked account %s without partial writes', async id => {
+ const pair = await purchase()
+ await f.request(`/accounts/${id}/lock`, 'PATCH')
+ const before = await state()
+ expect((await f.request(`/transactions/${pair.outgoing_transaction_id}`, 'PUT', {
+ account_id: 'savings', to_account_id: 'other-holding', amount: 150, amount_to: 0.25,
+ })).status).toBe(409)
+ expect(await state()).toEqual(before)
+ for (const path of [`/transactions/${pair.outgoing_transaction_id}`, `/investment-transactions/${pair.incoming_transaction_id}`]) {
+ if (['cash', 'holding'].includes(id)) {
+ expect((await f.request(path, 'DELETE')).status).toBe(409)
+ expect(await state()).toEqual(before)
+ }
+ }
+ })
+
+ it('rejects archived destinations and creation from an investment source before any writes', async () => {
+ const pair = await purchase()
+ expect((await f.request('/accounts/other-holding/archive', 'PATCH')).status).toBe(200)
+ const before = await state()
+ expect((await f.request(`/transactions/${pair.outgoing_transaction_id}`, 'PUT', { to_account_id: 'other-holding' })).status).toBe(409)
+ expect((await f.request('/transfers', 'POST', { ...input, from_account_id: 'holding', to_account_id: 'cash' })).status).toBe(400)
+ expect((await f.request('/transfers', 'POST', { ...input, to_account_id: 'other-holding' })).status).toBe(409)
+ expect(await state()).toEqual(before)
+ })
+
+ it.each(['transactions', 'investment_transactions', 'audit_log'])('rolls back deletion through the investment endpoint if %s fails', async table => {
+ const pair = await purchase()
+ const before = await state()
+ await f.db.prepare(`CREATE TRIGGER reject_delete BEFORE ${table === 'audit_log' ? 'INSERT' : 'DELETE'} ON ${table}
+ BEGIN SELECT RAISE(ABORT, 'injected delete failure'); END`).run()
+ expect((await f.request(`/investment-transactions/${pair.incoming_transaction_id}`, 'DELETE')).status).toBe(500)
+ expect(await state()).toEqual(before)
+ })
+
+ it('deletes from either endpoint concurrently, reversing each movement and auditing the pair once', async () => {
+ const pair = await purchase()
+ const paths = [`/transactions/${pair.outgoing_transaction_id}`, `/investment-transactions/${pair.incoming_transaction_id}`]
+ const results = await Promise.all([...paths, ...paths].map(path => f.request(path, 'DELETE')))
+ expect(results.map(response => response.status)).toEqual([200, 200, 200, 200])
+ for (const path of paths) expect((await f.request(path, 'DELETE')).status).toBe(200)
+ expect(await f.balances()).toEqual([{ id: 'cash', balance: 1000 }, { id: 'holding', balance: 2 },
+ { id: 'other-holding', balance: 0 }, { id: 'savings', balance: 200 }])
+ const current = await state()
+ expect(current.cash).toEqual([])
+ expect(current.trades).toEqual([])
+ expect(current.audit.filter(row => row.action === 'DELETE')).toHaveLength(2)
+ })
+
+ it.each(['investment', 'cash'])('rejects a broken %s link without treating either leg as standalone', async side => {
+ const pair = await purchase()
+ await f.db.prepare(side === 'investment'
+ ? 'UPDATE investment_transactions SET linked_transaction_id = NULL WHERE id = ?'
+ : 'UPDATE transactions SET linked_transaction_id = NULL WHERE id = ?')
+ .bind(side === 'investment' ? pair.incoming_transaction_id : pair.outgoing_transaction_id).run()
+ const before = await state()
+ expect((await f.request(`/transactions/${pair.outgoing_transaction_id}`, 'PUT', { amount: 150 })).status).toBe(409)
+ for (const path of [`/transactions/${pair.outgoing_transaction_id}`, `/investment-transactions/${pair.incoming_transaction_id}`]) {
+ expect((await f.request(path, 'DELETE')).status).toBe(409)
+ expect(await state()).toEqual(before)
+ }
+ })
+
+ it('keeps standalone investment records unlinked', async () => {
+ const response = await f.request('/transactions', 'POST', { account_id: 'holding', amount: 0.25, price: 400, date: '2026-01-15' })
+ expect(response.status).toBe(201)
+ const trades = await (await f.request('/investment-transactions')).json() as { id: string; linked_transaction_id: string | null }[]
+ expect(trades).toHaveLength(1)
+ expect(trades[0].linked_transaction_id).toBeNull()
+ expect((await state()).cash).toEqual([])
+ expect((await f.request(`/investment-transactions/${trades[0].id}`, 'DELETE')).status).toBe(200)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'holding'").first('balance')).toBe(2)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'cash'").first('balance')).toBe(1000)
+ })
+
+ it('does not allow manually attaching another cash movement to an investment record', async () => {
+ const pair = await purchase()
+ const before = await state()
+ const response = await f.request('/transactions', 'POST', {
+ account_id: 'cash', amount: -100, date: '2026-01-15', linked_transaction_id: pair.incoming_transaction_id,
+ })
+ expect(response.status).toBe(400)
+ expect(await state()).toEqual(before)
+ })
+
+ it('rejects legacy IDs shared by cash and investment records before editing or deleting', async () => {
+ const pair = await purchase()
+ await f.db.batch([
+ f.db.prepare('UPDATE investment_transactions SET linked_transaction_id = NULL WHERE id = ?').bind(pair.incoming_transaction_id),
+ f.db.prepare(`INSERT INTO transactions (id, account_id, amount, date, linked_transaction_id)
+ VALUES (?, 'savings', 50, '2026-01-15', ?)`).bind(pair.incoming_transaction_id, pair.outgoing_transaction_id),
+ ])
+ const before = await state()
+ expect((await f.request(`/transactions/${pair.outgoing_transaction_id}`, 'PUT', { amount: 150 })).status).toBe(409)
+ expect((await f.request(`/transactions/${pair.outgoing_transaction_id}`, 'DELETE')).status).toBe(409)
+ expect(await state()).toEqual(before)
+ })
+
+ it('preserves sale signs when editing and deleting an existing linked sale', async () => {
+ await f.db.batch([
+ f.db.prepare("INSERT INTO transactions (id, account_id, amount, date, linked_transaction_id) VALUES ('sale-cash', 'cash', 120, '2026-01-15', 'sale')"),
+ f.db.prepare(`INSERT INTO investment_transactions (id, account_id, type, quantity, price, total_amount, date, linked_transaction_id)
+ VALUES ('sale', 'holding', 'sell', 0.125, 400, 50, '2026-01-15', 'sale-cash')`),
+ f.db.prepare("UPDATE accounts SET balance = balance + 120 WHERE id = 'cash'"),
+ f.db.prepare("UPDATE accounts SET balance = balance - 0.125 WHERE id = 'holding'"),
+ ])
+ expect((await f.request('/transactions/sale-cash', 'PUT', { amount: 150, amount_to: 0.25 })).status).toBe(200)
+ expect(await f.db.prepare("SELECT amount FROM transactions WHERE id = 'sale-cash'").first('amount')).toBe(150)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'cash'").first('balance')).toBe(1150)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'holding'").first('balance')).toBe(1.75)
+ expect((await f.request('/investment-transactions/sale', 'DELETE')).status).toBe(200)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'cash'").first('balance')).toBe(1000)
+ expect(await f.db.prepare("SELECT balance FROM accounts WHERE id = 'holding'").first('balance')).toBe(2)
+ })
+})
diff --git a/tests/integration/migrations.test.ts b/tests/integration/migrations.test.ts
index 89a4739e..93aa6a72 100644
--- a/tests/integration/migrations.test.ts
+++ b/tests/integration/migrations.test.ts
@@ -3,6 +3,52 @@ import { migrate, useFinanceWorkers } from './harness'
const f = useFinanceWorkers({ migrateBeforeEach: false })
+it('backfills only unambiguous investment cash links, preserving financial data and archived history', async () => {
+ await migrate(f.db, 16)
+ await f.seed()
+ await f.db.batch([
+ f.db.prepare("INSERT INTO accounts (id, name, type, balance, currency, asset_type) VALUES ('holding', 'Test holding', 'investment', 2, 'SHARE', 'stock')"),
+ f.db.prepare("INSERT INTO accounts (id, name, type, balance, currency, asset_type) VALUES ('closed', 'Closed holding', 'investment', 0, 'SHARE', 'stock')"),
+ ])
+ for (const [id, accountId, type] of [
+ ['purchase', 'holding', 'buy'], ['sale', 'holding', 'sell'], ['standalone', 'holding', 'buy'],
+ ['ambiguous', 'holding', 'buy'], ['wrong-sign', 'holding', 'buy'], ['wrong-date', 'holding', 'buy'],
+ ['pending-cash', 'holding', 'buy'], ['collision', 'holding', 'buy'], ['archived-buy', 'closed', 'buy'], ['archived-sell', 'closed', 'sell'],
+ ]) {
+ await f.db.prepare(`INSERT INTO investment_transactions (id, account_id, type, quantity, price, total_amount, date, notes, created_at)
+ VALUES (?, ?, ?, 0.125, 400, 50, '2026-01-15', 'Preserve this note', 1)`).bind(id, accountId, type).run()
+ }
+ for (const [id, link, amount, date, status] of [
+ ['cash-buy', 'purchase', -100, '2026-01-15', 'posted'], ['cash-sell', 'sale', 120, '2026-01-15', 'posted'],
+ ['cash-ambiguous-a', 'ambiguous', -100, '2026-01-15', 'posted'], ['cash-ambiguous-b', 'ambiguous', -100, '2026-01-15', 'posted'],
+ ['cash-wrong-sign', 'wrong-sign', 100, '2026-01-15', 'posted'], ['cash-wrong-date', 'wrong-date', -100, '2026-01-14', 'posted'],
+ ['cash-pending', 'pending-cash', -100, '2026-01-15', 'pending'], ['cash-collision', 'collision', -100, '2026-01-15', 'posted'],
+ ['collision', null, -1, '2026-01-15', 'posted'],
+ ['cash-archived-buy', 'archived-buy', -100, '2026-01-15', 'posted'], ['cash-archived-sell', 'archived-sell', 120, '2026-01-15', 'posted'],
+ ]) {
+ await f.db.prepare(`INSERT INTO transactions (id, account_id, linked_transaction_id, amount, date, status)
+ VALUES (?, 'cash', ?, ?, ?, ?)`).bind(id, link, amount, date, status).run()
+ }
+ await f.db.prepare("UPDATE accounts SET archived_at = 1, updated_at = 1 WHERE id = 'closed'").run()
+ const beforeTrades = (await f.db.prepare('SELECT rowid, * FROM investment_transactions ORDER BY id').all()).results
+ const beforeCash = (await f.db.prepare('SELECT rowid, * FROM transactions ORDER BY id').all()).results
+ const accounts = (await f.db.prepare('SELECT * FROM accounts ORDER BY id').all()).results
+ const revision = await f.db.prepare('SELECT revision FROM financial_data_revision WHERE id = 1').first('revision')
+ await migrate(f.db)
+ const links: Record = { purchase: 'cash-buy', sale: 'cash-sell', 'archived-buy': 'cash-archived-buy', 'archived-sell': 'cash-archived-sell' }
+ expect((await f.db.prepare('SELECT rowid, * FROM investment_transactions ORDER BY id').all()).results)
+ .toEqual(beforeTrades.map(row => ({ ...row, linked_transaction_id: links[String(row.id)] ?? null })))
+ expect((await f.db.prepare('SELECT rowid, * FROM transactions ORDER BY id').all()).results).toEqual(beforeCash)
+ expect((await f.db.prepare('SELECT * FROM accounts ORDER BY id').all()).results).toEqual(accounts)
+ expect(await f.db.prepare('SELECT revision FROM financial_data_revision WHERE id = 1').first('revision')).toBe(revision)
+ await expect(f.db.prepare("UPDATE investment_transactions SET notes = 'Changed' WHERE id = 'archived-buy'").run()).rejects.toThrow('ACCOUNT_ARCHIVED')
+ await f.db.prepare("UPDATE investment_transactions SET price = 401 WHERE id = 'purchase'").run()
+ expect(await f.db.prepare('SELECT revision FROM financial_data_revision WHERE id = 1').first('revision')).toBe((revision as number) + 1)
+ await expect(f.db.prepare("UPDATE investment_transactions SET linked_transaction_id = 'cash-buy' WHERE id = 'standalone'").run()).rejects.toThrow('UNIQUE')
+ await expect(f.db.prepare("UPDATE investment_transactions SET linked_transaction_id = 'missing' WHERE id = 'standalone'").run()).rejects.toThrow('FOREIGN KEY')
+ expect(await f.db.prepare('PRAGMA foreign_key_check').all()).toMatchObject({ results: [] })
+})
+
it('builds a fresh schema, records every migration, and does not replay applied ALTERs', async () => {
await migrate(f.db)
const history = (await f.db.prepare('SELECT migration_name FROM migration_history ORDER BY migration_name').all()).results
From 679dcec741945998cf72b88e458f75e39844b20b Mon Sep 17 00:00:00 2001
From: 31b4
Date: Sat, 17 Oct 2026 19:27:49 +0200
Subject: [PATCH 02/53] Set up investment epic integration workflow
---
.github/workflows/ci.yml | 4 ++--
AGENTS.md | 2 ++
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a27b16ce..20daaa12 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI
on:
pull_request:
- branches: [main]
+ branches: [main, 'epic/**']
push:
- branches: [main]
+ branches: [main, 'epic/**']
merge_group:
workflow_dispatch:
diff --git a/AGENTS.md b/AGENTS.md
index 8084262c..722d2f28 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,6 +14,8 @@ This guide applies to the entire repository. Finance Manager is a personal finan
Use this workflow for feature, bug, documentation, and task changes. A request to record an idea or bug as an issue stops after the issue is created unless implementation is also requested.
+**Investment Page Rework epic (#123):** use `epic/123-investment-rework` as the integration branch. Fetch its current remote tip before starting each sub-issue, branch from that tip, and target sub-issue pull requests to the epic branch. PR #124 is the first foundation sub-issue. Integrate the complete investment rework into `main` together only after the epic is finished and the user requests the final merge. This epic-specific base/target overrides steps 2 and 4 below; preserve the remaining issue, verification, and approval requirements.
+
1. **Issue first.** Use the issue number the user provides, or check for an existing matching issue before creating one in `apptrackit/finance`. If none exists, select `.github/ISSUE_TEMPLATE/feature.md` for a feature or idea, `bug.md` for a bug, or `task.md` for documentation or another task. Use the selected template's body headings to write the issue, replacing placeholders with the actual purpose, behavior, scope or reproduction steps, and observable acceptance criteria. Include relevant screenshots or links when available; do not invent or require images. When using `gh`, pass the completed body with `gh issue create --body-file`; do not submit untouched template text. Record the GitHub-assigned issue number and set applicable metadata as described below before starting implementation.
2. **Branch from current main.** Fetch `origin/main` before starting implementation. Create a branch at that fetched commit named `/-`: `feature/123-description`, `bug/123-description`, `docs/123-description`, or `task/123-description`. Use the issue number without `#` in the branch name. Preserve unrelated working-tree changes; use an isolated worktree when the current checkout is dirty or occupied.
3. **Implement and verify.** Keep the branch focused on the issue, run the relevant checks described here, review the diff, and commit the completed work. Push the branch to `origin`.
From 7d61f64a8f07fa83c908aa8f3a82f1ac2a7e2c65 Mon Sep 17 00:00:00 2001
From: 31b4
Date: Sat, 17 Oct 2026 19:33:05 +0200
Subject: [PATCH 03/53] Clarify investment sub-issue branch naming
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 722d2f28..aba88b3b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ This guide applies to the entire repository. Finance Manager is a personal finan
Use this workflow for feature, bug, documentation, and task changes. A request to record an idea or bug as an issue stops after the issue is created unless implementation is also requested.
-**Investment Page Rework epic (#123):** use `epic/123-investment-rework` as the integration branch. Fetch its current remote tip before starting each sub-issue, branch from that tip, and target sub-issue pull requests to the epic branch. PR #124 is the first foundation sub-issue. Integrate the complete investment rework into `main` together only after the epic is finished and the user requests the final merge. This epic-specific base/target overrides steps 2 and 4 below; preserve the remaining issue, verification, and approval requirements.
+**Investment Page Rework epic (#123):** use `epic/123-investment-rework` as the integration branch. Fetch its current remote tip before starting each sub-issue, branch from that tip using that sub-issue's number, and target sub-issue pull requests to the epic branch. The first foundation sub-issue is #122 (`task/122-investment-transfer-links`). Integrate the complete investment rework into `main` together only after the epic is finished and the user requests the final merge. This epic-specific base/target overrides steps 2 and 4 below; preserve the remaining issue, verification, and approval requirements.
1. **Issue first.** Use the issue number the user provides, or check for an existing matching issue before creating one in `apptrackit/finance`. If none exists, select `.github/ISSUE_TEMPLATE/feature.md` for a feature or idea, `bug.md` for a bug, or `task.md` for documentation or another task. Use the selected template's body headings to write the issue, replacing placeholders with the actual purpose, behavior, scope or reproduction steps, and observable acceptance criteria. Include relevant screenshots or links when available; do not invent or require images. When using `gh`, pass the completed body with `gh issue create --body-file`; do not submit untouched template text. Record the GitHub-assigned issue number and set applicable metadata as described below before starting implementation.
2. **Branch from current main.** Fetch `origin/main` before starting implementation. Create a branch at that fetched commit named `/-`: `feature/123-description`, `bug/123-description`, `docs/123-description`, or `task/123-description`. Use the issue number without `#` in the branch name. Preserve unrelated working-tree changes; use an isolated worktree when the current checkout is dirty or occupied.
From 34643221e6457e10ec36afc05e2e3e363757ad8e Mon Sep 17 00:00:00 2001
From: 31b4
Date: Sat, 17 Oct 2026 19:56:08 +0200
Subject: [PATCH 04/53] Define investment accounting and legacy compatibility
contract
---
AGENTS.md | 1 +
README.md | 2 +
.../investment-accounting-contract.test.ts | 202 +++++++
docs/investment-accounting.md | 184 ++++++
tests/README.md | 4 +-
tests/fixtures/investment-accounting-v1.json | 571 ++++++++++++++++++
6 files changed, 963 insertions(+), 1 deletion(-)
create mode 100644 api/src/tests/investment-accounting-contract.test.ts
create mode 100644 docs/investment-accounting.md
create mode 100644 tests/fixtures/investment-accounting-v1.json
diff --git a/AGENTS.md b/AGENTS.md
index 3e335ba2..7f585a7c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -127,6 +127,7 @@ The API defaults to private mode, verifying Cloudflare Access JWT signature, iss
- **Review drafts vs. upcoming projections:** MCP drafts have `status='pending'`, `pending_kind='mcp_review'`, and `review_source='chatgpt_mcp'`. They affect neither balances nor projections until manually confirmed in the app. Projection code must opt in with `pending_kind === 'upcoming'`, rather than include all pending rows. Keep review provenance and flags through edits and confirmation. See `client/src/lib/transaction-review.ts`.
- **Signs and transfers:** cash income is positive and expenses are negative. A regular transfer has linked debit/credit rows; cash-to-investment purchases store reciprocal `linked_transaction_id` values on the cash row and investment transaction (standalone trades keep a null link). `InvestmentTransferRepository` batches linked creation, edits, deletion, balance/quantity deltas, and audit entries with snapshot/account guards. Edit through the cash transaction; deleting either side removes the pair once. Reject inconsistent legacy pairs rather than mutating one side. `/transfers` rejects investment sources until a proper linked-sale workflow is implemented. Linked transfers are excluded from income/expense aggregates, but still matter when reconstructing account balances.
- **Investments:** `investment_transactions` is active buy/sell history; do not remove or consolidate it based on the misleading comment in migration 001. Market-priced investment balances represent share/coin quantities, while manual asset values follow their own valuation path. Distinguish holding-unit `currency`, fiat `quote_currency`, unit price, and converted cash value. API, client, and MCP have differing legacy paths; trace the actual path instead of assuming their valuation behavior is identical.
+- **Investment rework contract:** read `docs/investment-accounting.md` before implementing epic #123 children. It distinguishes current observed anchors from known openings, cash flows from remaining cost basis, total versus per-unit manual values, and daily estimated returns from exact trade-time returns. `tests/fixtures/investment-accounting-v1.json` contains synthetic expected results; its API contract checks validate the specification, not legacy runtime parity. Follow each child's scope and stop at its PR for user review.
- **Archive:** `accounts.archived_at` is independent of locks/exclusions. Archive requires zero balance/holding and no pending rows; it atomically pauses source/destination schedules and records an audit entry. Restore leaves schedules paused. Keep archived accounts in historical reads/exports, omit them from active selectors/current valuations, and require restore before account/ledger mutation. Database triggers guard both Workers. Permanent account deletion blocks linked transfers to avoid orphaning the other leg.
- **Locks:** persisted `accounts.is_locked` and API lock/unlock endpoints are authoritative. Preserve source and destination lock checks before mutations and the scheduler's skip behavior. Existing coverage varies by endpoint; test the path being changed rather than assume a database-wide guarantee.
- **Currency conversion:** `shared/currency.ts` defines validated conversion results for API, client, and MCP. API/client aggregates become unavailable when a required rate is absent or invalid; preserve native amounts and expose missing currency codes with retry. MCP retains its explicitly warned partial-total policy. Rates are relative to the named base currency; never substitute raw foreign amounts or rate `1`.
diff --git a/README.md b/README.md
index 1e88a8b2..d22afaee 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,8 @@ Market-priced holdings distinguish share/coin quantity from cash value. Their bu
Market quotes and exchange rates come from external services and may be unavailable or delayed. Investment and conversion behavior differs between some API, client, and MCP paths; contributors should follow the [financial rules in AGENTS.md](AGENTS.md#financial-rules-to-preserve) when changing calculations.
+The investment rework's [versioned accounting contract](docs/investment-accounting.md) records these implementation gaps and specifies opening quantities, exact cash/fees, remaining cost basis, manual valuations, historical FX, and estimated return calculations. Its [synthetic worked examples](tests/fixtures/investment-accounting-v1.json) are checked by the API suite. This is the specification for the remaining epic work, not a change to current runtime behavior.
+
### Recurring schedules
The API Worker runs recurring processing using the configured daily cron (`0 0 * * *`, midnight UTC). It creates posted transactions and updates balances for eligible schedules, respecting locks, archived accounts, end dates, remaining occurrences, and last-processed dates. Monthly days beyond the end of a month clamp to its final day.
diff --git a/api/src/tests/investment-accounting-contract.test.ts b/api/src/tests/investment-accounting-contract.test.ts
new file mode 100644
index 00000000..1a92b8e7
--- /dev/null
+++ b/api/src/tests/investment-accounting-contract.test.ts
@@ -0,0 +1,202 @@
+import { describe, expect, it } from 'vitest'
+import examples from '../../../tests/fixtures/investment-accounting-v1.json'
+import { convertCurrency, sumAvailable } from '../../../shared/currency'
+
+// Arithmetic checks for a reviewed specification, not the future investment
+// implementation. Its adapters must later run against these same golden inputs.
+const close = (actual: number | null, expected: number | null) => {
+ if (expected === null) expect(actual).toBeNull()
+ else {
+ expect(actual).not.toBeNull()
+ expect(actual!).toBeCloseTo(expected, 9)
+ }
+}
+
+describe('investment accounting contract v1 examples', () => {
+ it('reconciles remaining weighted-average basis, actual cash and lifetime gain', () => {
+ let units = 0
+ let basis = 0
+ let realized = 0
+ let invested = 0
+ for (const event of examples.weighted_average.events) {
+ const gross = event.quantity * event.price
+ const buy = event.type === 'buy'
+ close(event.net_cash, gross + (buy ? event.included_fee : -event.included_fee))
+ if (buy) {
+ units += event.quantity
+ basis += event.net_cash
+ invested += event.net_cash
+ } else {
+ expect(event.quantity).toBeLessThanOrEqual(units)
+ const removedBasis = basis * event.quantity / units
+ realized += event.net_cash - removedBasis
+ basis -= removedBasis
+ units -= event.quantity
+ invested -= event.net_cash
+ }
+ const value = units === 0 ? 0 : units * event.mark!
+ const unrealized = value - basis
+ const expected = event.expected
+ close(units, expected.quantity)
+ close(basis, expected.remaining_basis)
+ close(realized, expected.realized_gain)
+ close(invested, expected.net_invested)
+ close(units > 0 ? basis / units : null, expected.average_cost)
+ close(value, expected.value)
+ close(unrealized, expected.unrealized_gain)
+ close(realized + unrealized, expected.total_gain)
+ close(value - invested, expected.total_gain)
+ }
+ })
+
+ it('preserves a current observation without fabricating an opening acquisition', () => {
+ const fixture = examples.unknown_opening
+ close(fixture.stored_current_quantity * fixture.price, fixture.expected.value)
+ close(fixture.stored_current_quantity, fixture.expected.quantity)
+ close(fixture.stored_current_quantity - fixture.signed_activity_quantity, fixture.expected.residual)
+ expect(fixture.expected.opening_date).toBeNull()
+ expect(fixture.expected.historical_quantity).toBeNull()
+ expect(fixture.expected.remaining_basis).toBeNull()
+ expect(fixture.expected.net_invested).toBeNull()
+ expect(fixture.expected.total_gain).toBeNull()
+ expect(fixture.expected.reasons).toContain('quantity_mismatch')
+ })
+
+ it('allows a known remaining basis after disposing all unknown-cost units, not a known lifetime return', () => {
+ const fixture = examples.unknown_cost_disposal
+ close(fixture.opening_quantity - fixture.sale_quantity + fixture.rebuy_quantity, fixture.expected.quantity)
+ close(fixture.rebuy_cash, fixture.expected.remaining_basis)
+ expect(fixture.opening_cost).toBeNull()
+ expect(fixture.expected.lifetime_realized_gain).toBeNull()
+ expect(fixture.expected.lifetime_net_invested).toBeNull()
+ expect(fixture.expected.lifetime_total_gain).toBeNull()
+ })
+
+ it('does not treat known imported remaining basis as a lifetime cash contribution', () => {
+ const f = examples.known_basis_unknown_cash
+ close(f.opening_quantity * f.price, f.expected.value)
+ close(f.expected.value - f.remaining_basis, f.expected.unrealized_gain)
+ expect(f.expected.net_invested).toBeNull()
+ expect(f.expected.lifetime_realized_gain).toBeNull()
+ expect(f.expected.total_gain).toBeNull()
+ })
+
+ it('preserves small crypto units rather than rounding them as cash', () => {
+ const f = examples.fractional_crypto
+ close(f.quantity * f.price_usd + f.included_fee_usd, f.net_cash_usd)
+ close(f.net_cash_usd, f.expected.remaining_basis_usd)
+ close(f.net_cash_usd / f.quantity, f.expected.average_cost_usd)
+ close(f.quantity * f.mark_usd, f.expected.value_usd)
+ close(f.expected.value_usd - f.net_cash_usd, f.expected.unrealized_gain_usd)
+ expect(f.quantity).toBeGreaterThan(0)
+ expect(Math.round(f.quantity * 100) / 100).toBe(0)
+ })
+
+ it('keeps exact reporting-currency acquisition cost separate from current-FX native gain', () => {
+ const f = examples.cross_currency
+ const e = f.expected
+ close(f.quantity * f.execution_price, e.gross_execution)
+ close(f.net_cash_paid - f.included_fee_cash, e.gross_cash)
+ close(e.gross_cash / e.gross_execution, e.implied_cash_per_execution)
+ close(f.included_fee_cash / e.implied_cash_per_execution, e.fee_execution)
+ close(e.gross_execution + e.fee_execution, e.native_basis)
+ close(f.net_cash_paid, e.reporting_basis)
+ close(f.quantity * f.mark_price, e.native_value)
+ close(e.native_value * f.mark_huf_per_eur, e.reporting_value)
+ close(e.native_value - e.native_basis, e.native_unrealized_gain)
+ close(e.reporting_value - e.reporting_basis, e.reporting_unrealized_gain)
+ close(e.native_unrealized_gain * f.mark_huf_per_eur, e.incorrect_current_fx_gain)
+ expect(e.incorrect_current_fx_gain).not.toEqual(e.reporting_unrealized_gain)
+ })
+
+ it('carries a manual observation forward only, with no contribution on revaluation', () => {
+ const f = examples.manual_total
+ for (const observation of f.observations) {
+ const known = f.valuations.filter(value => value.date <= observation.date).at(-1)
+ close(known?.value ?? null, observation.expected_value)
+ }
+ close(f.acquisition_cost, f.expected.net_invested)
+ close(f.acquisition_cost, f.expected.remaining_basis)
+ close(f.expected.value - f.expected.remaining_basis, f.expected.unrealized_gain)
+ expect(f.expected.cash_delta_on_revaluation).toBe(0)
+ expect(f.expected.unit_delta_on_revaluation).toBe(0)
+ })
+
+ for (const period of examples.periods) {
+ it(`checks daily cash-flow timing and geometric linking: ${period.id}`, () => {
+ let previous: number | null = period.start_value
+ let product = 1
+ let covered = true
+ let activeDays = 0
+ let netFlow: number | null = 0
+ for (const day of period.days) {
+ const { value, contribution, withdrawal } = day
+ netFlow = netFlow === null || contribution === null ? null : netFlow + contribution - withdrawal
+ let factor: number | null = null
+ if (previous !== null && value !== null && contribution !== null) {
+ const denominator = previous + contribution
+ const inactive = previous === 0 && value === 0 && contribution === 0 && withdrawal === 0
+ if (inactive) factor = 1
+ else if (denominator > 0 && value + withdrawal >= 0) {
+ factor = (value + withdrawal) / denominator
+ activeDays++
+ }
+ }
+ close(factor, day.expected_factor)
+ if (factor === null) covered = false
+ else product *= factor
+ previous = value
+ }
+ const endValue = period.days.at(-1)!.value
+ const gain = endValue === null || netFlow === null ? null : endValue - period.start_value - netFlow
+ close(gain, period.expected_gain)
+ close(covered && activeDays > 0 ? product - 1 : null, period.expected_estimated_return)
+ })
+ }
+
+ it('preserves missing and invalid FX in the shared conversion contract', () => {
+ for (const f of examples.missing_inputs) {
+ if (f.amount === undefined) continue
+ const rates: Record = {}
+ for (const [currency, rate] of Object.entries(f.rates!)) {
+ if (typeof rate === 'number') rates[currency] = rate
+ }
+ const result = convertCurrency(f.amount, f.source!, f.target!, rates, f.base!)
+ close(result.value, f.expected_value!)
+ expect(result.missingCurrencies).toEqual(f.expected_missing)
+ }
+ expect(sumAvailable([100, null])).toBeNull()
+ })
+
+ it('distinguishes known current zero from missing price on a nonzero position', () => {
+ for (const f of examples.missing_inputs) {
+ if (f.quantity === undefined) continue
+ expect(f.price).toBeNull()
+ close(f.quantity === 0 ? 0 : null, f.expected_native_value!)
+ }
+ })
+
+ it('keeps investment, net-worth and market-today universes explicit', () => {
+ const f = examples.scopes
+ const active = f.holdings.filter(holding => !holding.archived)
+ const netWorth = active.filter(holding => !holding.excluded_net_worth)
+ const today = active.filter(holding => holding.pricing_method === 'market')
+ expect(active.map(holding => holding.id)).toEqual(f.expected.investment_active_ids)
+ expect(f.holdings.map(holding => holding.id)).toEqual(f.expected.historical_ids)
+ close(sumAvailable(active.map(holding => holding.current_value)), f.expected.investment_current_value)
+ close(sumAvailable(netWorth.map(holding => holding.current_value)), f.expected.net_worth_investment_value)
+ close(sumAvailable(today.map(holding => holding.today_gain)), f.expected.market_today_gain)
+ close(sumAvailable(today.map(holding => holding.prior_market_value)), f.expected.market_today_prior_value)
+ expect(active.filter(holding => !today.includes(holding)).map(holding => holding.id))
+ .toEqual(f.expected.market_today_excluded_ids)
+ })
+
+ it('includes dated FX movement in converted daily gain, not in native price change', () => {
+ const f = examples.daily_fx
+ close(f.current_price_usd - f.prior_price_usd, f.expected.native_price_change)
+ close((f.current_price_usd / f.prior_price_usd - 1) * 100, f.expected.native_price_change_percent)
+ close(f.quantity * f.prior_price_usd * f.prior_huf_per_usd, f.expected.prior_value_huf)
+ close(f.quantity * f.current_price_usd * f.current_huf_per_usd, f.expected.current_value_huf)
+ close(f.expected.current_value_huf - f.expected.prior_value_huf, f.expected.converted_gain_huf)
+ })
+})
diff --git a/docs/investment-accounting.md b/docs/investment-accounting.md
new file mode 100644
index 00000000..5143947e
--- /dev/null
+++ b/docs/investment-accounting.md
@@ -0,0 +1,184 @@
+# Investment accounting contract — version 1
+
+Status: implementation contract for [#126](https://github.com/apptrackit/finance/issues/126), under epic [#123](https://github.com/apptrackit/finance/issues/123). This PR changes documentation and synthetic examples only. It does not change schema, balances, endpoints, or displayed calculations. The rules below are requirements for the child implementations, not a claim that today's app already follows them.
+
+The product keeps direct **cash ↔ holding** entry. A holding account represents one instrument or manually valued asset, not a brokerage cash wallet. `investment_transactions` remains the investment activity ledger; linked `transactions` rows remain the actual cash ledger. The completed #122 / #125 relationship is the foundation.
+
+## 1. Evidence and implementation owners
+
+The inventory below was traced at epic commit `ce12e97` (after #125). File links identify executable paths rather than the unused API route registry. These observations are compatibility risks, not desired behavior.
+
+| Concern | Current implementation | Required owner |
+| --- | --- | --- |
+| Current units | [Investments utils](../client/src/components/investments-module/utils.ts) sums signed trade quantity and ignores starting `account.balance`. [Dashboard data](../client/src/hooks/useFinanceData.ts) and [MCP portfolio](../mcp/src/finance-service.ts) value stored balances. | #127 quantity/anchors; #131 calculations |
+| Starting balance and corrections | [Account service](../api/src/services/account.service.ts) creates an account with a balance but no opening trade. An edit can overwrite balance or create ordinary adjustment rows, including on an investment account. | #127 explicit anchors/corrections; #129 manual values |
+| Linked trades | [Transfer service](../api/src/services/transfer.service.ts) creates reciprocal cash-debit/buy records in the [pair repository](../api/src/repositories/investment-transfer.repository.ts). It rejects investment-source transfers. [Transaction service](../api/src/services/transaction.service.ts) edits the pair through cash and handles pair deletion; investment-side deletion also resolves the pair. | #128 linked sale/create/edit parity |
+| Standalone trades | `POST /transactions` on an investment account writes history and then units as independent writes. `POST /investment-transactions` writes history without adding units; its standalone deletion subtracts units. Its supplied `total_amount` need not equal quantity × price. Investment branches do not share ordinary pending-date handling. See [transaction service](../api/src/services/transaction.service.ts), [investment service](../api/src/services/investment-transaction.service.ts), [repository](../api/src/repositories/investment-transaction.repository.ts), [live routes](../api/src/index.ts). | #128 unify writers and reject unsupported future posting |
+| Currency and cost | Accounts have `currency` and optional `quote_currency`; trades have price/total but no execution currency, actual fee or reporting-date rate. Client portfolio assumes account trading currency; MCP portfolio/activity still assumes USD for market trades. Cash pair amount may differ from quantity × price. | #127 metadata; #128 execution facts; #131 parity |
+| Price lookup | [Yahoo price utility](../api/src/utils/yahoo-finance.util.ts) can fall back to current price for a past transaction. [Market service](../api/src/services/market-data.service.ts) exposes individual charts; previous-close/change/session data are not a validated common contract. | #128 execution prices; #130 market inputs |
+| Manual assets | Investments uses `account.balance` as value, calls it invested capital, and uses transaction net as gain. MCP uses the manual balance as value and withholds manual gain. There is no dated valuation table. | #129 valuation history; #131 metrics |
+| Aggregate scope | Investments includes all active investment accounts. MCP `portfolio` omits `exclude_from_net_worth`; Dashboard investment fetch includes all active holdings and [Dashboard](../client/src/pages/DashboardPage.tsx) adds that entire investment value to cash net worth, without applying investment net-worth exclusions. | #131 explicit view policy and Dashboard exclusion fix; #133 labels |
+| Missing inputs | [Shared FX](../shared/currency.ts) validates rates and returns unavailable conversion; the client withholds complete totals. MCP intentionally warns and reports partial converted totals. Some legacy missing-price paths still produce zero. | #130 source states; #131 output coverage |
+| Charts | [Instrument chart](../client/src/components/investments-module/InvestmentChart.tsx) hardcodes dollars and keeps one marker per nearest quote, overwriting multiple trades. It is not a portfolio series. No combined historical quantity/FX/manual valuation path exists. | #132 portfolio series; #134 details |
+| Refresh/export/revision | [Investment snapshot](../client/src/components/investments-module/data.ts) and [hook](../client/src/hooks/useInvestmentData.ts) publish complete session snapshots. [JSON export coverage](../shared/data-export.ts) names durable tables. Migrations 010/014/016/017 govern financial revisions, posted/review semantics, archives and transfer links. | All children preserve these boundaries |
+
+## 2. Stored facts and coverage
+
+### Holding identity and pricing
+
+- Category is `etf`, `stock`, `crypto`, or `manual`. Pricing method is independently `market` or `manual`. A manually priced ETF remains an ETF. Existing `manual` category maps to manual pricing; existing stock/crypto categories are retained. Do not guess ETF identity from names or sample symbols.
+- Unit is separate from fiat: market ETF/stock uses `SHARE`; crypto uses its coin unit. Quote/execution currency is fiat, e.g. EUR. Provider symbol includes its listing/quote pair; an account's human label can differ.
+- For category `manual`, version 1 uses one indivisible asset (`ITEM`) and a **total** value in a fiat valuation currency. Metadata such as physical weight is descriptive, not an implicit share count. Partial manual-asset disposal is unsupported; full disposal must be an explicit asset event. Manually priced ETF/stock/crypto instead retain their units and store **per-unit** manual prices. Every valuation records `total` versus `per_unit`; never multiply a total property/gold value by units again.
+- `accounts.balance` remains a current quantity mirror for ETF/stock/crypto, including manual pricing; for legacy manual-category assets it remains a current monetary-value mirror. Keep that compatibility explicit in all consumers; pricing method alone cannot determine the balance meaning.
+- Changing category, unit, instrument or valuation currency with existing history must be blocked unless an explicit conversion/correction preserves each dated fact. Changing provider mapping for the same listing can be audited; it must not reinterpret old execution currency or apply split adjustments twice.
+
+### Current quantities, openings and reconciliation
+
+Stored current quantities are recorded facts. A shorter activity list is not evidence that the rest of the position never existed. Preserve balances and ledger rows during migration.
+
+A new nonzero holding requires an opening boundary with quantity and a user-supplied effective date; known cost is optional. The boundary is an end-of-day position, followed only by later dated events. Same-day opening/trading requires an explicit sequence or a prior-day opening; do not silently replay the opening day's trades twice. New zero-start holdings have a known zero opening before the first trade.
+
+For an existing holding, record an **observed current anchor** at the upgrade boundary, with existing quantity/value and unknown historical cost/date coverage. This is not an acquisition, contribution or opening backdated to the first trade. A residual `stored units − signed trade units` is a reconciliation warning, not a fabricated buy. Even a zero residual does not prove complete history because legacy writers and overwrites differ.
+
+Historical replay is complete only after the user confirms a complete zero start or supplies a dated opening/correction that reconciles with stored current units. Prior history stays readable while unsupported derived metrics are unavailable. Corrections are dated, audited and non-cash; neither an observed anchor nor a correction is automatically invested capital. An imported opening with known remaining basis can support unrealized gain, but does not establish earlier realized gains or lifetime cash contributions. Those remain unknown unless separately confirmed. Block a conflicting write and request explicit reconciliation instead of replacing stored units with the trade sum. Known later activity can extend the observed boundary without proving earlier history.
+
+Use effective date, a durable per-holding same-day order, then stable ID for replay. For old rows, preserve existing relative `rowid` order in the migration; do not depend on `created_at` being present, unique or chronological. A legacy ordering conflict is flagged rather than fixed by sorting buys first. Backdated changes must replay the affected suffix and reject any negative supported quantity. Future-dated legacy rows remain readable/flagged; their inconsistent balance impact is not silently undone in a metadata upgrade.
+
+### Trade and cash facts
+
+A new market/unit trade records positive quantity `q`, executed unit price `p`, execution currency `E`, gross execution amount `G = q × p`, effective local date/order, and its buy/sell direction. The price is an entered/confirmed execution fact. A quote is only a suggestion; never substitute today's quote for a past fill.
+
+A linked trade also records the **exact net cash** `M` in the cash account currency `C`, using the cash row as the authoritative ledger amount. Buy: cash delta `−M`, units `+q`. Sell: cash delta `+M`, units `−q`. Reciprocal IDs, amounts, unit delta, audits and replay marker commit together. The linked row is excluded from ordinary income/expense aggregates. A fee already included in this row is not also posted as a second expense.
+
+Version 1 accepts an optional explicit fee `f >= 0` in **cash currency C**, included in `M`; fees in another currency/account require a future separate flow. Fee state distinguishes `known` (including explicit zero) from `unknown`. If `E == C`, known fees require buy `M = G + f`, sell `M = G − f` within the currency's settlement-rounding policy. If fee is unknown, require the user to enter/confirm it for new same-currency trades rather than classify a discrepancy automatically.
+
+For a cross-currency trade with a known included fee, gross cash is buy `M − f`, sell `M + f`. Its ratio to `G` is the **implied settlement conversion**, not a pure market FX rate or a way to infer a fee/spread. It must be positive. To express the explicitly known fee in execution currency, divide it by that implied ratio. Exact cash `M` never comes from that derived ratio. If fee breakdown is unknown, retain `M` and `G`, but withhold fee-separated/native basis metrics needing the breakdown. Reporting-currency basis may still be known from exact cash and dated reporting FX.
+
+Existing standalone trades remain standalone and never acquire a fake cash leg. Keep their quantity, price and recorded `total_amount` even if inconsistent with `q × p`; flag the discrepancy. Reconciliation must first establish their unit impact and currency. Without confirmed external contribution/proceeds, they do not provide complete net-invested or cash-based gain history. A later explicit legacy reconciliation may supply missing facts without moving cash a second time. New UI trades use linked cash entry; alternative standalone writers must delegate to one atomic invariant-preserving service or be read-only until supported.
+
+Reject new future-dated investment posting using `X-Client-Date` (UTC fallback). No pending investment-pair lifecycle is added. Keep ordinary pending/upcoming/MCP review rows outside these calculations and preserve existing cash-to-cash confirmation semantics. Locked/archived accounts cannot be mutated. Replays must apply once; idempotency scope covers payload/account pair and rejects reuse with different content.
+
+### Coverage is per metric
+
+A known current quantity and valid quote can give a current value with unknown basis or opening date. A known opening value can start a later range without proving lifetime performance. A zero holding needs no quote to contribute known zero **current** value, but its past ownership still needs historical inputs.
+
+Each read exposes status/reason and affected holding/date/currency, independently for quantity, value, cost, cash flows, daily move and history. Core reasons: `unknown_opening`, `quantity_mismatch`, `unknown_cost`, `unknown_execution_currency`, `unknown_cash_flow`, `unknown_fee_breakdown`, `missing_price`, `stale_price`, `missing_fx`, `missing_valuation`, `ambiguous_event_order`, `unsupported_event`. Field names can follow existing DTO conventions, but a null must not become zero at a mapper boundary.
+
+API/client **complete** aggregate amounts and allocation weights are unavailable if any included nonzero holding lacks required value/FX. Native known amounts remain visible. MCP retains warned **partial** totals and covered/excluded metadata; an omitted amount is not described as complete. Current value coverage does not imply complete return coverage. An excluded holding must not prevent a total it is not part of.
+
+## 3. Calculation rules
+
+### Currency conversion and basis
+
+Use the existing `shared/currency.ts` direction: rate tables contain units of each named currency per unit of the named base. `convert(x, S, R) = x / rate[S] × rate[R]`, with base rate exactly 1. Same currency needs no external rate. Missing/nonpositive/nonfinite required rates make that conversion unavailable.
+
+Current value uses a quote/as-of and current FX/as-of. Historical values use the dated price and dated FX for that observation; cash contributions and reporting cost use FX on the cash-flow date. Never retranslate accumulated historic cost at today's FX. Record provenance/dates; market-cache refresh does not rewrite execution or cash facts.
+
+Maintain two optional cost ledgers: execution-native basis (for a holding whose execution currency is fixed) and reporting-currency basis. Convert each acquisition into reporting currency **when acquired**, using exact cash when available. A supplied native opening basis with unknown acquisition dates is not converted at the opening/anchor day's rate and called historic reporting cost; require confirmed reporting basis or the dated acquisition facts, otherwise withhold that reporting gain. Realized and unrealized report gains derive from that reporting basis, not from converting the final native gain at today's rate. If the reporting currency changes, recompute from dated source facts. Do not relabel a prior HUF total as USD. Unknown historic conversion can withhold reporting gain while leaving native gain available.
+
+For units `Q > 0` and known remaining basis `B`:
+
+- Average remaining cost per unit `A = B / Q`.
+- Buy `q`: `Q' = Q + q`, `B' = B + acquisition_cost`. Reporting acquisition cost is exact `M` converted from cash currency at acquisition date, including known/unknown included fees already present in `M`. Native acquisition cost is `G + explicit fee in E`, and is unavailable if its fee/currency breakdown is unknown.
+- Sell `q <= Q`: remove `basis_sold = B × q / Q`; `B' = B − basis_sold`, `Q' = Q − q`; realized gain is net proceeds minus `basis_sold`, in the same ledger/currency. At full sale set remaining units/basis to exactly zero within the validated unit precision. Keep realized gain and contribution history after closure.
+- Rebuy starts from the remaining basis, never an average over every historical buy. If earlier unknown-cost units remain, a new buy does not make their basis known. A complete disposal leaves a known zero **remaining** basis so a later buy can have known current basis; earlier realized/lifetime gain remains unknown.
+
+Apply currency/unit precision only at the defined boundaries: parse finite decimal drafts, retain fractional units (including small crypto quantities), do not round units as fiat. Do not claim arbitrary decimal precision from SQLite REAL. #127/#128 must declare supported unit precision and settlement minor units, reject overflow/underflow and test rounding. Display formatting never changes stored cash or units.
+
+### Metric dictionary
+
+All formulas use the same included holding universe and the selected reporting currency R unless labeled native. Unknown required inputs yield null with coverage. Values below are **not** interchangeable.
+
+| Visible metric | Formula and sources | Coverage and denominator |
+| --- | --- | --- |
+| Quantity | Reconciled current mirror / dated anchor plus ordered signed unit events | Known current units can survive incomplete historical replay. Negative holdings unsupported. Manual-category total assets display one item while owned. |
+| Price | Valid market unit quote or dated manual per-unit value, with its actual fiat currency/time | Missing is unavailable, not zero. A known zero manual valuation or confirmed worthless price is valid zero, distinguishable from absent quote. |
+| Current value V | Unit holding `Q × price`; manual total asset uses latest total valuation directly; convert each to R | Current market/FX provenance required. Stale last good value may remain visible with stale status; not fresh daily change. |
+| Net invested N | Confirmed cumulative external contributions minus withdrawals, each converted on flow date | Can be zero/negative. Includes exact linked purchase cash and net sale proceeds, plus confirmed opening contributions; not today's value, cost basis, or the raw sum of legacy trade totals. Unknown old flows make lifetime N unavailable. |
+| Remaining cost B / average cost | Moving weighted-average basis described above | Zero remaining units: B = 0; average cost is unavailable, not zero. Unknown old cost is not zero. Native and reporting bases can have different coverage. |
+| Realized gain L | Sum of net sale proceeds minus the removed basis | Sold holdings retain it. Unknown disposed basis withholds affected realized/lifetime totals, even after full liquidation. |
+| Unrealized gain U | `V − B` in the same currency/basis ledger | Known current value and known remaining basis required. At known full disposal U = 0. |
+| Total gain | `L + U`; with complete acquisition/cash history, also `V − N` | Only a confirmed opening contribution counts in N; opening remaining basis alone proves neither N nor earlier L. Both gain identities must reconcile when fully covered; do not use a current observed anchor as a purchase. |
+| Holding Return column | Amount = total gain; percentage = daily-linked estimated return since the earliest fully covered performance boundary | Label the percentage's covered start/method. Do not divide gain by N. If history is insufficient, keep known gain amount and show percentage unavailable. Detail can separately show unrealized gain and U/B when B > 0, explicitly labeled. |
+| Native price change today | `price_latest − previous_session_close`; percent divides by previous close > 0 | Use the instrument's exchange session; crypto compares the defined UTC daily close. Stale quotes/missing previous close withhold the change. This is an instrument price change, not account profit. |
+| Portfolio Today | Flow-adjusted gain for today's local reporting day using known prior boundary and current as-of values | Exclude manually priced holdings from the **market Today** statistic and name excluded/stale/missing coverage. Include FX movement for included foreign values. The market subset must be explicit, not presented as whole-portfolio complete. Its percentage uses the daily formula below for that same subset. |
+| Allocation / portfolio weight | `holding value / total value × 100` | Complete included current value and total > 0 required. Empty/zero-value portfolio has no percentages, not NaN or fictitious equal weights. Type/quote-currency allocation sums the same positions. |
+| Range gain | `V_b − V_(a−1) − sum(C_d − W_d)` for all dates a…b inclusive | Complete start/end values and external flows required; not lifetime gain minus deposits guessed at current FX. May be known even when intermediate return percentages are unavailable. |
+| Range estimated return | Geometrically linked daily factors below, ×100 for display | Requires every active daily factor, same universe and consistent date/flow timing. No gain/N fallback. A zero starting value alone does not force null if confirmed contributions fund the first active day. |
+
+### Local dates, history and return timing
+
+Version 1 chart ranges are local reporting **calendar days**, with `a…b` inclusive and opening value at close of `a−1`. YTD begins January 1, month/year subtraction clamps to valid calendar dates, and All begins at the earliest supported boundary (not an invented account creation date). Preserve actual business dates as YYYY-MM-DD, not UTC-midnight conversions that shift user dates.
+
+A historical daily point includes that day's ordered events and final valid observations. Use raw/unadjusted price history with the contemporaneous raw units. Do not multiply today's split-adjusted prices by old raw units. Historical split support requires corresponding unit events; otherwise mark the affected history unsupported. Closed/archived holdings contribute while owned, zero after disposal, and retain contribution/realized history. Archive/lock status is not a price or zero-value event.
+
+Known prior close can carry through scheduled non-trading dates, with original source time. It cannot fill an unknown pre-listing period or conceal an outage. A manual value carries forward from its effective date until changed, never backward from a future appraisal. An unknown opening cannot be filled with today's units. Revisions to trades/anchors/valuations invalidate the affected historical suffix; revise cached market/FX observations using source revision metadata separately.
+
+Daily end points use the last valid observation **at or before** the reporting cutoff, with session timestamp and coverage. Current local day is an explicitly provisional point at a common response as-of, not tomorrow's close. Non-aligned exchanges/FX and crypto observations carry their real dates; exact timestamp FX is not claimed from daily inputs. A price session's native Today label can therefore differ from the portfolio's local Today range; show both source times.
+
+Because the app owns holdings but no broker cash wallet, a buy is an external contribution to the investment universe and a sale is a withdrawal. For the daily estimate, positive contributions `C_d` are treated at start of day and positive withdrawals `W_d` at end of day:
+
+```
+D_d = V_(d−1) + C_d
+F_d = (V_d + W_d) / D_d
+r_d = F_d − 1
+estimated_return(a,b) = product(F_d for covered active days) − 1
+```
+
+Use exact cash including included fees in C/W, not execution gross plus a second fee. Do not net C and W before the denominator: a same-day round trip has positive funding even if net flows are zero. The daily formula is an application timing approximation for intraday trades, **not an exact trade-time TWR or a GIPS compliance claim**. The UI label is **Estimated return**, with timing/method in its tooltip. The standard distinction between exact subperiod valuation and approximate cash-flow methods, plus consistent flow timing and geometric linking, is described in the [CFA Institute GIPS handbook, calculation methodology](https://www.gipsstandards.org/standards/gips-standards-for-firms/gips-standards-handbook-for-firms/). The particular mixed timing above is this app's documented convention.
+
+If D = 0 and V = C = W = 0, skip an inactive day (factor 1 only when its zero state is known). A range with no active days has no return percentage, rather than a displayed 0%. If D <= 0 otherwise, or a numerator is negative, the factor is unavailable. A valid zero factor represents complete loss and stays zero when linked. Full liquidation does not reset historic gain; subsequent confirmed funding begins another active segment linked by the same rule. Missing active days invalidate the selected range percentage; never bridge them by silently skipping. No annualization is required. Dollar gain is not the sum of percentage returns.
+
+### Manual observations and unsupported events
+
+A manual acquisition records known cost/contribution and its own linked cash movement when funded through the app; imported/legacy cost does not create a second debit. A revaluation only updates dated value/history/current mirror and audit, never cash, units or invested capital. Known zero is allowed. For a per-unit manual price, units still follow trade history; for a total manual asset, full disposal requires a separate explicit owned/disposed event and known proceeds. Ordinary balance overwrite is not the future valuation workflow.
+
+Default stale-manual attention threshold is **90 local calendar days** since the last effective observation, with exact last-valued date/age exposed. This is an app reminder, not a claim that an appraisal is wrong; carry-forward history remains a known last observation, not live market performance. A correction to the same effective date updates that one observation under snapshot guards, preserving old/new values in audit rather than creating two competing prices. Future valuations are rejected; backdated corrections do not replace a later current value.
+
+No automated splits, dividend/reinvestment feed, in-kind transfer engine or tax-lot reporting is introduced. Preserve any existing cash dividend as cash history without relabeling it a linked sale. If known distributions/in-kind/split events make an investment's performance incomplete, flag it; price appreciation alone is not silently called total return. An unsupported history event never blocks showing independently known current value. Dedicated event support can be a later feature.
+
+## 4. View scopes and behavior
+
+- Investments page: all active investment holdings, regardless of `exclude_from_net_worth`. Category/allocation selection filters the holdings presentation; portfolio headline and chart remain the full investment universe and are labeled accordingly. A holding detail/history explicitly scopes to that holding, including its closed history.
+- Dashboard/net worth and MCP `portfolio`: respect `exclude_from_net_worth` for their investment aggregate; native current value for the same included account still agrees. Preserve MCP's warned partial-FX policy. Do not change cash balance/spending-estimate filters to mimic net-worth filters. Cash-balance analytics excludes investments by its own definition.
+- Historical Investments series uses the active **and archived** investment universe over ownership dates. Net-worth exclusions are a current view policy, not time-varying historical facts; a net-worth-scoped historical endpoint, if added, must state it uses today's exclusion selection. Restoring an account does not resume its schedules.
+- Currency mode recomputes the appropriate currency ledgers/dated conversions; it does not convert an old lifetime-gain number at current FX. Per-account native charts remain native. Price sparklines show the instrument's price, not portfolio profit; manual total-value sparklines show dated value steps.
+- Privacy follows the mounted app provider, including values/units/costs/percentage/weight policies, chart axes and tooltips, trade and manual previews, hidden description leaks and forecasts. Retain existing privacy-mode distinctions; no second investment-only privacy store. Use theme tokens and accessible keyboard/touch controls.
+- Complete account/history reads replace snapshots atomically; failed refreshes keep the last complete snapshot and mark its source age. Do not persist financial snapshots in browser storage. A market partial response is not a successful zero-value portfolio. Mutations invalidate current and hidden history/detail snapshots together; failed saves keep drafts open.
+
+## 5. Small additive schema plan, owned by child issue
+
+These are the minimum **logical records/fields**, not SQL to apply from this PR. Names can follow implementation conventions without changing their meaning. Keep migration 017 and all applied migrations unchanged. Allocate the next filename from the actual epic tip when each child starts, not from a preassigned numbering list.
+
+| Owner | Additive durable data | Upgrade/backfill policy |
+| --- | --- | --- |
+| #127 holdings | Pricing method and explicit unit/value interpretation; category enum extended to ETF; provenance/coverage for legacy metadata. A position-anchor/correction record with account, known effective boundary/order, observed/opening kind, units (or owned state for total assets), optional cost/currency and provenance. Current balance remains a mirror. | Preserve categories/balances. Legacy manual → total/manual pricing; existing stock/crypto retain category/unit. Create observed migration-boundary anchors, not backdated opening buys. No inferred costs/dates/ETF names; unknown stays null. |
+| #128 trades | Per-trade execution currency and gross facts (retain original total), explicit included fee amount/currency/state, stable effective order, reporting-cost source facts, reciprocal cash reference already from 017. Bounded idempotency/replay storage if not safely reusable. | Preserve ids, row order, amounts and links. Copy a known currency only with auditable evidence; current account metadata is a candidate requiring confirmation where old history could differ. Do not default legacy execution currency to USD, infer a fee or attach cash to standalone trades. Snapshot-guard pair writes and anchor/unit updates in one batch. |
+| #129 manual history | Dated total/per-unit valuation observations and source/as-of/audit metadata; manual acquisition/disposal/known-contribution facts if existing trade shape cannot represent these without confusing quantity and value. | Baseline legacy current value at the observed upgrade boundary, cost unknown. Keep earlier history for review; no invented appraisal/purchase trail. Unique effective observation with guarded audited correction; acquisition/cash flow is a distinct event. |
+| #130 sources | Provider-neutral price/FX records or disposable caches with symbol/pair, observation date/time, base/quote, raw/adjusted kind, provenance and freshness. | No copied prototype data, no current FX labeled historic. Explicit schema/export policy for reconstructible caches; never treat a cache row as a cash event or user-confirmed cost. |
+| #131/#132 metrics/series | Prefer derived reads/shared pure functions and bounded response contracts. Store only source facts; any derived cache is keyed by financial + market/FX revisions, scope, currency and range. | Do not add a parallel ledger of computed returns or overwrite immutable saved forecast snapshots. Cache invalidation cannot erase user facts. |
+
+All new user financial records belong in `shared/data-export.ts`, restore/migration documentation and relevant MCP read models. Forecast source-revision triggers must include new user-owned anchors/corrections/valuations and new trade financial fields. Migration 017's investment UPDATE trigger names its old columns explicitly: **adding a field alone will not invalidate forecasts for edits to that field**. Link-only repair remains metadata-only. Preserve pending `mcp_review` projection isolation and financial-source revision rules. Market/cache refresh gets separate freshness/versioning; it does not pretend to be a posted ledger change. Archive/lock guards must cover new writers/tables and manual-current-mirror updates in the same atomic operation.
+
+Each child schema PR must prove fresh install and upgrade from its preceding schema on disposable Worker/D1, compare old columns/rowids/balances/links, document intentional backfills and test rollback. Metadata migration must not silently rebalance the app. The October 12 export rehearsal proves 017 on that snapshot only; use a fresh export copy for the final all-migration rehearsal when available. Original exports stay untouched, private copies are removed, and reports contain no personal account/amount/identifier data. No remote migrations/deployments occur as verification.
+
+## 6. Worked examples and implementation gate
+
+[Machine-readable examples](../tests/fixtures/investment-accounting-v1.json) use only synthetic accounts, dates, decimal values and ratios. [Contract checks](../api/src/tests/investment-accounting-contract.test.ts) independently verify their arithmetic/identities and missing-data cases in the normal API test suite; they deliberately do not call the current legacy calculator or install a future production calculator. Future #127–#134 implementations must consume/adapt these same expectations rather than changing them to fit legacy results.
+
+| Example | Expected result |
+| --- | --- |
+| Buy 10 @ 100 + 10 fee; sell 4 @ 120 − 4 fee; buy 2 @ 90 + 2 fee; mark 8 @ 110 (same fiat) | Initial basis 1010; removed basis 404; sale realized 72; final basis 788, average 98.5; V 880, N 716, U 92, total gain 164. All-buys average would be wrong. |
+| Sell the last 8 @ 110 with zero fee, then buy 1 @ 50 | Before rebuy Q/B = 0, cumulative N = −164, realized gain 164, average cost unavailable. Rebuy Q = 1, B = 50, N = −114; lifetime gain still includes realized 164. |
+| Known current 5 shares, known dated buy history 2, price 20, unknown opening/cost | Current V = 100; current units stay 5. Residual 3 is a warning, not a buy; B/N/gain/earlier history unavailable. |
+| Imported opening with 3 shares, known remaining basis 90 and current price 40, unknown earlier cash/sales | V = 120, B = 90, U = 30; lifetime N/L/total gain remain unavailable. The opening basis is not a fabricated contribution. |
+| Buy 2 @ EUR 50; exact HUF debit 40,400 including HUF 400 fee; later price EUR 60 and HUF/EUR 410 | Gross EUR 100, implied settlement 400 HUF/EUR, native fee EUR 1, native basis EUR 101. Reporting basis HUF 40,400, V HUF 49,200, unrealized gain HUF 8,800; current-FX translation of native gain gives 7,790 and is not the reporting result. |
+| Manual total asset bought for 1000, later valued 1200 | Cash unchanged by appraisal, N/B 1000, U/gain 200. Value is 1000 before appraisal, 1200 from its date; no backward fill. |
+| Value 100 → 200 with contribution 100 and unchanged prices | Range gain 0, daily factor 1, estimated return 0%; deposit is not profit. |
+| Same-day buy cash 100 and sell proceeds 110, start/end holdings zero | Net invested −10, gain 10; factor 110/100 = 1.1. Netting flows in the denominator would incorrectly make it zero. |
+| Consecutive factors 1.1 and 1.1, second day contribution 100 | Ending value 231 from V0=100; N increment 100; range gain 31, estimated return 21%, not 31% and not 20%. |
+| Missing price/FX, zero units, manual/market mix and foreign basis | Preserve known native/current fields; unavailable required totals/percentages stay null with reasons. Zero holdings need no quote for current zero. Market Today coverage explicitly omits manual/stale positions. |
+
+Decisions for this MR review are the accounting rules above: remaining weighted-average basis, exact cash with included fees in cash currency, conservative legacy anchors, full-total versus unit manual valuation, daily estimated-return timing, portfolio scope and 90-day manual reminder. They are implementation defaults, not tax/accounting advice. No remaining product decision blocks writing these examples; later provider selection, precise storage names, supported numeric precision and UI copy are engineering choices owned by the named children and must be tested/documented before their PRs are ready. If review changes a financial rule, update this version and fixtures together before implementing dependents.
+
+Stop after the #126 MR for user verification. #127–#135 stay planned until review allows the next child; no epic/main merge or production write is implied.
diff --git a/tests/README.md b/tests/README.md
index 36d8a2eb..14f6f78e 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -28,7 +28,7 @@ npm run test:integration -- -t 'rolls back'
| Workflow validation | Invalid Actions syntax, expressions, and shell commands |
| CI passed | One stable check that requires every preceding job to succeed |
-The workflow runs on pull requests and pushes to `main`, merge queues, and manual dispatch. It cancels superseded runs, uses timeouts and read-only repository permissions, pins Actions to commits, and uploads JUnit reports for seven days. Failed integration tests print the harness's Worker logs. Reports use synthetic fixtures only.
+The workflow runs on pull requests and pushes to `main` and `epic/**`, merge queues, and manual dispatch. It cancels superseded runs, uses timeouts and read-only repository permissions, pins Actions to commits, and uploads JUnit reports for seven days. Failed integration tests print the harness's Worker logs. Reports use synthetic fixtures only.
Require **CI passed** in GitHub branch protection or a ruleset to make these checks block merging. Adding the workflow does not itself change repository protection settings. The original four check names are retained for existing rules.
@@ -51,6 +51,8 @@ The financial scenarios assert both API responses and persisted balances/rows. T
## Adding a regression
+The investment rework's [accounting contract](../docs/investment-accounting.md) has reusable [synthetic examples](fixtures/investment-accounting-v1.json), checked by `api/src/tests/investment-accounting-contract.test.ts` in the normal API suite. These checks verify worked arithmetic, coverage expectations and the existing shared FX contract; they do not certify that legacy investment endpoints implement the future rules. Future investment implementations should run their adapters against the same expected results. Use `npm test -w api -- src/tests/investment-accounting-contract.test.ts` for a focused check.
+
Choose a unit test for a pure calculation or validation rule. Choose integration when correctness depends on SQL, migrations, multiple writes, bindings, authentication, or API/MCP agreement. Reproduce the failure before fixing it; assert the resulting financial state, including what must remain unchanged on failure. Avoid tests that reimplement the production SQL in a mock.
Keep fixtures synthetic, external calls intercepted, and assertions independent of test order. Add a populated upgrade fixture when introducing a migration. Keep harness runtime flags and compatibility dates aligned with `api/wrangler.toml.example` and `mcp/wrangler.toml.example`. Do not regenerate baselines or disable checks merely to obtain a green run.
diff --git a/tests/fixtures/investment-accounting-v1.json b/tests/fixtures/investment-accounting-v1.json
new file mode 100644
index 00000000..91091dfc
--- /dev/null
+++ b/tests/fixtures/investment-accounting-v1.json
@@ -0,0 +1,571 @@
+{
+ "contract_version": 1,
+ "purpose": "Synthetic expected results for docs/investment-accounting.md; not a production calculator, customer data, or migration input.",
+ "weighted_average": {
+ "currency": "USD",
+ "events": [
+ {
+ "type": "buy",
+ "quantity": 10,
+ "price": 100,
+ "included_fee": 10,
+ "net_cash": 1010,
+ "mark": 100,
+ "expected": {
+ "quantity": 10,
+ "remaining_basis": 1010,
+ "realized_gain": 0,
+ "net_invested": 1010,
+ "average_cost": 101,
+ "value": 1000,
+ "unrealized_gain": -10,
+ "total_gain": -10
+ }
+ },
+ {
+ "type": "sell",
+ "quantity": 4,
+ "price": 120,
+ "included_fee": 4,
+ "net_cash": 476,
+ "mark": 110,
+ "expected": {
+ "quantity": 6,
+ "remaining_basis": 606,
+ "realized_gain": 72,
+ "net_invested": 534,
+ "average_cost": 101,
+ "value": 660,
+ "unrealized_gain": 54,
+ "total_gain": 126
+ }
+ },
+ {
+ "type": "buy",
+ "quantity": 2,
+ "price": 90,
+ "included_fee": 2,
+ "net_cash": 182,
+ "mark": 110,
+ "expected": {
+ "quantity": 8,
+ "remaining_basis": 788,
+ "realized_gain": 72,
+ "net_invested": 716,
+ "average_cost": 98.5,
+ "value": 880,
+ "unrealized_gain": 92,
+ "total_gain": 164
+ }
+ },
+ {
+ "type": "sell",
+ "quantity": 8,
+ "price": 110,
+ "included_fee": 0,
+ "net_cash": 880,
+ "mark": null,
+ "expected": {
+ "quantity": 0,
+ "remaining_basis": 0,
+ "realized_gain": 164,
+ "net_invested": -164,
+ "average_cost": null,
+ "value": 0,
+ "unrealized_gain": 0,
+ "total_gain": 164
+ }
+ },
+ {
+ "type": "buy",
+ "quantity": 1,
+ "price": 50,
+ "included_fee": 0,
+ "net_cash": 50,
+ "mark": 50,
+ "expected": {
+ "quantity": 1,
+ "remaining_basis": 50,
+ "realized_gain": 164,
+ "net_invested": -114,
+ "average_cost": 50,
+ "value": 50,
+ "unrealized_gain": 0,
+ "total_gain": 164
+ }
+ }
+ ]
+ },
+ "unknown_opening": {
+ "stored_current_quantity": 5,
+ "signed_activity_quantity": 2,
+ "price": 20,
+ "expected": {
+ "quantity": 5,
+ "value": 100,
+ "residual": 3,
+ "opening_date": null,
+ "remaining_basis": null,
+ "net_invested": null,
+ "total_gain": null,
+ "historical_quantity": null,
+ "reasons": [
+ "unknown_opening",
+ "quantity_mismatch",
+ "unknown_cost",
+ "unknown_cash_flow"
+ ]
+ }
+ },
+ "unknown_cost_disposal": {
+ "opening_quantity": 3,
+ "opening_cost": null,
+ "sale_quantity": 3,
+ "net_sale_cash": 90,
+ "rebuy_quantity": 1,
+ "rebuy_cash": 20,
+ "expected": {
+ "quantity": 1,
+ "remaining_basis": 20,
+ "lifetime_realized_gain": null,
+ "lifetime_net_invested": null,
+ "lifetime_total_gain": null
+ }
+ },
+ "cross_currency": {
+ "quantity": 2,
+ "execution_price": 50,
+ "execution_currency": "EUR",
+ "cash_currency": "HUF",
+ "reporting_currency": "HUF",
+ "net_cash_paid": 40400,
+ "included_fee_cash": 400,
+ "mark_price": 60,
+ "mark_huf_per_eur": 410,
+ "expected": {
+ "gross_execution": 100,
+ "gross_cash": 40000,
+ "implied_cash_per_execution": 400,
+ "fee_execution": 1,
+ "native_basis": 101,
+ "reporting_basis": 40400,
+ "native_value": 120,
+ "reporting_value": 49200,
+ "native_unrealized_gain": 19,
+ "reporting_unrealized_gain": 8800,
+ "incorrect_current_fx_gain": 7790
+ }
+ },
+ "manual_total": {
+ "acquisition_date": "2026-01-05",
+ "acquisition_cost": 1000,
+ "valuations": [
+ {
+ "date": "2026-01-05",
+ "value": 1000
+ },
+ {
+ "date": "2026-02-01",
+ "value": 1200
+ }
+ ],
+ "observations": [
+ {
+ "date": "2026-01-04",
+ "expected_value": null
+ },
+ {
+ "date": "2026-01-05",
+ "expected_value": 1000
+ },
+ {
+ "date": "2026-01-31",
+ "expected_value": 1000
+ },
+ {
+ "date": "2026-02-01",
+ "expected_value": 1200
+ }
+ ],
+ "expected": {
+ "net_invested": 1000,
+ "remaining_basis": 1000,
+ "value": 1200,
+ "unrealized_gain": 200,
+ "cash_delta_on_revaluation": 0,
+ "unit_delta_on_revaluation": 0
+ }
+ },
+ "periods": [
+ {
+ "id": "deposit_is_not_profit",
+ "start_value": 100,
+ "days": [
+ {
+ "value": 200,
+ "contribution": 100,
+ "withdrawal": 0,
+ "expected_factor": 1
+ }
+ ],
+ "expected_gain": 0,
+ "expected_estimated_return": 0
+ },
+ {
+ "id": "funded_zero_start",
+ "start_value": 0,
+ "days": [
+ {
+ "value": 100,
+ "contribution": 100,
+ "withdrawal": 0,
+ "expected_factor": 1
+ }
+ ],
+ "expected_gain": 0,
+ "expected_estimated_return": 0
+ },
+ {
+ "id": "fee_on_first_buy",
+ "start_value": 0,
+ "days": [
+ {
+ "value": 1000,
+ "contribution": 1010,
+ "withdrawal": 0,
+ "expected_factor": 0.9900990099009901
+ }
+ ],
+ "expected_gain": -10,
+ "expected_estimated_return": -0.009900990099009901
+ },
+ {
+ "id": "growth_with_later_deposit",
+ "start_value": 100,
+ "days": [
+ {
+ "value": 110,
+ "contribution": 0,
+ "withdrawal": 0,
+ "expected_factor": 1.1
+ },
+ {
+ "value": 231,
+ "contribution": 100,
+ "withdrawal": 0,
+ "expected_factor": 1.1
+ }
+ ],
+ "expected_gain": 31,
+ "expected_estimated_return": 0.21
+ },
+ {
+ "id": "same_day_round_trip",
+ "start_value": 0,
+ "days": [
+ {
+ "value": 0,
+ "contribution": 100,
+ "withdrawal": 110,
+ "expected_factor": 1.1
+ }
+ ],
+ "expected_gain": 10,
+ "expected_estimated_return": 0.1
+ },
+ {
+ "id": "full_withdrawal_keeps_gain",
+ "start_value": 100,
+ "days": [
+ {
+ "value": 0,
+ "contribution": 0,
+ "withdrawal": 120,
+ "expected_factor": 1.2
+ }
+ ],
+ "expected_gain": 20,
+ "expected_estimated_return": 0.2
+ },
+ {
+ "id": "keep_gross_inflow_outflow",
+ "start_value": 100,
+ "days": [
+ {
+ "value": 132,
+ "contribution": 50,
+ "withdrawal": 30,
+ "expected_factor": 1.08
+ }
+ ],
+ "expected_gain": 12,
+ "expected_estimated_return": 0.08
+ },
+ {
+ "id": "known_inactive_day_then_funding",
+ "start_value": 0,
+ "days": [
+ {
+ "value": 0,
+ "contribution": 0,
+ "withdrawal": 0,
+ "expected_factor": 1
+ },
+ {
+ "value": 100,
+ "contribution": 100,
+ "withdrawal": 0,
+ "expected_factor": 1
+ }
+ ],
+ "expected_gain": 0,
+ "expected_estimated_return": 0
+ },
+ {
+ "id": "empty_range_has_no_return",
+ "start_value": 0,
+ "days": [
+ {
+ "value": 0,
+ "contribution": 0,
+ "withdrawal": 0,
+ "expected_factor": 1
+ },
+ {
+ "value": 0,
+ "contribution": 0,
+ "withdrawal": 0,
+ "expected_factor": 1
+ }
+ ],
+ "expected_gain": 0,
+ "expected_estimated_return": null
+ },
+ {
+ "id": "missing_active_day_is_not_skipped",
+ "start_value": 100,
+ "days": [
+ {
+ "value": null,
+ "contribution": 0,
+ "withdrawal": 0,
+ "expected_factor": null
+ },
+ {
+ "value": 110,
+ "contribution": 0,
+ "withdrawal": 0,
+ "expected_factor": null
+ }
+ ],
+ "expected_gain": 10,
+ "expected_estimated_return": null
+ },
+ {
+ "id": "missing_final_value",
+ "start_value": 100,
+ "days": [
+ {
+ "value": null,
+ "contribution": 0,
+ "withdrawal": 0,
+ "expected_factor": null
+ }
+ ],
+ "expected_gain": null,
+ "expected_estimated_return": null
+ },
+ {
+ "id": "missing_contribution",
+ "start_value": 100,
+ "days": [
+ {
+ "value": 120,
+ "contribution": null,
+ "withdrawal": 0,
+ "expected_factor": null
+ }
+ ],
+ "expected_gain": null,
+ "expected_estimated_return": null
+ },
+ {
+ "id": "invalid_zero_denominator",
+ "start_value": 0,
+ "days": [
+ {
+ "value": 0,
+ "contribution": 0,
+ "withdrawal": 10,
+ "expected_factor": null
+ }
+ ],
+ "expected_gain": 10,
+ "expected_estimated_return": null
+ },
+ {
+ "id": "complete_loss_then_new_funding",
+ "start_value": 100,
+ "days": [
+ {
+ "value": 0,
+ "contribution": 0,
+ "withdrawal": 0,
+ "expected_factor": 0
+ },
+ {
+ "value": 100,
+ "contribution": 100,
+ "withdrawal": 0,
+ "expected_factor": 1
+ }
+ ],
+ "expected_gain": -100,
+ "expected_estimated_return": -1
+ }
+ ],
+ "missing_inputs": [
+ {
+ "id": "missing_eur_fx",
+ "amount": 100,
+ "source": "EUR",
+ "target": "HUF",
+ "base": "USD",
+ "rates": {
+ "HUF": 360
+ },
+ "expected_value": null,
+ "expected_missing": [
+ "EUR"
+ ]
+ },
+ {
+ "id": "missing_price_keeps_native_cost",
+ "quantity": 2,
+ "price": null,
+ "native_basis": 100,
+ "expected_native_value": null,
+ "expected_native_unrealized_gain": null
+ },
+ {
+ "id": "zero_units_need_no_price",
+ "quantity": 0,
+ "price": null,
+ "expected_native_value": 0
+ },
+ {
+ "id": "same_currency_needs_no_fx",
+ "amount": 100,
+ "source": "HUF",
+ "target": "HUF",
+ "base": "USD",
+ "rates": {},
+ "expected_value": 100,
+ "expected_missing": []
+ },
+ {
+ "id": "invalid_rate_is_not_one",
+ "amount": 100,
+ "source": "EUR",
+ "target": "HUF",
+ "base": "USD",
+ "rates": {
+ "EUR": 0,
+ "HUF": 360
+ },
+ "expected_value": null,
+ "expected_missing": [
+ "EUR"
+ ]
+ }
+ ],
+ "scopes": {
+ "holdings": [
+ {
+ "id": "synthetic-market",
+ "archived": false,
+ "excluded_net_worth": false,
+ "pricing_method": "market",
+ "current_value": 20,
+ "prior_market_value": 18,
+ "today_gain": 2
+ },
+ {
+ "id": "synthetic-manual",
+ "archived": false,
+ "excluded_net_worth": true,
+ "pricing_method": "manual",
+ "current_value": 30,
+ "prior_market_value": null,
+ "today_gain": null
+ },
+ {
+ "id": "synthetic-archived",
+ "archived": true,
+ "excluded_net_worth": false,
+ "pricing_method": "market",
+ "current_value": 0,
+ "prior_market_value": null,
+ "today_gain": null,
+ "historically_owned": true
+ }
+ ],
+ "expected": {
+ "investment_current_value": 50,
+ "net_worth_investment_value": 20,
+ "investment_active_ids": [
+ "synthetic-market",
+ "synthetic-manual"
+ ],
+ "historical_ids": [
+ "synthetic-market",
+ "synthetic-manual",
+ "synthetic-archived"
+ ],
+ "market_today_gain": 2,
+ "market_today_prior_value": 18,
+ "market_today_excluded_ids": [
+ "synthetic-manual"
+ ]
+ }
+ },
+ "daily_fx": {
+ "quantity": 2,
+ "prior_price_usd": 10,
+ "prior_huf_per_usd": 360,
+ "current_price_usd": 11,
+ "current_huf_per_usd": 370,
+ "expected": {
+ "native_price_change": 1,
+ "native_price_change_percent": 10,
+ "prior_value_huf": 7200,
+ "current_value_huf": 8140,
+ "converted_gain_huf": 940
+ }
+ },
+ "known_basis_unknown_cash": {
+ "opening_quantity": 3,
+ "remaining_basis": 90,
+ "price": 40,
+ "expected": {
+ "value": 120,
+ "unrealized_gain": 30,
+ "lifetime_realized_gain": null,
+ "net_invested": null,
+ "total_gain": null
+ }
+ },
+ "fractional_crypto": {
+ "quantity": 1e-06,
+ "price_usd": 60000,
+ "included_fee_usd": 0.001,
+ "net_cash_usd": 0.061,
+ "mark_usd": 65000,
+ "expected": {
+ "remaining_basis_usd": 0.061,
+ "average_cost_usd": 61000,
+ "value_usd": 0.065,
+ "unrealized_gain_usd": 0.004
+ }
+ }
+}
From 9eeb646b45532577d2e68c2f1bce40997898e489 Mon Sep 17 00:00:00 2001
From: 31b4
Date: Sat, 17 Oct 2026 20:35:01 +0200
Subject: [PATCH 05/53] Add explicit investment holding metadata and opening
positions
---
AGENTS.md | 3 +-
CHANGELOG.md | 2 +
README.md | 2 +
.../018-investment-holding-metadata.sql | 107 +++++++++
api/migrations/README.md | 4 +
api/src/controllers/account.controller.ts | 12 +-
api/src/dtos/account.dto.ts | 27 ++-
api/src/errors/codes.ts | 5 +
api/src/index.ts | 3 +
api/src/mappers/account.mapper.ts | 4 +
api/src/middlewares/validate.middleware.ts | 4 +
api/src/models/Account.ts | 5 +-
api/src/repositories/account.repository.ts | 71 +++++-
api/src/services/account.service.ts | 97 +++++++-
api/src/tests/holding.test.ts | 41 ++++
api/src/validators/account.validator.ts | 33 ++-
.../src/components/analytics-module/types.ts | 3 +-
.../AccountList.manage.test.tsx | 75 +++++-
.../dashboard-module/AccountList.tsx | 91 ++++++--
.../dashboard-module/AccountShortcuts.tsx | 2 +-
.../dashboard-module/AddAccountForm.tsx | 21 +-
.../dashboard-module/BulkTransactionModal.tsx | 3 +-
.../dashboard-module/EditAccountForm.tsx | 23 +-
.../dashboard-module/HoldingAccountFields.tsx | 67 ++++++
.../RecurringTransactions.tsx | 3 +-
.../dashboard-module/TransactionCalendar.tsx | 3 +-
.../dashboard-module/TransactionList.tsx | 5 +-
.../dashboard-module/account-form.ts | 18 +-
.../investments-module/HoldingsList.tsx | 9 +-
.../InvestmentDetailModal.tsx | 7 +-
.../src/components/investments-module/data.ts | 7 +-
.../components/investments-module/types.ts | 9 +-
.../investments-module/utils.test.ts | 18 ++
.../components/investments-module/utils.ts | 23 +-
.../src/components/settings-module/types.ts | 3 +-
client/src/hooks/useFinanceData.ts | 17 +-
client/src/lib/account-order.ts | 16 +-
client/src/pages/AccountsPage.tsx | 4 +-
docs/investment-holdings.md | 78 +++++++
mcp/README.md | 2 +
mcp/src/finance-service.ts | 102 ++++----
mcp/src/types.ts | 6 +-
shared/data-export.ts | 2 +-
shared/holding.ts | 65 ++++++
tests/integration/data-export.test.ts | 4 +-
tests/integration/holding-metadata.test.ts | 220 ++++++++++++++++++
tests/integration/migrations.test.ts | 2 +-
47 files changed, 1144 insertions(+), 184 deletions(-)
create mode 100644 api/migrations/018-investment-holding-metadata.sql
create mode 100644 api/src/tests/holding.test.ts
create mode 100644 client/src/components/dashboard-module/HoldingAccountFields.tsx
create mode 100644 docs/investment-holdings.md
create mode 100644 shared/holding.ts
create mode 100644 tests/integration/holding-metadata.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index 7f585a7c..1da00046 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -127,6 +127,7 @@ The API defaults to private mode, verifying Cloudflare Access JWT signature, iss
- **Review drafts vs. upcoming projections:** MCP drafts have `status='pending'`, `pending_kind='mcp_review'`, and `review_source='chatgpt_mcp'`. They affect neither balances nor projections until manually confirmed in the app. Projection code must opt in with `pending_kind === 'upcoming'`, rather than include all pending rows. Keep review provenance and flags through edits and confirmation. See `client/src/lib/transaction-review.ts`.
- **Signs and transfers:** cash income is positive and expenses are negative. A regular transfer has linked debit/credit rows; cash-to-investment purchases store reciprocal `linked_transaction_id` values on the cash row and investment transaction (standalone trades keep a null link). `InvestmentTransferRepository` batches linked creation, edits, deletion, balance/quantity deltas, and audit entries with snapshot/account guards. Edit through the cash transaction; deleting either side removes the pair once. Reject inconsistent legacy pairs rather than mutating one side. `/transfers` rejects investment sources until a proper linked-sale workflow is implemented. Linked transfers are excluded from income/expense aggregates, but still matter when reconstructing account balances.
- **Investments:** `investment_transactions` is active buy/sell history; do not remove or consolidate it based on the misleading comment in migration 001. Market-priced investment balances represent share/coin quantities, while manual asset values follow their own valuation path. Distinguish holding-unit `currency`, fiat `quote_currency`, unit price, and converted cash value. API, client, and MCP have differing legacy paths; trace the actual path instead of assuming their valuation behavior is identical.
+- **Holding metadata:** migration 018 adds independent pricing method/unit/provider fields and immutable `investment_position_anchors`. Current unit reads use stored balances and expose reconciliation coverage; observed legacy anchors are not backdated acquisitions. Accounts records dated openings/corrections atomically with audits; do not create ordinary income/expense rows for unit corrections. Protect history-changing identity edits (stock/ETF recategorization is safe). Manual-category balances are total fiat values; manually priced ETF/stock/crypto balances remain units, using a saved per-unit price. Read `docs/investment-holdings.md` for the implemented boundary; dated manual history and performance remain follow-up work.
- **Investment rework contract:** read `docs/investment-accounting.md` before implementing epic #123 children. It distinguishes current observed anchors from known openings, cash flows from remaining cost basis, total versus per-unit manual values, and daily estimated returns from exact trade-time returns. `tests/fixtures/investment-accounting-v1.json` contains synthetic expected results; its API contract checks validate the specification, not legacy runtime parity. Follow each child's scope and stop at its PR for user review.
- **Archive:** `accounts.archived_at` is independent of locks/exclusions. Archive requires zero balance/holding and no pending rows; it atomically pauses source/destination schedules and records an audit entry. Restore leaves schedules paused. Keep archived accounts in historical reads/exports, omit them from active selectors/current valuations, and require restore before account/ledger mutation. Database triggers guard both Workers. Permanent account deletion blocks linked transfers to avoid orphaning the other leg.
- **Locks:** persisted `accounts.is_locked` and API lock/unlock endpoints are authoritative. Preserve source and destination lock checks before mutations and the scheduler's skip behavior. Existing coverage varies by endpoint; test the path being changed rather than assume a database-wide guarantee.
@@ -163,7 +164,7 @@ Read `mcp/README.md` for the complete tool/workflow contract. `src/index.ts` han
## Migrations and deployment
-- The schema is the ordered sequence in `api/migrations/`, currently through `017-investment-transfer-links.sql`. Budgets are retired; earlier migrations remain necessary history. Add the next sequential `NNN-description.sql`; never rewrite an applied migration.
+- The schema is the ordered sequence in `api/migrations/`, currently through `018-investment-holding-metadata.sql`. Budgets are retired; earlier migrations remain necessary history. Add the next sequential `NNN-description.sql`; never rewrite an applied migration.
- Deployment tracks executed filenames in `migration_history`; local setup rebuilds and applies the full sequence. This is a custom runner, not Wrangler's built-in migration ledger. Use `IF EXISTS`/`IF NOT EXISTS` where supported, but do not assume one-time `ALTER TABLE ADD COLUMN` migrations can be replayed safely.
- Check source-revision triggers when adding/changing financial tables so forecasts become stale correctly. Validate both a fresh schema and the upgrade from the preceding schema when relevant.
- Keep `.deploy-config`, actual Wrangler configs, `.dev.vars`, `.env` credentials, local database state, and database backups out of commits and tool output. Use tracked example files for documentation. Never hardcode real account IDs, secrets, personal financial data, or deployment identifiers in tests.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52f2b0a2..27887288 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,11 +8,13 @@ Intermediate package/UI version bumps are grouped under the next published GitHu
### Changed
+- Investment account setup separates ETF/stock/crypto/manual categories from pricing, preserves stored quantities across portfolio consumers, and records explicit dated openings/corrections without fake cash movements. Missing legacy cost/history stays unavailable; manually priced unit holdings use their saved fiat price.
- Dashboard uses a shared period picker, a responsive balance/activity summary, compact transaction filters, and a combined review queue with explicit confirmation actions. Transaction rows show neutral transfers, daily cash-flow totals, and native/converted amounts; privacy masking and loading states follow the new layout.
- Investment purchases store reciprocal links between the cash ledger and investment history. Linked creation, edits, and deletion batch both records, balances, and audit entries atomically; deletion from either side reverses the pair once. History, portfolio reads, MCP activity, and JSON exports preserve the investment-side link. Inconsistent legacy links and unsupported investment-source transfers are rejected before changing balances.
### Migration and upgrade notes
+- Migration 018 adds holding pricing/unit/provider metadata and exported position anchors. The baseline preserves every existing financial column/rowid and financial-source revision, including locked/archived holdings. Apply it after 017 and before the updated Workers/client; this is an intermediate investment epic step, not the completed rework.
- Migration 017 adds the nullable investment-side cash link and backfills unambiguous active and archived history without changing financial values or source revision. Standalone trades remain unlinked; ambiguous or invalid legacy relationships are preserved for explicit repair. Apply the migration before deploying the updated API, client, or MCP.
## v3.3 — 2026-10-15
diff --git a/README.md b/README.md
index d22afaee..20765fa5 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,8 @@ Market quotes and exchange rates come from external services and may be unavaila
The investment rework's [versioned accounting contract](docs/investment-accounting.md) records these implementation gaps and specifies opening quantities, exact cash/fees, remaining cost basis, manual valuations, historical FX, and estimated return calculations. Its [synthetic worked examples](tests/fixtures/investment-accounting-v1.json) are checked by the API suite. This is the specification for the remaining epic work, not a change to current runtime behavior.
+The epic’s [holding model and account setup](docs/investment-holdings.md) implement explicit ETF/category/pricing selection, dated openings and safe current-position corrections. Migration 018 preserves existing balances and records while marking incomplete history. Full trade, manual-history and return calculations remain separate epic work.
+
### Recurring schedules
The API Worker runs recurring processing using the configured daily cron (`0 0 * * *`, midnight UTC). It creates posted transactions and updates balances for eligible schedules, respecting locks, archived accounts, end dates, remaining occurrences, and last-processed dates. Monthly days beyond the end of a month clamp to its final day.
diff --git a/api/migrations/018-investment-holding-metadata.sql b/api/migrations/018-investment-holding-metadata.sql
new file mode 100644
index 00000000..a97695f5
--- /dev/null
+++ b/api/migrations/018-investment-holding-metadata.sql
@@ -0,0 +1,107 @@
+-- Add metadata without rewriting any existing ledger columns, quantities or cash.
+ALTER TABLE accounts ADD COLUMN pricing_method TEXT CHECK (pricing_method IS NULL OR pricing_method IN ('market', 'manual'));
+ALTER TABLE accounts ADD COLUMN unit TEXT;
+ALTER TABLE accounts ADD COLUMN provider TEXT CHECK (provider IS NULL OR provider = 'yahoo');
+ALTER TABLE accounts ADD COLUMN exchange TEXT;
+ALTER TABLE accounts ADD COLUMN manual_price REAL CHECK (manual_price IS NULL OR (manual_price >= 0 AND manual_price <= 1e12));
+ALTER TABLE accounts ADD COLUMN manual_price_date TEXT;
+
+-- Metadata backfill does not change the financial source revision. Old columns
+-- and timestamps are untouched, including for locked and archived holdings.
+DROP TRIGGER IF EXISTS financial_revision_accounts_update;
+UPDATE accounts SET pricing_method = CASE WHEN asset_type = 'manual' THEN 'manual' ELSE 'market' END,
+ unit = CASE WHEN asset_type = 'manual' THEN 'ITEM' ELSE currency END,
+ provider = CASE WHEN asset_type IS NOT 'manual' AND symbol IS NOT NULL AND symbol != '' THEN 'yahoo' END
+WHERE type = 'investment';
+
+CREATE TABLE IF NOT EXISTS investment_position_anchors (
+ id TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ kind TEXT NOT NULL CHECK (kind IN ('observed', 'opening', 'correction')),
+ effective_date TEXT NOT NULL CHECK (length(effective_date) = 10 AND date(effective_date, '+0 days') IS NOT NULL AND date(effective_date, '+0 days') = effective_date),
+ balance REAL NOT NULL,
+ cost_amount REAL CHECK (cost_amount IS NULL OR (cost_amount >= 0 AND cost_amount <= 1e12)),
+ cost_currency TEXT,
+ trade_rowid_boundary INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ CHECK ((cost_amount IS NULL) = (cost_currency IS NULL))
+);
+CREATE INDEX IF NOT EXISTS idx_position_anchors_account ON investment_position_anchors(account_id);
+INSERT INTO investment_position_anchors (id, account_id, kind, effective_date, balance, trade_rowid_boundary, created_at)
+SELECT 'legacy-position:' || a.id, a.id, 'observed', date('now'), a.balance,
+ COALESCE((SELECT MAX(t.rowid) FROM investment_transactions t WHERE t.account_id = a.id), 0),
+ CAST(strftime('%s', 'now') AS INTEGER) * 1000
+FROM accounts a WHERE a.type = 'investment';
+
+CREATE TRIGGER financial_revision_accounts_update AFTER UPDATE ON accounts BEGIN
+ UPDATE financial_data_revision SET revision = revision + 1, updated_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000 WHERE id = 1;
+END;
+CREATE TRIGGER financial_revision_position_anchor_insert AFTER INSERT ON investment_position_anchors BEGIN
+ UPDATE financial_data_revision SET revision = revision + 1, updated_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000 WHERE id = 1;
+END;
+CREATE TRIGGER financial_revision_position_anchor_delete AFTER DELETE ON investment_position_anchors BEGIN
+ UPDATE financial_data_revision SET revision = revision + 1, updated_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000 WHERE id = 1;
+END;
+CREATE TRIGGER position_anchor_immutable BEFORE UPDATE ON investment_position_anchors BEGIN
+ SELECT RAISE(ABORT, 'HOLDING_ANCHOR_IMMUTABLE');
+END;
+CREATE TRIGGER position_anchor_requirements BEFORE INSERT ON investment_position_anchors BEGIN
+ SELECT CASE WHEN NOT EXISTS (SELECT 1 FROM accounts WHERE id = NEW.account_id AND type = 'investment') THEN RAISE(ABORT, 'HOLDING_ACCOUNT_REQUIRED') END;
+ SELECT CASE WHEN EXISTS (SELECT 1 FROM accounts WHERE id = NEW.account_id AND archived_at IS NOT NULL) THEN RAISE(ABORT, 'ACCOUNT_ARCHIVED') END;
+ SELECT CASE WHEN EXISTS (SELECT 1 FROM accounts WHERE id = NEW.account_id AND is_locked = 1) THEN RAISE(ABORT, 'ACCOUNT_LOCKED') END;
+ SELECT CASE WHEN NEW.kind = 'opening' AND EXISTS (
+ SELECT 1 FROM accounts a WHERE a.id = NEW.account_id AND a.asset_type IS NOT 'manual'
+ AND abs(a.balance - NEW.balance - COALESCE((SELECT SUM(CASE WHEN t.type = 'buy' THEN t.quantity ELSE -t.quantity END)
+ FROM investment_transactions t WHERE t.account_id = a.id AND t.date > NEW.effective_date), 0)) > max(5e-13, abs(a.balance) * 2e-15)
+ ) THEN RAISE(ABORT, 'HOLDING_POSITION_MISMATCH') END;
+END;
+
+-- Preserve archive protection for the newly durable account fields too.
+DROP TRIGGER IF EXISTS archived_account_read_only;
+CREATE TRIGGER archived_account_read_only BEFORE UPDATE ON accounts
+WHEN OLD.archived_at IS NOT NULL AND (
+ NEW.name IS NOT OLD.name OR NEW.type IS NOT OLD.type OR NEW.balance IS NOT OLD.balance OR
+ NEW.currency IS NOT OLD.currency OR NEW.quote_currency IS NOT OLD.quote_currency OR NEW.symbol IS NOT OLD.symbol OR
+ NEW.asset_type IS NOT OLD.asset_type OR NEW.is_locked IS NOT OLD.is_locked OR
+ NEW.exclude_from_net_worth IS NOT OLD.exclude_from_net_worth OR NEW.exclude_from_cash_balance IS NOT OLD.exclude_from_cash_balance OR
+ NEW.pricing_method IS NOT OLD.pricing_method OR NEW.unit IS NOT OLD.unit OR NEW.provider IS NOT OLD.provider OR
+ NEW.exchange IS NOT OLD.exchange OR NEW.manual_price IS NOT OLD.manual_price OR NEW.manual_price_date IS NOT OLD.manual_price_date)
+BEGIN SELECT RAISE(ABORT, 'ACCOUNT_ARCHIVED'); END;
+
+CREATE TRIGGER holding_history_metadata BEFORE UPDATE ON accounts
+WHEN (OLD.type = 'investment' OR NEW.type = 'investment') AND (
+ OLD.balance != 0 OR EXISTS (SELECT 1 FROM transactions WHERE account_id = OLD.id) OR
+ EXISTS (SELECT 1 FROM investment_transactions WHERE account_id = OLD.id) OR
+ EXISTS (SELECT 1 FROM investment_position_anchors WHERE account_id = OLD.id AND balance != 0)
+) AND (
+ OLD.type IS NOT NEW.type OR OLD.currency IS NOT NEW.currency OR OLD.quote_currency IS NOT NEW.quote_currency OR
+ OLD.symbol IS NOT NEW.symbol OR OLD.unit IS NOT NEW.unit OR OLD.pricing_method IS NOT NEW.pricing_method OR
+ OLD.provider IS NOT NEW.provider OR OLD.exchange IS NOT NEW.exchange OR
+ (OLD.asset_type IS NOT NEW.asset_type AND NOT (COALESCE(OLD.asset_type IN ('stock', 'etf'), 0) AND COALESCE(NEW.asset_type IN ('stock', 'etf'), 0)))
+)
+BEGIN SELECT RAISE(ABORT, 'HOLDING_HISTORY_METADATA'); END;
+
+-- A dated opening is EOD. New events cannot be silently inserted into it.
+CREATE TRIGGER investment_opening_insert BEFORE INSERT ON investment_transactions
+WHEN EXISTS (SELECT 1 FROM investment_position_anchors p WHERE p.account_id = NEW.account_id AND p.kind = 'opening'
+ AND p.rowid = (SELECT MAX(x.rowid) FROM investment_position_anchors x WHERE x.account_id = NEW.account_id)
+ AND NEW.date <= p.effective_date)
+BEGIN SELECT RAISE(ABORT, 'HOLDING_OPENING_DATE'); END;
+CREATE TRIGGER investment_opening_update BEFORE UPDATE ON investment_transactions
+WHEN (OLD.date IS NOT NEW.date OR OLD.account_id IS NOT NEW.account_id) AND EXISTS (
+ SELECT 1 FROM investment_position_anchors p WHERE p.account_id = NEW.account_id AND p.kind = 'opening'
+ AND p.rowid = (SELECT MAX(x.rowid) FROM investment_position_anchors x WHERE x.account_id = NEW.account_id)
+ AND NEW.date <= p.effective_date)
+BEGIN SELECT RAISE(ABORT, 'HOLDING_OPENING_DATE'); END;
+
+CREATE TRIGGER locked_holding_metadata BEFORE UPDATE ON accounts
+WHEN OLD.type = 'investment' AND OLD.is_locked = 1 AND (
+ NEW.pricing_method IS NOT OLD.pricing_method OR NEW.unit IS NOT OLD.unit OR NEW.provider IS NOT OLD.provider OR
+ NEW.exchange IS NOT OLD.exchange OR NEW.manual_price IS NOT OLD.manual_price OR NEW.manual_price_date IS NOT OLD.manual_price_date)
+BEGIN SELECT RAISE(ABORT, 'ACCOUNT_LOCKED'); END;
+
+CREATE TRIGGER protected_position_anchor_delete BEFORE DELETE ON investment_position_anchors
+BEGIN
+ SELECT CASE WHEN EXISTS (SELECT 1 FROM accounts WHERE id = OLD.account_id AND archived_at IS NOT NULL) THEN RAISE(ABORT, 'ACCOUNT_ARCHIVED') END;
+ SELECT CASE WHEN EXISTS (SELECT 1 FROM accounts WHERE id = OLD.account_id AND is_locked = 1) THEN RAISE(ABORT, 'ACCOUNT_LOCKED') END;
+END;
diff --git a/api/migrations/README.md b/api/migrations/README.md
index 04099861..04919362 100644
--- a/api/migrations/README.md
+++ b/api/migrations/README.md
@@ -34,3 +34,7 @@ Example: `001-init.sql`, `002-add-user-table.sql`
## Important
The initial schema (`001-init.sql`) is applied during the first deployment. All subsequent changes must be new migration files.
+
+## Holding metadata upgrade
+
+Migration 018 follows the reciprocal-link foundation in 017. It adds independent holding metadata and observed position anchors without rewriting balances or ledger rows. See [holding setup and migration notes](../../docs/investment-holdings.md) for compatibility, export/source-revision rules and verification boundaries. Apply before deploying its consumers.
diff --git a/api/src/controllers/account.controller.ts b/api/src/controllers/account.controller.ts
index 68c718a5..e06ca856 100644
--- a/api/src/controllers/account.controller.ts
+++ b/api/src/controllers/account.controller.ts
@@ -14,17 +14,17 @@ export class AccountController {
}
async create(c: Context<{ Bindings: Bindings }>) {
- const body = await c.req.json()
- const account = await this.accountService.createAccount(body)
- await new AuditRepository(c.env.DB).log('CREATE', 'account', account.id, { name: account.name, type: account.type })
+ const body = c.get('validatedBody') as CreateAccountDto
+ const account = await this.accountService.createAccount(body, c.req.header('X-Client-Date'))
+ if (account.type !== 'investment') await new AuditRepository(c.env.DB).log('CREATE', 'account', account.id, { name: account.name, type: account.type })
return c.json(AccountMapper.toResponseDto(account), 201)
}
async update(c: Context<{ Bindings: Bindings }>) {
const id = c.req.param('id')
- const body = await c.req.json()
- const account = await this.accountService.updateAccount(id, body)
- await new AuditRepository(c.env.DB).log('UPDATE', 'account', id, { name: body.name, balance: body.balance })
+ const body = c.get('validatedBody') as UpdateAccountDto
+ const account = await this.accountService.updateAccount(id, body, c.req.header('X-Client-Date'))
+ if (account.type !== 'investment') await new AuditRepository(c.env.DB).log('UPDATE', 'account', id, { name: body.name, balance: body.balance })
return c.json(AccountMapper.toResponseDto(account))
}
diff --git a/api/src/dtos/account.dto.ts b/api/src/dtos/account.dto.ts
index 6dfdce8e..46e435cd 100644
--- a/api/src/dtos/account.dto.ts
+++ b/api/src/dtos/account.dto.ts
@@ -1,3 +1,4 @@
+import type { HoldingFields } from '../../../shared/holding'
import { AccountType, AssetType } from '../models/Account'
export interface SplitTransactionDto {
@@ -8,7 +9,23 @@ export interface SplitTransactionDto {
date: string
}
-export interface CreateAccountDto {
+export interface HoldingOpeningDto {
+ balance: number
+ date: string
+ cost_amount?: number | null
+ cost_currency?: string | null
+}
+
+export interface HoldingWriteDto {
+ pricing_method?: 'market' | 'manual'
+ provider?: 'yahoo' | null
+ exchange?: string | null
+ manual_price?: number | null
+ manual_price_date?: string | null
+ opening?: HoldingOpeningDto
+}
+
+export interface CreateAccountDto extends HoldingWriteDto {
name: string
type: AccountType
balance: number
@@ -20,7 +37,11 @@ export interface CreateAccountDto {
exclude_from_cash_balance?: boolean
}
-export interface UpdateAccountDto {
+export interface UpdateAccountDto extends HoldingWriteDto {
+ expected_balance?: number
+ expected_updated_at?: number
+ correction_date?: string
+
name?: string
type?: AccountType
balance?: number
@@ -34,7 +55,7 @@ export interface UpdateAccountDto {
splitTransactions?: SplitTransactionDto[]
}
-export interface AccountResponseDto {
+export interface AccountResponseDto extends HoldingFields {
id: string
name: string
type: AccountType
diff --git a/api/src/errors/codes.ts b/api/src/errors/codes.ts
index fa3d13c8..fad9d8b3 100644
--- a/api/src/errors/codes.ts
+++ b/api/src/errors/codes.ts
@@ -7,6 +7,11 @@ export const ErrorCode = {
ACCOUNT_ARCHIVE_PENDING: 'ACCOUNT_ARCHIVE_PENDING',
ACCOUNT_DELETE_LINKED: 'ACCOUNT_DELETE_LINKED',
+ HOLDING_HISTORY_METADATA: 'HOLDING_HISTORY_METADATA',
+ HOLDING_POSITION_CONFLICT: 'HOLDING_POSITION_CONFLICT',
+ HOLDING_POSITION_MISMATCH: 'HOLDING_POSITION_MISMATCH',
+ HOLDING_OPENING_DATE: 'HOLDING_OPENING_DATE',
+
ACCOUNT_HAS_TRANSACTIONS: 'ACCOUNT_HAS_TRANSACTIONS',
// Transaction errors
diff --git a/api/src/index.ts b/api/src/index.ts
index 3d9992ab..ff5603c2 100644
--- a/api/src/index.ts
+++ b/api/src/index.ts
@@ -129,6 +129,9 @@ app.onError((err, c) => {
const accountErrors = {
ACCOUNT_ARCHIVED: 'Restore the archived account before making changes.',
ACCOUNT_LOCKED: 'Unlock the account before making changes.',
+ HOLDING_HISTORY_METADATA: 'This change would reinterpret holding history. Keep its instrument, units, quote currency and pricing method, or create a separate holding.',
+ HOLDING_POSITION_MISMATCH: 'Opening quantity and later activity do not match the current holding. Reconcile the opening or record an explicit correction.',
+ HOLDING_OPENING_DATE: 'The trade date must be after the end-of-day opening position. Correct the opening date before backdating this trade.',
ACCOUNT_ARCHIVE_NONZERO: 'The balance or holding must be zero before archiving.',
ACCOUNT_ARCHIVE_PENDING: 'Confirm or decline all pending and review transactions before archiving.',
ACCOUNT_DELETE_LINKED: 'This account has linked transfers. Archive it to preserve both sides and their history.',
diff --git a/api/src/mappers/account.mapper.ts b/api/src/mappers/account.mapper.ts
index dabcbb3f..cc8df7c6 100644
--- a/api/src/mappers/account.mapper.ts
+++ b/api/src/mappers/account.mapper.ts
@@ -12,6 +12,8 @@ export class AccountMapper {
quote_currency: account.quote_currency,
symbol: account.symbol,
asset_type: account.asset_type,
+ pricing_method: account.pricing_method, unit: account.unit, provider: account.provider, exchange: account.exchange,
+ manual_price: account.manual_price, manual_price_date: account.manual_price_date, holding_position: account.holding_position,
exclude_from_net_worth: Boolean(account.exclude_from_net_worth),
exclude_from_cash_balance: Boolean(account.exclude_from_cash_balance),
is_locked: Boolean(account.is_locked),
@@ -30,6 +32,8 @@ export class AccountMapper {
quote_currency: dto.quote_currency,
symbol: dto.symbol,
asset_type: dto.asset_type,
+ pricing_method: dto.pricing_method, provider: dto.provider, exchange: dto.exchange,
+ manual_price: dto.manual_price, manual_price_date: dto.manual_price_date,
exclude_from_net_worth: dto.exclude_from_net_worth,
exclude_from_cash_balance: dto.exclude_from_cash_balance,
updated_at
diff --git a/api/src/middlewares/validate.middleware.ts b/api/src/middlewares/validate.middleware.ts
index 68bed384..643d524a 100644
--- a/api/src/middlewares/validate.middleware.ts
+++ b/api/src/middlewares/validate.middleware.ts
@@ -1,6 +1,10 @@
import { Context, Next } from 'hono'
import { z, ZodSchema } from 'zod'
+declare module 'hono' {
+ interface ContextVariableMap { validatedBody: unknown }
+}
+
export function validateBody(schema: ZodSchema) {
return async (c: Context, next: Next) => {
let body: unknown
diff --git a/api/src/models/Account.ts b/api/src/models/Account.ts
index 99c3943a..f2334565 100644
--- a/api/src/models/Account.ts
+++ b/api/src/models/Account.ts
@@ -1,7 +1,8 @@
+import type { HoldingFields } from '../../../shared/holding'
export type AccountType = 'cash' | 'investment'
-export type AssetType = 'stock' | 'crypto' | 'manual'
+export type AssetType = 'etf' | 'stock' | 'crypto' | 'manual'
-export interface Account {
+export interface Account extends HoldingFields {
id: string
name: string
type: AccountType
diff --git a/api/src/repositories/account.repository.ts b/api/src/repositories/account.repository.ts
index f67b7a37..2091f237 100644
--- a/api/src/repositories/account.repository.ts
+++ b/api/src/repositories/account.repository.ts
@@ -1,15 +1,20 @@
-import { Account } from '../models/Account'
+import { HOLDING_ACCOUNT_SELECT, mapHoldingAccount } from '../../../shared/holding'
+import type { PositionAnchor } from '../../../shared/holding'
+import type { Account } from '../models/Account'
+
+type D1Value = string | number | null
export class AccountRepository {
constructor(private db: D1Database) {}
async findAll(): Promise {
- const { results } = await this.db.prepare('SELECT * FROM accounts').all()
- return results
+ const { results } = await this.db.prepare(`${HOLDING_ACCOUNT_SELECT} ORDER BY a.name, a.id`).all()
+ return results.map(row => mapHoldingAccount(row))
}
async findById(id: string): Promise {
- return await this.db.prepare('SELECT * FROM accounts WHERE id = ?').bind(id).first()
+ const row = await this.db.prepare(`${HOLDING_ACCOUNT_SELECT} WHERE a.id = ?`).bind(id).first()
+ return row ? mapHoldingAccount(row) : null
}
async create(account: Account): Promise {
@@ -49,6 +54,64 @@ export class AccountRepository {
).run()
}
+ async hasFutureInvestmentActivity(id: string, today: string): Promise {
+ return Boolean(await this.db.prepare('SELECT 1 AS found FROM investment_transactions WHERE account_id = ? AND date > ? LIMIT 1').bind(id, today).first())
+ }
+
+ private holdingValues(a: Account): D1Value[] {
+ return [a.name, a.type, a.balance, a.currency, a.quote_currency ?? null, a.symbol ?? null, a.asset_type ?? null,
+ a.pricing_method ?? null, a.unit ?? null, a.provider ?? null, a.exchange ?? null, a.manual_price ?? null,
+ a.manual_price_date ?? null, a.exclude_from_net_worth ? 1 : 0, a.exclude_from_cash_balance ? 1 : 0, a.updated_at]
+ }
+
+ private anchorStatement(a: Account, anchor: PositionAnchor, auditId?: string): D1PreparedStatement {
+ return this.db.prepare(`INSERT INTO investment_position_anchors
+ (id, account_id, kind, effective_date, balance, cost_amount, cost_currency, trade_rowid_boundary, created_at)
+ SELECT ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT MAX(rowid) FROM investment_transactions WHERE account_id = ?), 0), ?
+ ${auditId ? 'WHERE EXISTS (SELECT 1 FROM audit_log WHERE id = ?)' : ''}`)
+ .bind(anchor.id, a.id, anchor.kind, anchor.effective_date, anchor.balance, anchor.cost_amount, anchor.cost_currency,
+ a.id, anchor.created_at, ...(auditId ? [auditId] : []))
+ }
+
+ async createHolding(a: Account, anchor: PositionAnchor): Promise {
+ await this.db.batch([
+ this.db.prepare(`INSERT INTO accounts
+ (id, name, type, balance, currency, quote_currency, symbol, asset_type, pricing_method, unit, provider, exchange,
+ manual_price, manual_price_date, exclude_from_net_worth, exclude_from_cash_balance, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ .bind(a.id, ...this.holdingValues(a)),
+ this.anchorStatement(a, anchor),
+ this.db.prepare("INSERT INTO audit_log (id, action, entity, entity_id, details, created_at) VALUES (?, 'CREATE', 'account', ?, ?, ?)")
+ .bind(crypto.randomUUID(), a.id, JSON.stringify({ name: a.name, asset_type: a.asset_type, opening: anchor }), a.updated_at),
+ ])
+ }
+
+ async updateHolding(before: Account, after: Account, anchor: PositionAnchor | null): Promise {
+ const auditId = crypto.randomUUID()
+ // The audit insertion claims the complete account snapshot. All following
+ // writes depend on that unique claim, so a stale/locked request changes none.
+ const fields = ['name', 'type', 'balance', 'currency', 'quote_currency', 'symbol', 'asset_type', 'pricing_method',
+ 'unit', 'provider', 'exchange', 'manual_price', 'manual_price_date', 'exclude_from_net_worth', 'exclude_from_cash_balance', 'updated_at']
+ const snapshot = this.holdingValues(before)
+ snapshot[13] = before.exclude_from_net_worth == null ? null : before.exclude_from_net_worth ? 1 : 0
+ snapshot[14] = before.exclude_from_cash_balance == null ? null : before.exclude_from_cash_balance ? 1 : 0
+ const statements = [
+ this.db.prepare(`INSERT INTO audit_log (id, action, entity, entity_id, details, created_at)
+ SELECT ?, 'UPDATE', 'account', a.id, ?, ? FROM accounts a
+ WHERE a.id = ? AND COALESCE(a.is_locked, 0) = 0 AND a.archived_at IS NULL
+ AND ${fields.map(f => `a.${f} IS ?`).join(' AND ')}`)
+ .bind(auditId, JSON.stringify({ before: Object.fromEntries(fields.map(field => [field, before[field as keyof Account]])),
+ after: Object.fromEntries(fields.map(field => [field, after[field as keyof Account]])), anchor }),
+ after.updated_at, before.id, ...snapshot),
+ this.db.prepare(`UPDATE accounts SET ${fields.map(f => `${f} = ?`).join(', ')}
+ WHERE id = ? AND EXISTS (SELECT 1 FROM audit_log WHERE id = ?)`)
+ .bind(...this.holdingValues(after), after.id, auditId),
+ ]
+ if (anchor) statements.push(this.anchorStatement(after, anchor, auditId))
+ const results = await this.db.batch(statements)
+ return (results[0].meta.changes ?? 0) > 0
+ }
+
async updateBalance(id: string, balance: number, updated_at: number): Promise {
await this.db.prepare('UPDATE accounts SET balance = ?, updated_at = ? WHERE id = ?')
.bind(balance, updated_at, id).run()
diff --git a/api/src/services/account.service.ts b/api/src/services/account.service.ts
index 9a9d5818..49d717e6 100644
--- a/api/src/services/account.service.ts
+++ b/api/src/services/account.service.ts
@@ -1,9 +1,14 @@
+import { validLocalDate, validHoldingNumber } from '../../../shared/holding'
+import type { PositionAnchor } from '../../../shared/holding'
+import type { HoldingOpeningDto } from '../dtos/account.dto'
import { Account } from '../models/Account'
import { AccountRepository } from '../repositories/account.repository'
import { TransactionRepository } from '../repositories/transaction.repository'
import { CreateAccountDto, UpdateAccountDto } from '../dtos/account.dto'
import { AppError } from '../errors/codes'
+const FIAT = new Set(['HUF', 'EUR', 'USD', 'GBP', 'CHF', 'PLN', 'CZK', 'RON', 'MXN'])
+
export class AccountService {
constructor(
private accountRepo: AccountRepository,
@@ -18,12 +23,12 @@ export class AccountService {
return await this.accountRepo.findById(id)
}
- async createAccount(dto: CreateAccountDto): Promise {
+ async createAccount(dto: CreateAccountDto, clientDate?: string): Promise {
const id = crypto.randomUUID()
const now = Date.now()
const currency = dto.currency?.trim() || 'HUF'
- const account: Account = {
+ let account: Account = {
id,
name: dto.name,
type: dto.type,
@@ -32,18 +37,67 @@ export class AccountService {
quote_currency: dto.quote_currency?.trim().toUpperCase(),
symbol: dto.symbol,
asset_type: dto.asset_type,
+ pricing_method: dto.pricing_method, provider: dto.provider, exchange: dto.exchange,
+ manual_price: dto.manual_price, manual_price_date: dto.manual_price_date,
exclude_from_net_worth: dto.exclude_from_net_worth,
exclude_from_cash_balance: dto.exclude_from_cash_balance,
updated_at: now
}
+ if (account.type === 'investment') {
+ const today = this.today(clientDate)
+ const prior = new Date(`${today}T00:00:00Z`); prior.setUTCDate(prior.getUTCDate() - 1)
+ const date = dto.opening?.date ?? (account.balance === 0 ? prior.toISOString().slice(0, 10) : '')
+ if (!date || !validLocalDate(date) || date > today) throw AppError.validation('A nonzero holding needs a valid opening date on or before today')
+ if (dto.opening && dto.opening.balance !== account.balance) throw AppError.validation('The new opening amount must match the starting quantity/value')
+ account = this.normalizeHolding(account)
+ if (account.manual_price != null) account.manual_price_date = dto.manual_price_date ?? date
+ if (account.manual_price_date && (!validLocalDate(account.manual_price_date) || account.manual_price_date > today)) throw AppError.validation('Manual price date cannot be in the future')
+ await this.accountRepo.createHolding(account, this.anchor(account.balance, 'opening', date, now, dto.opening))
+ return (await this.accountRepo.findById(id))!
+ }
+ if (dto.opening || dto.manual_price != null || dto.pricing_method) throw AppError.validation('Holding metadata belongs to investment accounts')
await this.accountRepo.create(account)
return account
}
- async updateAccount(id: string, dto: UpdateAccountDto): Promise {
- await this.requireActive(id)
+ async updateAccount(id: string, dto: UpdateAccountDto, clientDate?: string): Promise {
+ const existing = await this.requireActive(id)
const now = Date.now()
+ if (dto.type && dto.type !== existing.type && (dto.type === 'investment' || existing.type === 'investment')) throw new AppError('HOLDING_HISTORY_METADATA', 'Create a separate account to change between cash and investment', 409)
+ if (existing.type === 'investment') {
+ if (dto.adjustWithTransaction || dto.splitTransactions?.length) throw AppError.validation('Use a dated position correction, not an income/expense adjustment, for a holding')
+ if (dto.balance !== undefined && dto.balance !== existing.balance && dto.expected_balance === undefined) throw new AppError('HOLDING_POSITION_CONFLICT', 'Refresh and provide the saved quantity/value before correcting a holding', 409)
+ if ((dto.expected_balance !== undefined && dto.expected_balance !== existing.balance) || (dto.expected_updated_at !== undefined && dto.expected_updated_at !== existing.updated_at)) throw new AppError('HOLDING_POSITION_CONFLICT', 'Holding changed; refresh before saving', 409)
+ const today = this.today(clientDate)
+ let next: Account = { ...existing, ...dto, updated_at: now }
+ const identityFields = ['currency', 'quote_currency', 'symbol', 'pricing_method', 'provider', 'exchange'] as const
+ const identityChanged = identityFields.some(field => dto[field] !== undefined && (dto[field] ?? null) !== (existing[field] ?? null))
+ const categoryChanged = dto.asset_type !== undefined && dto.asset_type !== existing.asset_type
+ const safeCategoryChange = categoryChanged && ['stock', 'etf'].includes(existing.asset_type || '') && ['stock', 'etf'].includes(dto.asset_type!)
+ if ((identityChanged || (categoryChanged && !safeCategoryChange)) && (existing.holding_position?.has_history || existing.balance !== 0)) throw new AppError('HOLDING_HISTORY_METADATA', 'This change would reinterpret existing holding history. Keep its instrument, units, currency and pricing, or create a separate holding.', 409)
+ if (identityChanged || (categoryChanged && !safeCategoryChange)) {
+ if (next.pricing_method === 'market') next = { ...next, manual_price: null, manual_price_date: null }
+ next = this.normalizeHolding(next)
+ }
+ if (next.balance !== existing.balance && !validHoldingNumber(next.balance)) throw AppError.validation('Invalid holding quantity/value precision or range')
+ if (dto.manual_price !== undefined && dto.manual_price != null && (next.asset_type === 'manual' || next.pricing_method !== 'manual' || !validHoldingNumber(dto.manual_price))) throw AppError.validation('A manual unit price requires a manually priced ETF, stock or crypto holding')
+ let anchor: PositionAnchor | null = null
+ if (dto.opening) {
+ if (await this.accountRepo.hasFutureInvestmentActivity(id, today)) throw AppError.validation('Resolve future-dated legacy investment activity before confirming an opening')
+ if (!validLocalDate(dto.opening.date) || dto.opening.date > today) throw AppError.validation('Opening date must be on or before today')
+ anchor = this.anchor(dto.opening.balance, 'opening', dto.opening.date, now, dto.opening)
+ } else if (next.balance !== existing.balance) {
+ const date = dto.correction_date ?? today
+ if (!validLocalDate(date) || date !== today) throw AppError.validation('A current position correction must use today; supply a dated opening to reconcile earlier history')
+ anchor = this.anchor(next.balance, 'correction', date, now, next.asset_type === 'manual' ? { balance: next.balance, date, cost_amount: existing.holding_position?.anchor?.cost_amount, cost_currency: existing.holding_position?.anchor?.cost_currency } : undefined)
+ }
+ if (dto.manual_price !== undefined && dto.manual_price !== existing.manual_price) next = { ...next, manual_price_date: dto.manual_price == null ? null : dto.manual_price_date ?? today }
+ if (next.manual_price_date && (!validLocalDate(next.manual_price_date) || next.manual_price_date > today)) throw AppError.validation('Manual price date cannot be in the future')
+ if (!await this.accountRepo.updateHolding(existing, next, anchor)) throw new AppError('HOLDING_POSITION_CONFLICT', 'Holding changed or became protected; refresh before saving', 409)
+ return (await this.accountRepo.findById(id))!
+ }
+ if (dto.opening || dto.manual_price != null || dto.pricing_method) throw AppError.validation('Holding metadata belongs to investment accounts')
// Get the current account data if we're potentially adjusting balance with a transaction
let oldAccount: Account | null = null
@@ -58,6 +112,9 @@ export class AccountService {
}
delete (updates as any).adjustWithTransaction
delete (updates as any).splitTransactions
+ delete (updates as any).expected_balance
+ delete (updates as any).expected_updated_at
+ delete (updates as any).correction_date
const currency = dto.currency?.trim()
if (currency) {
@@ -122,6 +179,38 @@ export class AccountService {
return updatedAccount
}
+ private today(clientDate?: string) {
+ return clientDate && validLocalDate(clientDate) ? clientDate : new Date().toISOString().slice(0, 10)
+ }
+
+ private normalizeHolding(a: Account): Account {
+ const category = a.asset_type
+ if (!category || !['stock', 'etf', 'crypto', 'manual'].includes(category)) throw AppError.validation('Choose ETF, stock, crypto or manual for the holding')
+ if (!validHoldingNumber(a.balance)) throw AppError.validation('Use a nonnegative quantity/value with at most 12 decimal and 15 significant digits (maximum 1 trillion)')
+ const total = category === 'manual'
+ const method = total ? 'manual' : a.pricing_method || 'market'
+ const currency = a.currency.toUpperCase()
+ if (total && !FIAT.has(currency)) throw AppError.validation('Manual total assets need a supported fiat currency')
+ if ((category === 'stock' || category === 'etf') && currency !== 'SHARE') throw AppError.validation('Stocks and ETFs use SHARE units')
+ if (category === 'crypto' && !/^[A-Z0-9]{2,20}$/.test(currency)) throw AppError.validation('Crypto needs its coin unit')
+ const quote = total ? currency : a.quote_currency?.toUpperCase()
+ if (!quote || !FIAT.has(quote)) throw AppError.validation('Choose a supported fiat quote currency')
+ const symbol = a.symbol?.trim().toUpperCase() || undefined
+ if (method === 'market' && !symbol) throw AppError.validation('Market pricing needs a canonical provider symbol')
+ if (a.manual_price != null && (!validHoldingNumber(a.manual_price) || method !== 'manual' || total)) throw AppError.validation('A per-unit manual price is only valid for manually priced unit holdings')
+ return { ...a, currency, quote_currency: quote, unit: total ? 'ITEM' : currency, symbol,
+ pricing_method: method, provider: method === 'market' ? 'yahoo' : null,
+ exchange: a.exchange?.trim() || null, manual_price: method === 'manual' && !total ? a.manual_price ?? null : null,
+ manual_price_date: method === 'manual' && !total ? a.manual_price_date ?? null : null }
+ }
+
+ private anchor(balance: number, kind: PositionAnchor['kind'], date: string, now: number, opening?: HoldingOpeningDto): PositionAnchor {
+ if (!validHoldingNumber(balance)) throw AppError.validation('Invalid opening quantity/value')
+ if (opening?.cost_amount != null && (!Number.isFinite(opening.cost_amount) || opening.cost_amount < 0 || opening.cost_amount > 1e12 || !opening.cost_currency)) throw AppError.validation('Known opening cost needs a valid amount and fiat currency')
+ return { id: crypto.randomUUID(), kind, effective_date: date, balance,
+ cost_amount: opening?.cost_amount ?? null, cost_currency: opening?.cost_currency ?? null, trade_rowid_boundary: 0, created_at: now }
+ }
+
async lockAccount(id: string): Promise {
await this.requireActive(id, true)
await this.accountRepo.setLocked(id, true)
diff --git a/api/src/tests/holding.test.ts b/api/src/tests/holding.test.ts
new file mode 100644
index 00000000..809b78ae
--- /dev/null
+++ b/api/src/tests/holding.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { holdingNativeValue, positionFromObservation, validHoldingNumber, validLocalDate } from '../../../shared/holding'
+import type { PositionAnchor } from '../../../shared/holding'
+const anchor: PositionAnchor = { id: 'synthetic-opening', kind: 'opening', effective_date: '2026-01-01', balance: 3,
+ cost_amount: 30, cost_currency: 'EUR', trade_rowid_boundary: 0, created_at: 1 }
+const etf = { type: 'investment', balance: 5, currency: 'SHARE', quote_currency: 'EUR', asset_type: 'etf' }
+describe('shared holding read model', () => {
+ it('preserves stored units and flags incomplete history instead of inventing a buy', () => {
+ expect(positionFromObservation(etf, 2, 0, { ...anchor, kind: 'observed', cost_amount: null }))
+ .toMatchObject({ quantity: 5, activity_quantity: 2, residual: 3, history_status: 'mismatch', coverage_reasons: ['unknown_opening', 'quantity_mismatch', 'unknown_cost'] })
+ })
+ it('replays a confirmed EOD opening with later units but does not claim old cash history', () => {
+ expect(positionFromObservation(etf, 2, 2, anchor)).toMatchObject({ quantity: 5, residual: 0, history_status: 'reconciled' })
+ expect(positionFromObservation(etf, 2, 3, anchor)).toMatchObject({ quantity: 5, residual: -1, history_status: 'mismatch' })
+ })
+ it('keeps unknown acquisition history when activity happens to equal observed units', () => {
+ expect(positionFromObservation(etf, 5, 0, { ...anchor, kind: 'observed' })).toMatchObject({ residual: 0, history_status: 'unverified', coverage_reasons: ['unknown_opening'] })
+ })
+ it('prices a manual ETF per unit and a manual total asset once', () => {
+ expect(holdingNativeValue({ ...etf, pricing_method: 'manual', manual_price: 12 })).toEqual({ value: 60, currency: 'EUR' })
+ expect(holdingNativeValue({ ...etf, asset_type: 'manual', currency: 'HUF', balance: 500 })).toEqual({ value: 500, currency: 'HUF' })
+ })
+ it('distinguishes missing unit price, zero price and empty units', () => {
+ expect(holdingNativeValue(etf).value).toBeNull()
+ expect(holdingNativeValue({ ...etf, pricing_method: 'manual', manual_price: 0 }).value).toBe(0)
+ expect(holdingNativeValue({ ...etf, balance: 0 }).value).toBe(0)
+ expect(holdingNativeValue({ ...etf, pricing_method: 'manual', manual_price: Infinity }).value).toBeNull()
+ })
+ it.each([0, 1e-12, 0.123456789012, 1e12])('accepts supported unit precision %s', value => expect(validHoldingNumber(value)).toBe(true))
+ it.each([-1, 1e-13, 1e13, Infinity, NaN, 0.1234567890123])('rejects unsupported unit precision %s', value => expect(validHoldingNumber(value)).toBe(false))
+ it('validates actual calendar dates, including leap days', () => {
+ expect(validLocalDate('2028-02-29')).toBe(true)
+ expect(validLocalDate('2026-02-29')).toBe(false)
+ expect(validLocalDate('2026-02-30')).toBe(false)
+ expect(validLocalDate('2026-13-01')).toBe(false)
+ })
+})
+
+it('does not round away one minimum supported crypto unit when reconciling', () => {
+ expect(positionFromObservation({ ...etf, balance: 1e-12 }, 0, 0, { ...anchor, balance: 0 })).toMatchObject({ residual: 1e-12, history_status: 'mismatch' })
+})
diff --git a/api/src/validators/account.validator.ts b/api/src/validators/account.validator.ts
index 67c1b898..db036cee 100644
--- a/api/src/validators/account.validator.ts
+++ b/api/src/validators/account.validator.ts
@@ -1,15 +1,16 @@
import { z } from 'zod'
+import { validLocalDate, validHoldingNumber } from '../../../shared/holding'
const SUPPORTED_CURRENCIES = ['HUF', 'EUR', 'USD', 'GBP', 'CHF', 'PLN', 'CZK', 'RON', 'MXN']
-function isValidAccountCurrency(data: { type?: 'cash' | 'investment', currency?: string, asset_type?: 'stock' | 'crypto' | 'manual' }) {
+function isValidAccountCurrency(data: { type?: 'cash' | 'investment', currency?: string, asset_type?: 'etf' | 'stock' | 'crypto' | 'manual' }) {
if (!data.currency) return true
if (data.type === 'cash' || data.asset_type === 'manual') {
return SUPPORTED_CURRENCIES.includes(data.currency)
}
- if (data.asset_type === 'stock') return data.currency === 'SHARE'
+ if (data.asset_type === 'stock' || data.asset_type === 'etf') return data.currency === 'SHARE'
if (data.asset_type === 'crypto') return /^[A-Z0-9]{2,20}$/.test(data.currency)
// A request without an asset type cannot establish an investment-unit context.
@@ -24,14 +25,30 @@ const quoteCurrencySchema = z.string().toUpperCase().refine(c => SUPPORTED_CURRE
message: `Quote currency must be one of: ${SUPPORTED_CURRENCIES.join(', ')}`
}).optional()
+const dateSchema = z.string().refine(validLocalDate, 'Use a valid YYYY-MM-DD date')
+const holdingFields = {
+ pricing_method: z.enum(['market', 'manual']).optional(),
+ provider: z.literal('yahoo').nullable().optional(),
+ exchange: z.string().trim().max(40).nullable().optional(),
+ manual_price: z.number().refine(validHoldingNumber, 'Use a nonnegative price with at most 12 decimal and 15 significant digits').nullable().optional(),
+ manual_price_date: dateSchema.nullable().optional(),
+ opening: z.object({
+ balance: z.number().refine(validHoldingNumber, 'Use a nonnegative opening amount with at most 12 decimal and 15 significant digits'),
+ date: dateSchema,
+ cost_amount: z.number().finite().min(0).max(1e12).nullable().optional(),
+ cost_currency: quoteCurrencySchema.nullable(),
+ }).optional().refine(v => !v || (v.cost_amount == null) === (v.cost_currency == null), 'Known opening cost needs its currency'),
+}
+
export const CreateAccountSchema = z.object({
- name: z.string().min(1, 'Name is required').max(100),
+ ...holdingFields,
+ name: z.string().trim().min(1, 'Name is required').max(100),
type: z.enum(['cash', 'investment']),
balance: z.number().finite('Balance must be a finite number'),
currency: z.string().toUpperCase().optional(),
quote_currency: quoteCurrencySchema,
symbol: z.string().max(20).optional(),
- asset_type: z.enum(['stock', 'crypto', 'manual']).optional(),
+ asset_type: z.enum(['etf', 'stock', 'crypto', 'manual']).optional(),
exclude_from_net_worth: z.boolean().optional(),
exclude_from_cash_balance: z.boolean().optional(),
}).superRefine((data, ctx) => {
@@ -47,15 +64,19 @@ const SplitTransactionSchema = z.object({
})
export const UpdateAccountSchema = z.object({
- name: z.string().min(1).max(100).optional(),
+ ...holdingFields,
+ name: z.string().trim().min(1).max(100).optional(),
type: z.enum(['cash', 'investment']).optional(),
balance: z.number().finite().optional(),
currency: z.string().toUpperCase().optional(),
quote_currency: quoteCurrencySchema,
symbol: z.string().max(20).optional(),
- asset_type: z.enum(['stock', 'crypto', 'manual']).optional(),
+ asset_type: z.enum(['etf', 'stock', 'crypto', 'manual']).optional(),
exclude_from_net_worth: z.boolean().optional(),
exclude_from_cash_balance: z.boolean().optional(),
+ expected_balance: z.number().finite().optional(),
+ expected_updated_at: z.number().finite().optional(),
+ correction_date: dateSchema.optional(),
adjustWithTransaction: z.boolean().optional(),
splitTransactions: z.array(SplitTransactionSchema).optional(),
}).superRefine((data, ctx) => {
diff --git a/client/src/components/analytics-module/types.ts b/client/src/components/analytics-module/types.ts
index 05104d37..ea51e136 100644
--- a/client/src/components/analytics-module/types.ts
+++ b/client/src/components/analytics-module/types.ts
@@ -1,3 +1,4 @@
+import type { HoldingFields } from '../../../../shared/holding'
import type { PendingKind, ReviewSource } from '../../lib/transaction-review'
export type Transaction = {
@@ -22,7 +23,7 @@ export type Category = {
type: 'income' | 'expense'
}
-export type Account = {
+export type Account = HoldingFields & {
id: string
name: string
type: string
diff --git a/client/src/components/dashboard-module/AccountList.manage.test.tsx b/client/src/components/dashboard-module/AccountList.manage.test.tsx
index 6ba62ac2..76878afc 100644
--- a/client/src/components/dashboard-module/AccountList.manage.test.tsx
+++ b/client/src/components/dashboard-module/AccountList.manage.test.tsx
@@ -152,11 +152,12 @@ describe('dedicated account management', () => {
expect(screen.getByLabelText('Quantity')).toHaveValue('0.12345678')
expect(screen.getByLabelText('Trading currency')).toHaveValue('EUR')
expect(screen.queryByRole('switch', { name: 'Cash balance' })).not.toBeInTheDocument()
- fireEvent.change(screen.getByLabelText('Trading currency'), { target: { value: 'USD' } })
+ expect(screen.getByLabelText('Trading currency')).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Renamed stock' } })
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
await waitFor(() => expect(refresh).toHaveBeenCalledOnce())
const request = fetchMock.mock.calls.find(([, options]) => options?.method === 'PUT')
- expect(JSON.parse(String(request?.[1]?.body))).toMatchObject({ balance: 0.12345678, currency: 'SHARE', quote_currency: 'USD', asset_type: 'stock', symbol: 'TEST' })
+ expect(JSON.parse(String(request?.[1]?.body))).toMatchObject({ balance: 0.12345678, currency: 'SHARE', quote_currency: 'EUR', asset_type: 'stock', symbol: 'TEST' })
})
it('masks saved balance and difference previews while allowing explicit edits', () => {
@@ -508,3 +509,73 @@ describe('account creation redesign', () => {
expect(refresh).toHaveBeenCalledOnce()
})
})
+
+describe('holding category, pricing and opening setup', () => {
+ it('creates a manually priced ETF with a dated opening and optional cost, without a market symbol', async () => {
+ render()
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Manually priced ETF' } })
+ fireEvent.change(screen.getByLabelText('Asset category'), { target: { value: 'etf' } })
+ fireEvent.change(screen.getByLabelText('Pricing'), { target: { value: 'manual' } })
+ fireEvent.change(screen.getByLabelText('Trading currency'), { target: { value: 'EUR' } })
+ fireEvent.change(screen.getByLabelText('Manual price per unit'), { target: { value: '120,25' } })
+ fireEvent.change(screen.getByLabelText('Starting quantity'), { target: { value: '2,123456789012' } })
+ fireEvent.change(screen.getByLabelText('Starting position as of (end of day)'), { target: { value: '2026-01-10' } })
+ fireEvent.change(screen.getByLabelText('Known remaining cost (optional)'), { target: { value: '200' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Create account' }))
+ await waitFor(() => expect(refresh).toHaveBeenCalledOnce())
+ const request = fetchMock.mock.calls.find(([, options]) => options?.method === 'POST')!
+ expect(JSON.parse(String(request[1]?.body))).toMatchObject({ asset_type: 'etf', pricing_method: 'manual', currency: 'SHARE', quote_currency: 'EUR',
+ manual_price: 120.25, balance: 2.123456789012, opening: { balance: 2.123456789012, date: '2026-01-10', cost_amount: 200, cost_currency: 'EUR' } })
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/market/'))).toBe(false)
+ })
+
+ it('retains a reconciliation draft after a failed save and does not create a cash adjustment', async () => {
+ fail = true
+ const a = { id: 'legacy-opening', name: 'Legacy ETF', type: 'investment' as const, asset_type: 'etf' as const, pricing_method: 'manual' as const, balance: 5, currency: 'SHARE', quote_currency: 'EUR', updated_at: 1 }
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Legacy ETF' }))
+ fireEvent.click(screen.getByLabelText('Reconcile a dated opening position'))
+ fireEvent.change(screen.getByLabelText('Opening quantity'), { target: { value: '3' } })
+ fireEvent.change(screen.getByLabelText('Starting position as of (end of day)'), { target: { value: '2026-01-10' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ await waitFor(() => expect(alerts.showAlert).toHaveBeenCalledWith({ type: 'error', message: 'Save rejected' }))
+ expect(screen.getByLabelText('Opening quantity')).toHaveValue('3')
+ const request = fetchMock.mock.calls.find(([, options]) => options?.method === 'PUT')!
+ expect(JSON.parse(String(request[1]?.body))).toMatchObject({ expected_balance: 5, expected_updated_at: 1, opening: { balance: 3, date: '2026-01-10' }, adjustWithTransaction: false })
+ expect(screen.queryByRole('dialog', { name: /adjustment/i })).not.toBeInTheDocument()
+ expect(refresh).not.toHaveBeenCalled()
+ })
+
+ it('keeps the saved quantity guard when refreshed account props change under an open editor', async () => {
+ const a = { id: 'guarded', name: 'Guarded holding', type: 'investment' as const, asset_type: 'etf' as const, pricing_method: 'manual' as const, balance: 2, currency: 'SHARE', quote_currency: 'EUR', updated_at: 1 }
+ const { rerender } = render()
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Guarded holding' }))
+ fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Kept name draft' } })
+ rerender()
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
+ await waitFor(() => expect(refresh).toHaveBeenCalledOnce())
+ const request = fetchMock.mock.calls.find(([, options]) => options?.method === 'PUT')!
+ expect(JSON.parse(String(request[1]?.body))).toMatchObject({ expected_balance: 2, expected_updated_at: 1, balance: 2 })
+ })
+})
+
+it('masks saved manual prices in a locked private holding editor', async () => {
+ privacy.hidden = true
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'View Private holding' }))
+ expect(screen.getByLabelText('Manual price per unit')).toHaveValue('••••••')
+ expect(screen.getByLabelText('Quantity')).toHaveValue('••••••')
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled()
+})
+
+it('prefills a previously confirmed opening rather than replacing its known basis with an unknown current observation', () => {
+ const anchor = { id: 'known-opening', kind: 'opening' as const, balance: 3, effective_date: '2026-01-10', cost_amount: 30, cost_currency: 'EUR', trade_rowid_boundary: 0, created_at: 1 }
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Known opening' }))
+ fireEvent.click(screen.getByLabelText('Reconcile a dated opening position'))
+ expect(screen.getByLabelText('Opening quantity')).toHaveValue('3')
+ expect(screen.getByLabelText('Starting position as of (end of day)')).toHaveValue('2026-01-10')
+ expect(screen.getByLabelText('Known remaining cost (optional)')).toHaveValue('30')
+})
diff --git a/client/src/components/dashboard-module/AccountList.tsx b/client/src/components/dashboard-module/AccountList.tsx
index f9c5a311..5626cc8c 100644
--- a/client/src/components/dashboard-module/AccountList.tsx
+++ b/client/src/components/dashboard-module/AccountList.tsx
@@ -1,3 +1,6 @@
+import { usesMarketPricing } from '../../../../shared/holding'
+import { holdingFormDefaults, localAccountDate } from './account-form'
+import type { HoldingFields } from '../../../../shared/holding'
import { useUnsavedChanges } from '../../navigation/UnsavedChanges'
import { convertCurrency, sumAvailable, validRates } from '../../../../shared/currency'
import { accountBalanceShares, accountValueForOrdering, sortAccountsByValue } from '../../lib/account-order'
@@ -22,7 +25,7 @@ import type { AccountFormData } from './account-form'
import { AdjustmentChoiceModal } from './AdjustmentChoiceModal'
import { formatAmount, parseAmount } from '../../lib/amount'
-type Account = {
+type Account = HoldingFields & {
id: string
name: string
type: 'cash' | 'investment'
@@ -30,11 +33,12 @@ type Account = {
currency: string
quote_currency?: string
symbol?: string
- asset_type?: 'stock' | 'crypto' | 'manual'
+ asset_type?: 'etf' | 'stock' | 'crypto' | 'manual'
exclude_from_net_worth?: boolean
exclude_from_cash_balance?: boolean
archived_at?: number | null
is_locked?: boolean
+ updated_at?: number
}
type Category = {
@@ -61,22 +65,24 @@ type MarketQuote = {
regularMarketChangePercent?: number
}
-export function AccountList({ accounts, onAccountAdded, loading, manage = false, editAccountId, addRequest = false, requestKey = '', sortValues }: {
- accounts: Account[]; onAccountAdded: () => void; loading?: boolean; manage?: boolean; editAccountId?: string; addRequest?: boolean; requestKey?: string; sortValues?: AccountSortValues
+export function AccountList({ accounts, onAccountAdded, loading, manage = false, editAccountId, addRequest = false, initialInvestment = false, requestKey = '', sortValues }: {
+ accounts: Account[]; initialInvestment?: boolean; onAccountAdded: () => void; loading?: boolean; manage?: boolean; editAccountId?: string; addRequest?: boolean; requestKey?: string; sortValues?: AccountSortValues
}) {
const { confirm, showAlert } = useAlert()
const isLocked = (id: string) => accounts.find(a => a.id === id)?.is_locked ?? false
const [isAdding, setIsAdding] = useState(false)
const [editingId, setEditingId] = useState(null)
+ const savedHoldingVersion = useRef(undefined)
const [savedFormData, setSavedFormData] = useState(null)
const [formData, setFormData] = useState({
+ ...holdingFormDefaults(),
name: '',
type: 'cash',
balance: '',
currency: 'HUF',
quote_currency: 'USD',
symbol: '',
- asset_type: 'stock' as 'stock' | 'crypto' | 'manual',
+ asset_type: 'stock' as 'etf' | 'stock' | 'crypto' | 'manual',
adjustWithTransaction: false,
exclude_from_net_worth: false,
exclude_from_cash_balance: false
@@ -170,7 +176,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
// Fetch market quotes for investment accounts
useEffect(() => {
const fetchQuotes = async () => {
- const investmentAccounts = activeAccounts.filter(a => a.type === 'investment' && a.symbol && a.asset_type !== 'manual')
+ const investmentAccounts = activeAccounts.filter(a => a.type === 'investment' && a.symbol && usesMarketPricing(a))
if (investmentAccounts.length === 0) return
const symbols = [...new Set(investmentAccounts.map(a => a.symbol!))]
@@ -219,7 +225,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
if (accounts.length) convertCurrency(1, 'USD', 'HUF', exchangeRates, 'USD').missingCurrencies.forEach(item => missingCurrencies.add(item))
const resetForm = () => {
- setFormData({ name: '', type: 'cash', balance: '', currency: 'HUF', quote_currency: 'USD', symbol: '', asset_type: 'stock', adjustWithTransaction: false, exclude_from_net_worth: false, exclude_from_cash_balance: false })
+ setFormData({ ...holdingFormDefaults(), name: '', type: 'cash', balance: '', currency: 'HUF', quote_currency: 'USD', symbol: '', asset_type: 'stock', adjustWithTransaction: false, exclude_from_net_worth: false, exclude_from_cash_balance: false })
setShowSymbolSearch(false)
setSearchQuery('')
setSearchResults([])
@@ -229,6 +235,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
const handleTypeChange = (type: AccountFormData['type']) => {
setFormData(current => ({ ...current, type,
+ ...(type === 'investment' && current.type === 'cash' ? { currency: 'SHARE', pricing_method: 'market' as const } : {}),
...(type === 'cash' && current.type === 'investment'
? { currency: 'HUF', quote_currency: 'USD', symbol: '', asset_type: 'stock' as const }
: {})
@@ -257,7 +264,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
}
const handleSelectAsset = async (asset: any) => {
- const assetType = asset.quoteType === 'CRYPTOCURRENCY' ? 'crypto' : 'stock'
+ const assetType = asset.quoteType === 'CRYPTOCURRENCY' ? 'crypto' : asset.quoteType === 'ETF' ? 'etf' : 'stock'
// For crypto, currency is the crypto symbol (e.g., BTC). For stocks, use 'SHARE'
const currency = assetType === 'crypto' ? asset.symbol.split('-')[0] : 'SHARE'
const initialQuoteCurrency = typeof asset.currency === 'string' ? asset.currency.toUpperCase() : 'USD'
@@ -265,7 +272,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
...formData,
name: asset.shortname || asset.longname || asset.symbol,
symbol: asset.symbol,
- asset_type: assetType,
+ asset_type: assetType, pricing_method: 'market', exchange: asset.exchange || '',
currency: currency,
quote_currency: initialQuoteCurrency
})
@@ -292,7 +299,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
...formData,
currency: 'HUF',
quote_currency: 'HUF',
- asset_type: 'manual',
+ asset_type: 'manual', pricing_method: 'manual',
symbol: ''
})
setShowSymbolSearch(false)
@@ -303,7 +310,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
if (isSubmitting || accountActionBusy || editorReadOnly || deleteTarget || (editingId && !editorDirty)) return
if (!editingId && (!formData.name.trim() ||
- (formData.type === 'investment' && formData.asset_type !== 'manual' && !formData.symbol))) return
+ (formData.type === 'investment' && formData.asset_type !== 'manual' && formData.pricing_method !== 'manual' && !formData.symbol))) return
setIsSubmitting(true)
try {
const wasEditing = !!editingId
@@ -322,13 +329,44 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
}
if (formData.type === 'investment') {
- payload.symbol = formData.symbol || (editingId ? null : undefined)
+ payload.symbol = formData.symbol || undefined
payload.asset_type = formData.asset_type
+ payload.pricing_method = formData.asset_type === 'manual' ? 'manual' : formData.pricing_method || 'market'
+ if (formData.exchange) payload.exchange = formData.exchange
+ if (formData.manual_price?.trim()) {
+ const price = parseAmount(formData.manual_price)
+ if (price === null) throw new Error('Enter a complete manual unit price')
+ payload.manual_price = price
+ payload.manual_price_date = formData.manual_price_date || localAccountDate()
+ }
+ if (!formData.quote_currency) delete payload.quote_currency
+ if (!editingId || formData.confirm_opening) {
+ const openingBalance = editingId ? parseAmount(formData.opening_balance || '') : payload.balance
+ if (openingBalance === null) throw new Error('Enter a complete opening quantity/value')
+ let date = formData.opening_date || ''
+ if (!editingId && openingBalance === 0 && date === localAccountDate()) {
+ const prior = new Date(`${date}T12:00:00`); prior.setDate(prior.getDate() - 1)
+ date = `${prior.getFullYear()}-${String(prior.getMonth() + 1).padStart(2, '0')}-${String(prior.getDate()).padStart(2, '0')}`
+ }
+ if (!date) throw new Error('Choose the opening date')
+ payload.opening = { balance: openingBalance, date }
+ if (formData.opening_cost?.trim()) {
+ const cost = parseAmount(formData.opening_cost)
+ if (cost === null) throw new Error('Enter a complete opening cost or leave it blank')
+ payload.opening.cost_amount = cost
+ payload.opening.cost_currency = formData.opening_cost_currency || formData.quote_currency || formData.currency
+ }
+ }
+ if (editingId && !formData.manual_price?.trim() && editingAccount?.manual_price != null) payload.manual_price = null
+ if (editingId) {
+ payload.expected_balance = savedFormData ? parseAmount(savedFormData.balance) : undefined
+ payload.expected_updated_at = typeof savedHoldingVersion.current === 'number' ? savedHoldingVersion.current : undefined
+ }
}
if (editingId) {
// Check if we need to show adjustment choice modal
- if (formData.adjustWithTransaction) {
+ if (formData.type !== 'investment' && formData.adjustWithTransaction) {
const account = accounts.find(a => a.id === editingId)
if (account && account.balance !== payload.balance) {
// Store pending adjustment and show choice modal
@@ -344,7 +382,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
}
// Include the adjustWithTransaction flag when editing
- payload.adjustWithTransaction = formData.adjustWithTransaction
+ payload.adjustWithTransaction = formData.type === 'investment' ? false : formData.adjustWithTransaction
await apiFetch(`${API_BASE_URL}/accounts/${editingId}`, {
method: 'PUT',
@@ -378,15 +416,25 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
}
const handleEdit = (account: Account) => {
+ savedHoldingVersion.current = account.updated_at
+ const anchor = account.holding_position?.anchor
const draft: AccountFormData = {
+ ...holdingFormDefaults(),
name: account.name,
type: account.type,
balance: formatAmount(account.balance),
currency: account.currency,
- quote_currency: account.quote_currency || (account.symbol ? quotes[account.symbol]?.currency : undefined) || 'USD',
+ quote_currency: account.quote_currency || '',
symbol: account.symbol || '',
asset_type: account.asset_type || 'stock',
- adjustWithTransaction: true,
+ pricing_method: account.pricing_method || (account.asset_type === 'manual' ? 'manual' : 'market'),
+ exchange: account.exchange || '', manual_price: account.manual_price == null ? '' : formatAmount(account.manual_price, { maximumFractionDigits: 12 }),
+ manual_price_date: account.manual_price_date || localAccountDate(),
+ opening_date: anchor?.kind === 'opening' ? anchor.effective_date : localAccountDate(),
+ opening_balance: formatAmount(anchor?.kind === 'opening' ? anchor.balance : account.balance, { maximumFractionDigits: 12 }),
+ opening_cost: anchor?.cost_amount == null ? '' : formatAmount(anchor.cost_amount),
+ opening_cost_currency: anchor?.cost_currency || account.quote_currency || account.currency,
+ adjustWithTransaction: account.type !== 'investment',
exclude_from_net_worth: Boolean(account.exclude_from_net_worth),
exclude_from_cash_balance: Boolean(account.exclude_from_cash_balance)
}
@@ -429,9 +477,10 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
handleEdit(account)
} else {
handledRequest.current = request
+ if (initialInvestment) setFormData(current => ({ ...current, type: 'investment', currency: 'SHARE' }))
setIsAdding(true)
}
- }, [accounts, editAccountId, addRequest, requestKey])
+ }, [accounts, editAccountId, addRequest, initialInvestment, requestKey])
const confirmAccountAction = async (id: string, options: Parameters[0]) => {
const fromEditor = isAdding && editingId === id
@@ -664,7 +713,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
className="flex max-w-[520px] flex-col overflow-hidden rounded-2xl sm:max-w-[520px]"
contentClassName="flex min-h-0 flex-1 flex-col p-0 sm:p-0">
{editingAccount ? { if (savedFormData) setFormData({ ...savedFormData }); setDeleteTarget(null); setDeleteName('') }} onCancel={handleCancel}
locked={editorStatus.locked} archived={editorStatus.archived} busy={isSubmitting || accountActionBusy} saving={isSubmitting} hidden={privacyMode === 'hidden'}
@@ -911,7 +960,7 @@ export function AccountList({ accounts, onAccountAdded, loading, manage = false,
{privacyMode === 'hidden' || shouldHideInvestment() ? (
'••••••'
) : (
- `${account.balance.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 8 })} ${account.currency}`
+ `${account.balance.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 12 })} ${account.currency}`
)}