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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user