diff --git a/DECISIONS.md b/DECISIONS.md index 9b05a26f..7a0faee3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1129,3 +1129,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-20] RIP-3 chat cutover is scoped to general.help only: the free-form Q&A /chat panel now runs on a page-scoped single-call console (AskConsole → POST /api/agent/ask, persist:true), so it works on any configured backend incl. a local OpenAI-compatible model. The tool-loop intents (transaction.categorization, invoice.draft, supplier_invoice.review) and the docked AgentSheet still use AgentChat + run-turn.ts because they stage operations and need the tool loop, so run-turn.ts is NOT deleted here (the plan gates its deletion on "once nothing calls them"; RIP-4 migrates the rest). Persistence is an opt-in branch on the existing /api/agent/ask route rather than a new endpoint, so page-scoped one-off asks (a report page) stay stateless; the console writes both turns to agent_conversations/agent_messages as canonical Anthropic text blocks so the /chat sidebar and resume keep working across old streaming threads and new single-call ones. [2026-08-19] Provider re-sync replace mode resolves EVERY overlapping completed sie_imports row and treats one it cannot resolve (not_found/not_completed) as a stale watermark to skip, importing the year fresh, but still aborts on a locked or closed period: an unresolvable row has nothing left to delete, while importing over entries that could not be deleted would duplicate verifikationer. [2026-08-20] The single-call /chat assistant answers over a bounded READ-ONLY tool loop (audit Option A: "single-call actions over the existing MCP tool functions"), plus an always-on company snapshot as the backstop: #1759 shipped a version that read only the company name/entity, so it answered "jag har ingen bokföringsdata" to every figures question. Rather than re-introduce the ripped streaming Anthropic runtime, the provider-agnostic lib/ai generateText gained optional `tools`/`maxSteps`: the OpenAI-compatible service forwards them to the Vercel AI SDK (stopWhen: stepCountIs) which drives the loop, and the Anthropic-family service hand-rolls a small loop against messages.create (kept on the raw Anthropic SDK so no new deps and hosted stays byte-identical for every non-tool caller). ask-service attaches ONLY the read slice of general.help's tool whitelist via agentToolRegistry (write/staging + memory-write tools excluded; readOnlyHint/destructiveHint re-checked), dispatched with the same agent_chat actor run-turn uses. Works on Bedrock and on any local model with function-calling (Qwen); a text-only model still answers status questions from the snapshot (company_settings + deadlines, never figures). Not chosen: deterministic-context-only (bounded coverage) and unifying both providers on the AI SDK (would need @ai-sdk/anthropic + @ai-sdk/amazon-bedrock deps and change the hosted path). +[2026-08-19] undo_bank_file_import (#1672) skips unbooked rows carrying payment_match_log history instead of weakening the audit_log_immutable delete guard: the log is append-only räkenskapsinformation (BFL 7 kap) per 20260323120000 ("Do NOT add cleanup/DELETE jobs") and the single-row DELETE route already refuses those rows (TRANSACTION_DELETE_HAS_AUDIT_TRAIL); the undo reports skipped_match_history so the user can ignore the stragglers. Rejected: a scoped trigger bypass like the GDPR account-delete RPC uses (erasure is a legal right overriding retention; an import undo is not). +[2026-08-19] transactions.bank_file_import_id has NO retroactive backfill: attribution by (format, date window) can mislink rows to the wrong batch when a company has several same-format imports, and "undo this import" must never delete rows from a different one. Imports executed before 20260820071500 are simply not undoable through this action. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index e7dd86bd..d13a77e5 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -122,6 +122,7 @@ const AccountMappingStep = dynamic(() => import('@/components/import/AccountMapp 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 }) +const BankFileImportHistory = dynamic(() => import('@/components/import/BankFileImportHistory'), { loading: ImportStepLoading }) const UnderlagImportWizard = dynamic(() => import('@/components/import/UnderlagImportWizard'), { loading: ImportStepLoading }) // ============================================================ @@ -2325,6 +2326,7 @@ export default function ImportPage() { const [archiveDialogOpen, setArchiveDialogOpen] = useState(false) const [cloudOpen, setCloudOpen] = useState(false) const [sieHistoryOpen, setSieHistoryOpen] = useState(false) + const [bankFileHistoryOpen, setBankFileHistoryOpen] = useState(false) const [userId, setUserId] = useState('') const [exportPeriodId, setExportPeriodId] = useState(null) const [exportExcludeClosing, setExportExcludeClosing] = useState(true) @@ -2537,12 +2539,23 @@ export default function ImportPage() { expanded={sieHistoryOpen} onClick={() => setSieHistoryOpen((v) => !v)} /> + setBankFileHistoryOpen((v) => !v)} + /> {sieHistoryOpen && (
)} + {bankFileHistoryOpen && ( +
+ +
+ )}

{t('pgnote')}

) : ( diff --git a/app/api/import/bank-file/[id]/undo/__tests__/route.test.ts b/app/api/import/bank-file/[id]/undo/__tests__/route.test.ts new file mode 100644 index 00000000..5ccf0458 --- /dev/null +++ b/app/api/import/bank-file/[id]/undo/__tests__/route.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for DELETE /api/import/bank-file/[id]/undo. + * + * Exercises the route through the real withRouteContext wrapper, mocking its + * auth/company/write dependencies and the undoBankFileImport service. Covers: + * 401, 403 viewer, 403 non-owner/admin (RPC 42501 mapped to + * BANK_FILE_UNDO_FORBIDDEN), 404 unknown import (BANK_FILE_UNDO_NOT_FOUND), + * the success passthrough with the skip report, and the + * BANK_FILE_UNDO_FAILED envelope with the service's Swedish reason. + */ +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 undoBankFileImportMock = vi.fn() +vi.mock('@/lib/import/bank-file/undo', () => ({ + undoBankFileImport: (...args: unknown[]) => undoBankFileImportMock(...args), +})) + +import { DELETE } from '../route' + +const routeParams = () => createMockRouteParams({ id: 'import-1' }) + +describe('DELETE /api/import/bank-file/[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/bank-file/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(401) + expect(undoBankFileImportMock).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/bank-file/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(403) + expect(undoBankFileImportMock).not.toHaveBeenCalled() + }) + + it('maps the RPC role rejection to BANK_FILE_UNDO_FORBIDDEN (403)', async () => { + undoBankFileImportMock.mockResolvedValue({ + success: false, + deletedTransactions: 0, + skippedBooked: 0, + skippedMatchHistory: 0, + forbidden: true, + error: 'Endast ägare eller administratörer kan ångra en bankfilsimport', + }) + + const response = await DELETE( + createMockRequest('/api/import/bank-file/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en: string } + }>(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('BANK_FILE_UNDO_FORBIDDEN') + expect(body.error.message).toBe( + 'Endast ägare eller administratörer kan ångra en bankfilsimport.', + ) + }) + + it('maps an unknown import to BANK_FILE_UNDO_NOT_FOUND (404)', async () => { + undoBankFileImportMock.mockResolvedValue({ + success: false, + deletedTransactions: 0, + skippedBooked: 0, + skippedMatchHistory: 0, + notFound: true, + error: 'Importen hittades inte', + }) + + const response = await DELETE( + createMockRequest('/api/import/bank-file/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en: string } + }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('BANK_FILE_UNDO_NOT_FOUND') + expect(body.error.message).toBe('Bankfilsimporten kunde inte hittas.') + expect(body.error.message_en).toBe('Bank file import not found.') + }) + + it('passes through the deletion report on a successful undo', async () => { + undoBankFileImportMock.mockResolvedValue({ + success: true, + deletedTransactions: 212, + skippedBooked: 3, + skippedMatchHistory: 2, + }) + + const response = await DELETE( + createMockRequest('/api/import/bank-file/import-1/undo', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ + success: boolean + deletedTransactions: number + skippedBooked: number + skippedMatchHistory: number + }>(response) + + expect(status).toBe(200) + expect(body).toEqual({ + success: true, + deletedTransactions: 212, + skippedBooked: 3, + skippedMatchHistory: 2, + }) + expect(undoBankFileImportMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'import-1', + 'user-1', + ) + }) + + it('returns the BANK_FILE_UNDO_FAILED envelope when the service refuses', async () => { + undoBankFileImportMock.mockResolvedValue({ + success: false, + deletedTransactions: 0, + skippedBooked: 0, + skippedMatchHistory: 0, + error: 'Kan bara ångra slutförda importer (status: processing)', + }) + + const response = await DELETE( + createMockRequest('/api/import/bank-file/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('BANK_FILE_UNDO_FAILED') + expect(body.error.message).toBe('Bankfilsimporten kunde inte ångras.') + expect(body.error.message_en).toBe('Failed to undo bank file import.') + expect(body.error.details?.reason).toBe( + 'Kan bara ångra slutförda importer (status: processing)', + ) + }) +}) diff --git a/app/api/import/bank-file/[id]/undo/route.ts b/app/api/import/bank-file/[id]/undo/route.ts new file mode 100644 index 00000000..8280107f --- /dev/null +++ b/app/api/import/bank-file/[id]/undo/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server' +import { undoBankFileImport } from '@/lib/import/bank-file/undo' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' + +// Bulk-deleting a large batch (a full-year CSV is thousands of rows) can take +// longer than the default function timeout. Match the bank-file execute route +// so the serverless function doesn't kill the request first. +export const maxDuration = 300 + +/** + * DELETE /api/import/bank-file/[id]/undo + * + * Undo a completed bank file import: hard-deletes the batch's unbooked + * transactions, INCLUDING ignored ones, and marks the bank_file_imports row + * 'undone' so the same file can be re-imported cleanly (the execute route's + * upsert reuses the row). Rows it never touches, reported in the response: + * - booked rows (verifikat-anchored, direct or via payment/voucher links): + * räkenskapsinformation; unlink or storno, never delete. + * - unbooked rows with payment_match_log history: the log is append-only + * (BFL 7 kap) and cascades on delete, so the parent row must stay; it can + * be ignored instead. Same rule as DELETE /api/transactions/[id]. + * Owner/admin only (enforced by the undo_bank_file_import RPC's actor gate; + * requireWrite blocks viewers before that). + */ +export const DELETE = withRouteContext( + 'bank_file.undo', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, user, log, requestId } = ctx + const opLog = log.child({ bankFileImportId: id }) + + const result = await undoBankFileImport(supabase, companyId!, id, user.id) + + if (!result.success) { + if (result.notFound) { + // 404, not 400: the id names no import in this company (same + // semantics as the SIE import routes' 'Import not found'). + return errorResponseFromCode('BANK_FILE_UNDO_NOT_FOUND', opLog, { requestId }) + } + if (result.forbidden) { + return errorResponseFromCode('BANK_FILE_UNDO_FORBIDDEN', opLog, { requestId }) + } + return errorResponseFromCode('BANK_FILE_UNDO_FAILED', opLog, { + requestId, + details: { reason: result.error }, + }) + } + + opLog.info('bank file import undone', { + actor: user.id, + deletedTransactions: result.deletedTransactions, + skippedBooked: result.skippedBooked, + skippedMatchHistory: result.skippedMatchHistory, + }) + + return NextResponse.json({ + success: true, + deletedTransactions: result.deletedTransactions, + skippedBooked: result.skippedBooked, + skippedMatchHistory: result.skippedMatchHistory, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/import/bank-file/__tests__/route.test.ts b/app/api/import/bank-file/__tests__/route.test.ts new file mode 100644 index 00000000..3545c330 --- /dev/null +++ b/app/api/import/bank-file/__tests__/route.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for GET /api/import/bank-file (the bank file 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. + * Mirrors the GET /api/import/sie list test. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +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: 'kontoutdrag-2026.csv', + file_format: 'swedbank', + transaction_count: 212, + imported_count: 208, + duplicate_count: 4, + matched_count: 12, + status: 'completed', + created_at: '2026-08-01T08:59:00Z', + updated_at: '2026-08-01T09:00:00Z', + ...overrides, +}) + +describe('GET /api/import/bank-file', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + 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/bank-file'), 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/bank-file'), 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('bank_file_imports', 'eq')).toContainEqual(['company_id', 'company-1']) + expect(findCalls('bank_file_imports', 'order')).toContainEqual([ + 'created_at', + { ascending: false }, + ]) + expect(findCalls('bank_file_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/bank-file', { + 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('bank_file_imports', 'eq')).toContainEqual(['status', 'completed']) + expect(findCalls('bank_file_imports', 'range')).toContainEqual([10, 14]) + }) + + it.each([ + { name: 'non-numeric limit', searchParams: { limit: 'abc' } }, + { name: 'partial-integer limit', searchParams: { limit: '12abc' } }, + { name: 'negative limit', searchParams: { limit: '-1' } }, + { name: 'zero limit', searchParams: { limit: '0' } }, + { name: 'limit above the cap', searchParams: { limit: '101' } }, + { name: 'negative offset', searchParams: { offset: '-5' } }, + { name: 'non-numeric offset', searchParams: { offset: 'NaN' } }, + { name: 'unknown status', searchParams: { status: 'sabotage' } }, + ])('returns a mapped 400 for $name', async ({ searchParams }) => { + const response = await GET( + createMockRequest('/api/import/bank-file', { searchParams }), + staticParams(), + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('BANK_FILE_LIST_INVALID_QUERY') + // Invalid input must never reach the query builder. + expect(findCalls('bank_file_imports', 'range')).toEqual([]) + }) + + it('accepts the boundary values limit=1, limit=100 and offset=0', async () => { + enqueue({ data: [], count: 0 }) + enqueue({ data: [], count: 0 }) + + const min = await GET( + createMockRequest('/api/import/bank-file', { searchParams: { limit: '1', offset: '0' } }), + staticParams(), + ) + expect(min.status).toBe(200) + + const max = await GET( + createMockRequest('/api/import/bank-file', { searchParams: { limit: '100' } }), + staticParams(), + ) + expect(max.status).toBe(200) + expect(findCalls('bank_file_imports', 'range')).toContainEqual([0, 0]) + expect(findCalls('bank_file_imports', 'range')).toContainEqual([0, 99]) + }) + + 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/bank-file'), 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/app/api/import/bank-file/execute/__tests__/route.test.ts b/app/api/import/bank-file/execute/__tests__/route.test.ts index 621fce92..fc7c1322 100644 --- a/app/api/import/bank-file/execute/__tests__/route.test.ts +++ b/app/api/import/bank-file/execute/__tests__/route.test.ts @@ -152,6 +152,9 @@ describe('POST /api/import/bank-file/execute (SIE overlap)', () => { expect(response.status).toBe(200) const ingestOptions = ingestMock.mock.calls[0][4] as Record expect(ingestOptions.skipAutoCategorization).toBeUndefined() + // Every inserted row is stamped with the batch id so the owner/admin + // "undo this import" action can scope its bulk delete exactly. + expect(ingestOptions.bankFileImportId).toBe('import-1') expect(sweepMock).not.toHaveBeenCalled() }) diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts index 1dc4a5df..07a3d807 100644 --- a/app/api/import/bank-file/execute/route.ts +++ b/app/api/import/bank-file/execute/route.ts @@ -126,7 +126,11 @@ export const POST = withRouteContext( sieOverlap = data ?? null } - const ingestOptions: IngestOptions = {} + const ingestOptions: IngestOptions = { + // Stamp every inserted row with this batch so the owner/admin + // "undo this import" action can scope its bulk delete exactly. + bankFileImportId: importRecord.id, + } if (settlement_account) ingestOptions.settlementAccount = settlement_account if (role === 'viewer') ingestOptions.rawInsertOnly = true if (sieOverlap) ingestOptions.skipAutoCategorization = true diff --git a/app/api/import/bank-file/route.ts b/app/api/import/bank-file/route.ts new file mode 100644 index 00000000..2aed0693 --- /dev/null +++ b/app/api/import/bank-file/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import type { BankFileImportStatus } from '@/types' + +const VALID_STATUSES: readonly BankFileImportStatus[] = [ + 'pending', + 'processing', + 'completed', + 'failed', + 'undone', +] +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 100 + +/** Strictly-digits nonnegative integer, or null when the value is invalid. */ +function parseNonNegativeInt(raw: string | null, fallback: number): number | null { + if (raw === null) return fallback + if (!/^\d+$/.test(raw)) return null + const n = Number(raw) + return Number.isSafeInteger(n) ? n : null +} + +/** + * GET /api/import/bank-file + * List the company's bank file imports, newest first. Mirrors + * GET /api/import/sie; feeds the 'Tidigare bankfilsimporter' history table + * on the import tab (BankFileImportHistory), which is where the per-import + * undo lives. + */ +export const GET = withRouteContext( + 'bank_file.list', + async (request, { supabase, companyId, log, requestId }) => { + const { searchParams } = new URL(request.url) + // parseInt would accept 'NaN'-producing and partial values ('12abc', + // '1e9', '-1') and build an invalid or unbounded range from them; the + // strict parse rejects them with a mapped 400, and limit is capped so a + // single request cannot page the whole table. + const limit = parseNonNegativeInt(searchParams.get('limit'), DEFAULT_LIMIT) + const offset = parseNonNegativeInt(searchParams.get('offset'), 0) + const rawStatus = searchParams.get('status') + const status = + rawStatus === null + ? null + : (VALID_STATUSES as readonly string[]).includes(rawStatus) + ? (rawStatus as BankFileImportStatus) + : undefined + if (limit === null || limit < 1 || limit > MAX_LIMIT || offset === null || status === undefined) { + return errorResponseFromCode('BANK_FILE_LIST_INVALID_QUERY', log, { + requestId, + details: { + limit: searchParams.get('limit'), + offset: searchParams.get('offset'), + status: rawStatus, + maxLimit: MAX_LIMIT, + }, + }) + } + + let query = supabase + .from('bank_file_imports') + .select('*', { count: 'exact' }) + .eq('company_id', companyId) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) + + if (status) { + query = query.eq('status', status) + } + + const { data, error, count } = await query + + if (error) { + return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + } + + return NextResponse.json({ + data, + count, + limit, + offset, + }) + }, +) diff --git a/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts index 6ca6b32e..33e9df63 100644 --- a/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts @@ -278,6 +278,27 @@ describe('POST /api/v1/companies/:companyId/imports/bank', () => { } }) + it('passes the bank_file_imports batch id to ingest so undo can scope its delete', async () => { + supabase = makeSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + bank_file_imports: { data: { id: 'import-1' }, error: null }, + }) + mockServiceClient.mockReturnValue(supabase) + + await callRoute() + + expect(ingestMock).toHaveBeenCalledTimes(1) + expect(ingestMock.mock.calls[0][4]).toEqual({ bankFileImportId: 'import-1' }) + }) + + it('still ingests (rows unlinked) when the upsert returns no batch id', async () => { + // Default beforeEach supabase: bank_file_imports resolves { data: null }. + await callRoute() + + expect(ingestMock).toHaveBeenCalledTimes(1) + expect(ingestMock.mock.calls[0][4]).toBeUndefined() + }) + it('stamps ids and provenance with the fallback format when an explicit override parses nothing', async () => { // Swedbank file forced as `seb`: the parser falls back to the detected // format, and external ids / import_source must follow the format the diff --git a/app/api/v1/companies/[companyId]/imports/bank/route.ts b/app/api/v1/companies/[companyId]/imports/bank/route.ts index c4a9c7f1..1ac3b147 100644 --- a/app/api/v1/companies/[companyId]/imports/bank/route.ts +++ b/app/api/v1/companies/[companyId]/imports/bank/route.ts @@ -228,7 +228,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( // upsert gives duplicate-rerun protection within one company. The // old (user_id, file_hash) key and its cross-company pre-check // (BANK_IMPORT_DUPLICATE_OTHER_COMPANY) are gone. - await ctx.supabase + // `.select('id')` so the inserted transactions can be stamped with the + // batch id (transactions.bank_file_import_id): the scope key for the + // owner/admin "undo this import" action. A missing id (upsert error) is + // non-fatal BY DESIGN: the import proceeds, its rows just stay + // unlinked, exactly like a pre-20260820071500 import. Without the row + // the batch never appears in the undo history, so nothing falsely + // advertises undo for it — but the failure is logged loudly, since an + // unattributed batch is permanently exempt from bulk undo. + const { data: importRow, error: importRowError } = await ctx.supabase .from('bank_file_imports') .upsert( { @@ -244,6 +252,16 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( }, { onConflict: 'company_id,file_hash' }, ) + .select('id') + .maybeSingle() + + if (importRowError || !importRow?.id) { + ctx.log.warn('bank_file_imports upsert failed: batch will import without undo attribution', { + filename: file.name, + fileHash, + error: importRowError?.message ?? 'no row returned', + }) + } // Convert parsed transactions to the RawTransaction shape that // ingestTransactions expects. external_id stays stable so re-imports @@ -290,6 +308,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( ctx.companyId!, ctx.userId, raw, + importRow?.id ? { bankFileImportId: importRow.id as string } : undefined, ) // Mark the bank_file_imports row complete. The unique constraint is diff --git a/components/import/BankFileImportHistory.tsx b/components/import/BankFileImportHistory.tsx new file mode 100644 index 00000000..8fc14846 --- /dev/null +++ b/components/import/BankFileImportHistory.tsx @@ -0,0 +1,252 @@ +'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 { getFormat } from '@/lib/import/bank-file/parser' +import type { BankFileFormatId } from '@/lib/import/bank-file/types' +import { cn, formatDate } from '@/lib/utils' + +/** + * Subset of the bank_file_imports row (GET /api/import/bank-file) actually + * rendered here. `status` stays a plain string: unknown values fall back to + * raw muted text instead of crashing on a missing translation key (same + * defensive shape as SIEImportHistory). + */ +interface BankFileImportListRow { + id: string + filename: string + file_format: string + imported_count: number + status: string + created_at: string +} + +const STATUS_LABEL_KEY: Record = { + completed: 'bankfile_history_status_completed', + undone: 'bankfile_history_status_undone', + failed: 'bankfile_history_status_failed', + pending: 'bankfile_history_status_pending', + processing: 'bankfile_history_status_processing', +} + +/** + * 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', + failed: 'destructive', + pending: 'warning', + processing: 'warning', +} + +/** + * The undo report returned by DELETE /api/import/bank-file/[id]/undo: + * deleted rows plus what was deliberately left alone (booked rows are + * räkenskapsinformation; rows with payment_match_log history keep their + * append-only log). + */ +interface UndoReport { + deletedTransactions?: number + skippedBooked?: number + skippedMatchHistory?: number +} + +/** + * History of past bank file imports with per-row undo for completed ones. + * Rendered fold-open from the 'Tidigare bankfilsimporter' row on the import + * tab; mirrors SIEImportHistory. The undo itself is owner/admin-only, + * enforced server-side by the undo_bank_file_import RPC's actor gate. + */ +export default function BankFileImportHistory() { + 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/bank-file?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/bank-file/${pendingUndo.id}/undo`, { + method: 'DELETE', + }) + const data = await res.json() + + if (!res.ok) { + toast({ + title: t('bankfile_history_undo_failed'), + description: getErrorMessage(data), + variant: 'destructive', + }) + return + } + + // The full report, clearly: X removed, and any rows the undo refused + // to touch (booked / match history) so nothing disappears silently. + const report = data as UndoReport + const skippedBooked = report.skippedBooked ?? 0 + const skippedMatchHistory = report.skippedMatchHistory ?? 0 + const parts = [ + t('bankfile_history_undo_success_deleted', { + count: report.deletedTransactions ?? 0, + }), + ] + if (skippedBooked > 0) { + parts.push(t('bankfile_history_undo_skipped_booked', { count: skippedBooked })) + } + if (skippedMatchHistory > 0) { + parts.push( + t('bankfile_history_undo_skipped_match_history', { count: skippedMatchHistory }), + ) + } + + toast({ + title: t('bankfile_history_undo_success_title'), + description: parts.join(' '), + }) + await fetchImports() + } catch (err) { + toast({ + title: t('bankfile_history_undo_failed'), + description: getErrorMessage(err), + variant: 'destructive', + }) + } + }, [pendingUndo, fetchImports, t, toast]) + + // 'swedbank' → 'Swedbank' via the format registry; unknown ids (a format + // removed from the registry) fall back to the raw stored code. + const formatLabel = (row: BankFileImportListRow): string => + getFormat(row.file_format as BankFileFormatId)?.name ?? row.file_format + + 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('bankfile_history_load_error')} +

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

+ {t('bankfile_history_empty')} +

+ ) + } + + return ( +
+
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
{t('bankfile_history_col_file')}{t('bankfile_history_col_date')}{t('bankfile_history_col_format')} + {t('bankfile_history_col_transactions')} + {t('bankfile_history_col_status')} + {t('bankfile_history_undo_button')} +
{row.filename} + {formatDate(row.created_at)} + {formatLabel(row)}{row.imported_count}{statusCell(row.status)} + {row.status === 'completed' && ( + + )} +
+
+ + { + if (!open) setPendingUndo(null) + }} + title={t('bankfile_history_undo_confirm_title')} + description={t('bankfile_history_undo_confirm_description')} + confirmLabel={t('bankfile_history_undo_confirm_label')} + onConfirm={handleUndoConfirm} + /> +
+ ) +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index f6f2c178..cd6ea4eb 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1713,6 +1713,26 @@ const BANK_FILE: Record = { message_en: 'This file looks like a Skatteverket tax account statement. Use the skattekonto import instead.', }, + BANK_FILE_UNDO_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Bankfilsimporten kunde inte hittas.', + message_en: 'Bank file import not found.', + }, + BANK_FILE_UNDO_FAILED: { + httpStatus: 400, + message_sv: 'Bankfilsimporten kunde inte ångras.', + message_en: 'Failed to undo bank file import.', + }, + BANK_FILE_UNDO_FORBIDDEN: { + httpStatus: 403, + message_sv: 'Endast ägare eller administratörer kan ångra en bankfilsimport.', + message_en: 'Only company owners and admins can undo a bank file import.', + }, + BANK_FILE_LIST_INVALID_QUERY: { + httpStatus: 400, + message_sv: 'Ogiltiga listparametrar: limit måste vara 1-100, offset ett icke-negativt heltal och status ett giltigt importstatus.', + message_en: 'Invalid list parameters: limit must be 1-100, offset a nonnegative integer, and status a valid import status.', + }, } const SKATTEKONTO_FILE: Record = { diff --git a/lib/import/__tests__/undo-bank-file-import.pg.test.ts b/lib/import/__tests__/undo-bank-file-import.pg.test.ts new file mode 100644 index 00000000..ced3c3b4 --- /dev/null +++ b/lib/import/__tests__/undo-bank-file-import.pg.test.ts @@ -0,0 +1,259 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup' +import { + seedCompany, + insertAuthUser, + insertCompanyMember, + insertPostedJournalEntry, + insertTransaction, +} from '@/tests/pg/fixtures' + +// Migration 20260820071500_undo_bank_file_import.sql (issue #1672): +// transactions.bank_file_import_id links every bank-file-imported row to its +// batch, and undo_bank_file_import bulk-deletes the batch's unbooked rows +// (ignored INCLUDED) while skipping booked rows and rows with +// payment_match_log history. The actor gate mirrors undo_sie_import +// (20260727121000): p_user_id honored only for service_role callers, every +// other caller pinned to its own auth.uid(), 42501 otherwise. + +async function insertCompletedBankImport(params: { + companyId: string + userId: string + status?: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.bank_file_imports + (id, user_id, company_id, filename, file_hash, file_format, + transaction_count, imported_count, status, date_from, date_to) + VALUES ($1, $2, $3, 'lunar-2026.csv', $4, 'lunar', + 3, 3, $5, '2026-01-01', '2026-06-30')`, + [id, params.userId, params.companyId, `hash-${id}`, params.status ?? 'completed'], + ) + return id +} + +type UndoReport = { + deleted: number + skipped_booked: number + skipped_match_history: number +} + +async function callUndo( + companyId: string, + importId: string, + actor: string | null, +): Promise { + const res = await runAsServiceRole((client) => + client.query<{ report: UndoReport }>( + `SELECT public.undo_bank_file_import($1::uuid, $2::uuid, $3::uuid) AS report`, + [companyId, importId, actor], + ), + ) + return res.rows[0].report +} + +describe('undo_bank_file_import', () => { + it('deletes the batch unbooked rows (ignored included), skips booked and match-history rows, and reports', async () => { + const { companyId, userId, fiscalPeriodId } = await seedCompany() + const importId = await insertCompletedBankImport({ companyId, userId }) + + // Two plain unbooked rows, one of them ignored: both must go. + const plainTx = await insertTransaction({ + companyId, + userId, + bankFileImportId: importId, + }) + const ignoredTx = await insertTransaction({ + companyId, + userId, + isIgnored: true, + bankFileImportId: importId, + }) + + // A booked row from the same batch: must stay. + const jeId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId }) + const bookedTx = await insertTransaction({ + companyId, + userId, + journalEntryId: jeId, + bankFileImportId: importId, + }) + + // An unbooked row carrying append-only match history: must stay (its + // payment_match_log rows cascade on delete, which audit_log_immutable + // blocks; same rule as the single-row DELETE route). + const historyTx = await insertTransaction({ + companyId, + userId, + bankFileImportId: importId, + }) + await getPool().query( + `INSERT INTO public.payment_match_log (user_id, transaction_id, action) + VALUES ($1, $2, 'auto_suggested')`, + [userId, historyTx], + ) + + // Rows OUTSIDE the batch: another import's row and an unlinked (PSD2-ish) + // row. Strict scoping means neither is touched. + const otherImportId = await insertCompletedBankImport({ companyId, userId }) + const otherBatchTx = await insertTransaction({ + companyId, + userId, + bankFileImportId: otherImportId, + }) + const unlinkedTx = await insertTransaction({ companyId, userId }) + + const report = await callUndo(companyId, importId, userId) + + expect(report.deleted).toBe(2) + expect(report.skipped_booked).toBe(1) + expect(report.skipped_match_history).toBe(1) + + const { rows: remaining } = await getPool().query<{ id: string }>( + `SELECT id FROM public.transactions WHERE company_id = $1`, + [companyId], + ) + const remainingIds = new Set(remaining.map((r) => r.id)) + expect(remainingIds.has(plainTx)).toBe(false) + expect(remainingIds.has(ignoredTx)).toBe(false) + expect(remainingIds.has(bookedTx)).toBe(true) + expect(remainingIds.has(historyTx)).toBe(true) + expect(remainingIds.has(otherBatchTx)).toBe(true) + expect(remainingIds.has(unlinkedTx)).toBe(true) + + const { rows: impRows } = await getPool().query<{ status: string }>( + `SELECT status FROM public.bank_file_imports WHERE id = $1`, + [importId], + ) + expect(impRows[0].status).toBe('undone') + + // Behandlingshistorik: the bulk delete leaves one audit_log summary row. + const { rows: auditRows } = await getPool().query<{ + new_state: { deleted_transactions: number } + }>( + `SELECT new_state FROM public.audit_log + WHERE table_name = 'transactions' AND record_id = $1 AND action = 'DELETE'`, + [importId], + ) + expect(auditRows).toHaveLength(1) + expect(auditRows[0].new_state.deleted_transactions).toBe(2) + }) + + it('raises when the import is not in completed status', async () => { + const { companyId, userId } = await seedCompany() + const importId = await insertCompletedBankImport({ + companyId, + userId, + status: 'processing', + }) + const txId = await insertTransaction({ companyId, userId, bankFileImportId: importId }) + + await expect(callUndo(companyId, importId, userId)).rejects.toThrow( + /not in completed status/i, + ) + + const { rows } = await getPool().query( + `SELECT 1 FROM public.transactions WHERE id = $1`, + [txId], + ) + expect(rows).toHaveLength(1) + }) + + it('raises 42501 for a plain member and for a stranger', async () => { + const { companyId, userId } = await seedCompany() + const importId = await insertCompletedBankImport({ companyId, userId }) + + const memberId = await insertAuthUser() + await insertCompanyMember({ companyId, userId: memberId, role: 'member' }) + + await expect(callUndo(companyId, importId, memberId)).rejects.toThrow( + /owners and admins/i, + ) + await expect(callUndo(companyId, importId, randomUUID())).rejects.toThrow( + /owners and admins/i, + ) + await expect(callUndo(companyId, importId, null)).rejects.toThrow( + /owners and admins/i, + ) + }) + + it('ignores a spoofed p_user_id from an authenticated (non-service) caller', async () => { + const { companyId, userId: ownerId } = await seedCompany() + const memberId = await insertAuthUser() + await insertCompanyMember({ companyId, userId: memberId, role: 'member' }) + + const importId = await insertCompletedBankImport({ companyId, userId: ownerId }) + const txId = await insertTransaction({ + companyId, + userId: ownerId, + bankFileImportId: importId, + }) + + await withUserContext(memberId, async (client) => { + let raised: (Error & { code?: string }) | null = null + try { + await client.query( + `SELECT public.undo_bank_file_import($1::uuid, $2::uuid, $3::uuid)`, + [companyId, importId, ownerId], + ) + } catch (err) { + raised = err as Error & { code?: string } + } + expect(raised, 'spoofed p_user_id must not authorize').not.toBeNull() + expect(raised!.message).toMatch(/owners and admins/i) + expect(raised!.code).toBe('42501') + }) + + // The gate fired before any mutation. + const { rows: impRows } = await getPool().query<{ status: string }>( + `SELECT status FROM public.bank_file_imports WHERE id = $1`, + [importId], + ) + expect(impRows[0].status).toBe('completed') + const { rows: txRows } = await getPool().query( + `SELECT 1 FROM public.transactions WHERE id = $1`, + [txId], + ) + expect(txRows).toHaveLength(1) + }) + + it('resolves the actor from auth.uid() for an authenticated owner (session-client fallback)', async () => { + const { companyId, userId } = await seedCompany() + const importId = await insertCompletedBankImport({ companyId, userId }) + await insertTransaction({ companyId, userId, bankFileImportId: importId }) + + const report = await withUserContext(userId, async (client) => { + const res = await client.query<{ report: UndoReport }>( + `SELECT public.undo_bank_file_import($1::uuid, $2::uuid) AS report`, + [companyId, importId], + ) + const imp = await client.query<{ status: string }>( + `SELECT status FROM public.bank_file_imports WHERE id = $1`, + [importId], + ) + expect(imp.rows[0].status).toBe('undone') + return res.rows[0].report + }) + expect(report.deleted).toBe(1) + }) + + it('does not grant EXECUTE to anon or PUBLIC (least privilege)', async () => { + const { rows } = await getPool().query<{ + anon_can: boolean + public_can: boolean + authenticated_can: boolean + service_role_can: boolean + }>( + `SELECT has_function_privilege('anon', 'public.undo_bank_file_import(uuid,uuid,uuid)', 'EXECUTE') AS anon_can, + has_function_privilege('public', 'public.undo_bank_file_import(uuid,uuid,uuid)', 'EXECUTE') AS public_can, + has_function_privilege('authenticated', 'public.undo_bank_file_import(uuid,uuid,uuid)', 'EXECUTE') AS authenticated_can, + has_function_privilege('service_role', 'public.undo_bank_file_import(uuid,uuid,uuid)', 'EXECUTE') AS service_role_can`, + ) + expect(rows[0].anon_can, 'anon must not be able to call undo_bank_file_import').toBe(false) + expect(rows[0].public_can, 'PUBLIC must not hold EXECUTE').toBe(false) + expect(rows[0].authenticated_can).toBe(true) + expect(rows[0].service_role_can).toBe(true) + }) +}) diff --git a/lib/import/bank-file/__tests__/undo.test.ts b/lib/import/bank-file/__tests__/undo.test.ts new file mode 100644 index 00000000..72dc6977 --- /dev/null +++ b/lib/import/bank-file/__tests__/undo.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for undoBankFileImport (lib/import/bank-file/undo.ts). + * + * The RPC itself (owner/admin gate, scoped delete, skip counting) is covered + * by the pg-real suite (lib/import/__tests__/undo-bank-file-import.pg.test.ts); + * this file covers the service wrapper: the session-client pre-checks, the + * RPC error mapping (42501 → forbidden), and the report shape. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { SupabaseClient } from '@supabase/supabase-js' + +// The bulk-delete escalation helper would swap in a service client when +// SUPABASE_SERVICE_ROLE_KEY is set; pin it to the fallback so the queued mock +// observes the rpc call. +vi.mock('@/lib/import/sie-import', () => ({ + rpcClientForBulkDelete: async (fallback: SupabaseClient) => fallback, +})) + +import { undoBankFileImport } from '../undo' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() +const client = supabase as unknown as SupabaseClient + +describe('undoBankFileImport', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + }) + + it('fails with the notFound flag, without calling the RPC, when the import is not found', async () => { + // PGRST116 is PostgREST's ".single() matched zero rows": the one error + // code that positively means the row does not exist. + enqueue({ + data: null, + error: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' }, + }) + + const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1') + + expect(result.success).toBe(false) + // The route maps this to 404 BANK_FILE_UNDO_NOT_FOUND (not the generic 400). + expect(result.notFound).toBe(true) + expect(result.error).toBe('Importen hittades inte') + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('reports a non-PGRST116 lookup failure as an error, never as notFound (fail closed)', async () => { + enqueue({ + data: null, + error: { code: '57014', message: 'canceling statement due to statement timeout' }, + }) + + const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1') + + expect(result.success).toBe(false) + // A transient fault must not become a permanent-looking 404. + expect(result.notFound).toBeUndefined() + expect(result.error).toMatch(/Kunde inte läsa importen/) + expect(result.error).toMatch(/statement timeout/) + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('fails without calling the RPC when the import is not completed', async () => { + enqueue({ data: { id: 'import-1', status: 'processing' } }) + + const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1') + + expect(result.success).toBe(false) + expect(result.error).toBe('Kan bara ångra slutförda importer (status: processing)') + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('maps the RPC 42501 rejection to forbidden', async () => { + enqueue({ data: { id: 'import-1', status: 'completed' } }) + enqueue({ data: null, error: { code: '42501', message: 'permission denied' } }) + + const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1') + + expect(result.success).toBe(false) + expect(result.forbidden).toBe(true) + }) + + it('surfaces other RPC errors with the message', async () => { + enqueue({ data: { id: 'import-1', status: 'completed' } }) + enqueue({ data: null, error: { code: 'P0001', message: 'boom' } }) + + const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1') + + expect(result.success).toBe(false) + expect(result.forbidden).toBeUndefined() + expect(result.error).toBe('Kunde inte ångra importen: boom') + }) + + it('returns the deletion report and passes the authorising user to the RPC', async () => { + enqueue({ data: { id: 'import-1', status: 'completed' } }) + enqueue({ + data: { deleted: 42, skipped_booked: 3, skipped_match_history: 1 }, + }) + + const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1') + + expect(result).toEqual({ + success: true, + deletedTransactions: 42, + skippedBooked: 3, + skippedMatchHistory: 1, + }) + expect(supabase.rpc).toHaveBeenCalledWith('undo_bank_file_import', { + p_company_id: 'company-1', + p_import_id: 'import-1', + p_user_id: 'user-1', + }) + }) +}) diff --git a/lib/import/bank-file/undo.ts b/lib/import/bank-file/undo.ts new file mode 100644 index 00000000..69bbfc96 --- /dev/null +++ b/lib/import/bank-file/undo.ts @@ -0,0 +1,111 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { rpcClientForBulkDelete } from '@/lib/import/sie-import' + +/** + * Result of undoing a bank file import. + * + * `deletedTransactions` counts the batch rows hard-deleted (unbooked rows, + * ignored included). The two skipped counters are the "clear report of what + * was not touched": booked rows (verifikat-anchored räkenskapsinformation) + * and unbooked rows with payment_match_log history (append-only under BFL + * 7 kap; their FK cascade makes the parent row undeletable, same rule as the + * single-row DELETE route). Skipped rows stay visible and can be ignored. + */ +export interface UndoBankFileImportResult { + success: boolean + deletedTransactions: number + skippedBooked: number + skippedMatchHistory: number + /** True when the caller is not an owner/admin of the company (RPC 42501). */ + forbidden?: boolean + /** True when no import with this id exists in the company (404, not 400). */ + notFound?: boolean + error?: string +} + +const FAILED: Omit = { + success: false, + deletedTransactions: 0, + skippedBooked: 0, + skippedMatchHistory: 0, +} + +/** + * Undo a completed bank file import: hard-delete the batch's unbooked + * transactions, INCLUDING ignored ones, and mark the bank_file_imports row + * 'undone'. Booked rows and rows with match history are never touched; the + * result reports them. Owner/admin only (enforced by the RPC's actor gate). + * + * Scope: strictly the rows stamped with this batch's id at ingest + * (transactions.bank_file_import_id). Imports executed before migration + * 20260820071500 carry no stamp and therefore delete nothing: there is no + * fuzzy fallback on format or date windows by design. + * + * `userId` is the authorising user and is required: the RPC usually runs on + * the service client (see rpcClientForBulkDelete) where auth.uid() is NULL, + * so the owner/admin gate resolves against p_user_id instead. + */ +export async function undoBankFileImport( + supabase: SupabaseClient, + companyId: string, + importId: string, + userId: string +): Promise { + // Validate against the RLS-scoped session client BEFORE escalating to the + // service client: a caller outside the company sees no row and stops here. + const { data: importRecord, error: lookupError } = await supabase + .from('bank_file_imports') + .select('id, status') + .eq('id', importId) + .eq('company_id', companyId) + .single() + + // Only PGRST116 (.single() found zero rows) means "does not exist". Any + // other error leaves the row's existence UNKNOWN; reporting it as a 404 + // would tell the caller a retryable fault is permanent. + if (lookupError && lookupError.code !== 'PGRST116') { + return { ...FAILED, error: `Kunde inte läsa importen: ${lookupError.message}` } + } + + if (!importRecord) { + return { ...FAILED, notFound: true, error: 'Importen hittades inte' } + } + + if (importRecord.status !== 'completed') { + return { + ...FAILED, + error: `Kan bara ångra slutförda importer (status: ${importRecord.status})`, + } + } + + const rpcClient = await rpcClientForBulkDelete(supabase) + const { data, error: rpcError } = await rpcClient.rpc('undo_bank_file_import', { + p_company_id: companyId, + p_import_id: importId, + p_user_id: userId, + }) + + if (rpcError) { + if ((rpcError as { code?: string }).code === '42501') { + return { + ...FAILED, + forbidden: true, + error: 'Endast ägare eller administratörer kan ångra en bankfilsimport', + } + } + return { ...FAILED, error: `Kunde inte ångra importen: ${rpcError.message}` } + } + + const report = (data ?? {}) as { + deleted?: number + skipped_booked?: number + skipped_match_history?: number + } + + return { + success: true, + deletedTransactions: report.deleted ?? 0, + skippedBooked: report.skipped_booked ?? 0, + skippedMatchHistory: report.skipped_match_history ?? 0, + } +} diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 199c1ffe..c7871f86 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -198,7 +198,8 @@ export async function checkDuplicatePeriodImport( } /** - * Client for the bulk hard-delete RPCs (replace_sie_import / undo_sie_import). + * Client for the bulk hard-delete RPCs (replace_sie_import / undo_sie_import / + * undo_bank_file_import). * * The authenticated role carries statement_timeout=8s on hosted Supabase, * and deleting a large import (thousands of journal_entries, each firing @@ -213,8 +214,11 @@ export async function checkDuplicatePeriodImport( * * Falls back to the caller's client when the service key is absent * (unit tests, misconfigured self-hosted), same behavior as before. + * + * Exported for lib/import/bank-file/undo.ts, which needs the exact same + * escalation shape for undo_bank_file_import. */ -async function rpcClientForBulkDelete(fallback: SupabaseClient): Promise { +export async function rpcClientForBulkDelete(fallback: SupabaseClient): Promise { if (!process.env.SUPABASE_SERVICE_ROLE_KEY) return fallback const { createServiceClient } = await import('@/lib/supabase/server') return createServiceClient() diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts index 8703b0ba..2cc86cd9 100644 --- a/lib/transactions/__tests__/ingest.test.ts +++ b/lib/transactions/__tests__/ingest.test.ts @@ -221,6 +221,54 @@ describe('ingestTransactions', () => { expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBeNull() }) + // ----------------------------------------------------------------------- + // 1c2. Stamps bank_file_import_id from the batch option + // ----------------------------------------------------------------------- + it('stamps bank_file_import_id on the insert when bankFileImportId is given', async () => { + const { supabase, enqueue, inserts } = createQueueMockSupabase() + const raw = makeRaw({ amount: -100 }) + const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id }) + + enqueue({ data: [], error: null }) // booked map + enqueue({ data: [], error: null }) // unbooked map + enqueue({ data: [], error: null }) // supplier invoices + enqueue({ data: [], error: null }) // external_id dedup + enqueue({ data: inserted, error: null }) // insert + mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 })) + + const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], { + bankFileImportId: 'import-1', + }) + + expect(result.imported).toBe(1) + const txInserts = inserts['transactions'] ?? [] + expect(txInserts).toHaveLength(1) + expect( + (txInserts[0] as { bank_file_import_id?: string | null }).bank_file_import_id, + ).toBe('import-1') + }) + + it('inserts bank_file_import_id null when no batch id is given (PSD2/MCP paths)', async () => { + const { supabase, enqueue, inserts } = createQueueMockSupabase() + const raw = makeRaw({ amount: -100 }) + const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id }) + + enqueue({ data: [], error: null }) // booked map + enqueue({ data: [], error: null }) // unbooked map + enqueue({ data: [], error: null }) // supplier invoices + enqueue({ data: [], error: null }) // external_id dedup + enqueue({ data: inserted, error: null }) // insert + mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 })) + + const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw]) + + expect(result.imported).toBe(1) + const txInserts = inserts['transactions'] ?? [] + expect( + (txInserts[0] as { bank_file_import_id?: string | null }).bank_file_import_id, + ).toBeNull() + }) + // ----------------------------------------------------------------------- // 1d. Transaction-method classification at the insert boundary // ----------------------------------------------------------------------- diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index 9e99fdb8..c6ef4f41 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -947,6 +947,9 @@ export async function ingestTransactions( merchant_name: raw.merchant_name || null, reference: raw.reference || null, import_source: raw.import_source || null, + // Batch link for "undo this import": only the bank-file import paths + // pass this; PSD2 sync and MCP rows stay NULL. + bank_file_import_id: options?.bankFileImportId ?? null, counterparty_iban: raw.counterparty_iban || null, counterparty_account: raw.counterparty_account || null, }) diff --git a/messages/en.json b/messages/en.json index 908e10f9..f8fd8b32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7486,6 +7486,29 @@ "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", + "bankfile_history_title": "Previous bank file imports", + "bankfile_history_description": "View history and undo a completed import", + "bankfile_history_empty": "No previous bank file imports.", + "bankfile_history_load_error": "Could not load the import history.", + "bankfile_history_col_file": "File name", + "bankfile_history_col_date": "Date", + "bankfile_history_col_format": "Format", + "bankfile_history_col_transactions": "Transactions", + "bankfile_history_col_status": "Status", + "bankfile_history_status_completed": "Completed", + "bankfile_history_status_undone": "Undone", + "bankfile_history_status_failed": "Failed", + "bankfile_history_status_pending": "In progress", + "bankfile_history_status_processing": "In progress", + "bankfile_history_undo_button": "Undo", + "bankfile_history_undo_confirm_title": "Undo this import?", + "bankfile_history_undo_confirm_label": "Undo import", + "bankfile_history_undo_confirm_description": "This removes the import's unbooked transactions, including ignored ones. Booked transactions and transactions with match history are not touched. The same file can then be imported again.", + "bankfile_history_undo_success_title": "Import undone", + "bankfile_history_undo_success_deleted": "{count, plural, =1 {1 transaction removed} other {# transactions removed}}.", + "bankfile_history_undo_skipped_booked": "{count, plural, =1 {1 booked transaction skipped} other {# booked transactions skipped}}.", + "bankfile_history_undo_skipped_match_history": "{count, plural, =1 {1 transaction with match history skipped} other {# transactions with match history skipped}}.", + "bankfile_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 10b7eba7..be82d010 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -7486,6 +7486,29 @@ "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", + "bankfile_history_title": "Tidigare bankfilsimporter", + "bankfile_history_description": "Se historik och ångra en genomförd import", + "bankfile_history_empty": "Inga tidigare bankfilsimporter.", + "bankfile_history_load_error": "Kunde inte hämta importhistoriken.", + "bankfile_history_col_file": "Filnamn", + "bankfile_history_col_date": "Datum", + "bankfile_history_col_format": "Format", + "bankfile_history_col_transactions": "Transaktioner", + "bankfile_history_col_status": "Status", + "bankfile_history_status_completed": "Slutförd", + "bankfile_history_status_undone": "Ångrad", + "bankfile_history_status_failed": "Misslyckad", + "bankfile_history_status_pending": "Pågående", + "bankfile_history_status_processing": "Pågående", + "bankfile_history_undo_button": "Ångra", + "bankfile_history_undo_confirm_title": "Ångra denna import?", + "bankfile_history_undo_confirm_label": "Ångra import", + "bankfile_history_undo_confirm_description": "Detta tar bort importens obokade transaktioner, även ignorerade. Bokförda transaktioner och transaktioner med matchningshistorik rörs inte. Samma fil kan sedan importeras igen.", + "bankfile_history_undo_success_title": "Import ångrad", + "bankfile_history_undo_success_deleted": "{count, plural, =1 {1 transaktion borttagen} other {# transaktioner borttagna}}.", + "bankfile_history_undo_skipped_booked": "{count, plural, =1 {1 bokförd transaktion hoppades över} other {# bokförda transaktioner hoppades över}}.", + "bankfile_history_undo_skipped_match_history": "{count, plural, =1 {1 transaktion med matchningshistorik hoppades över} other {# transaktioner med matchningshistorik hoppades över}}.", + "bankfile_history_undo_failed": "Kunde inte ångra import", "bank_format_wise_statement": "Wise kontoutdrag" }, "annualReportStudio": { diff --git a/supabase/migrations/20260820071500_undo_bank_file_import.sql b/supabase/migrations/20260820071500_undo_bank_file_import.sql new file mode 100644 index 00000000..ee52f46e --- /dev/null +++ b/supabase/migrations/20260820071500_undo_bank_file_import.sql @@ -0,0 +1,202 @@ +-- Undo a bank file import (issue #1672). +-- +-- A bad bank-file import (e.g. a mis-parsed CSV) could not be cleaned up: +-- re-importing dedup-skips the bad rows, the single-row DELETE refuses +-- imported rows by design (TRANSACTION_DELETE_IMPORTED), and there was no +-- bulk action. Transactions also did not record WHICH import batch inserted +-- them, so a strictly scoped "undo this import" was impossible. +-- +-- Two pieces: +-- +-- 1. transactions.bank_file_import_id: the batch link, stamped at ingest by +-- the bank-file import paths (dashboard execute route + v1 REST route). +-- NULL for rows predating this migration and for every other source (PSD2 +-- sync, manual, MCP). Pre-existing imports therefore cannot be undone +-- through this action: there is no reliable retroactive attribution, and a +-- fuzzy backfill (format + date window) could delete rows belonging to a +-- DIFFERENT import, which is exactly the footgun this feature must not be. +-- +-- 2. undo_bank_file_import RPC: owner/admin-only bulk delete of the batch's +-- unbooked transactions, ignored rows INCLUDED (is_ignored is deliberately +-- not filtered on). Never touched, and reported back instead: +-- * booked rows: journal_entry_id set, an invoice/supplier-invoice link, +-- an invoice_payments / supplier_invoice_payments row, or a +-- transaction_voucher_links row (the is_transaction_booked() predicate, +-- 20260529120000). Those rows are räkenskapsinformation; the fix for a +-- wrong booking is unlink or storno, never delete. +-- * rows with payment_match_log history: the log is append-only +-- räkenskapsinformation (BFL 7 kap, 20260323120000) and its FK cascades +-- on transaction delete, which the audit_log_immutable trigger blocks. +-- Same rule as the single-row DELETE route +-- (TRANSACTION_DELETE_HAS_AUDIT_TRAIL); such rows can be ignored, not +-- deleted. +-- The actor gate mirrors the hardened undo_sie_import shape +-- (20260727121000): p_user_id is honored only for service_role callers +-- (the cookieless server client, auth.uid() NULL); every other caller is +-- pinned to its own auth.uid(). Raises 42501 otherwise. + +ALTER TABLE public.transactions + ADD COLUMN bank_file_import_id uuid + REFERENCES public.bank_file_imports(id) ON DELETE SET NULL; + +COMMENT ON COLUMN public.transactions.bank_file_import_id IS + 'The bank_file_imports batch that inserted this row (bank-file CSV/CAMT import paths only). NULL for PSD2/manual/MCP rows and rows imported before 20260819100000. Scope key for undo_bank_file_import.'; + +-- Only a small fraction of transactions carry the link; the undo path and the +-- import detail views filter on it directly. +CREATE INDEX idx_transactions_bank_file_import_id + ON public.transactions (bank_file_import_id) + WHERE bank_file_import_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION public.undo_bank_file_import( + p_company_id uuid, + p_import_id uuid, + p_user_id uuid DEFAULT NULL +) + RETURNS jsonb + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' + -- The authenticated role carries statement_timeout=8s on hosted Supabase; a + -- multi-thousand-row batch delete must not race that budget. Same shape as + -- undo_sie_import (20260629160100 / 20260727121000). + SET statement_timeout TO '290s' +AS $function$ +DECLARE + v_actor uuid; + v_caller_role text; + v_status text; + v_filename text; + v_deleted integer := 0; + v_skipped_booked integer := 0; + v_skipped_match_history integer := 0; +BEGIN + -- Actor resolution: p_user_id is an assertion by the caller, honored ONLY + -- for the service role (cookieless server client, auth.uid() NULL). Any + -- other caller is pinned to its own auth.uid(). Same guard as + -- undo_sie_import (20260727121000). + IF auth.role() = 'service_role' THEN + v_actor := COALESCE(p_user_id, auth.uid()); + ELSE + v_actor := auth.uid(); + END IF; + + SELECT cm.role INTO v_caller_role + FROM company_members cm + WHERE cm.company_id = p_company_id + AND cm.user_id = v_actor; + + IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN + RAISE EXCEPTION 'Only company owners and admins can undo bank file imports' + USING ERRCODE = '42501'; + END IF; + + -- Lock the import row: serializes concurrent undo calls and any concurrent + -- re-import upsert of the same (company_id, file_hash) row. + SELECT status, filename + INTO v_status, v_filename + FROM public.bank_file_imports + WHERE id = p_import_id + AND company_id = p_company_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Import % not found', p_import_id; + END IF; + + IF v_status <> 'completed' THEN + RAISE EXCEPTION 'Import % is not in completed status (status: %)', p_import_id, v_status; + END IF; + + -- Booked rows the undo will NOT touch: any anchor to a verifikat (direct + -- journal_entry_id, denormalized invoice links, payment rows, or a + -- voucher-link junction row). Counted for the report. + SELECT count(*) INTO v_skipped_booked + FROM public.transactions t + WHERE t.company_id = p_company_id + AND t.bank_file_import_id = p_import_id + AND ( + t.journal_entry_id IS NOT NULL + OR t.invoice_id IS NOT NULL + OR t.supplier_invoice_id IS NOT NULL + OR EXISTS (SELECT 1 FROM public.invoice_payments ip WHERE ip.transaction_id = t.id) + OR EXISTS (SELECT 1 FROM public.supplier_invoice_payments sip WHERE sip.transaction_id = t.id) + OR EXISTS (SELECT 1 FROM public.transaction_voucher_links tvl WHERE tvl.transaction_id = t.id) + ); + + -- Unbooked rows with payment_match_log history: their log rows are + -- append-only räkenskapsinformation whose FK cascades on delete, which the + -- audit_log_immutable trigger rejects. Skipped and reported. + SELECT count(*) INTO v_skipped_match_history + FROM public.transactions t + WHERE t.company_id = p_company_id + AND t.bank_file_import_id = p_import_id + AND t.journal_entry_id IS NULL + AND t.invoice_id IS NULL + AND t.supplier_invoice_id IS NULL + AND NOT EXISTS (SELECT 1 FROM public.invoice_payments ip WHERE ip.transaction_id = t.id) + AND NOT EXISTS (SELECT 1 FROM public.supplier_invoice_payments sip WHERE sip.transaction_id = t.id) + AND NOT EXISTS (SELECT 1 FROM public.transaction_voucher_links tvl WHERE tvl.transaction_id = t.id) + AND EXISTS (SELECT 1 FROM public.payment_match_log pml WHERE pml.transaction_id = t.id); + + -- Delete the batch's unbooked, history-free rows. is_ignored is deliberately + -- NOT filtered: an ignored row is still unbooked staging data and undoing + -- the import must clear it too (the reporting user's exact complaint). + WITH deleted AS ( + DELETE FROM public.transactions t + WHERE t.company_id = p_company_id + AND t.bank_file_import_id = p_import_id + AND t.journal_entry_id IS NULL + AND t.invoice_id IS NULL + AND t.supplier_invoice_id IS NULL + AND NOT EXISTS (SELECT 1 FROM public.invoice_payments ip WHERE ip.transaction_id = t.id) + AND NOT EXISTS (SELECT 1 FROM public.supplier_invoice_payments sip WHERE sip.transaction_id = t.id) + AND NOT EXISTS (SELECT 1 FROM public.transaction_voucher_links tvl WHERE tvl.transaction_id = t.id) + AND NOT EXISTS (SELECT 1 FROM public.payment_match_log pml WHERE pml.transaction_id = t.id) + RETURNING id + ) + SELECT count(*) INTO v_deleted FROM deleted; + + UPDATE public.bank_file_imports + SET status = 'undone' + WHERE id = p_import_id + AND company_id = p_company_id; + + -- Behandlingshistorik: one summary row for the bulk delete. Transactions + -- carry no per-row audit trigger, so without this the undo would leave no + -- trace of what was removed and by whom. + INSERT INTO public.audit_log ( + user_id, company_id, action, table_name, record_id, actor_id, + old_state, new_state, description + ) VALUES ( + v_actor, p_company_id, 'DELETE', 'transactions', p_import_id, v_actor, + jsonb_build_object('bank_file_import_id', p_import_id, 'filename', v_filename), + jsonb_build_object( + 'deleted_transactions', v_deleted, + 'skipped_booked', v_skipped_booked, + 'skipped_match_history', v_skipped_match_history + ), + 'Bank file import undone: batch''s unbooked transactions (ignored included) hard-deleted' + ); + + RETURN jsonb_build_object( + 'deleted', v_deleted, + 'skipped_booked', v_skipped_booked, + 'skipped_match_history', v_skipped_match_history + ); +END; +$function$; + +-- Least privilege, same discipline as undo_sie_import (20260727121000): +-- CREATE FUNCTION applies the Supabase default grants (PUBLIC + anon + +-- authenticated + service_role), so PUBLIC and anon are revoked explicitly +-- and the two legitimate callers re-asserted: service_role for the normal +-- server path, authenticated for the session-client fallback (scoped by the +-- in-function owner/admin gate). +REVOKE EXECUTE ON FUNCTION public.undo_bank_file_import(uuid, uuid, uuid) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.undo_bank_file_import(uuid, uuid, uuid) TO authenticated, service_role; + +COMMENT ON FUNCTION public.undo_bank_file_import(uuid, uuid, uuid) IS + 'Hard-deletes a completed bank-file import batch''s unbooked transactions (ignored included), scoped by transactions.bank_file_import_id. Booked rows and rows with payment_match_log history are skipped and reported. Requires the actor to be an owner or admin of p_company_id; p_user_id is honored only for service_role callers, every other caller resolves from its own auth.uid(). Raises 42501 otherwise. Not callable by anon.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/fixtures.ts b/tests/pg/fixtures.ts index 84403c7d..7185b382 100644 --- a/tests/pg/fixtures.ts +++ b/tests/pg/fixtures.ts @@ -137,13 +137,15 @@ export async function insertTransaction(params: { journalEntryId?: string | null cashAccountId?: string | null isIgnored?: boolean + bankFileImportId?: string | null }): Promise { const id = randomUUID() await getPool().query( `INSERT INTO public.transactions (id, company_id, user_id, currency, amount, date, description, - external_id, journal_entry_id, cash_account_id, is_ignored, category) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'uncategorized')`, + external_id, journal_entry_id, cash_account_id, is_ignored, + bank_file_import_id, category) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'uncategorized')`, [ id, params.companyId, @@ -156,6 +158,7 @@ export async function insertTransaction(params: { params.journalEntryId ?? null, params.cashAccountId ?? null, params.isIgnored ?? false, + params.bankFileImportId ?? null, ], ) return id diff --git a/types/index.ts b/types/index.ts index ffc46eda..e9acd29e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -744,6 +744,12 @@ export interface Transaction { // Import tracking import_source: string | null + // The bank_file_imports batch that inserted this row (bank-file CSV/CAMT + // import paths only). NULL for PSD2/manual/MCP rows and rows imported + // before migration 20260820071500. Scope key for undo_bank_file_import. + // Optional like the other late-added columns: older fixtures/readers + // predate it. + bank_file_import_id?: string | null reference: string | null // OCR number, Bankgiro reference // Counterparty identification from PSD2 (creditor for outflows, debtor for @@ -761,7 +767,10 @@ export interface Transaction { } // Bank File Import (tracking table for file-based imports) -export type BankFileImportStatus = 'pending' | 'processing' | 'completed' | 'failed' +// 'undone' = the batch's unbooked transactions were bulk-deleted via +// undo_bank_file_import; a re-import of the same file reuses the row +// (upsert on company_id + file_hash) and moves it back to 'processing'. +export type BankFileImportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'undone' export interface BankFileImport { id: string @@ -4061,6 +4070,11 @@ export interface IngestOptions { /** Only INSERT transactions + dedup. Skip reconciliation, invoice matching, * supplier matching, and auto-categorization. For viewer imports. */ rawInsertOnly?: boolean + /** The bank_file_imports batch id to stamp on every inserted row + * (transactions.bank_file_import_id). Set by the bank-file import paths + * so "undo this import" can scope its bulk delete to exactly this batch. + * Omitted by every other caller (PSD2 sync, MCP): those rows stay NULL. */ + bankFileImportId?: string } /** Result of the transaction ingestion pipeline */