Files
accounted/supabase/migrations/20260820071500_undo_bank_file_import.sql
T
MattssonandClaude Fable 5 47c039453c feat(import): undo a bank file import including ignored transactions (#1764)
* 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>
2026-08-20 21:25:18 +02:00

203 lines
9.5 KiB
PL/PgSQL

-- 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';