fix(parties): readable suggestions from assistant vouchers, auto-build queue, SCB fetch after promotion (#2259)

* fix(parties): readable suggestions from assistant-written vouchers, auto-build queue, SCB fetch after promotion

Live feedback on a real company (2026-09-03): the queue showed 35 one-off
suggestions with sentence-long names, wide empty rows, a "Hämta förslag"
step nobody could predict, no SCB fetch after promotion, and an empty
supplier created from a Finansinspektionen fee line.

- ledger_key v2 (migration 20260904002000): keep the counterpart head of
  "<counterpart> · <note>" descriptions, drop bank method tokens and long
  references before normalising; JS mirror in lib/parties/ledger-key.ts
  with shared LEDGER_KEY_CASES. Suggested parties nobody has touched are
  rebuilt under the new keys (repair in the same migration).
- apply_party_suggestions attaches by VAT number too, so ledger keys with
  a VAT number but no org number reach existing roles.
- Queue: fixed name/reason column widths, inline "Hitta i
  företagsregistret" for rows without an org number.
- Page: builds the queue automatically on first visit when nothing has
  been suggested yet; after promotion, fetches SCB facts for every
  promoted legal person (spaced under the 10 calls/10 s limit) and fills
  the role's VAT number; confirm dialog says how many rows lack an org
  number.
- Classifier: more authorities (Finansinspektionen, Arbetsförmedlingen,
  Pensionsmyndigheten, ...) and fee words (registreringsavgift,
  tillsynsavgift, ...) so fee lines stop becoming suppliers.

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

* fix(parties): scope the suggestion repair to keys the new ledger_key no longer produces

Superagent flagged the repair DELETE as global. It now only removes
untouched pipeline suggestions that no posted voucher of the company maps
to under the new function; suggestions whose key is unchanged stay.

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-03 21:19:12 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent d670fe6663
commit 91e2c66afc
12 changed files with 418 additions and 28 deletions
+56 -5
View File
@@ -23,6 +23,7 @@ import { ScbPickerDialog } from '@/components/parties/ScbPickerDialog'
import type { ScbCandidate } from '@/lib/parties/scb/client'
import { SuggestionQueue } from '@/components/parties/SuggestionQueue'
import { hasHardKey } from '@/components/parties/format'
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { PartyRole, Register, RegisterPeriod, RegisterRow, RegisterView } from '@/lib/parties/register'
@@ -82,6 +83,7 @@ function SuggestionsPage() {
const [dossierReload, setDossierReload] = useState(0)
const [merge, setMerge] = useState<{ subject: MergeCandidate; suggested: MergeCandidate[] } | null>(null)
const preselected = useRef<string | null>(null)
const autoRan = useRef(false)
useEffect(() => {
const id = setTimeout(() => setDebounced(query.trim()), 250)
@@ -122,6 +124,18 @@ function SuggestionsPage() {
setDossierReload((k) => k + 1)
}, [])
// First visit for a company whose books name counterparts nobody has
// registered: build the queue right away instead of asking for a click
// whose effect nobody could guess. Suggestions only, reversible.
useEffect(() => {
if (!register || autoRan.current || !canWrite || debounced) return
if (register.counts.suggested === 0 && register.counts.observed > 0) {
autoRan.current = true
void refreshSuggestions(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs at most once per mount, guarded by autoRan
}, [register, canWrite, debounced])
const counts = register?.counts
const scbEnabled = Boolean(register?.scbConfigured)
const viewOptions = useMemo(
@@ -144,12 +158,13 @@ function SuggestionsPage() {
const fail = useCallback(() => toast({ title: t('action_failed'), variant: 'destructive' }), [toast, t])
const rolesFor = useCallback((row: RegisterRow): PartyRole[] => roleOverrides[row.id] ?? row.defaultRoles, [roleOverrides])
async function refreshSuggestions() {
async function refreshSuggestions(auto = false) {
if (refreshing) return
setRefreshing(true)
try {
const summary = await post<{ created: number; attached: number }>('/api/parties/suggest')
toast({ title: t('refreshed_title'), description: t('refreshed_description', { created: summary.created, attached: summary.attached }) })
if (auto) toast({ title: t('auto_created_title', { count: summary.created }), description: t('auto_created_description') })
else toast({ title: t('refreshed_title'), description: t('refreshed_description', { created: summary.created, attached: summary.attached }) })
reload()
} catch {
fail()
@@ -179,6 +194,35 @@ function SuggestionsPage() {
})
}
/**
* After Lägg upp: fetch the registry facts for every new supplier or
* customer that carries a legal person's org number, one call at a time
* (SCB allows ten per ten seconds). Best effort: a failed fetch leaves the
* row as it was and the dossier still offers Hämta uppgifter.
*/
async function enrichAfterPromotion(partyIds: string[]) {
const targets = rows.filter((r) => partyIds.includes(r.id) && isLegalPersonOrgNumber(r.orgNumber)).map((r) => r.id)
if (targets.length === 0) return
setFetchingRegistry(true)
toast({ title: t('promoted_enriching', { count: targets.length }) })
let done = 0
try {
for (const [i, id] of targets.entries()) {
try {
const res = await fetch(`/api/parties/${id}/enrich`, { method: 'POST' })
if (res.ok) done += 1
} catch {
// counted as not fetched
}
if (i < targets.length - 1) await new Promise((r) => setTimeout(r, 1100))
}
} finally {
setFetchingRegistry(false)
toast({ title: t('promoted_enriched_title', { done, total: targets.length }) })
reload()
}
}
async function promote(items: Array<{ partyId: string; roles: PartyRole[] }>) {
if (items.length === 0) return
setBusy(true)
@@ -191,6 +235,7 @@ function SuggestionsPage() {
return next
})
reload()
if (scbEnabled) void enrichAfterPromotion(items.map((i) => i.partyId))
} catch {
fail()
} finally {
@@ -284,10 +329,11 @@ function SuggestionsPage() {
const rows = register?.rows ?? []
const searching = debounced.length > 0
const selectedItems = rows.filter((r) => selected.has(r.id)).map((r) => ({ partyId: r.id, roles: rolesFor(r) }))
const missingOrg = rows.filter((r) => selected.has(r.id) && !r.orgNumber && r.kind !== 'person').length
let attn: React.ReactNode = null
if (counts && counts.suggested === 0 && counts.observed > 0 && canWrite && view === 'suggested') {
attn = <AttnLine action={{ label: t('refresh'), onClick: () => void refreshSuggestions() }}>{t('attn_observed', { count: counts.observed })}</AttnLine>
if (counts && counts.suggested === 0 && counts.observed > 0 && canWrite && view === 'observed') {
attn = <AttnLine action={{ label: t('attn_create'), onClick: () => void refreshSuggestions() }}>{t('attn_observed', { count: counts.observed })}</AttnLine>
}
function empty() {
@@ -342,6 +388,7 @@ function SuggestionsPage() {
onConfirmSelected={() => setConfirmOpen(true)}
onDismiss={(row) => void dismiss([row.id])}
onOpen={setDossierId}
onFind={scbEnabled ? (row) => setPicker({ partyId: row.id, name: row.displayName }) : undefined}
/>
)
}
@@ -403,7 +450,11 @@ function SuggestionsPage() {
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('promote_dialog_title', { count: selectedItems.length })}
description={t('promote_dialog_body', { detail: roleSummary(t, selectedItems) })}
description={
missingOrg > 0
? `${t('promote_dialog_body', { detail: roleSummary(t, selectedItems) })} ${t('promote_dialog_missing_org', { missing: missingOrg, count: selectedItems.length })}`
: t('promote_dialog_body', { detail: roleSummary(t, selectedItems) })
}
confirmLabel={t('promote_n', { count: selectedItems.length })}
onConfirm={async () => {
setConfirmOpen(false)
+6 -3
View File
@@ -121,10 +121,13 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
// The VAT number has one valid form, so it fills an empty field outright.
// The VAT number has one valid form, so it fills an empty field outright,
// on the party and on the supplier and customer rows that point at it.
const vat = lookup.facts.find((f) => f.field === 'vat_number')?.value
if (!p.vat_number && typeof vat === 'string' && vat) {
await supabase.from('parties').update({ vat_number: vat }).eq('company_id', companyId).eq('id', id)
if (typeof vat === 'string' && vat) {
if (!p.vat_number) await supabase.from('parties').update({ vat_number: vat }).eq('company_id', companyId).eq('id', id)
await supabase.from('suppliers').update({ vat_number: vat }).eq('company_id', companyId).eq('party_id', id).is('vat_number', null)
await supabase.from('customers').update({ vat_number: vat }).eq('company_id', companyId).eq('party_id', id).is('vat_number', null)
}
const r = (summary ?? {}) as Partial<Record<'inserted' | 'superseded' | 'refreshed', number>>
+22 -3
View File
@@ -30,6 +30,7 @@ export function SuggestionQueue({
onConfirmSelected,
onDismiss,
onOpen,
onFind,
}: {
rows: RegisterRow[]
selected: Set<string>
@@ -43,6 +44,8 @@ export function SuggestionQueue({
onConfirmSelected: () => void
onDismiss: (row: RegisterRow) => void
onOpen: (id: string) => void
/** Open the SCB picker for a row without an org number; undefined hides the link. */
onFind?: (row: RegisterRow) => void
}) {
const t = useTranslations('parties')
const count = selected.size
@@ -92,12 +95,13 @@ export function SuggestionQueue({
<td className={`${TD_CLASS} w-8`}>
<Checkbox checked={checked} onCheckedChange={() => onToggle(row.id)} aria-label={row.displayName} disabled={!canWrite} />
</td>
<td className={`${TD_CLASS} whitespace-nowrap`}>
<td className={`${TD_CLASS} max-w-[22rem]`}>
<button
type="button"
className="text-left font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="block max-w-full truncate text-left font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onOpen(row.id)}
aria-label={t('open_dossier', { name: row.displayName })}
title={row.displayName}
>
{row.displayName}
</button>
@@ -107,7 +111,22 @@ export function SuggestionQueue({
</Badge>
) : null}
</td>
<td className={`${TD_CLASS} text-muted-foreground`}>{reasonText(t, row.reason, row.stats?.rhythm ?? null, row.orgNumber)}</td>
<td className={`${TD_CLASS} min-w-[16rem] max-w-[28rem] text-muted-foreground`}>
{reasonText(t, row.reason, row.stats?.rhythm ?? null, row.orgNumber)}
{onFind && !row.orgNumber && row.kind !== 'person' ? (
<>
{' · '}
<button
type="button"
className="text-foreground underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onFind(row)}
disabled={!canWrite}
>
{t('pick_registry')}
</button>
</>
) : null}
</td>
<td className={`${TD_CLASS} whitespace-nowrap`}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
+10
View File
@@ -38,3 +38,13 @@ describe('classifyKey', () => {
expect(classifyKey({ key: '12 34' })).toBe('unsure')
})
})
describe('classifyKey on fee descriptions and authorities (prod 2026-09-03)', () => {
it('keeps state fees and authorities out of the party queue', async () => {
const { classifyKey } = await import('../classify')
expect(classifyKey({ key: 'registeringsavgift finansinspektionen', acct: '6991' })).toBe('authority')
expect(classifyKey({ key: 'finansinspektionen', acct: '6991' })).toBe('authority')
expect(classifyKey({ key: 'utlägg', acct: '5420' })).toBe('category')
expect(classifyKey({ key: 'utlägg anthropic', acct: '5420' })).toBe('party')
})
})
+20
View File
@@ -4,6 +4,14 @@ import { ledgerKey } from '../ledger-key'
// Fixture pairs are shared with tests/pg/observed-parties-rpc.pg.test.ts,
// which runs the same inputs through public.ledger_key() and asserts parity.
export const LEDGER_KEY_CASES: [string, string][] = [
// Descriptions our own booking flows write (seen on prod 2026-09-03).
['Utlägg Anthropic · Anthropic PBC, 206,12 EUR inkl. 41,22 EUR VAT-Sweden 25% via OSS.', 'utlägg anthropic'],
['1511768101 · Visma Spcs AB, faktura 2025-10-02, programvarulicens/abonnemang', 'visma spcs'],
['TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ).', 'tic identity'],
['Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms', 'hotel at bookingcom'],
['Utlägg · Utlägg, mjukvara, leverantör ej angiven', 'utlägg'],
['1260424603197 Pris betalning', 'pris betalning'],
['leverantörsfaktura 20250928, The Intelligence Company AB (publ)', 'the intelligence company publ'],
['Levfakt BEIJER BYGGMATERIAL AB (2089)', 'beijer byggmaterial'],
['Levfakt Beijer Byggmaterial AB, 097 (1001)', 'beijer byggmaterial'],
['Leverantörsfaktura från 18 Loopia, 1009146000', 'loopia'],
@@ -35,3 +43,15 @@ describe('ledgerKey', () => {
expect(ledgerKey('Levfakt BEIJER BYGGMATERIAL AB (2089)')).toBe(ledgerKey('Levfakt Beijer Byggmaterial AB, 097 (1001)'))
})
})
describe('displayNameFromVoucherText on assistant-written descriptions', () => {
it('keeps the counterpart, drops the note, method tokens and references', async () => {
const { displayNameFromVoucherText } = await import('../ledger-key')
expect(displayNameFromVoucherText('Utlägg Anthropic · Anthropic PBC, 206,12 EUR inkl. VAT')).toBe('Utlägg Anthropic')
expect(displayNameFromVoucherText('1511768101 · Visma Spcs AB, faktura 2025-10-02')).toBe('Visma Spcs AB')
expect(displayNameFromVoucherText('TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746')).toBe('TIC identity')
expect(displayNameFromVoucherText('Hotel at Booking.com K3667 Kortköp/uttag · Hotell')).toBe('Hotel at Booking.com')
expect(displayNameFromVoucherText('leverantörsfaktura 20250928, The Intelligence Company AB (publ)')).toBe('The Intelligence Company AB (publ)')
expect(displayNameFromVoucherText('1260424603197 Pris betalning')).toBe('Pris betalning')
})
})
+2 -1
View File
@@ -32,6 +32,7 @@ const STOP = new Set([
const GENERIC = [
'inköp', 'inkp', 'kvitto', 'kvitton', 'fika', 'diesel', 'bensin', 'bränsle', 'försäkring', 'telefon', 'mobil', 'hyra',
'lokalhyra', 'frakt', 'hosting', 'julklapp', 'frimärken', 'utlägg', 'hotell', 'resa', 'resor', 'resekostnader',
'registreringsavgift', 'registeringsavgift', 'tillsynsavgift', 'årsavgift', 'medlemsavgift', 'serviceavgift', 'anmälningsavgift', 'expeditionsavgift',
'biljett', 'biljetter', 'biljettkostnad', 'taxi', 'taxiresor', 'parkering', 'parkeringsavgifter', 'representation',
'måltidsrepresentation', 'kollektivtrafik', 'kollektivtra', 'kontorsmaterial', 'förbrukning', 'förbrukningsmateriel',
'frbrukningsmateriel', 'programvara', 'mjukvara', 'licens', 'avgift', 'avgifter', 'traktamente', 'traktamenten',
@@ -66,7 +67,7 @@ const PAYROLL = /\b(lön|löner|löne\w*|lneutbetalning|lönebesked|salary|semes
const ADJUSTMENT =
/(periodisering|omföring|omforing|lagerförändring|lagerforandring|nedskrivning|rättelse|rattelse|kostnadsföring|avskrivning|bokslut|kursdiff|valutakurs|eur till sek|omvänd betalningsskyldighet)/
const BANK = /(bankkostnad|bankavgift|banktjänst|baspaket bank|bank årsavg|årsavg|avi överdrag|företagspaket)/
const AUTHORITY = /\b(skatteverket|bolagsverket|transportstyrelsen|försäkringskassan|kronofogden|tullverket|skattekonto)\b/
const AUTHORITY = /\b(skatteverket|bolagsverket|transportstyrelsen|försäkringskassan|kronofogden|tullverket|skattekonto|finansinspektionen|arbetsförmedlingen|pensionsmyndigheten|migrationsverket|lantmäteriet|csn|polisen|domstol|tingsrätt|förvaltningsrätt)\b/
const INTERMEDIARY = /\b(klarna|paypal|zettle|izettle|swish|payex|bankgirot|adyen|nets)\b/
function acctNum(a: string | null | undefined): number {
+30 -2
View File
@@ -19,9 +19,30 @@ import { normalizeCounterpartyName } from '@/lib/bookkeeping/counterparty-templa
const AP_PREFIX = /^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+/
const LEADING_SUPPLIER_NUMBER = /^\d{1,5}\s+/
const TRAILING_SHORT_DIGITS = /(\s+\d{1,3})+$/
const BANK_METHOD = /(kortköp\/uttag|kortkp\/uttag|överföring via internet|bg-bet\.? via internet|pg-bet\.? via internet|bg-bet\.?|autogiro)/gi
const GIRO_REFERENCE = /\b(bg|pg)\s*\d{5,}\b/gi
const CARD_REFERENCE = /\bk\d{3,6}\b/gi
const LONG_DIGITS = /\b\d{6,}\b/g
/**
* What our own booking flows write: "<counterpart> · <note>", bank method
* tokens and long references. Keep the head before " · " (or, when the head
* has no letters, the text after it up to the first comma), drop the tokens.
* Mirrors the pre-clean in the SQL ledger_key (20260904002000).
*/
export function preClean(raw: string): string {
let pre = raw
const sep = pre.indexOf(' · ')
if (sep >= 0) {
let head = pre.slice(0, sep)
if (!/\p{L}/u.test(head)) head = pre.slice(sep + 3).split(',')[0] ?? ''
pre = head
}
return pre.replace(BANK_METHOD, ' ').replace(GIRO_REFERENCE, ' ').replace(CARD_REFERENCE, ' ').replace(LONG_DIGITS, ' ')
}
export function ledgerKey(raw: string | null | undefined): string {
const k = normalizeCounterpartyName(raw ?? '')
const k = normalizeCounterpartyName(preClean(raw ?? ''))
if (!k) return ''
const stripped = k
.replace(AP_PREFIX, '')
@@ -67,6 +88,13 @@ const DISPLAY_SUFFIX = /(\s*[,(]\s*\d{1,6}\s*\)?|\s+\d{1,4})+$/
* a printed name; nothing here is generated, only removed.
*/
export function displayNameFromVoucherText(raw: string): string {
const cleaned = raw.trim().replace(DISPLAY_PREFIX, '').replace(DISPLAY_SUFFIX, '').trim()
const cleaned = preClean(raw)
.trim()
.replace(DISPLAY_PREFIX, '')
.replace(/^\d{6,10}[,\s]+/, '')
.replace(DISPLAY_SUFFIX, '')
.replace(/\s+/g, ' ')
.replace(/^[,\s]+|[,\s]+$/g, '')
.trim()
return cleaned.length >= 2 ? cleaned : raw.trim()
}
+12 -6
View File
@@ -8264,17 +8264,17 @@
},
"parties": {
"title": "Suggestions from the books",
"help": "Counterparts your vouchers and documents point to that are not among your suppliers and customers yet. Confirm and they are added, with the details from the documents. Nothing is posted, and it can be undone for 30 days.",
"help": "Counterparts your vouchers and documents point to that are not among your suppliers and customers yet. Suggestions are built from the books; Update suggestions picks up what arrived since last time. Add and they are created with the details from the documents. Nothing is posted, and it can be undone for 30 days.",
"summary": "{suggested} suggestions · {observed} only in the books",
"refresh": "Fetch suggestions",
"refreshing": "Fetching…",
"refresh": "Update suggestions",
"refreshing": "Updating…",
"refreshed_title": "Suggestions updated",
"refreshed_description": "{created} new suggestions, {attached} attached to existing ones.",
"attn_suppliers": "{count} supplier suggestions are waiting from the books.",
"attn_customers": "{count} customer suggestions are waiting from the books.",
"attn_review": "Review",
"attn_fetch": "Show",
"attn_observed": "The books know {count} counterparts that are not in the register.",
"attn_observed": "The books know {count} counterparts that are not among your suppliers and customers.",
"view_suggested": "Suggestions",
"view_observed": "Only in the books",
"search_placeholder": "Search name or org number",
@@ -8430,7 +8430,13 @@
"picker_taken_title": "Org number already on {name}",
"picker_taken_description": "Merge the two instead of choosing the same company twice.",
"reason_org_picked": "Org number {org} chosen in the business register",
"promoted_enriching": "Fetching details from SCB for {count} contacts…",
"promoted_enriched_title": "Details fetched from SCB for {done} of {total}",
"promote_dialog_missing_org": "{missing} of {count} have no org number and get nothing from SCB; find them in the business register first if you want them complete.",
"fact_trade_name": "Trade name",
"open_dossier": "Open {name}"
}
"open_dossier": "Open {name}",
"attn_create": "Create suggestions",
"auto_created_title": "{count} suggestions created from the books",
"auto_created_description": "Counterparts your vouchers name that are not in the register. Add them, or hide the ones that do not belong here."
}
}
+12 -6
View File
@@ -8264,17 +8264,17 @@
},
"parties": {
"title": "Förslag från bokföringen",
"help": "Motparter som dina verifikat och underlag pekar ut men som inte finns bland dina leverantörer och kunder. Bekräfta så läggs de upp, med uppgifterna från underlagen. Ingenting bokförs, och det går att ångra i 30 dagar.",
"help": "Motparter som dina verifikat och underlag pekar ut men som inte finns bland dina leverantörer och kunder. Förslagen skapas från bokföringen; Uppdatera förslag hämtar det som tillkommit sedan sist. Lägg upp så läggs de upp med uppgifterna från underlagen. Ingenting bokförs, och det går att ångra i 30 dagar.",
"summary": "{suggested} förslag · {observed} bara i bokföringen",
"refresh": "Hämta förslag",
"refreshing": "Hämtar…",
"refresh": "Uppdatera förslag",
"refreshing": "Uppdaterar…",
"refreshed_title": "Förslag uppdaterade",
"refreshed_description": "{created} nya förslag, {attached} kopplade till befintliga.",
"attn_suppliers": "{count} förslag på leverantörer väntar från bokföringen.",
"attn_customers": "{count} förslag på kunder väntar från bokföringen.",
"attn_review": "Granska",
"attn_fetch": "Visa",
"attn_observed": "Bokföringen känner {count} motparter som inte finns i registret.",
"attn_observed": "Bokföringen känner {count} motparter som inte finns bland dina leverantörer och kunder.",
"view_suggested": "Förslag",
"view_observed": "Bara i bokföringen",
"search_placeholder": "Sök namn eller org.nr",
@@ -8430,7 +8430,13 @@
"picker_taken_title": "Org.nr finns redan på {name}",
"picker_taken_description": "Slå ihop de två i stället för att välja samma företag två gånger.",
"reason_org_picked": "Org.nr {org} valt i företagsregistret",
"promoted_enriching": "Hämtar uppgifter från SCB för {count} kontakter…",
"promoted_enriched_title": "Uppgifter hämtade från SCB för {done} av {total}",
"promote_dialog_missing_org": "{missing} av {count} saknar org.nr och får inga uppgifter från SCB; leta upp dem i företagsregistret först om du vill ha dem kompletta.",
"fact_trade_name": "Firma",
"open_dossier": "Öppna {name}"
}
"open_dossier": "Öppna {name}",
"attn_create": "Skapa förslag",
"auto_created_title": "{count} förslag skapade från bokföringen",
"auto_created_description": "Motparter som dina verifikat namnger men som inte finns i registret. Lägg upp dem, eller dölj de som inte hör hemma här."
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"totalErrors": 537,
"totalErrors": 535,
"perFile": {
"app/api/assets/__tests__/id.test.ts": 9,
"app/api/auth/email-hook/__tests__/route.test.ts": 1,
@@ -16,7 +16,7 @@
"app/api/export/articles/__tests__/route.test.ts": 3,
"app/api/export/suppliers/__tests__/route.test.ts": 2,
"app/api/extensions/shopify/orders/cron/__tests__/route.test.ts": 2,
"app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts": 6,
"app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts": 4,
"app/api/import/articles/__tests__/execute.test.ts": 23,
"app/api/import/bank-file/__tests__/route.test.ts": 1,
"app/api/import/bank-file/check-duplicates/__tests__/route.test.ts": 8,
@@ -0,0 +1,233 @@
-- Parties: ledger keys for the descriptions our own booking flows write.
--
-- Vouchers created through the assistant and the transaction inbox carry
-- "<counterpart> · <note>" descriptions with a long note, bank method
-- tokens ("K3667 Kortköp/uttag", "Överföring via internet", "Bg-bet. via
-- internet", "BG 0000005786439") and long references. ledger_key grouped by
-- the whole text, so every voucher of a real company became its own
-- suggestion with a sentence for a name (35 for one company on 2026-09-03).
--
-- ledger_key now (1) keeps only the part before " · ", falling back to the
-- text after it up to the first comma when the head has no letters
-- ("1511768101 · Visma Spcs AB, faktura ..."), (2) drops bank method tokens,
-- card references and digit runs of six or more, then (3) applies the
-- existing normalisation. Mirrored in lib/parties/ledger-key.ts; the shared
-- cases live in lib/parties/__tests__/ledger-key.test.ts.
--
-- apply_party_suggestions gains the VAT number as a hard key beside the org
-- number, for foreign suppliers.
--
-- Repair: suggested parties the pipeline made under the old keys, that
-- nobody touched (no decisions, no role links, no user or registry facts)
-- and that no posted voucher maps to any more under the new function are
-- removed; the queue recreates their counterparts under the new keys on
-- its next visit. Suggestions whose key is unchanged stay. On prod that
-- is at most 27 rows in one company, all from 2026-09-03.
CREATE OR REPLACE FUNCTION public.ledger_key(raw text)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
PARALLEL SAFE
AS $$
DECLARE
pre text;
head text;
k text;
stripped text;
BEGIN
pre := coalesce(raw, '');
IF position(' · ' in pre) > 0 THEN
head := split_part(pre, ' · ', 1);
IF head !~ '[[:alpha:]]' THEN
head := split_part(split_part(pre, ' · ', 2), ',', 1);
END IF;
pre := head;
END IF;
pre := regexp_replace(pre, '(kortköp/uttag|kortkp/uttag|överföring via internet|bg-bet\.? via internet|pg-bet\.? via internet|bg-bet\.?|autogiro)', ' ', 'gi');
pre := regexp_replace(pre, '\m(bg|pg)\s*\d{5,}\M', ' ', 'gi');
pre := regexp_replace(pre, '\mk\d{3,6}\M', ' ', 'gi');
pre := regexp_replace(pre, '\m\d{6,}\M', ' ', 'g');
k := public.normalize_counterparty_key(pre);
IF k IS NULL OR k = '' THEN RETURN coalesce(k, ''); END IF;
stripped := k;
stripped := regexp_replace(stripped, '^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+', '', '');
stripped := regexp_replace(stripped, '^\d{1,5}\s+', '', '');
stripped := regexp_replace(stripped, '(\s+\d{1,3})+$', '', '');
stripped := btrim(regexp_replace(stripped, '\s+', ' ', 'g'));
IF stripped = '' THEN RETURN k; END IF;
RETURN stripped;
END;
$$;
CREATE OR REPLACE FUNCTION public.apply_party_suggestions(
p_company_id uuid,
p_user_id uuid,
p_items jsonb
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_item jsonb;
v_fact jsonb;
v_ident jsonb;
v_party_id uuid;
v_key text;
v_org text;
v_vat text;
v_aliases text[];
v_created integer := 0;
v_attached integer := 0;
v_identities integer := 0;
v_facts integer := 0;
v_seen integer;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'apply_party_suggestions: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
IF p_items IS NULL OR jsonb_typeof(p_items) <> 'array' THEN
RAISE EXCEPTION 'apply_party_suggestions: p_items must be a JSON array' USING ERRCODE = '22023';
END IF;
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) LOOP
v_key := nullif(btrim(coalesce(v_item->>'key', '')), '');
IF v_key IS NULL THEN
RAISE EXCEPTION 'apply_party_suggestions: every item needs a key' USING ERRCODE = '22023';
END IF;
v_org := public.normalize_org_number(v_item->>'org_number');
v_vat := nullif(upper(regexp_replace(coalesce(v_item->>'vat_number', ''), '[^0-9A-Za-z]', '', 'g')), '');
v_aliases := ARRAY(SELECT DISTINCT x FROM (
SELECT v_key AS x UNION ALL SELECT jsonb_array_elements_text(coalesce(v_item->'alias_keys', '[]'::jsonb))
) a WHERE x IS NOT NULL AND btrim(x) <> '');
v_party_id := NULL;
IF v_item->>'party_id' IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE id = (v_item->>'party_id')::uuid AND company_id = p_company_id AND merged_into IS NULL;
IF v_party_id IS NULL THEN
RAISE EXCEPTION 'apply_party_suggestions: party % is not a live party of this company', v_item->>'party_id'
USING ERRCODE = '23503';
END IF;
END IF;
IF v_party_id IS NULL AND v_org IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
END IF;
-- A VAT number is a hard key too: foreign suppliers (Framer B.V.,
-- Anthropic Ireland) never carry a Swedish org number, and three keys for
-- one Dutch company made three suggestions on 2026-09-03.
IF v_party_id IS NULL AND v_vat IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND merged_into IS NULL
AND upper(regexp_replace(coalesce(vat_number, ''), '[^0-9A-Za-z]', '', 'g')) = v_vat
ORDER BY (status = 'confirmed') DESC, created_at
LIMIT 1;
END IF;
IF v_party_id IS NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND merged_into IS NULL AND alias_keys @> ARRAY[v_key]
ORDER BY (status = 'confirmed') DESC, created_at
LIMIT 1;
END IF;
IF v_party_id IS NULL THEN
INSERT INTO public.parties (company_id, user_id, display_name, legal_name, kind, status, org_number, vat_number, alias_keys, origin, suggested_reason)
VALUES (
p_company_id, p_user_id,
coalesce(nullif(btrim(v_item->>'display_name'), ''), v_key),
nullif(btrim(v_item->>'legal_name'), ''),
coalesce(v_item->>'kind', 'company'),
'suggested',
v_org,
nullif(btrim(v_item->>'vat_number'), ''),
v_aliases,
coalesce(v_item->>'origin', 'ledger'),
v_item->'reason'
)
ON CONFLICT DO NOTHING
RETURNING id INTO v_party_id;
IF v_party_id IS NULL THEN
-- Lost a race on (company_id, org_number): attach to the winner.
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
v_attached := v_attached + 1;
ELSE
v_created := v_created + 1;
END IF;
ELSE
v_attached := v_attached + 1;
UPDATE public.parties
SET alias_keys = ARRAY(SELECT DISTINCT x FROM unnest(alias_keys || v_aliases) AS x),
vat_number = coalesce(vat_number, nullif(btrim(v_item->>'vat_number'), '')),
legal_name = coalesce(legal_name, nullif(btrim(v_item->>'legal_name'), '')),
org_number = coalesce(org_number, v_org)
WHERE id = v_party_id
AND (NOT (alias_keys @> v_aliases)
OR (vat_number IS NULL AND nullif(btrim(v_item->>'vat_number'), '') IS NOT NULL)
OR (legal_name IS NULL AND nullif(btrim(v_item->>'legal_name'), '') IS NOT NULL)
OR (org_number IS NULL AND v_org IS NOT NULL));
END IF;
FOR v_ident IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'identities', '[]'::jsonb)) LOOP
v_seen := greatest(coalesce((v_ident->>'seen_count')::integer, 1), 1);
INSERT INTO public.party_identities (party_id, company_id, user_id, scheme, value, status, source, first_seen, last_seen, seen_count)
VALUES (
v_party_id, p_company_id, p_user_id,
v_ident->>'scheme', v_ident->>'value',
CASE WHEN v_seen >= 2 THEN 'known' ELSE 'unverified' END,
coalesce(v_ident->>'source', 'document'),
(v_ident->>'first_seen')::date, (v_ident->>'last_seen')::date, v_seen
)
ON CONFLICT (party_id, scheme, value) DO UPDATE
SET seen_count = greatest(party_identities.seen_count, EXCLUDED.seen_count),
first_seen = least(party_identities.first_seen, EXCLUDED.first_seen),
last_seen = greatest(party_identities.last_seen, EXCLUDED.last_seen),
status = CASE WHEN greatest(party_identities.seen_count, EXCLUDED.seen_count) >= 2 THEN 'known' ELSE party_identities.status END
WHERE party_identities.seen_count < EXCLUDED.seen_count
OR party_identities.last_seen IS DISTINCT FROM greatest(party_identities.last_seen, EXCLUDED.last_seen)
OR party_identities.first_seen IS DISTINCT FROM least(party_identities.first_seen, EXCLUDED.first_seen);
v_identities := v_identities + 1;
END LOOP;
FOR v_fact IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'facts', '[]'::jsonb)) LOOP
INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source, reference)
SELECT v_party_id, p_company_id, p_user_id, v_fact->>'field', v_fact->'value', v_fact->>'source', v_fact->'reference'
WHERE NOT EXISTS (
SELECT 1 FROM public.party_facts f
WHERE f.party_id = v_party_id AND f.field = v_fact->>'field' AND f.source = v_fact->>'source'
AND f.value = v_fact->'value' AND f.superseded_at IS NULL
);
IF FOUND THEN v_facts := v_facts + 1; END IF;
END LOOP;
END LOOP;
RETURN jsonb_build_object('created', v_created, 'attached', v_attached, 'identities', v_identities, 'facts', v_facts);
END;
$$;
-- Repair: untouched pipeline suggestions made under the old keys that the
-- new ledger_key no longer produces from any posted voucher of the company
-- (same evidence filter as get_ledger_key_evidence). Rows still backed by
-- a voucher under the new keys are left alone.
DELETE FROM public.parties p
WHERE p.status = 'suggested'
AND p.merged_into IS NULL
AND p.origin IN ('ledger', 'document')
AND NOT EXISTS (SELECT 1 FROM public.party_decisions d WHERE d.party_id = p.id)
AND NOT EXISTS (SELECT 1 FROM public.suppliers s WHERE s.party_id = p.id)
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.party_id = p.id)
AND NOT EXISTS (SELECT 1 FROM public.party_facts f WHERE f.party_id = p.id AND f.source IN ('user', 'registry_scb', 'registry_tic', 'vies', 'peppol'))
AND NOT EXISTS (
SELECT 1 FROM public.journal_entries je
WHERE je.company_id = p.company_id
AND je.status = 'posted'
AND je.source_type NOT IN ('storno', 'opening_balance', 'year_end', 'vat_settlement')
AND je.description IS NOT NULL
AND btrim(je.description) <> ''
AND public.ledger_key(je.description) = ANY (p.alias_keys)
);
NOTIFY pgrst, 'reload schema';
+13
View File
@@ -161,6 +161,19 @@ describe('apply_party_suggestions (pg)', () => {
expect(facts.rowCount).toBe(1)
})
it('attaches by VAT number when there is no org number (foreign suppliers)', async () => {
const c = await seedCompany()
await apply(c.companyId, c.userId, [{ key: 'utlägg framer', display_name: 'Framer B.V.', vat_number: 'NL853695386B01', origin: 'document' }])
const second = await apply(c.companyId, c.userId, [{ key: 'framer utlägg', display_name: 'Framer B.V.', vat_number: 'nl 853695386 b01', origin: 'document' }])
expect(second).toMatchObject({ created: 0, attached: 1 })
const rows = await getPool().query<{ n: string; alias_keys: string[] }>(
`SELECT (SELECT count(*) FROM public.parties WHERE company_id = $1)::text AS n, alias_keys FROM public.parties WHERE company_id = $1`,
[c.companyId],
)
expect(rows.rows[0]!.n).toBe('1')
expect([...rows.rows[0]!.alias_keys].sort()).toEqual(['framer utlägg', 'utlägg framer'])
})
it('never merges on name: same core text becomes a second suggested party', async () => {
const c = await seedCompany()
await apply(c.companyId, c.userId, [{ key: 'fortnox', display_name: 'Fortnox AB', org_number: ORG }])