fix(assistant): the salary fact follows the ledger, never the column default (#2290)

* 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>
This commit is contained in:
Jakob Wennberg
2026-09-05 09:49:21 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent e684606b23
commit 0bd3c27fba
6 changed files with 278 additions and 16 deletions
+76 -8
View File
@@ -8,13 +8,28 @@ vi.mock('@/lib/deadlines/status-engine', () => ({
import { buildAssistantSnapshot } from '../snapshot'
function supabaseWith(settings: Record<string, unknown> | null): SupabaseClient {
const chain = {
select: () => chain,
eq: () => chain,
maybeSingle: async () => ({ data: settings, error: null }),
}
return { from: () => chain } as unknown as SupabaseClient
/**
* 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(() => {
@@ -45,7 +60,60 @@ describe('buildAssistantSnapshot', () => {
)
expect(snap).toContain('ej momsregistrerad')
expect(snap).toContain('bokföringsmetod: kontantmetod')
expect(snap).toContain('betalar inte löner')
})
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 () => {
+52 -6
View File
@@ -1,5 +1,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getDeadlinesNeedingAttention } from '@/lib/deadlines/status-engine'
import {
loadActiveEmployeeCount,
resolveEmployeeFacts,
type EmployeeVerdict,
} from '@/lib/agent/composer/employee-facts'
/**
* A compact, always-on grounding block for the single-call assistant.
@@ -24,6 +29,30 @@ function accountingMethodLabel(method: string | null | undefined): string | null
return method
}
/**
* The salary part of the status line, or null when nothing is known.
*
* company_settings.pays_salaries is NOT NULL DEFAULT false and its only
* writer is the Skatt settings form, so `false` is what every company that
* never opened that form reads, whatever its ledger says. Stating "betalar
* inte löner" from that default told a payroll-running aktiebolag in every
* answer that it had no salaries (support case 2026-09-04). The same doctrine
* as the composer (lib/agent/composer/employee-facts.ts): positive evidence
* (active employees, the flag, an attested employer registration) yields the
* fact, only an attested negative yields the negative, and the default
* yields nothing.
*/
function salaryPart(verdict: EmployeeVerdict): string | null {
switch (verdict.kind) {
case 'count':
return `betalar löner (${verdict.count} ${verdict.count === 1 ? 'anställd' : 'anställda'})`
case 'employer':
return verdict.isEmployer ? 'betalar löner' : 'betalar inte löner'
default:
return null
}
}
export async function buildAssistantSnapshot(
supabase: SupabaseClient,
companyId: string,
@@ -31,16 +60,20 @@ export async function buildAssistantSnapshot(
const lines: string[] = []
try {
const { data } = await supabase
.from('company_settings')
.select('vat_registered, moms_period, accounting_method, pays_salaries')
.eq('company_id', companyId)
.maybeSingle()
const [{ data }, activeEmployees] = await Promise.all([
supabase
.from('company_settings')
.select('vat_registered, moms_period, accounting_method, pays_salaries, employer_registered')
.eq('company_id', companyId)
.maybeSingle(),
loadActiveEmployeeCount(supabase, companyId),
])
const row = data as {
vat_registered?: boolean | null
moms_period?: string | null
accounting_method?: string | null
pays_salaries?: boolean | null
employer_registered?: boolean | null
} | null
if (row) {
const parts: string[] = []
@@ -51,8 +84,21 @@ export async function buildAssistantSnapshot(
)
const method = accountingMethodLabel(row.accounting_method)
if (method) parts.push(`bokföringsmetod: ${method}`)
parts.push(row.pays_salaries ? 'betalar löner' : 'betalar inte löner')
const salary = salaryPart(
resolveEmployeeFacts({
activeEmployees,
ticEmployeeRange: null,
employerRegistered: row.employer_registered ?? null,
paysSalaries: row.pays_salaries ?? null,
}),
)
if (salary) parts.push(salary)
lines.push(`Status: ${parts.join(', ')}.`)
// So the model can point the user at the right page instead of
// "inställningarna" in general when a value here is wrong.
lines.push(
'Grunduppgifterna ovan ändras under Inställningar > Skatt (moms, löner) och Inställningar > Bokföring (bokföringsmetod).',
)
}
} catch {
// best-effort: skip the status line
+6 -2
View File
@@ -480,8 +480,12 @@ export function buildKnownFacts(inputs: ComposerInputs): string[] {
if (s.vat_registered != null) {
out.push(`Momsregistrerad: ${s.vat_registered ? 'ja' : 'nej'}`)
}
if (s.pays_salaries != null) {
out.push(`Betalar ut lön: ${s.pays_salaries ? 'ja' : 'nej'}`)
// Only a true is a fact: the column is NOT NULL DEFAULT false, so false is
// what every company that never opened the Skatt settings reads
// (employee-facts.ts). An attested negative surfaces through
// employeeKnownFact below; the default must not be read as "nej".
if (s.pays_salaries === true) {
out.push('Betalar ut lön: ja')
}
if (s.employer_registered != null) {
out.push(`Arbetsgivarregistrerad: ${s.employer_registered ? 'ja' : 'nej'}`)