From c52840f503994d5aad912fc814e569ec92ad9014 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Sun, 18 Oct 2026 14:34:16 -0500 Subject: [PATCH] feat: the consolidating entries on the consolidation entity's close On the consolidation entity, the Closing Book's period panel shows the month's consolidating entries as the engine computes them from the members' books, by kind and member, with what was withheld, the intercompany figures that do not agree, and the engine's notes. Draft entries writes them as drafts in the consolidation entity, where the draft review lists them and the month's close posts them; drafting again replaces them. A closed month reads as posted. Moves @robosystems/client to 2.19.0 (typescript-client #277). Claude-Session: https://claude.ai/code/session_01M6TRefZ8MTAxax6pMxer4q --- package-lock.json | 8 +- package.json | 2 +- .../ConsolidationEntriesPanel.test.tsx | 200 ++++++++++++++ .../close/__tests__/PeriodClosePanel.test.tsx | 54 ++++ .../components/ConsolidationEntriesPanel.tsx | 255 ++++++++++++++++++ .../close/components/PeriodClosePanel.tsx | 16 ++ 6 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 src/app/(app)/ledger/close/__tests__/ConsolidationEntriesPanel.test.tsx create mode 100644 src/app/(app)/ledger/close/components/ConsolidationEntriesPanel.tsx 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) => ( + + ), + Card: ({ children }: any) =>
{children}
, + Spinner: () => , + Table: ({ children }: any) => {children}
, + 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 && ( + + )} +
+ + {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 && (