feat(parties): the register fills the row, a compact Företagsuppgifter, and the party for agents (v1 expand + MCP) (#2315)

* feat(parties): the register fills the row, Företagsuppgifter shrinks to what only the register knows, and agents get the party

Founder feedback on the first Företagsuppgifter (2026-09-05): the org
number twice, the VAT number twice, the legal name repeating the
heading, and Kontaktuppgifter showing dashes while the block above had
the phone, e-mail and address from SCB.

- After a fetch the register's contact details land on the supplier and
  customer rows that point at the party: an empty field, or one still
  carrying what the register said last time, takes the new value; a
  value a person typed stays. Shown as "från SCB" on the row (by
  equality with the registry fact, no source column).
- Företagsuppgifter becomes one status line (legal form, active or not,
  registrations, a Bolagsverket warning when there is one), industry,
  seat with registration date, and size. Identity stays in the header
  (org number now formatted) and Kontaktuppgifter. The legal name shows
  only when it differs from the row's name.
- lib/parties/registry-summary.ts reads the coded SCB facts once for the
  page, the v1 API and MCP; lib/parties/party-api.ts is the agent shape.
- v1: party_id on supplier and customer list rows and detail;
  ?expand=party on detail embeds identity, the register summary, what
  the ledger has seen and payment identities. MCP: party_id on
  gnubok_list_suppliers/customers rows and gnubok_get_party (by party,
  supplier or customer id). Read-only; the parties resource follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(parties): regenerate the API skill for the party expansion; tighten the get_party description

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(mcp): gnubok_get_party is search-only, keeping tools/list under its byte budget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

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 14:33:20 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent f33628f005
commit 0ad83b8d71
21 changed files with 881 additions and 85 deletions
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { addressRowsFromRegistry, contactFill, fromRegistry, registrySummary } from '../registry-summary'
const scb = (field: string, value: unknown, fetchedAt = '2026-09-05T10:00:00Z') => ({ field, value, source: 'registry_scb', fetchedAt })
const WEBHALLEN = [
scb('legal_name', 'WEBHALLEN SVERIGE AB'),
scb('legal_form', { code: '49', label: 'Övriga aktiebolag' }),
scb('company_status', { code: '1', label: 'Verksamt' }),
scb('bolagsverket_status', { code: '0', label: 'Normalläge', warning: false }),
scb('f_tax', { code: '1', label: 'Godkänd för F-skatt' }),
scb('vat_registration', { code: '1', label: 'Momsregistrerad' }),
scb('employer_registration', { code: '1', label: 'Registrerad som arbetsgivare' }),
scb('industry', { code: '47410', label: 'Detaljhandel med datorer, programvara, data- och tv-spel' }),
scb('seat', { municipality: 'Stockholm', county: 'Stockholm' }),
scb('employees_band', { code: '6', label: '100-199 anställda' }),
scb('turnover_band', { code: '10', label: '1 000 000 - 4 999 999 tkr', year: '2025' }),
scb('workplaces', 15),
scb('registered_at', '1999-02-19'),
scb('postal_address', { co: null, street: 'TELEGRAFGATAN 4', postal_code: '169 72', city: 'SOLNA' }),
scb('phone', '086736000'),
scb('email', 'info@webhallen.com'),
scb('vat_number', 'SE556558822401', '2026-09-05T11:00:00Z'),
{ field: 'voucher_text', value: ['x'], source: 'ledger', fetchedAt: null },
]
describe('registrySummary', () => {
it('reads the coded facts into one summary', () => {
const s = registrySummary(WEBHALLEN)!
expect(s.legal_name).toBe('WEBHALLEN SVERIGE AB')
expect(s.legal_form).toBe('Övriga aktiebolag')
expect(s.status).toEqual({ label: 'Verksamt', active: true })
expect(s.warning).toBeNull()
expect(s.registrations).toEqual({ f_tax: true, vat: true, employer: true })
expect(s.industry?.label).toContain('Detaljhandel')
expect(s.seat).toBe('Stockholm')
expect(s.employees_band).toBe('100-199 anställda')
expect(s.turnover).toEqual({ band: '1 000 000 - 4 999 999 tkr', year: '2025' })
expect(s.workplaces).toBe(15)
expect(s.contact).toEqual({ email: 'info@webhallen.com', phone: '086736000', address: { co: null, street: 'TELEGRAFGATAN 4', postal_code: '169 72', city: 'SOLNA' } })
expect(s.vat_number).toBe('SE556558822401')
expect(s.fetched_at).toBe('2026-09-05T11:00:00Z')
})
it('surfaces a Bolagsverket warning and an inactive status, and is null without registry facts', () => {
const s = registrySummary([
scb('company_status', { code: '9', label: 'Ej verksamt' }),
scb('bolagsverket_status', { code: '31', label: 'Konkurs inledd', warning: true }),
scb('f_tax', { code: '9', label: 'Avregistrerad för F-skatt' }),
])!
expect(s.status).toEqual({ label: 'Ej verksamt', active: false })
expect(s.warning).toBe('Konkurs inledd')
expect(s.registrations.f_tax).toBe(false)
expect(s.registrations.vat).toBeNull()
expect(registrySummary([{ field: 'country', value: 'NL', source: 'ledger' }])).toBeNull()
})
})
describe('contactFill', () => {
const now = { email: 'info@webhallen.com', phone: '086736000', address: { co: null, street: 'Telegrafgatan 4', postal_code: '169 72', city: 'Solna' } }
const empty = { email: null, phone: null, address_line1: null, address_line2: null, postal_code: null, city: null }
it('fills empty fields and never a value a person typed', () => {
expect(contactFill(empty, now, null)).toEqual({ email: 'info@webhallen.com', phone: '086736000', address_line1: 'Telegrafgatan 4', address_line2: null, postal_code: '169 72', city: 'Solna' })
const typed = { ...empty, email: 'faktura@webhallen.com', address_line1: 'Box 12', postal_code: '101 20', city: 'Stockholm' }
expect(contactFill(typed, now, null)).toEqual({ phone: '086736000' })
})
it('replaces what the register said last time when the register changed, and nothing when it did not', () => {
const before = { email: 'old@webhallen.com', phone: '086736000', address: { co: null, street: 'Gamla gatan 1', postal_code: '111 11', city: 'Stockholm' } }
const row = { email: 'old@webhallen.com', phone: '086736000', address_line1: 'Gamla gatan 1', address_line2: null, postal_code: '111 11', city: 'Stockholm' }
expect(contactFill(row, now, before)).toEqual({ email: 'info@webhallen.com', address_line1: 'Telegrafgatan 4', address_line2: null, postal_code: '169 72', city: 'Solna' })
expect(contactFill(row, before, before)).toEqual({})
})
it('puts a c/o line first and knows what came from the register', () => {
expect(addressRowsFromRegistry({ co: 'c/o Ekonomi AB', street: 'Storgatan 1', postal_code: '111 22', city: 'Stockholm' })).toEqual({ address_line1: 'c/o Ekonomi AB', address_line2: 'Storgatan 1', postal_code: '111 22', city: 'Stockholm' })
expect(fromRegistry('info@webhallen.com', 'INFO@webhallen.com')).toBe(true)
expect(fromRegistry('', 'x')).toBe(false)
expect(fromRegistry('a', null)).toBe(false)
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* Parties: the party behind a supplier or customer, as the v1 REST API and
* the MCP server hand it to an agent.
*
* One shape for both surfaces: identity, where the party sits (roles,
* status), the register's summary and what the ledger has seen. Read-only;
* the write paths (promote, enrich, merge) stay in the app until the
* parties resource lands in v1.
*/
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getDossier, type Dossier } from './register'
import { registrySummary, type RegistrySummary } from './registry-summary'
export interface PartyForApi {
id: string
display_name: string
legal_name: string | null
org_number: string | null
vat_number: string | null
/** ISO 3166-1 alpha-2 read out of vouchers or a register; null when unknown. */
country: string | null
kind: string
status: 'confirmed' | 'suggested'
roles: { supplier_id: string | null; customer_id: string | null }
/** What SCB's company register says, or null when it was never asked. */
registry: RegistrySummary | null
/** What the ledger has seen under this party's keys in the dossier's period. */
ledger: {
occurrences: number
expense_sek: number
revenue_sek: number
first_seen: string | null
last_seen: string | null
dominant_account: string | null
} | null
/** Payment identities seen on documents: bankgiro, plusgiro. */
identities: Array<{ scheme: string; value: string; status: string; seen_count: number }>
}
export function partyForApi(dossier: Dossier): PartyForApi {
const p = dossier.party
const s = p.stats
return {
id: p.id,
display_name: p.displayName,
legal_name: p.legalName,
org_number: p.orgNumber,
vat_number: p.vatNumber,
country: p.country,
kind: p.kind,
status: p.status,
roles: { supplier_id: p.roles.supplierId, customer_id: p.roles.customerId },
registry: registrySummary(dossier.facts),
ledger: s
? {
occurrences: s.occurrences,
expense_sek: s.expenseSek,
revenue_sek: s.revenueSek,
first_seen: s.firstSeen,
last_seen: s.lastSeen,
dominant_account: s.dominantAccount ?? null,
}
: null,
identities: dossier.identities.map((i) => ({ scheme: i.scheme, value: i.value, status: i.status, seen_count: i.seenCount })),
}
}
/** The v1 REST schema of the party, for the OpenAPI spec and the agent skill. */
export const PartyForApiSchema = z.object({
id: z.string().uuid(),
display_name: z.string(),
legal_name: z.string().nullable(),
org_number: z.string().nullable(),
vat_number: z.string().nullable(),
country: z.string().nullable(),
kind: z.string(),
status: z.enum(['confirmed', 'suggested']),
roles: z.object({ supplier_id: z.string().uuid().nullable(), customer_id: z.string().uuid().nullable() }),
registry: z
.object({
legal_name: z.string().nullable(),
legal_form: z.string().nullable(),
status: z.object({ label: z.string(), active: z.boolean() }).nullable(),
warning: z.string().nullable(),
registrations: z.object({ f_tax: z.boolean().nullable(), vat: z.boolean().nullable(), employer: z.boolean().nullable() }),
industry: z.object({ code: z.string(), label: z.string() }).nullable(),
seat: z.string().nullable(),
registered_at: z.string().nullable(),
active_since: z.string().nullable(),
active_until: z.string().nullable(),
employees_band: z.string().nullable(),
turnover: z.object({ band: z.string(), year: z.string().nullable() }).nullable(),
workplaces: z.number().nullable(),
contact: z.object({
email: z.string().nullable(),
phone: z.string().nullable(),
address: z.object({ co: z.string().nullable(), street: z.string().nullable(), postal_code: z.string().nullable(), city: z.string().nullable() }).nullable(),
}),
vat_number: z.string().nullable(),
fetched_at: z.string().nullable(),
})
.nullable(),
ledger: z
.object({
occurrences: z.number(),
expense_sek: z.number(),
revenue_sek: z.number(),
first_seen: z.string().nullable(),
last_seen: z.string().nullable(),
dominant_account: z.string().nullable(),
})
.nullable(),
identities: z.array(z.object({ scheme: z.string(), value: z.string(), status: z.string(), seen_count: z.number() })),
})
/** The party behind a row, for ?expand=party: null when the row has none or it was dismissed. */
export async function expandParty(supabase: SupabaseClient, companyId: string, partyId: string | null): Promise<PartyForApi | null> {
if (!partyId) return null
const dossier = await getDossier(supabase, companyId, partyId)
return dossier ? partyForApi(dossier) : null
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Parties: the register's facts as one summary.
*
* SCB answers with twenty-odd coded columns. Three surfaces need them as
* a few plain values: the Företagsuppgifter block on a supplier or customer
* page, the v1 REST `party` expansion, and the MCP party tool. One reading
* of the facts, so the three cannot drift. Pure: facts in, summary out.
*/
export interface RegistryFactLike {
field: string
value: unknown
source: string
fetchedAt?: string | null
}
export interface RegistryAddress {
co: string | null
street: string | null
postal_code: string | null
city: string | null
}
export interface RegistrySummary {
legal_name: string | null
legal_form: string | null
/** SCB's company status: label and whether it means "active". */
status: { label: string; active: boolean } | null
/** Bolagsverket status only when it is a warning (likvidation, konkurs, ...). */
warning: string | null
registrations: { f_tax: boolean | null; vat: boolean | null; employer: boolean | null }
industry: { code: string; label: string } | null
seat: string | null
registered_at: string | null
active_since: string | null
active_until: string | null
employees_band: string | null
turnover: { band: string; year: string | null } | null
workplaces: number | null
contact: { email: string | null; phone: string | null; address: RegistryAddress | null }
vat_number: string | null
fetched_at: string | null
}
type Coded = { code?: unknown; label?: unknown; warning?: unknown; year?: unknown }
function coded(value: unknown): Coded | null {
return value && typeof value === 'object' ? (value as Coded) : null
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
/** Registered under SCB's coding: 1 registered, 3 via representant; 0 never, 9 deregistered. */
function registered(value: unknown): boolean | null {
const code = text(coded(value)?.code)
if (code === null) return null
return code === '1' || code === '2' || code === '3'
}
/** The registry facts of a party as one summary, or null when the register has said nothing. */
export function registrySummary(facts: RegistryFactLike[]): RegistrySummary | null {
const scb = facts.filter((f) => f.source === 'registry_scb')
if (scb.length === 0) return null
const get = (field: string) => scb.find((f) => f.field === field)?.value
const status = coded(get('company_status'))
const bolagsverket = coded(get('bolagsverket_status'))
const industry = coded(get('industry'))
const seat = get('seat') as { municipality?: string | null; county?: string | null } | undefined
const turnover = coded(get('turnover_band'))
const address = get('postal_address') as Partial<RegistryAddress> | undefined
const fetchedAt = scb.map((f) => f.fetchedAt ?? null).filter((d): d is string => !!d).sort().at(-1) ?? null
return {
legal_name: text(get('legal_name')),
legal_form: text(coded(get('legal_form'))?.label),
status: status ? { label: text(status.label) ?? '', active: text(status.code) === '1' } : null,
warning: bolagsverket?.warning === true ? (text(bolagsverket.label) ?? null) : null,
registrations: { f_tax: registered(get('f_tax')), vat: registered(get('vat_registration')), employer: registered(get('employer_registration')) },
industry: industry && text(industry.label) ? { code: text(industry.code) ?? '', label: text(industry.label) ?? '' } : null,
seat: seat ? ([seat.municipality, seat.county].filter((x, i, arr): x is string => !!x && (i === 0 || x !== arr[0])).join(', ') || null) : null,
registered_at: text(get('registered_at')),
active_since: text(get('active_since')),
active_until: text(get('active_until')),
employees_band: text(coded(get('employees_band'))?.label),
turnover: turnover && text(turnover.label) ? { band: text(turnover.label) ?? '', year: text(turnover.year) } : null,
workplaces: typeof get('workplaces') === 'number' ? (get('workplaces') as number) : null,
contact: {
email: text(get('email')),
phone: text(get('phone')),
address:
address && (text(address.street) || text(address.postal_code) || text(address.city))
? { co: text(address.co), street: text(address.street), postal_code: text(address.postal_code), city: text(address.city) }
: null,
},
vat_number: text(get('vat_number')),
fetched_at: fetchedAt,
}
}
/**
* How a registry address lands on a supplier or customer row. The c/o line
* goes first, as Swedish post wants it; the street follows.
*/
export function addressRowsFromRegistry(address: RegistryAddress): { address_line1: string | null; address_line2: string | null; postal_code: string | null; city: string | null } {
return address.co
? { address_line1: address.co, address_line2: address.street, postal_code: address.postal_code, city: address.city }
: { address_line1: address.street, address_line2: null, postal_code: address.postal_code, city: address.city }
}
export interface ContactRow {
email: string | null
phone: string | null
address_line1: string | null
address_line2: string | null
postal_code: string | null
city: string | null
}
const norm = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
/**
* Which contact fields to write on a row after a fetch. A field is filled
* when it is empty, or when it still carries what the register said last
* time (the person never touched it) and the register now says something
* else. A value a person typed is never replaced.
*/
export function contactFill(row: ContactRow, now: RegistrySummary['contact'], before: RegistrySummary['contact'] | null): Partial<ContactRow> {
const out: Partial<ContactRow> = {}
const untouched = (current: string | null, previous: string | null | undefined) => !norm(current) || (previous != null && norm(current) === norm(previous))
if (now.email && untouched(row.email, before?.email) && norm(row.email) !== norm(now.email)) out.email = now.email
if (now.phone && untouched(row.phone, before?.phone) && norm(row.phone) !== norm(now.phone)) out.phone = now.phone
if (now.address) {
const next = addressRowsFromRegistry(now.address)
const prev = before?.address ? addressRowsFromRegistry(before.address) : null
const addressUntouched =
untouched(row.address_line1, prev?.address_line1) &&
untouched(row.address_line2, prev?.address_line2) &&
untouched(row.postal_code, prev?.postal_code) &&
untouched(row.city, prev?.city)
const changed = (['address_line1', 'address_line2', 'postal_code', 'city'] as const).some((k) => norm(row[k]) !== norm(next[k]))
if (addressUntouched && changed) Object.assign(out, next)
}
return out
}
/** True when the row's value is what the register said: shown as "från SCB". */
export function fromRegistry(rowValue: string | null | undefined, registryValue: string | null | undefined): boolean {
return !!norm(rowValue) && norm(rowValue) === norm(registryValue)
}