Files
accounted/lib/parties/registry-summary.ts
T
bff44e5757 fix(parties): one "från SCB" note per contact section (#2316)
* fix(parties): one "från SCB" note per contact section, not one under every field

The founder read four tags in a row. Kontaktuppgifter now ends with one
sentence naming the fields the register gave: "E-post, Telefon, adress
och Momsnr från SCB." Nothing else changes; the fill rule and the
equality test behind it are the same.

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

* fix(parties): the customer page's registry note tolerates the loading state

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

* fix(parties): the supplier page's registry note tolerates the loading state

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>
2026-09-05 14:53:13 +02:00

158 lines
7.2 KiB
TypeScript

/**
* 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)
}
/** "E-post, telefon och adress": a list the way Swedish (or English) joins it. */
export function listSv(items: string[], and: string): string {
if (items.length <= 1) return items.join('')
return `${items.slice(0, -1).join(', ')} ${and} ${items[items.length - 1]}`
}