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 && (