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
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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 ───────────────
|
||||
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user