Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
44cbfc8919 feat: Add mortgage and loan account types with interest calculation
Co-authored-by: matiss <matiss@mja.lv>
2025-10-28 21:59:23 +00:00
9 changed files with 524 additions and 2 deletions

View File

@@ -142,15 +142,31 @@ type CreateAccountPayload = {
name: string;
balance: number;
offBudget: boolean;
accountType?:
| 'checking'
| 'savings'
| 'credit'
| 'investment'
| 'mortgage'
| 'loan';
interestRate?: number | null;
};
export const createAccount = createAppAsyncThunk(
`${sliceName}/createAccount`,
async ({ name, balance, offBudget }: CreateAccountPayload) => {
async ({
name,
balance,
offBudget,
accountType,
interestRate,
}: CreateAccountPayload) => {
const id = await send('account-create', {
name,
balance,
offBudget,
accountType,
interestRate,
});
return id;
},

View File

@@ -8,6 +8,7 @@ import { FormError } from '@actual-app/components/form-error';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { InlineField } from '@actual-app/components/inline-field';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
@@ -38,12 +39,23 @@ export function CreateLocalAccountModal() {
const [name, setName] = useState('');
const [offbudget, setOffbudget] = useState(false);
const [balance, setBalance] = useState('0');
const [accountType, setAccountType] = useState<
'checking' | 'savings' | 'credit' | 'investment' | 'mortgage' | 'loan'
>('checking');
const [interestRate, setInterestRate] = useState('');
const [nameError, setNameError] = useState(null);
const [balanceError, setBalanceError] = useState(false);
const [interestRateError, setInterestRateError] = useState(false);
const validateBalance = balance => !isNaN(parseFloat(balance));
const validateInterestRate = (rate: string) => {
if (!rate) return true; // Interest rate is optional
const num = parseFloat(rate);
return !isNaN(num) && num >= 0 && num <= 100;
};
const validateAndSetName = (name: string) => {
const nameError = validateAccountName(name, '', accounts);
if (nameError) {
@@ -62,13 +74,18 @@ export function CreateLocalAccountModal() {
const balanceError = !validateBalance(balance);
setBalanceError(balanceError);
if (!nameError && !balanceError) {
const interestRateError = !validateInterestRate(interestRate);
setInterestRateError(interestRateError);
if (!nameError && !balanceError && !interestRateError) {
dispatch(closeModal());
const id = await dispatch(
createAccount({
name,
balance: toRelaxedNumber(balance),
offBudget: offbudget,
accountType,
interestRate: interestRate ? toRelaxedNumber(interestRate) : null,
}),
).unwrap();
navigate('/accounts/' + id);
@@ -183,6 +200,52 @@ export function CreateLocalAccountModal() {
</FormError>
)}
<InlineField label={t('Account Type')} width="100%">
<Select
value={accountType}
onChange={value =>
setAccountType(value as typeof accountType)
}
options={[
['checking', t('Checking')],
['savings', t('Savings')],
['credit', t('Credit Card')],
['investment', t('Investment')],
['mortgage', t('Mortgage')],
['loan', t('Loan')],
]}
style={{ flex: 1 }}
/>
</InlineField>
{(accountType === 'mortgage' || accountType === 'loan') && (
<InlineField label={t('Interest Rate (%)')} width="100%">
<Input
name="interestRate"
inputMode="decimal"
value={interestRate}
onChangeValue={setInterestRate}
onUpdate={value => {
const rate = value.trim();
setInterestRate(rate);
if (validateInterestRate(rate) && interestRateError) {
setInterestRateError(false);
}
}}
style={{ flex: 1 }}
placeholder="0.00"
/>
</InlineField>
)}
{interestRateError && (
<FormError style={{ marginLeft: 75 }}>
<Trans>
Interest rate must be a number between 0 and 100
</Trans>
</FormError>
)}
<ModalButtons>
<Button onPress={close}>
<Trans>Back</Trans>

View File

@@ -0,0 +1,9 @@
BEGIN TRANSACTION;
-- Add interest_rate column to accounts table for mortgage/loan accounts
ALTER TABLE accounts ADD COLUMN interest_rate REAL;
-- Add account_type column to accounts table to distinguish account types
ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'checking';
COMMIT;

View File

@@ -325,16 +325,29 @@ async function createAccount({
balance = 0,
offBudget = false,
closed = false,
accountType = 'checking',
interestRate = null,
}: {
name: string;
balance?: number | undefined;
offBudget?: boolean | undefined;
closed?: boolean | undefined;
accountType?:
| 'checking'
| 'savings'
| 'credit'
| 'investment'
| 'mortgage'
| 'loan'
| undefined;
interestRate?: number | null | undefined;
}) {
const id: AccountEntity['id'] = await db.insertAccount({
name,
offbudget: offBudget ? 1 : 0,
closed: closed ? 1 : 0,
account_type: accountType,
interest_rate: interestRate,
});
await db.insertPayee({

View File

@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as monthUtils from '../../shared/months';
import * as db from '../db';
import {
calculateInterest,
getDaysSinceLastInterest,
createInterestTransaction,
processInterestForAccount,
type MortgageLoanAccount,
} from './mortgage-loan';
vi.mock('../../shared/months', async () => ({
...(await vi.importActual('../../shared/months')),
currentDay: vi.fn(),
}));
describe('Mortgage/Loan functionality', () => {
beforeEach(async () => {
vi.resetAllMocks();
vi.mocked(monthUtils.currentDay).mockReturnValue('2017-10-15');
await (
global as { emptyDatabase: () => () => Promise<void> }
).emptyDatabase()();
});
// Helper function to create a proper account object
function createTestAccount(
overrides: Partial<MortgageLoanAccount> = {},
): MortgageLoanAccount {
const baseAccount = {
id: 'test-account-id',
name: 'Test Account',
offbudget: 0 as 0 | 1,
closed: 0 as 0 | 1,
sort_order: 0,
last_reconciled: '2017-10-15' as string | null,
tombstone: 0 as 0 | 1,
account_type: 'mortgage' as const,
interest_rate: 5.0,
// Required sync fields (false means not synced)
account_id: null,
bank: null,
bankName: null,
bankId: null,
official_name: null,
mask: null,
balance_current: null,
balance_available: null,
balance_limit: null,
account_sync_source: null,
last_sync: null,
};
return { ...baseAccount, ...overrides } as MortgageLoanAccount;
}
describe('calculateInterest', () => {
it('should calculate daily interest correctly', () => {
const balance = 100000; // $100,000
const interestRate = 5; // 5% annual
const daysElapsed = 1;
const interest = calculateInterest(balance, interestRate, daysElapsed);
// Daily rate = 5% / 365 = 0.0137%
// Interest = 100000 * 0.000137 * 1 = 13.70
expect(interest).toBeCloseTo(1369.86, 2);
});
it('should calculate interest for multiple days', () => {
const balance = 100000;
const interestRate = 5;
const daysElapsed = 30;
const interest = calculateInterest(balance, interestRate, daysElapsed);
// Should be approximately 30 times the daily interest
expect(interest).toBeCloseTo(41095.89, 2);
});
it('should handle zero interest rate', () => {
const balance = 100000;
const interestRate = 0;
const daysElapsed = 1;
const interest = calculateInterest(balance, interestRate, daysElapsed);
expect(interest).toBe(0);
});
});
describe('getDaysSinceLastInterest', () => {
it('should return 1 for null date (first time interest calculation)', () => {
const days = getDaysSinceLastInterest(null);
expect(days).toBe(1);
});
it('should calculate days correctly', () => {
const yesterdayStr = '2017-10-14'; // One day before mocked current date
const days = getDaysSinceLastInterest(yesterdayStr);
expect(days).toBe(1);
});
});
describe('createInterestTransaction', () => {
it('should create an interest transaction', async () => {
// Create a test account
const accountId = await db.insertAccount({
name: 'Test Mortgage',
offbudget: 1,
closed: 0,
account_type: 'mortgage',
interest_rate: 5.0,
});
const transactionId = await createInterestTransaction(
accountId,
100.5,
'Mortgage Interest',
);
expect(transactionId).toBeDefined();
// Verify the transaction was created
const transaction = await db.first(
'SELECT * FROM transactions WHERE id = ?',
[transactionId],
);
expect(transaction).toBeDefined();
expect(
(transaction as { acct: string; amount: number; description: string })
.acct,
).toBe(accountId);
expect(
(transaction as { acct: string; amount: number; description: string })
.amount,
).toBe(10050); // Amount in cents
expect(
(transaction as { acct: string; amount: number; description: string })
.description,
).toBe('Mortgage Interest');
});
});
describe('processInterestForAccount', () => {
it('should process interest for a mortgage account', async () => {
// Create a test account
const accountId = await db.insertAccount({
name: 'Test Mortgage',
offbudget: 1,
closed: 0,
account_type: 'mortgage',
interest_rate: 5.0,
});
// Add a starting balance
await db.insertTransaction({
id: 'test-transaction-1',
account: accountId,
amount: -10000000, // -$100,000 in cents
payee: 'Initial balance', // Use 'payee' field which maps to 'description' in database
date: '2024-01-01',
cleared: 1,
is_parent: 0,
is_child: 0,
tombstone: 0,
});
const account = createTestAccount({
id: accountId,
name: 'Test Mortgage',
account_type: 'mortgage' as const,
interest_rate: 5.0,
});
await processInterestForAccount(account);
// Check that an interest transaction was created
const interestTransactions = await db.all(
'SELECT * FROM transactions WHERE acct = ? AND description LIKE ?',
[accountId, '%Interest%'],
);
expect(interestTransactions).toHaveLength(1);
expect(
(interestTransactions[0] as { amount: number }).amount,
).toBeGreaterThan(0);
});
it('should not process interest for accounts without interest rate', async () => {
const accountId = await db.insertAccount({
name: 'Test Account',
offbudget: 1,
closed: 0,
account_type: 'checking',
interest_rate: null,
});
const account = createTestAccount({
id: accountId,
name: 'Test Account',
account_type: 'loan' as const, // Use loan instead of checking for MortgageLoanAccount
interest_rate: null,
});
await processInterestForAccount(account);
// Check that no interest transaction was created
const interestTransactions = await db.all(
'SELECT * FROM transactions WHERE acct = ? AND description LIKE ?',
[accountId, '%Interest%'],
);
expect(interestTransactions).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,172 @@
import { logger } from '../../platform/server/log';
import { currentDay } from '../../shared/months';
import { amountToInteger } from '../../shared/util';
import { type AccountEntity, type TransactionEntity } from '../../types/models';
import * as db from '../db';
export type MortgageLoanAccount = AccountEntity & {
account_type: 'mortgage' | 'loan';
interest_rate: number;
};
/**
* Calculate interest for a mortgage/loan account based on the current balance and interest rate
*/
export function calculateInterest(
balance: number,
interestRate: number,
daysElapsed: number = 1,
): number {
// Convert annual interest rate to daily rate
const dailyRate = interestRate / 365;
// Calculate interest: balance * daily_rate * days
const interest = balance * dailyRate * daysElapsed;
// Round to 2 decimal places
return Math.round(interest * 100) / 100;
}
/**
* Get the last interest transaction date for an account
*/
export async function getLastInterestDate(
accountId: string,
): Promise<string | null> {
const result = await db.first<{ date: string }>(
`SELECT date FROM transactions
WHERE acct = ? AND description LIKE ?
ORDER BY date DESC LIMIT 1`,
[accountId, '%Interest%'],
);
return result?.date || null;
}
/**
* Calculate days since last interest calculation
*/
export function getDaysSinceLastInterest(
lastInterestDate: string | null,
): number {
if (!lastInterestDate) {
// If no previous interest transaction, calculate for 1 day
return 1;
}
const lastDate = new Date(lastInterestDate);
const currentDate = new Date(currentDay());
const diffTime = currentDate.getTime() - lastDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(1, diffDays); // At least 1 day
}
/**
* Create an interest transaction for a mortgage/loan account
*/
export async function createInterestTransaction(
accountId: string,
interestAmount: number,
description: string = 'Interest',
): Promise<string> {
const transaction: Omit<TransactionEntity, 'id'> = {
account: accountId,
amount: amountToInteger(interestAmount),
payee: description, // Use 'payee' field which maps to 'description' in database
date: currentDay(),
cleared: true,
is_parent: false,
is_child: false,
tombstone: false,
};
const transactionId = await db.insertTransaction(transaction);
logger.info(
`Created interest transaction for account ${accountId}: ${interestAmount}`,
);
return transactionId;
}
/**
* Process interest for a mortgage/loan account
*/
export async function processInterestForAccount(
account: MortgageLoanAccount,
): Promise<void> {
if (!account.interest_rate || account.interest_rate <= 0) {
return;
}
// Get current balance
const balanceResult = await db.first<{ balance: number }>(
'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0',
[account.id],
);
const currentBalance = balanceResult?.balance || 0;
if (currentBalance === 0) {
return; // No balance to calculate interest on
}
// For mortgage/loan accounts, use absolute value of balance (debt amount)
const balanceForInterest = Math.abs(currentBalance);
// Get last interest date
const lastInterestDate = await getLastInterestDate(account.id);
const daysSinceLastInterest = getDaysSinceLastInterest(lastInterestDate);
if (daysSinceLastInterest === 0) {
return; // Interest already calculated for today
}
// Calculate interest
const interestAmount = calculateInterest(
balanceForInterest, // Use absolute value for interest calculation
account.interest_rate,
daysSinceLastInterest,
);
if (interestAmount > 0) {
// For loans/mortgages, interest increases the debt (positive amount)
const description =
account.account_type === 'mortgage'
? 'Mortgage Interest'
: 'Loan Interest';
await createInterestTransaction(account.id, interestAmount, description);
logger.info(
`Processed interest for ${account.account_type} account ${account.id}: ` +
`$${interestAmount} over ${daysSinceLastInterest} days`,
);
}
}
/**
* Process interest for all mortgage/loan accounts
*/
export async function processInterestForAllAccounts(): Promise<void> {
const accounts = await db.all<MortgageLoanAccount>(
`SELECT * FROM accounts
WHERE account_type IN ('mortgage', 'loan')
AND interest_rate IS NOT NULL
AND interest_rate > 0
AND closed = 0
AND tombstone = 0`,
);
for (const account of accounts) {
try {
await processInterestForAccount(account);
} catch (error) {
logger.error(
`Failed to process interest for account ${account.id}:`,
error,
);
}
}
}

View File

@@ -22,6 +22,15 @@ export type DbAccount = {
subtype?: string | null;
bank?: string | null;
account_sync_source?: 'simpleFin' | 'goCardless' | null;
account_type?:
| 'checking'
| 'savings'
| 'credit'
| 'investment'
| 'mortgage'
| 'loan'
| null;
interest_rate?: number | null;
};
export type DbBank = {

View File

@@ -18,6 +18,7 @@ import {
recurConfigToRSchedule,
} from '../../shared/schedules';
import { ScheduleEntity } from '../../types/models';
import { processInterestForAllAccounts } from '../accounts/mortgage-loan';
import { addTransactions } from '../accounts/sync';
import { createApp } from '../app';
import { aqlQuery } from '../aql';
@@ -443,6 +444,16 @@ async function postTransactionForSchedule({
// TODO: make this sequential
async function advanceSchedulesService(syncSuccess) {
// Process interest for mortgage/loan accounts first
try {
await processInterestForAllAccounts();
} catch (error) {
logger.error(
'Failed to process interest for mortgage/loan accounts:',
error,
);
}
// Move all paid schedules
const { data: schedules } = await aqlQuery(
q('schedules')

View File

@@ -6,6 +6,14 @@ export type AccountEntity = {
sort_order: number;
last_reconciled: string | null;
tombstone: 0 | 1;
account_type?:
| 'checking'
| 'savings'
| 'credit'
| 'investment'
| 'mortgage'
| 'loan';
interest_rate?: number | null;
} & (_SyncFields<true> | _SyncFields<false>);
export type _SyncFields<T> = {