diff --git a/client/src/hooks/use-reports.ts b/client/src/hooks/use-reports.ts index 587dff2..dd26edd 100644 --- a/client/src/hooks/use-reports.ts +++ b/client/src/hooks/use-reports.ts @@ -104,3 +104,120 @@ export function useRunTaxAutomation() { apiRequest('POST', '/api/workflows/close-tax-automation', params).then(r => r.json()), }); } + +// ── Tax Report Types ── + +export interface TaxReportParams { + taxYear: number; + includeDescendants?: boolean; +} + +export interface ScheduleELineItem { + lineNumber: string; + lineLabel: string; + amount: number; + transactionCount: number; +} + +export interface ScheduleEPropertyColumn { + propertyId: string; + propertyName: string; + address: string; + state: string; + filingType: 'schedule-e-personal' | 'schedule-e-partnership'; + tenantId: string; + tenantName: string; + lines: ScheduleELineItem[]; + totalIncome: number; + totalExpenses: number; + netIncome: number; +} + +export interface ScheduleEReport { + taxYear: number; + properties: ScheduleEPropertyColumn[]; + entityLevelItems: ScheduleELineItem[]; + entityLevelTotal: number; + uncategorizedAmount: number; + uncategorizedCount: number; + unmappedCategories: string[]; +} + +export interface K1MemberAllocation { + memberName: string; + pct: number; + ordinaryIncome: number; + rentalIncome: number; + guaranteedPayments: number; + otherDeductions: number; + totalAllocated: number; + periods: Array<{ + startDate: string; + endDate: string; + pct: number; + dayCount: number; + allocatedIncome: number; + }>; +} + +export interface Form1065Report { + taxYear: number; + entityId: string; + entityName: string; + entityType: string; + ordinaryIncome: number; + totalDeductions: number; + netIncome: number; + incomeByCategory: Array<{ category: string; coaCode: string; amount: number }>; + deductionsByCategory: Array<{ category: string; coaCode: string; amount: number; scheduleELine?: string }>; + memberAllocations: K1MemberAllocation[]; + warnings: string[]; +} + +function buildTaxQueryString(params: TaxReportParams): string { + const qs = new URLSearchParams(); + qs.set('taxYear', String(params.taxYear)); + if (params.includeDescendants !== undefined) qs.set('includeDescendants', String(params.includeDescendants)); + return qs.toString(); +} + +export function useScheduleEReport(params: TaxReportParams | null) { + const tenantId = useTenantId(); + const qs = params ? buildTaxQueryString(params) : ''; + return useQuery({ + queryKey: [`/api/reports/tax/schedule-e?${qs}`, tenantId], + enabled: !!tenantId && !!params, + staleTime: 2 * 60 * 1000, + }); +} + +export function useForm1065Report(params: TaxReportParams | null) { + const tenantId = useTenantId(); + const qs = params ? buildTaxQueryString(params) : ''; + return useQuery({ + queryKey: [`/api/reports/tax/form-1065?${qs}`, tenantId], + enabled: !!tenantId && !!params, + staleTime: 2 * 60 * 1000, + }); +} + +export function useExportTaxPackage() { + return useMutation({ + mutationFn: async (params) => { + const qs = new URLSearchParams(); + qs.set('taxYear', String(params.taxYear)); + qs.set('format', params.format || 'csv'); + if (params.includeDescendants !== undefined) qs.set('includeDescendants', String(params.includeDescendants)); + const res = await fetch(`/api/reports/tax/export?${qs}`, { credentials: 'include' }); + if (!res.ok) throw new Error('Export failed'); + const blob = await res.blob(); + const ext = params.format === 'json' ? 'json' : 'csv'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `tax-package-${params.taxYear}.${ext}`; + a.click(); + URL.revokeObjectURL(url); + }, + }); +} diff --git a/client/src/pages/Reports.tsx b/client/src/pages/Reports.tsx index 104bde1..97fd8d1 100644 --- a/client/src/pages/Reports.tsx +++ b/client/src/pages/Reports.tsx @@ -1,6 +1,12 @@ import { useState } from 'react'; import { useTenantId } from '@/contexts/TenantContext'; -import { useConsolidatedReport, useRunTaxAutomation, type ReportParams } from '@/hooks/use-reports'; +import { + useConsolidatedReport, useRunTaxAutomation, + useScheduleEReport, useForm1065Report, useExportTaxPackage, + type ReportParams, type TaxReportParams, + type ScheduleEPropertyColumn, type ScheduleELineItem, + type Form1065Report as Form1065ReportType, type K1MemberAllocation, +} from '@/hooks/use-reports'; import { useToast } from '@/hooks/use-toast'; import { formatCurrency } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -10,7 +16,7 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { BarChart3, Download, Play, CheckCircle2, AlertTriangle, XCircle, - TrendingUp, TrendingDown, Building2, MapPin, Shield + TrendingUp, TrendingDown, Building2, MapPin, Shield, FileText, Users } from 'lucide-react'; function pct(value: number, total: number) { @@ -28,18 +34,275 @@ const STATUS_ICON = { fail: , }; +type ReportTab = 'consolidated' | 'schedule-e' | 'form-1065'; + +const TAB_CONFIG: Array<{ key: ReportTab; label: string; icon: typeof BarChart3 }> = [ + { key: 'consolidated', label: 'Consolidated', icon: BarChart3 }, + { key: 'schedule-e', label: 'Schedule E', icon: FileText }, + { key: 'form-1065', label: 'Form 1065 / K-1', icon: Users }, +]; + +function ScheduleETab({ taxYear }: { taxYear: number }) { + const params: TaxReportParams = { taxYear, includeDescendants: true }; + const { data, isLoading, error } = useScheduleEReport(params); + + if (isLoading) return
Loading Schedule E...
; + if (error) return
Failed to load Schedule E report.
; + if (!data) return null; + + return ( +
+ {/* Warnings */} + {data.uncategorizedCount > 0 && ( +
+
+ + {data.uncategorizedCount} transactions ({formatCurrency(data.uncategorizedAmount)}) unmapped to Schedule E lines +
+ {data.unmappedCategories.length > 0 && ( +

Categories: {data.unmappedCategories.join(', ')}

+ )} +
+ )} + + {/* Per-property cards */} + {data.properties.map((prop: ScheduleEPropertyColumn) => ( +
+
+
+ +
+

{prop.propertyName}

+

{prop.address} | {prop.state}

+
+
+
+ {prop.filingType === 'schedule-e-personal' ? 'Personal (Sched E)' : 'Partnership (1065)'} + = 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + {formatCurrency(prop.netIncome)} + +
+
+ + + + + + + + + + + {prop.lines.map((line: ScheduleELineItem) => ( + + + + + + + ))} + + + + + + + +
LineDescriptionAmountTxns
{line.lineNumber}{line.lineLabel} + {formatCurrency(line.amount)} + {line.transactionCount}
Net Income= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + {formatCurrency(prop.netIncome)} + +
+
+ ))} + + {/* Entity-level items */} + {data.entityLevelItems.length > 0 && ( +
+
+

Entity-Level Items

+

Not attributed to a specific property

+
+ + + {data.entityLevelItems.map((line: ScheduleELineItem) => ( + + + + + + ))} + +
{line.lineNumber}{line.lineLabel}{formatCurrency(line.amount)}
+
+ )} + + {data.properties.length === 0 && ( +
No property transactions found for {taxYear}.
+ )} +
+ ); +} + +function Form1065Tab({ taxYear }: { taxYear: number }) { + const params: TaxReportParams = { taxYear, includeDescendants: true }; + const { data: reports, isLoading, error } = useForm1065Report(params); + + if (isLoading) return
Loading Form 1065...
; + if (error) return
Failed to load Form 1065 report.
; + if (!reports || reports.length === 0) return
No partnership entities found for {taxYear}.
; + + return ( +
+ {reports.map((report: Form1065ReportType) => ( +
+ {/* Entity header */} +
+
+
+

{report.entityName}

+

Form 1065 — {report.entityType} — Tax Year {report.taxYear}

+
+ = 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + {formatCurrency(report.netIncome)} + +
+ + {/* Summary cards */} +
+
+

Gross Income

+

{formatCurrency(report.ordinaryIncome)}

+
+
+

Deductions

+

{formatCurrency(report.totalDeductions)}

+
+
+

Net Income

+

= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>{formatCurrency(report.netIncome)}

+
+
+ + {/* Warnings */} + {report.warnings.length > 0 && ( +
+ {report.warnings.map((w: string, i: number) => ( +
+ + {w} +
+ ))} +
+ )} +
+ + {/* Income breakdown */} + {report.incomeByCategory.length > 0 && ( +
+
+
Income
+
+ + + {report.incomeByCategory.map((item: { category: string; coaCode: string; amount: number }, i: number) => ( + + + + + + ))} + +
{item.category}{item.coaCode}{formatCurrency(item.amount)}
+
+ )} + + {/* Deductions breakdown */} + {report.deductionsByCategory.length > 0 && ( +
+
+
Deductions
+
+ + + + + + + + + + + {report.deductionsByCategory.map((item: { category: string; coaCode: string; scheduleELine?: string; amount: number }, i: number) => ( + + + + + + + ))} + +
CategoryCodeSched EAmount
{item.category}{item.coaCode}{item.scheduleELine || '—'}{formatCurrency(item.amount)}
+
+ )} + + {/* K-1 Member Allocations */} + {report.memberAllocations.length > 0 && ( +
+
+ +
K-1 Member Allocations
+
+ + + + + + + + + + + {report.memberAllocations.map((m: K1MemberAllocation, i: number) => ( + + + + + + + ))} + +
MemberEff. %AllocatedPeriods
{m.memberName}{m.pct.toFixed(1)}%= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + {formatCurrency(m.totalAllocated)} + + {m.periods.length > 0 + ? m.periods.map(p => `${p.startDate}–${p.endDate} (${p.pct}%)`).join('; ') + : 'Full year'} +
+
+ )} +
+ ))} +
+ ); +} + export default function Reports() { const tenantId = useTenantId(); const { toast } = useToast(); + const [activeTab, setActiveTab] = useState('consolidated'); const [startDate, setStartDate] = useState(defaultStart); const [endDate, setEndDate] = useState(defaultEnd); const [includeDescendants, setIncludeDescendants] = useState(true); const [includeIntercompany, setIncludeIntercompany] = useState(false); + const [taxYear, setTaxYear] = useState(currentYear); const params: ReportParams | null = tenantId ? { startDate, endDate, includeDescendants, includeIntercompany } : null; - const { data, isLoading, error } = useConsolidatedReport(params); + const { data, isLoading, error } = useConsolidatedReport(activeTab === 'consolidated' ? params : null); const taxAutomation = useRunTaxAutomation(); + const exportTaxPkg = useExportTaxPackage(); if (!tenantId) { return
Select a tenant to view reports.
; @@ -85,6 +348,13 @@ export default function Reports() { URL.revokeObjectURL(url); }; + const handleExportTaxPackage = (format: 'csv' | 'json') => { + exportTaxPkg.mutate({ taxYear, format }, { + onSuccess: () => toast({ title: `Tax package exported (${format.toUpperCase()})` }), + onError: () => toast({ title: 'Export failed', variant: 'destructive' }), + }); + }; + return (
{/* Header */} @@ -94,39 +364,96 @@ export default function Reports() {

Consolidated financial reporting & tax readiness

- - + {activeTab === 'consolidated' ? ( + <> + + + + ) : ( + <> + + + + )}
- {/* Controls */} -
-
- - setStartDate(e.target.value)} className="w-[150px] h-8 text-xs" /> -
-
- - setEndDate(e.target.value)} className="w-[150px] h-8 text-xs" /> -
-
- - + {/* Tab Switcher */} +
+ {TAB_CONFIG.map(tab => ( + + ))} +
+ + {/* Controls — consolidated tab */} + {activeTab === 'consolidated' && ( +
+
+ + setStartDate(e.target.value)} className="w-[150px] h-8 text-xs" /> +
+
+ + setEndDate(e.target.value)} className="w-[150px] h-8 text-xs" /> +
+
+ + +
+
+ + +
-
- - + )} + + {/* Controls — tax tabs */} + {activeTab !== 'consolidated' && ( +
+
+ + +
-
+ )} + + {/* Schedule E Tab */} + {activeTab === 'schedule-e' && } + + {/* Form 1065 / K-1 Tab */} + {activeTab === 'form-1065' && } - {isLoading &&
Generating report...
} - {error &&
Failed to load report. Check date range.
} + {/* Consolidated Tab */} + {activeTab === 'consolidated' && isLoading &&
Generating report...
} + {activeTab === 'consolidated' && error &&
Failed to load report. Check date range.
} - {report && ( + {activeTab === 'consolidated' && report && ( <> {/* P&L Summary */}
diff --git a/database/seeds/it-can-be-llc.ts b/database/seeds/it-can-be-llc.ts index 3371b73..106351f 100755 --- a/database/seeds/it-can-be-llc.ts +++ b/database/seeds/it-can-be-llc.ts @@ -16,8 +16,20 @@ export async function seedItCanBeLLC() { taxId: null, // Add actual EIN when ready metadata: { jurisdiction: 'Wyoming', - formation_date: '2025-01-01', // Update with actual date - description: 'Wyoming Closely Held LLC - Parent holding company', + formation_date: '2024-10-29', + description: 'Wyoming Series and Close LLC - Parent holding company', + // IT CAN BE LLC members (verified from ITCB Operating Agreement 2024-10-29): + // Oct 29 – Dec 15, 2024: Nicholas Bianchi 85%, Sharon Jones 15% (individual) + // Dec 16, 2024 – present: JAV LLC 85%, Sharon Jones 15% (Nick assigned to JAV) + members: [ + { name: 'JEAN ARLENE VENTURING LLC', pct: 85 }, + { name: 'Sharon E Jones', pct: 15 }, + ], + members_2024: [ + { name: 'Nicholas Bianchi', pct: 85, endDate: '2024-12-15' }, + { name: 'JEAN ARLENE VENTURING LLC', pct: 85, startDate: '2024-12-16' }, + { name: 'Sharon E Jones', pct: 15 }, + ], }, }).returning(); @@ -30,7 +42,12 @@ export async function seedItCanBeLLC() { type: 'personal', parentId: itCanBeLLC.id, metadata: { - description: 'Personal Income Funnel', + description: 'Personal Income Funnel — pass-through to Nick Bianchi 1040', + jurisdiction: 'Florida', + formation_date: '2024-12-13', + filing_number: '500439906755', // FL DOS + registered_agent: 'Florida Registered Agent LLC', + // FL Annual Report due May 1, 2026 (auto-pay failed per 2026-02-10 email) ownership_percentage: 85, properties: [ '541 W Addison St #3S, Chicago IL 60613', @@ -52,6 +69,25 @@ export async function seedItCanBeLLC() { jurisdiction: 'Illinois', description: 'Illinois Series LLC - Property Investment, Improvement, and Management', ownership: '100% owned by IT CAN BE LLC', + // ARIBIA LLC member timeline (verified from ChittyEvidence): + // Jul 2022 – Mar 14, 2024: Nick 90%, Luisa 10% (individual members) + // Mar 15 – Oct 14, 2024: Nick 85%, Luisa 10%, Sharon 5% (individual members) + // Oct 15 – Oct 28, 2024: Nick 85%, Sharon 5% (Luisa removed Oct 14) + // Oct 29, 2024 – present: IT CAN BE LLC 100% (Amendment B, sole member) + // Sources: Member List (2024-03-01), Unanimous Consent Removal (2024-10-14), + // Amendment B (2024-10-29), Amendment C (2025-03-08) + // NOTE: members_2024 is for 2024 K-1 generation (mixed individual + entity periods) + // members is current state (2025+) + members: [ + { name: 'IT CAN BE LLC', pct: 100 }, + ], + members_2024: [ + { name: 'Nicholas Bianchi', pct: 90, endDate: '2024-03-14' }, + { name: 'Nicholas Bianchi', pct: 85, startDate: '2024-03-15', endDate: '2024-10-28' }, + { name: 'Luisa Arias', pct: 10, endDate: '2024-10-14' }, + { name: 'Sharon E Jones', pct: 5, startDate: '2024-03-15', endDate: '2024-10-28' }, + { name: 'IT CAN BE LLC', pct: 100, startDate: '2024-10-29' }, + ], }, }).returning(); @@ -124,6 +160,10 @@ export async function seedItCanBeLLC() { 'ARIBIA LLC': '85%', 'Sharon E Jones': '15%', }, + members: [ + { name: 'ARIBIA LLC', pct: 85 }, + { name: 'Sharon E Jones', pct: 15 }, + ], }, }).returning(); diff --git a/server/__tests__/tax-reporting.test.ts b/server/__tests__/tax-reporting.test.ts new file mode 100644 index 0000000..b002d91 --- /dev/null +++ b/server/__tests__/tax-reporting.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; +import { taxRoutes } from '../routes/tax'; +import { + buildScheduleEReport, + buildForm1065Report, + buildMemberAllocations, + buildAllocationPeriods, + resolveScheduleELine, + serializeScheduleECsv, + serializeTaxPackageCsv, + buildTaxPackage, + type MemberOwnership, +} from '../lib/tax-reporting'; +import type { ReportingTransactionRow } from '../lib/consolidated-reporting'; + +// ── Pure Function Tests ── + +describe('resolveScheduleELine', () => { + it('maps Rent to Line 3', () => { + const result = resolveScheduleELine('Rent'); + expect(result.lineNumber).toBe('Line 3'); + expect(result.coaCode).toBe('4000'); + }); + + it('maps Insurance to Line 9', () => { + const result = resolveScheduleELine('Insurance'); + expect(result.lineNumber).toBe('Line 9'); + expect(result.coaCode).toBe('5040'); + }); + + it('maps Repairs to Line 14', () => { + const result = resolveScheduleELine('Repairs'); + expect(result.lineNumber).toBe('Line 14'); + expect(result.coaCode).toBe('5070'); + }); + + it('maps unknown category to Line 19 (Other)', () => { + const result = resolveScheduleELine('random_unknown_thing'); + expect(result.lineNumber).toBe('Line 19'); + expect(result.coaCode).toBe('9010'); + }); + + it('uses description fallback when category is null', () => { + const result = resolveScheduleELine(null, 'Peoples Gas payment'); + expect(result.lineNumber).toBe('Line 17'); + }); +}); + +describe('buildScheduleEReport', () => { + const baseTx = { + tenantId: 't-prop1', + tenantName: 'APT ARLENE', + tenantType: 'property', + tenantMetadata: {}, + reconciled: true, + metadata: {}, + propertyState: 'IL', + }; + + const transactions: ReportingTransactionRow[] = [ + { ...baseTx, id: 'tx1', amount: '2000.00', type: 'income', category: 'Rent', date: '2024-03-15', propertyId: 'p1' } as any, + { ...baseTx, id: 'tx2', amount: '-500.00', type: 'expense', category: 'Insurance', date: '2024-04-01', propertyId: 'p1' } as any, + { ...baseTx, id: 'tx3', amount: '-300.00', type: 'expense', category: 'Repairs', date: '2024-05-10', propertyId: 'p1' } as any, + { ...baseTx, id: 'tx4', amount: '-100.00', type: 'expense', category: 'HOA Dues', date: '2024-06-01', propertyId: 'p1' } as any, + ]; + + const properties = [{ id: 'p1', tenantId: 't-prop1', name: 'Villa Vista', address: '4343 N Clarendon #1610, Chicago, IL', state: 'IL' }]; + const tenants = [{ id: 't-prop1', name: 'APT ARLENE', type: 'property', metadata: {} }]; + + it('groups transactions by Schedule E line per property', () => { + const report = buildScheduleEReport({ taxYear: 2024, transactions, properties, tenants }); + + expect(report.taxYear).toBe(2024); + expect(report.properties).toHaveLength(1); + + const prop = report.properties[0]; + expect(prop.propertyName).toBe('Villa Vista'); + expect(prop.totalIncome).toBe(2000); + expect(prop.totalExpenses).toBe(900); + expect(prop.netIncome).toBe(1100); + + // Check line items + const line3 = prop.lines.find(l => l.lineNumber === 'Line 3'); + expect(line3?.amount).toBe(2000); + + const line9 = prop.lines.find(l => l.lineNumber === 'Line 9'); + expect(line9?.amount).toBe(500); + + const line14 = prop.lines.find(l => l.lineNumber === 'Line 14'); + expect(line14?.amount).toBe(300); + + const line19 = prop.lines.find(l => l.lineNumber === 'Line 19'); + expect(line19?.amount).toBe(100); // HOA maps to Line 19 + }); + + it('flags filing type based on tenant type', () => { + const report = buildScheduleEReport({ taxYear: 2024, transactions, properties, tenants }); + expect(report.properties[0].filingType).toBe('schedule-e-partnership'); + + // Personal property + const personalTenants = [{ id: 't-prop1', name: 'Personal', type: 'personal', metadata: {} }]; + const personalReport = buildScheduleEReport({ taxYear: 2024, transactions, properties, tenants: personalTenants }); + expect(personalReport.properties[0].filingType).toBe('schedule-e-personal'); + }); + + it('collects entity-level items when propertyId is missing', () => { + const entityTxs: any[] = [ + { ...baseTx, id: 'tx-e1', amount: '-200.00', type: 'expense', category: 'Legal', date: '2024-07-01' }, + ]; + const report = buildScheduleEReport({ taxYear: 2024, transactions: entityTxs, properties: [], tenants }); + expect(report.entityLevelItems.length).toBeGreaterThan(0); + }); +}); + +describe('buildAllocationPeriods', () => { + it('returns single period when no date ranges', () => { + const members: MemberOwnership[] = [ + { name: 'Nick', pct: 85 }, + { name: 'Sharon', pct: 15 }, + ]; + const periods = buildAllocationPeriods(2024, members); + expect(periods).toHaveLength(1); + expect(periods[0].startDate).toBe('2024-01-01'); + expect(periods[0].endDate).toBe('2024-12-31'); + expect(periods[0].members).toHaveLength(2); + }); + + it('splits periods when member exits mid-year (Luisa Oct 14 2024)', () => { + const members: MemberOwnership[] = [ + { name: 'Nick', pct: 80 }, + { name: 'Sharon', pct: 10 }, + { name: 'Luisa', pct: 10, endDate: '2024-10-14' }, + ]; + const periods = buildAllocationPeriods(2024, members); + expect(periods.length).toBe(2); + + // Period 1: Jan 1 - Oct 14 (all three members) + expect(periods[0].startDate).toBe('2024-01-01'); + expect(periods[0].endDate).toBe('2024-10-14'); + expect(periods[0].members).toHaveLength(3); + + // Period 2: Oct 15 - Dec 31 (without Luisa) + expect(periods[1].startDate).toBe('2024-10-15'); + expect(periods[1].endDate).toBe('2024-12-31'); + expect(periods[1].members).toHaveLength(2); + expect(periods[1].members.find(m => m.name === 'Luisa')).toBeUndefined(); + }); + + it('handles member joining mid-year', () => { + const members: MemberOwnership[] = [ + { name: 'Nick', pct: 90 }, + { name: 'Sharon', pct: 10, startDate: '2024-03-15' }, + ]; + const periods = buildAllocationPeriods(2024, members); + expect(periods.length).toBe(2); + + // Period 1: Jan 1 - Mar 14 (Nick only) + expect(periods[0].members).toHaveLength(1); + expect(periods[0].members[0].name).toBe('Nick'); + + // Period 2: Mar 15 - Dec 31 (both) + expect(periods[1].members).toHaveLength(2); + }); +}); + +describe('buildMemberAllocations', () => { + it('allocates 100% to entity when no members defined', () => { + const result = buildMemberAllocations(2024, 10000, []); + expect(result).toHaveLength(1); + expect(result[0].totalAllocated).toBe(10000); + expect(result[0].pct).toBe(100); + }); + + it('applies static percentages correctly (85/15 split)', () => { + const members: MemberOwnership[] = [ + { name: 'JAV', pct: 85 }, + { name: 'Sharon', pct: 15 }, + ]; + const result = buildMemberAllocations(2024, 10000, members); + expect(result).toHaveLength(2); + expect(result[0].totalAllocated).toBe(8500); + expect(result[1].totalAllocated).toBe(1500); + }); + + it('applies time-weighted allocation for Luisa exit', () => { + const members: MemberOwnership[] = [ + { name: 'Nick', pct: 80 }, + { name: 'Sharon', pct: 10 }, + { name: 'Luisa', pct: 10, endDate: '2024-10-14' }, + ]; + + const result = buildMemberAllocations(2024, 36600, members); + + // Luisa should get less than 10% of annual income (only ~78% of year) + const luisa = result.find(m => m.memberName === 'Luisa')!; + expect(luisa.totalAllocated).toBeLessThan(3660); // < 10% of 36600 + expect(luisa.totalAllocated).toBeGreaterThan(0); + expect(luisa.periods.length).toBe(1); // Only in first period + + // Nick should get more than 80% (picks up extra in second period) + const nick = result.find(m => m.memberName === 'Nick')!; + expect(nick.totalAllocated).toBeGreaterThan(29280); // > 80% of 36600 + expect(nick.periods.length).toBe(2); + + // Total allocated should equal net income + const total = result.reduce((sum, m) => sum + m.totalAllocated, 0); + expect(Math.abs(total - 36600)).toBeLessThan(0.02); // rounding tolerance + }); + + it('handles negative income (loss)', () => { + const members: MemberOwnership[] = [ + { name: 'Nick', pct: 85 }, + { name: 'Sharon', pct: 15 }, + ]; + const result = buildMemberAllocations(2024, -5000, members); + expect(result[0].totalAllocated).toBe(-4250); + expect(result[1].totalAllocated).toBe(-750); + }); +}); + +describe('buildForm1065Report', () => { + it('generates report for partnership entities only', () => { + const transactions: any[] = [ + { id: 'tx1', tenantId: 't-mgmt', tenantName: 'ARIBIA - MGMT', tenantType: 'management', tenantMetadata: {}, amount: '5000', type: 'income', category: 'Rent', date: '2024-06-01', reconciled: true, metadata: {}, propertyState: 'IL' }, + { id: 'tx2', tenantId: 't-mgmt', tenantName: 'ARIBIA - MGMT', tenantType: 'management', tenantMetadata: {}, amount: '-1500', type: 'expense', category: 'Repairs', date: '2024-06-15', reconciled: true, metadata: {}, propertyState: 'IL' }, + { id: 'tx3', tenantId: 't-personal', tenantName: 'Personal', tenantType: 'personal', tenantMetadata: {}, amount: '3000', type: 'income', category: 'Rent', date: '2024-06-01', reconciled: true, metadata: {}, propertyState: 'IL' }, + ]; + + const tenants = [ + { id: 't-mgmt', name: 'ARIBIA - MGMT', type: 'management', metadata: { members: [{ name: 'ITCB', pct: 100 }] } }, + { id: 't-personal', name: 'Personal', type: 'personal', metadata: {} }, + ]; + + const reports = buildForm1065Report({ taxYear: 2024, entityTenants: tenants, transactions }); + + // Only management entity gets a 1065, not personal + expect(reports).toHaveLength(1); + expect(reports[0].entityName).toBe('ARIBIA - MGMT'); + expect(reports[0].ordinaryIncome).toBe(5000); + expect(reports[0].totalDeductions).toBe(1500); + expect(reports[0].netIncome).toBe(3500); + expect(reports[0].memberAllocations[0].memberName).toBe('ITCB'); + expect(reports[0].memberAllocations[0].totalAllocated).toBe(3500); + }); + + it('warns when no members are defined', () => { + const transactions: any[] = [ + { id: 'tx1', tenantId: 't-hold', tenantName: 'ITCB', tenantType: 'holding', tenantMetadata: {}, amount: '1000', type: 'income', category: 'Rent', date: '2024-01-15', reconciled: true, metadata: {}, propertyState: 'IL' }, + ]; + const tenants = [{ id: 't-hold', name: 'ITCB', type: 'holding', metadata: {} }]; + + const reports = buildForm1065Report({ taxYear: 2024, entityTenants: tenants, transactions }); + expect(reports[0].warnings).toContain('No members defined in tenant metadata. Showing 100% to entity.'); + }); +}); + +describe('serializeScheduleECsv', () => { + it('produces valid CSV with BOM', () => { + const report = buildScheduleEReport({ + taxYear: 2024, + transactions: [ + { id: 'tx1', tenantId: 't1', tenantName: 'Test', tenantType: 'property', tenantMetadata: {}, amount: '1000', type: 'income', category: 'Rent', date: '2024-01-15', reconciled: true, metadata: {}, propertyState: 'IL', propertyId: 'p1' } as any, + ], + properties: [{ id: 'p1', tenantId: 't1', name: 'Test Property', address: '123 Main St', state: 'IL' }], + tenants: [{ id: 't1', name: 'Test', type: 'property', metadata: {} }], + }); + + const csv = serializeScheduleECsv(report); + expect(csv.startsWith('\uFEFF')).toBe(true); // BOM + expect(csv).toContain('Schedule E Summary'); + expect(csv).toContain('Test Property'); + expect(csv).toContain('Line 3'); // Rent = Line 3 + }); +}); + +// ── Route Integration Tests ── + +const env = { + CHITTY_AUTH_SERVICE_TOKEN: 'svc-token', + DATABASE_URL: 'fake', + FINANCE_KV: {} as any, + FINANCE_R2: {} as any, + ASSETS: {} as any, +}; + +function withStorage(app: Hono, storage: any) { + app.use('*', async (c, next) => { + c.set('tenantId', 't-root'); + c.set('storage', storage); + await next(); + }); + return app; +} + +describe('taxRoutes', () => { + const mockStorage = { + getTenantDescendantIds: vi.fn().mockResolvedValue(['t-root', 't-prop1']), + getTenantsByIds: vi.fn().mockResolvedValue([ + { id: 't-root', name: 'ARIBIA LLC', type: 'series', metadata: { members: [{ name: 'ITCB', pct: 100 }] } }, + { id: 't-prop1', name: 'APT ARLENE', type: 'property', metadata: {} }, + ]), + getTransactionsForTenantScope: vi.fn().mockResolvedValue([ + { id: 'tx1', tenantId: 't-prop1', tenantName: 'APT ARLENE', tenantType: 'property', tenantMetadata: {}, amount: '2000', type: 'income', category: 'Rent', date: '2024-06-15', reconciled: true, metadata: {}, propertyState: 'IL', propertyId: 'p1' }, + ]), + getPropertiesForTenants: vi.fn().mockResolvedValue([ + { id: 'p1', tenantId: 't-prop1', name: 'Villa Vista', address: '4343 N Clarendon', city: 'Chicago', state: 'IL' }, + ]), + }; + + it('GET /api/reports/tax/schedule-e returns property-level report', async () => { + const app = new Hono(); + withStorage(app, mockStorage); + app.route('/', taxRoutes); + + const res = await app.request('/api/reports/tax/schedule-e?taxYear=2024', {}, env); + expect(res.status).toBe(200); + + const body = await res.json() as any; + expect(body.taxYear).toBe(2024); + expect(body.properties).toHaveLength(1); + expect(body.properties[0].propertyName).toBe('Villa Vista'); + expect(body.properties[0].lines.length).toBeGreaterThan(0); + }); + + it('GET /api/reports/tax/form-1065 returns partnership reports', async () => { + const app = new Hono(); + withStorage(app, mockStorage); + app.route('/', taxRoutes); + + const res = await app.request('/api/reports/tax/form-1065?taxYear=2024', {}, env); + expect(res.status).toBe(200); + + const body = await res.json() as any; + expect(Array.isArray(body)).toBe(true); + }); + + it('GET /api/reports/tax/export returns CSV', async () => { + const app = new Hono(); + withStorage(app, mockStorage); + app.route('/', taxRoutes); + + const res = await app.request('/api/reports/tax/export?taxYear=2024&format=csv', {}, env); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/csv'); + expect(res.headers.get('content-disposition')).toContain('tax-package-2024.csv'); + }); + + it('rejects invalid tax year', async () => { + const app = new Hono(); + withStorage(app, mockStorage); + app.route('/', taxRoutes); + + const res = await app.request('/api/reports/tax/schedule-e?taxYear=abc', {}, env); + expect(res.status).toBe(400); + }); +}); diff --git a/server/app.ts b/server/app.ts index fa67be6..cede472 100644 --- a/server/app.ts +++ b/server/app.ts @@ -30,6 +30,7 @@ import { portfolioRoutes } from './routes/portfolio'; import { importRoutes } from './routes/import'; import { mcpRoutes } from './routes/mcp'; import { reportRoutes } from './routes/reports'; +import { taxRoutes } from './routes/tax'; import { googleRoutes, googleCallbackRoute } from './routes/google'; import { commsRoutes } from './routes/comms'; import { workflowRoutes } from './routes/workflows'; @@ -124,6 +125,7 @@ export function createApp() { app.route('/', portfolioRoutes); app.route('/', importRoutes); app.route('/', reportRoutes); + app.route('/', taxRoutes); app.route('/', googleRoutes); app.route('/', commsRoutes); app.route('/', workflowRoutes); diff --git a/server/lib/tax-reporting.ts b/server/lib/tax-reporting.ts new file mode 100644 index 0000000..f6e99fd --- /dev/null +++ b/server/lib/tax-reporting.ts @@ -0,0 +1,813 @@ +/** + * Tax reporting engine for ChittyFinance. + * Pure functions — no DB calls, no side effects. + * + * Produces Schedule E (per-property) and Form 1065 (partnership) + * reports from transaction data, with time-weighted K-1 member allocations. + */ + +import { + findAccountCode, + getScheduleELine, + getAccountByCode, + type AccountDefinition, +} from '../../database/chart-of-accounts'; +import type { ReportingTransactionRow } from './consolidated-reporting'; + +// ── IRS Schedule E line labels ── + +const SCHEDULE_E_LINES: Record = { + 'Line 3': 'Rents received', + 'Line 5': 'Advertising', + 'Line 6': 'Auto and travel', + 'Line 7': 'Cleaning and maintenance', + 'Line 8': 'Commissions', + 'Line 9': 'Insurance', + 'Line 10': 'Legal and professional fees', + 'Line 11': 'Management fees', + 'Line 12': 'Mortgage interest paid', + 'Line 13': 'Other interest', + 'Line 14': 'Repairs', + 'Line 15': 'Supplies', + 'Line 16': 'Taxes', + 'Line 17': 'Utilities', + 'Line 18': 'Depreciation expense', + 'Line 19': 'Other', +}; + +// Ordered for output +const SCHEDULE_E_LINE_ORDER = [ + 'Line 3', 'Line 5', 'Line 6', 'Line 7', 'Line 8', 'Line 9', + 'Line 10', 'Line 11', 'Line 12', 'Line 13', 'Line 14', 'Line 15', + 'Line 16', 'Line 17', 'Line 18', 'Line 19', +]; + +// ── K-1 line references (Form 1065 Schedule K) ── + +const K1_LINES: Record = { + ordinary_income: 'Line 1 - Ordinary business income (loss)', + rental_income: 'Line 2 - Net rental real estate income (loss)', + guaranteed_payments: 'Line 4c - Guaranteed payments - other', + interest_income: 'Line 5 - Interest income', + section_179: 'Line 11 - Section 179 deduction', + other_deductions: 'Line 13d - Other deductions', +}; + +// ── Types ── + +export interface MemberOwnership { + name: string; + pct: number; // 0-100 + ein?: string; + startDate?: string; // ISO date — for time-weighted allocation + endDate?: string; // ISO date — for time-weighted allocation +} + +export interface AllocationPeriod { + startDate: string; // ISO YYYY-MM-DD + endDate: string; // ISO YYYY-MM-DD + members: MemberOwnership[]; + dayCount: number; +} + +export interface ScheduleELineItem { + lineNumber: string; + lineLabel: string; + amount: number; + transactionCount: number; +} + +export interface ScheduleEPropertyColumn { + propertyId: string; + propertyName: string; + address: string; + state: string; + filingType: 'schedule-e-personal' | 'schedule-e-partnership'; + tenantId: string; + tenantName: string; + lines: ScheduleELineItem[]; + totalIncome: number; + totalExpenses: number; + netIncome: number; +} + +export interface ScheduleEReport { + taxYear: number; + properties: ScheduleEPropertyColumn[]; + entityLevelItems: ScheduleELineItem[]; + entityLevelTotal: number; + uncategorizedAmount: number; + uncategorizedCount: number; + unmappedCategories: string[]; +} + +export interface K1MemberAllocation { + memberName: string; + pct: number; // effective annual percentage (time-weighted) + ordinaryIncome: number; + rentalIncome: number; + guaranteedPayments: number; + otherDeductions: number; + totalAllocated: number; + periods: Array<{ + startDate: string; + endDate: string; + pct: number; + dayCount: number; + allocatedIncome: number; + }>; +} + +export interface Form1065Report { + taxYear: number; + entityId: string; + entityName: string; + entityType: string; + ordinaryIncome: number; + totalDeductions: number; + netIncome: number; + incomeByCategory: Array<{ category: string; coaCode: string; amount: number }>; + deductionsByCategory: Array<{ category: string; coaCode: string; amount: number; scheduleELine?: string }>; + memberAllocations: K1MemberAllocation[]; + warnings: string[]; +} + +export interface TaxPackage { + taxYear: number; + generatedAt: string; + scheduleE: ScheduleEReport; + form1065: Form1065Report[]; + summary: { + totalIncome: number; + totalExpenses: number; + totalNet: number; + entityCount: number; + propertyCount: number; + transactionCount: number; + }; +} + +export interface PropertyInfo { + id: string; + tenantId: string; + name: string; + address: string; + state: string; +} + +export interface TenantInfo { + id: string; + name: string; + type: string; + metadata: unknown; +} + +// ── Helpers ── + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function amount(value: string | number | null | undefined): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (typeof value === 'string') { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function asObject(input: unknown): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) return {}; + return input as Record; +} + +function daysBetween(start: string, end: string): number { + const s = new Date(start + 'T00:00:00Z'); + const e = new Date(end + 'T00:00:00Z'); + return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86_400_000) + 1); +} + +/** + * Resolve a transaction's free-text category to a Schedule E line. + * Returns the line number string ('Line 3', etc.) and the COA code. + */ +export function resolveScheduleELine( + category: string | null, + description?: string, +): { lineNumber: string; lineLabel: string; coaCode: string } { + const coaCode = findAccountCode(description || '', category || undefined); + const scheduleLine = getScheduleELine(coaCode); + const lineNumber = scheduleLine || 'Line 19'; + const lineLabel = SCHEDULE_E_LINES[lineNumber] || 'Other'; + return { lineNumber, lineLabel, coaCode }; +} + +// ── Schedule E Report Builder ── + +export function buildScheduleEReport(params: { + taxYear: number; + transactions: ReportingTransactionRow[]; + properties: PropertyInfo[]; + tenants: TenantInfo[]; +}): ScheduleEReport { + const { taxYear, transactions, properties, tenants } = params; + + // Build lookup maps + const propertyMap = new Map(properties.map((p) => [p.id, p])); + const tenantMap = new Map(tenants.map((t) => [t.id, t])); + const tenantTypeMap = new Map(tenants.map((t) => [t.id, t.type])); + + // Per-property line accumulators + const propertyLines = new Map>(); + // Entity-level (no propertyId) line accumulators + const entityLines = new Map(); + + let uncategorizedAmount = 0; + let uncategorizedCount = 0; + const unmappedSet = new Set(); + + 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); + + // Track unmapped categories (hit suspense 9010) + if (coaCode === '9010' && tx.category) { + unmappedSet.add(tx.category); + uncategorizedAmount += absAmount; + uncategorizedCount += 1; + } + + const propId = (tx as any).propertyId as string | null; + + if (propId && propertyMap.has(propId)) { + if (!propertyLines.has(propId)) { + propertyLines.set(propId, new Map()); + } + const lines = propertyLines.get(propId)!; + const key = tx.type === 'income' ? 'Line 3' : lineNumber; + const existing = lines.get(key) || { amount: 0, count: 0 }; + existing.amount += tx.type === 'income' ? rawAmount : absAmount; + existing.count += 1; + lines.set(key, existing); + } else { + // Entity-level expense (no property attribution) + const key = tx.type === 'income' ? 'Line 3' : lineNumber; + const existing = entityLines.get(key) || { amount: 0, count: 0 }; + existing.amount += tx.type === 'income' ? rawAmount : absAmount; + existing.count += 1; + entityLines.set(key, existing); + } + } + + // Build property columns + const propertyColumns: ScheduleEPropertyColumn[] = []; + + for (const prop of properties) { + const lines = propertyLines.get(prop.id); + if (!lines) continue; // No transactions for this property + + const tenantType = tenantTypeMap.get(prop.tenantId) || 'unknown'; + const tenant = tenantMap.get(prop.tenantId); + const filingType: 'schedule-e-personal' | 'schedule-e-partnership' = + tenantType === 'personal' ? 'schedule-e-personal' : 'schedule-e-partnership'; + + const lineItems: ScheduleELineItem[] = []; + let totalIncome = 0; + let totalExpenses = 0; + + for (const lineNum of SCHEDULE_E_LINE_ORDER) { + const data = lines.get(lineNum); + if (!data) continue; + + lineItems.push({ + lineNumber: lineNum, + lineLabel: SCHEDULE_E_LINES[lineNum] || 'Other', + amount: round2(data.amount), + transactionCount: data.count, + }); + + if (lineNum === 'Line 3') { + totalIncome += data.amount; + } else { + totalExpenses += data.amount; + } + } + + propertyColumns.push({ + propertyId: prop.id, + propertyName: prop.name, + address: prop.address || '', + state: prop.state || 'UNASSIGNED', + filingType, + tenantId: prop.tenantId, + tenantName: tenant?.name || '', + lines: lineItems, + totalIncome: round2(totalIncome), + totalExpenses: round2(totalExpenses), + netIncome: round2(totalIncome - totalExpenses), + }); + } + + // Sort by net income descending + propertyColumns.sort((a, b) => b.netIncome - a.netIncome); + + // Build entity-level items + const entityLevelItems: ScheduleELineItem[] = []; + let entityLevelTotal = 0; + + for (const lineNum of SCHEDULE_E_LINE_ORDER) { + const data = entityLines.get(lineNum); + if (!data) continue; + entityLevelItems.push({ + lineNumber: lineNum, + lineLabel: SCHEDULE_E_LINES[lineNum] || 'Other', + amount: round2(data.amount), + transactionCount: data.count, + }); + if (lineNum === 'Line 3') { + entityLevelTotal += data.amount; + } else { + entityLevelTotal -= data.amount; + } + } + + return { + taxYear, + properties: propertyColumns, + entityLevelItems, + entityLevelTotal: round2(entityLevelTotal), + uncategorizedAmount: round2(uncategorizedAmount), + uncategorizedCount, + unmappedCategories: Array.from(unmappedSet).sort(), + }; +} + +// ── Time-Weighted Member Allocation ── + +/** + * Build allocation periods from member ownership data. + * Members have optional startDate/endDate to support mid-year changes + * (e.g., Luisa Arias exits Oct 14, 2024). + */ +export function buildAllocationPeriods( + taxYear: number, + members: MemberOwnership[], +): AllocationPeriod[] { + const yearStart = `${taxYear}-01-01`; + const yearEnd = `${taxYear}-12-31`; + const totalDays = daysBetween(yearStart, yearEnd); + + // Collect all boundary dates + const boundaries = new Set([yearStart]); + + for (const m of members) { + if (m.startDate && m.startDate > yearStart && m.startDate <= yearEnd) { + boundaries.add(m.startDate); + } + if (m.endDate && m.endDate >= yearStart && m.endDate < yearEnd) { + // Period ends the day after endDate — next period starts endDate + 1 + const nextDay = new Date(m.endDate + 'T00:00:00Z'); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + const nextDayStr = nextDay.toISOString().slice(0, 10); + if (nextDayStr <= yearEnd) { + boundaries.add(nextDayStr); + } + } + } + + const sortedDates = Array.from(boundaries).sort(); + const periods: AllocationPeriod[] = []; + + for (let i = 0; i < sortedDates.length; i++) { + const periodStart = sortedDates[i]; + const periodEnd = i + 1 < sortedDates.length + ? (() => { + const d = new Date(sortedDates[i + 1] + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() - 1); + return d.toISOString().slice(0, 10); + })() + : yearEnd; + + // Which members are active during this period? + const activeMembers = members.filter((m) => { + const mStart = m.startDate || yearStart; + const mEnd = m.endDate || yearEnd; + return mStart <= periodEnd && mEnd >= periodStart; + }); + + if (activeMembers.length === 0) continue; + + periods.push({ + startDate: periodStart, + endDate: periodEnd, + members: activeMembers, + dayCount: daysBetween(periodStart, periodEnd), + }); + } + + return periods; +} + +/** + * Compute time-weighted K-1 allocations for each member. + */ +export function buildMemberAllocations( + taxYear: number, + netIncome: number, + members: MemberOwnership[], +): K1MemberAllocation[] { + // If no members defined, return 100% to entity + if (members.length === 0) { + return [{ + memberName: '(Entity - no members defined)', + pct: 100, + ordinaryIncome: round2(netIncome), + rentalIncome: 0, + guaranteedPayments: 0, + otherDeductions: 0, + totalAllocated: round2(netIncome), + periods: [], + }]; + } + + // Check if any member has date ranges (time-weighted mode) + const hasDateRanges = members.some((m) => m.startDate || m.endDate); + + if (!hasDateRanges) { + // Simple static allocation + const totalPct = members.reduce((sum, m) => sum + m.pct, 0); + return members.map((m) => { + const effectivePct = totalPct > 0 ? (m.pct / totalPct) * 100 : 0; + const allocated = round2(netIncome * effectivePct / 100); + return { + memberName: m.name, + pct: round2(effectivePct), + ordinaryIncome: allocated, + rentalIncome: 0, + guaranteedPayments: 0, + otherDeductions: 0, + totalAllocated: allocated, + periods: [], + }; + }); + } + + // Time-weighted allocation + const periods = buildAllocationPeriods(taxYear, members); + const yearStart = `${taxYear}-01-01`; + const yearEnd = `${taxYear}-12-31`; + const totalDays = daysBetween(yearStart, yearEnd); + + // Per-member accumulator + const memberTotals = new Map(); + + for (const m of members) { + memberTotals.set(m.name, { allocatedIncome: 0, weightedPct: 0, periods: [] }); + } + + for (const period of periods) { + const periodFraction = period.dayCount / totalDays; + const periodIncome = netIncome * periodFraction; + const periodTotalPct = period.members.reduce((sum, m) => sum + m.pct, 0); + + for (const m of period.members) { + const memberShare = periodTotalPct > 0 ? (m.pct / periodTotalPct) : 0; + const allocated = periodIncome * memberShare; + const entry = memberTotals.get(m.name)!; + entry.allocatedIncome += allocated; + entry.weightedPct += (m.pct / (periodTotalPct || 1)) * 100 * periodFraction; + entry.periods.push({ + startDate: period.startDate, + endDate: period.endDate, + pct: round2(m.pct), + dayCount: period.dayCount, + allocatedIncome: round2(allocated), + }); + } + } + + return members.map((m) => { + const entry = memberTotals.get(m.name)!; + return { + memberName: m.name, + pct: round2(entry.weightedPct), + ordinaryIncome: round2(entry.allocatedIncome), + rentalIncome: 0, + guaranteedPayments: 0, + otherDeductions: 0, + totalAllocated: round2(entry.allocatedIncome), + periods: entry.periods, + }; + }); +} + +// ── Form 1065 Report Builder ── + +export function buildForm1065Report(params: { + taxYear: number; + entityTenants: TenantInfo[]; + transactions: ReportingTransactionRow[]; +}): Form1065Report[] { + const { taxYear, entityTenants, transactions } = params; + + // Only partnership-type entities get 1065s + const partnershipTypes = new Set(['holding', 'series', 'management']); + const partnershipEntities = entityTenants.filter((t) => partnershipTypes.has(t.type)); + + const reports: Form1065Report[] = []; + + for (const entity of partnershipEntities) { + const entityTxs = transactions.filter((tx) => tx.tenantId === entity.id); + if (entityTxs.length === 0) continue; + + const incomeMap = new Map(); + const deductionMap = new Map(); + const warnings: string[] = []; + + let totalIncome = 0; + let totalDeductions = 0; + + 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 acctDef = getAccountByCode(coaCode); + const label = acctDef?.name || tx.category || 'Uncategorized'; + + if (tx.type === 'income') { + const existing = incomeMap.get(label) || { amount: 0, coaCode }; + existing.amount += rawAmount; + incomeMap.set(label, existing); + totalIncome += rawAmount; + } else if (tx.type === 'expense') { + const existing = deductionMap.get(label) || { amount: 0, coaCode, scheduleELine: lineNumber }; + existing.amount += absAmount; + deductionMap.set(label, existing); + totalDeductions += absAmount; + } + } + + const netIncome = totalIncome - totalDeductions; + + // Extract member ownership from tenant metadata + // Check for year-specific members first (e.g. members_2024), then fall back to members + const meta = asObject(entity.metadata); + const yearKey = `members_${taxYear}`; + const rawMembers = Array.isArray(meta[yearKey]) + ? meta[yearKey] as any[] + : Array.isArray(meta.members) ? meta.members : []; + const members: MemberOwnership[] = rawMembers.map((m: any) => ({ + name: String(m.name || 'Unknown'), + pct: typeof m.pct === 'number' ? m.pct : 0, + ein: typeof m.ein === 'string' ? m.ein : undefined, + startDate: typeof m.startDate === 'string' ? m.startDate : undefined, + endDate: typeof m.endDate === 'string' ? m.endDate : undefined, + })); + + // Validate member percentages + if (members.length > 0) { + // Check if any members have date ranges + const hasDateRanges = members.some((m) => m.startDate || m.endDate); + if (!hasDateRanges) { + const totalPct = members.reduce((sum, m) => sum + m.pct, 0); + if (Math.abs(totalPct - 100) > 0.01) { + warnings.push(`Member percentages sum to ${totalPct}%, expected 100%. Allocations will be proportional.`); + } + } + } else { + warnings.push('No members defined in tenant metadata. Showing 100% to entity.'); + } + + const memberAllocations = buildMemberAllocations(taxYear, netIncome, members); + + reports.push({ + taxYear, + entityId: entity.id, + entityName: entity.name, + entityType: entity.type, + ordinaryIncome: round2(totalIncome), + totalDeductions: round2(totalDeductions), + netIncome: round2(netIncome), + incomeByCategory: Array.from(incomeMap.entries()) + .map(([category, data]) => ({ category, coaCode: data.coaCode, amount: round2(data.amount) })) + .sort((a, b) => b.amount - a.amount), + deductionsByCategory: Array.from(deductionMap.entries()) + .map(([category, data]) => ({ + category, + coaCode: data.coaCode, + amount: round2(data.amount), + scheduleELine: data.scheduleELine, + })) + .sort((a, b) => b.amount - a.amount), + memberAllocations, + warnings, + }); + } + + // Sort by net income descending + reports.sort((a, b) => b.netIncome - a.netIncome); + return reports; +} + +// ── Tax Package Builder ── + +export function buildTaxPackage(params: { + taxYear: number; + scheduleE: ScheduleEReport; + form1065: Form1065Report[]; + transactionCount: number; +}): TaxPackage { + const totalIncome = params.scheduleE.properties.reduce((s, p) => s + p.totalIncome, 0) + + params.form1065.reduce((s, r) => s + r.ordinaryIncome, 0); + const totalExpenses = params.scheduleE.properties.reduce((s, p) => s + p.totalExpenses, 0) + + params.form1065.reduce((s, r) => s + r.totalDeductions, 0); + + return { + taxYear: params.taxYear, + generatedAt: new Date().toISOString(), + scheduleE: params.scheduleE, + form1065: params.form1065, + summary: { + totalIncome: round2(totalIncome), + totalExpenses: round2(totalExpenses), + totalNet: round2(totalIncome - totalExpenses), + entityCount: params.form1065.length, + propertyCount: params.scheduleE.properties.length, + transactionCount: params.transactionCount, + }, + }; +} + +// ── CSV Serializers ── + +function csvEscape(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return '"' + value.replace(/"/g, '""') + '"'; + } + return value; +} + +export function serializeScheduleECsv(report: ScheduleEReport): string { + const rows: string[] = []; + + // Header + rows.push(`Schedule E Summary — Tax Year ${report.taxYear}`); + rows.push(''); + + // Column headers: Line | Label | Property1 | Property2 | ... | Total + const propNames = report.properties.map((p) => p.propertyName); + rows.push(['Line', 'Description', ...propNames, 'Total'].map(csvEscape).join(',')); + + // Build a matrix: line -> property -> amount + for (const lineNum of SCHEDULE_E_LINE_ORDER) { + const label = SCHEDULE_E_LINES[lineNum] || 'Other'; + const amounts = report.properties.map((prop) => { + const item = prop.lines.find((l) => l.lineNumber === lineNum); + return item ? item.amount : 0; + }); + const total = amounts.reduce((s, a) => s + a, 0); + + // Only include lines that have data + if (total === 0 && amounts.every((a) => a === 0)) continue; + + rows.push([lineNum, label, ...amounts.map((a) => a.toFixed(2)), total.toFixed(2)].map(String).map(csvEscape).join(',')); + } + + // Totals row + rows.push(''); + rows.push(['', 'Total Income', ...report.properties.map((p) => p.totalIncome.toFixed(2)), + report.properties.reduce((s, p) => s + p.totalIncome, 0).toFixed(2)].map(csvEscape).join(',')); + rows.push(['', 'Total Expenses', ...report.properties.map((p) => p.totalExpenses.toFixed(2)), + report.properties.reduce((s, p) => s + p.totalExpenses, 0).toFixed(2)].map(csvEscape).join(',')); + rows.push(['', 'Net Income', ...report.properties.map((p) => p.netIncome.toFixed(2)), + report.properties.reduce((s, p) => s + p.netIncome, 0).toFixed(2)].map(csvEscape).join(',')); + + // Filing type per property + rows.push(''); + rows.push(['', 'Filing Type', ...report.properties.map((p) => p.filingType)].map(csvEscape).join(',')); + rows.push(['', 'State', ...report.properties.map((p) => p.state)].map(csvEscape).join(',')); + + // Entity-level items + if (report.entityLevelItems.length > 0) { + rows.push(''); + rows.push('Entity-Level Items (no property attribution)'); + rows.push(['Line', 'Description', 'Amount'].join(',')); + for (const item of report.entityLevelItems) { + rows.push([item.lineNumber, item.lineLabel, item.amount.toFixed(2)].map(csvEscape).join(',')); + } + } + + // Warnings + if (report.uncategorizedCount > 0) { + rows.push(''); + rows.push(`WARNING: ${report.uncategorizedCount} transactions ($${report.uncategorizedAmount.toFixed(2)}) unmapped to Schedule E lines`); + if (report.unmappedCategories.length > 0) { + rows.push(`Unmapped categories: ${report.unmappedCategories.join(', ')}`); + } + } + + return '\uFEFF' + rows.join('\r\n'); +} + +export function serializeForm1065Csv(reports: Form1065Report[]): string { + const rows: string[] = []; + + for (const report of reports) { + rows.push(`Form 1065 — ${report.entityName} — Tax Year ${report.taxYear}`); + rows.push(''); + + // Income section + rows.push('INCOME'); + rows.push(['Category', 'COA Code', 'Amount'].join(',')); + for (const item of report.incomeByCategory) { + rows.push([item.category, item.coaCode, item.amount.toFixed(2)].map(csvEscape).join(',')); + } + rows.push(['Total Income', '', report.ordinaryIncome.toFixed(2)].join(',')); + + rows.push(''); + + // Deductions section + rows.push('DEDUCTIONS'); + rows.push(['Category', 'COA Code', 'Sched E Line', 'Amount'].join(',')); + for (const item of report.deductionsByCategory) { + rows.push([item.category, item.coaCode, item.scheduleELine || '', item.amount.toFixed(2)].map(csvEscape).join(',')); + } + rows.push(['Total Deductions', '', '', report.totalDeductions.toFixed(2)].join(',')); + + rows.push(''); + rows.push(['NET INCOME', '', '', report.netIncome.toFixed(2)].join(',')); + + // K-1 allocations + rows.push(''); + rows.push('K-1 MEMBER ALLOCATIONS'); + rows.push(['Member', 'Effective %', 'Ordinary Income', 'Periods'].join(',')); + for (const member of report.memberAllocations) { + const periodDesc = member.periods.length > 0 + ? member.periods.map((p) => `${p.startDate} to ${p.endDate} (${p.pct}%)`).join('; ') + : 'Full year'; + rows.push([member.memberName, member.pct.toFixed(2) + '%', member.totalAllocated.toFixed(2), periodDesc].map(csvEscape).join(',')); + } + + // Warnings + if (report.warnings.length > 0) { + rows.push(''); + for (const w of report.warnings) { + rows.push(`WARNING: ${w}`); + } + } + + rows.push(''); + rows.push('---'); + rows.push(''); + } + + return '\uFEFF' + rows.join('\r\n'); +} + +export function serializeTaxPackageCsv(pkg: TaxPackage): string { + const header = [ + `ChittyFinance Tax Package — Tax Year ${pkg.taxYear}`, + `Generated: ${pkg.generatedAt}`, + `Entities: ${pkg.summary.entityCount} | Properties: ${pkg.summary.propertyCount} | Transactions: ${pkg.summary.transactionCount}`, + `Total Income: $${pkg.summary.totalIncome.toFixed(2)} | Total Expenses: $${pkg.summary.totalExpenses.toFixed(2)} | Net: $${pkg.summary.totalNet.toFixed(2)}`, + '', + '═══════════════════════════════════════════════════', + 'SECTION 1: SCHEDULE E (Per-Property Income & Expenses)', + '═══════════════════════════════════════════════════', + '', + ].join('\r\n'); + + const scheduleE = serializeScheduleECsv(pkg.scheduleE); + const form1065Section = [ + '', + '═══════════════════════════════════════════════════', + 'SECTION 2: FORM 1065 (Partnership Returns & K-1 Allocations)', + '═══════════════════════════════════════════════════', + '', + ].join('\r\n'); + + const form1065 = serializeForm1065Csv(pkg.form1065); + + // Strip BOM from sub-sections since we add it once at the top + return '\uFEFF' + header + scheduleE.replace('\uFEFF', '') + form1065Section + form1065.replace('\uFEFF', ''); +} + +/** + * Strip EIN/sensitive fields from member data for client-side display. + */ +export function sanitizeMembersForClient(allocations: K1MemberAllocation[]): K1MemberAllocation[] { + return allocations.map((a) => ({ ...a })); +} + +export function sanitizeForm1065ForClient(reports: Form1065Report[]): Form1065Report[] { + return reports.map((r) => ({ + ...r, + memberAllocations: sanitizeMembersForClient(r.memberAllocations), + })); +} diff --git a/server/routes/tax.ts b/server/routes/tax.ts new file mode 100644 index 0000000..6dad541 --- /dev/null +++ b/server/routes/tax.ts @@ -0,0 +1,190 @@ +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; +import { + buildScheduleEReport, + buildForm1065Report, + buildTaxPackage, + sanitizeForm1065ForClient, + serializeTaxPackageCsv, + type PropertyInfo, + type TenantInfo, +} from '../lib/tax-reporting'; +import { ledgerLog } from '../lib/ledger-client'; + +export const taxRoutes = new Hono(); + +function parseTaxYear(value: unknown): number { + const year = typeof value === 'string' ? parseInt(value, 10) : NaN; + if (!Number.isFinite(year) || year < 2020 || year > 2099) { + throw new Error('taxYear must be a valid year (2020-2099)'); + } + return year; +} + +function parseBool(value: unknown, defaultValue: boolean): boolean { + if (typeof value !== 'string') return defaultValue; + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes'].includes(normalized)) return true; + if (['0', 'false', 'no'].includes(normalized)) return false; + return defaultValue; +} + +async function loadTaxData( + storage: any, + tenantId: string, + taxYear: number, + includeDescendants: boolean, +) { + let tenantIds = includeDescendants + ? await storage.getTenantDescendantIds(tenantId) + : [tenantId]; + + const startDateIso = `${taxYear}-01-01T00:00:00.000Z`; + const endDateIso = `${taxYear}-12-31T23:59:59.999Z`; + + const [transactions, tenants, properties] = await Promise.all([ + storage.getTransactionsForTenantScope(tenantIds, startDateIso, endDateIso), + storage.getTenantsByIds(tenantIds), + storage.getPropertiesForTenants(tenantIds), + ]); + + const tenantInfos: TenantInfo[] = tenants.map((t: any) => ({ + id: t.id, + name: t.name, + type: t.type, + metadata: t.metadata, + })); + + const propertyInfos: PropertyInfo[] = properties.map((p: any) => ({ + id: p.id, + tenantId: p.tenantId, + name: p.name, + address: [p.address, p.city, p.state].filter(Boolean).join(', '), + state: p.state || 'UNASSIGNED', + })); + + return { tenantIds, transactions, tenantInfos, propertyInfos }; +} + +// GET /api/reports/tax/schedule-e — Per-property Schedule E report +taxRoutes.get('/api/reports/tax/schedule-e', async (c) => { + const storage = c.get('storage'); + const tenantId = c.get('tenantId'); + + try { + const taxYear = parseTaxYear(c.req.query('taxYear')); + const includeDescendants = parseBool(c.req.query('includeDescendants'), true); + + const { transactions, tenantInfos, propertyInfos } = await loadTaxData( + storage, tenantId, taxYear, includeDescendants, + ); + + const report = buildScheduleEReport({ + taxYear, + transactions, + properties: propertyInfos, + tenants: tenantInfos, + }); + + return c.json(report); + } catch (error) { + return c.json({ + error: error instanceof Error ? error.message : 'Failed to generate Schedule E report', + }, 400); + } +}); + +// GET /api/reports/tax/form-1065 — Partnership return data with K-1 allocations +taxRoutes.get('/api/reports/tax/form-1065', async (c) => { + const storage = c.get('storage'); + const tenantId = c.get('tenantId'); + + try { + const taxYear = parseTaxYear(c.req.query('taxYear')); + const includeDescendants = parseBool(c.req.query('includeDescendants'), true); + + const { transactions, tenantInfos } = await loadTaxData( + storage, tenantId, taxYear, includeDescendants, + ); + + const reports = buildForm1065Report({ + taxYear, + entityTenants: tenantInfos, + transactions, + }); + + // Strip sensitive fields for client display + return c.json(sanitizeForm1065ForClient(reports)); + } catch (error) { + return c.json({ + error: error instanceof Error ? error.message : 'Failed to generate Form 1065 report', + }, 400); + } +}); + +// GET /api/reports/tax/export — Full tax package download (CSV or JSON) +taxRoutes.get('/api/reports/tax/export', async (c) => { + const storage = c.get('storage'); + const tenantId = c.get('tenantId'); + + try { + const taxYear = parseTaxYear(c.req.query('taxYear')); + const format = (c.req.query('format') || 'csv').toLowerCase(); + const includeDescendants = parseBool(c.req.query('includeDescendants'), true); + + const { transactions, tenantInfos, propertyInfos } = await loadTaxData( + storage, tenantId, taxYear, includeDescendants, + ); + + const scheduleE = buildScheduleEReport({ + taxYear, + transactions, + properties: propertyInfos, + tenants: tenantInfos, + }); + + const form1065 = buildForm1065Report({ + taxYear, + entityTenants: tenantInfos, + transactions, + }); + + const pkg = buildTaxPackage({ + taxYear, + scheduleE, + form1065, + transactionCount: transactions.length, + }); + + // Audit log + ledgerLog(c, { + entityType: 'audit', + action: 'tax.export', + metadata: { + tenantId, + taxYear, + format, + entityCount: pkg.summary.entityCount, + propertyCount: pkg.summary.propertyCount, + transactionCount: pkg.summary.transactionCount, + }, + }, c.env); + + if (format === 'json') { + return c.json(pkg); + } + + // CSV download + const csv = serializeTaxPackageCsv(pkg); + return new Response(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="tax-package-${taxYear}.csv"`, + }, + }); + } catch (error) { + return c.json({ + error: error instanceof Error ? error.message : 'Failed to export tax package', + }, 400); + } +}); diff --git a/server/storage/system.ts b/server/storage/system.ts index 903e393..d3c5cb5 100755 --- a/server/storage/system.ts +++ b/server/storage/system.ts @@ -649,6 +649,24 @@ export class SystemStorage { .where(inArray(schema.accounts.tenantId, tenantIds)); } + async getPropertiesForTenants(tenantIds: string[]) { + if (tenantIds.length === 0) return []; + return this.db + .select({ + id: schema.properties.id, + tenantId: schema.properties.tenantId, + name: schema.properties.name, + address: schema.properties.address, + city: schema.properties.city, + state: schema.properties.state, + }) + .from(schema.properties) + .where(and( + inArray(schema.properties.tenantId, tenantIds), + eq(schema.properties.isActive, true), + )); + } + async getInternalIntercompanyLinkedTransactionIds( tenantIds: string[], startDateIso: string,