Part 3 of #2185. A user with several imported years concluded the assistant "only reads the period I am standing in". Nothing restricted it: the report tools defaulted to the most recent fiscal period when no period_id was given and then rejected any from_date/to_date/as_of_date outside it, and neither the snapshot nor the chat identity block told the model which years existed or how to address them. - extensions/general/mcp-server/server.ts: resolveReportPeriod takes a date hint; without period_id, a date in the call resolves the fiscal period that contains it (findFiscalPeriodContaining, company-scoped), and a date no period covers fails with the company's span instead of the latest year's bounds. Income statement (from_date or to_date), balance sheet (as_of_date) and dimension P&L (to_date) use it. The range guard is unchanged. No schema change: tools/list sits at its token ceiling. - lib/agent/fiscal-years.ts: one query and one line, "Räkenskapsår (senaste först): ... period_id=<uuid> (senaste|avslutat)", plus the rule on addressing an earlier year, shared by the single-call snapshot and the streaming chat's always-on identity block. - lib/agent/intents/shared-rules.ts and TOOL_RULES: pass that year's period_id, one call per year, and say which räkenskapsår the answer covers. Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
178 lines
6.7 KiB
TypeScript
178 lines
6.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
const getDeadlines = vi.fn()
|
|
vi.mock('@/lib/deadlines/status-engine', () => ({
|
|
getDeadlinesNeedingAttention: (...a: unknown[]) => getDeadlines(...a),
|
|
}))
|
|
|
|
import { buildAssistantSnapshot } from '../snapshot'
|
|
|
|
/**
|
|
* company_settings answers maybeSingle(); employees answers the awaited head
|
|
* count (the builder chain is thenable, like the real PostgREST builder).
|
|
*/
|
|
function supabaseWith(
|
|
settings: Record<string, unknown> | null,
|
|
activeEmployees: number | null = null,
|
|
): SupabaseClient {
|
|
return {
|
|
from: (table: string) => {
|
|
const chain = {
|
|
select: () => chain,
|
|
eq: () => chain,
|
|
maybeSingle: async () => ({ data: table === 'company_settings' ? settings : null, error: null }),
|
|
then: (onFulfilled: (v: unknown) => unknown) =>
|
|
Promise.resolve({ count: table === 'employees' ? activeEmployees : null, error: null }).then(
|
|
onFulfilled,
|
|
),
|
|
}
|
|
return chain
|
|
},
|
|
} as unknown as SupabaseClient
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
getDeadlines.mockResolvedValue({ overdue: [], actionNeeded: [] })
|
|
})
|
|
|
|
describe('buildAssistantSnapshot', () => {
|
|
it('summarises the company status line', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({
|
|
vat_registered: true,
|
|
moms_period: 'quarterly',
|
|
accounting_method: 'accrual',
|
|
pays_salaries: true,
|
|
}),
|
|
'c1',
|
|
)
|
|
expect(snap).toContain('momsregistrerad (momsperiod: quarterly)')
|
|
expect(snap).toContain('bokföringsmetod: fakturametod')
|
|
expect(snap).toContain('betalar löner')
|
|
})
|
|
|
|
it('handles a non-VAT, cash-method company', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({ vat_registered: false, accounting_method: 'cash', pays_salaries: false }),
|
|
'c1',
|
|
)
|
|
expect(snap).toContain('ej momsregistrerad')
|
|
expect(snap).toContain('bokföringsmetod: kontantmetod')
|
|
})
|
|
|
|
describe('the salary fact', () => {
|
|
// pays_salaries is NOT NULL DEFAULT false and only the Skatt settings form
|
|
// writes it, so false is what every company that never opened that form
|
|
// reads. Claiming "betalar inte löner" from it told a payroll-running
|
|
// aktiebolag in every answer that it had no salaries.
|
|
it('claims nothing when the flag is merely at its column default', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({ vat_registered: true, pays_salaries: false, employer_registered: null }, 0),
|
|
'c1',
|
|
)
|
|
expect(snap).not.toContain('betalar inte löner')
|
|
expect(snap).not.toContain('betalar löner')
|
|
expect(snap).toContain('Status: momsregistrerad.')
|
|
})
|
|
|
|
it('derives it from active employees when the flag was never set', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({ vat_registered: true, pays_salaries: false, employer_registered: null }, 1),
|
|
'c1',
|
|
)
|
|
expect(snap).toContain('betalar löner (1 anställd)')
|
|
})
|
|
|
|
it('pluralises the headcount', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({ vat_registered: true, pays_salaries: false }, 3),
|
|
'c1',
|
|
)
|
|
expect(snap).toContain('betalar löner (3 anställda)')
|
|
})
|
|
|
|
it('accepts an attested employer registration as the positive fact', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({ vat_registered: true, pays_salaries: false, employer_registered: true }, 0),
|
|
'c1',
|
|
)
|
|
expect(snap).toContain('betalar löner')
|
|
})
|
|
|
|
it('states the negative only from an attested employer_registered = false', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({ vat_registered: true, pays_salaries: false, employer_registered: false }, 0),
|
|
'c1',
|
|
)
|
|
expect(snap).toContain('betalar inte löner')
|
|
})
|
|
})
|
|
|
|
it('tells the model where the profile values are edited', async () => {
|
|
const snap = await buildAssistantSnapshot(supabaseWith({ vat_registered: true }), 'c1')
|
|
expect(snap).toContain('Inställningar > Skatt')
|
|
expect(snap).toContain('Inställningar > Bokföring')
|
|
})
|
|
|
|
it('lists deadlines that need attention (overdue first, capped)', async () => {
|
|
getDeadlines.mockResolvedValue({
|
|
overdue: [{ id: '1', title: 'Momsdeklaration', due_date: '2026-08-12', tax_deadline_type: 'vat' }],
|
|
actionNeeded: [{ id: '2', title: 'Arbetsgivardeklaration', due_date: '2026-08-17', tax_deadline_type: 'employer' }],
|
|
})
|
|
const snap = await buildAssistantSnapshot(supabaseWith(null), 'c1')
|
|
expect(snap).toContain('Deadlines som behöver åtgärd:')
|
|
expect(snap).toContain('Momsdeklaration (2026-08-12)')
|
|
expect(snap).toContain('Arbetsgivardeklaration (2026-08-17)')
|
|
})
|
|
|
|
it('is best-effort: a failing settings query still yields the deadlines line', async () => {
|
|
const throwing = {
|
|
from: () => ({
|
|
select: () => ({ eq: () => ({ maybeSingle: async () => { throw new Error('db down') } }) }),
|
|
}),
|
|
} as unknown as SupabaseClient
|
|
getDeadlines.mockResolvedValue({
|
|
overdue: [],
|
|
actionNeeded: [{ id: '2', title: 'Moms', due_date: '2026-09-12', tax_deadline_type: 'vat' }],
|
|
})
|
|
const snap = await buildAssistantSnapshot(throwing, 'c1')
|
|
expect(snap).toContain('Moms (2026-09-12)')
|
|
})
|
|
|
|
it('lists the räkenskapsår newest first with their period_id (#2185)', async () => {
|
|
const periods = [
|
|
{ id: 'fp-2026', name: '2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
|
|
{ id: 'fp-2025', name: '2025', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true },
|
|
]
|
|
const supabase = {
|
|
from: (table: string) => {
|
|
const chain = {
|
|
select: () => chain,
|
|
eq: () => chain,
|
|
order: () => chain,
|
|
limit: () => chain,
|
|
maybeSingle: async () => ({ data: null, error: null }),
|
|
then: (onFulfilled: (v: unknown) => unknown) =>
|
|
Promise.resolve(
|
|
table === 'fiscal_periods'
|
|
? { data: periods, error: null }
|
|
: { count: null, error: null },
|
|
).then(onFulfilled),
|
|
}
|
|
return chain
|
|
},
|
|
} as unknown as SupabaseClient
|
|
const snap = await buildAssistantSnapshot(supabase, 'c1')
|
|
expect(snap).toContain(
|
|
'Räkenskapsår (senaste först): 2026-01-01..2026-12-31 period_id=fp-2026 (senaste); 2025-01-01..2025-12-31 period_id=fp-2025 (avslutat).',
|
|
)
|
|
})
|
|
|
|
it('returns an empty string when there is nothing to say', async () => {
|
|
const snap = await buildAssistantSnapshot(supabaseWith(null), 'c1')
|
|
expect(snap).toBe('')
|
|
})
|
|
})
|