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:
2026-02-19 15:13:22 -05:00
parent 301f8a7bde
commit b0634f7263
2 changed files with 202 additions and 46 deletions

View File

@@ -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( 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, accountId, acctDebit, acctCredit, 'Opening balance'],
);
// Line 2: balancing entry to equity offset account
if (equityRows.length) {
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
VALUES ($1, $2, $3, $4, $5)`,
[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) {

View File

@@ -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,13 +472,35 @@ 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>
{!editing && (
<Select
label="Account Type"
required
data={ACCOUNT_TYPE_OPTIONS}
{...form.getInputProps('accountType')}
/>
)}
<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')} /> <NumberInput label="Account Number" required {...form.getInputProps('accountNumber')} />
<TextInput label="Account Name" required {...form.getInputProps('name')} />
<TextInput label="Description" {...form.getInputProps('description')} /> <TextInput label="Description" {...form.getInputProps('description')} />
<Group grow> {editing && (
<Select <Select
label="Account Type" label="Account Type"
required required
@@ -440,6 +513,10 @@ export function AccountsPage() {
]} ]}
{...form.getInputProps('accountType')} {...form.getInputProps('accountType')}
/> />
)}
</>
)}
<Select <Select
label="Fund Type" label="Fund Type"
required required
@@ -449,7 +526,66 @@ export function AccountsPage() {
]} ]}
{...form.getInputProps('fundType')} {...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>
<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' })} /> <Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
{!editing && ( {!editing && (
<NumberInput <NumberInput
@@ -460,8 +596,11 @@ export function AccountsPage() {
{...form.getInputProps('initialBalance')} {...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>