Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/migrations/008-investment-quote-currency.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- The account currency is the holding unit (for example SHARE or BTC). Market
-- prices and purchase costs need a separate currency so EUR-listed securities
-- are not incorrectly treated as USD.
ALTER TABLE accounts ADD COLUMN quote_currency TEXT;
3 changes: 3 additions & 0 deletions api/src/dtos/account.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface CreateAccountDto {
type: AccountType
balance: number
currency?: string
quote_currency?: string
symbol?: string
asset_type?: AssetType
exclude_from_net_worth?: boolean
Expand All @@ -24,6 +25,7 @@ export interface UpdateAccountDto {
type?: AccountType
balance?: number
currency?: string
quote_currency?: string
symbol?: string
asset_type?: AssetType
exclude_from_net_worth?: boolean
Expand All @@ -38,6 +40,7 @@ export interface AccountResponseDto {
type: AccountType
balance: number
currency: string
quote_currency?: string
symbol?: string
asset_type?: AssetType
exclude_from_net_worth?: boolean
Expand Down
1 change: 1 addition & 0 deletions api/src/dtos/transaction.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface UpdateTransactionDto {
category_id?: string | null
amount?: number
amount_to?: number
price?: number
description?: string | null
date?: string
exclude_from_estimate?: boolean
Expand Down
2 changes: 2 additions & 0 deletions api/src/mappers/account.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export class AccountMapper {
type: account.type,
balance: account.balance,
currency: account.currency,
quote_currency: account.quote_currency,
symbol: account.symbol,
asset_type: account.asset_type,
exclude_from_net_worth: account.exclude_from_net_worth,
Expand All @@ -25,6 +26,7 @@ export class AccountMapper {
type: dto.type,
balance: dto.balance,
currency: dto.currency || 'HUF',
quote_currency: dto.quote_currency,
symbol: dto.symbol,
asset_type: dto.asset_type,
exclude_from_net_worth: dto.exclude_from_net_worth,
Expand Down
2 changes: 1 addition & 1 deletion api/src/middlewares/cors.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function corsMiddleware(c: Context<{ Bindings: Bindings }>, next: N
// Set CORS headers for allowed origins
c.header('Access-Control-Allow-Origin', origin)
c.header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Client-Date')
c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')

// Handle preflight requests - return early without API key check
if (c.req.method === 'OPTIONS') {
Expand Down
1 change: 1 addition & 0 deletions api/src/models/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface Account {
type: AccountType
balance: number
currency: string
quote_currency?: string
symbol?: string
asset_type?: AssetType
exclude_from_net_worth?: boolean
Expand Down
15 changes: 15 additions & 0 deletions api/src/repositories/investment-transaction.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ export class InvestmentTransactionRepository {
).run()
}

async update(id: string, transaction: Omit<InvestmentTransaction, 'id' | 'created_at'>): Promise<void> {
await this.db.prepare(
'UPDATE investment_transactions SET account_id = ?, type = ?, quantity = ?, price = ?, total_amount = ?, date = ?, notes = ? WHERE id = ?'
).bind(
transaction.account_id,
transaction.type,
transaction.quantity,
transaction.price,
transaction.total_amount,
transaction.date,
transaction.notes || null,
id
).run()
}

async delete(id: string): Promise<void> {
await this.db.prepare('DELETE FROM investment_transactions WHERE id = ?').bind(id).run()
}
Expand Down
8 changes: 8 additions & 0 deletions api/src/services/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class AccountService {
type: dto.type,
balance: dto.balance,
currency: currency.toUpperCase(),
quote_currency: dto.quote_currency?.trim().toUpperCase(),
symbol: dto.symbol,
asset_type: dto.asset_type,
exclude_from_net_worth: dto.exclude_from_net_worth,
Expand Down Expand Up @@ -64,6 +65,13 @@ export class AccountService {
delete (updates as any).currency
}

const quoteCurrency = dto.quote_currency?.trim()
if (quoteCurrency) {
updates.quote_currency = quoteCurrency.toUpperCase()
} else {
delete (updates as any).quote_currency
}

await this.accountRepo.update(id, updates)

// If adjustWithTransaction is true and balance changed, create transactions
Expand Down
94 changes: 94 additions & 0 deletions api/src/services/transaction.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ export class TransactionService {
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)
}
}

const oldAccount = await this.accountRepo.findById(oldTx.account_id)
Expand Down Expand Up @@ -316,6 +321,95 @@ export class TransactionService {
return updated!
}

private async updateInvestmentTransfer(
outgoing: Transaction,
investmentTx: InvestmentTransaction,
dto: UpdateTransactionDto
): Promise<Transaction> {
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)
const amountTo = dto.amount_to ?? investmentTx.quantity
const price = dto.price ?? investmentTx.price
const date = dto.date || outgoing.date
const note = dto.description !== undefined ? dto.description : undefined
const now = Date.now()

if (fromAccountId === toAccountId) {
throw new Error('Cannot transfer to same account')
}
if (amountFrom <= 0 || amountTo <= 0 || price < 0) {
throw new Error('Transfer amounts and price must be valid')
}

const accountIds = new Set([outgoing.account_id, investmentTx.account_id, fromAccountId, toAccountId])
const accounts = new Map<string, Account>()
for (const accountId of accountIds) {
const account = await this.accountRepo.findById(accountId)
if (!account) throw new Error('Account not found')
this.assertAccountUnlocked(account)
accounts.set(accountId, account)
}

const toAccount = accounts.get(toAccountId)!
if (toAccount.type !== 'investment') {
throw new Error('Investment transfer must target an investment account')
}

const balanceDeltas = new Map<string, number>()
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)
}

let outgoingDescription = outgoing.description
let investmentNotes = investmentTx.notes
if (note !== undefined) {
const fromAccount = accounts.get(fromAccountId)!
const quoteCurrency = toAccount.quote_currency || 'USD'
const purchaseDetails = `${amountTo.toFixed(8)} shares${price > 0 ? ` @ ${quoteCurrency} ${price.toFixed(2)}/share` : ''}`
outgoingDescription = `Transfer to ${toAccount.name} (${purchaseDetails})`
investmentNotes = `Transfer from ${fromAccount.name} (${purchaseDetails})`
if (note) {
outgoingDescription += ` - ${note}`
investmentNotes += ` - ${note}`
}
}

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 updated = await this.transactionRepo.findById(outgoing.id)
return updated!
}

async deleteTransaction(id: string): Promise<void> {
// Get transaction to revert balance
const tx = await this.transactionRepo.findById(id)
Expand Down
11 changes: 9 additions & 2 deletions api/src/services/transfer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,18 @@ export class TransferService {
const outgoingId = crypto.randomUUID()
const incomingId = crypto.randomUUID()

// Build description with exchange rate info if currencies differ
// Build descriptions that distinguish a share price from the FX rate used
// to convert the cash amount into the share quantity.
let outgoingDesc = `Transfer to ${toAccount.name}`
let incomingDesc = `Transfer from ${fromAccount.name}`

if (fromAccount.currency !== toAccount.currency) {
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) {
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)})`
Expand Down
44 changes: 44 additions & 0 deletions api/src/tests/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,50 @@ describe('Account validators', () => {
expect(result.success).toBe(true)
})

it('accepts the SHARE unit used for stock investment quantities', () => {
const result = CreateAccountSchema.safeParse({
name: 'Vanguard FTSE All-World',
type: 'investment',
balance: 0,
currency: 'SHARE',
symbol: 'VWCE.MI',
asset_type: 'stock',
})
expect(result.success).toBe(true)
})

it('accepts a crypto ticker as the investment unit', () => {
const result = CreateAccountSchema.safeParse({
name: 'Bitcoin',
type: 'investment',
balance: 0,
currency: 'BTC',
symbol: 'BTC-USD',
asset_type: 'crypto',
})
expect(result.success).toBe(true)
})

it('rejects investment units for cash accounts', () => {
const result = CreateAccountSchema.safeParse({
name: 'Checking Account',
type: 'cash',
balance: 100,
currency: 'SHARE',
})
expect(result.success).toBe(false)
})

it('rejects an investment unit without an investment asset type', () => {
const result = CreateAccountSchema.safeParse({
name: 'Invalid investment',
type: 'investment',
balance: 0,
currency: 'SHARE',
})
expect(result.success).toBe(false)
})

it('allows partial update with no fields', () => {
const result = UpdateAccountSchema.safeParse({})
expect(result.success).toBe(true)
Expand Down
38 changes: 32 additions & 6 deletions api/src/validators/account.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,40 @@ import { z } from 'zod'

const SUPPORTED_CURRENCIES = ['HUF', 'EUR', 'USD', 'GBP', 'CHF', 'PLN', 'CZK', 'RON']

function isValidAccountCurrency(data: { type?: 'cash' | 'investment', currency?: string, asset_type?: '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 === 'crypto') return /^[A-Z0-9]{2,20}$/.test(data.currency)

// A request without an asset type cannot establish an investment-unit context.
return SUPPORTED_CURRENCIES.includes(data.currency)
}

const accountCurrencyError = {
message: `Cash and manual investment currencies must be one of: ${SUPPORTED_CURRENCIES.join(', ')}. Stocks use SHARE and crypto uses its ticker.`,
path: ['currency']
}
const quoteCurrencySchema = z.string().toUpperCase().refine(c => SUPPORTED_CURRENCIES.includes(c), {
message: `Quote currency must be one of: ${SUPPORTED_CURRENCIES.join(', ')}`
}).optional()

export const CreateAccountSchema = z.object({
name: z.string().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().refine(c => SUPPORTED_CURRENCIES.includes(c), {
message: `Currency must be one of: ${SUPPORTED_CURRENCIES.join(', ')}`
}).optional(),
currency: z.string().toUpperCase().optional(),
quote_currency: quoteCurrencySchema,
symbol: z.string().max(20).optional(),
asset_type: z.enum(['stock', 'crypto', 'manual']).optional(),
exclude_from_net_worth: z.boolean().optional(),
exclude_from_cash_balance: z.boolean().optional(),
}).superRefine((data, ctx) => {
if (!isValidAccountCurrency(data)) ctx.addIssue({ code: 'custom', ...accountCurrencyError })
})

const SplitTransactionSchema = z.object({
Expand All @@ -27,13 +50,16 @@ export const UpdateAccountSchema = z.object({
name: z.string().min(1).max(100).optional(),
type: z.enum(['cash', 'investment']).optional(),
balance: z.number().finite().optional(),
currency: z.string().toUpperCase().refine(c => SUPPORTED_CURRENCIES.includes(c), {
message: `Currency must be one of: ${SUPPORTED_CURRENCIES.join(', ')}`
}).optional(),
currency: z.string().toUpperCase().optional(),
quote_currency: quoteCurrencySchema,
symbol: z.string().max(20).optional(),
asset_type: z.enum(['stock', 'crypto', 'manual']).optional(),
exclude_from_net_worth: z.boolean().optional(),
exclude_from_cash_balance: z.boolean().optional(),
adjustWithTransaction: z.boolean().optional(),
splitTransactions: z.array(SplitTransactionSchema).optional(),
}).superRefine((data, ctx) => {
if (!isValidAccountCurrency(data)) {
ctx.addIssue({ code: 'custom', ...accountCurrencyError })
}
})
1 change: 1 addition & 0 deletions api/src/validators/transaction.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const UpdateTransactionSchema = z.object({
category_id: z.string().nullable().optional(),
amount: z.number().finite().refine(n => n !== 0, 'Amount cannot be zero').optional(),
amount_to: z.number().finite().positive('amount_to must be positive').optional(),
price: z.number().finite().positive('price must be positive').optional(),
description: z.string().max(500).nullable().optional(),
date: z.string().regex(dateRegex, 'Date must be YYYY-MM-DD').optional(),
exclude_from_estimate: z.boolean().optional(),
Expand Down
5 changes: 5 additions & 0 deletions client/public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
Pragma: no-cache
Expires: 0

/site.webmanifest
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0

/registerSW.js
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expand Down
Loading
Loading