diff --git a/CLAUDE.md b/CLAUDE.md index fd6d018..f76c4d9 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/database/seeds/chart-of-accounts.ts b/database/seeds/chart-of-accounts.ts new file mode 100644 index 0000000..593ee0a --- /dev/null +++ b/database/seeds/chart-of-accounts.ts @@ -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); + }); +} diff --git a/database/system.schema.ts b/database/system.schema.ts index 6669397..6e07ed5 100644 --- a/database/system.schema.ts +++ b/database/system.schema.ts @@ -63,6 +63,40 @@ export const insertTenantUserSchema = createInsertSchema(tenantUsers); export type TenantUser = typeof tenantUsers.$inferSelect; export type InsertTenantUser = z.infer; +// 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; + // Financial accounts (bank accounts, credit cards, etc.) export const accounts = pgTable('accounts', { id: uuid('id').primaryKey().defaultRandom(), @@ -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(), @@ -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); @@ -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; + +// 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; diff --git a/server/lib/consolidated-reporting.ts b/server/lib/consolidated-reporting.ts index 9bcdf91..048b94b 100644 --- a/server/lib/consolidated-reporting.ts +++ b/server/lib/consolidated-reporting.ts @@ -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; diff --git a/server/lib/tax-reporting.ts b/server/lib/tax-reporting.ts index 3c286ad..d9b81da 100644 --- a/server/lib/tax-reporting.ts +++ b/server/lib/tax-reporting.ts @@ -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'; @@ -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) { @@ -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'; diff --git a/server/storage/system.ts b/server/storage/system.ts index f189a96..d1c651a 100755 --- a/server/storage/system.ts +++ b/server/storage/system.ts @@ -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,