diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 3c271137..e553e6a4 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -107,6 +107,7 @@ const SIEPreviewStep = dynamic(() => import('@/components/import/SIEPreviewStep' const AccountMappingStep = dynamic(() => import('@/components/import/AccountMappingStep'), { loading: ImportStepLoading }) const ImportReviewStep = dynamic(() => import('@/components/import/ImportReviewStep'), { loading: ImportStepLoading }) const ImportResultStep = dynamic(() => import('@/components/import/ImportResultStep'), { loading: ImportStepLoading }) +const SIEImportHistory = dynamic(() => import('@/components/import/SIEImportHistory'), { loading: ImportStepLoading }) // ============================================================ // Bank File Import Wizard Steps @@ -2050,6 +2051,7 @@ export default function ImportPage() { const [view, setView] = useState<'import' | 'export'>('import') const [sieDialogOpen, setSieDialogOpen] = useState(false) const [cloudOpen, setCloudOpen] = useState(false) + const [sieHistoryOpen, setSieHistoryOpen] = useState(false) const [userId, setUserId] = useState('') const [exportPeriodId, setExportPeriodId] = useState(null) const [exportExcludeClosing, setExportExcludeClosing] = useState(true) @@ -2253,7 +2255,18 @@ export default function ImportPage() { sub={t('sie_description')} onClick={() => setMode('sie')} /> + setSieHistoryOpen((v) => !v)} + /> + {sieHistoryOpen && ( +
+ +
+ )}

{t('pgnote')}

) : ( diff --git a/app/api/import/sie/[id]/undo/__tests__/route.test.ts b/app/api/import/sie/[id]/undo/__tests__/route.test.ts new file mode 100644 index 00000000..ef2e91ef --- /dev/null +++ b/app/api/import/sie/[id]/undo/__tests__/route.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for DELETE /api/import/sie/[id]/undo. + * + * Exercises the route through the real withRouteContext wrapper, mocking its + * auth/company/write dependencies and the undoSIEImport service. Covers: 401, + * 403 viewer, the { success, deletedEntries } passthrough, and the + * SIE_UNDO_FAILED envelope with the service's Swedish reason in details. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, +} from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const undoSIEImportMock = vi.fn() +vi.mock('@/lib/import/sie-import', () => ({ + undoSIEImport: (...args: unknown[]) => undoSIEImportMock(...args), +})) + +import { DELETE } from '../route' + +const routeParams = () => createMockRouteParams({ id: 'import-1' }) + +describe('DELETE /api/import/sie/[id]/undo', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(401) + expect(undoSIEImportMock).not.toHaveBeenCalled() + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(403) + expect(undoSIEImportMock).not.toHaveBeenCalled() + }) + + it('passes through { success, deletedEntries } on a successful undo', async () => { + undoSIEImportMock.mockResolvedValue({ success: true, deletedEntries: 214 }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ + success: boolean + deletedEntries: number + }>(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true, deletedEntries: 214 }) + expect(undoSIEImportMock).toHaveBeenCalledWith(supabase, 'company-1', 'import-1', 'user-1') + }) + + it('returns the SIE_UNDO_FAILED envelope when the service refuses', async () => { + undoSIEImportMock.mockResolvedValue({ + success: false, + deletedEntries: 0, + error: 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.', + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ + error: { + code: string + message: string + message_en: string + details?: { reason?: string } + } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('SIE_UNDO_FAILED') + expect(body.error.message).toBe('SIE-importen kunde inte ångras.') + expect(body.error.message_en).toBe('Failed to undo SIE import.') + expect(body.error.details?.reason).toBe( + 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.', + ) + }) +}) diff --git a/app/api/import/sie/__tests__/route.test.ts b/app/api/import/sie/__tests__/route.test.ts new file mode 100644 index 00000000..2fec087c --- /dev/null +++ b/app/api/import/sie/__tests__/route.test.ts @@ -0,0 +1,122 @@ +/** + * Tests for GET /api/import/sie (the SIE import list). + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, the { data, count, limit, offset } happy-path + * shape, the status filter, and the 500 path returning a Swedish error. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, +} from '@/tests/helpers' + +const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +// Next.js 16 always passes a params promise, even on static routes. +const staticParams = () => createMockRouteParams({}) + +const makeImportRow = (overrides: Record = {}) => ({ + id: 'import-1', + company_id: 'company-1', + filename: 'bokforing-2025.se', + fiscal_year_start: '2025-01-01', + fiscal_year_end: '2025-12-31', + transactions_count: 214, + status: 'completed', + imported_at: '2026-08-01T09:00:00Z', + created_at: '2026-08-01T08:59:00Z', + ...overrides, +}) + +describe('GET /api/import/sie', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await GET(createMockRequest('/api/import/sie'), staticParams()) + + expect(response.status).toBe(401) + }) + + it('returns { data, count, limit, offset } with defaults', async () => { + const rows = [makeImportRow(), makeImportRow({ id: 'import-2', status: 'undone' })] + enqueue({ data: rows, count: 2 }) + + const response = await GET(createMockRequest('/api/import/sie'), staticParams()) + const { status, body } = await parseJsonResponse<{ + data: { id: string }[] + count: number + limit: number + offset: number + }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(2) + expect(body.data[0].id).toBe('import-1') + expect(body.count).toBe(2) + expect(body.limit).toBe(20) + expect(body.offset).toBe(0) + + // Scoped to the active company; ordered newest-first; default range 0-19. + expect(findCalls('sie_imports', 'eq')).toContainEqual(['company_id', 'company-1']) + expect(findCalls('sie_imports', 'order')).toContainEqual(['created_at', { ascending: false }]) + expect(findCalls('sie_imports', 'range')).toContainEqual([0, 19]) + }) + + it('applies the status filter and custom limit/offset', async () => { + enqueue({ data: [makeImportRow()], count: 1 }) + + const response = await GET( + createMockRequest('/api/import/sie', { + searchParams: { status: 'completed', limit: '5', offset: '10' }, + }), + staticParams(), + ) + const { status, body } = await parseJsonResponse<{ limit: number; offset: number }>(response) + + expect(status).toBe(200) + expect(body.limit).toBe(5) + expect(body.offset).toBe(10) + expect(findCalls('sie_imports', 'eq')).toContainEqual(['status', 'completed']) + expect(findCalls('sie_imports', 'range')).toContainEqual([10, 14]) + }) + + it('returns 500 with a Swedish error message on a database error', async () => { + enqueue({ data: null, error: { message: 'connection reset by peer' } }) + + const response = await GET(createMockRequest('/api/import/sie'), staticParams()) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(typeof body.error).toBe('string') + // The raw driver message must not leak; the mapped message is Swedish. + expect(body.error).not.toContain('connection reset') + expect(body.error).toBe('Något gick fel. Försök igen.') + }) +}) diff --git a/components/import/SIEImportHistory.tsx b/components/import/SIEImportHistory.tsx new file mode 100644 index 00000000..fc8a5f92 --- /dev/null +++ b/components/import/SIEImportHistory.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Undo2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useToast } from '@/components/ui/use-toast' +import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { cn, formatDate } from '@/lib/utils' + +/** + * Subset of the sie_imports row (GET /api/import/sie) actually rendered here. + * `status` stays a plain string: the DB CHECK also allows values this table + * never renders specially (e.g. the legacy 'mapped'), which fall back to raw + * muted text instead of crashing on a missing translation key. + */ +interface SIEImportListRow { + id: string + filename: string + fiscal_year_start: string | null + fiscal_year_end: string | null + transactions_count: number + status: string + imported_at: string | null + created_at: string +} + +/** + * At or above this voucher count the undo confirm adds a warning that undoing + * can leave gaps in the voucher numbering when other vouchers were booked + * after the import (gaps need a documented explanation per BFNAR 2013:2). + */ +const GAP_WARNING_VOUCHER_COUNT = 100 + +const STATUS_LABEL_KEY: Record = { + completed: 'sie_history_status_completed', + undone: 'sie_history_status_undone', + replaced: 'sie_history_status_replaced', + failed: 'sie_history_status_failed', + pending: 'sie_history_status_pending', +} + +/** + * Chips mark exceptions (design.md convention 5): the normal 'completed' + * state renders as muted text; only deviating states get a Badge. + */ +const STATUS_BADGE_VARIANT: Record = { + undone: 'secondary', + replaced: 'secondary', + failed: 'destructive', + pending: 'warning', +} + +/** + * History of past SIE imports with per-row undo for completed ones. + * Rendered fold-open from the 'Tidigare SIE-importer' row on the import tab. + */ +export default function SIEImportHistory() { + const t = useTranslations('import') + const { toast } = useToast() + const [rows, setRows] = useState(null) + const [loadFailed, setLoadFailed] = useState(false) + const [pendingUndo, setPendingUndo] = useState(null) + + const fetchImports = useCallback(async () => { + try { + const res = await fetch('/api/import/sie?limit=20') + if (!res.ok) { + setLoadFailed(true) + return + } + const data = await res.json() + setRows(Array.isArray(data.data) ? data.data : []) + setLoadFailed(false) + } catch { + setLoadFailed(true) + } + }, []) + + useEffect(() => { + void fetchImports() + }, [fetchImports]) + + // Deliberately no client-side timeout: undoing a large import can take + // minutes (the route runs with maxDuration 300) and the confirm dialog + // stays open with its spinner until this resolves. + const handleUndoConfirm = useCallback(async () => { + if (!pendingUndo) return + try { + const res = await fetch(`/api/import/sie/${pendingUndo.id}/undo`, { method: 'DELETE' }) + const data = await res.json() + + if (!res.ok) { + toast({ + title: t('sie_history_undo_failed'), + description: getErrorMessage(data), + variant: 'destructive', + }) + return + } + + toast({ + title: t('sie_history_undo_success_title'), + description: t('sie_history_undo_success', { count: data.deletedEntries ?? 0 }), + }) + await fetchImports() + } catch (err) { + toast({ + title: t('sie_history_undo_failed'), + description: getErrorMessage(err), + variant: 'destructive', + }) + } + }, [pendingUndo, fetchImports, t, toast]) + + const fiscalYearLabel = (row: SIEImportListRow): string => { + if (row.fiscal_year_start && row.fiscal_year_end) { + return t('sie_history_fiscal_year_range', { + start: formatDate(row.fiscal_year_start), + end: formatDate(row.fiscal_year_end), + }) + } + if (row.fiscal_year_start) return formatDate(row.fiscal_year_start) + if (row.fiscal_year_end) return formatDate(row.fiscal_year_end) + return '-' + } + + const statusCell = (status: string) => { + const labelKey = STATUS_LABEL_KEY[status] + const label = labelKey ? t(labelKey) : status + const variant = STATUS_BADGE_VARIANT[status] + if (!variant) { + return {label} + } + return ( + + {label} + + ) + } + + if (loadFailed) { + return

{t('sie_history_load_error')}

+ } + + if (rows === null) { + return ( +
+ + + +
+ ) + } + + if (rows.length === 0) { + return

{t('sie_history_empty')}

+ } + + const confirmDescription = pendingUndo + ? t('sie_history_undo_confirm_description', { count: pendingUndo.transactions_count }) + + (pendingUndo.transactions_count >= GAP_WARNING_VOUCHER_COUNT + ? '\n\n' + t('sie_history_undo_gap_warning') + : '') + : '' + + return ( +
+
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
{t('sie_history_col_file')}{t('sie_history_col_date')}{t('sie_history_col_fiscal_year')}{t('sie_history_col_vouchers')}{t('sie_history_col_status')} + {t('sie_history_undo_button')} +
{row.filename} + {formatDate(row.imported_at ?? row.created_at)} + + {fiscalYearLabel(row)} + {row.transactions_count}{statusCell(row.status)} + {row.status === 'completed' && ( + + )} +
+
+ + { + if (!open) setPendingUndo(null) + }} + title={t('sie_history_undo_confirm_title')} + description={confirmDescription} + confirmLabel={t('sie_history_undo_confirm_label')} + onConfirm={handleUndoConfirm} + /> +
+ ) +} diff --git a/messages/en.json b/messages/en.json index 1471726b..6a532d7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6863,6 +6863,29 @@ "cloud_row_description": "Continuous backup of the archive to Google Drive", "help_text": "Every way in and out lives here: bank connections, file imports and migrations from other systems, plus SIE export and backups. Every import is reviewed before anything is booked.", "pgnote": "Every import goes through the same steps: upload, map columns, review, result. Nothing is booked without you seeing it first.", + "sie_history_title": "Previous SIE imports", + "sie_history_description": "View history and undo a completed import", + "sie_history_empty": "No previous SIE imports.", + "sie_history_load_error": "Could not load the import history.", + "sie_history_col_file": "File name", + "sie_history_col_date": "Date", + "sie_history_col_fiscal_year": "Fiscal year", + "sie_history_col_vouchers": "Vouchers", + "sie_history_col_status": "Status", + "sie_history_fiscal_year_range": "{start} to {end}", + "sie_history_status_completed": "Completed", + "sie_history_status_undone": "Undone", + "sie_history_status_replaced": "Replaced", + "sie_history_status_failed": "Failed", + "sie_history_status_pending": "In progress", + "sie_history_undo_button": "Undo", + "sie_history_undo_confirm_title": "Undo the entire import?", + "sie_history_undo_confirm_label": "Undo import", + "sie_history_undo_confirm_description": "This deletes {count, plural, =1 {1 voucher} other {# vouchers}} and clears opening balances from this import. Attached documents are detached but kept.", + "sie_history_undo_gap_warning": "This import contains many vouchers: if anything was booked after the import, gaps can appear in the voucher numbering, and gaps require a documented explanation.", + "sie_history_undo_success_title": "Import undone", + "sie_history_undo_success": "{count, plural, =1 {1 voucher was deleted} other {# vouchers were deleted}}.", + "sie_history_undo_failed": "Could not undo import", "bank_format_wise_statement": "Wise balance statement" }, "annualReportStudio": { diff --git a/messages/sv.json b/messages/sv.json index 25524b8a..21ef830b 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6863,6 +6863,29 @@ "cloud_row_description": "Löpande säkerhetskopia av arkivet till Google Drive", "help_text": "Här samlas alla vägar in och ut: bankkoppling, filimporter och flytt från andra system, samt export av SIE och säkerhetskopior. Varje import granskas innan något bokförs.", "pgnote": "Varje import går genom samma steg: ladda upp, mappa kolumner, granska, resultat. Inget bokförs utan att du ser det först.", + "sie_history_title": "Tidigare SIE-importer", + "sie_history_description": "Se historik och ångra en genomförd import", + "sie_history_empty": "Inga tidigare SIE-importer.", + "sie_history_load_error": "Kunde inte hämta importhistoriken.", + "sie_history_col_file": "Filnamn", + "sie_history_col_date": "Datum", + "sie_history_col_fiscal_year": "Räkenskapsår", + "sie_history_col_vouchers": "Verifikat", + "sie_history_col_status": "Status", + "sie_history_fiscal_year_range": "{start} till {end}", + "sie_history_status_completed": "Slutförd", + "sie_history_status_undone": "Ångrad", + "sie_history_status_replaced": "Ersatt", + "sie_history_status_failed": "Misslyckad", + "sie_history_status_pending": "Pågående", + "sie_history_undo_button": "Ångra", + "sie_history_undo_confirm_title": "Ångra hela importen?", + "sie_history_undo_confirm_label": "Ångra import", + "sie_history_undo_confirm_description": "Detta raderar {count, plural, =1 {1 verifikation} other {# verifikationer}} och rensar ingående balanser från den här importen. Bifogade dokument blir okopplade men finns kvar.", + "sie_history_undo_gap_warning": "Importen innehåller många verifikat: om annat har bokförts efter importen kan luckor uppstå i verifikatnumreringen, och luckor kräver en dokumenterad förklaring.", + "sie_history_undo_success_title": "Import ångrad", + "sie_history_undo_success": "{count, plural, =1 {1 verifikation raderades} other {# verifikationer raderades}}.", + "sie_history_undo_failed": "Kunde inte ångra import", "bank_format_wise_statement": "Wise kontoutdrag" }, "annualReportStudio": {