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:
Mattsson
2026-05-30 01:28:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fc7a46c3f2
commit ea1bf01f1e
76 changed files with 5474 additions and 532 deletions
@@ -45,6 +45,11 @@ const UNBALANCED_SIE = [
'}',
].join('\n')
const COVER_VALID_SIE = [
{ sourceAccount: '6110', sourceName: 'Kontorsmaterial', targetAccount: '6110', targetName: 'Kontorsmaterial', confidence: 1, matchType: 'exact', isOverride: false },
{ sourceAccount: '1930', sourceName: 'Företagskonto', targetAccount: '1930', targetName: 'Företagskonto', confidence: 1, matchType: 'exact', isOverride: false },
]
beforeEach(() => {
vi.clearAllMocks()
})
@@ -55,7 +60,7 @@ describe('gnubok_import_sie — stage-time validation', () => {
enqueue({ data: { id: 'op-sie' }, error: null }) // pending_operations insert
const result = (await importSie.execute(
{ file_content: VALID_SIE, filename: 'bok.se', mappings: [] },
{ file_content: VALID_SIE, filename: 'bok.se', mappings: COVER_VALID_SIE },
'company-1',
'user-1',
supabase as never,
@@ -70,6 +75,8 @@ describe('gnubok_import_sie — stage-time validation', () => {
expect(result.preview.account_count).toBe(3)
expect(result.preview.fiscal_year).toMatchObject({ start: '2024-01-01', end: '2024-12-31' })
expect(result.preview.opening_balance).toMatchObject({ total: 0, is_balanced: true })
expect(result.preview.accounts_mapped).toMatchObject({ covered: 2, total: 2 })
expect(result.preview.would_skip_all_vouchers).toBe(false)
})
it('rejects an unbalanced file at stage time (no blind staging)', async () => {
@@ -77,7 +84,7 @@ describe('gnubok_import_sie — stage-time validation', () => {
await expect(
importSie.execute(
{ file_content: UNBALANCED_SIE, filename: 'trasig.se', mappings: [] },
{ file_content: UNBALANCED_SIE, filename: 'trasig.se', mappings: COVER_VALID_SIE },
'company-1',
'user-1',
supabase as never,
@@ -98,4 +105,75 @@ describe('gnubok_import_sie — stage-time validation', () => {
),
).rejects.toThrow(/file_content/)
})
// Lookma AB regression (support case 2026-05-28): staging with mappings=[]
// committed a 0-entry 'completed' sie_imports row that then blocked retry.
// The fix refuses to stage when the mappings can't cover the file.
it('rejects when mappings is empty and the file has vouchers', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
importSie.execute(
{ file_content: VALID_SIE, filename: 'lookma.se', mappings: [] },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/täcker inga konton|skulle hoppas över/i)
})
it('rejects when mappings don\'t overlap the file\'s accounts', async () => {
const { supabase } = createQueuedMockSupabase()
const wrongMappings = [
{ sourceAccount: '9999', sourceName: 'Fantasi', targetAccount: '9999', targetName: 'Fantasi', confidence: 1, matchType: 'exact', isOverride: false },
]
await expect(
importSie.execute(
{ file_content: VALID_SIE, filename: 'wrong.se', mappings: wrongMappings },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/täcker inga konton/i)
})
it('rejects when targetAccount is null on every mapping (Lookma shape)', async () => {
const { supabase } = createQueuedMockSupabase()
// The original Lookma agent sent #KONTO rows formatted as mapping objects
// but with no targetAccount resolved. Coverage check ignores those.
const halfBakedMappings = [
{ sourceAccount: '6110', sourceName: 'Kontorsmaterial', targetAccount: null, targetName: '', confidence: 0, matchType: 'manual', isOverride: false },
{ sourceAccount: '1930', sourceName: 'Företagskonto', targetAccount: null, targetName: '', confidence: 0, matchType: 'manual', isOverride: false },
]
await expect(
importSie.execute(
{ file_content: VALID_SIE, filename: 'half.se', mappings: halfBakedMappings },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/täcker inga konton/i)
})
it('stages when partial overlap exists (1 of 2 accounts mapped)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-sie-partial' }, error: null })
const partial = [
{ sourceAccount: '6110', sourceName: 'Kontorsmaterial', targetAccount: '6110', targetName: 'Kontorsmaterial', confidence: 1, matchType: 'exact', isOverride: false },
]
const result = (await importSie.execute(
{ file_content: VALID_SIE, filename: 'bok.se', mappings: partial },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.accounts_mapped).toMatchObject({ covered: 1, total: 2 })
expect(result.preview.would_skip_all_vouchers).toBe(false)
})
})
@@ -0,0 +1,154 @@
/**
* Stage-time pre-flight for gnubok_undo_sie_import.
*
* The tool mirrors undoSIEImport's gates so the approver sees an honest
* preview: row must exist, must be in 'completed' status, and if linked
* to a fiscal period that period must be open + unlocked.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
const undoTool = tools.find((t) => t.name === 'gnubok_undo_sie_import')!
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_undo_sie_import — stage-time validation', () => {
it('stages an undo when the import is completed and the period is open', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'imp-1',
filename: 'lookmaab-201907-202006.se',
fiscal_year_start: '2019-07-01',
fiscal_year_end: '2020-06-30',
transactions_count: 109,
opening_balance_entry_id: null,
status: 'completed',
fiscal_period_id: 'fp-1',
imported_at: '2026-05-28T10:00:00Z',
},
error: null,
}) // sie_imports lookup
enqueue({
data: { name: 'Räkenskapsår 2019/2020', is_closed: false, locked_at: null },
error: null,
}) // fiscal_periods lookup
enqueue({ data: { id: 'op-undo-1' }, error: null }) // pending_operations insert
const result = (await undoTool.execute(
{ import_id: 'imp-1', reason: 'Importen skapade 0 verifikat' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)) as { staged: boolean; operation_id?: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-undo-1')
expect(result.preview.import).toMatchObject({
id: 'imp-1',
filename: 'lookmaab-201907-202006.se',
transactions_count: 109,
has_opening_balance_entry: false,
fiscal_period_name: 'Räkenskapsår 2019/2020',
})
})
it('rejects when the import row is not found', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // sie_imports lookup misses
await expect(
undoTool.execute(
{ import_id: 'imp-missing' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/hittades inte/i)
})
it('rejects when the import is not in completed status', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'imp-2',
filename: 'half.se',
fiscal_year_start: '2024-01-01',
fiscal_year_end: '2024-12-31',
transactions_count: 0,
opening_balance_entry_id: null,
status: 'pending',
fiscal_period_id: null,
imported_at: null,
},
error: null,
})
await expect(
undoTool.execute(
{ import_id: 'imp-2' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/slutförda importer kan ångras/i)
})
it('rejects when the linked fiscal period is locked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'imp-3',
filename: 'locked.se',
fiscal_year_start: '2024-01-01',
fiscal_year_end: '2024-12-31',
transactions_count: 1,
opening_balance_entry_id: 'ob-1',
status: 'completed',
fiscal_period_id: 'fp-locked',
imported_at: '2026-05-01T00:00:00Z',
},
error: null,
})
enqueue({
data: { name: 'Räkenskapsår 2024', is_closed: false, locked_at: '2026-04-30T00:00:00Z' },
error: null,
})
await expect(
undoTool.execute(
{ import_id: 'imp-3' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/låst eller stängt/i)
})
it('rejects missing import_id before any DB hit', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
undoTool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' }),
).rejects.toThrow(/import_id/i)
})
it('rejects reason longer than 500 characters', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
undoTool.execute(
{ import_id: 'imp-1', reason: 'x'.repeat(501) },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/500/)
})
})
+132
View File
@@ -6847,6 +6847,38 @@ export const tools: McpTool[] = [
const ibCurrent = parsed.openingBalances.filter((b) => b.yearIndex === 0)
const ibTotal = Math.round(ibCurrent.reduce((s, b) => s + b.amount, 0) * 100) / 100
// Mapping-coverage check. The executor's per-voucher loop silently
// skips any line whose account is not in `mappings`, so an empty or
// non-overlapping mapping set produces a committed import with
// journal_entries_created=0 that then claims the (company_id,
// file_hash) slot in the partial unique index and blocks retry.
// Refuse to stage when the mapping wouldn't cover a single account
// present in the file.
const importOB = Boolean(args.import_opening_balances)
const sourceAccountsInFile = new Set<string>()
for (const v of parsed.vouchers) for (const l of v.lines) sourceAccountsInFile.add(l.account)
if (importOB) for (const b of ibCurrent) sourceAccountsInFile.add(b.account)
const mappedSources = new Set(
(mappings as Array<{ sourceAccount?: unknown; targetAccount?: unknown }>)
.filter((m) => typeof m?.targetAccount === 'string' && m.targetAccount.length > 0 && typeof m?.sourceAccount === 'string')
.map((m) => m.sourceAccount as string),
)
const coveredAccounts = [...sourceAccountsInFile].filter((a) => mappedSources.has(a))
const accountsMapped = { covered: coveredAccounts.length, total: sourceAccountsInFile.size }
const wouldSkipAllVouchers = sourceAccountsInFile.size > 0 && coveredAccounts.length === 0
if (wouldSkipAllVouchers) {
const sample = [...sourceAccountsInFile].slice(0, 8).join(', ')
throw new Error(
`Kontomappningarna täcker inga konton i SIE-filen — alla ` +
`${parsed.stats.totalVouchers} verifikationer skulle hoppas över ` +
`och importen skulle skapa 0 verifikat. Filen innehåller ` +
`${sourceAccountsInFile.size} unika källkonton (t.ex. ${sample}). ` +
`Bifoga "mappings" där sourceAccount matchar #KONTO-numren i filen ` +
`och targetAccount är ett giltigt BAS-konto.`,
)
}
return stagePendingOperation(supabase, companyId, userId, 'import_sie',
`SIE-import: ${filename}`,
{
@@ -6862,6 +6894,8 @@ export const tools: McpTool[] = [
filename,
file_size_bytes: fileContent.length,
mappings_count: mappings.length,
accounts_mapped: accountsMapped,
would_skip_all_vouchers: wouldSkipAllVouchers,
company_name: parsed.header.companyName,
org_number: parsed.header.orgNumber,
fiscal_year: { start: parsed.stats.fiscalYearStart, end: parsed.stats.fiscalYearEnd },
@@ -6884,6 +6918,104 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_undo_sie_import',
description: 'Stage undo of a completed SIE import: hard-deletes its entries (transactions + opening balance), detaches docs, resets voucher_sequences, marks the row \'undone\' so the file can be re-imported. Use after a botched import. Period must be open. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
import_id: { type: 'string', description: 'UUID of the sie_imports row to undo. Must be status=\'completed\'.' },
reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason — shown in pending_operations review.' },
},
required: ['import_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const importId = args.import_id as string
const reason = typeof args.reason === 'string' ? args.reason : undefined
if (!importId) throw new Error('import_id is required')
if (reason !== undefined && reason.length > 500) {
throw new Error('reason must be 500 characters or fewer')
}
// Pre-flight mirrors undoSIEImport: confirm row exists, belongs to
// this company, is in 'completed' status, and (if linked) the fiscal
// period is open + unlocked. Surfacing rejection at stage-time keeps
// the agent honest about what the approver is being asked to confirm.
type ImportRow = {
id: string
filename: string
fiscal_year_start: string | null
fiscal_year_end: string | null
transactions_count: number | null
opening_balance_entry_id: string | null
status: string
fiscal_period_id: string | null
imported_at: string | null
}
const { data, error: lookupErr } = await supabase
.from('sie_imports')
.select('id, filename, fiscal_year_start, fiscal_year_end, transactions_count, opening_balance_entry_id, status, fiscal_period_id, imported_at')
.eq('id', importId)
.eq('company_id', companyId)
.maybeSingle()
const importRow = data as ImportRow | null
if (lookupErr) {
throw new Error(`Kunde inte slå upp SIE-import ${importId}: ${lookupErr.message}`)
}
if (!importRow) {
throw new Error(`SIE-import hittades inte: ${importId}`)
}
if (importRow.status !== 'completed') {
throw new Error(`Bara slutförda importer kan ångras (nuvarande status: ${importRow.status}).`)
}
let fiscalPeriodName: string | null = null
if (importRow.fiscal_period_id) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('name, is_closed, locked_at')
.eq('id', importRow.fiscal_period_id)
.eq('company_id', companyId)
.maybeSingle()
if (period?.is_closed || period?.locked_at) {
throw new Error(
`Räkenskapsåret "${period.name ?? 'okänt'}" är låst eller stängt. ` +
`Öppna perioden innan du ångrar importen.`,
)
}
fiscalPeriodName = (period as { name?: string } | null)?.name ?? null
}
return stagePendingOperation(supabase, companyId, userId, 'undo_sie_import',
`Ångra SIE-import: ${importRow.filename}`,
{ import_id: importId },
{
import: {
id: importRow.id,
filename: importRow.filename,
fiscal_year: { start: importRow.fiscal_year_start, end: importRow.fiscal_year_end },
fiscal_period_name: fiscalPeriodName,
transactions_count: importRow.transactions_count ?? 0,
has_opening_balance_entry: Boolean(importRow.opening_balance_entry_id),
imported_at: importRow.imported_at,
},
reason: reason ?? null,
will: 'hard-delete the import\'s journal entries (transactions + opening balance), detach user-attached documents, reset voucher_sequences, and mark the sie_imports row as \'undone\' so the file can be re-imported',
},
actor,
{
description: 'After commit, re-stage the SIE import with corrected mappings via gnubok_import_sie.',
tool: 'gnubok_import_sie',
},
)
},
},
// ── Phase 4: arbitrary-line bookkeeping primitives ───────────────
{