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)}
+
+
+
+
+
+ Line
+ Description
+ Amount
+ Txns
+ Breakdown
+
+
+
+ {summary.map((line) => {
+ const isExpanded = expandedLine === line.lineNumber;
+ const isIncome = line.lineNumber === 'Line 3';
+ const hasMultipleCoa = line.coaBreakdown.length > 1;
+ return (
+
+
+ {line.lineNumber}
+ {line.lineLabel}
+
+ {formatCurrency(line.amount)}
+
+ {line.transactionCount}
+
+ {hasMultipleCoa ? (
+ setExpandedLine(isExpanded ? null : line.lineNumber)}
+ className="text-[10px] text-[hsl(var(--cf-lime))] hover:underline"
+ >
+ {isExpanded ? 'Hide' : `${line.coaBreakdown.length} codes`}
+
+ ) : (
+ {line.coaBreakdown[0]?.coaCode}
+ )}
+
+
+ {isExpanded &&
+ line.coaBreakdown.map((entry) => (
+
+
+
+ {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"
>
- 2024
- 2025
+ {Array.from({ length: 5 }, (_, i) => currentYear - 3 + i).map((y) => (
+ {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,