diff --git a/package-lock.json b/package-lock.json
index 4cb6a18f..3a20cbf1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/client-sns": "^3.1145.0",
- "@robosystems/client": "^2.17.0",
+ "@robosystems/client": "^2.19.0",
"@robosystems/core": "^0.15.0",
"@robosystems/report-components": "^0.9.0",
"flowbite": "^3.1",
@@ -2252,9 +2252,9 @@
}
},
"node_modules/@robosystems/client": {
- "version": "2.17.0",
- "resolved": "https://registry.npmjs.org/@robosystems/client/-/client-2.17.0.tgz",
- "integrity": "sha512-PdiW2NHX5QybmqJ4b0/oIfUgKBsM4TUWuwx9MUUyFrkHSBkONME/c9qvzMAJbbRwqx+9hXS4SDj4EruV/cnIwA==",
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/@robosystems/client/-/client-2.19.0.tgz",
+ "integrity": "sha512-nQ2O4y8oVsl842yTv3aESZO2dp3CfwkzmtxYM6Pd6E8xC7C9N5uACatQXpMemhR2vOVyjV5DKCokX1KA6fI0Dw==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
diff --git a/package.json b/package.json
index 3decea25..04d706df 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
},
"dependencies": {
"@aws-sdk/client-sns": "^3.1145.0",
- "@robosystems/client": "^2.17.0",
+ "@robosystems/client": "^2.19.0",
"@robosystems/core": "^0.15.0",
"@robosystems/report-components": "^0.9.0",
"flowbite": "^3.1",
diff --git a/src/app/(app)/ledger/close/__tests__/ConsolidationEntriesPanel.test.tsx b/src/app/(app)/ledger/close/__tests__/ConsolidationEntriesPanel.test.tsx
new file mode 100644
index 00000000..532c586c
--- /dev/null
+++ b/src/app/(app)/ledger/close/__tests__/ConsolidationEntriesPanel.test.tsx
@@ -0,0 +1,200 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockPreview = vi.fn()
+const mockPropose = vi.fn()
+
+vi.mock('@robosystems/core', () => ({
+ clients: {
+ ledger: {
+ previewConsolidationEntries: (...args: any[]) => mockPreview(...args),
+ proposeConsolidationEntries: (...args: any[]) => mockPropose(...args),
+ },
+ },
+ LoadingState: () => (
+
+ Loading
+
+ ),
+}))
+
+vi.mock('flowbite-react', () => ({
+ Badge: ({ children, color }: any) => (
+ {children}
+ ),
+ Button: ({ children, onClick, disabled }: any) => (
+
+ {children}
+
+ ),
+ Card: ({ children }: any) => {children}
,
+ Spinner: () => ,
+ Table: ({ children }: any) => ,
+ TableBody: ({ children }: any) => {children} ,
+ TableCell: ({ children }: any) => {children} ,
+ TableHead: ({ children }: any) => {children} ,
+ TableHeadCell: ({ children }: any) => {children} ,
+ TableRow: ({ children }: any) => {children} ,
+}))
+
+vi.mock('react-icons/hi', () => ({
+ HiExclamationCircle: () => ,
+}))
+
+vi.mock('react-icons/tb', () => ({
+ TbFileInvoice: () => ,
+}))
+
+import ConsolidationEntriesPanel from '../components/ConsolidationEntriesPanel'
+
+const PROPOSAL = {
+ adjustment_kind: 'elimination_investment',
+ adjusts_entity_id: 'ent_harbor',
+ adjusts_entity_name: 'Harbor Café LLC',
+ posting_date: '2026-09-30',
+ memo: 'Eliminate the investment — Harbor Café LLC (2026-09)',
+ basis: "70% of Harbor Café LLC's capital.",
+ lines: [
+ {
+ element_id: 'el_cap',
+ account_name: 'Members Capital',
+ concept: 'rs-gaap:MembersEquity',
+ debit_amount: 700000,
+ credit_amount: 0,
+ },
+ {
+ element_id: 'el_inv',
+ account_name: 'Investment in Subsidiaries',
+ concept: 'rs-gaap:InvestmentInSubsidiaries',
+ debit_amount: 0,
+ credit_amount: 700000,
+ },
+ ],
+}
+
+const PREVIEW = {
+ period: '2026-09',
+ period_start: '2026-09-01',
+ period_end: '2026-09-30',
+ consolidation_entity_id: 'ent_cons',
+ proposals: [PROPOSAL],
+ residuals: [],
+ reconciling_items: [],
+ notes: [],
+ up_to_date: false,
+ closed: false,
+ drafted_entry_ids: [],
+}
+
+describe('ConsolidationEntriesPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockPreview.mockResolvedValue(PREVIEW)
+ })
+
+ it('lists what the month needs, by kind and member', async () => {
+ render( )
+
+ expect(
+ await screen.findByText('Investment elimination — Harbor Café LLC')
+ ).toBeInTheDocument()
+ expect(screen.getByText('$7,000.00')).toBeInTheDocument()
+ expect(mockPreview).toHaveBeenCalledWith('kg1', '2026-09')
+ })
+
+ it('drafts the entries, then reloads the preview and the drafts', async () => {
+ const onDrafted = vi.fn()
+ mockPropose.mockResolvedValue({
+ period: '2026-09',
+ consolidation_entity_id: 'ent_cons',
+ entries: [{ id: 'je_1' }],
+ replaced: 0,
+ })
+ render(
+
+ )
+
+ mockPreview.mockResolvedValue({ ...PREVIEW, drafted_entry_ids: ['je_1'] })
+ fireEvent.click(await screen.findByText('Draft entries'))
+
+ // Once drafted, they are named, and a second run replaces them.
+ expect(
+ await screen.findByText(/1 draft written for this month/)
+ ).toBeInTheDocument()
+ expect(screen.getByText('Draft again')).toBeInTheDocument()
+ expect(mockPropose).toHaveBeenCalledWith('kg1', '2026-09')
+ expect(onDrafted).toHaveBeenCalled()
+ expect(mockPreview).toHaveBeenCalledTimes(2)
+ })
+
+ it('shows a residual as withheld, never as an entry', async () => {
+ mockPreview.mockResolvedValue({
+ ...PREVIEW,
+ proposals: [],
+ residuals: [
+ {
+ entity_id: 'ent_harbor',
+ entity_name: 'Harbor Café LLC',
+ amount: 50000,
+ explanation: 'The parent carries more than its share of capital.',
+ },
+ ],
+ })
+ render( )
+
+ expect(
+ await screen.findByText('Withheld until explained')
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText(/The parent carries more than its share/)
+ ).toBeInTheDocument()
+ // Nothing to draft: the button is there but holds back.
+ expect(screen.getByText('Draft entries').closest('button')).toBeDisabled()
+ })
+
+ it('says a closed month is as posted, with nothing to draft', async () => {
+ mockPreview.mockResolvedValue({
+ ...PREVIEW,
+ proposals: [],
+ up_to_date: true,
+ closed: true,
+ })
+ render( )
+
+ expect(
+ await screen.findByText(/This month is closed here/)
+ ).toBeInTheDocument()
+ expect(screen.queryByText('Draft entries')).not.toBeInTheDocument()
+ })
+
+ it('says when the entity already holds what the month needs', async () => {
+ mockPreview.mockResolvedValue({
+ ...PREVIEW,
+ proposals: [],
+ up_to_date: true,
+ })
+ render( )
+
+ expect(await screen.findByText('Up to date')).toBeInTheDocument()
+ })
+
+ it('never shows the raw error envelope', async () => {
+ mockPreview.mockRejectedValue(
+ new Error(
+ 'Preview consolidation entries failed: {"detail":"This group has no consolidation entity."}'
+ )
+ )
+ render( )
+
+ await waitFor(() =>
+ expect(
+ screen.getByText('This group has no consolidation entity.')
+ ).toBeInTheDocument()
+ )
+ expect(screen.queryByText(/"detail"/)).not.toBeInTheDocument()
+ })
+})
diff --git a/src/app/(app)/ledger/close/__tests__/PeriodClosePanel.test.tsx b/src/app/(app)/ledger/close/__tests__/PeriodClosePanel.test.tsx
index adbba7d3..a7716261 100644
--- a/src/app/(app)/ledger/close/__tests__/PeriodClosePanel.test.tsx
+++ b/src/app/(app)/ledger/close/__tests__/PeriodClosePanel.test.tsx
@@ -95,6 +95,18 @@ vi.mock('next/link', () => ({
default: ({ href, children }: any) => {children} ,
}))
+let mockScopedEntity: { entityRole: string } | null = null
+
+vi.mock('@/lib/entity-scope', () => ({
+ useEntityScope: () => ({ entity: mockScopedEntity }),
+}))
+
+vi.mock('../components/ConsolidationEntriesPanel', () => ({
+ default: ({ period }: any) => (
+ {period}
+ ),
+}))
+
import PeriodClosePanel from '../components/PeriodClosePanel'
const CALENDAR = {
@@ -575,3 +587,45 @@ describe('PeriodClosePanel — close overrides', () => {
expect(screen.queryByText(/to QuickBooks/)).not.toBeInTheDocument()
})
})
+
+describe('PeriodClosePanel — consolidating entries', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetFiscalCalendar.mockResolvedValue(CALENDAR)
+ mockGetPeriodCloseStatus.mockResolvedValue({
+ schedules: [],
+ totalDraft: 0,
+ totalPosted: 0,
+ })
+ mockListPeriodDrafts.mockResolvedValue({
+ draftCount: 0,
+ totalDebit: 0,
+ totalCredit: 0,
+ allBalanced: true,
+ qbPublishCount: 0,
+ localOnlyCount: 0,
+ drafts: [],
+ })
+ })
+
+ it("shows the engine's entries on the consolidation entity's close", async () => {
+ mockScopedEntity = { entityRole: 'consolidation' }
+ render( )
+
+ expect(
+ await screen.findByTestId('consolidation-entries')
+ ).toHaveTextContent('2026-05')
+ mockScopedEntity = null
+ })
+
+ it('leaves them off an operating company', async () => {
+ mockScopedEntity = { entityRole: 'operating' }
+ render( )
+
+ await screen.findByText('Close Period')
+ expect(
+ screen.queryByTestId('consolidation-entries')
+ ).not.toBeInTheDocument()
+ mockScopedEntity = null
+ })
+})
diff --git a/src/app/(app)/ledger/close/components/ConsolidationEntriesPanel.tsx b/src/app/(app)/ledger/close/components/ConsolidationEntriesPanel.tsx
new file mode 100644
index 00000000..7971220f
--- /dev/null
+++ b/src/app/(app)/ledger/close/components/ConsolidationEntriesPanel.tsx
@@ -0,0 +1,255 @@
+'use client'
+
+import { friendlyError } from '@/lib/ledger/errors'
+import type {
+ ConsolidationProposal,
+ PreviewConsolidationEntriesResponse,
+} from '@robosystems/client/types'
+import { clients, LoadingState } from '@robosystems/core'
+import {
+ Badge,
+ Button,
+ Card,
+ Spinner,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeadCell,
+ TableRow,
+} from 'flowbite-react'
+import type { FC } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { HiExclamationCircle } from 'react-icons/hi'
+import { TbFileInvoice } from 'react-icons/tb'
+import { formatCurrencyDollars } from '../utils'
+
+const KIND_LABELS: Record = {
+ elimination_investment: 'Investment elimination',
+ elimination_intercompany: 'Intercompany elimination',
+ elimination_unrealized_profit: 'Unrealized profit',
+ nci_allocation: 'Noncontrolling interest: share of income',
+ nci_distribution: 'Noncontrolling interest: distributions',
+ equity_method_pickup: 'Equity method: share of income',
+ equity_method_distribution: 'Equity method: distributions',
+}
+
+const proposalTotal = (proposal: ConsolidationProposal): number =>
+ proposal.lines.reduce((sum, line) => sum + (line.debit_amount ?? 0), 0)
+
+interface ConsolidationEntriesPanelProps {
+ graphId: string
+ /** The month, as YYYY-MM. */
+ period: string
+ /** Called after drafts are written, so the drafts list reloads. */
+ onDrafted?: () => void
+}
+
+/**
+ * The group's consolidating entries for one month, on the consolidation
+ * entity's close: what the members' books call for, what was withheld and
+ * why, and the act of drafting them. The drafts then sit in the draft review
+ * below, and this entity's close posts them like any other.
+ */
+const ConsolidationEntriesPanel: FC = ({
+ graphId,
+ period,
+ onDrafted,
+}) => {
+ const [preview, setPreview] =
+ useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [isDrafting, setIsDrafting] = useState(false)
+ const seq = useRef(0)
+
+ const loadPreview = useCallback(async () => {
+ const current = ++seq.current
+ try {
+ setIsLoading(true)
+ setError(null)
+ const result = await clients.ledger.previewConsolidationEntries(
+ graphId,
+ period
+ )
+ if (current !== seq.current) return
+ setPreview(result)
+ } catch (err) {
+ if (current !== seq.current) return
+ setPreview(null)
+ setError(
+ friendlyError(
+ err instanceof Error
+ ? err.message
+ : 'Failed to compute the consolidating entries.'
+ ).message
+ )
+ } finally {
+ if (current === seq.current) setIsLoading(false)
+ }
+ }, [graphId, period])
+
+ useEffect(() => {
+ loadPreview()
+ }, [loadPreview])
+
+ const handleDraft = useCallback(async () => {
+ try {
+ setIsDrafting(true)
+ setError(null)
+ await clients.ledger.proposeConsolidationEntries(graphId, period)
+ onDrafted?.()
+ await loadPreview()
+ } catch (err) {
+ setError(
+ friendlyError(
+ err instanceof Error
+ ? err.message
+ : 'Failed to draft the consolidating entries.'
+ ).message
+ )
+ } finally {
+ setIsDrafting(false)
+ }
+ }, [graphId, period, onDrafted, loadPreview])
+
+ const proposals = preview?.proposals ?? []
+ const residuals = preview?.residuals ?? []
+ const reconciling = preview?.reconciling_items ?? []
+ const notes = preview?.notes ?? []
+ const draftedCount = preview?.drafted_entry_ids?.length ?? 0
+
+ return (
+
+
+
+
Consolidating entries
+
+ Computed from the members' books. Drafting writes them here;
+ closing this month posts them.
+
+
+ {preview && !preview.closed && (
+
+ {isDrafting ? (
+
+ ) : (
+
+ )}
+ {draftedCount > 0 ? 'Draft again' : 'Draft entries'}
+
+ )}
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {isLoading && !preview ? (
+
+ ) : !preview ? null : preview.closed ? (
+
+ This month is closed here; its consolidating entries are as posted.
+
+ ) : (
+
+ {proposals.length > 0 ? (
+
+
+
+ Entry
+ Amount
+
+
+
+ {proposals.map((p, i) => (
+
+
+
+ {KIND_LABELS[p.adjustment_kind] ?? p.adjustment_kind}
+ {' — '}
+ {p.adjusts_entity_name}
+
+
+ {p.basis}
+
+
+
+ {formatCurrencyDollars(proposalTotal(p) / 100)}
+
+
+ ))}
+
+
+ ) : (
+ preview.up_to_date && (
+
+
+ Up to date
+
+ This entity already holds what the month needs.
+
+ )
+ )}
+
+ {draftedCount > 0 && (
+
+ {draftedCount} draft{draftedCount === 1 ? '' : 's'} written for
+ this month, listed under Draft review. Draft again after a
+ member's books change to replace them.
+
+ )}
+
+ {residuals.length > 0 && (
+
+
Withheld until explained
+
+ {residuals.map((r) => (
+
+ {r.entity_name}: {formatCurrencyDollars(r.amount / 100)}.{' '}
+ {r.explanation}
+
+ ))}
+
+
+ )}
+
+ {reconciling.length > 0 && (
+
+
+ Intercompany figures that do not agree
+
+
+ {reconciling.map((item) => (
+
+ {item.description} (
+ {formatCurrencyDollars(item.amount / 100)} left in the
+ consolidated statements)
+
+ ))}
+
+
+ )}
+
+ {notes.length > 0 && (
+
+ {notes.map((note) => (
+ {note}
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
+
+export default ConsolidationEntriesPanel
diff --git a/src/app/(app)/ledger/close/components/PeriodClosePanel.tsx b/src/app/(app)/ledger/close/components/PeriodClosePanel.tsx
index 63304ce7..b4bbb7e1 100644
--- a/src/app/(app)/ledger/close/components/PeriodClosePanel.tsx
+++ b/src/app/(app)/ledger/close/components/PeriodClosePanel.tsx
@@ -44,6 +44,7 @@ import {
import { TbFileInvoice } from 'react-icons/tb'
import { formatCurrencyDollars } from '../utils'
import { isStatementBlockType } from './blockview/types'
+import ConsolidationEntriesPanel from './ConsolidationEntriesPanel'
const STATUS_COLORS: Record = {
posted: 'success',
@@ -350,6 +351,12 @@ const PeriodClosePanel: FC = ({
[graphId, selectedPeriod, onEntryCreated, loadCloseStatus, loadDrafts]
)
+ const handleConsolidationDrafted = useCallback(() => {
+ onEntryCreated?.()
+ void loadCloseStatus()
+ void loadDrafts()
+ }, [onEntryCreated, loadCloseStatus, loadDrafts])
+
const handleClosePeriod = useCallback(async () => {
if (!selectedPeriod) return
const period = selectedPeriod
@@ -662,6 +669,15 @@ const PeriodClosePanel: FC = ({
>
)}
+ {/* The consolidation entity's month starts from the engine's entries. */}
+ {scopedEntity?.entityRole === 'consolidation' && selectedPeriod && (
+
+ )}
+
{/* Draft review + close action */}
{drafts && drafts.draftCount > 0 && (