* feat(import): undo a bank file import including ignored transactions (#1672) A mis-parsed bank 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 never recorded which import batch inserted them, so a strictly scoped undo was impossible. - transactions.bank_file_import_id: batch link stamped at ingest by both bank-file import paths (dashboard execute route, v1 REST route). PSD2/ manual/MCP rows stay NULL. No retroactive backfill: fuzzy attribution could delete rows belonging to a different import. - undo_bank_file_import RPC: owner/admin-only bulk delete of the batch's unbooked rows, ignored INCLUDED. Booked rows (journal link, payment rows, voucher links) and rows with append-only payment_match_log history are skipped and reported, mirroring the single-row route's guards. Marks the import 'undone' (re-import reuses the row via the company_id+file_hash upsert), writes one audit_log summary row, and hardens the actor gate like undo_sie_import: p_user_id honored only for service_role callers, 42501 otherwise, no anon EXECUTE. - DELETE /api/import/bank-file/[id]/undo returns the deletion report; RPC 42501 maps to BANK_FILE_UNDO_FORBIDDEN (403). Closes #1672 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): return 404 when the bank-file undo target does not exist An unknown or out-of-company import id answered 400 BANK_FILE_UNDO_FAILED, hiding the not-found semantics the SIE import routes already expose ('Import not found', 404). Flag the case in undoBankFileImport (notFound) and map it to a new BANK_FILE_UNDO_NOT_FOUND structured error (404); status-refusals and RPC failures keep the 400 envelope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * feat(import): show bank file import history with undo on the import tab The undo shipped for issue #1672 was API-only: no surface listed a company's bank_file_imports, so neither users nor founders could reach DELETE /api/import/bank-file/[id]/undo, and the deletion report existed only in JSON. Mirror the SIE pattern (SIEImportHistory, #1574): - GET /api/import/bank-file: list the company's imports newest-first, same { data, count, limit, offset } shape as GET /api/import/sie. - BankFileImportHistory: fold-open 'Tidigare bankfilsimporter' row on the Importera tab with filename, date, format, imported count and status per import, plus an undo action on completed rows behind a DestructiveConfirmDialog. The undo stays owner/admin-only via the undo_bank_file_import RPC's actor gate, like the SIE one. - After undo the toast shows the full report: transactions removed, booked rows skipped, rows with match history skipped, so nothing disappears silently from the ledger's surroundings. - i18n strings in messages/sv.json and messages/en.json following the sie_history_* key style; list-route test mirroring the SIE list test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * chore(migrations): move undo_bank_file_import after main's 2026-08-19 migrations Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): validate bank-file list params, fail closed on undo lookup, log lost batch attribution Review findings on #1764 (CodeRabbit): - GET /api/import/bank-file rejects non-integer/negative/oversized limit and offset and unknown status with a mapped 400 (BANK_FILE_LIST_INVALID_QUERY), limit capped at 100; boundary and invalid-input tests added. - undoBankFileImport distinguishes PGRST116 (zero rows -> notFound/404) from other lookup failures, which now return an error instead of masquerading as a permanent 404. - The v1 import route no longer discards the bank_file_imports upsert error: kept non-fatal by design (an unattributed batch imports fine and never appears in undo history), but the failure is now logged loudly. - Route test beforeEach clears the event bus (repo convention). Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
116 lines
4.3 KiB
TypeScript
116 lines
4.3 KiB
TypeScript
/**
|
|
* 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',
|
|
})
|
|
})
|
|
})
|