mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
feat: Add mortgage and loan account types with interest calculation
Co-authored-by: matiss <matiss@mja.lv>
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
221
packages/loot-core/src/server/accounts/mortgage-loan.test.ts
Normal file
221
packages/loot-core/src/server/accounts/mortgage-loan.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
172
packages/loot-core/src/server/accounts/mortgage-loan.ts
Normal file
172
packages/loot-core/src/server/accounts/mortgage-loan.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user