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:
co-authored by
Claude Fable 5
parent
ff4425d10e
commit
47c039453c
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user