diff --git a/src/app/(app)/ledger/close/__tests__/ChangedTransactionsPanel.test.tsx b/src/app/(app)/ledger/close/__tests__/ChangedTransactionsPanel.test.tsx
new file mode 100644
index 0000000..0ff6459
--- /dev/null
+++ b/src/app/(app)/ledger/close/__tests__/ChangedTransactionsPanel.test.tsx
@@ -0,0 +1,491 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockListEventBlocks = vi.fn()
+const mockPreviewReconcilingItem = vi.fn()
+const mockResolveReconcilingItem = vi.fn()
+
+vi.mock('@robosystems/core', () => ({
+ clients: {
+ ledger: {
+ listEventBlocks: (...args: any[]) => mockListEventBlocks(...args),
+ previewReconcilingItem: (...args: any[]) =>
+ mockPreviewReconcilingItem(...args),
+ resolveReconcilingItem: (...args: any[]) =>
+ mockResolveReconcilingItem(...args),
+ },
+ },
+ LoadingState: () => (
+
+ Loading
+
+ ),
+}))
+
+vi.mock('flowbite-react', () => ({
+ Button: ({ children, onClick, disabled }: any) => (
+
+ {children}
+
+ ),
+ Label: ({ children, htmlFor }: 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} ,
+ Textarea: ({ value, onChange, id }: any) => (
+
+ ),
+}))
+
+vi.mock('react-icons/hi', () => ({
+ HiCheckCircle: () => ,
+ HiExclamationCircle: () => ,
+}))
+
+import ChangedTransactionsPanel from '../components/ChangedTransactionsPanel'
+
+const event = (overrides: Record = {}) => ({
+ id: 'evt_invoice',
+ eventType: 'invoice_issued',
+ occurredAt: '2026-07-14T00:00:00Z',
+ externalId: 'qb-inv-1042',
+ externalUrl: null,
+ source: 'quickbooks',
+ amount: 150000,
+ currency: 'USD',
+ description: 'Invoice 1042 to Northwind',
+ ...overrides,
+})
+
+const plan = (overrides: Record = {}) => ({
+ event_id: 'evt_invoice',
+ source: 'quickbooks',
+ event_type: 'invoice_issued',
+ event_status: 'fulfilled',
+ default_disposition: 'restate',
+ default_posting_date: '2026-09-30',
+ closed_periods: [],
+ prior_entries: [
+ {
+ entry_id: 'ent_1',
+ posting_date: '2026-07-14',
+ memo: 'Invoice 1042',
+ total_debit: 120000,
+ total_credit: 120000,
+ },
+ ],
+ accepted_entries: [
+ {
+ entry_id: null,
+ posting_date: '2026-07-14',
+ memo: 'Invoice 1042',
+ total_debit: 150000,
+ total_credit: 150000,
+ },
+ ],
+ delta: [
+ {
+ element_id: 'elem_ar',
+ element_code: '1200',
+ element_name: 'Accounts Receivable',
+ prior_net: 120000,
+ accepted_net: 150000,
+ delta: 30000,
+ },
+ {
+ element_id: 'elem_rev',
+ element_code: '4000',
+ element_name: 'Service Revenue',
+ prior_net: -120000,
+ accepted_net: -150000,
+ delta: -30000,
+ },
+ ],
+ no_gl_effect: false,
+ restate_blockers: [],
+ unmapped_element_external_ids: [],
+ ...overrides,
+})
+
+const resolved = (overrides: Record = {}) => ({
+ event_id: 'evt_invoice',
+ disposition: 'restate',
+ regenerated: { entry_ids: ['ent_2'] },
+ resolved_at: '2026-10-03T12:00:00Z',
+ resolved_by: 'user_1',
+ ...overrides,
+})
+
+const refusal = (detail: string) => new Error(JSON.stringify({ detail }))
+
+async function openFirst() {
+ render( )
+ fireEvent.click(await screen.findByRole('button', { name: 'Review' }))
+ await screen.findByText('Difference by account')
+}
+
+describe('ChangedTransactionsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockListEventBlocks.mockResolvedValue([event()])
+ mockPreviewReconcilingItem.mockResolvedValue(plan())
+ mockResolveReconcilingItem.mockResolvedValue(resolved())
+ })
+
+ it('asks only for the transactions changed at the source', async () => {
+ render( )
+
+ await screen.findByText('qb-inv-1042')
+ expect(mockListEventBlocks).toHaveBeenCalledWith(
+ 'kg_books',
+ expect.objectContaining({ isReconcilingItem: true })
+ )
+ expect(screen.getByText('Invoice 1042 to Northwind')).toBeInTheDocument()
+ expect(screen.getByText('invoice issued')).toBeInTheDocument()
+ expect(screen.getByText('$1,500.00')).toBeInTheDocument()
+ })
+
+ it('names where each change came from, and links to it there', async () => {
+ mockListEventBlocks.mockResolvedValue([
+ event({ externalUrl: 'https://qbo.example/txn/1042' }),
+ event({
+ id: 'evt_bank',
+ source: 'plaid',
+ externalId: 'bank-line-7',
+ occurredAt: '2026-06-02T00:00:00Z',
+ }),
+ ])
+ mockPreviewReconcilingItem.mockResolvedValue(
+ plan({ event_id: 'evt_bank', source: 'plaid' })
+ )
+ render( )
+
+ expect(await screen.findByText('QuickBooks')).toBeInTheDocument()
+ expect(screen.getByText('Plaid')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'qb-inv-1042' })).toHaveAttribute(
+ 'href',
+ 'https://qbo.example/txn/1042'
+ )
+ // No link where the source gave none.
+ expect(
+ screen.queryByRole('link', { name: 'bank-line-7' })
+ ).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Review' })[1])
+ expect(await screen.findByText('Plaid now')).toBeInTheDocument()
+ })
+
+ it('names a transaction by its type when it has no description', async () => {
+ mockListEventBlocks.mockResolvedValue([event({ description: null })])
+ render( )
+
+ expect(await screen.findAllByText('invoice issued')).toHaveLength(1)
+ })
+
+ it('says so when nothing is waiting', async () => {
+ mockListEventBlocks.mockResolvedValue([])
+ render( )
+
+ expect(
+ await screen.findByText('Nothing is waiting to be settled.')
+ ).toBeInTheDocument()
+ })
+
+ it('does not claim nothing is waiting when the list failed to load', async () => {
+ mockListEventBlocks.mockRejectedValue(new Error('boom'))
+ render( )
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Failed to load changed transactions.'
+ )
+ expect(
+ screen.queryByText('Nothing is waiting to be settled.')
+ ).not.toBeInTheDocument()
+ })
+
+ it('shows what was posted, what the source says now, and the difference', async () => {
+ await openFirst()
+
+ expect(mockPreviewReconcilingItem).toHaveBeenCalledWith(
+ 'kg_books',
+ 'evt_invoice'
+ )
+ expect(screen.getByText('Accounts Receivable')).toBeInTheDocument()
+ expect(screen.getByText('$1,200.00 Dr')).toBeInTheDocument()
+ expect(screen.getByText('$1,500.00 Dr')).toBeInTheDocument()
+ expect(screen.getByText('$300.00 Dr')).toBeInTheDocument()
+ expect(screen.getByText('$300.00 Cr')).toBeInTheDocument()
+ // Looking at a change settles nothing.
+ expect(mockResolveReconcilingItem).not.toHaveBeenCalled()
+ })
+
+ it('starts on the treatment the plan recommends', async () => {
+ mockPreviewReconcilingItem.mockResolvedValue(
+ plan({ default_disposition: 'catch_up' })
+ )
+ await openFirst()
+
+ expect(screen.getByRole('radio', { name: /^Catch up/ })).toBeChecked()
+ expect(screen.getByRole('radio', { name: /^Restate/ })).not.toBeChecked()
+ })
+
+ it('settles with the chosen treatment and takes the row off the list', async () => {
+ await openFirst()
+ fireEvent.click(screen.getByRole('button', { name: 'Settle' }))
+
+ await waitFor(() =>
+ expect(mockResolveReconcilingItem).toHaveBeenCalledWith('kg_books', {
+ event_id: 'evt_invoice',
+ disposition: 'restate',
+ })
+ )
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'Restated. 1 entry was rebuilt to match the source.'
+ )
+ expect(screen.queryByText('qb-inv-1042')).not.toBeInTheDocument()
+ })
+
+ it('reports a catch-up as drafted, with its date', async () => {
+ mockResolveReconcilingItem.mockResolvedValue(
+ resolved({
+ disposition: 'catch_up',
+ regenerated: null,
+ catch_up: {
+ event_id: 'evt_catch_up',
+ posting_date: '2026-09-30',
+ status: 'draft',
+ },
+ })
+ )
+ await openFirst()
+ fireEvent.click(screen.getByRole('radio', { name: /^Catch up/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Settle' }))
+
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ /A catch-up entry was drafted for .*2026/
+ )
+ expect(mockResolveReconcilingItem).toHaveBeenCalledWith('kg_books', {
+ event_id: 'evt_invoice',
+ disposition: 'catch_up',
+ })
+ })
+
+ it('will not offer a restate the ledger would refuse', async () => {
+ mockPreviewReconcilingItem.mockResolvedValue(
+ plan({
+ default_disposition: 'catch_up',
+ closed_periods: ['2026-07'],
+ restate_blockers: [
+ 'posted in closed period(s) 2026-07 — reopen first, or catch up',
+ ],
+ })
+ )
+ await openFirst()
+
+ expect(screen.getByRole('radio', { name: /^Restate/ })).toBeDisabled()
+ expect(
+ screen.getByText(/Not available: posted in closed period\(s\) 2026-07/)
+ ).toBeInTheDocument()
+ expect(screen.getByRole('radio', { name: /^Catch up/ })).toBeChecked()
+ expect(screen.getByRole('button', { name: 'Settle' })).toBeEnabled()
+ })
+
+ it('leaves only "mark as handled" when the source uses an unmapped account', async () => {
+ mockPreviewReconcilingItem.mockResolvedValue(
+ plan({
+ default_disposition: 'catch_up',
+ unmapped_element_external_ids: ['qb-acct-88'],
+ })
+ )
+ await openFirst()
+
+ expect(screen.getByRole('radio', { name: /^Restate/ })).toBeDisabled()
+ expect(screen.getByRole('radio', { name: /^Catch up/ })).toBeDisabled()
+ expect(
+ screen.getByRole('radio', { name: /^Mark as handled/ })
+ ).toBeEnabled()
+ // The recommended treatment is one that cannot run, so nothing settles
+ // until another is chosen.
+ expect(screen.getByRole('button', { name: 'Settle' })).toBeDisabled()
+ })
+
+ it('requires a note to mark a change as handled, and sends it', async () => {
+ mockResolveReconcilingItem.mockResolvedValue(
+ resolved({ disposition: 'acknowledge', regenerated: null })
+ )
+ await openFirst()
+ fireEvent.click(screen.getByRole('radio', { name: /^Mark as handled/ }))
+
+ const settle = screen.getByRole('button', { name: 'Settle' })
+ expect(settle).toBeDisabled()
+
+ fireEvent.change(screen.getByLabelText(/Note/), {
+ target: { value: ' Booked by JE-88 in September ' },
+ })
+ expect(settle).toBeEnabled()
+ fireEvent.click(settle)
+
+ await waitFor(() =>
+ expect(mockResolveReconcilingItem).toHaveBeenCalledWith('kg_books', {
+ event_id: 'evt_invoice',
+ disposition: 'acknowledge',
+ note: 'Booked by JE-88 in September',
+ })
+ )
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'Marked as handled. Nothing was posted.'
+ )
+ })
+
+ it('holds the other rows still while one is being settled', async () => {
+ mockListEventBlocks.mockResolvedValue([
+ event(),
+ event({ id: 'evt_bill', externalId: 'qb-bill-7' }),
+ ])
+ let finish: (value: unknown) => void = () => {}
+ mockResolveReconcilingItem.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ render( )
+ fireEvent.click(
+ (await screen.findAllByRole('button', { name: 'Review' }))[0]
+ )
+ await screen.findByText('Difference by account')
+ fireEvent.click(screen.getByRole('button', { name: 'Settle' }))
+
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Review' })).toBeDisabled()
+ )
+ expect(screen.getByRole('button', { name: 'Hide' })).toBeDisabled()
+
+ finish(resolved())
+ expect(await screen.findByRole('status')).toHaveTextContent('Restated.')
+ expect(screen.getByRole('button', { name: 'Review' })).toBeEnabled()
+ })
+
+ it('says a wording-only change moves no figures', async () => {
+ mockPreviewReconcilingItem.mockResolvedValue(
+ plan({ delta: [], no_gl_effect: true })
+ )
+ await openFirst()
+
+ expect(screen.getByText(/No account's balance changes/)).toBeInTheDocument()
+ expect(screen.getByText(/No figures change\./)).toBeInTheDocument()
+ expect(
+ screen.getByText(/This change moves no money, so nothing posts\./)
+ ).toBeInTheDocument()
+ })
+
+ it('keeps the change open and shows the refusal when settling fails', async () => {
+ mockResolveReconcilingItem.mockRejectedValue(
+ refusal('Event evt_invoice is being written by another process.')
+ )
+ await openFirst()
+ fireEvent.click(screen.getByRole('button', { name: 'Settle' }))
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Event evt_invoice is being written by another process.'
+ )
+ expect(screen.getByText('qb-inv-1042')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Settle' })).toBeEnabled()
+ })
+
+ it('drops a change someone else already settled', async () => {
+ mockResolveReconcilingItem.mockRejectedValue(
+ refusal(
+ "Event evt_invoice is not a reconciling item — its payload matches the source system. It was already resolved as 'restate'."
+ )
+ )
+ await openFirst()
+ fireEvent.click(screen.getByRole('button', { name: 'Settle' }))
+
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'That change was already settled. Nothing was done.'
+ )
+ expect(screen.queryByText('qb-inv-1042')).not.toBeInTheDocument()
+ })
+
+ it('shows the newer figures when the source changed again mid-review', async () => {
+ mockResolveReconcilingItem.mockRejectedValue(
+ refusal(
+ 'Event evt_invoice was re-flagged with a newer payload while this resolution was being prepared. Preview it again.'
+ )
+ )
+ await openFirst()
+ mockPreviewReconcilingItem.mockResolvedValue(
+ plan({
+ delta: [
+ {
+ element_id: 'elem_ar',
+ element_name: 'Accounts Receivable',
+ prior_net: 120000,
+ accepted_net: 175000,
+ delta: 55000,
+ },
+ ],
+ })
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Settle' }))
+
+ expect(await screen.findByText('$550.00 Dr')).toBeInTheDocument()
+ expect(
+ screen.getByText(/This transaction changed again at the source/)
+ ).toBeInTheDocument()
+ expect(mockPreviewReconcilingItem).toHaveBeenCalledTimes(2)
+ expect(screen.getByText('qb-inv-1042')).toBeInTheDocument()
+ })
+
+ it('ignores a preview that lands after another change was opened', async () => {
+ mockListEventBlocks.mockResolvedValue([
+ event(),
+ event({
+ id: 'evt_bill',
+ externalId: 'qb-bill-7',
+ occurredAt: '2026-06-02T00:00:00Z',
+ }),
+ ])
+ let releaseFirst: (value: unknown) => void = () => {}
+ mockPreviewReconcilingItem.mockImplementation((_graph, eventId) =>
+ eventId === 'evt_invoice'
+ ? new Promise((resolve) => {
+ releaseFirst = resolve
+ })
+ : Promise.resolve(
+ plan({
+ event_id: 'evt_bill',
+ delta: [
+ {
+ element_id: 'elem_ap',
+ element_name: 'Accounts Payable',
+ prior_net: -5000,
+ accepted_net: -9000,
+ delta: -4000,
+ },
+ ],
+ })
+ )
+ )
+ render( )
+
+ const [first, second] = await screen.findAllByRole('button', {
+ name: 'Review',
+ })
+ fireEvent.click(first)
+ fireEvent.click(second)
+ expect(await screen.findByText('Accounts Payable')).toBeInTheDocument()
+
+ releaseFirst(plan())
+ await waitFor(() =>
+ expect(screen.queryByText('Accounts Receivable')).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 28ee88a..f32fb4a 100644
--- a/src/app/(app)/ledger/close/__tests__/PeriodClosePanel.test.tsx
+++ b/src/app/(app)/ledger/close/__tests__/PeriodClosePanel.test.tsx
@@ -312,6 +312,39 @@ describe('PeriodClosePanel — blockers name what is holding the close', () => {
expect(screen.queryByText('reconciling_items')).not.toBeInTheDocument()
})
+ it('leads from the reconciling-item blocker to the changes themselves', async () => {
+ mockGetFiscalCalendar.mockResolvedValue({
+ ...CALENDAR,
+ closeableNow: false,
+ blockers: ['reconciling_items'],
+ reconcilingItemCount: 3,
+ })
+ const onReviewChanges = vi.fn()
+ render( )
+
+ const link = await screen.findByRole('button', {
+ name: 'Review changed transactions',
+ })
+ expect(link.parentElement).toHaveTextContent('3 holding this close.')
+ fireEvent.click(link)
+ expect(onReviewChanges).toHaveBeenCalledTimes(1)
+ })
+
+ it('offers no review link where nothing can open it', async () => {
+ mockGetFiscalCalendar.mockResolvedValue({
+ ...CALENDAR,
+ closeableNow: false,
+ blockers: ['reconciling_items'],
+ reconcilingItemCount: 3,
+ })
+ render( )
+
+ await screen.findByText(/edited in QuickBooks after they were synced/)
+ expect(
+ screen.queryByRole('button', { name: 'Review changed transactions' })
+ ).not.toBeInTheDocument()
+ })
+
it('offers Reopen only on the latest closed period', async () => {
mockGetFiscalCalendar.mockResolvedValue(CALENDAR_AFTER)
render( )
diff --git a/src/app/(app)/ledger/close/__tests__/StructureSidebar.test.tsx b/src/app/(app)/ledger/close/__tests__/StructureSidebar.test.tsx
index f6f42e1..d783a2a 100644
--- a/src/app/(app)/ledger/close/__tests__/StructureSidebar.test.tsx
+++ b/src/app/(app)/ledger/close/__tests__/StructureSidebar.test.tsx
@@ -234,6 +234,52 @@ describe('StructureSidebar', () => {
})
})
+ it('selects and highlights the changed transactions entry', () => {
+ const onSelect = vi.fn()
+ const categories: ClosingBookCategory[] = [
+ {
+ label: 'Reconciliations',
+ items: [
+ {
+ id: 'reconciliations',
+ name: 'Reconciliations',
+ itemType: 'reconciliations',
+ blockType: null,
+ reportId: null,
+ status: null,
+ },
+ {
+ id: 'changed_transactions',
+ name: 'Changed transactions',
+ itemType: 'changed_transactions',
+ blockType: null,
+ reportId: null,
+ status: null,
+ },
+ ],
+ },
+ ]
+ const selected: SelectedItem = { type: 'changed_transactions' }
+ render(
+
+ )
+
+ const entry = screen.getByText('Changed transactions').closest('button')
+ expect(entry?.className).toContain('font-medium')
+ // The worklist beside it is a different selection.
+ expect(
+ screen.getByRole('button', { name: 'Reconciliations' }).className
+ ).not.toContain('font-medium')
+
+ fireEvent.click(entry!)
+ expect(onSelect).toHaveBeenCalledWith({ type: 'changed_transactions' })
+ })
+
it('collapses and expands the sidebar', () => {
render(
({
{categories?.map((c: any) => (
{c.label}: {c.items.length} items
+ {c.items.map((item: any) => (
+
+ ))}
))}
{selectedItem && {selectedItem.type}
}
@@ -121,7 +124,17 @@ vi.mock('../components/AccountRollupsPanel', () => ({
}))
vi.mock('../components/PeriodClosePanel', () => ({
- default: () =>
,
+ default: ({ onReviewChanges }: any) => (
+
+ review changes
+
+ ),
+}))
+
+vi.mock('../components/ChangedTransactionsPanel', () => ({
+ default: ({ graphId }: any) => (
+ {graphId}
+ ),
}))
vi.mock('../components/TrialBalancePanel', () => ({
@@ -340,6 +353,94 @@ describe('CloseContent', () => {
expect(screen.queryByTestId('view-mode-toggle')).not.toBeInTheDocument()
})
+ it('lists the changed transactions beside the reconciliations worklist', async () => {
+ const graph = makeGraph('kg_test')
+ mockUseGraphContext.mockReturnValue(makeGraphState([graph], 'kg_test'))
+ mockGetClosingBookStructures.mockResolvedValue({
+ categories: [
+ {
+ label: 'Period Close',
+ items: [
+ { id: 'pc', name: 'Current Period', itemType: 'period_close' },
+ ],
+ },
+ {
+ label: 'Reconciliations',
+ items: [
+ {
+ id: 'reconciliations',
+ name: 'Reconciliations',
+ itemType: 'reconciliations',
+ },
+ ],
+ },
+ ],
+ has_data: true,
+ })
+ mockGetEntity.mockResolvedValue(null)
+
+ render( )
+
+ expect(
+ await screen.findByText('Reconciliations: 2 items')
+ ).toBeInTheDocument()
+ expect(screen.getByTestId('item-changed_transactions')).toBeInTheDocument()
+ // Only that category gains the entry.
+ expect(screen.getByText('Period Close: 1 items')).toBeInTheDocument()
+ })
+
+ it('adds no changed transactions entry before anything is posted', async () => {
+ const graph = makeGraph('kg_test')
+ mockUseGraphContext.mockReturnValue(makeGraphState([graph], 'kg_test'))
+ mockGetClosingBookStructures.mockResolvedValue({
+ categories: [
+ {
+ label: 'Period Close',
+ items: [
+ { id: 'pc', name: 'Current Period', itemType: 'period_close' },
+ ],
+ },
+ ],
+ has_data: false,
+ })
+ mockGetEntity.mockResolvedValue(null)
+
+ render( )
+
+ await screen.findByTestId('period-close-panel')
+ expect(
+ screen.queryByTestId('item-changed_transactions')
+ ).not.toBeInTheDocument()
+ })
+
+ it('opens the changed transactions from the close hub', async () => {
+ const graph = makeGraph('kg_test')
+ mockUseGraphContext.mockReturnValue(makeGraphState([graph], 'kg_test'))
+ mockGetClosingBookStructures.mockResolvedValue({
+ categories: [
+ {
+ label: 'Period Close',
+ items: [
+ { id: 'pc', name: 'Current Period', itemType: 'period_close' },
+ ],
+ },
+ ],
+ has_data: true,
+ })
+ mockGetEntity.mockResolvedValue(null)
+
+ render( )
+ fireEvent.click(await screen.findByText('review changes'))
+
+ expect(screen.getByTestId('changed-transactions-panel')).toHaveTextContent(
+ 'kg_test'
+ )
+ expect(screen.getByTestId('selected')).toHaveTextContent(
+ 'changed_transactions'
+ )
+ expect(screen.queryByTestId('period-close-panel')).not.toBeInTheDocument()
+ })
+
it('calls API with correct graph ID', async () => {
const graph = makeGraph('kg_mygraph123')
mockUseGraphContext.mockReturnValue(
diff --git a/src/app/(app)/ledger/close/components/ChangedTransactionsPanel.tsx b/src/app/(app)/ledger/close/components/ChangedTransactionsPanel.tsx
new file mode 100644
index 0000000..38d25f8
--- /dev/null
+++ b/src/app/(app)/ledger/close/components/ChangedTransactionsPanel.tsx
@@ -0,0 +1,642 @@
+'use client'
+
+import DocsLink from '@/components/DocsLink'
+import { extractDetail } from '@/lib/ledger/errors'
+import { formatAmount, formatDate } from '@/lib/ledger/formatters'
+import type {
+ ReconcilingItemPlan,
+ ResolveReconcilingItemResponse,
+} from '@robosystems/client'
+import type { LedgerEventBlock } from '@robosystems/client/clients'
+import { clients, LoadingState } from '@robosystems/core'
+import {
+ Button,
+ Label,
+ Spinner,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeadCell,
+ TableRow,
+ Textarea,
+} from 'flowbite-react'
+import type { FC } from 'react'
+import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
+import { HiCheckCircle, HiExclamationCircle } from 'react-icons/hi'
+
+type Treatment = 'restate' | 'catch_up' | 'acknowledge'
+
+const CHANGES_LIMIT = 200
+
+// A sync from the books or an amended bank line can both flag a change.
+const SOURCE_NAMES: Record = {
+ quickbooks: 'QuickBooks',
+ plaid: 'Plaid',
+ mercury: 'Mercury',
+}
+
+function sourceName(source: string | null | undefined): string {
+ return (source && SOURCE_NAMES[source]) || 'The source'
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────
+
+/** The API's own words for a refusal, or a fallback when there are none. */
+const describeError = (err: unknown, fallback: string): string =>
+ err instanceof Error ? extractDetail(err.message) : fallback
+
+/** A signed amount in minor units, debit-positive, as the API reports it. */
+function netAmount(cents: number, currency: string | null | undefined) {
+ if (cents === 0) return formatAmount(0, currency)
+ return `${formatAmount(Math.abs(cents), currency)} ${cents > 0 ? 'Dr' : 'Cr'}`
+}
+
+function describeOutcome(result: ResolveReconcilingItemResponse): string {
+ if (result.disposition === 'restate') {
+ const rebuilt = result.regenerated?.entry_ids?.length ?? 0
+ return `Restated. ${rebuilt} ${rebuilt === 1 ? 'entry was' : 'entries were'} rebuilt to match the source.`
+ }
+ if (result.disposition === 'catch_up') {
+ if (!result.catch_up) {
+ return 'Settled. The change moved no money, so nothing was posted.'
+ }
+ const verb = result.catch_up.status === 'posted' ? 'posted' : 'drafted'
+ return `Settled. A catch-up entry was ${verb} for ${formatDate(result.catch_up.posting_date)}.`
+ }
+ return 'Marked as handled. Nothing was posted.'
+}
+
+/** Why a treatment cannot be chosen for this change, or null when it can. */
+function blockedReason(
+ treatment: Treatment,
+ plan: ReconcilingItemPlan
+): string | null {
+ const unmapped = plan.unmapped_element_external_ids ?? []
+ if (treatment !== 'acknowledge' && unmapped.length > 0) {
+ return 'The source now uses an account this ledger has not mapped. Sync and map it first.'
+ }
+ if (treatment === 'restate') {
+ const blockers = plan.restate_blockers ?? []
+ if (blockers.length > 0) return `${blockers.join('; ')}.`
+ }
+ return null
+}
+
+// ── Component ──────────────────────────────────────────────────────────
+
+interface ChangedTransactionsPanelProps {
+ graphId: string
+}
+
+/**
+ * Transactions edited in the source system after they were posted. Each one
+ * is shown as posted against as it stands now, and settled one of three ways.
+ */
+const ChangedTransactionsPanel: FC = ({
+ graphId,
+}) => {
+ const [items, setItems] = useState(null)
+ const [error, setError] = useState(null)
+ const [truncated, setTruncated] = useState(false)
+
+ const [selectedId, setSelectedId] = useState(null)
+ const [plan, setPlan] = useState(null)
+ const [planError, setPlanError] = useState(null)
+
+ const [treatment, setTreatment] = useState('restate')
+ const [note, setNote] = useState('')
+ const [isSettling, setIsSettling] = useState(false)
+ const [settleError, setSettleError] = useState(null)
+ const [notice, setNotice] = useState(null)
+ const [outcome, setOutcome] = useState(null)
+
+ // Sequence guards: a preview that lands after another row was opened, and
+ // anything that lands after the graph changed, is dropped.
+ const planSeq = useRef(0)
+ const graphSeq = useRef(0)
+
+ useEffect(() => {
+ let cancelled = false
+ planSeq.current += 1
+ graphSeq.current += 1
+ setItems(null)
+ setTruncated(false)
+ setSelectedId(null)
+ setPlan(null)
+ setOutcome(null)
+ setError(null)
+ void (async () => {
+ try {
+ const list = await clients.ledger.listEventBlocks(graphId, {
+ isReconcilingItem: true,
+ limit: CHANGES_LIMIT,
+ })
+ if (cancelled) return
+ list.sort(
+ (a, b) =>
+ new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime()
+ )
+ setItems(list)
+ setTruncated(list.length >= CHANGES_LIMIT)
+ } catch (err) {
+ if (cancelled) return
+ console.error('Error loading changed transactions:', err)
+ setError('Failed to load changed transactions.')
+ setItems([])
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [graphId])
+
+ const openItem = useCallback(
+ async (eventId: string) => {
+ const seq = ++planSeq.current
+ setSelectedId(eventId)
+ setPlan(null)
+ setPlanError(null)
+ setSettleError(null)
+ setNotice(null)
+ setNote('')
+ try {
+ const result = await clients.ledger.previewReconcilingItem(
+ graphId,
+ eventId
+ )
+ if (seq !== planSeq.current) return
+ setPlan(result)
+ setTreatment(result.default_disposition)
+ } catch (err) {
+ if (seq !== planSeq.current) return
+ console.error('Error previewing changed transaction:', err)
+ setPlanError(describeError(err, 'Failed to load this change.'))
+ }
+ },
+ [graphId]
+ )
+
+ const closeItem = useCallback(() => {
+ planSeq.current += 1
+ setSelectedId(null)
+ setPlan(null)
+ setPlanError(null)
+ setSettleError(null)
+ setNotice(null)
+ }, [])
+
+ const handleSettle = useCallback(async () => {
+ if (!selectedId || !plan) return
+ const trimmed = note.trim()
+ const seq = graphSeq.current
+ try {
+ setIsSettling(true)
+ setSettleError(null)
+ const result = await clients.ledger.resolveReconcilingItem(graphId, {
+ event_id: selectedId,
+ disposition: treatment,
+ ...(trimmed ? { note: trimmed } : {}),
+ })
+ if (seq !== graphSeq.current) return
+ setOutcome(describeOutcome(result))
+ setItems((current) =>
+ current ? current.filter((item) => item.id !== selectedId) : current
+ )
+ closeItem()
+ } catch (err) {
+ console.error('Error settling changed transaction:', err)
+ if (seq !== graphSeq.current) return
+ const detail = describeError(err, 'The change was not settled.')
+ if (detail.includes('not a reconciling item')) {
+ // Settled from somewhere else since the list loaded.
+ setItems((current) =>
+ current ? current.filter((item) => item.id !== selectedId) : current
+ )
+ closeItem()
+ setOutcome('That change was already settled. Nothing was done.')
+ } else if (detail.includes('re-flagged')) {
+ // The source changed again; what was on screen is no longer the plan.
+ await openItem(selectedId)
+ setNotice(
+ 'This transaction changed again at the source while it was open. These are the latest figures; nothing was settled.'
+ )
+ } else {
+ setSettleError(detail)
+ }
+ } finally {
+ setIsSettling(false)
+ }
+ }, [graphId, selectedId, plan, treatment, note, closeItem, openItem])
+
+ const selected = items?.find((item) => item.id === selectedId) ?? null
+
+ return (
+
+
+
+ Changed transactions
+
+
+ Transactions that changed in QuickBooks or at the bank after
+ RoboLedger recorded them. A close will not run over one nobody has
+ settled.
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {outcome && (
+
+
+ {outcome}
+
+ )}
+
+ {items === null ? (
+
+ ) : items.length === 0 ? (
+ !error && (
+
+
+ Nothing is waiting to be settled.
+
+
+ When a transaction changes in QuickBooks or at the bank after
+ RoboLedger recorded it, the next sync flags it here instead of
+ overwriting what was posted.
+
+
+ )
+ ) : (
+
+
+
+ Date
+ Transaction
+ Changed in
+ Reference
+ Amount
+
+ Actions
+
+
+
+
+ {items.map((item) => {
+ const isOpen = item.id === selectedId
+ return (
+
+
+
+ {formatDate(item.occurredAt)}
+
+
+
+ {item.description ?? item.eventType.replace(/_/g, ' ')}
+
+ {item.description ? (
+
+ {item.eventType.replace(/_/g, ' ')}
+
+ ) : null}
+
+
+ {sourceName(item.source)}
+
+
+ {item.externalUrl && item.externalId ? (
+
+ {item.externalId}
+
+ ) : (
+ (item.externalId ?? '—')
+ )}
+
+
+ {formatAmount(item.amount ?? null, item.currency)}
+
+
+
+ isOpen ? closeItem() : openItem(item.id)
+ }
+ >
+ {isOpen ? 'Hide' : 'Review'}
+
+
+
+ {isOpen && (
+
+
+ {planError ? (
+
+ {planError}
+
+ ) : !plan ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ )
+ })}
+
+
+ )}
+
+ {truncated && (
+
+ Showing the {CHANGES_LIMIT} most recent. Settle these to see the rest.
+
+ )}
+
+ )
+}
+
+// ── Detail ─────────────────────────────────────────────────────────────
+
+interface ChangeDetailProps {
+ plan: ReconcilingItemPlan
+ currency: string | null | undefined
+ treatment: Treatment
+ onTreatment: (treatment: Treatment) => void
+ note: string
+ onNote: (note: string) => void
+ isSettling: boolean
+ settleError: string | null
+ notice: string | null
+ onSettle: () => void
+}
+
+const ChangeDetail: FC = ({
+ plan,
+ currency,
+ treatment,
+ onTreatment,
+ note,
+ onNote,
+ isSettling,
+ settleError,
+ notice,
+ onSettle,
+}) => {
+ const delta = plan.delta ?? []
+ const closedPeriods = plan.closed_periods ?? []
+ const catchUpDate = plan.default_posting_date
+ ? formatDate(plan.default_posting_date)
+ : null
+
+ const options: { value: Treatment; label: string; detail: string }[] = [
+ {
+ value: 'restate',
+ label: 'Restate',
+ detail: plan.no_gl_effect
+ ? 'Rebuild the entries so they carry the new wording. No figures change.'
+ : "Rebuild the entries to match the source, in the month the transaction belongs to. That month's figures change.",
+ },
+ {
+ value: 'catch_up',
+ label: 'Catch up',
+ detail: plan.no_gl_effect
+ ? 'The original month stays as it was. This change moves no money, so nothing posts.'
+ : `The original month stays as it was. The difference is drafted as one entry${catchUpDate ? ` on ${catchUpDate}` : ' in an open month'} and posts when that period closes. It stays in RoboLedger.`,
+ },
+ {
+ value: 'acknowledge',
+ label: 'Mark as handled',
+ detail:
+ 'You already booked the difference yourself. Nothing posts; your note is the record.',
+ },
+ ]
+
+ const chosenBlocked = blockedReason(treatment, plan)
+ const needsNote = treatment === 'acknowledge' && !note.trim()
+
+ return (
+
+ {notice && (
+
+ {notice}
+
+ )}
+
+
+
+
+
+
+
+
+ Difference by account
+
+ {delta.length === 0 ? (
+
+ No account's balance changes. The edit was to a memo or a
+ reference.
+
+ ) : (
+
+
+
+ Account
+ Posted
+ Now
+ Difference
+
+
+
+ {delta.map((line, index) => (
+
+
+ {line.element_name ??
+ `Unmapped account ${line.element_external_id ?? ''}`.trim()}
+ {line.element_code ? (
+
+ {line.element_code}
+
+ ) : null}
+
+
+ {netAmount(line.prior_net, currency)}
+
+
+ {netAmount(line.accepted_net, currency)}
+
+
+ {netAmount(line.delta, currency)}
+
+
+ ))}
+
+
+ )}
+
+
+ {closedPeriods.length > 0 && (
+
+ The posted entries sit in{' '}
+ {closedPeriods.length === 1 ? 'a closed period' : 'closed periods'}:{' '}
+ {closedPeriods.join(', ')}.
+
+ )}
+
+
+
+ How to settle it
+
+ {options.map((option) => {
+ const blocked = blockedReason(option.value, plan)
+ return (
+
+ onTreatment(option.value)}
+ />
+
+
+ {option.label}
+
+
+ {option.detail}
+
+ {blocked && (
+
+ Not available: {blocked}
+
+ )}
+
+
+ )
+ })}
+
+
+
+
+ Note{treatment === 'acknowledge' ? ' (required)' : ' (optional)'}
+
+
+
+ {settleError && (
+
+ {settleError}
+
+ )}
+
+
+ {isSettling ? : null}
+ Settle
+
+
+ )
+}
+
+interface EntryListProps {
+ title: string
+ entries: NonNullable
+ currency: string | null | undefined
+}
+
+const EntryList: FC = ({ title, entries, currency }) => (
+
+
{title}
+ {entries.length === 0 ? (
+
No entries.
+ ) : (
+
+ {entries.map((entry, index) => (
+
+
+ {entry.posting_date ? formatDate(entry.posting_date) : 'No date'}
+
+
+ {formatAmount(entry.total_debit ?? 0, currency)}
+
+ {entry.memo ? (
+
+ {entry.memo}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+)
+
+export default ChangedTransactionsPanel
diff --git a/src/app/(app)/ledger/close/components/PeriodClosePanel.tsx b/src/app/(app)/ledger/close/components/PeriodClosePanel.tsx
index 1cde5f3..3f17daa 100644
--- a/src/app/(app)/ledger/close/components/PeriodClosePanel.tsx
+++ b/src/app/(app)/ledger/close/components/PeriodClosePanel.tsx
@@ -148,6 +148,8 @@ function obligationDetailFor(
interface PeriodClosePanelProps {
graphId: string
onEntryCreated?: () => void
+ /** Opens the changed transactions the close is waiting on. */
+ onReviewChanges?: () => void
}
// Compute period bounds (YYYY-MM-DD) for a given YYYY-MM period name.
@@ -172,6 +174,7 @@ function formatPeriod(period: string | null): string {
const PeriodClosePanel: FC = ({
graphId,
onEntryCreated,
+ onReviewChanges,
}) => {
// ── State ────────────────────────────────────────────────────────────
const [calendar, setCalendar] = useState(null)
@@ -530,7 +533,11 @@ const PeriodClosePanel: FC = ({
/>
)}
-
+
{/* Period selector */}
@@ -820,9 +827,14 @@ const CloseSuccessCard: FC
= ({ result, onDismiss }) => {
interface CalendarSummaryProps {
calendar: LedgerFiscalCalendar
onRefresh: () => void
+ onReviewChanges?: () => void
}
-const CalendarSummary: FC = ({ calendar, onRefresh }) => {
+const CalendarSummary: FC = ({
+ calendar,
+ onRefresh,
+ onReviewChanges,
+}) => {
return (
@@ -879,6 +891,18 @@ const CalendarSummary: FC
= ({ calendar, onRefresh }) => {
{BLOCKER_MESSAGES[code] ?? code}
{obligationDetailFor(code, calendar)}
+ {code === 'reconciling_items' && onReviewChanges && (
+
+ {calendar.reconcilingItemCount} holding this close.{' '}
+
+ Review changed transactions
+
+
+ )}
))}
diff --git a/src/app/(app)/ledger/close/components/StructureSidebar.tsx b/src/app/(app)/ledger/close/components/StructureSidebar.tsx
index 2eb7957..4f34b4d 100644
--- a/src/app/(app)/ledger/close/components/StructureSidebar.tsx
+++ b/src/app/(app)/ledger/close/components/StructureSidebar.tsx
@@ -20,6 +20,7 @@ export type SelectedItem =
| { type: 'account_rollups'; mappingId: string; name: string }
| { type: 'trial_balance' }
| { type: 'reconciliations' }
+ | { type: 'changed_transactions' }
| { type: 'period_close' }
// ── Helpers ────────────────────────────────────────────────────────────
@@ -46,6 +47,8 @@ function itemToSelected(item: ClosingBookItem): SelectedItem {
return { type: 'trial_balance' }
case 'reconciliations':
return { type: 'reconciliations' }
+ case 'changed_transactions':
+ return { type: 'changed_transactions' }
case 'period_close':
return { type: 'period_close' }
default:
@@ -74,6 +77,8 @@ function isActive(
return item.itemType === 'trial_balance'
case 'reconciliations':
return item.itemType === 'reconciliations'
+ case 'changed_transactions':
+ return item.itemType === 'changed_transactions'
case 'period_close':
return item.itemType === 'period_close'
}
diff --git a/src/app/(app)/ledger/close/content.tsx b/src/app/(app)/ledger/close/content.tsx
index 74747c1..432112a 100644
--- a/src/app/(app)/ledger/close/content.tsx
+++ b/src/app/(app)/ledger/close/content.tsx
@@ -13,10 +13,11 @@ import {
} from '@robosystems/core'
import { Card } from 'flowbite-react'
import type { FC } from 'react'
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { HiExclamationCircle } from 'react-icons/hi'
import { TbBook2 } from 'react-icons/tb'
import AccountRollupsPanel from './components/AccountRollupsPanel'
+import ChangedTransactionsPanel from './components/ChangedTransactionsPanel'
import { NewScheduleModal } from './components/NewScheduleModal'
import PeriodClosePanel from './components/PeriodClosePanel'
import ReconciliationsPanel from './components/ReconciliationsPanel'
@@ -35,8 +36,8 @@ type ClosingBookCategories = LedgerClosingBookStructures['categories']
* Whether a selection still refers to something in the freshly loaded
* structures. Statements, schedules and rollups are identified by an id that is
* per-graph, so a selection made before a graph switch must not be carried
- * over; the hub, the trial balance and the reconciliations worklist are not
- * tied to an id.
+ * over; the hub, the trial balance, the reconciliations worklist and the
+ * changed transactions are not tied to an id.
*/
function selectionExists(
selected: SelectedItem,
@@ -45,7 +46,8 @@ function selectionExists(
if (
selected.type === 'period_close' ||
selected.type === 'trial_balance' ||
- selected.type === 'reconciliations'
+ selected.type === 'reconciliations' ||
+ selected.type === 'changed_transactions'
) {
return true
}
@@ -58,6 +60,35 @@ function selectionExists(
)
}
+const RECONCILIATIONS_LABEL = 'Reconciliations'
+
+/**
+ * The server lists the reconciliations worklist. The transactions changed at
+ * the source sit beside it, and that entry is added here.
+ */
+function withChangedTransactions(
+ categories: ClosingBookCategories
+): ClosingBookCategories {
+ return categories.map((category) =>
+ category.label === RECONCILIATIONS_LABEL
+ ? {
+ ...category,
+ items: [
+ ...category.items,
+ {
+ id: 'changed_transactions',
+ name: 'Changed transactions',
+ itemType: 'changed_transactions',
+ blockType: null,
+ reportId: null,
+ status: null,
+ },
+ ],
+ }
+ : category
+ )
+}
+
const CloseContent: FC = function () {
const { state: graphState } = useGraphContext()
@@ -159,6 +190,11 @@ const CloseContent: FC = function () {
loadSidebarData()
}, [loadSidebarData])
+ const sidebarCategories = useMemo(
+ () => withChangedTransactions(categories),
+ [categories]
+ )
+
// No qualifying graph
if (!currentGraph && !graphState.isLoading) {
return (
@@ -215,7 +251,7 @@ const CloseContent: FC = function () {
{/* Structure Sidebar */}
) : selectedItem.type === 'reconciliations' && currentGraph ? (
+ ) : selectedItem.type === 'changed_transactions' && currentGraph ? (
+
) : selectedItem.type === 'period_close' && currentGraph ? (
+ setSelectedItem({ type: 'changed_transactions' })
+ }
/>
) : null}