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