Files
accounted/lib/parties/scb/client.ts
T
82859d01db feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones (#2265)
* feat(parties): name the company inside a voucher text, and stop asking SCB about foreign ones

The registry picker searched SCB on the whole display name, which for an
assistant-written voucher is a sentence, so "1511768101 · Visma Spcs AB,
faktura ..." never matched and foreign suppliers produced an empty list
with no explanation.

- lib/parties/name-extract.ts: name candidates read out of the text,
  anchored on legal-form words (AB, AB (publ), Inc., Ltd, B.V., GmbH, Oy,
  ...) and on country words, plus EU VAT numbers. Every candidate is a
  substring of the text; foreign forms and countries mark the candidate
  as one SCB cannot hold.
- Suggestions: the display name prefers the legal person named in the
  text ("TIC identity" becomes "The Intelligence Company AB (publ)"),
  the voucher texts are stored as a ledger fact for the picker, the
  country is stored when the text says, and a single foreign VAT number
  in the text becomes the party's VAT number.
- GET .../enrich/candidates plans the search: Swedish legal person first,
  cleaned head last, at most three queries, stopping at the first hit;
  no SCB call when the best reading is foreign, the response says which
  company it read and where.
- Picker: "X ser ut att vara ett utländskt bolag (Irland). SCB:s register
  täcker bara svenska företag." with a hint to save by name and VAT
  number; alternate readings offered as one-click searches when the
  first found nothing.
- nameQuery strips stacked legal-form suffixes ("AB (publ)").
- The queue builds itself whenever the books hold counterparts it has
  not seen, not only on a first visit; the toast only appears when
  something was created.

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

* fix(parties): take a text-derived VAT number only on the expense side

A customer's VAT number steers reverse charge on outgoing invoices, so it
must come from a document or a person, never from a text heuristic. A
supplier's is informational and may still be read from the voucher text.

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-04 13:43:41 +02:00

148 lines
6.2 KiB
TypeScript

import type { ScbConfig } from './config'
import { factsFromScbCompany, type ScbCompanyRow, type ScbFact } from './map'
import { isLegalPersonOrgNumber, toPeOrgNr } from './org-number'
import { scbJson } from './transport'
/**
* The wire format of the current SokPaVar API, checked against the live
* service on 2026-09-03 (scripts/scb/discover.ts, help page at
* <base>/help). A search is a list of variable filters; an identity lookup
* is one filter on "OrgNr (10 siffror)" with operator ArLikaMed, and
* without Företagsstatus/Registreringsstatus so a deregistered company is
* still returned (an empty string there is rejected with 400). The row
* comes back with every purchased column, codes and texts side by side.
*/
export const SCB_ORG_VARIABLE = 'OrgNr (10 siffror)'
export interface ScbLookupResult {
found: boolean
peOrgNr: string
row: ScbCompanyRow | null
facts: ScbFact[]
fetchedAt: string
}
export interface ScbCandidate {
orgNumber: string
name: string
city: string | null
industry: string | null
legalForm: string | null
/** SCB's own status text; active is Företagsstatus code 1. */
status: string | null
active: boolean
}
export interface ScbSearchResult {
query: string
/** How SCB was asked: a prefix match first, a contains match as fallback. */
mode: 'starts_with' | 'contains'
/** Rows SCB counted before the cap; above the cap the list is cut and the user should refine. */
total: number
truncated: boolean
candidates: ScbCandidate[]
}
export interface ScbClient {
variables(): Promise<unknown>
categories(): Promise<unknown>
lookupByOrgNumber(orgNumber: string): Promise<ScbLookupResult>
searchByName(query: string): Promise<ScbSearchResult>
}
/** Candidates shown per search; SCB can return thousands for a short word. */
export const SCB_SEARCH_CAP = 25
/** Legal forms never offered in the picker: natural persons and estates. */
const NON_COMPANY_LEGAL_FORMS = new Set(['10', '91'])
/**
* What we send SCB for a name: the AP prefix, supplier numbers and a
* trailing legal form are noise ("Levfakt Telia Sverige AB (17)" becomes
* "Telia Sverige"). SCB's name filter refuses an apostrophe.
*/
export function nameQuery(raw: string): string {
return raw
.replace(/^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura från\s*\d*|leverantörsfaktura|levbet\.?|kundbet\.?|kundfaktura|faktura från|faktura|kvitto|utgift|inköp)\s+/i, '')
.replace(/[(),]/g, ' ')
.replace(/\b\d{1,6}\b/g, ' ')
.replace(/(\s+(?:ab|aktiebolag|hb|kb|publ|\(publ\)))+\.?\s*$/i, '')
.replace(/'/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function nameSearchBody(query: string, mode: 'starts_with' | 'contains') {
return {
Variabler: [{ Variabel: 'Namn', Operator: mode === 'starts_with' ? 'BorjarPa' : 'Innehaller', Varde1: query, Varde2: '' }],
Kategorier: [],
}
}
function candidateFrom(row: ScbCompanyRow): ScbCandidate | null {
const org = String(row.OrgNr ?? '').replace(/[^0-9]/g, '')
const legalFormCode = String(row['Juridisk form, kod'] ?? '').trim()
if (org.length !== 10 || NON_COMPANY_LEGAL_FORMS.has(legalFormCode)) return null
const str = (k: string) => {
const v = row[k]
const t = v === null || v === undefined ? '' : String(v).trim()
return t === '' ? null : t
}
return {
orgNumber: org,
name: str('Företagsnamn') ?? org,
city: str('PostOrt'),
industry: str('Bransch_1'),
legalForm: str('Juridisk form'),
status: str('Företagsstatus'),
active: String(row['Företagsstatus, kod'] ?? '').trim() === '1',
}
}
export function identityLookupBody(orgNumber10: string) {
return {
Variabler: [{ Variabel: SCB_ORG_VARIABLE, Operator: 'ArLikaMed', Varde1: orgNumber10, Varde2: '' }],
Kategorier: [],
}
}
export function createScbClient(config: ScbConfig, deps: { json?: typeof scbJson } = {}): ScbClient {
const json = deps.json ?? scbJson
return {
variables: () => json(config, 'GET', '/api/Je/Variabler'),
categories: () => json(config, 'GET', '/api/Je/KategorierMedKodtabeller'),
async lookupByOrgNumber(orgNumber) {
if (!isLegalPersonOrgNumber(orgNumber)) {
throw new Error('SCB-uppslag görs bara på organisationsnummer för juridiska personer.')
}
const org10 = orgNumber.replace(/[^0-9]/g, '')
const peOrgNr = toPeOrgNr(org10)
const fetchedAt = new Date().toISOString()
const rows = await json<ScbCompanyRow[]>(config, 'POST', '/api/Je/HamtaForetag', identityLookupBody(org10))
const list = Array.isArray(rows) ? rows : []
const row = list.find((r) => String(r.OrgNr ?? r.PeOrgNr ?? '').replace(/[^0-9]/g, '').endsWith(org10)) ?? null
return { found: Boolean(row), peOrgNr, row, facts: row ? factsFromScbCompany(row) : [], fetchedAt }
},
async searchByName(raw) {
const query = nameQuery(raw)
if (query.length < 2) return { query, mode: 'starts_with', total: 0, truncated: false, candidates: [] }
// Count first: a short word can match thousands and we never pull those.
const run = async (mode: 'starts_with' | 'contains'): Promise<ScbSearchResult> => {
const body = nameSearchBody(query, mode)
const total = Number(await json<number | string>(config, 'POST', '/api/Je/RaknaForetag', body)) || 0
if (total === 0) return { query, mode, total, truncated: false, candidates: [] }
if (total > SCB_SEARCH_CAP * 4) return { query, mode, total, truncated: true, candidates: [] }
const rows = await json<ScbCompanyRow[]>(config, 'POST', '/api/Je/HamtaForetag', body)
const all = (Array.isArray(rows) ? rows : []).map(candidateFrom).filter((c): c is ScbCandidate => c !== null)
// Active companies first, then by name; the cap keeps the picker a picker.
all.sort((a, b) => Number(b.active) - Number(a.active) || a.name.localeCompare(b.name, 'sv'))
// total is what the picker can offer: SCB's count minus the natural
// persons and estates we never show ("Eismann" counted 1, offered 0).
return { query, mode, total: all.length, truncated: all.length > SCB_SEARCH_CAP, candidates: all.slice(0, SCB_SEARCH_CAP) }
}
const first = await run('starts_with')
if (first.total > 0 || first.truncated || query.length < 4) return first
return run('contains')
},
}
}