diff --git a/app/(dashboard)/parties/page.tsx b/app/(dashboard)/parties/page.tsx index 30beed2f..3e44ba57 100644 --- a/app/(dashboard)/parties/page.tsx +++ b/app/(dashboard)/parties/page.tsx @@ -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(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 = void refreshSuggestions() }}>{t('attn_observed', { count: counts.observed })} + if (counts && counts.suggested === 0 && counts.observed > 0 && canWrite && view === 'observed') { + attn = void refreshSuggestions() }}>{t('attn_observed', { count: counts.observed })} } 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) diff --git a/app/api/parties/[id]/enrich/route.ts b/app/api/parties/[id]/enrich/route.ts index 6d282d5c..8975b1c7 100644 --- a/app/api/parties/[id]/enrich/route.ts +++ b/app/api/parties/[id]/enrich/route.ts @@ -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> diff --git a/components/parties/SuggestionQueue.tsx b/components/parties/SuggestionQueue.tsx index 25854a38..2b023509 100644 --- a/components/parties/SuggestionQueue.tsx +++ b/components/parties/SuggestionQueue.tsx @@ -30,6 +30,7 @@ export function SuggestionQueue({ onConfirmSelected, onDismiss, onOpen, + onFind, }: { rows: RegisterRow[] selected: Set @@ -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({ onToggle(row.id)} aria-label={row.displayName} disabled={!canWrite} /> - + @@ -107,7 +111,22 @@ export function SuggestionQueue({ ) : null} - {reasonText(t, row.reason, row.stats?.rhythm ?? null, row.orgNumber)} + + {reasonText(t, row.reason, row.stats?.rhythm ?? null, row.orgNumber)} + {onFind && !row.orgNumber && row.kind !== 'person' ? ( + <> + {' · '} + + + ) : null} + diff --git a/lib/parties/__tests__/classify.test.ts b/lib/parties/__tests__/classify.test.ts index 83fc50b8..710b9d44 100644 --- a/lib/parties/__tests__/classify.test.ts +++ b/lib/parties/__tests__/classify.test.ts @@ -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') + }) +}) diff --git a/lib/parties/__tests__/ledger-key.test.ts b/lib/parties/__tests__/ledger-key.test.ts index 4804fce9..942deece 100644 --- a/lib/parties/__tests__/ledger-key.test.ts +++ b/lib/parties/__tests__/ledger-key.test.ts @@ -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') + }) +}) diff --git a/lib/parties/classify.ts b/lib/parties/classify.ts index 722b4951..3ddc8689 100644 --- a/lib/parties/classify.ts +++ b/lib/parties/classify.ts @@ -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 { diff --git a/lib/parties/ledger-key.ts b/lib/parties/ledger-key.ts index 803d5d7f..2a9715a6 100644 --- a/lib/parties/ledger-key.ts +++ b/lib/parties/ledger-key.ts @@ -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: " · ", 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() } diff --git a/messages/en.json b/messages/en.json index 79b96881..fcd31940 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." +} } diff --git a/messages/sv.json b/messages/sv.json index dbe39c1a..99c940c7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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." +} } diff --git a/scripts/checks/typecheck-baseline.json b/scripts/checks/typecheck-baseline.json index 3ebe87db..9d88104d 100644 --- a/scripts/checks/typecheck-baseline.json +++ b/scripts/checks/typecheck-baseline.json @@ -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, diff --git a/supabase/migrations/20260904002000_ledger_key_v2_and_vat_attach.sql b/supabase/migrations/20260904002000_ledger_key_v2_and_vat_attach.sql new file mode 100644 index 00000000..fa900fc3 --- /dev/null +++ b/supabase/migrations/20260904002000_ledger_key_v2_and_vat_attach.sql @@ -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 +-- " · " 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'; diff --git a/tests/pg/party-suggestions.pg.test.ts b/tests/pg/party-suggestions.pg.test.ts index 308412ed..6138f771 100644 --- a/tests/pg/party-suggestions.pg.test.ts +++ b/tests/pg/party-suggestions.pg.test.ts @@ -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 }])