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>
This commit is contained in:
Mattsson
2026-08-20 21:25:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent ff4425d10e
commit 47c039453c
23 changed files with 1665 additions and 7 deletions
+2
View File
@@ -1129,3 +1129,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+13
View File
@@ -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<string | null>(null)
const [exportExcludeClosing, setExportExcludeClosing] = useState(true)
@@ -2537,12 +2539,23 @@ export default function ImportPage() {
expanded={sieHistoryOpen}
onClick={() => setSieHistoryOpen((v) => !v)}
/>
<ImportRow
title={t('bankfile_history_title')}
sub={t('bankfile_history_description')}
expanded={bankFileHistoryOpen}
onClick={() => setBankFileHistoryOpen((v) => !v)}
/>
</div>
{sieHistoryOpen && (
<div className="mt-6">
<SIEImportHistory />
</div>
)}
{bankFileHistoryOpen && (
<div className="mt-6">
<BankFileImportHistory />
</div>
)}
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">{t('pgnote')}</p>
</div>
) : (
@@ -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)',
)
})
})
@@ -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 },
)
@@ -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<string, unknown> = {}) => ({
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.')
})
})
@@ -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<string, unknown>
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()
})
+5 -1
View File
@@ -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
+85
View File
@@ -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,
})
},
)
@@ -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
@@ -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
+252
View File
@@ -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<string, string> = {
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<string, 'secondary' | 'warning' | 'destructive'> = {
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<BankFileImportListRow[] | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [pendingUndo, setPendingUndo] = useState<BankFileImportListRow | null>(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 <span className="text-xs text-muted-foreground">{label}</span>
}
return (
<Badge variant={variant} className="font-normal">
{label}
</Badge>
)
}
if (loadFailed) {
return (
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('bankfile_history_load_error')}
</p>
)
}
if (rows === null) {
return (
<div className="space-y-2" role="status">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
)
}
if (rows.length === 0) {
return (
<p className="px-1 text-xs leading-5 text-muted-foreground">
{t('bankfile_history_empty')}
</p>
)
}
return (
<div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('bankfile_history_col_file')}</th>
<th className={TH_CLASS}>{t('bankfile_history_col_date')}</th>
<th className={TH_CLASS}>{t('bankfile_history_col_format')}</th>
<th className={cn(TH_CLASS, 'text-right')}>
{t('bankfile_history_col_transactions')}
</th>
<th className={TH_CLASS}>{t('bankfile_history_col_status')}</th>
<th className={TH_CLASS}>
<span className="sr-only">{t('bankfile_history_undo_button')}</span>
</th>
</tr>
</thead>
<tbody className="stagger-enter">
{rows.map((row) => (
<tr key={row.id} className="transition-colors duration-150 hover:bg-secondary/35">
<td className={TD_CLASS}>{row.filename}</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
{formatDate(row.created_at)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>{formatLabel(row)}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>{row.imported_count}</td>
<td className={TD_CLASS}>{statusCell(row.status)}</td>
<td className={cn(TD_CLASS, 'text-right')}>
{row.status === 'completed' && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setPendingUndo(row)}
>
<Undo2 className="mr-2 h-4 w-4" />
{t('bankfile_history_undo_button')}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<DestructiveConfirmDialog
open={pendingUndo !== null}
onOpenChange={(open) => {
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}
/>
</div>
)
}
+20
View File
@@ -1713,6 +1713,26 @@ const BANK_FILE: Record<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
@@ -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<string> {
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<UndoReport> {
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)
})
})
+115
View File
@@ -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',
})
})
})
+111
View File
@@ -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<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,
}
}
+6 -2
View File
@@ -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<SupabaseClient> {
export async function rpcClientForBulkDelete(fallback: SupabaseClient): Promise<SupabaseClient> {
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) return fallback
const { createServiceClient } = await import('@/lib/supabase/server')
return createServiceClient()
+48
View File
@@ -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
// -----------------------------------------------------------------------
+3
View File
@@ -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,
})
+23
View File
@@ -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": {
+23
View File
@@ -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": {
@@ -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';
+5 -2
View File
@@ -137,13 +137,15 @@ export async function insertTransaction(params: {
journalEntryId?: string | null
cashAccountId?: string | null
isIgnored?: boolean
bankFileImportId?: string | null
}): Promise<string> {
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
+15 -1
View File
@@ -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 */