* 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>
112 lines
3.9 KiB
TypeScript
112 lines
3.9 KiB
TypeScript
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<UndoBankFileImportResult, 'error' | 'forbidden'> = {
|
|
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<UndoBankFileImportResult> {
|
|
// 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,
|
|
}
|
|
}
|