* fix(assistant): the salary fact follows the ledger, never the column default The in-app assistant told a payroll-running aktiebolag in every answer that it "betalar inte löner" (support case 2026-09-04). company_settings. pays_salaries is NOT NULL DEFAULT false and only the Skatt settings form writes it, so for every company that never opened that form the flag reads false whatever the ledger says, and lib/agent/ask/snapshot.ts asserted that default as a fact. - Trigger salary_runs_booked_marks_employer (20260904191000): a booked salary run sets pays_salaries = true and fills a never-attested employer_registered, at the one place every writer (dashboard, MCP, v1, seeders) passes through. Backfill for the 12 companies on prod already booking payroll with the flag at its default (8 of them also lacked the employer flag, and with it their AGI deadline reminders). An explicit employer_registered = false stays the user's answer. - The assistant snapshot applies the composer's employee-facts doctrine: positive evidence (active employees, the flag, an attested employer registration) yields the fact, only an attested negative yields the negative, the default yields nothing. It also names Inställningar > Skatt / > Bokföring so the model can point at the page. - The composer's KÄNDA FAKTA no longer prints "Betalar ut lön: nej" from the same default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE * fix(migration): NOT EXISTS instead of NOT IN for the reset-source exclusion A NULL source_company_id in the subquery would make NOT IN never true and silently skip the whole backfill. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
149 lines
5.6 KiB
TypeScript
149 lines
5.6 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('returns an empty string when there is nothing to say', async () => {
|
|
const snap = await buildAssistantSnapshot(supabaseWith(null), 'c1')
|
|
expect(snap).toBe('')
|
|
})
|
|
})
|