Fix initial balance journal entries and add investment account creation
- Fix account creation initial balance bug: the INSERT RETURNING result was not being unwrapped correctly from TypeORM's array response, causing account.id to be undefined and journal entry lines to have $0 amounts. Now uses findOne() after insert for reliable ID retrieval. - Add missing balancing equity entry line (debit/credit to account 3000/3100) so opening balance journal entries are proper double-entry - Fix account update to use findOne() instead of RETURNING * for consistent response format - Add investment account creation to Accounts page: account type dropdown now shows a separator followed by investment types (CD, Money Market, Treasury, Savings, Brokerage). Selecting an investment type expands the modal to show investment-specific fields (institution, principal, interest rate, purchase date, maturity date, account last 4, notes) and posts to /investment-accounts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,10 +42,10 @@ export class AccountsService {
|
|||||||
throw new BadRequestException(`Account number ${dto.accountNumber} already exists`);
|
throw new BadRequestException(`Account number ${dto.accountNumber} already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.tenant.query(
|
const insertResult = await this.tenant.query(
|
||||||
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable)
|
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING *`,
|
RETURNING id`,
|
||||||
[
|
[
|
||||||
dto.accountNumber,
|
dto.accountNumber,
|
||||||
dto.name,
|
dto.name,
|
||||||
@@ -56,7 +56,8 @@ export class AccountsService {
|
|||||||
dto.is1099Reportable || false,
|
dto.is1099Reportable || false,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const account = rows[0];
|
const accountId = Array.isArray(insertResult[0]) ? insertResult[0][0].id : insertResult[0].id;
|
||||||
|
const account = await this.findOne(accountId);
|
||||||
|
|
||||||
// Create opening balance journal entry if initialBalance is provided and non-zero
|
// Create opening balance journal entry if initialBalance is provided and non-zero
|
||||||
if (dto.initialBalance && dto.initialBalance !== 0) {
|
if (dto.initialBalance && dto.initialBalance !== 0) {
|
||||||
@@ -64,7 +65,7 @@ export class AccountsService {
|
|||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = now.getMonth() + 1;
|
const month = now.getMonth() + 1;
|
||||||
|
|
||||||
// Find or use the current fiscal period
|
// Find the current fiscal period
|
||||||
const periods = await this.tenant.query(
|
const periods = await this.tenant.query(
|
||||||
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||||
[year, month],
|
[year, month],
|
||||||
@@ -75,11 +76,18 @@ export class AccountsService {
|
|||||||
|
|
||||||
// Determine debit/credit based on account type
|
// Determine debit/credit based on account type
|
||||||
const isDebitNormal = ['asset', 'expense'].includes(dto.accountType);
|
const isDebitNormal = ['asset', 'expense'].includes(dto.accountType);
|
||||||
const debit = isDebitNormal ? absAmount : 0;
|
const acctDebit = isDebitNormal ? absAmount : 0;
|
||||||
const credit = isDebitNormal ? 0 : absAmount;
|
const acctCredit = isDebitNormal ? 0 : absAmount;
|
||||||
|
|
||||||
|
// Determine equity offset account based on fund type
|
||||||
|
const equityAccountNumber = dto.fundType === 'reserve' ? 3100 : 3000;
|
||||||
|
const equityRows = await this.tenant.query(
|
||||||
|
'SELECT id FROM accounts WHERE account_number = $1',
|
||||||
|
[equityAccountNumber],
|
||||||
|
);
|
||||||
|
|
||||||
// Create the journal entry
|
// Create the journal entry
|
||||||
const jeRows = await this.tenant.query(
|
const jeInsert = await this.tenant.query(
|
||||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
|
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
@@ -89,12 +97,21 @@ export class AccountsService {
|
|||||||
'00000000-0000-0000-0000-000000000000',
|
'00000000-0000-0000-0000-000000000000',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const jeId = Array.isArray(jeInsert[0]) ? jeInsert[0][0].id : jeInsert[0].id;
|
||||||
|
|
||||||
if (jeRows.length) {
|
// Line 1: debit/credit the target account
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[jeId, accountId, acctDebit, acctCredit, 'Opening balance'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Line 2: balancing entry to equity offset account
|
||||||
|
if (equityRows.length) {
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
[jeRows[0].id, account.id, debit, credit, 'Opening balance'],
|
[jeId, equityRows[0].id, acctCredit, acctDebit, `Opening balance for ${dto.name}`],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,11 +154,11 @@ export class AccountsService {
|
|||||||
sets.push(`updated_at = NOW()`);
|
sets.push(`updated_at = NOW()`);
|
||||||
params.push(id);
|
params.push(id);
|
||||||
|
|
||||||
const rows = await this.tenant.query(
|
await this.tenant.query(
|
||||||
`UPDATE accounts SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
`UPDATE accounts SET ${sets.join(', ')} WHERE id = $${idx}`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
return rows[0];
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPrimary(id: string) {
|
async setPrimary(id: string) {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Alert,
|
Alert,
|
||||||
|
Divider,
|
||||||
|
Textarea,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { DateInput } from '@mantine/dates';
|
import { DateInput } from '@mantine/dates';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
@@ -39,6 +41,24 @@ import {
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
||||||
|
|
||||||
|
const isInvestmentType = (t: string) => INVESTMENT_TYPES.includes(t);
|
||||||
|
|
||||||
|
const ACCOUNT_TYPE_OPTIONS = [
|
||||||
|
{ value: 'asset', label: 'Asset' },
|
||||||
|
{ value: 'liability', label: 'Liability' },
|
||||||
|
{ value: 'equity', label: 'Equity' },
|
||||||
|
{ value: 'income', label: 'Income' },
|
||||||
|
{ value: 'expense', label: 'Expense' },
|
||||||
|
{ value: '__divider', label: '── Investment Accounts ──', disabled: true },
|
||||||
|
{ value: 'inv_cd', label: 'Investment — CD' },
|
||||||
|
{ value: 'inv_money_market', label: 'Investment — Money Market' },
|
||||||
|
{ value: 'inv_treasury', label: 'Investment — Treasury' },
|
||||||
|
{ value: 'inv_savings', label: 'Investment — Savings' },
|
||||||
|
{ value: 'inv_brokerage', label: 'Investment — Brokerage' },
|
||||||
|
];
|
||||||
|
|
||||||
interface Account {
|
interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
account_number: number;
|
account_number: number;
|
||||||
@@ -142,10 +162,19 @@ export function AccountsPage() {
|
|||||||
fundType: 'operating',
|
fundType: 'operating',
|
||||||
is1099Reportable: false,
|
is1099Reportable: false,
|
||||||
initialBalance: 0,
|
initialBalance: 0,
|
||||||
|
// Investment fields
|
||||||
|
institution: '',
|
||||||
|
accountNumberLast4: '',
|
||||||
|
principal: 0,
|
||||||
|
interestRate: 0,
|
||||||
|
maturityDate: null as Date | null,
|
||||||
|
purchaseDate: null as Date | null,
|
||||||
|
investmentNotes: '',
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
accountNumber: (v) => (v > 0 ? null : 'Required'),
|
accountNumber: (v, values) => isInvestmentType(values.accountType) ? null : (v > 0 ? null : 'Required'),
|
||||||
name: (v) => (v.length > 0 ? null : 'Required'),
|
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
|
principal: (v, values) => isInvestmentType(values.accountType) ? (v > 0 ? null : 'Required') : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,11 +197,33 @@ export function AccountsPage() {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
return api.put(`/accounts/${editing.id}`, values);
|
return api.put(`/accounts/${editing.id}`, values);
|
||||||
}
|
}
|
||||||
|
// Investment account creation
|
||||||
|
if (isInvestmentType(values.accountType)) {
|
||||||
|
const investmentTypeMap: Record<string, string> = {
|
||||||
|
inv_cd: 'cd', inv_money_market: 'money_market', inv_treasury: 'treasury',
|
||||||
|
inv_savings: 'savings', inv_brokerage: 'other',
|
||||||
|
};
|
||||||
|
return api.post('/investment-accounts', {
|
||||||
|
name: values.name,
|
||||||
|
institution: values.institution || null,
|
||||||
|
account_number_last4: values.accountNumberLast4 || null,
|
||||||
|
investment_type: investmentTypeMap[values.accountType] || 'other',
|
||||||
|
fund_type: values.fundType,
|
||||||
|
principal: values.principal,
|
||||||
|
interest_rate: values.interestRate || 0,
|
||||||
|
maturity_date: values.maturityDate ? values.maturityDate.toISOString().split('T')[0] : null,
|
||||||
|
purchase_date: values.purchaseDate ? values.purchaseDate.toISOString().split('T')[0] : null,
|
||||||
|
current_value: values.principal,
|
||||||
|
notes: values.investmentNotes || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
return api.post('/accounts', values);
|
return api.post('/accounts', values);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
notifications.show({ message: editing ? 'Account updated' : 'Account created', color: 'green' });
|
queryClient.invalidateQueries({ queryKey: ['investments'] });
|
||||||
|
const isInv = isInvestmentType(form.values.accountType);
|
||||||
|
notifications.show({ message: editing ? 'Account updated' : (isInv ? 'Investment created' : 'Account created'), color: 'green' });
|
||||||
close();
|
close();
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -421,47 +472,135 @@ export function AccountsPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Create / Edit Account Modal */}
|
{/* Create / Edit Account Modal */}
|
||||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Account' : 'New Account'} size="md">
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={editing ? 'Edit Account' : isInvestmentType(form.values.accountType) ? 'New Investment Account' : 'New Account'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
|
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<NumberInput label="Account Number" required {...form.getInputProps('accountNumber')} />
|
{!editing && (
|
||||||
<TextInput label="Account Name" required {...form.getInputProps('name')} />
|
|
||||||
<TextInput label="Description" {...form.getInputProps('description')} />
|
|
||||||
<Group grow>
|
|
||||||
<Select
|
<Select
|
||||||
label="Account Type"
|
label="Account Type"
|
||||||
required
|
required
|
||||||
data={[
|
data={ACCOUNT_TYPE_OPTIONS}
|
||||||
{ value: 'asset', label: 'Asset' },
|
|
||||||
{ value: 'liability', label: 'Liability' },
|
|
||||||
{ value: 'equity', label: 'Equity' },
|
|
||||||
{ value: 'income', label: 'Income' },
|
|
||||||
{ value: 'expense', label: 'Expense' },
|
|
||||||
]}
|
|
||||||
{...form.getInputProps('accountType')}
|
{...form.getInputProps('accountType')}
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
label="Fund Type"
|
|
||||||
required
|
|
||||||
data={[
|
|
||||||
{ value: 'operating', label: 'Operating' },
|
|
||||||
{ value: 'reserve', label: 'Reserve' },
|
|
||||||
]}
|
|
||||||
{...form.getInputProps('fundType')}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
|
|
||||||
{!editing && (
|
|
||||||
<NumberInput
|
|
||||||
label="Initial Balance"
|
|
||||||
description="Opening balance (creates a journal entry)"
|
|
||||||
prefix="$"
|
|
||||||
decimalScale={2}
|
|
||||||
{...form.getInputProps('initialBalance')}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={isInvestmentType(form.values.accountType) ? 'Investment Name' : 'Account Name'}
|
||||||
|
placeholder={isInvestmentType(form.values.accountType) ? 'e.g. Reserve CD - 12 Month' : 'e.g. Operating Cash'}
|
||||||
|
required
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isInvestmentType(form.values.accountType) && (
|
||||||
|
<>
|
||||||
|
<NumberInput label="Account Number" required {...form.getInputProps('accountNumber')} />
|
||||||
|
<TextInput label="Description" {...form.getInputProps('description')} />
|
||||||
|
{editing && (
|
||||||
|
<Select
|
||||||
|
label="Account Type"
|
||||||
|
required
|
||||||
|
data={[
|
||||||
|
{ value: 'asset', label: 'Asset' },
|
||||||
|
{ value: 'liability', label: 'Liability' },
|
||||||
|
{ value: 'equity', label: 'Equity' },
|
||||||
|
{ value: 'income', label: 'Income' },
|
||||||
|
{ value: 'expense', label: 'Expense' },
|
||||||
|
]}
|
||||||
|
{...form.getInputProps('accountType')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Fund Type"
|
||||||
|
required
|
||||||
|
data={[
|
||||||
|
{ value: 'operating', label: 'Operating' },
|
||||||
|
{ value: 'reserve', label: 'Reserve' },
|
||||||
|
]}
|
||||||
|
{...form.getInputProps('fundType')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Investment-specific fields */}
|
||||||
|
{!editing && isInvestmentType(form.values.accountType) && (
|
||||||
|
<>
|
||||||
|
<Divider label="Investment Details" labelPosition="center" />
|
||||||
|
<TextInput
|
||||||
|
label="Institution"
|
||||||
|
placeholder="e.g. First National Bank"
|
||||||
|
{...form.getInputProps('institution')}
|
||||||
|
/>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label="Principal Amount"
|
||||||
|
required
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
thousandSeparator=","
|
||||||
|
min={0}
|
||||||
|
{...form.getInputProps('principal')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Interest Rate (%)"
|
||||||
|
decimalScale={4}
|
||||||
|
suffix="%"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
{...form.getInputProps('interestRate')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<DateInput
|
||||||
|
label="Purchase Date"
|
||||||
|
clearable
|
||||||
|
{...form.getInputProps('purchaseDate')}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Maturity Date"
|
||||||
|
clearable
|
||||||
|
{...form.getInputProps('maturityDate')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<TextInput
|
||||||
|
label="Account # (last 4)"
|
||||||
|
placeholder="1234"
|
||||||
|
maxLength={4}
|
||||||
|
{...form.getInputProps('accountNumberLast4')}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Notes"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
maxRows={4}
|
||||||
|
{...form.getInputProps('investmentNotes')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Regular account fields */}
|
||||||
|
{!isInvestmentType(form.values.accountType) && (
|
||||||
|
<>
|
||||||
|
<Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
|
||||||
|
{!editing && (
|
||||||
|
<NumberInput
|
||||||
|
label="Initial Balance"
|
||||||
|
description="Opening balance (creates a journal entry)"
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
{...form.getInputProps('initialBalance')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button type="submit" loading={createMutation.isPending}>
|
<Button type="submit" loading={createMutation.isPending}>
|
||||||
{editing ? 'Update' : 'Create'}
|
{editing ? 'Update' : isInvestmentType(form.values.accountType) ? 'Create Investment' : 'Create Account'}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user