diff --git a/client/src/hooks/use-reports.ts b/client/src/hooks/use-reports.ts index 6cb0699..e3d28da 100644 --- a/client/src/hooks/use-reports.ts +++ b/client/src/hooks/use-reports.ts @@ -133,6 +133,29 @@ export interface ScheduleEPropertyColumn { netIncome: number; } +export interface ScheduleELineSummaryItem { + lineNumber: string; + lineLabel: string; + amount: number; + transactionCount: number; + coaBreakdown: Array<{ + coaCode: string; + coaName: string; + amount: number; + transactionCount: number; + }>; +} + +export interface ClassificationQuality { + totalTransactions: number; + l2ClassifiedCount: number; + l1SuggestedOnlyCount: number; + unclassifiedCount: number; + l1SuggestedOnlyAmount: number; + confirmedPct: number; + readyToFile: boolean; +} + export interface ScheduleEReport { taxYear: number; properties: ScheduleEPropertyColumn[]; @@ -141,6 +164,8 @@ export interface ScheduleEReport { uncategorizedAmount: number; uncategorizedCount: number; unmappedCategories: string[]; + lineSummary: ScheduleELineSummaryItem[]; + classificationQuality: ClassificationQuality; } export interface K1MemberAllocation { diff --git a/client/src/pages/Reports.tsx b/client/src/pages/Reports.tsx index 97fd8d1..5d9f79a 100644 --- a/client/src/pages/Reports.tsx +++ b/client/src/pages/Reports.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import { useTenantId } from '@/contexts/TenantContext'; import { useConsolidatedReport, useRunTaxAutomation, useScheduleEReport, useForm1065Report, useExportTaxPackage, type ReportParams, type TaxReportParams, type ScheduleEPropertyColumn, type ScheduleELineItem, + type ScheduleELineSummaryItem, type ClassificationQuality, type Form1065Report as Form1065ReportType, type K1MemberAllocation, } from '@/hooks/use-reports'; import { useToast } from '@/hooks/use-toast'; @@ -42,6 +43,129 @@ const TAB_CONFIG: Array<{ key: ReportTab; label: string; icon: typeof BarChart3 { key: 'form-1065', label: 'Form 1065 / K-1', icon: Users }, ]; +function ClassificationQualityBanner({ quality }: { quality: ClassificationQuality }) { + if (quality.totalTransactions === 0) return null; + const ready = quality.readyToFile; + const borderClass = ready ? 'border-l-emerald-400' : 'border-l-rose-400'; + const iconClass = ready ? 'text-emerald-400' : 'text-rose-400'; + const textClass = ready ? 'text-emerald-400' : 'text-rose-400'; + + return ( +
+
+
+ {ready ? ( + + ) : ( + + )} + + {ready ? 'Ready to file' : 'Not ready to file'} — {quality.confirmedPct}% of transactions are human-confirmed (L2) + +
+ + {quality.l2ClassifiedCount} confirmed · {quality.l1SuggestedOnlyCount} L1 suggested · {quality.unclassifiedCount} unclassified + +
+ {quality.l1SuggestedOnlyCount > 0 && ( +

+ {formatCurrency(quality.l1SuggestedOnlyAmount)} of suggested-only transactions haven't been reviewed. Visit Classification to confirm them before filing. +

+ )} +
+ ); +} + +function LineSummarySection({ summary }: { summary: ScheduleELineSummaryItem[] }) { + const [expandedLine, setExpandedLine] = useState(null); + if (summary.length === 0) return null; + + const totalIncome = summary + .filter((s) => s.lineNumber === 'Line 3') + .reduce((sum, s) => sum + s.amount, 0); + const totalExpenses = summary + .filter((s) => s.lineNumber !== 'Line 3') + .reduce((sum, s) => sum + s.amount, 0); + const net = totalIncome - totalExpenses; + + return ( +
+
+
+ +
+

Schedule E Line Summary

+

Aggregated across all properties — what you file on the form

+
+
+ = 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + Net: {formatCurrency(net)} + +
+ + + + + + + + + + + + {summary.map((line) => { + const isExpanded = expandedLine === line.lineNumber; + const isIncome = line.lineNumber === 'Line 3'; + const hasMultipleCoa = line.coaBreakdown.length > 1; + return ( + + + + + + + + + {isExpanded && + line.coaBreakdown.map((entry) => ( + + + + + + ))} + + ); + })} + +
LineDescriptionAmountTxnsBreakdown
{line.lineNumber}{line.lineLabel} + {formatCurrency(line.amount)} + {line.transactionCount} + {hasMultipleCoa ? ( + + ) : ( + {line.coaBreakdown[0]?.coaCode} + )} +
+ + {entry.coaCode} — {entry.coaName} + + {formatCurrency(entry.amount)} + + {entry.transactionCount} + +
+
+ ); +} + function ScheduleETab({ taxYear }: { taxYear: number }) { const params: TaxReportParams = { taxYear, includeDescendants: true }; const { data, isLoading, error } = useScheduleEReport(params); @@ -52,6 +176,9 @@ function ScheduleETab({ taxYear }: { taxYear: number }) { return (
+ {/* Classification quality banner */} + + {/* Warnings */} {data.uncategorizedCount > 0 && (
@@ -65,6 +192,9 @@ function ScheduleETab({ taxYear }: { taxYear: number }) {
)} + {/* Aggregated Line Summary — this is what goes on the IRS form */} + + {/* Per-property cards */} {data.properties.map((prop: ScheduleEPropertyColumn) => (
@@ -436,8 +566,9 @@ export default function Reports() { onChange={e => setTaxYear(Number(e.target.value))} className="block w-[120px] h-8 text-xs rounded border border-[hsl(var(--cf-border-subtle))] bg-[hsl(var(--cf-surface))] text-[hsl(var(--cf-text))] px-2" > - - + {Array.from({ length: 5 }, (_, i) => currentYear - 3 + i).map((y) => ( + + ))}
diff --git a/server/__tests__/tax-reporting.test.ts b/server/__tests__/tax-reporting.test.ts index 58569ab..3759f7a 100644 --- a/server/__tests__/tax-reporting.test.ts +++ b/server/__tests__/tax-reporting.test.ts @@ -114,6 +114,191 @@ describe('buildScheduleEReport', () => { }); }); +describe('buildScheduleEReport — line summary', () => { + const baseTx = { + tenantName: 'T', + tenantType: 'property', + tenantMetadata: {}, + reconciled: true, + metadata: {}, + propertyState: 'IL', + }; + + const tenants = [ + { id: 't-a', name: 'Tenant A', type: 'property', metadata: {} }, + { id: 't-b', name: 'Tenant B', type: 'property', metadata: {} }, + ]; + const properties = [ + { id: 'p-a', tenantId: 't-a', name: 'Property A', address: 'A', state: 'IL' }, + { id: 'p-b', tenantId: 't-b', name: 'Property B', address: 'B', state: 'IL' }, + ]; + + it('aggregates Line 14 (Repairs + Cleaning) across multiple properties with per-COA breakdown', () => { + // Note: per database/chart-of-accounts.ts, both 5070 (Repairs) and + // 5020 (Cleaning & Maintenance) map to Schedule E Line 14, so they + // should aggregate under the same summary row with a 2-entry breakdown. + const transactions: ReportingTransactionRow[] = [ + { ...baseTx, id: 'r1', tenantId: 't-a', amount: '-150.00', type: 'expense', category: 'Repairs', date: '2024-03-01', propertyId: 'p-a' } as any, + { ...baseTx, id: 'r2', tenantId: 't-b', amount: '-250.00', type: 'expense', category: 'Repairs', date: '2024-04-01', propertyId: 'p-b' } as any, + { ...baseTx, id: 'r3', tenantId: 't-a', amount: '-100.00', type: 'expense', category: 'Cleaning', date: '2024-05-01', propertyId: 'p-a' } as any, + ]; + + const report = buildScheduleEReport({ taxYear: 2024, transactions, properties, tenants }); + + const line14 = report.lineSummary.find((l) => l.lineNumber === 'Line 14'); + expect(line14).toBeDefined(); + expect(line14!.amount).toBe(500); // 150 + 250 + 100 + expect(line14!.transactionCount).toBe(3); + expect(line14!.coaBreakdown).toHaveLength(2); + + // Breakdown sorted by amount descending — Repairs (400) > Cleaning (100) + expect(line14!.coaBreakdown[0].coaCode).toBe('5070'); + expect(line14!.coaBreakdown[0].coaName).toBe('Repairs'); + expect(line14!.coaBreakdown[0].amount).toBe(400); + expect(line14!.coaBreakdown[0].transactionCount).toBe(2); + + expect(line14!.coaBreakdown[1].coaCode).toBe('5020'); + expect(line14!.coaBreakdown[1].amount).toBe(100); + expect(line14!.coaBreakdown[1].transactionCount).toBe(1); + }); + + it('groups multiple COA codes under the same Schedule E line (Line 17 utilities)', () => { + const transactions: ReportingTransactionRow[] = [ + { ...baseTx, id: 'u1', tenantId: 't-a', amount: '-80.00', type: 'expense', category: 'Electric', date: '2024-03-01', propertyId: 'p-a' } as any, + { ...baseTx, id: 'u2', tenantId: 't-a', amount: '-60.00', type: 'expense', category: 'Gas', date: '2024-04-01', propertyId: 'p-a' } as any, + { ...baseTx, id: 'u3', tenantId: 't-b', amount: '-40.00', type: 'expense', category: 'Water', date: '2024-05-01', propertyId: 'p-b' } as any, + ]; + + const report = buildScheduleEReport({ taxYear: 2024, transactions, properties, tenants }); + + const line17 = report.lineSummary.find((l) => l.lineNumber === 'Line 17'); + expect(line17).toBeDefined(); + expect(line17!.amount).toBe(180); + expect(line17!.coaBreakdown).toHaveLength(3); // 5100 (Electric), 5110 (Gas), 5120 (Water/Sewer) + // Breakdown sorted by amount descending — Electric (80) > Gas (60) > Water (40) + expect(line17!.coaBreakdown[0].coaCode).toBe('5100'); + expect(line17!.coaBreakdown[0].amount).toBe(80); + expect(line17!.coaBreakdown[1].coaCode).toBe('5110'); + expect(line17!.coaBreakdown[2].coaCode).toBe('5120'); + }); + + it('preserves Schedule E line order in lineSummary', () => { + const transactions: ReportingTransactionRow[] = [ + { ...baseTx, id: 't1', tenantId: 't-a', amount: '-100.00', type: 'expense', category: 'Insurance', date: '2024-03-01', propertyId: 'p-a' } as any, + { ...baseTx, id: 't2', tenantId: 't-a', amount: '1000.00', type: 'income', category: 'Rent', date: '2024-03-01', propertyId: 'p-a' } as any, + { ...baseTx, id: 't3', tenantId: 't-a', amount: '-50.00', type: 'expense', category: 'Advertising', date: '2024-03-01', propertyId: 'p-a' } as any, + ]; + const report = buildScheduleEReport({ taxYear: 2024, transactions, properties, tenants }); + + const lineNumbers = report.lineSummary.map((l) => l.lineNumber); + const line3Idx = lineNumbers.indexOf('Line 3'); + const line5Idx = lineNumbers.indexOf('Line 5'); + const line9Idx = lineNumbers.indexOf('Line 9'); + + expect(line3Idx).toBeLessThan(line5Idx); // Rent before Advertising + expect(line5Idx).toBeLessThan(line9Idx); // Advertising before Insurance + }); +}); + +describe('buildScheduleEReport — classification quality', () => { + const baseTx = { + tenantId: 't-a', + tenantName: 'T', + tenantType: 'property', + tenantMetadata: {}, + reconciled: false, + metadata: {}, + propertyState: 'IL', + }; + const tenants = [{ id: 't-a', name: 'Tenant A', type: 'property', metadata: {} }]; + const properties = [{ id: 'p-a', tenantId: 't-a', name: 'Prop A', address: 'A', state: 'IL' }]; + + it('counts L2-classified rows separately from L1 suggestions and unclassified', () => { + const transactions: ReportingTransactionRow[] = [ + // L2 — has coaCode set + { ...baseTx, id: 'a', amount: '-100.00', type: 'expense', category: null, description: 'a', coaCode: '5070', date: '2024-03-01', propertyId: 'p-a' } as any, + { ...baseTx, id: 'b', amount: '-200.00', type: 'expense', category: null, description: 'b', coaCode: '5040', date: '2024-03-02', propertyId: 'p-a' } as any, + // L1 — only suggested_coa_code + { ...baseTx, id: 'c', amount: '-300.00', type: 'expense', category: null, description: 'c', suggestedCoaCode: '5070', date: '2024-03-03', propertyId: 'p-a' } as any, + // Unclassified — neither set + { ...baseTx, id: 'd', amount: '-50.00', type: 'expense', category: null, description: 'd', date: '2024-03-04', propertyId: 'p-a' } as any, + ]; + + const report = buildScheduleEReport({ taxYear: 2024, transactions, properties, tenants }); + const q = report.classificationQuality; + + expect(q.totalTransactions).toBe(4); + expect(q.l2ClassifiedCount).toBe(2); + expect(q.l1SuggestedOnlyCount).toBe(1); + expect(q.unclassifiedCount).toBe(1); + expect(q.l1SuggestedOnlyAmount).toBe(300); + expect(q.confirmedPct).toBe(50); // 2/4 + expect(q.readyToFile).toBe(false); // below 95% threshold + }); + + it('marks readyToFile=true when 100% L2-classified', () => { + const transactions: ReportingTransactionRow[] = [ + { ...baseTx, id: 'a', amount: '-100.00', type: 'expense', category: null, description: 'a', coaCode: '5070', date: '2024-03-01', propertyId: 'p-a' } as any, + { ...baseTx, id: 'b', amount: '-200.00', type: 'expense', category: null, description: 'b', coaCode: '5040', date: '2024-03-02', propertyId: 'p-a' } as any, + ]; + const report = buildScheduleEReport({ taxYear: 2024, transactions, properties, tenants }); + const q = report.classificationQuality; + + expect(q.totalTransactions).toBe(2); + expect(q.l2ClassifiedCount).toBe(2); + expect(q.confirmedPct).toBe(100); + expect(q.readyToFile).toBe(true); + }); + + it('marks readyToFile=true when there are zero contributing transactions (empty report)', () => { + const report = buildScheduleEReport({ taxYear: 2024, transactions: [], properties, tenants }); + expect(report.classificationQuality.totalTransactions).toBe(0); + expect(report.classificationQuality.readyToFile).toBe(true); + }); + + it('marks readyToFile=false just below the 95% threshold', () => { + // 19 L2 + 1 L1 = 95% — should be READY (>= 95) + const txs19: ReportingTransactionRow[] = Array.from({ length: 19 }, (_, i) => ({ + ...baseTx, + id: `ok-${i}`, + amount: '-10.00', + type: 'expense', + category: null, + description: 'x', + coaCode: '5070', + date: '2024-03-01', + propertyId: 'p-a', + })) as any; + const txs1: ReportingTransactionRow[] = [ + { ...baseTx, id: 'risk', amount: '-10.00', type: 'expense', category: null, description: 'x', suggestedCoaCode: '5070', date: '2024-03-01', propertyId: 'p-a' } as any, + ]; + + const report = buildScheduleEReport({ + taxYear: 2024, + transactions: [...txs19, ...txs1], + properties, + tenants, + }); + expect(report.classificationQuality.confirmedPct).toBe(95); + expect(report.classificationQuality.readyToFile).toBe(true); + + // 18 L2 + 2 L1 = 90% → not ready + const txs18 = txs19.slice(0, 18); + const txs2 = [ + ...txs1, + { ...baseTx, id: 'risk2', amount: '-10.00', type: 'expense', category: null, description: 'y', suggestedCoaCode: '5040', date: '2024-03-01', propertyId: 'p-a' } as any, + ]; + const report2 = buildScheduleEReport({ + taxYear: 2024, + transactions: [...txs18, ...txs2], + properties, + tenants, + }); + expect(report2.classificationQuality.confirmedPct).toBe(90); + expect(report2.classificationQuality.readyToFile).toBe(false); + }); +}); + describe('buildAllocationPeriods', () => { it('returns single period when no date ranges', () => { const members: MemberOwnership[] = [ diff --git a/server/lib/consolidated-reporting.ts b/server/lib/consolidated-reporting.ts index 048b94b..3287974 100644 --- a/server/lib/consolidated-reporting.ts +++ b/server/lib/consolidated-reporting.ts @@ -26,6 +26,7 @@ export interface ReportingTransactionRow { category: string | null; description?: string; coaCode?: string | null; // pre-classified COA code (preferred over fuzzy match) + suggestedCoaCode?: string | null; // L1 suggestion — used only to count classification quality, never as authoritative date: Date | string; reconciled: boolean; metadata: unknown; diff --git a/server/lib/tax-reporting.ts b/server/lib/tax-reporting.ts index d9b81da..02ff7df 100644 --- a/server/lib/tax-reporting.ts +++ b/server/lib/tax-reporting.ts @@ -91,6 +91,52 @@ export interface ScheduleEPropertyColumn { netIncome: number; } +/** + * Aggregated Schedule E line totals across all properties. + * This is what you type into the actual IRS Schedule E form — the sum + * of every property's contribution to each line. + * + * The `coaBreakdown` lets tax preparers see which specific COA codes + * rolled up into a given line, with counts for drill-down. + */ +export interface ScheduleELineSummaryItem { + lineNumber: string; + lineLabel: string; + amount: number; + transactionCount: number; + /** Per-COA-code contribution to this line (for drill-down UI). */ + coaBreakdown: Array<{ + coaCode: string; + coaName: string; + amount: number; + transactionCount: number; + }>; +} + +/** + * Classification quality stats on the transactions that fed the report. + * + * Tax reports should prefer L2-classified rows (human-approved coa_code). + * L1-only rows (suggested_coa_code set, coa_code null) are AI/keyword + * guesses that have not been confirmed — the filer should review them + * before trusting the totals. + */ +export interface ClassificationQuality { + totalTransactions: number; + /** Rows with authoritative coa_code set (L2+). */ + l2ClassifiedCount: number; + /** Rows with only suggested_coa_code set (L1, not yet confirmed). */ + l1SuggestedOnlyCount: number; + /** Rows with neither coa_code nor suggested_coa_code — fell back to keyword matcher. */ + unclassifiedCount: number; + /** Dollar amount covered by L1-only suggestions — the "at-risk" portion. */ + l1SuggestedOnlyAmount: number; + /** 0-100. Share of transactions that are L2 (safe to file). */ + confirmedPct: number; + /** True when confirmedPct is at or above the safe-to-file threshold (default 0.95). */ + readyToFile: boolean; +} + export interface ScheduleEReport { taxYear: number; properties: ScheduleEPropertyColumn[]; @@ -99,6 +145,10 @@ export interface ScheduleEReport { uncategorizedAmount: number; uncategorizedCount: number; unmappedCategories: string[]; + /** Aggregated line totals across all properties (what you file on the form). */ + lineSummary: ScheduleELineSummaryItem[]; + /** Classification trust-path quality stats. */ + classificationQuality: ClassificationQuality; } export interface K1MemberAllocation { @@ -226,10 +276,26 @@ export function buildScheduleEReport(params: { // Entity-level (no propertyId) line accumulators const entityLines = new Map(); + // Aggregated line summary across all properties — keyed by Schedule E line, + // each holds a per-COA-code rollup for drill-down. + const lineSummaryAgg = new Map< + string, + { amount: number; count: number; coaBreakdown: Map } + >(); + let uncategorizedAmount = 0; let uncategorizedCount = 0; const unmappedSet = new Set(); + // Classification quality counters — track every transaction that makes it + // into this report, regardless of property attribution. This is what the + // filer uses to decide whether the report is safe to file. + let l2ClassifiedCount = 0; + let l1SuggestedOnlyCount = 0; + let unclassifiedCount = 0; + let l1SuggestedOnlyAmount = 0; + let contributingTxCount = 0; + // Partnership entity types are reported on Form 1065, not Schedule E entity-level. // Only property-attributed transactions and non-partnership entity-level items belong here. const partnershipTypes = new Set(['holding', 'series', 'management']); @@ -247,6 +313,7 @@ export function buildScheduleEReport(params: { } const propId = (tx as any).propertyId as string | null; + let contributed = false; if (propId && propertyMap.has(propId)) { // Property-attributed transaction → Schedule E property column @@ -259,6 +326,7 @@ export function buildScheduleEReport(params: { existing.amount += tx.type === 'income' ? rawAmount : absAmount; existing.count += 1; lines.set(key, existing); + contributed = true; } else { // No property attribution — only include in Schedule E entity-level // if the transaction's tenant is NOT a partnership type (those go to Form 1065) @@ -271,6 +339,38 @@ export function buildScheduleEReport(params: { existing.amount += tx.type === 'income' ? rawAmount : absAmount; existing.count += 1; entityLines.set(key, existing); + contributed = true; + } + + if (!contributed) continue; + + // Roll up into cross-property line summary (amount sign-normalized: + // income positive, expenses positive magnitudes) + const summaryKey = tx.type === 'income' ? 'Line 3' : lineNumber; + let summaryBucket = lineSummaryAgg.get(summaryKey); + if (!summaryBucket) { + summaryBucket = { amount: 0, count: 0, coaBreakdown: new Map() }; + lineSummaryAgg.set(summaryKey, summaryBucket); + } + const normalized = tx.type === 'income' ? rawAmount : absAmount; + summaryBucket.amount += normalized; + summaryBucket.count += 1; + const coaBucket = summaryBucket.coaBreakdown.get(coaCode) || { amount: 0, count: 0 }; + coaBucket.amount += normalized; + coaBucket.count += 1; + summaryBucket.coaBreakdown.set(coaCode, coaBucket); + + // Classification quality tally — one bucket per contributing transaction + contributingTxCount += 1; + const hasL2 = Boolean(tx.coaCode); + const hasL1 = Boolean(tx.suggestedCoaCode); + if (hasL2) { + l2ClassifiedCount += 1; + } else if (hasL1) { + l1SuggestedOnlyCount += 1; + l1SuggestedOnlyAmount += absAmount; + } else { + unclassifiedCount += 1; } } @@ -346,6 +446,49 @@ export function buildScheduleEReport(params: { } } + // Build aggregated line summary across all properties (what you file on the form) + const lineSummary: ScheduleELineSummaryItem[] = []; + for (const lineNum of SCHEDULE_E_LINE_ORDER) { + const bucket = lineSummaryAgg.get(lineNum); + if (!bucket) continue; + + const coaBreakdown = Array.from(bucket.coaBreakdown.entries()) + .map(([coaCode, data]) => { + const account = getAccountByCode(coaCode); + return { + coaCode, + coaName: account?.name || 'Unknown', + amount: round2(data.amount), + transactionCount: data.count, + }; + }) + .sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)); + + lineSummary.push({ + lineNumber: lineNum, + lineLabel: SCHEDULE_E_LINES[lineNum] || 'Other', + amount: round2(bucket.amount), + transactionCount: bucket.count, + coaBreakdown, + }); + } + + // Classification quality — 95% confirmed is the "ready to file" threshold. + // Chosen because tax filings tolerate some residual uncertainty (tenant + // rounding, late-posting charges) but more than 5% unconfirmed means the + // filer hasn't actually reviewed the AI suggestions. + const confirmedPct = + contributingTxCount === 0 ? 100 : round2((l2ClassifiedCount / contributingTxCount) * 100); + const classificationQuality: ClassificationQuality = { + totalTransactions: contributingTxCount, + l2ClassifiedCount, + l1SuggestedOnlyCount, + unclassifiedCount, + l1SuggestedOnlyAmount: round2(l1SuggestedOnlyAmount), + confirmedPct, + readyToFile: contributingTxCount === 0 || confirmedPct >= 95, + }; + return { taxYear, properties: propertyColumns, @@ -354,6 +497,8 @@ export function buildScheduleEReport(params: { uncategorizedAmount: round2(uncategorizedAmount), uncategorizedCount, unmappedCategories: Array.from(unmappedSet).sort(), + lineSummary, + classificationQuality, }; } diff --git a/server/storage/system.ts b/server/storage/system.ts index 7d55246..e37ec1f 100755 --- a/server/storage/system.ts +++ b/server/storage/system.ts @@ -637,6 +637,7 @@ export class SystemStorage { category: schema.transactions.category, description: schema.transactions.description, coaCode: schema.transactions.coaCode, + suggestedCoaCode: schema.transactions.suggestedCoaCode, date: schema.transactions.date, payee: schema.transactions.payee, propertyId: schema.transactions.propertyId,