Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,26 @@ chittyfinance/
- `leases` - Tenant leases with rent and dates
- `property_valuations` - Cached AVM estimates from external providers (Zillow, Redfin, HouseCanary, ATTOM, County)

**Chart of Accounts & Classification Tables**:
- `chart_of_accounts` - Database-backed COA with tenant customization (NULL tenant_id = global defaults, 60 REI accounts seeded)
- `classification_audit` - Audit trail for every COA code change on a transaction (trust level L0-L4, actor attribution)

**Classification columns on `transactions`**:
- `coa_code` - Authoritative COA classification (L2+ executor writes)
- `suggested_coa_code` - AI/keyword proposal (L1 writes, L3 auditor reviews)
- `classification_confidence` - 0.000-1.000 score
- `classified_by` / `classified_at` - Who set coa_code and when
- `reconciled_by` / `reconciled_at` - L3 auditor who locked the transaction

**Trust Path for Classification** (executor/auditor segregation):
| Level | Role | COA Permissions |
|-------|------|----------------|
| L0 Ingest | Executor | Write to 9010 (suspense) only |
| L1 Suggest | Executor | Write `suggested_coa_code`, not `coa_code` |
| L2 Classify | Executor | Set `coa_code` on unreconciled transactions |
| L3 Reconcile | Auditor | Lock transactions, review L2 classifications |
| L4 Govern | Auditor | Modify the COA itself (add/rename/retire accounts) |

**Supporting Tables**:
- `integrations` - Mercury/Wave/Stripe API connections
- `tasks` - Financial tasks
Expand Down
77 changes: 77 additions & 0 deletions database/seeds/chart-of-accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Seed script for Chart of Accounts global defaults
// Seeds REI_CHART_OF_ACCOUNTS as global entries (tenant_id = NULL)
// Run after schema push: npm run db:push:system && npx tsx database/seeds/chart-of-accounts.ts

import { db } from '../../server/db';
import * as schema from '../system.schema';
import { REI_CHART_OF_ACCOUNTS, TURBOTENANT_CATEGORY_MAP } from '../chart-of-accounts';

export async function seedChartOfAccounts() {
console.log('Seeding global Chart of Accounts...');

const values = REI_CHART_OF_ACCOUNTS.map((acct) => ({
tenantId: null as unknown as undefined, // global account
code: acct.code,
name: acct.name,
type: acct.type,
subtype: acct.subtype ?? null,
description: acct.description,
scheduleELine: acct.scheduleE ?? null,
taxDeductible: acct.taxDeductible ?? false,
parentCode: deriveParentCode(acct.code),
isActive: true,
modifiedBy: 'seed:chart-of-accounts',
metadata: {
keywords: getKeywordsForCode(acct.code),
},
}));

// Upsert: insert or skip on conflict (global code uniqueness)
let inserted = 0;
for (const val of values) {
try {
await db.insert(schema.chartOfAccounts).values(val);
inserted++;
} catch (e: any) {
if (e.message?.includes('unique') || e.code === '23505') {
// Already exists — skip
continue;
}
throw e;
}
}

console.log(` ${inserted} accounts seeded (${values.length - inserted} already existed)`);
console.log('Done.');
}

// Derive parent code from account code (e.g. '5110' -> '5100', '5020' -> '5000')
function deriveParentCode(code: string): string | null {
if (code.length !== 4) return null;
const num = parseInt(code, 10);
// Top-level codes (x000) have no parent
if (num % 1000 === 0) return null;
// Sub-codes within a 100-range share a parent at the x00 level
const parentNum = Math.floor(num / 100) * 100;
const parentCode = parentNum.toString().padStart(4, '0');
// Only set parent if the parent account exists in our COA
const parentExists = REI_CHART_OF_ACCOUNTS.some((a) => a.code === parentCode);
return parentExists ? parentCode : null;
}

// Reverse-map: for a given COA code, find all keywords from TURBOTENANT_CATEGORY_MAP
function getKeywordsForCode(code: string): string[] {
return Object.entries(TURBOTENANT_CATEGORY_MAP)
.filter(([_, c]) => c === code)
.map(([keyword]) => keyword);
}

// Run directly if executed as script
if (import.meta.url.endsWith(process.argv[1]?.replace(/^file:\/\//, '') || '')) {
seedChartOfAccounts()
.then(() => process.exit(0))
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
});
}
71 changes: 71 additions & 0 deletions database/system.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,40 @@ export const insertTenantUserSchema = createInsertSchema(tenantUsers);
export type TenantUser = typeof tenantUsers.$inferSelect;
export type InsertTenantUser = z.infer<typeof insertTenantUserSchema>;

// Chart of Accounts (database-backed, tenant-customizable)
// Global accounts have NULL tenant_id; tenant-specific accounts override or extend
export const chartOfAccounts = pgTable('chart_of_accounts', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id), // NULL = global default
code: text('code').notNull(), // e.g. '5070'
name: text('name').notNull(), // e.g. 'Repairs'
type: text('type').notNull(), // 'asset', 'liability', 'equity', 'income', 'expense'
subtype: text('subtype'), // 'cash', 'receivable', 'fixed', 'contra', 'current', 'long-term', 'capital', 'suspense', 'non-deductible'
description: text('description'),
scheduleELine: text('schedule_e_line'), // IRS Schedule E line reference
taxDeductible: boolean('tax_deductible').notNull().default(false),
parentCode: text('parent_code'), // for hierarchical grouping (e.g. '5100' parent of '5110')
isActive: boolean('is_active').notNull().default(true),
effectiveDate: timestamp('effective_date'), // when this account definition became active
modifiedBy: text('modified_by'), // L4 auditor who last changed this (user ID or agent session ID)
metadata: jsonb('metadata'), // additional config (keywords, aliases, etc.)
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
}, (table) => ({
tenantIdx: index('coa_tenant_idx').on(table.tenantId),
codeIdx: index('coa_code_idx').on(table.code),
typeIdx: index('coa_type_idx').on(table.type),
// Unique code per tenant (tenant-specific accounts)
tenantCodeIdx: uniqueIndex('coa_tenant_code_idx').on(table.tenantId, table.code),
// Unique code for global accounts (WHERE tenant_id IS NULL)
// Drizzle doesn't support partial indexes directly, so we use the composite above
// and enforce global uniqueness via application logic + seed script
}));

export const insertChartOfAccountsSchema = createInsertSchema(chartOfAccounts);
export type ChartOfAccount = typeof chartOfAccounts.$inferSelect;
export type InsertChartOfAccount = z.infer<typeof insertChartOfAccountsSchema>;

// Financial accounts (bank accounts, credit cards, etc.)
export const accounts = pgTable('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
Expand Down Expand Up @@ -107,7 +141,15 @@ export const transactions = pgTable('transactions', {
propertyId: uuid('property_id').references(() => properties.id), // Links to properties table
unitId: uuid('unit_id').references(() => units.id), // Links to units table
externalId: text('external_id'), // For bank/Wave API sync
// COA classification (trust-path governed)
coaCode: text('coa_code'), // authoritative classification (L2+ can write)
suggestedCoaCode: text('suggested_coa_code'), // AI/keyword proposal (L1 writes, L3 reviews)
classificationConfidence: decimal('classification_confidence', { precision: 4, scale: 3 }), // 0.000-1.000
classifiedBy: text('classified_by'), // who/what set coa_code: user UUID, agent session ID, or 'auto'
classifiedAt: timestamp('classified_at'), // when coa_code was set
reconciled: boolean('reconciled').notNull().default(false),
reconciledBy: text('reconciled_by'), // L3 auditor who locked this transaction
reconciledAt: timestamp('reconciled_at'), // when reconciliation happened
metadata: jsonb('metadata'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
Expand All @@ -116,6 +158,8 @@ export const transactions = pgTable('transactions', {
accountIdx: index('transactions_account_idx').on(table.accountId),
dateIdx: index('transactions_date_idx').on(table.date),
propertyIdx: index('transactions_property_idx').on(table.propertyId),
coaIdx: index('transactions_coa_idx').on(table.tenantId, table.coaCode),
unclassifiedIdx: index('transactions_unclassified_idx').on(table.tenantId, table.coaCode),
}));

export const insertTransactionSchema = createInsertSchema(transactions);
Expand Down Expand Up @@ -409,3 +453,30 @@ export const workflows = pgTable('workflows', {
export const insertWorkflowSchema = createInsertSchema(workflows);
export type Workflow = typeof workflows.$inferSelect;
export type InsertWorkflow = z.infer<typeof insertWorkflowSchema>;

// Classification audit trail (tracks every COA code change on a transaction)
// Enforces maker/checker: L2 classifies, L3 reconciles — same session can't do both
export const classificationAudit = pgTable('classification_audit', {
id: uuid('id').primaryKey().defaultRandom(),
transactionId: uuid('transaction_id').notNull().references(() => transactions.id),
tenantId: uuid('tenant_id').notNull().references(() => tenants.id), // denormalized for tenant-scoped queries
previousCoaCode: text('previous_coa_code'), // NULL on first classification
newCoaCode: text('new_coa_code').notNull(),
action: text('action').notNull(), // 'classify', 'reclassify', 'reconcile', 'override'
trustLevel: text('trust_level').notNull(), // 'L0', 'L1', 'L2', 'L3', 'L4'
actorId: text('actor_id').notNull(), // user UUID, agent session ID, or 'auto'
actorType: text('actor_type').notNull(), // 'user', 'agent', 'system'
confidence: decimal('confidence', { precision: 4, scale: 3 }), // 0.000-1.000 at time of action
reason: text('reason'), // why the change was made (correction reason, AI explanation, etc.)
metadata: jsonb('metadata'), // session context, model used, etc.
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => ({
transactionIdx: index('classification_audit_transaction_idx').on(table.transactionId),
tenantIdx: index('classification_audit_tenant_idx').on(table.tenantId),
actorIdx: index('classification_audit_actor_idx').on(table.actorId),
trustLevelIdx: index('classification_audit_trust_level_idx').on(table.trustLevel),
}));

export const insertClassificationAuditSchema = createInsertSchema(classificationAudit);
export type ClassificationAudit = typeof classificationAudit.$inferSelect;
export type InsertClassificationAudit = z.infer<typeof insertClassificationAuditSchema>;
2 changes: 2 additions & 0 deletions server/lib/consolidated-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface ReportingTransactionRow {
amount: string;
type: string;
category: string | null;
description?: string;
coaCode?: string | null; // pre-classified COA code (preferred over fuzzy match)
date: Date | string;
reconciled: boolean;
metadata: unknown;
Expand Down
9 changes: 6 additions & 3 deletions server/lib/tax-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,15 @@ function daysBetween(start: string, end: string): number {
/**
* Resolve a transaction's free-text category to a Schedule E line.
* Returns the line number string ('Line 3', etc.) and the COA code.
* If preClassifiedCoaCode is provided (from transaction.coa_code), uses it directly
* instead of running fuzzy match — this is the trust-path governed classification.
*/
export function resolveScheduleELine(
category: string | null,
description?: string,
preClassifiedCoaCode?: string | null,
): { lineNumber: string; lineLabel: string; coaCode: string } {
const coaCode = findAccountCode(description || '', category || undefined);
const coaCode = preClassifiedCoaCode || findAccountCode(description || '', category || undefined);
const scheduleLine = getScheduleELine(coaCode);
const lineNumber = scheduleLine || 'Line 19';
const lineLabel = SCHEDULE_E_LINES[lineNumber] || 'Other';
Expand Down Expand Up @@ -234,7 +237,7 @@ export function buildScheduleEReport(params: {
for (const tx of transactions) {
const rawAmount = amount(tx.amount);
const absAmount = Math.abs(rawAmount);
const { lineNumber, coaCode } = resolveScheduleELine(tx.category, (tx as any).description);
const { lineNumber, coaCode } = resolveScheduleELine(tx.category, tx.description, tx.coaCode);

// Track unmapped categories (hit suspense 9010)
if (coaCode === '9010' && tx.category) {
Expand Down Expand Up @@ -556,7 +559,7 @@ export function buildForm1065Report(params: {
for (const tx of entityTxs) {
const rawAmount = amount(tx.amount);
const absAmount = Math.abs(rawAmount);
const { coaCode, lineNumber } = resolveScheduleELine(tx.category, (tx as any).description);
const { coaCode, lineNumber } = resolveScheduleELine(tx.category, tx.description, tx.coaCode);
const acctDef = getAccountByCode(coaCode);
const label = acctDef?.name || tx.category || 'Uncategorized';

Expand Down
1 change: 1 addition & 0 deletions server/storage/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ export class SystemStorage {
type: schema.transactions.type,
category: schema.transactions.category,
description: schema.transactions.description,
coaCode: schema.transactions.coaCode,
date: schema.transactions.date,
payee: schema.transactions.payee,
propertyId: schema.transactions.propertyId,
Expand Down
Loading