mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 10:14:53 -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;
|
name: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
offBudget: boolean;
|
offBudget: boolean;
|
||||||
|
accountType?:
|
||||||
|
| 'checking'
|
||||||
|
| 'savings'
|
||||||
|
| 'credit'
|
||||||
|
| 'investment'
|
||||||
|
| 'mortgage'
|
||||||
|
| 'loan';
|
||||||
|
interestRate?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createAccount = createAppAsyncThunk(
|
export const createAccount = createAppAsyncThunk(
|
||||||
`${sliceName}/createAccount`,
|
`${sliceName}/createAccount`,
|
||||||
async ({ name, balance, offBudget }: CreateAccountPayload) => {
|
async ({
|
||||||
|
name,
|
||||||
|
balance,
|
||||||
|
offBudget,
|
||||||
|
accountType,
|
||||||
|
interestRate,
|
||||||
|
}: CreateAccountPayload) => {
|
||||||
const id = await send('account-create', {
|
const id = await send('account-create', {
|
||||||
name,
|
name,
|
||||||
balance,
|
balance,
|
||||||
offBudget,
|
offBudget,
|
||||||
|
accountType,
|
||||||
|
interestRate,
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FormError } from '@actual-app/components/form-error';
|
|||||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||||
import { InlineField } from '@actual-app/components/inline-field';
|
import { InlineField } from '@actual-app/components/inline-field';
|
||||||
import { Input } from '@actual-app/components/input';
|
import { Input } from '@actual-app/components/input';
|
||||||
|
import { Select } from '@actual-app/components/select';
|
||||||
import { Text } from '@actual-app/components/text';
|
import { Text } from '@actual-app/components/text';
|
||||||
import { theme } from '@actual-app/components/theme';
|
import { theme } from '@actual-app/components/theme';
|
||||||
import { View } from '@actual-app/components/view';
|
import { View } from '@actual-app/components/view';
|
||||||
@@ -38,12 +39,23 @@ export function CreateLocalAccountModal() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [offbudget, setOffbudget] = useState(false);
|
const [offbudget, setOffbudget] = useState(false);
|
||||||
const [balance, setBalance] = useState('0');
|
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 [nameError, setNameError] = useState(null);
|
||||||
const [balanceError, setBalanceError] = useState(false);
|
const [balanceError, setBalanceError] = useState(false);
|
||||||
|
const [interestRateError, setInterestRateError] = useState(false);
|
||||||
|
|
||||||
const validateBalance = balance => !isNaN(parseFloat(balance));
|
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 validateAndSetName = (name: string) => {
|
||||||
const nameError = validateAccountName(name, '', accounts);
|
const nameError = validateAccountName(name, '', accounts);
|
||||||
if (nameError) {
|
if (nameError) {
|
||||||
@@ -62,13 +74,18 @@ export function CreateLocalAccountModal() {
|
|||||||
const balanceError = !validateBalance(balance);
|
const balanceError = !validateBalance(balance);
|
||||||
setBalanceError(balanceError);
|
setBalanceError(balanceError);
|
||||||
|
|
||||||
if (!nameError && !balanceError) {
|
const interestRateError = !validateInterestRate(interestRate);
|
||||||
|
setInterestRateError(interestRateError);
|
||||||
|
|
||||||
|
if (!nameError && !balanceError && !interestRateError) {
|
||||||
dispatch(closeModal());
|
dispatch(closeModal());
|
||||||
const id = await dispatch(
|
const id = await dispatch(
|
||||||
createAccount({
|
createAccount({
|
||||||
name,
|
name,
|
||||||
balance: toRelaxedNumber(balance),
|
balance: toRelaxedNumber(balance),
|
||||||
offBudget: offbudget,
|
offBudget: offbudget,
|
||||||
|
accountType,
|
||||||
|
interestRate: interestRate ? toRelaxedNumber(interestRate) : null,
|
||||||
}),
|
}),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
navigate('/accounts/' + id);
|
navigate('/accounts/' + id);
|
||||||
@@ -183,6 +200,52 @@ export function CreateLocalAccountModal() {
|
|||||||
</FormError>
|
</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>
|
<ModalButtons>
|
||||||
<Button onPress={close}>
|
<Button onPress={close}>
|
||||||
<Trans>Back</Trans>
|
<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,
|
balance = 0,
|
||||||
offBudget = false,
|
offBudget = false,
|
||||||
closed = false,
|
closed = false,
|
||||||
|
accountType = 'checking',
|
||||||
|
interestRate = null,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
balance?: number | undefined;
|
balance?: number | undefined;
|
||||||
offBudget?: boolean | undefined;
|
offBudget?: boolean | undefined;
|
||||||
closed?: boolean | undefined;
|
closed?: boolean | undefined;
|
||||||
|
accountType?:
|
||||||
|
| 'checking'
|
||||||
|
| 'savings'
|
||||||
|
| 'credit'
|
||||||
|
| 'investment'
|
||||||
|
| 'mortgage'
|
||||||
|
| 'loan'
|
||||||
|
| undefined;
|
||||||
|
interestRate?: number | null | undefined;
|
||||||
}) {
|
}) {
|
||||||
const id: AccountEntity['id'] = await db.insertAccount({
|
const id: AccountEntity['id'] = await db.insertAccount({
|
||||||
name,
|
name,
|
||||||
offbudget: offBudget ? 1 : 0,
|
offbudget: offBudget ? 1 : 0,
|
||||||
closed: closed ? 1 : 0,
|
closed: closed ? 1 : 0,
|
||||||
|
account_type: accountType,
|
||||||
|
interest_rate: interestRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insertPayee({
|
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;
|
subtype?: string | null;
|
||||||
bank?: string | null;
|
bank?: string | null;
|
||||||
account_sync_source?: 'simpleFin' | 'goCardless' | null;
|
account_sync_source?: 'simpleFin' | 'goCardless' | null;
|
||||||
|
account_type?:
|
||||||
|
| 'checking'
|
||||||
|
| 'savings'
|
||||||
|
| 'credit'
|
||||||
|
| 'investment'
|
||||||
|
| 'mortgage'
|
||||||
|
| 'loan'
|
||||||
|
| null;
|
||||||
|
interest_rate?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DbBank = {
|
export type DbBank = {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
recurConfigToRSchedule,
|
recurConfigToRSchedule,
|
||||||
} from '../../shared/schedules';
|
} from '../../shared/schedules';
|
||||||
import { ScheduleEntity } from '../../types/models';
|
import { ScheduleEntity } from '../../types/models';
|
||||||
|
import { processInterestForAllAccounts } from '../accounts/mortgage-loan';
|
||||||
import { addTransactions } from '../accounts/sync';
|
import { addTransactions } from '../accounts/sync';
|
||||||
import { createApp } from '../app';
|
import { createApp } from '../app';
|
||||||
import { aqlQuery } from '../aql';
|
import { aqlQuery } from '../aql';
|
||||||
@@ -443,6 +444,16 @@ async function postTransactionForSchedule({
|
|||||||
// TODO: make this sequential
|
// TODO: make this sequential
|
||||||
|
|
||||||
async function advanceSchedulesService(syncSuccess) {
|
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
|
// Move all paid schedules
|
||||||
const { data: schedules } = await aqlQuery(
|
const { data: schedules } = await aqlQuery(
|
||||||
q('schedules')
|
q('schedules')
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export type AccountEntity = {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
last_reconciled: string | null;
|
last_reconciled: string | null;
|
||||||
tombstone: 0 | 1;
|
tombstone: 0 | 1;
|
||||||
|
account_type?:
|
||||||
|
| 'checking'
|
||||||
|
| 'savings'
|
||||||
|
| 'credit'
|
||||||
|
| 'investment'
|
||||||
|
| 'mortgage'
|
||||||
|
| 'loan';
|
||||||
|
interest_rate?: number | null;
|
||||||
} & (_SyncFields<true> | _SyncFields<false>);
|
} & (_SyncFields<true> | _SyncFields<false>);
|
||||||
|
|
||||||
export type _SyncFields<T> = {
|
export type _SyncFields<T> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user