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
@@ -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