* 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>
86 lines
2.8 KiB
TypeScript
86 lines
2.8 KiB
TypeScript
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,
|
|
})
|
|
},
|
|
)
|