diff --git a/.gitignore b/.gitignore index e296d1f2..b42005de 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ scripts/*.csv scripts/reopen-bokslut.sql .claude/plans/write-up-a-plan-streamed-fiddle.md +/ingaende-balanser-test.csv diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 458d8dfb..faaf2606 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -22,8 +22,10 @@ import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' +import CorrectOpeningBalanceDialog from '@/components/bookkeeping/CorrectOpeningBalanceDialog' import EditDraftEntryDialog from '@/components/bookkeeping/EditDraftEntryDialog' import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog' +import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import CorrectionChain from '@/components/bookkeeping/CorrectionChain' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { useToast } from '@/components/ui/use-toast' @@ -43,6 +45,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [showCorrection, setShowCorrection] = useState(false) + const [showCorrectIB, setShowCorrectIB] = useState(false) const [showEdit, setShowEdit] = useState(false) const [showRecordate, setShowRecordate] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) @@ -237,6 +240,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i // correction (or the original) and corrects that one. const canCorrect = entry.status === 'posted' && entry.source_type !== 'storno' + // An opening-balance verifikat must be corrected through the IB-aware flow + // (storno + rebook + relink the period's opening_balance_entry_id), never the + // generic "Rätta rader" — that books a `correction` entry but leaves the + // period pointing at the stornoed IB, so the Balansrapport "Ingående balans" + // column goes stale. Only surface it on the *active* IB (posted; stornoed + // predecessors are `reversed`, so exactly one posted IB exists per period). + const isOpeningBalance = entry.source_type === 'opening_balance' && entry.status === 'posted' + // Include current entry in the chain for the visualization const fullChain = [entry, ...chain] @@ -265,6 +276,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i {(entry.status === 'posted' || entry.status === 'draft') && (
+ {entry.status === 'draft' && ( + + )} {entry.status === 'draft' && ( + )} {entry.status === 'posted' && ( +
+ + +
} /> diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 6c0c0a41..104df581 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -813,6 +813,7 @@ const OB_STEP_LABELS: Record = { function OpeningBalanceFlow() { const { toast } = useToast() + const { dialogProps, confirm } = useDestructiveConfirm() const [obStep, setObStep] = useState('upload') const [obIsLoading, setObIsLoading] = useState(false) @@ -917,12 +918,27 @@ function OpeningBalanceFlow() { setObStep('period') }, []) - const handleExecute = useCallback(async (fiscalPeriodId: string) => { + const handleExecute = useCallback(async (fiscalPeriodId: string, replace: boolean) => { + if (replace) { + const ok = await confirm({ + title: 'Ersätt ingående balanser?', + description: + 'Den befintliga IB-verifikationen makuleras (stornas) och en ny bokförs med beloppen du angett. Detta går inte att ångra automatiskt.', + confirmLabel: 'Ersätt', + variant: 'warning', + }) + if (!ok) return + } + setObIsLoading(true) setObError(null) + const endpoint = replace + ? '/api/import/opening-balance/correct' + : '/api/import/opening-balance/execute' + try { - const res = await fetch('/api/import/opening-balance/execute', { + const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -938,11 +954,7 @@ function OpeningBalanceFlow() { const data = await res.json() if (!res.ok) { - if (res.status === 409) { - setObError(data.error || 'Perioden har redan ingående balanser') - } else { - setObError(data.error || 'Importen misslyckades') - } + setObError(getErrorMessage(data)) return } @@ -951,7 +963,7 @@ function OpeningBalanceFlow() { if (data.data.success) { toast({ - title: 'Ingående balanser bokförda', + title: replace ? 'Ingående balanser korrigerade' : 'Ingående balanser bokförda', description: `${data.data.lines_created} kontorader skapades`, }) } @@ -960,7 +972,7 @@ function OpeningBalanceFlow() { } finally { setObIsLoading(false) } - }, [editedRows, toast]) + }, [editedRows, toast, confirm]) const handleNewImport = () => { setObStep('upload') @@ -1047,6 +1059,8 @@ function OpeningBalanceFlow() { onNewImport={handleNewImport} /> )} + +
) } diff --git a/app/api/bookkeeping/accounts/bas-catalog/__tests__/route.test.ts b/app/api/bookkeeping/accounts/bas-catalog/__tests__/route.test.ts new file mode 100644 index 00000000..346d0a2c --- /dev/null +++ b/app/api/bookkeeping/accounts/bas-catalog/__tests__/route.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockAuth = vi.fn() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockResolvedValue({ + from: vi.fn(), + auth: { getUser: () => mockAuth() }, + }), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +function mkReq() { + return new Request('http://localhost/api/bookkeeping/accounts/bas-catalog') +} +function mkParams() { + return { params: Promise.resolve({}) } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/bookkeeping/accounts/bas-catalog', () => { + it('returns 401 when not authenticated', async () => { + mockAuth.mockResolvedValue({ data: { user: null } }) + const res = await GET(mkReq(), mkParams()) + expect(res.status).toBe(401) + }) + + it('returns the full BAS catalogue with the projected fields', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + + const res = await GET(mkReq(), mkParams()) + const body = await res.json() + + expect(res.status).toBe(200) + expect(Array.isArray(body.data)).toBe(true) + // The real BAS 2026 chart is ~1,276 accounts. + expect(body.data.length).toBeGreaterThan(1000) + + const it = body.data.find((a: { account_number: string }) => a.account_number === '6540') + expect(it).toMatchObject({ + account_number: '6540', + account_name: 'IT-tjänster', + account_class: 6, + account_group: '65', + }) + expect(typeof it.description).toBe('string') + }) + + it('sets a client cache header (static reference data)', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + const res = await GET(mkReq(), mkParams()) + expect(res.headers.get('Cache-Control')).toContain('max-age=') + }) +}) diff --git a/app/api/bookkeeping/accounts/bas-catalog/route.ts b/app/api/bookkeeping/accounts/bas-catalog/route.ts new file mode 100644 index 00000000..ae61d6b4 --- /dev/null +++ b/app/api/bookkeeping/accounts/bas-catalog/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' + +/** + * GET /api/bookkeeping/accounts/bas-catalog + * + * The full BAS 2026 catalogue (~1,276 accounts), projected to the fields the + * AccountCombobox needs to search and render. This lets the manual bookkeeping + * flow surface accounts by name even when they aren't in the company's chart + * yet — selecting one routes through the existing activate-on-commit rail + * (ACCOUNTS_NOT_IN_CHART → ActivateAccountsDialog → /accounts/activate). + * + * The payload is static reference data for the deploy and identical for every + * company, so it's cached hard on the client. Wrapped in withRouteContext so it + * stays behind auth (MFA on hosted) like every other bookkeeping route. + */ +export const GET = withRouteContext('bookkeeping.accounts.bas_catalog', async () => { + const data = BAS_REFERENCE.map((a) => ({ + account_number: a.account_number, + account_name: a.account_name, + account_class: a.account_class, + account_group: a.account_group, + description: a.description, + })) + + return NextResponse.json( + { data }, + { headers: { 'Cache-Control': 'private, max-age=86400' } }, + ) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/unlock/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/unlock/__tests__/route.test.ts new file mode 100644 index 00000000..b04aa60c --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/unlock/__tests__/route.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest, createMockRouteParams } from '@/tests/helpers' + +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: vi.fn(), +})) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/core/bookkeeping/period-service', () => ({ + unlockPeriod: vi.fn(), +})) + +import { requireAuth } from '@/lib/auth/require-auth' +import { unlockPeriod } from '@/lib/core/bookkeeping/period-service' +import { POST } from '../route' + +function unlockRequest(): Request { + return createMockRequest('/api/bookkeeping/fiscal-periods/p1/unlock', { method: 'POST' }) +} + +function mockAuth() { + ;(requireAuth as ReturnType).mockResolvedValue({ + user: { id: 'user-1' }, + supabase: {}, + error: null, + }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('POST /api/bookkeeping/fiscal-periods/[id]/unlock', () => { + it('unlocks the period and returns it on success', async () => { + mockAuth() + ;(unlockPeriod as ReturnType).mockResolvedValue({ id: 'p1', locked_at: null }) + const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.id).toBe('p1') + }) + + it('maps a not-locked period to a 409', async () => { + mockAuth() + ;(unlockPeriod as ReturnType).mockRejectedValue(new Error('Period is not locked')) + const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' })) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('PERIOD_UNLOCK_NOT_LOCKED') + }) + + it('maps a closed period to a 409', async () => { + mockAuth() + ;(unlockPeriod as ReturnType).mockRejectedValue( + new Error('Cannot unlock a closed period'), + ) + const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' })) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('PERIOD_UNLOCK_CLOSED') + }) + + it('maps a missing period to a 404', async () => { + mockAuth() + ;(unlockPeriod as ReturnType).mockRejectedValue(new Error('Fiscal period not found')) + const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' })) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('PERIOD_NOT_FOUND') + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/unlock/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/unlock/route.ts new file mode 100644 index 00000000..01a10ea0 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/unlock/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' +import { unlockPeriod } from '@/lib/core/bookkeeping/period-service' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' + +export const POST = withRouteContext( + 'period.unlock', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + const opLog = log.child({ periodId: id }) + + try { + const period = await unlockPeriod(supabase, companyId!, user.id, id) + return NextResponse.json({ data: period }) + } catch (err) { + opLog.error('failed to unlock period', err as Error) + // unlockPeriod() throws plain Error with messages like "Fiscal period not + // found", "Cannot unlock a closed period" or "Period is not locked" — + // translate to envelope codes, mirroring the sibling lock route. + const message = err instanceof Error ? err.message : '' + if (/not found/i.test(message)) { + return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId }) + } + if (/closed/i.test(message)) { + return errorResponseFromCode('PERIOD_UNLOCK_CLOSED', opLog, { requestId }) + } + if (/not locked/i.test(message)) { + return errorResponseFromCode('PERIOD_UNLOCK_NOT_LOCKED', opLog, { requestId }) + } + return errorResponse(err, opLog, { requestId }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/import/opening-balance/__tests__/correct.test.ts b/app/api/import/opening-balance/__tests__/correct.test.ts new file mode 100644 index 00000000..c961b35b --- /dev/null +++ b/app/api/import/opening-balance/__tests__/correct.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const mockCreateJournalEntry = vi.fn() +const mockReverseEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), + reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), +})) + +vi.mock('@/lib/bookkeeping/bas-reference', () => ({ + getBASReference: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/supabase/fetch-all', () => ({ + // All referenced accounts already exist → no chart activation insert. + fetchAllRows: vi.fn().mockResolvedValue([ + { account_number: '1930' }, + { account_number: '2099' }, + ]), +})) + +import { POST } from '../correct/route' + +const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000' +const BALANCED_LINES = [ + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 50000 }, +] + +function makeRequest(body: unknown) { + return createMockRequest('/api/import/opening-balance/correct', { + method: 'POST', + body, + }) +} + +function openPeriodWithOB(overrides: Record = {}) { + return { + id: PERIOD_ID, + company_id: 'company-1', + is_closed: false, + locked_at: null, + opening_balances_set: true, + opening_balance_entry_id: 'entry-old', + period_start: '2026-01-01', + ...overrides, + } +} + +describe('POST /api/import/opening-balance/correct', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 for unauthenticated requests', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(401) + expect(body.error).toBe('Unauthorized') + }) + + it('returns 400 for invalid body', async () => { + const res = await POST(makeRequest({ fiscal_period_id: 'not-a-uuid', lines: [] })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }) + + it('returns 404 for non-existent fiscal period', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(404) + expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_NOT_FOUND') + }) + + it('returns 400 when the period is closed', async () => { + enqueue({ data: openPeriodWithOB({ is_closed: true }) }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(400) + expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_CLOSED') + }) + + it('returns 400 when the period is locked', async () => { + enqueue({ data: openPeriodWithOB({ locked_at: '2026-06-28T00:00:00Z' }) }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(400) + expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_LOCKED') + }) + + it('returns 409 when the period has no opening balances to correct', async () => { + enqueue({ data: openPeriodWithOB({ opening_balances_set: false, opening_balance_entry_id: null }) }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(409) + expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_NO_EXISTING') + }) + + it('returns 409 when a year-end close exists on the period', async () => { + enqueue({ data: openPeriodWithOB() }) // period + enqueue({ count: 1 }) // year-end entry count + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(409) + expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_YEAR_END_EXISTS') + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + expect(mockReverseEntry).not.toHaveBeenCalled() + }) + + it('returns 400 for unbalanced corrected lines', async () => { + enqueue({ data: openPeriodWithOB() }) // period + enqueue({ count: 0 }) // year-end check + + const res = await POST(makeRequest({ + fiscal_period_id: PERIOD_ID, + lines: [ + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 40000 }, + ], + })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(400) + expect((body.error as unknown as { code: string }).code).toBe('OB_UNBALANCED') + }) + + it('books a corrected IB, stornoes the old one, and relinks on success', async () => { + enqueue({ data: openPeriodWithOB() }) // period + enqueue({ count: 0 }) // year-end check + enqueue({ error: null }) // replace_period_opening_balance_link RPC + + mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 5 }) + mockReverseEntry.mockResolvedValue({ id: 'entry-storno' }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + expect(body.data.journal_entry_id).toBe('entry-new') + expect(body.data.reversed_entry_id).toBe('entry-old') + expect(body.data.lines_created).toBe(2) + + // New IB created before the old one is reversed. + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ source_type: 'opening_balance', voucher_series: 'A' }), + ) + expect(mockReverseEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'entry-old', + ) + expect(mockSupabase.rpc).toHaveBeenCalledWith( + 'replace_period_opening_balance_link', + expect.objectContaining({ p_period_id: PERIOD_ID, p_new_entry_id: 'entry-new' }), + ) + }) + + it('returns 500 OB_CORRECT_FAILED if the relink RPC fails', async () => { + enqueue({ data: openPeriodWithOB() }) // period + enqueue({ count: 0 }) // year-end check + enqueue({ error: { message: 'relink boom' } }) // RPC failure + + mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 5 }) + mockReverseEntry.mockResolvedValue({ id: 'entry-storno' }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(500) + expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED') + }) +}) diff --git a/app/api/import/opening-balance/correct/__tests__/route.test.ts b/app/api/import/opening-balance/correct/__tests__/route.test.ts new file mode 100644 index 00000000..d11bc728 --- /dev/null +++ b/app/api/import/opening-balance/correct/__tests__/route.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const mockCreateJournalEntry = vi.fn() +const mockReverseEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), + reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), +})) + +vi.mock('@/lib/bookkeeping/bas-reference', () => ({ + getBASReference: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/supabase/fetch-all', () => ({ + // All referenced accounts already exist → no chart activation insert (and no + // extra supabase.from() call that would shift the queued-mock cursor). + fetchAllRows: vi.fn().mockResolvedValue([ + { account_number: '1930' }, + { account_number: '2099' }, + ]), +})) + +import { POST } from '../route' + +type SpyInstance = ReturnType + +const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000' +const BALANCED_LINES = [ + { account_number: '1930', debit_amount: 50000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 50000 }, +] + +function makeRequest(body: unknown) { + return createMockRequest('/api/import/opening-balance/correct', { + method: 'POST', + body, + }) +} + +function openPeriodWithOB(overrides: Record = {}) { + return { + id: PERIOD_ID, + company_id: 'company-1', + is_closed: false, + locked_at: null, + opening_balances_set: true, + opening_balance_entry_id: 'entry-old', + period_start: '2026-01-01', + // Embedded resource from the period fetch — the original IB verifikat's + // voucher label, used to build the BFL 5 kap 5§ reference. + opening_balance_entry: { voucher_series: 'A', voucher_number: 123 }, + ...overrides, + } +} + +/** Flatten every console.error call into one searchable string. */ +function auditLines(spy: SpyInstance): string { + return spy.mock.calls + .map((call) => call.map((a: unknown) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')) + .filter((line) => line.includes('opening balance correction failed')) + .join('\n') +} + +describe('POST /api/import/opening-balance/correct — atomicity, audit, BFL reference', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + let errorSpy: SpyInstance + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + // The structured logger writes error-level records to console.error even in + // the test env; spy on it so we can assert the durable audit line. + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + errorSpy.mockRestore() + }) + + // FIX 3 (BFL 5 kap 5§) — the corrected entry references the original voucher. + it('references the original verifikationsnummer in the corrected entry description', async () => { + enqueue({ data: openPeriodWithOB({ opening_balance_entry: { voucher_series: 'B', voucher_number: 7 } }) }) // period + enqueue({ count: 0 }) // year-end check + enqueue({ error: null }) // replace_period_opening_balance_link RPC + + mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 }) + mockReverseEntry.mockResolvedValue({ id: 'entry-storno' }) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + description: 'Ingående balanser (korrigerade, rättelse av B7)', + source_type: 'opening_balance', + }), + ) + // Happy path stornoes ONLY the old entry — no compensating reverse. + expect(mockReverseEntry).toHaveBeenCalledTimes(1) + expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-old') + }) + + // FIX 1 (ASVS V2.3) — compensation when the storno of the OLD entry throws + // after the new entry was already created. + it('compensates by stornoing the new entry when reverseEntry throws, returning OB_CORRECT_FAILED', async () => { + enqueue({ data: openPeriodWithOB() }) // period + enqueue({ count: 0 }) // year-end check + // No RPC enqueue: step B throws before the relink is reached. + + mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 }) + mockReverseEntry + .mockRejectedValueOnce(new Error('storno of old failed')) // step B (oldEntryId) + .mockResolvedValueOnce({ id: 'entry-storno-new' }) // compensation (newEntry.id) + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(500) + expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED') + + // First the failed storno of the old entry, then the compensating storno of + // the new entry. + expect(mockReverseEntry).toHaveBeenCalledTimes(2) + expect(mockReverseEntry).toHaveBeenNthCalledWith(1, expect.anything(), 'company-1', 'user-1', 'entry-old') + expect(mockReverseEntry).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'user-1', 'entry-new') + + // Durable audit carries both ids for manual recovery. + const audit = auditLines(errorSpy) + expect(audit).toContain('entry-new') + expect(audit).toContain('entry-old') + }) + + // FIX 1 + FIX 2 — relink RPC error triggers compensation and a durable audit. + it('compensates and emits a durable audit when the relink RPC returns an error', async () => { + enqueue({ data: openPeriodWithOB() }) // period + enqueue({ count: 0 }) // year-end check + enqueue({ error: { message: 'relink boom' } }) // RPC failure + + mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 }) + mockReverseEntry.mockResolvedValue({ id: 'entry-storno' }) // step B + compensation both succeed + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(500) + const err = body.error as unknown as { code: string; details?: { newEntryId?: string; oldEntryId?: string } } + expect(err.code).toBe('OB_CORRECT_FAILED') + expect(err.details?.newEntryId).toBe('entry-new') + expect(err.details?.oldEntryId).toBe('entry-old') + + // Compensation: old entry stornoed (step B) then the new entry stornoed. + expect(mockReverseEntry).toHaveBeenCalledTimes(2) + expect(mockReverseEntry).toHaveBeenNthCalledWith(1, expect.anything(), 'company-1', 'user-1', 'entry-old') + expect(mockReverseEntry).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'user-1', 'entry-new') + + // Durable audit event payload contains newEntryId + oldEntryId. + const audit = auditLines(errorSpy) + expect(audit).toContain('opening_balance.correction_failed') + expect(audit).toContain('entry-new') + expect(audit).toContain('entry-old') + }) + + // FIX 2 — the compensating storno may itself fail; the handler must still + // return the envelope and audit the compensation failure (never rethrow). + it('audits a compensation failure and still returns OB_CORRECT_FAILED', async () => { + enqueue({ data: openPeriodWithOB() }) // period + enqueue({ count: 0 }) // year-end check + enqueue({ error: { message: 'relink boom' } }) // RPC failure + + mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 }) + mockReverseEntry + .mockResolvedValueOnce({ id: 'entry-storno' }) // step B ok + .mockRejectedValueOnce(new Error('compensation storno failed')) // compensation throws + + const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(500) + expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED') + + const audit = auditLines(errorSpy) + expect(audit).toContain('compensation_failed') + expect(audit).toContain('entry-new') + expect(audit).toContain('entry-old') + }) +}) diff --git a/app/api/import/opening-balance/correct/route.ts b/app/api/import/opening-balance/correct/route.ts new file mode 100644 index 00000000..45afaacd --- /dev/null +++ b/app/api/import/opening-balance/correct/route.ts @@ -0,0 +1,254 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas' +import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { + validateOpeningBalanceLines, + activateMissingAccounts, + buildOpeningBalanceEntryLines, +} from '@/lib/import/opening-balance/execute-helpers' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +/** + * POST /api/import/opening-balance/correct + * + * Correct a period's existing opening balances the BFL-compliant way: the + * current IB verifikat (immutable, posted) is stornoed and a corrected IB is + * booked, then fiscal_periods.opening_balance_entry_id is relinked to the new + * entry via the replace_period_opening_balance_link RPC. + * + * Because getOpeningBalances reads the linked entry directly and the + * trial-balance / general-ledger movement queries include both `posted` and + * `reversed` lines (excluding only the linked OB entry), the stornoed old IB + * and its storno mirror cancel out in period movement — so the Balansrapport + * IB column shows the corrected figures and UB stays correct. + * + * Gated to the safe case only: the period must be open, unlocked, already have + * opening balances, and have no year-end close on top. Locked/closed periods or + * periods with a bokslut must be unwound first (assisted) — we refuse here. + */ +export const POST = withRouteContext( + 'opening_balance.correct', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const result = await validateBody(request, OpeningBalanceExecuteSchema, { + log, + operation: 'opening_balance.correct', + }) + if (!result.success) return result.response + + const { fiscal_period_id, lines } = result.data + const opLog = log.child({ fiscalPeriodId: fiscal_period_id }) + + try { + // 1. Verify the fiscal period belongs to the company and is correctable. + // Write-role (non-viewer) + company membership are already enforced by + // withRouteContext({ requireWrite: true }) before this handler runs + // (requireWritePermission + getActiveCompanyId), and this fetch is scoped + // by that verified companyId — no redundant authz here (ASVS V8.2.1). + // The embedded opening_balance_entry pulls the original IB verifikat's + // voucher label so the corrected entry can reference it (BFL 5 kap 5§). + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select( + '*, opening_balance_entry:journal_entries!opening_balance_entry_id(voucher_series, voucher_number)', + ) + .eq('id', fiscal_period_id) + .eq('company_id', companyId) + .single() + + if (periodError || !period) { + return errorResponseFromCode('OB_PERIOD_NOT_FOUND', opLog, { requestId }) + } + + if (period.is_closed) { + return errorResponseFromCode('OB_PERIOD_CLOSED', opLog, { requestId }) + } + + if (period.locked_at) { + return errorResponseFromCode('OB_PERIOD_LOCKED', opLog, { requestId }) + } + + if (!period.opening_balances_set || !period.opening_balance_entry_id) { + return errorResponseFromCode('OB_CORRECT_NO_EXISTING', opLog, { requestId }) + } + + // Refuse if a year-end close was built on top — correcting the IB without + // unwinding the bokslut would leave the period (and the next period's + // carried-forward IB) internally inconsistent. + const { count: yearEndCount } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscal_period_id) + .eq('source_type', 'year_end') + .eq('status', 'posted') + + if ((yearEndCount ?? 0) > 0) { + return errorResponseFromCode('OB_CORRECT_YEAR_END_EXISTS', opLog, { requestId }) + } + + const oldEntryId = period.opening_balance_entry_id + + // 2. Validate the corrected lines (drop zeros, ≥2 rows, no P&L, must balance). + const validation = validateOpeningBalanceLines(lines) + if (!validation.ok) { + return errorResponseFromCode(validation.code, opLog, { + requestId, + details: + validation.code === 'OB_PNL_ACCOUNT' + ? { accounts: validation.accounts } + : validation.code === 'OB_UNBALANCED' + ? { totalDebit: validation.totalDebit, totalCredit: validation.totalCredit, diff: validation.diff } + : undefined, + }) + } + const { validLines, totalDebit, totalCredit } = validation + + // 3. Auto-activate BAS accounts the corrected file references but the chart lacks. + const accountNumbers = [...new Set(validLines.map((l) => l.account_number))] + const activation = await activateMissingAccounts(supabase, companyId!, user.id, accountNumbers) + if (!activation.ok) { + opLog.error('opening balance account activation failed', new Error(activation.reason)) + return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, { + requestId, + details: { reason: activation.reason }, + }) + } + + // BFL 5 kap 5§ — reference the original verifikat so the correction is + // traceable to the entry it rättar. The embed above gave us the old IB's + // voucher label (e.g. "A123"). CreateJournalEntryInput exposes no dedicated + // correction-linkage field (corrects_entry_id / correction_of / metadata), + // so the description reference IS the linkage; we deliberately leave the + // generic source_id unset rather than overload it for an opening_balance. + const originalRef = ( + period as { + opening_balance_entry?: { + voucher_series?: string | null + voucher_number?: number | null + } | null + } + ).opening_balance_entry + const originalVoucherLabel = + originalRef?.voucher_series && originalRef?.voucher_number + ? `${originalRef.voucher_series}${originalRef.voucher_number}` + : null + const correctedDescription = originalVoucherLabel + ? `Ingående balanser (korrigerade, rättelse av ${originalVoucherLabel})` + : 'Ingående balanser (korrigerade)' + + // 4. Book the corrected IB, storno the old one, then relink the period. + // Order matters: create the replacement BEFORE reversing the original so a + // mid-failure never leaves the period without an opening balance. + const newEntry = await createJournalEntry(supabase, companyId!, user.id, { + fiscal_period_id, + entry_date: period.period_start, + description: correctedDescription, + source_type: 'opening_balance', + voucher_series: 'A', + lines: buildOpeningBalanceEntryLines(validLines), + }) + + // ASVS V16 — durable audit sink for a failed correction. The core event bus + // has no opening_balance.* correction event type and lib/events/types.ts is + // outside the scope of this change, so the failure is recorded via the + // structured logger: it lands in the JSON log sink (Vercel/Sentry), tagged + // `audit: true` + both entry ids so an operator can reconcile the period by + // hand. (Follow-up: promote to a typed event persisted to event_log.) + const auditCorrectionFailure = (fields: Record) => { + opLog.error('audit: opening balance correction failed', { + audit: true, + event: 'opening_balance.correction_failed', + companyId, + userId: user.id, + fiscalPeriodId: fiscal_period_id, + newEntryId: newEntry.id, + oldEntryId, + ...fields, + }) + } + + // FIX (ASVS V2.3 — atomicity via compensation): steps B (storno old) and + // C (relink) are NOT atomic with A (create new). A already produced a second + // posted opening_balance entry for the period; if B or C fails, that entry is + // orphaned and the Balansrapport would show two OB entries. Wrap B+C so that + // on ANY failure below we compensate by stornoing the NEW entry, restoring the + // period to its original consistent state (original OB still linked, new entry + // cancelled by its own storno). + try { + // B: storno the original IB. + await reverseEntry(supabase, companyId!, user.id, oldEntryId) + + // C: point the period at the corrected IB (single atomic RPC). + const { error: relinkError } = await supabase.rpc('replace_period_opening_balance_link', { + p_company_id: companyId, + p_period_id: fiscal_period_id, + p_new_entry_id: newEntry.id, + }) + if (relinkError) { + // Funnel the RPC error into the single compensation path below. + throw new Error(`replace_period_opening_balance_link failed: ${relinkError.message}`) + } + } catch (seqErr) { + const reason = seqErr instanceof Error ? seqErr.message : 'unknown' + + // Durable audit BEFORE compensation so the ids survive even if the + // compensating storno also throws. + // + // Residual edge (documented): if B succeeded but C failed, the old entry is + // now reversed yet still linked to the period. We still compensate the new + // entry; the audit payload carries newEntryId + oldEntryId so an operator can + // finish recovery (re-link or re-book) manually. + auditCorrectionFailure({ phase: 'sequence_failed', reason }) + + // Compensating rollback. This may itself throw (e.g. the period was locked + // between A and here) — catch + audit and never let it propagate past the + // handler, so the caller always gets the OB_CORRECT_FAILED envelope. + try { + await reverseEntry(supabase, companyId!, user.id, newEntry.id) + auditCorrectionFailure({ phase: 'compensated', reason }) + } catch (compErr) { + auditCorrectionFailure({ + phase: 'compensation_failed', + reason, + compensationError: compErr instanceof Error ? compErr.message : 'unknown', + }) + } + + return errorResponseFromCode('OB_CORRECT_FAILED', opLog, { + requestId, + details: { reason, newEntryId: newEntry.id, oldEntryId }, + }) + } + + return NextResponse.json({ + data: { + success: true, + journal_entry_id: newEntry.id, + reversed_entry_id: oldEntryId, + fiscal_period_id, + lines_created: validLines.length, + total_debit: totalDebit, + total_credit: totalCredit, + }, + }) + } catch (err) { + if (isBookkeepingError(err)) { + return errorResponse(err, opLog, { requestId }) + } + opLog.error('opening balance correct failed', err as Error) + return errorResponseFromCode('OB_CORRECT_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/import/opening-balance/execute/route.ts b/app/api/import/opening-balance/execute/route.ts index d53492fc..79461701 100644 --- a/app/api/import/opening-balance/execute/route.ts +++ b/app/api/import/opening-balance/execute/route.ts @@ -4,11 +4,13 @@ import { validateBody } from '@/lib/api/validate' import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas' import { createJournalEntry } from '@/lib/bookkeeping/engine' import { isBookkeepingError } from '@/lib/bookkeeping/errors' -import { getBASReference } from '@/lib/bookkeeping/bas-reference' -import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + validateOpeningBalanceLines, + activateMissingAccounts, + buildOpeningBalanceEntryLines, +} from '@/lib/import/opening-balance/execute-helpers' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -import type { CreateJournalEntryLineInput } from '@/types' ensureInitialized() @@ -60,127 +62,34 @@ export const POST = withRouteContext( }) } - // 2. Filter zero-amount lines and reject P&L accounts. - const validLines = lines.filter((l) => l.debit_amount > 0 || l.credit_amount > 0) - - if (validLines.length < 2) { - return errorResponseFromCode('OB_TOO_FEW_LINES', opLog, { requestId }) - } - - const pnlAccounts = validLines - .map((l) => l.account_number) - .filter((num) => { - const cls = parseInt(num.charAt(0), 10) - return cls >= 3 && cls <= 8 - }) - - if (pnlAccounts.length > 0) { - return errorResponseFromCode('OB_PNL_ACCOUNT', opLog, { + // 2. Validate lines (drop zeros, ≥2 rows, no P&L accounts, must balance). + const validation = validateOpeningBalanceLines(lines) + if (!validation.ok) { + return errorResponseFromCode(validation.code, opLog, { requestId, - details: { accounts: pnlAccounts.slice(0, 5) }, + details: + validation.code === 'OB_PNL_ACCOUNT' + ? { accounts: validation.accounts } + : validation.code === 'OB_UNBALANCED' + ? { totalDebit: validation.totalDebit, totalCredit: validation.totalCredit, diff: validation.diff } + : undefined, }) } + const { validLines, totalDebit, totalCredit } = validation - // 3. Verify balance. - let totalDebit = 0 - let totalCredit = 0 - for (const line of validLines) { - totalDebit = Math.round((totalDebit + line.debit_amount) * 100) / 100 - totalCredit = Math.round((totalCredit + line.credit_amount) * 100) / 100 - } - - const diff = Math.round((totalDebit - totalCredit) * 100) / 100 - if (Math.abs(diff) >= 0.01) { - return errorResponseFromCode('OB_UNBALANCED', opLog, { - requestId, - details: { totalDebit, totalCredit, diff }, - }) - } - - // 4. Auto-activate BAS accounts not in the company's chart. + // 3. Auto-activate BAS accounts not in the company's chart. const accountNumbers = [...new Set(validLines.map((l) => l.account_number))] - - const existingAccounts = await fetchAllRows(({ from, to }) => - supabase - .from('chart_of_accounts') - .select('account_number') - .eq('company_id', companyId) - .range(from, to), - ) - - const existingNumbers = new Set(existingAccounts.map((a) => a.account_number)) - const accountsToActivate = accountNumbers - .filter((num) => !existingNumbers.has(num)) - .map((num) => { - const ref = getBASReference(num) - - if (ref) { - return { - user_id: user.id, - company_id: companyId, - account_number: ref.account_number, - account_name: ref.account_name, - account_class: ref.account_class, - account_group: ref.account_group, - account_type: ref.account_type, - normal_balance: ref.normal_balance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: ref.description, - sru_code: ref.sru_code, - sort_order: parseInt(ref.account_number), - } - } - - const accountClass = parseInt(num.charAt(0), 10) - const accountGroup = num.substring(0, 2) - const accountType = - accountClass === 1 ? 'asset' - : accountClass === 2 ? 'liability' - : accountClass === 3 ? 'revenue' - : 'expense' - const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit' - - return { - user_id: user.id, - company_id: companyId, - account_number: num, - account_name: `Konto ${num}`, - account_class: accountClass, - account_group: accountGroup, - account_type: accountType, - normal_balance: normalBalance, - plan_type: 'full_bas' as const, - is_active: true, - is_system_account: false, - description: `Konto ${num}`, - sru_code: null, - sort_order: parseInt(num), - } + const activation = await activateMissingAccounts(supabase, companyId!, user.id, accountNumbers) + if (!activation.ok) { + opLog.error('opening balance account activation failed', new Error(activation.reason)) + return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, { + requestId, + details: { reason: activation.reason }, }) - - if (accountsToActivate.length > 0) { - const { error: activateError } = await supabase - .from('chart_of_accounts') - .insert(accountsToActivate) - - if (activateError) { - opLog.error('opening balance account activation failed', activateError) - return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, { - requestId, - details: { reason: activateError.message }, - }) - } } - // 5. Create the opening balance journal entry. - const entryLines: CreateJournalEntryLineInput[] = validLines.map((line) => ({ - account_number: line.account_number, - debit_amount: line.debit_amount, - credit_amount: line.credit_amount, - line_description: `IB ${line.account_number}`, - })) + // 4. Create the opening balance journal entry. + const entryLines = buildOpeningBalanceEntryLines(validLines) const entry = await createJournalEntry(supabase, companyId!, user.id, { fiscal_period_id, @@ -191,7 +100,7 @@ export const POST = withRouteContext( lines: entryLines, }) - // 6. Mark the fiscal period. + // 5. Mark the fiscal period. await supabase .from('fiscal_periods') .update({ diff --git a/app/api/import/sie/[id]/replace/route.ts b/app/api/import/sie/[id]/replace/route.ts index a50e63ae..c79047cf 100644 --- a/app/api/import/sie/[id]/replace/route.ts +++ b/app/api/import/sie/[id]/replace/route.ts @@ -3,6 +3,11 @@ import { replaceSIEImport } from '@/lib/import/sie-import' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +// Hard-deleting a large import (thousands of audit-logged journal entries + +// cascading lines) can take well over the default function timeout. Match the +// SIE execute route so the serverless function doesn't kill the request first. +export const maxDuration = 300 + /** * POST /api/import/sie/[id]/replace * diff --git a/app/api/import/sie/[id]/undo/route.ts b/app/api/import/sie/[id]/undo/route.ts index 5b82dc11..6c608f55 100644 --- a/app/api/import/sie/[id]/undo/route.ts +++ b/app/api/import/sie/[id]/undo/route.ts @@ -3,6 +3,11 @@ import { undoSIEImport } from '@/lib/import/sie-import' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +// Hard-deleting a large import (thousands of audit-logged journal entries + +// cascading lines) can take well over the default function timeout. Match the +// SIE execute route so the serverless function doesn't kill the request first. +export const maxDuration = 300 + /** * DELETE /api/import/sie/[id]/undo * diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts index 69dc56fe..31014538 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts @@ -37,7 +37,9 @@ function buildSupabase( } return chain } - // journal_entry_lines + // journal_entry_lines — terminates on `.range()` (fetchAllRows), which + // resolves to the line result. `data.length < PAGE_SIZE` so a single + // page is fetched. const chain = { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -47,6 +49,7 @@ function buildSupabase( order: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), or: vi.fn().mockReturnThis(), + range: vi.fn().mockResolvedValue(linesResult), then: (resolve: (v: unknown) => void) => resolve(linesResult), } return chain @@ -94,6 +97,24 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => expect(res.status).toBe(404) }) + it('returns 400 when the cursor date component is not a structural ISO date', async () => { + // Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a + // malformed date component must still be rejected structurally. + mockCreateClient.mockResolvedValue( + buildSupabase( + { id: 'user-1' }, + { account_number: '1930', account_name: 'Företagskonto' }, + { data: [], error: null } + ) as never + ) + const req = createMockRequest( + '/api/reports/trial-balance/account/1930/sources', + { searchParams: { fiscal_period_id: 'period-1', cursor: 'notadate|5' } } + ) + const res = await GET(req, createMockRouteParams({ accountNumber: '1930' })) + expect(res.status).toBe(400) + }) + it('happy path: returns mapped lines for an account', async () => { const linesData = [ { @@ -293,4 +314,69 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => expect(body.data.lines[0].journal_entry_id).toBe('je-low') // voucher 5 first expect(body.data.lines[1].journal_entry_id).toBe('je-high') // voucher 20 second }) + + it('paginates a >500-line account deterministically regardless of DB return order', async () => { + // Regression: with no stable parent ORDER BY, a raw `.limit(500)` returned + // an arbitrary subset that varied between identical requests — the + // "different rows on every reload" bug for high-volume accounts. We now + // fetch the full set and sort/slice in JS, so the first page is always the + // 500 chronologically-earliest lines. + const total = 600 + const ordered = Array.from({ length: total }, (_, i) => { + const day = String((i % 28) + 1).padStart(2, '0') + return { + debit_amount: i + 1, + credit_amount: 0, + journal_entry_id: `je-${String(i).padStart(4, '0')}`, + journal_entries: { + id: `je-${String(i).padStart(4, '0')}`, + voucher_number: i + 1, // unique, monotonic with intended order + voucher_series: 'A', + entry_date: `2026-${String((i % 12) + 1).padStart(2, '0')}-${day}`, + description: `Row ${i}`, + status: 'posted', + company_id: 'company-1', + fiscal_period_id: 'period-1', + }, + } + }) + // Shuffle deterministically so the DB "return order" is not the sorted one. + const shuffled = [...ordered].sort((a, b) => + a.journal_entry_id < b.journal_entry_id ? 1 : -1 + ) + + mockCreateClient.mockResolvedValue( + buildSupabase( + { id: 'user-1' }, + { account_number: '3001', account_name: 'Försäljning' }, + { data: shuffled, error: null } + ) as never + ) + + const req = createMockRequest( + '/api/reports/trial-balance/account/3001/sources', + { searchParams: { fiscal_period_id: 'period-1' } } + ) + const res = await GET(req, createMockRouteParams({ accountNumber: '3001' })) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + data: { lines: Array<{ voucher_number: number; date: string }>; next_cursor: string | null } + } + + // First page is exactly PAGE_LIMIT rows, fully sorted (date ASC, then + // voucher_number ASC — numeric, not lexicographic). + expect(body.data.lines).toHaveLength(500) + const lines = body.data.lines + for (let i = 1; i < lines.length; i++) { + const prev = lines[i - 1] + const cur = lines[i] + const ordered = + prev.date < cur.date || + (prev.date === cur.date && prev.voucher_number <= cur.voucher_number) + expect(ordered).toBe(true) + } + // More rows remain → a cursor is returned pointing at the last delivered row. + expect(body.data.next_cursor).toBe(`${lines[499].date}|${lines[499].voucher_number}`) + }) }) diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts index 07a07ce9..73b2e476 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { requireCompanyId } from '@/lib/company/context' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { ReportSourceLine } from '@/lib/reports/source-lines' /** @@ -55,60 +56,66 @@ export async function GET( ) } - // Pull all lines on this account in this period. We rely on the same - // join+filter pattern as `generateTrialBalance`. Pagination is server-side - // via cursor so even an account with tens of thousands of rows stays cheap. - let query = supabase - .from('journal_entry_lines') - .select(` - debit_amount, - credit_amount, - journal_entry_id, - journal_entries!inner( - id, - voucher_number, - voucher_series, - entry_date, - description, - status, - company_id, - fiscal_period_id - ) - `) - .eq('account_number', accountNumber) - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', fiscalPeriodId) - .in('journal_entries.status', ['posted', 'reversed']) - .limit(PAGE_LIMIT + 1) - + // Parse the optional cursor up front (format: |). + // Pagination is applied in JS after a full, deterministically-ordered fetch. + let cursorDate: string | null = null + let cursorVoucherNum = 0 if (cursor) { - // Cursor format: | - const [cursorDate, cursorVoucher] = cursor.split('|') - const cursorVoucherNum = parseInt(cursorVoucher, 10) - if (!cursorDate || isNaN(cursorVoucherNum)) { + const [cd, cv] = cursor.split('|') + cursorVoucherNum = parseInt(cv, 10) + // The cursor is applied in JS (string compare); structurally validating the + // date component here is defense-in-depth against malformed/injection cursors. + if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) { return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 }) } - // Filter for rows strictly after the cursor (date>cur OR same date & voucher>cur). - // Supabase doesn't expose tuple compare, so use an `or()` clause. - query = query.or( - `entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`, - { foreignTable: 'journal_entries' } - ) + cursorDate = cd } - const { data, error } = await query + // Pull ALL lines on this account in this period, then sort + paginate in JS. + // + // Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST + // sorts the *embedded* resource's rows, not the parent result set, so it + // cannot give us a chronological parent order. Without a stable parent order + // a raw `.limit()` returns an arbitrary subset that varies between identical + // requests — which surfaced as the trial-balance drill-down showing + // "different rows on every reload" for high-volume accounts. We instead page + // on the line PK (`id`) for a stable total order (see fetch-all.ts) and do + // the chronological sort here, mirroring `generateGeneralLedger`. + const rows = await fetchAllRows<{ + id: string + debit_amount: number + credit_amount: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + journal_entries: any + }>(({ from, to }) => + supabase + .from('journal_entry_lines') + .select(` + id, + debit_amount, + credit_amount, + journal_entry_id, + journal_entries!inner( + id, + voucher_number, + voucher_series, + entry_date, + description, + status, + company_id, + fiscal_period_id + ) + `) + .eq('account_number', accountNumber) + .eq('journal_entries.company_id', companyId) + .eq('journal_entries.fiscal_period_id', fiscalPeriodId) + .in('journal_entries.status', ['posted', 'reversed']) + .order('id', { ascending: true }) + .range(from, to), { dedupeBy: (r) => r.id }) - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rows = (data || []) as any[] - - // Map all rows then sort in JS (date ASC, voucher_number ASC). - // .order({ foreignTable }) in Supabase sorts the embedded resource's rows, - // not the parent result set, so we cannot rely on DB ordering here. - // This mirrors the sort in generateGeneralLedger. + // Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as + // a final deterministic tiebreak for lines sharing a date and voucher number + // across series). const allMapped: ReportSourceLine[] = rows.map((row) => ({ journal_entry_id: row.journal_entries.id, voucher_number: row.journal_entries.voucher_number, @@ -120,14 +127,26 @@ export async function GET( })) allMapped.sort((a, b) => { const dateComp = a.date.localeCompare(b.date) - return dateComp !== 0 ? dateComp : a.voucher_number - b.voucher_number + if (dateComp !== 0) return dateComp + if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number + return a.journal_entry_id.localeCompare(b.journal_entry_id) }) - const lines = allMapped.slice(0, PAGE_LIMIT) - // If we got more than PAGE_LIMIT rows back, the next cursor points at the - // last delivered row so the next call resumes from after it. + // Apply the cursor in JS: keep rows strictly after (date, voucher_number). + const afterCursor = cursorDate + ? allMapped.filter( + (l) => + l.date > cursorDate! || + (l.date === cursorDate! && l.voucher_number > cursorVoucherNum) + ) + : allMapped + + const lines = afterCursor.slice(0, PAGE_LIMIT) + + // If more rows remain beyond this page, point the next cursor at the last + // delivered row so the next call resumes from after it. let next_cursor: string | null = null - if (rows.length > PAGE_LIMIT && lines.length > 0) { + if (afterCursor.length > PAGE_LIMIT && lines.length > 0) { const last = lines[lines.length - 1] next_cursor = `${last.date}|${last.voucher_number}` } diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts index 640ade60..87cb4b1c 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts @@ -32,6 +32,8 @@ function buildSupabase( limit: vi.fn().mockReturnThis(), or: vi.fn().mockReturnThis(), maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + // journal_entry_lines terminates on `.range()` (fetchAllRows). + range: vi.fn().mockResolvedValue(linesResult), then: (resolve: (v: unknown) => void) => resolve(linesResult), })), } @@ -117,4 +119,95 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { expect(body.data.lines[0].voucher_number).toBe(12) expect(body.data.lines[0].credit).toBe(250) }) + + it('returns 400 when the cursor date component is not a structural ISO date', async () => { + // Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a + // malformed date component must still be rejected structurally. + mockCreateClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never + ) + const req = createMockRequest( + '/api/reports/vat-declaration/ruta/10/sources', + { + searchParams: { + periodType: 'monthly', + year: '2026', + period: '5', + cursor: 'notadate|5', + }, + } + ) + const res = await GET(req, createMockRouteParams({ ruta: '10' })) + expect(res.status).toBe(400) + }) + + it('sorts lines by entry_date ASC then voucher_number ASC regardless of DB return order', async () => { + // Regression: this endpoint relied on `.order({ foreignTable })`, which + // sorts the embedded resource — not the parent — so lines came back in + // arbitrary order and the drill-down showed "different rows on reload". + const linesData = [ + { + account_number: '2611', + debit_amount: 0, + credit_amount: 500, + journal_entries: { + id: 'je-late', + voucher_number: 30, + voucher_series: 'A', + entry_date: '2026-05-20', + description: 'Late', + status: 'posted', + company_id: 'company-1', + }, + }, + { + account_number: '2611', + debit_amount: 0, + credit_amount: 100, + journal_entries: { + id: 'je-early', + voucher_number: 4, + voucher_series: 'A', + entry_date: '2026-05-02', + description: 'Early', + status: 'posted', + company_id: 'company-1', + }, + }, + { + account_number: '2611', + debit_amount: 0, + credit_amount: 250, + journal_entries: { + id: 'je-mid', + voucher_number: 18, + voucher_series: 'A', + entry_date: '2026-05-11', + description: 'Mid', + status: 'posted', + company_id: 'company-1', + }, + }, + ] + mockCreateClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never + ) + + const req = createMockRequest( + '/api/reports/vat-declaration/ruta/10/sources', + { searchParams: { periodType: 'monthly', year: '2026', period: '5' } } + ) + const res = await GET(req, createMockRouteParams({ ruta: '10' })) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + data: { lines: Array<{ journal_entry_id: string }> } + } + + expect(body.data.lines.map((l) => l.journal_entry_id)).toEqual([ + 'je-early', + 'je-mid', + 'je-late', + ]) + }) }) diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts index 984dc29e..684ea16b 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { requireCompanyId } from '@/lib/company/context' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import { ACCOUNT_RUTA, calculatePeriodDates, @@ -93,66 +94,94 @@ export async function GET( end = dates.end } - let query = supabase - .from('journal_entry_lines') - .select(` - account_number, - debit_amount, - credit_amount, - journal_entries!inner( - id, - voucher_number, - voucher_series, - entry_date, - description, - status, - company_id - ) - `) - .in('account_number', accountsForRuta) - .eq('journal_entries.company_id', companyId) - .in('journal_entries.status', ['posted', 'reversed']) - .gte('journal_entries.entry_date', start) - .lte('journal_entries.entry_date', end) - .order('entry_date', { foreignTable: 'journal_entries', ascending: true }) - .order('voucher_number', { foreignTable: 'journal_entries', ascending: true }) - .limit(PAGE_LIMIT + 1) - + // Parse the optional cursor up front (format: |). + // Pagination is applied in JS after a full, deterministically-ordered fetch. + let cursorDate: string | null = null + let cursorVoucherNum = 0 if (cursor) { - const [cursorDate, cursorVoucher] = cursor.split('|') - const cursorVoucherNum = parseInt(cursorVoucher, 10) - if (!cursorDate || isNaN(cursorVoucherNum)) { + const [cd, cv] = cursor.split('|') + cursorVoucherNum = parseInt(cv, 10) + // The cursor is applied in JS (string compare); structurally validating the + // date component here is defense-in-depth against malformed/injection cursors. + if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) { return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 }) } - query = query.or( - `entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`, - { foreignTable: 'journal_entries' } - ) + cursorDate = cd } - const { data, error } = await query + // Pull ALL contributing lines, then sort + paginate in JS. + // + // Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST + // sorts the *embedded* resource's rows, not the parent result set, so it + // cannot give us a chronological parent order. Without a stable parent order + // a raw `.limit()` returns an arbitrary subset that varies between identical + // requests, making the drill-down show "different rows on every reload". We + // page on the line PK (`id`) for a stable total order (see fetch-all.ts) and + // do the chronological sort here, mirroring `generateGeneralLedger` and the + // trial-balance sources route. + const rows = await fetchAllRows<{ + id: string + debit_amount: number + credit_amount: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + journal_entries: any + }>(({ from, to }) => + supabase + .from('journal_entry_lines') + .select(` + id, + account_number, + debit_amount, + credit_amount, + journal_entries!inner( + id, + voucher_number, + voucher_series, + entry_date, + description, + status, + company_id + ) + `) + .in('account_number', accountsForRuta) + .eq('journal_entries.company_id', companyId) + .in('journal_entries.status', ['posted', 'reversed']) + .gte('journal_entries.entry_date', start) + .lte('journal_entries.entry_date', end) + .order('id', { ascending: true }) + .range(from, to), { dedupeBy: (r) => r.id }) - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + // Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as + // a final deterministic tiebreak). + const allMapped: ReportSourceLine[] = rows.map((row) => ({ + journal_entry_id: row.journal_entries.id, + voucher_number: row.journal_entries.voucher_number, + voucher_series: row.journal_entries.voucher_series || 'A', + date: row.journal_entries.entry_date, + description: row.journal_entries.description || '', + debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100, + credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100, + })) + allMapped.sort((a, b) => { + const dateComp = a.date.localeCompare(b.date) + if (dateComp !== 0) return dateComp + if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number + return a.journal_entry_id.localeCompare(b.journal_entry_id) + }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rows = (data || []) as any[] + // Apply the cursor in JS: keep rows strictly after (date, voucher_number). + const afterCursor = cursorDate + ? allMapped.filter( + (l) => + l.date > cursorDate! || + (l.date === cursorDate! && l.voucher_number > cursorVoucherNum) + ) + : allMapped - const lines: ReportSourceLine[] = rows - .slice(0, PAGE_LIMIT) - .map((row) => ({ - journal_entry_id: row.journal_entries.id, - voucher_number: row.journal_entries.voucher_number, - voucher_series: row.journal_entries.voucher_series || 'A', - date: row.journal_entries.entry_date, - description: row.journal_entries.description || '', - debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100, - credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100, - })) + const lines = afterCursor.slice(0, PAGE_LIMIT) let next_cursor: string | null = null - if (rows.length > PAGE_LIMIT && lines.length > 0) { + if (afterCursor.length > PAGE_LIMIT && lines.length > 0) { const last = lines[lines.length - 1] next_cursor = `${last.date}|${last.voucher_number}` } diff --git a/components/agent/AgentSessionList.tsx b/components/agent/AgentSessionList.tsx new file mode 100644 index 00000000..1e038ac5 --- /dev/null +++ b/components/agent/AgentSessionList.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { Search, X, Loader2, MessageSquare, Pencil } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useToast } from '@/components/ui/use-toast' +import { + type ConversationRow, + BUCKET_LABELS, + relativeTime, + intentLabel, + groupConversations, +} from './conversation-display' + +interface Props { + // Highlight the row for the conversation currently open in the sheet. + activeConversationId?: string | null + // Fired when the user picks a conversation to resume. The sheet fetches its + // messages and swaps back to the chat view — the list itself stays dumb. + onSelect: (id: string) => void +} + +// In-sheet conversation picker. Renders the same grouped/searchable list as the +// /chat sidebar (shared helpers in conversation-display.ts), but instead of +// navigating to /chat/[id] it hands the id back so the conversation opens +// inline in the sheet and the user keeps chatting without leaving the page. +// Rows are renameable inline (PATCH /api/agent/conversations/[id]). +export default function AgentSessionList({ activeConversationId, onSelect }: Props) { + const [conversations, setConversations] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState('') + // Set by Esc so the blur that fires when the input unmounts doesn't save. + const cancelRef = useRef(false) + const { toast } = useToast() + + useEffect(() => { + let cancelled = false + void (async () => { + setLoading(true) + setError(null) + try { + const res = await fetch('/api/agent/conversations?limit=100') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = (await res.json()) as { data?: ConversationRow[] } + if (!cancelled) setConversations(json.data ?? []) + } catch { + if (!cancelled) setError('Kunde inte hämta konversationer.') + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, []) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return conversations + return conversations.filter( + (c) => + (c.title ?? '').toLowerCase().includes(q) || + (c.last_message_preview ?? '').toLowerCase().includes(q) || + (c.context_ref ?? '').toLowerCase().includes(q) || + c.intent_id.toLowerCase().includes(q), + ) + }, [conversations, query]) + + const grouped = useMemo(() => groupConversations(filtered), [filtered]) + + function startEdit(c: ConversationRow) { + setEditingId(c.id) + setEditValue(c.title ?? '') + cancelRef.current = false + } + function cancelEdit() { + cancelRef.current = true + setEditingId(null) + } + async function commitEdit(id: string) { + if (cancelRef.current) { + cancelRef.current = false + return + } + setEditingId(null) + const title = editValue.trim() + const current = conversations.find((c) => c.id === id) + if (!title || title === current?.title) return + // Capture the pre-rename title so we can roll back if the PATCH fails. + const previousTitle = current?.title ?? null + setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c))) + try { + const res = await fetch(`/api/agent/conversations/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + } catch { + // Revert the optimistic rename so the list stays in sync with the server. + setConversations((prev) => + prev.map((c) => (c.id === id ? { ...c, title: previousTitle } : c)), + ) + toast({ + variant: 'destructive', + title: 'Kunde inte byta namn på konversationen.', + }) + } + } + + return ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Sök konversationer…" + className="w-full rounded-md border border-border bg-background pl-8 pr-7 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> + {query.length > 0 && ( + + )} +
+
+ +
+ {loading ? ( +
+ Hämtar… +
+ ) : error ? ( +
{error}
+ ) : grouped.length === 0 ? ( +
+ + {conversations.length === 0 ? 'Inga konversationer ännu.' : 'Inga träffar.'} +
+ ) : ( + grouped.map(({ bucket, rows }) => ( +
+

+ {BUCKET_LABELS[bucket]} +

+
    + {rows.map((c) => ( +
  • + {editingId === c.id ? ( +
    + setEditValue(e.target.value)} + onBlur={() => void commitEdit(c.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + ;(e.target as HTMLInputElement).blur() + } else if (e.key === 'Escape') { + e.preventDefault() + cancelEdit() + } + }} + placeholder="Namnge konversationen…" + maxLength={200} + aria-label="Nytt namn på konversationen" + className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
    + ) : ( +
    + + +
    + )} +
  • + ))} +
+
+ )) + )} +
+
+ ) +} diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx index 6c8da3b8..490c8f4d 100644 --- a/components/agent/AgentSheet.tsx +++ b/components/agent/AgentSheet.tsx @@ -1,13 +1,14 @@ 'use client' import { useEffect, useState } from 'react' -import { X, Expand } from 'lucide-react' -import Link from 'next/link' -import AgentChat from './AgentChat' +import { X, Expand, Shrink, PanelRightClose, Eraser, History, ChevronLeft, Loader2 } from 'lucide-react' +import AgentChat, { normalizeStoredMessages, type ChatMessage } from './AgentChat' import AgentAvatar from './AgentAvatar' +import AgentSessionList from './AgentSessionList' import SandboxAgentPreview from './SandboxAgentPreview' import { useAgentSheet } from './AgentSheetProvider' import { useCompanyOptional } from '@/contexts/CompanyContext' +import { cn } from '@/lib/utils' // Undimmed non-modal side sheet — sits above the page on a hairline border + // shadow, but the page underneath stays fully interactive. Plan §3b. @@ -21,72 +22,251 @@ interface Props { intentArgs?: Record contextRef?: string seedUserMessage?: string + // Hidden (display:none) but still mounted so the conversation survives. The + // provider keeps rendering this component; we just visually remove it. + collapsed: boolean + onCollapse: () => void + onRestart: () => void onClose: () => void } +interface LoadedConversation { + id: string + intentId: string + contextRef: string | null + title: string | null + messages: ChatMessage[] +} + export default function AgentSheet({ intentId, intentArgs, contextRef, seedUserMessage, + collapsed, + onCollapse, + onRestart, onClose, }: Props) { + // Live conversation id from the active AgentChat (fresh sessions report it via + // onConversationIdChange; resumed ones we set directly on select). const [conversationId, setConversationId] = useState(null) + // 'chat' shows the conversation; 'list' shows the session picker. + const [view, setView] = useState<'chat' | 'list'>('chat') + // A past conversation the user picked from the list, hydrated for resume. When + // set, it replaces the intent-driven fresh chat. + const [loaded, setLoaded] = useState(null) + const [loadingConversation, setLoadingConversation] = useState(false) + const [loadError, setLoadError] = useState(null) + // Enlarge the panel IN PLACE (no navigation) — the user stays on the current + // page (e.g. /bookkeeping) with a wider reading/verifying surface. + const [expanded, setExpanded] = useState(false) const { identity } = useAgentSheet() const companyCtx = useCompanyOptional() const isSandbox = companyCtx?.isSandbox ?? false const agentName = identity.displayName?.trim() || null const sheetTitle = intentToTitle(intentId, agentName) + const displayTitle = loaded ? (loaded.title ?? intentToTitle(loaded.intentId, agentName)) : sheetTitle + const activeConversationId = loaded?.id ?? conversationId - // Esc closes the sheet. + // Esc: back out of the session list first, otherwise close. Never while + // collapsed (the sheet is hidden off-screen, so Esc belongs elsewhere). useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (collapsed || e.key !== 'Escape') return + if (view === 'list') setView('chat') + else onClose() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [onClose]) + }, [onClose, collapsed, view]) + + // Move focus off the sheet before hiding it, so it never sits on a + // display:none node (accessibility). + const handleCollapse = () => { + if (typeof document !== 'undefined') { + ;(document.activeElement as HTMLElement | null)?.blur() + } + onCollapse() + } + + // Resume a past conversation inline: fetch its messages, hydrate, and swap the + // sheet back to the chat view. Picking the one already open just closes the + // list (keeps its live in-memory state instead of re-hydrating it). + async function handleSelectConversation(id: string) { + if (id === activeConversationId) { + setView('chat') + return + } + setView('chat') + setLoaded(null) + setLoadingConversation(true) + setLoadError(null) + try { + const res = await fetch(`/api/agent/conversations/${id}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = (await res.json()) as { + data?: { + conversation: { + id: string + intent_id: string + context_ref: string | null + title: string | null + } + messages: { role: string; content: unknown; hidden?: boolean | null }[] + } + } + const data = json.data + if (!data) throw new Error('missing data') + setLoaded({ + id: data.conversation.id, + intentId: data.conversation.intent_id, + contextRef: data.conversation.context_ref, + title: data.conversation.title, + messages: normalizeStoredMessages(data.messages), + }) + setConversationId(data.conversation.id) + } catch { + setLoadError('Kunde inte öppna konversationen.') + } finally { + setLoadingConversation(false) + } + } return (
-
- -

{sheetTitle}

-
- {conversationId && !isSandbox && ( - - - - )} + {view === 'list' ? ( +
+ +

Konversationer

-
-
+ + ) : ( +
+ {!isSandbox && ( + + )} + +

{displayTitle}

+
+ {/* Grow/shrink the panel in place — NEVER navigates away, so the + user stays on the current page. Hidden on mobile where the sheet + is already full-width (the toggle would be a no-op). */} + {!isSandbox && ( + + )} + {/* Labeled (not icon-only) so it isn't mistaken for close/minimize — + and gated on an existing conversation so there's nothing to + mis-click on a fresh, empty chat. */} + {activeConversationId && !isSandbox && ( + + )} + + +
+
+ )} {isSandbox ? ( + ) : view === 'list' ? ( + + ) : loadingConversation ? ( +
+ Öppnar konversation… +
+ ) : loadError ? ( +
+

{loadError}

+ +
+ ) : loaded ? ( + setConversationId(id)} + /> ) : ( void closeAgentSheet: () => void + // Collapse hides the sheet WITHOUT unmounting it, so the in-memory + // conversation (messages, streaming, pending approval cards) survives — the + // floating trigger re-expands the same session. Distinct from close, which + // ends the session entirely. + collapseAgentSheet: () => void + expandAgentSheet: () => void + // Discard the current thread and start a fresh conversation on the same + // intent (the header "Ny konversation" control). Implemented by remounting + // the sheet via a nonce in its key. + restartAgentSheet: () => void + // True while a session exists (open or collapsed). isOpen: boolean + // True while a session exists but is minimized off-screen. + collapsed: boolean // Agent name + avatar — set once from the server-loaded agent_profile // and exposed through context so the trigger / chat headers can render // them without their own fetches. Null when the user hasn't verified a @@ -55,26 +68,56 @@ interface AgentSheetProviderProps { export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) { const [activeArgs, setActiveArgs] = useState(null) + // Collapsed = session alive but hidden. Kept separate from activeArgs so + // collapsing never unmounts AgentChat (which would wipe the conversation). + const [collapsed, setCollapsed] = useState(false) + // Bumped by restartAgentSheet to force a fresh AgentChat mount (a new thread) + // on the same intent, without closing the sheet. + const [restartNonce, setRestartNonce] = useState(0) const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => { setActiveArgs(args) + setCollapsed(false) }, []) const closeAgentSheet = useCallback(() => { setActiveArgs(null) + setCollapsed(false) }, []) - const resolvedIdentity: AgentIdentity = - identity ?? { displayName: null, avatarId: null, isVerified: false } + const collapseAgentSheet = useCallback(() => setCollapsed(true), []) + const expandAgentSheet = useCallback(() => setCollapsed(false), []) + const restartAgentSheet = useCallback(() => { + setRestartNonce((n) => n + 1) + setCollapsed(false) + }, []) + + const resolvedIdentity = useMemo( + () => identity ?? { displayName: null, avatarId: null, isVerified: false }, + [identity], + ) const value = useMemo( () => ({ openAgentSheet, closeAgentSheet, + collapseAgentSheet, + expandAgentSheet, + restartAgentSheet, isOpen: activeArgs !== null, + collapsed, identity: resolvedIdentity, }), - [openAgentSheet, closeAgentSheet, activeArgs, resolvedIdentity], + [ + openAgentSheet, + closeAgentSheet, + collapseAgentSheet, + expandAgentSheet, + restartAgentSheet, + activeArgs, + collapsed, + resolvedIdentity, + ], ) return ( @@ -82,11 +125,14 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro {children} {activeArgs && ( )} diff --git a/components/agent/AgentTrigger.tsx b/components/agent/AgentTrigger.tsx index c9adcb6b..6230f434 100644 --- a/components/agent/AgentTrigger.tsx +++ b/components/agent/AgentTrigger.tsx @@ -27,21 +27,28 @@ import { CAPABILITY } from '@/lib/entitlements/keys' // "Fråga assistenten" in Dokumentinkorgen — both passing a transaction_id the // pathname-only FAB can't know.) export default function AgentTrigger() { - const { openAgentSheet, isOpen, identity } = useAgentSheet() + const { openAgentSheet, expandAgentSheet, isOpen, collapsed, identity } = useAgentSheet() const pathname = usePathname() const router = useRouter() const hasAi = useCapability(CAPABILITY.ai) - if (isOpen) return null - // The /chat surface IS the chat — a floating "Fråga …" pill on top of it - // is redundant and overlaps the input. Suppress while the user is here. - if (pathname?.startsWith('/chat')) return null - // The verifikation editor is a dense regulatory surface (debits/credits, - // BAS codes, period locks) — a floating "Fråga … om denna verifikation" - // pill on top of it adds noise without earning its place. Suppress on - // /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new, - // and /bookkeeping/year-end still get the FAB. - { + // Sheet open AND visible → hide the FAB so the icon doesn't double up. When + // the session is merely collapsed we KEEP the FAB — it's the handle that + // brings the minimized conversation back. + if (isOpen && !collapsed) return null + // The page-suppression rules below apply only to a FRESH open. A collapsed + // session always gets its reopen handle, regardless of page — otherwise a + // conversation minimized on /chat or /bookkeeping/[id] could never be + // brought back. + if (!collapsed) { + // The /chat surface IS the chat — a floating "Fråga …" pill on top of it + // is redundant and overlaps the input. Suppress while the user is here. + if (pathname?.startsWith('/chat')) return null + // The verifikation editor is a dense regulatory surface (debits/credits, + // BAS codes, period locks) — a floating "Fråga … om denna verifikation" + // pill on top of it adds noise without earning its place. Suppress on + // /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new, + // and /bookkeeping/year-end still get the FAB. const segs = pathname?.split('/').filter(Boolean) ?? [] if (segs[0] === 'bookkeeping' && segs[1] && segs[1] !== 'year-end' && segs[1] !== 'new') { return null @@ -49,7 +56,8 @@ export default function AgentTrigger() { } // Pre-onboarding: no agent_profile.verified_at yet. The FAB would lead // into a generic chat with no specialization. Better to hide it until - // the user has finished /onboarding/agent. + // the user has finished /onboarding/agent. (A collapsed session implies the + // agent is already in use, so this only gates fresh opens in practice.) if (!identity.isVerified) return null const name = identity.displayName?.trim() || 'min assistent' @@ -57,23 +65,34 @@ export default function AgentTrigger() { // AI assistant runs on a paid cloud service. Without the capability, opening // the sheet would land the user in a chat whose send is dead. Keep the FAB // visible (it's the conversion surface) but route it to billing instead. - const labelText = !hasAi - ? `Uppgradera för att använda ${name}` - : dispatch.labelSuffix - ? `Fråga ${name} ${dispatch.labelSuffix}` - : `Fråga ${name}` + const labelText = collapsed + ? `Fortsätt med ${name}` + : !hasAi + ? `Uppgradera för att använda ${name}` + : dispatch.labelSuffix + ? `Fråga ${name} ${dispatch.labelSuffix}` + : `Fråga ${name}` + + const handleClick = () => { + // Collapsed → bring the existing session back, don't start a new one. + if (collapsed) { + expandAgentSheet() + return + } + if (!hasAi) { + router.push('/settings/billing') + return + } + openAgentSheet({ + intentId: dispatch.intentId, + intentArgs: dispatch.intentArgs, + contextRef: dispatch.contextRef, + }) + } return ( - -
- + {/* Always-visible action icons. Touch-friendly, no + hover-only invisibility on mobile. Laid out + horizontally so three icons don't stack and inflate + the row height. */} +
+ + + +
+ + )} ))} @@ -322,26 +330,3 @@ export default function ChatSidebar({ initialConversations }: Props) { ) } - -function intentLabel(intentId: string): string { - switch (intentId) { - case 'general.help': - return 'Fråga din assistent' - case 'transaction.categorization': - return 'Hjälp med transaktion' - case 'invoice.draft': - return 'Hjälp med faktura' - case 'supplier_invoice.review': - return 'Granska leverantörsfaktura' - case 'vat.review': - return 'Granska moms­deklaration' - case 'bokslut.step': - return 'Hjälp med bokslut' - case 'verifikation.draft': - return 'Hjälp med verifikation' - case 'kpi.explain': - return 'Förklara nyckeltal' - default: - return intentId - } -} diff --git a/components/agent/conversation-display.ts b/components/agent/conversation-display.ts new file mode 100644 index 00000000..bd400fa0 --- /dev/null +++ b/components/agent/conversation-display.ts @@ -0,0 +1,103 @@ +// Shared display helpers for the agent conversation list — used by both the +// full-page /chat sidebar (ChatSidebar) and the in-sheet "resume conversation" +// list (AgentSessionList). Pure functions; no React. Keeping them in one place +// means the Idag / Igår / Denna vecka / Äldre grouping and the relative-time +// labels stay identical across both surfaces. + +export interface ConversationRow { + id: string + intent_id: string + context_ref: string | null + title: string | null + pinned: boolean + archived: boolean + last_message_at: string | null + last_message_preview: string | null + created_at: string +} + +// Time buckets for date grouping. Computed once per render against now(). +// Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from +// Mail and iMessage. +export type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older' + +export const BUCKET_LABELS: Record = { + pinned: 'Fästade', + today: 'Idag', + yesterday: 'Igår', + thisWeek: 'Denna vecka', + older: 'Äldre', +} + +export const BUCKET_ORDER: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older'] + +export function bucketFor(c: ConversationRow): DateBucket { + if (c.pinned) return 'pinned' + const when = c.last_message_at ?? c.created_at + if (!when) return 'older' + const t = new Date(when) + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000) + const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000) + if (t >= todayStart) return 'today' + if (t >= yesterdayStart) return 'yesterday' + if (t >= weekStart) return 'thisWeek' + return 'older' +} + +// Compact relative-time label shown to the right of each row. Locale-tuned +// to feel native in Swedish without going full date-fns. +export function relativeTime(iso: string | null | undefined): string { + if (!iso) return '' + const t = new Date(iso).getTime() + const now = Date.now() + const diffMin = Math.round((now - t) / 60000) + if (diffMin < 1) return 'nu' + if (diffMin < 60) return `${diffMin} min` + const diffHr = Math.round(diffMin / 60) + if (diffHr < 24) return `${diffHr} h` + const diffDay = Math.round(diffHr / 24) + if (diffDay < 7) return `${diffDay} d` + return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' }) +} + +export function intentLabel(intentId: string): string { + switch (intentId) { + case 'general.help': + return 'Fråga din assistent' + case 'transaction.categorization': + return 'Hjälp med transaktion' + case 'invoice.draft': + return 'Hjälp med faktura' + case 'supplier_invoice.review': + return 'Granska leverantörsfaktura' + case 'vat.review': + return 'Granska moms­deklaration' + case 'bokslut.step': + return 'Hjälp med bokslut' + case 'verifikation.draft': + return 'Hjälp med verifikation' + case 'kpi.explain': + return 'Förklara nyckeltal' + default: + return intentId + } +} + +// Group a flat (already server-sorted: pinned first, then last_message_at desc) +// list into ordered, non-empty buckets. Shared so both list surfaces render +// the same section order. +export function groupConversations( + rows: ConversationRow[], +): { bucket: DateBucket; rows: ConversationRow[] }[] { + const buckets: Record = { + pinned: [], + today: [], + yesterday: [], + thisWeek: [], + older: [], + } + for (const c of rows) buckets[bucketFor(c)].push(c) + return BUCKET_ORDER.map((b) => ({ bucket: b, rows: buckets[b] })).filter((g) => g.rows.length > 0) +} diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index c8cc8c94..e26841f3 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -4,6 +4,12 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { Plus } from 'lucide-react' import { Input } from '@/components/ui/input' import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions' +import { + buildAccountIndex, + searchAccounts, + type SearchableAccount, + type AccountSearchItem, +} from '@/lib/bookkeeping/account-search' import type { BASAccount } from '@/types' interface AccountComboboxProps { @@ -19,51 +25,60 @@ interface AccountComboboxProps { // dropdown's empty state. The current search string is passed so the caller // can prefill the create dialog. onCreateAccount?: (prefill: string) => void + // The full BAS catalogue. When provided, accounts not yet in `accounts` + // (the company's active chart) become searchable by name and are surfaced + // with the `notActivatedLabel` marker; picking one activates it at commit + // via the existing ACCOUNTS_NOT_IN_CHART rail. + catalog?: SearchableAccount[] + // Label shown next to catalogue-only (not-yet-activated) accounts. Defaults + // to Swedish; bilingual hosts pass a localized string. + notActivatedLabel?: string // Extra classes merged into the trigger Input — callers pass `h-8` for dense // table rows, omit it to use the default Input height. className?: string + // Optional callback ref to the underlying , invoked alongside the + // internal one. Lets a parent imperatively focus the field (e.g. auto-advance + // to the next konteringsrad's account on Enter — see JournalEntryForm.focusAccount). + inputRef?: React.RefCallback } -const MAX_RESULTS = 50 - -export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, className }: AccountComboboxProps) { +export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef }: AccountComboboxProps) { const [search, setSearch] = useState(value) const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(0) const containerRef = useRef(null) - const inputRef = useRef(null) + const internalInputRef = useRef(null) const listRef = useRef(null) + // Attach the internal ref (used for focus bookkeeping) and forward the element + // to any external callback ref the parent passed. + const setInputRef = useCallback((el: HTMLInputElement | null) => { + internalInputRef.current = el + inputRef?.(el) + }, [inputRef]) + // Sync external value changes into the search field useEffect(() => { setSearch(value) }, [value]) - // Filter accounts based on search input - const filteredAccounts = useMemo(() => { - if (!search) return accounts.slice(0, MAX_RESULTS) + // Index the active chart + the full BAS catalogue once per source change. + // Searching it per keystroke is then just substring checks over pre-folded + // haystacks (number + name + description, diacritics stripped). + const accountIndex = useMemo( + () => buildAccountIndex({ active: accounts, catalog }), + [accounts, catalog] + ) - const trimmed = search.trim() - if (!trimmed) return accounts.slice(0, MAX_RESULTS) - - const startsWithDigit = /^\d/.test(trimmed) - - if (startsWithDigit) { - return accounts - .filter((a) => a.account_number.startsWith(trimmed)) - .slice(0, MAX_RESULTS) - } - - const lowerSearch = trimmed.toLowerCase() - return accounts - .filter((a) => a.account_name.toLowerCase().includes(lowerSearch)) - .slice(0, MAX_RESULTS) - }, [accounts, search]) + const filteredAccounts = useMemo( + () => searchAccounts(accountIndex, search), + [accountIndex, search] + ) // Group filtered accounts by class const groupedAccounts = useMemo(() => { - const groups: { className: string; accounts: BASAccount[] }[] = [] - const groupMap = new Map() + const groups: { className: string; accounts: AccountSearchItem[] }[] = [] + const groupMap = new Map() for (const account of filteredAccounts) { const className = getAccountClassName(account.account_class) @@ -163,8 +178,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o if (/^\d{4}$/.test(newValue)) { onChange(newValue) // Only treat as a commit when the value newly becomes this account, so - // editing an already-committed number doesn't keep stealing focus. - if (newValue !== value) onCommit?.(newValue) + // editing an already-committed number doesn't keep stealing focus. On + // commit, close the dropdown too — focus advances to the amount field, so + // a lingering open list would just cover the rows below. + if (newValue !== value) { + onCommit?.(newValue) + setIsOpen(false) + return + } } if (!isOpen) { setIsOpen(true) @@ -176,6 +197,9 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o } const handleBlur = () => { + // Close the dropdown as soon as focus leaves, so it never lingers open over + // the rows below when focus advances via keyboard (Enter/Tab). + setIsOpen(false) // Small delay to allow dropdown click to fire first. Keep any 4-digit // numeric value even if it's not in the currently-active chart — the // submit handler will prompt to activate it. @@ -190,7 +214,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o return (
{group.className}
- {group.accounts.map((account) => { - const flatIndex = flatList.indexOf(account) + {group.accounts.map((item) => { + const flatIndex = flatList.indexOf(item) const isHighlighted = flatIndex === highlightedIndex return ( ) })} diff --git a/components/bookkeeping/CorrectOpeningBalanceDialog.tsx b/components/bookkeeping/CorrectOpeningBalanceDialog.tsx new file mode 100644 index 00000000..e632addb --- /dev/null +++ b/components/bookkeeping/CorrectOpeningBalanceDialog.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useMemo, useState, useCallback } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { AlertTriangle } from 'lucide-react' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' +import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data' +import OpeningBalanceRowEditor, { + type EditableRow, + type OpeningBalanceEditorState, +} from '@/components/import/OpeningBalanceRowEditor' +import type { JournalEntry, JournalEntryLine } from '@/types' + +interface Props { + /** The currently-linked, posted opening-balance verifikat being corrected. */ + entry: JournalEntry + open: boolean + onOpenChange: (open: boolean) => void + onCorrected: () => void +} + +let seedIdCounter = 0 + +// Map the booked IB's lines into editable rows. account_name isn't stored on +// the line, so resolve it from BAS for display (cosmetic — only account_number +// + amounts are sent on save). +function seedRowsFromEntry(entry: JournalEntry): EditableRow[] { + const lines = ((entry.lines || []) as JournalEntryLine[]) + .slice() + .sort((a, b) => a.sort_order - b.sort_order) + + return lines.map((l) => { + const bas = BAS_REFERENCE.find((a) => a.account_number === l.account_number) + return { + id: l.id || `seed_${++seedIdCounter}`, + account_number: l.account_number, + account_name: bas?.account_name ?? '', + debit_amount: Number(l.debit_amount) || 0, + credit_amount: Number(l.credit_amount) || 0, + validation_errors: [], + bas_match: bas?.account_name ?? null, + } + }) +} + +/** + * Inline correction of an already-booked opening-balance verifikat. The user + * edits the IB's lines directly; on save we POST to + * /api/import/opening-balance/correct, which (BFL-compliant) stornoes the old + * IB, books a corrected one, and relinks the period to it. Works regardless of + * how the IB was created (SIE import, CSV/Excel import, or year-end carry). + */ +export default function CorrectOpeningBalanceDialog({ + entry, + open, + onOpenChange, + onCorrected, +}: Props) { + const { toast } = useToast() + const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry]) + const [state, setState] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = useCallback(async () => { + if (!state?.canSubmit || isSubmitting) return + + setIsSubmitting(true) + try { + const lines = state.rows + .filter((r) => r.debit_amount > 0 || r.credit_amount > 0) + .map((r) => ({ + account_number: r.account_number, + debit_amount: r.debit_amount, + credit_amount: r.credit_amount, + })) + + const res = await fetch('/api/import/opening-balance/correct', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fiscal_period_id: entry.fiscal_period_id, lines }), + }) + + const result = await res.json() + + if (!res.ok) { + const err = new Error('Failed to correct opening balances') as Error & { + body?: unknown + status?: number + } + err.body = result + err.status = res.status + throw err + } + + toast({ + title: 'Ingående balanser korrigerade', + description: 'Den gamla IB-verifikationen stornades och en ny bokfördes.', + }) + onOpenChange(false) + onCorrected() + } catch (err) { + const anyErr = err as { body?: unknown; status?: number } + toast({ + title: 'Kunde inte korrigera ingående balanser', + description: getErrorMessage(anyErr.body ?? err, { + context: 'journal_entry', + statusCode: anyErr.status, + }), + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + }, [state, isSubmitting, entry.fiscal_period_id, toast, onOpenChange, onCorrected]) + + return ( + + + + Korrigera ingående balanser + + Ändra beloppen nedan och spara. Den befintliga IB-verifikationen ( + {formatVoucher(entry)}) makuleras och en ny bokförs med de korrigerade beloppen. + + + + {/* Storno explanation — a booked verifikat can't be edited in place */} +
+ +

+ En bokförd verifikation kan inte ändras direkt (Bokföringslagen). När du sparar stornas + den gamla IB-verifikationen och en ny bokförs — båda sparas som en spårbar rättelse. +

+
+ + + + + + + +
+
+ ) +} diff --git a/components/bookkeeping/DocumentViewerPane.tsx b/components/bookkeeping/DocumentViewerPane.tsx new file mode 100644 index 00000000..5449b1b8 --- /dev/null +++ b/components/bookkeeping/DocumentViewerPane.tsx @@ -0,0 +1,189 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { ExternalLink, FileText } from 'lucide-react' +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' + +/** + * Side-by-side document viewer used while booking manually, so the user can + * read the figures off a receipt/invoice while filling in the journal entry. + * + * Renders by document id through the same-origin inline proxy + * (/api/documents/:id/inline). PDFs use rather + * than