Quality-of-life enhancements: CSV import/export, opening balances, interest rates, mobile UX

- CSV import/export for Units, Projects, and Vendors with match-on-name/number upsert
- Cash Flow report toggle for Cash Only vs Cash + Investments
- Per-account and bulk opening balance setting with as-of date
- Interest rate field on normal accounts with estimated monthly/annual interest display
- Mobile sidebar auto-close on navigation
- Shared CSV parsing/export utility extracted to frontend/src/utils/csv.ts

DB migration needed for existing tenants:
  ALTER TABLE accounts ADD COLUMN IF NOT EXISTS interest_rate DECIMAL(6,4);

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 09:13:51 -05:00
parent 32af961173
commit 45a267d787
21 changed files with 1015 additions and 128 deletions

View File

@@ -55,8 +55,8 @@ export class AccountsService {
}
const insertResult = await this.tenant.query(
`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)
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable, interest_rate)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
dto.accountNumber,
@@ -66,6 +66,7 @@ export class AccountsService {
dto.fundType,
dto.parentAccountId || null,
dto.is1099Reportable || false,
dto.interestRate || null,
],
);
const accountId = Array.isArray(insertResult[0]) ? insertResult[0][0].id : insertResult[0].id;
@@ -172,6 +173,7 @@ export class AccountsService {
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
if (dto.isPrimary !== undefined) { sets.push(`is_primary = $${idx++}`); params.push(dto.isPrimary); }
if (dto.interestRate !== undefined) { sets.push(`interest_rate = $${idx++}`); params.push(dto.interestRate); }
if (!sets.length) return account;
@@ -204,7 +206,30 @@ export class AccountsService {
return this.findOne(id);
}
async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) {
async setOpeningBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) {
return this.adjustBalance(id, dto, 'opening_balance');
}
async bulkSetOpeningBalances(dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) {
let processed = 0, skipped = 0;
const errors: string[] = [];
for (const entry of dto.entries) {
try {
const result = await this.setOpeningBalance(entry.accountId, {
targetBalance: entry.targetBalance,
asOfDate: dto.asOfDate,
});
if (result.message === 'No adjustment needed') skipped++;
else processed++;
} catch (err: any) {
errors.push(`${entry.accountId}: ${err.message}`);
}
}
return { processed, skipped, errors };
}
async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }, entryType = 'adjustment') {
const account = await this.findOne(id);
// Get current balance for this account using trial balance logic
@@ -282,16 +307,20 @@ export class AccountsService {
const equityDebit = targetCredit > 0 ? targetCredit : 0;
const equityCredit = targetDebit > 0 ? targetDebit : 0;
const memo = dto.memo || `Balance adjustment to ${dto.targetBalance}`;
const defaultMemo = entryType === 'opening_balance'
? `Opening balance for ${account.name}`
: `Balance adjustment to ${dto.targetBalance}`;
const memo = dto.memo || defaultMemo;
// Create journal entry
const jeRows = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'adjustment', $3, true, NOW(), $4)
VALUES ($1, $2, $3, $4, true, NOW(), $5)
RETURNING *`,
[
dto.asOfDate,
memo,
entryType,
fiscalPeriodId,
'00000000-0000-0000-0000-000000000000',
],