Fix/m sprint fixes (#613)
* fix(dashboard): exclude ignored and already-triaged transactions from stale count The "Gamla transaktioner" widget counted transactions that had been ignored or already marked as is_business=true but not yet booked, so users saw a nag for a row they had already dealt with — and the /transactions inbox correctly hid it. Align the count with the inbox criterion (is_business IS NULL, is_ignored = false) so the widget clears when the row leaves the inbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): read entity_type from settings response wrapper The transactions page read entityRes.entity_type directly, but /api/settings returns { data: { entity_type, ... } }. The expression was always undefined, so setEntityType never fired and entityType stayed at its initial 'enskild_firma'. The template picker's entity_type filter then dropped every aktiebolag-tagged user template for AB customers — only entity_type='all' templates made it through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * stale templates bank sync journal entry from transaction * fixed pr comments * fixed pr comment --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fc7a46c3f2
commit
ea1bf01f1e
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Regression suite for the Lookma AB support case (2026-05-28).
|
||||
*
|
||||
* The bug: gnubok_import_sie + executeSIEImport accepted mappings that
|
||||
* couldn't cover a single account in the file. The per-voucher loop then
|
||||
* silently skipped every verifikation, finalizeImportRecord marked the
|
||||
* sie_imports row 'completed' with transactions_count=0, and the partial
|
||||
* unique index on (company_id, file_hash) held the slot — blocking retry.
|
||||
*
|
||||
* The fix layers three guards:
|
||||
* 1. Stage-time refusal in gnubok_import_sie (covered in
|
||||
* extensions/general/mcp-server/__tests__/import-sie-stage.test.ts).
|
||||
* 2. Defense-in-depth refusal in executeSIEImport (this file).
|
||||
* 3. Finalizer downgrade of any 0-entry success to 'failed' (this file).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { executeSIEImport, finalizeImportRecord } from '../sie-import'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ParsedSIEFile, AccountMapping, ImportResult } from '../types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||||
return {
|
||||
header: {
|
||||
sieType: 4,
|
||||
flagga: 0,
|
||||
program: 'TestProg',
|
||||
programVersion: '1.0',
|
||||
generatedDate: '2024-01-01',
|
||||
format: 'PC8',
|
||||
companyName: 'Lookma Mock AB',
|
||||
orgNumber: '5567201701',
|
||||
address: null,
|
||||
fiscalYears: [{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }],
|
||||
currency: 'SEK',
|
||||
kontoPlanType: null,
|
||||
},
|
||||
accounts: [
|
||||
{ number: '1930', name: 'Företagskonto' },
|
||||
{ number: '6110', name: 'Kontorsmaterial' },
|
||||
],
|
||||
openingBalances: [{ yearIndex: 0, account: '1930', amount: 50000 }],
|
||||
closingBalances: [],
|
||||
resultBalances: [],
|
||||
vouchers: [
|
||||
{
|
||||
series: 'A',
|
||||
number: 1,
|
||||
date: new Date(2024, 0, 15),
|
||||
description: 'Inköp',
|
||||
lines: [
|
||||
{ account: '6110', amount: 1000 },
|
||||
{ account: '1930', amount: -1000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
issues: [],
|
||||
stats: {
|
||||
totalAccounts: 2,
|
||||
totalVouchers: 1,
|
||||
totalTransactionLines: 2,
|
||||
fiscalYearStart: '2024-01-01',
|
||||
fiscalYearEnd: '2024-12-31',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMapping(source: string, target: string | null): AccountMapping {
|
||||
return {
|
||||
sourceAccount: source,
|
||||
sourceName: `Account ${source}`,
|
||||
targetAccount: target as string,
|
||||
targetName: target ? `Target ${target}` : '',
|
||||
confidence: target ? 1 : 0,
|
||||
matchType: target ? 'exact' : 'manual',
|
||||
isOverride: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe('executeSIEImport — defense-in-depth coverage check', () => {
|
||||
it('refuses to insert a sie_imports row when mappings is empty', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const parsed = makeParsedFile()
|
||||
|
||||
const result = await executeSIEImport(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
parsed,
|
||||
[],
|
||||
{
|
||||
filename: 'lookma.se',
|
||||
fileContent: '#dummy',
|
||||
createFiscalPeriod: false,
|
||||
importOpeningBalances: false,
|
||||
importTransactions: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.importId).toBeNull()
|
||||
expect(result.errors.join(' ')).toMatch(/täcker inga konton/i)
|
||||
})
|
||||
|
||||
it('refuses when mappings exist but cover none of the file\'s accounts', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const parsed = makeParsedFile()
|
||||
|
||||
const result = await executeSIEImport(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
parsed,
|
||||
[makeMapping('9999', '9999')],
|
||||
{
|
||||
filename: 'wrong.se',
|
||||
fileContent: '#dummy',
|
||||
createFiscalPeriod: false,
|
||||
importOpeningBalances: false,
|
||||
importTransactions: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.importId).toBeNull()
|
||||
expect(result.errors.join(' ')).toMatch(/täcker inga konton/i)
|
||||
})
|
||||
|
||||
it('still rejects mappings with targetAccount=null (existing guard)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const parsed = makeParsedFile()
|
||||
|
||||
const result = await executeSIEImport(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
parsed,
|
||||
[makeMapping('6110', null), makeMapping('1930', null)],
|
||||
{
|
||||
filename: 'half.se',
|
||||
fileContent: '#dummy',
|
||||
createFiscalPeriod: false,
|
||||
importOpeningBalances: false,
|
||||
importTransactions: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors.join(' ')).toMatch(/not mapped/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('finalizeImportRecord — 0-entry downgrade', () => {
|
||||
it('flips a 0-entry success to status=failed and records the reason', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const result: ImportResult = {
|
||||
success: true,
|
||||
importId: 'imp-1',
|
||||
fiscalPeriodId: 'fp-1',
|
||||
openingBalanceEntryId: null,
|
||||
journalEntriesCreated: 0,
|
||||
journalEntryIds: [],
|
||||
errors: [],
|
||||
warnings: ['100 verifikationer hoppades över med ej mappade konton'],
|
||||
replacedPriorImport: null,
|
||||
}
|
||||
|
||||
await finalizeImportRecord(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'imp-1',
|
||||
'company-1',
|
||||
result,
|
||||
'#dummy',
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors.join(' ')).toMatch(/0 verifikationer/i)
|
||||
})
|
||||
|
||||
it('leaves a successful run with entries alone', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const result: ImportResult = {
|
||||
success: true,
|
||||
importId: 'imp-2',
|
||||
fiscalPeriodId: 'fp-2',
|
||||
openingBalanceEntryId: null,
|
||||
journalEntriesCreated: 42,
|
||||
journalEntryIds: Array(42).fill('je'),
|
||||
errors: [],
|
||||
warnings: [],
|
||||
replacedPriorImport: null,
|
||||
}
|
||||
|
||||
await finalizeImportRecord(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'imp-2',
|
||||
'company-1',
|
||||
result,
|
||||
'#dummy',
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a 0-voucher run alone when an OB entry was created', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const result: ImportResult = {
|
||||
success: true,
|
||||
importId: 'imp-3',
|
||||
fiscalPeriodId: 'fp-3',
|
||||
openingBalanceEntryId: 'ob-1',
|
||||
journalEntriesCreated: 1,
|
||||
journalEntryIds: ['ob-1'],
|
||||
errors: [],
|
||||
warnings: [],
|
||||
replacedPriorImport: null,
|
||||
}
|
||||
|
||||
await finalizeImportRecord(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'imp-3',
|
||||
'company-1',
|
||||
result,
|
||||
'#dummy',
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
// Migration 20260529120000_sie_imports_undone_release_slot.sql extends
|
||||
// the partial unique index sie_imports_company_id_file_hash_active_idx
|
||||
// to also exclude 'undone'. Without this, undo_sie_import marks a row
|
||||
// 'undone' but the slot stays held — the caller cannot re-import the
|
||||
// same file.
|
||||
|
||||
async function insertSIEImport(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
fileHash: string
|
||||
status: 'pending' | 'mapped' | 'completed' | 'failed' | 'replaced' | 'undone'
|
||||
fiscalPeriodId?: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.sie_imports
|
||||
(id, user_id, company_id, filename, file_hash, sie_type,
|
||||
fiscal_year_start, fiscal_year_end, accounts_count, transactions_count,
|
||||
status, fiscal_period_id, imported_at)
|
||||
VALUES ($1, $2, $3, 'undone-test.se', $4, 4,
|
||||
'2026-01-01', '2026-12-31', 0, 0,
|
||||
$5, $6, $7)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.fileHash,
|
||||
params.status,
|
||||
params.fiscalPeriodId ?? null,
|
||||
params.status === 'completed' ? new Date().toISOString() : null,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('sie_imports partial unique index: undone status releases the slot', () => {
|
||||
it('still blocks a duplicate active row (regression guard)', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const hash = `hash-${randomUUID()}`
|
||||
|
||||
await insertSIEImport({ companyId, userId, fileHash: hash, status: 'completed', fiscalPeriodId })
|
||||
|
||||
await expect(
|
||||
insertSIEImport({ companyId, userId, fileHash: hash, status: 'pending', fiscalPeriodId }),
|
||||
).rejects.toThrow(/sie_imports_company_id_file_hash_active_idx/)
|
||||
})
|
||||
|
||||
it('allows a new pending row once the prior is undone', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const hash = `hash-${randomUUID()}`
|
||||
|
||||
const priorId = await insertSIEImport({
|
||||
companyId,
|
||||
userId,
|
||||
fileHash: hash,
|
||||
status: 'completed',
|
||||
fiscalPeriodId,
|
||||
})
|
||||
|
||||
// Mimic undo_sie_import's terminal write (we don't run the RPC here —
|
||||
// the RPC also detaches docs + deletes JEs which need richer setup).
|
||||
await getPool().query(
|
||||
`UPDATE public.sie_imports SET status = 'undone', replaced_at = now() WHERE id = $1`,
|
||||
[priorId],
|
||||
)
|
||||
|
||||
const newId = await insertSIEImport({
|
||||
companyId,
|
||||
userId,
|
||||
fileHash: hash,
|
||||
status: 'pending',
|
||||
fiscalPeriodId,
|
||||
})
|
||||
expect(newId).toBeTruthy()
|
||||
})
|
||||
|
||||
it('allows a re-import after a 0-entry vacuous import is backfilled to failed', async () => {
|
||||
// Mirrors the Lookma AB recovery path: 0-entry 'completed' rows are
|
||||
// backfilled to 'failed' by the same migration; the partial index
|
||||
// already excludes 'failed', so a fresh re-import succeeds.
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const hash = `hash-${randomUUID()}`
|
||||
|
||||
const stuckId = await insertSIEImport({
|
||||
companyId,
|
||||
userId,
|
||||
fileHash: hash,
|
||||
status: 'completed',
|
||||
fiscalPeriodId,
|
||||
})
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.sie_imports SET status = 'failed' WHERE id = $1`,
|
||||
[stuckId],
|
||||
)
|
||||
|
||||
const newId = await insertSIEImport({
|
||||
companyId,
|
||||
userId,
|
||||
fileHash: hash,
|
||||
status: 'pending',
|
||||
fiscalPeriodId,
|
||||
})
|
||||
expect(newId).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1641,7 +1641,7 @@ async function createPendingImportRecord(
|
||||
/**
|
||||
* Phase 2: Finalize the import record with results and archive the SIE file.
|
||||
*/
|
||||
async function finalizeImportRecord(
|
||||
export async function finalizeImportRecord(
|
||||
supabase: SupabaseClient,
|
||||
importId: string,
|
||||
companyId: string,
|
||||
@@ -1649,6 +1649,28 @@ async function finalizeImportRecord(
|
||||
fileContent: string,
|
||||
documentation?: MigrationDocumentation
|
||||
): Promise<void> {
|
||||
// Safety net: if the import ran without errors but didn't actually create
|
||||
// any journal entries (no OB entry, no vouchers), refuse to mark it as
|
||||
// 'completed'. A 'completed' row with transactions_count=0 would claim
|
||||
// the (company_id, file_hash) slot in the partial unique index and the
|
||||
// overlapping-period check would block any retry. Flipping to 'failed'
|
||||
// (which the partial index already excludes) keeps the slot free so the
|
||||
// caller can re-import the same file once the mapping is fixed.
|
||||
const noEntriesCreated =
|
||||
result.success &&
|
||||
result.journalEntriesCreated === 0 &&
|
||||
!result.openingBalanceEntryId
|
||||
if (noEntriesCreated) {
|
||||
result.success = false
|
||||
if (result.errors.length === 0) {
|
||||
result.errors.push(
|
||||
'Importen skapade 0 verifikationer — markerar som misslyckad så filen ' +
|
||||
'kan importeras om utan replace/undo. Granska varningarna för att se ' +
|
||||
'vilka konton som behöver mappas.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const status = result.success ? 'completed' : 'failed'
|
||||
|
||||
await supabase
|
||||
@@ -1799,6 +1821,35 @@ export async function executeSIEImport(
|
||||
return result
|
||||
}
|
||||
|
||||
// Defense in depth: refuse to enter executeSIEImport when the mapping
|
||||
// doesn't cover a single account present in the file. Without this guard
|
||||
// a stale MCP client (or the HTTP execute route) could still drive
|
||||
// importVouchers to silently skip every voucher and write a 0-entry
|
||||
// 'completed' sie_imports row that holds the unique-index slot. Mirrors
|
||||
// the stage-time check in gnubok_import_sie.
|
||||
const sourceAccountsInFile = new Set<string>()
|
||||
for (const v of parsed.vouchers) for (const l of v.lines) sourceAccountsInFile.add(l.account)
|
||||
if (options.importOpeningBalances) {
|
||||
for (const b of parsed.openingBalances.filter((b) => b.yearIndex === 0)) {
|
||||
sourceAccountsInFile.add(b.account)
|
||||
}
|
||||
}
|
||||
const mappedSources = new Set(
|
||||
mappings.filter((m) => m.targetAccount).map((m) => m.sourceAccount),
|
||||
)
|
||||
const hasOverlap = [...sourceAccountsInFile].some((a) => mappedSources.has(a))
|
||||
if (sourceAccountsInFile.size > 0 && !hasOverlap) {
|
||||
const sample = [...sourceAccountsInFile].slice(0, 8).join(', ')
|
||||
result.errors.push(
|
||||
`Kontomappningarna täcker inga konton i SIE-filen. ` +
|
||||
`Filen innehåller ${sourceAccountsInFile.size} unika källkonton ` +
|
||||
`(t.ex. ${sample}), men inget av dem finns i mappings.sourceAccount. ` +
|
||||
`Importen avbryts innan en sie_imports-rad skapas så att du kan ` +
|
||||
`försöka igen med korrekta mappningar.`,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
// Replace mode: if a prior completed import overlaps the new SIE's fiscal
|
||||
// year, mark it 'replaced' (and cancel its imported entries) before we
|
||||
// try to insert. Done before checkDuplicateImport / checkDuplicatePeriodImport
|
||||
|
||||
Reference in New Issue
Block a user