feat(import): undo a bank file import including ignored transactions (#1764)

* feat(import): undo a bank file import including ignored transactions (#1672)

A mis-parsed bank CSV could not be cleaned up: re-importing dedup-skips
the bad rows, the single-row DELETE refuses imported rows by design
(TRANSACTION_DELETE_IMPORTED), and there was no bulk action. Transactions
also never recorded which import batch inserted them, so a strictly
scoped undo was impossible.

- transactions.bank_file_import_id: batch link stamped at ingest by both
  bank-file import paths (dashboard execute route, v1 REST route). PSD2/
  manual/MCP rows stay NULL. No retroactive backfill: fuzzy attribution
  could delete rows belonging to a different import.
- undo_bank_file_import RPC: owner/admin-only bulk delete of the batch's
  unbooked rows, ignored INCLUDED. Booked rows (journal link, payment
  rows, voucher links) and rows with append-only payment_match_log
  history are skipped and reported, mirroring the single-row route's
  guards. Marks the import 'undone' (re-import reuses the row via the
  company_id+file_hash upsert), writes one audit_log summary row, and
  hardens the actor gate like undo_sie_import: p_user_id honored only
  for service_role callers, 42501 otherwise, no anon EXECUTE.
- DELETE /api/import/bank-file/[id]/undo returns the deletion report;
  RPC 42501 maps to BANK_FILE_UNDO_FORBIDDEN (403).

Closes #1672

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(import): return 404 when the bank-file undo target does not exist

An unknown or out-of-company import id answered 400 BANK_FILE_UNDO_FAILED,
hiding the not-found semantics the SIE import routes already expose
('Import not found', 404). Flag the case in undoBankFileImport (notFound)
and map it to a new BANK_FILE_UNDO_NOT_FOUND structured error (404);
status-refusals and RPC failures keep the 400 envelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* feat(import): show bank file import history with undo on the import tab

The undo shipped for issue #1672 was API-only: no surface listed a
company's bank_file_imports, so neither users nor founders could reach
DELETE /api/import/bank-file/[id]/undo, and the deletion report existed
only in JSON. Mirror the SIE pattern (SIEImportHistory, #1574):

- GET /api/import/bank-file: list the company's imports newest-first,
  same { data, count, limit, offset } shape as GET /api/import/sie.
- BankFileImportHistory: fold-open 'Tidigare bankfilsimporter' row on
  the Importera tab with filename, date, format, imported count and
  status per import, plus an undo action on completed rows behind a
  DestructiveConfirmDialog. The undo stays owner/admin-only via the
  undo_bank_file_import RPC's actor gate, like the SIE one.
- After undo the toast shows the full report: transactions removed,
  booked rows skipped, rows with match history skipped, so nothing
  disappears silently from the ledger's surroundings.
- i18n strings in messages/sv.json and messages/en.json following the
  sie_history_* key style; list-route test mirroring the SIE list test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* chore(migrations): move undo_bank_file_import after main's 2026-08-19 migrations

Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(import): validate bank-file list params, fail closed on undo lookup, log lost batch attribution

Review findings on #1764 (CodeRabbit):
- GET /api/import/bank-file rejects non-integer/negative/oversized limit
  and offset and unknown status with a mapped 400
  (BANK_FILE_LIST_INVALID_QUERY), limit capped at 100; boundary and
  invalid-input tests added.
- undoBankFileImport distinguishes PGRST116 (zero rows -> notFound/404)
  from other lookup failures, which now return an error instead of
  masquerading as a permanent 404.
- The v1 import route no longer discards the bank_file_imports upsert
  error: kept non-fatal by design (an unattributed batch imports fine and
  never appears in undo history), but the failure is now logged loudly.
- Route test beforeEach clears the event bus (repo convention).

Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-20 21:25:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent ff4425d10e
commit 47c039453c
23 changed files with 1665 additions and 7 deletions
+20
View File
@@ -1713,6 +1713,26 @@ const BANK_FILE: Record<string, StructuredErrorEntry> = {
message_en:
'This file looks like a Skatteverket tax account statement. Use the skattekonto import instead.',
},
BANK_FILE_UNDO_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Bankfilsimporten kunde inte hittas.',
message_en: 'Bank file import not found.',
},
BANK_FILE_UNDO_FAILED: {
httpStatus: 400,
message_sv: 'Bankfilsimporten kunde inte ångras.',
message_en: 'Failed to undo bank file import.',
},
BANK_FILE_UNDO_FORBIDDEN: {
httpStatus: 403,
message_sv: 'Endast ägare eller administratörer kan ångra en bankfilsimport.',
message_en: 'Only company owners and admins can undo a bank file import.',
},
BANK_FILE_LIST_INVALID_QUERY: {
httpStatus: 400,
message_sv: 'Ogiltiga listparametrar: limit måste vara 1-100, offset ett icke-negativt heltal och status ett giltigt importstatus.',
message_en: 'Invalid list parameters: limit must be 1-100, offset a nonnegative integer, and status a valid import status.',
},
}
const SKATTEKONTO_FILE: Record<string, StructuredErrorEntry> = {
@@ -0,0 +1,259 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup'
import {
seedCompany,
insertAuthUser,
insertCompanyMember,
insertPostedJournalEntry,
insertTransaction,
} from '@/tests/pg/fixtures'
// Migration 20260820071500_undo_bank_file_import.sql (issue #1672):
// transactions.bank_file_import_id links every bank-file-imported row to its
// batch, and undo_bank_file_import bulk-deletes the batch's unbooked rows
// (ignored INCLUDED) while skipping booked rows and rows with
// payment_match_log history. The actor gate mirrors undo_sie_import
// (20260727121000): p_user_id honored only for service_role callers, every
// other caller pinned to its own auth.uid(), 42501 otherwise.
async function insertCompletedBankImport(params: {
companyId: string
userId: string
status?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.bank_file_imports
(id, user_id, company_id, filename, file_hash, file_format,
transaction_count, imported_count, status, date_from, date_to)
VALUES ($1, $2, $3, 'lunar-2026.csv', $4, 'lunar',
3, 3, $5, '2026-01-01', '2026-06-30')`,
[id, params.userId, params.companyId, `hash-${id}`, params.status ?? 'completed'],
)
return id
}
type UndoReport = {
deleted: number
skipped_booked: number
skipped_match_history: number
}
async function callUndo(
companyId: string,
importId: string,
actor: string | null,
): Promise<UndoReport> {
const res = await runAsServiceRole((client) =>
client.query<{ report: UndoReport }>(
`SELECT public.undo_bank_file_import($1::uuid, $2::uuid, $3::uuid) AS report`,
[companyId, importId, actor],
),
)
return res.rows[0].report
}
describe('undo_bank_file_import', () => {
it('deletes the batch unbooked rows (ignored included), skips booked and match-history rows, and reports', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const importId = await insertCompletedBankImport({ companyId, userId })
// Two plain unbooked rows, one of them ignored: both must go.
const plainTx = await insertTransaction({
companyId,
userId,
bankFileImportId: importId,
})
const ignoredTx = await insertTransaction({
companyId,
userId,
isIgnored: true,
bankFileImportId: importId,
})
// A booked row from the same batch: must stay.
const jeId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId })
const bookedTx = await insertTransaction({
companyId,
userId,
journalEntryId: jeId,
bankFileImportId: importId,
})
// An unbooked row carrying append-only match history: must stay (its
// payment_match_log rows cascade on delete, which audit_log_immutable
// blocks; same rule as the single-row DELETE route).
const historyTx = await insertTransaction({
companyId,
userId,
bankFileImportId: importId,
})
await getPool().query(
`INSERT INTO public.payment_match_log (user_id, transaction_id, action)
VALUES ($1, $2, 'auto_suggested')`,
[userId, historyTx],
)
// Rows OUTSIDE the batch: another import's row and an unlinked (PSD2-ish)
// row. Strict scoping means neither is touched.
const otherImportId = await insertCompletedBankImport({ companyId, userId })
const otherBatchTx = await insertTransaction({
companyId,
userId,
bankFileImportId: otherImportId,
})
const unlinkedTx = await insertTransaction({ companyId, userId })
const report = await callUndo(companyId, importId, userId)
expect(report.deleted).toBe(2)
expect(report.skipped_booked).toBe(1)
expect(report.skipped_match_history).toBe(1)
const { rows: remaining } = await getPool().query<{ id: string }>(
`SELECT id FROM public.transactions WHERE company_id = $1`,
[companyId],
)
const remainingIds = new Set(remaining.map((r) => r.id))
expect(remainingIds.has(plainTx)).toBe(false)
expect(remainingIds.has(ignoredTx)).toBe(false)
expect(remainingIds.has(bookedTx)).toBe(true)
expect(remainingIds.has(historyTx)).toBe(true)
expect(remainingIds.has(otherBatchTx)).toBe(true)
expect(remainingIds.has(unlinkedTx)).toBe(true)
const { rows: impRows } = await getPool().query<{ status: string }>(
`SELECT status FROM public.bank_file_imports WHERE id = $1`,
[importId],
)
expect(impRows[0].status).toBe('undone')
// Behandlingshistorik: the bulk delete leaves one audit_log summary row.
const { rows: auditRows } = await getPool().query<{
new_state: { deleted_transactions: number }
}>(
`SELECT new_state FROM public.audit_log
WHERE table_name = 'transactions' AND record_id = $1 AND action = 'DELETE'`,
[importId],
)
expect(auditRows).toHaveLength(1)
expect(auditRows[0].new_state.deleted_transactions).toBe(2)
})
it('raises when the import is not in completed status', async () => {
const { companyId, userId } = await seedCompany()
const importId = await insertCompletedBankImport({
companyId,
userId,
status: 'processing',
})
const txId = await insertTransaction({ companyId, userId, bankFileImportId: importId })
await expect(callUndo(companyId, importId, userId)).rejects.toThrow(
/not in completed status/i,
)
const { rows } = await getPool().query(
`SELECT 1 FROM public.transactions WHERE id = $1`,
[txId],
)
expect(rows).toHaveLength(1)
})
it('raises 42501 for a plain member and for a stranger', async () => {
const { companyId, userId } = await seedCompany()
const importId = await insertCompletedBankImport({ companyId, userId })
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
await expect(callUndo(companyId, importId, memberId)).rejects.toThrow(
/owners and admins/i,
)
await expect(callUndo(companyId, importId, randomUUID())).rejects.toThrow(
/owners and admins/i,
)
await expect(callUndo(companyId, importId, null)).rejects.toThrow(
/owners and admins/i,
)
})
it('ignores a spoofed p_user_id from an authenticated (non-service) caller', async () => {
const { companyId, userId: ownerId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const importId = await insertCompletedBankImport({ companyId, userId: ownerId })
const txId = await insertTransaction({
companyId,
userId: ownerId,
bankFileImportId: importId,
})
await withUserContext(memberId, async (client) => {
let raised: (Error & { code?: string }) | null = null
try {
await client.query(
`SELECT public.undo_bank_file_import($1::uuid, $2::uuid, $3::uuid)`,
[companyId, importId, ownerId],
)
} catch (err) {
raised = err as Error & { code?: string }
}
expect(raised, 'spoofed p_user_id must not authorize').not.toBeNull()
expect(raised!.message).toMatch(/owners and admins/i)
expect(raised!.code).toBe('42501')
})
// The gate fired before any mutation.
const { rows: impRows } = await getPool().query<{ status: string }>(
`SELECT status FROM public.bank_file_imports WHERE id = $1`,
[importId],
)
expect(impRows[0].status).toBe('completed')
const { rows: txRows } = await getPool().query(
`SELECT 1 FROM public.transactions WHERE id = $1`,
[txId],
)
expect(txRows).toHaveLength(1)
})
it('resolves the actor from auth.uid() for an authenticated owner (session-client fallback)', async () => {
const { companyId, userId } = await seedCompany()
const importId = await insertCompletedBankImport({ companyId, userId })
await insertTransaction({ companyId, userId, bankFileImportId: importId })
const report = await withUserContext(userId, async (client) => {
const res = await client.query<{ report: UndoReport }>(
`SELECT public.undo_bank_file_import($1::uuid, $2::uuid) AS report`,
[companyId, importId],
)
const imp = await client.query<{ status: string }>(
`SELECT status FROM public.bank_file_imports WHERE id = $1`,
[importId],
)
expect(imp.rows[0].status).toBe('undone')
return res.rows[0].report
})
expect(report.deleted).toBe(1)
})
it('does not grant EXECUTE to anon or PUBLIC (least privilege)', async () => {
const { rows } = await getPool().query<{
anon_can: boolean
public_can: boolean
authenticated_can: boolean
service_role_can: boolean
}>(
`SELECT has_function_privilege('anon', 'public.undo_bank_file_import(uuid,uuid,uuid)', 'EXECUTE') AS anon_can,
has_function_privilege('public', 'public.undo_bank_file_import(uuid,uuid,uuid)', 'EXECUTE') AS public_can,
has_function_privilege('authenticated', 'public.undo_bank_file_import(uuid,uuid,uuid)', 'EXECUTE') AS authenticated_can,
has_function_privilege('service_role', 'public.undo_bank_file_import(uuid,uuid,uuid)', 'EXECUTE') AS service_role_can`,
)
expect(rows[0].anon_can, 'anon must not be able to call undo_bank_file_import').toBe(false)
expect(rows[0].public_can, 'PUBLIC must not hold EXECUTE').toBe(false)
expect(rows[0].authenticated_can).toBe(true)
expect(rows[0].service_role_can).toBe(true)
})
})
+115
View File
@@ -0,0 +1,115 @@
/**
* Tests for undoBankFileImport (lib/import/bank-file/undo.ts).
*
* The RPC itself (owner/admin gate, scoped delete, skip counting) is covered
* by the pg-real suite (lib/import/__tests__/undo-bank-file-import.pg.test.ts);
* this file covers the service wrapper: the session-client pre-checks, the
* RPC error mapping (42501 → forbidden), and the report shape.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
// The bulk-delete escalation helper would swap in a service client when
// SUPABASE_SERVICE_ROLE_KEY is set; pin it to the fallback so the queued mock
// observes the rpc call.
vi.mock('@/lib/import/sie-import', () => ({
rpcClientForBulkDelete: async (fallback: SupabaseClient) => fallback,
}))
import { undoBankFileImport } from '../undo'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const client = supabase as unknown as SupabaseClient
describe('undoBankFileImport', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('fails with the notFound flag, without calling the RPC, when the import is not found', async () => {
// PGRST116 is PostgREST's ".single() matched zero rows": the one error
// code that positively means the row does not exist.
enqueue({
data: null,
error: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' },
})
const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1')
expect(result.success).toBe(false)
// The route maps this to 404 BANK_FILE_UNDO_NOT_FOUND (not the generic 400).
expect(result.notFound).toBe(true)
expect(result.error).toBe('Importen hittades inte')
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('reports a non-PGRST116 lookup failure as an error, never as notFound (fail closed)', async () => {
enqueue({
data: null,
error: { code: '57014', message: 'canceling statement due to statement timeout' },
})
const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1')
expect(result.success).toBe(false)
// A transient fault must not become a permanent-looking 404.
expect(result.notFound).toBeUndefined()
expect(result.error).toMatch(/Kunde inte läsa importen/)
expect(result.error).toMatch(/statement timeout/)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('fails without calling the RPC when the import is not completed', async () => {
enqueue({ data: { id: 'import-1', status: 'processing' } })
const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1')
expect(result.success).toBe(false)
expect(result.error).toBe('Kan bara ångra slutförda importer (status: processing)')
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('maps the RPC 42501 rejection to forbidden', async () => {
enqueue({ data: { id: 'import-1', status: 'completed' } })
enqueue({ data: null, error: { code: '42501', message: 'permission denied' } })
const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1')
expect(result.success).toBe(false)
expect(result.forbidden).toBe(true)
})
it('surfaces other RPC errors with the message', async () => {
enqueue({ data: { id: 'import-1', status: 'completed' } })
enqueue({ data: null, error: { code: 'P0001', message: 'boom' } })
const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1')
expect(result.success).toBe(false)
expect(result.forbidden).toBeUndefined()
expect(result.error).toBe('Kunde inte ångra importen: boom')
})
it('returns the deletion report and passes the authorising user to the RPC', async () => {
enqueue({ data: { id: 'import-1', status: 'completed' } })
enqueue({
data: { deleted: 42, skipped_booked: 3, skipped_match_history: 1 },
})
const result = await undoBankFileImport(client, 'company-1', 'import-1', 'user-1')
expect(result).toEqual({
success: true,
deletedTransactions: 42,
skippedBooked: 3,
skippedMatchHistory: 1,
})
expect(supabase.rpc).toHaveBeenCalledWith('undo_bank_file_import', {
p_company_id: 'company-1',
p_import_id: 'import-1',
p_user_id: 'user-1',
})
})
})
+111
View File
@@ -0,0 +1,111 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { rpcClientForBulkDelete } from '@/lib/import/sie-import'
/**
* Result of undoing a bank file import.
*
* `deletedTransactions` counts the batch rows hard-deleted (unbooked rows,
* ignored included). The two skipped counters are the "clear report of what
* was not touched": booked rows (verifikat-anchored räkenskapsinformation)
* and unbooked rows with payment_match_log history (append-only under BFL
* 7 kap; their FK cascade makes the parent row undeletable, same rule as the
* single-row DELETE route). Skipped rows stay visible and can be ignored.
*/
export interface UndoBankFileImportResult {
success: boolean
deletedTransactions: number
skippedBooked: number
skippedMatchHistory: number
/** True when the caller is not an owner/admin of the company (RPC 42501). */
forbidden?: boolean
/** True when no import with this id exists in the company (404, not 400). */
notFound?: boolean
error?: string
}
const FAILED: Omit<UndoBankFileImportResult, 'error' | 'forbidden'> = {
success: false,
deletedTransactions: 0,
skippedBooked: 0,
skippedMatchHistory: 0,
}
/**
* Undo a completed bank file import: hard-delete the batch's unbooked
* transactions, INCLUDING ignored ones, and mark the bank_file_imports row
* 'undone'. Booked rows and rows with match history are never touched; the
* result reports them. Owner/admin only (enforced by the RPC's actor gate).
*
* Scope: strictly the rows stamped with this batch's id at ingest
* (transactions.bank_file_import_id). Imports executed before migration
* 20260820071500 carry no stamp and therefore delete nothing: there is no
* fuzzy fallback on format or date windows by design.
*
* `userId` is the authorising user and is required: the RPC usually runs on
* the service client (see rpcClientForBulkDelete) where auth.uid() is NULL,
* so the owner/admin gate resolves against p_user_id instead.
*/
export async function undoBankFileImport(
supabase: SupabaseClient,
companyId: string,
importId: string,
userId: string
): Promise<UndoBankFileImportResult> {
// Validate against the RLS-scoped session client BEFORE escalating to the
// service client: a caller outside the company sees no row and stops here.
const { data: importRecord, error: lookupError } = await supabase
.from('bank_file_imports')
.select('id, status')
.eq('id', importId)
.eq('company_id', companyId)
.single()
// Only PGRST116 (.single() found zero rows) means "does not exist". Any
// other error leaves the row's existence UNKNOWN; reporting it as a 404
// would tell the caller a retryable fault is permanent.
if (lookupError && lookupError.code !== 'PGRST116') {
return { ...FAILED, error: `Kunde inte läsa importen: ${lookupError.message}` }
}
if (!importRecord) {
return { ...FAILED, notFound: true, error: 'Importen hittades inte' }
}
if (importRecord.status !== 'completed') {
return {
...FAILED,
error: `Kan bara ångra slutförda importer (status: ${importRecord.status})`,
}
}
const rpcClient = await rpcClientForBulkDelete(supabase)
const { data, error: rpcError } = await rpcClient.rpc('undo_bank_file_import', {
p_company_id: companyId,
p_import_id: importId,
p_user_id: userId,
})
if (rpcError) {
if ((rpcError as { code?: string }).code === '42501') {
return {
...FAILED,
forbidden: true,
error: 'Endast ägare eller administratörer kan ångra en bankfilsimport',
}
}
return { ...FAILED, error: `Kunde inte ångra importen: ${rpcError.message}` }
}
const report = (data ?? {}) as {
deleted?: number
skipped_booked?: number
skipped_match_history?: number
}
return {
success: true,
deletedTransactions: report.deleted ?? 0,
skippedBooked: report.skipped_booked ?? 0,
skippedMatchHistory: report.skipped_match_history ?? 0,
}
}
+6 -2
View File
@@ -198,7 +198,8 @@ export async function checkDuplicatePeriodImport(
}
/**
* Client for the bulk hard-delete RPCs (replace_sie_import / undo_sie_import).
* Client for the bulk hard-delete RPCs (replace_sie_import / undo_sie_import /
* undo_bank_file_import).
*
* The authenticated role carries statement_timeout=8s on hosted Supabase,
* and deleting a large import (thousands of journal_entries, each firing
@@ -213,8 +214,11 @@ export async function checkDuplicatePeriodImport(
*
* Falls back to the caller's client when the service key is absent
* (unit tests, misconfigured self-hosted), same behavior as before.
*
* Exported for lib/import/bank-file/undo.ts, which needs the exact same
* escalation shape for undo_bank_file_import.
*/
async function rpcClientForBulkDelete(fallback: SupabaseClient): Promise<SupabaseClient> {
export async function rpcClientForBulkDelete(fallback: SupabaseClient): Promise<SupabaseClient> {
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) return fallback
const { createServiceClient } = await import('@/lib/supabase/server')
return createServiceClient()
+48
View File
@@ -221,6 +221,54 @@ describe('ingestTransactions', () => {
expect((txInserts[0] as { cash_account_id?: string | null }).cash_account_id).toBeNull()
})
// -----------------------------------------------------------------------
// 1c2. Stamps bank_file_import_id from the batch option
// -----------------------------------------------------------------------
it('stamps bank_file_import_id on the insert when bankFileImportId is given', async () => {
const { supabase, enqueue, inserts } = createQueueMockSupabase()
const raw = makeRaw({ amount: -100 })
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
enqueue({ data: [], error: null }) // booked map
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: inserted, error: null }) // insert
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
bankFileImportId: 'import-1',
})
expect(result.imported).toBe(1)
const txInserts = inserts['transactions'] ?? []
expect(txInserts).toHaveLength(1)
expect(
(txInserts[0] as { bank_file_import_id?: string | null }).bank_file_import_id,
).toBe('import-1')
})
it('inserts bank_file_import_id null when no batch id is given (PSD2/MCP paths)', async () => {
const { supabase, enqueue, inserts } = createQueueMockSupabase()
const raw = makeRaw({ amount: -100 })
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
enqueue({ data: [], error: null }) // booked map
enqueue({ data: [], error: null }) // unbooked map
enqueue({ data: [], error: null }) // supplier invoices
enqueue({ data: [], error: null }) // external_id dedup
enqueue({ data: inserted, error: null }) // insert
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
const txInserts = inserts['transactions'] ?? []
expect(
(txInserts[0] as { bank_file_import_id?: string | null }).bank_file_import_id,
).toBeNull()
})
// -----------------------------------------------------------------------
// 1d. Transaction-method classification at the insert boundary
// -----------------------------------------------------------------------
+3
View File
@@ -947,6 +947,9 @@ export async function ingestTransactions(
merchant_name: raw.merchant_name || null,
reference: raw.reference || null,
import_source: raw.import_source || null,
// Batch link for "undo this import": only the bank-file import paths
// pass this; PSD2 sync and MCP rows stay NULL.
bank_file_import_id: options?.bankFileImportId ?? null,
counterparty_iban: raw.counterparty_iban || null,
counterparty_account: raw.counterparty_account || null,
})