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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
f33628f005
commit
0ad83b8d71
@@ -109,6 +109,7 @@ describe('POST /api/parties/[id]/enrich', () => {
|
||||
],
|
||||
fetchedAt: '2026-09-03T10:00:00Z',
|
||||
})
|
||||
enqueue({ data: [] }) // previous registry contact facts
|
||||
enqueue({ data: { inserted: 2, superseded: 0, refreshed: 0 } })
|
||||
enqueue({ data: null, count: 0 }) // no user-entered legal name
|
||||
enqueue({ data: null }) // parties.update
|
||||
@@ -131,10 +132,42 @@ describe('POST /api/parties/[id]/enrich', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/parties/[id]/enrich, contact details land on the rows', () => {
|
||||
it('fills empty supplier contact fields from the register, never a typed one, and reports what it filled', async () => {
|
||||
enqueue({ data: { id: PARTY, display_name: 'Webhallen Sverige AB', org_number: '5565588224', legal_name: 'WEBHALLEN SVERIGE AB' } })
|
||||
lookupByOrgNumber.mockResolvedValue({
|
||||
found: true,
|
||||
peOrgNr: '165565588224',
|
||||
row: {},
|
||||
facts: [
|
||||
{ field: 'legal_name', value: 'WEBHALLEN SVERIGE AB' },
|
||||
{ field: 'email', value: 'info@webhallen.com' },
|
||||
{ field: 'phone', value: '086736000' },
|
||||
{ field: 'postal_address', value: { co: null, street: 'TELEGRAFGATAN 4', postal_code: '169 72', city: 'SOLNA' } },
|
||||
],
|
||||
fetchedAt: '2026-09-05T10:00:00Z',
|
||||
})
|
||||
enqueue({ data: [] }) // previous registry contact facts: none
|
||||
enqueue({ data: { inserted: 4, superseded: 0, refreshed: 0 } })
|
||||
enqueue({ data: null, count: 0 }) // no user-entered legal name
|
||||
// suppliers pointing at the party: one with a typed e-mail, empty otherwise
|
||||
enqueue({ data: [{ id: 's-1', email: 'faktura@webhallen.com', phone: null, address_line1: null, address_line2: null, postal_code: null, city: null }] })
|
||||
enqueue({ data: null }) // suppliers.update
|
||||
enqueue({ data: [] }) // customers: none
|
||||
const { status, body } = await parseJsonResponse<{ data: { filled: Record<string, string[]>; renamedTo: string | null } }>(await call())
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.renamedTo).toBeNull()
|
||||
expect(body.data.filled).toEqual({ suppliers: ['phone', 'address_line1', 'address_line2', 'postal_code', 'city'] })
|
||||
const update = mockSupabase.from.mock.calls.map((c, i) => ({ table: c[0], i })).filter((c) => c.table === 'suppliers')
|
||||
expect(update.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/parties/[id]/enrich, the registry name becomes the displayed name', () => {
|
||||
it('renames a memo-named party and its supplier row to the registry name in title case, and reports it', async () => {
|
||||
enqueue({ data: { id: PARTY, display_name: 'Webhallen Oktober', org_number: '5565588224', legal_name: null } })
|
||||
lookupByOrgNumber.mockResolvedValue({ found: true, peOrgNr: '165565588224', row: {}, facts: [{ field: 'legal_name', value: 'WEBHALLEN SVERIGE AB' }], fetchedAt: '2026-09-05T10:00:00Z' })
|
||||
enqueue({ data: [] }) // previous registry contact facts
|
||||
enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } })
|
||||
enqueue({ data: null, count: 0 }) // no user-entered legal name
|
||||
enqueue({ data: null }) // parties.update
|
||||
@@ -152,6 +185,7 @@ describe('POST /api/parties/[id]/enrich, the registry name becomes the displayed
|
||||
it('keeps a display name that already is the registry name, spelling aside', async () => {
|
||||
enqueue({ data: { id: PARTY, display_name: 'Visma Spcs AB', org_number: '5562529155', legal_name: null } })
|
||||
lookupByOrgNumber.mockResolvedValue({ found: true, peOrgNr: '165562529155', row: {}, facts: [{ field: 'legal_name', value: 'VISMA SPCS AB' }], fetchedAt: '2026-09-05T10:00:00Z' })
|
||||
enqueue({ data: [] }) // previous registry contact facts
|
||||
enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } })
|
||||
enqueue({ data: null, count: 0 })
|
||||
enqueue({ data: null }) // parties.update (legal_name only)
|
||||
@@ -165,6 +199,7 @@ describe('POST /api/parties/[id]/enrich, legal name survivorship', () => {
|
||||
it('replaces a document-sourced legal name with the registry name, but never one a person entered', async () => {
|
||||
enqueue({ data: { id: PARTY, display_name: 'Beijer Bygg', org_number: '5560125790', legal_name: 'Beijer Bygg' } })
|
||||
lookupByOrgNumber.mockResolvedValue({ found: true, peOrgNr: '165560125790', row: {}, facts: [{ field: 'legal_name', value: 'AKTIEBOLAGET VOLVO' }], fetchedAt: '2026-09-03T10:00:00Z' })
|
||||
enqueue({ data: [] }) // previous registry contact facts
|
||||
enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } })
|
||||
enqueue({ data: null, count: 1 }) // a user-entered legal name exists
|
||||
const { status } = await parseJsonResponse(await call())
|
||||
@@ -307,8 +342,10 @@ describe('POST /api/parties/[id]/enrich with a picked org number', () => {
|
||||
enqueue({ data: { id: PARTY, org_number: null, legal_name: null, vat_number: null } })
|
||||
enqueue({ data: null }) // no holder
|
||||
enqueue({ data: null }) // parties.update org_number
|
||||
enqueue({ data: [] }) // previous registry contact facts
|
||||
enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } }) // record_party_facts (user)
|
||||
lookupByOrgNumber.mockResolvedValue({ found: true, peOrgNr: '165564082161', row: {}, facts: [{ field: 'legal_name', value: 'Adobe Systems Nordic Aktiebolag' }], fetchedAt: '2026-09-03T10:00:00Z' })
|
||||
enqueue({ data: [] }) // previous registry contact facts
|
||||
enqueue({ data: { inserted: 1, superseded: 0, refreshed: 0 } }) // record_party_facts (registry)
|
||||
enqueue({ data: null, count: 0 }) // no user legal name
|
||||
enqueue({ data: null }) // parties.update legal_name
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config'
|
||||
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
|
||||
import { ScbApiError } from '@/lib/parties/scb/transport'
|
||||
import { displayNameFromRegistry, sameName } from '@/lib/parties/registry-name'
|
||||
import { contactFill, registrySummary, type ContactRow } from '@/lib/parties/registry-summary'
|
||||
|
||||
/**
|
||||
* POST /api/parties/[id]/enrich: fetch the party's registry facts from SCB
|
||||
@@ -94,6 +95,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return NextResponse.json({ data: { found: false, orgNumber: p.org_number, inserted: 0, superseded: 0, refreshed: 0 } })
|
||||
}
|
||||
|
||||
// What the register said last time, read before the new facts land:
|
||||
// a contact field that still carries it was never touched by a person
|
||||
// and may follow the register.
|
||||
const { data: previousFacts } = await supabase
|
||||
.from('party_facts')
|
||||
.select('field, value, source')
|
||||
.eq('company_id', companyId)
|
||||
.eq('party_id', id)
|
||||
.eq('source', 'registry_scb')
|
||||
.in('field', ['email', 'phone', 'postal_address'])
|
||||
.is('superseded_at', null)
|
||||
const before = registrySummary(Array.isArray(previousFacts) ? (previousFacts as Array<{ field: string; value: unknown; source: string }>) : [])?.contact ?? null
|
||||
|
||||
const { data: summary, error: recordError } = await supabase.rpc('record_party_facts', {
|
||||
p_company_id: companyId,
|
||||
p_user_id: user.id,
|
||||
@@ -148,9 +162,33 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
await supabase.from('customers').update({ vat_number: vat }).eq('company_id', companyId).eq('party_id', id).is('vat_number', null)
|
||||
}
|
||||
|
||||
// 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. These are the fields payment files and documents use,
|
||||
// which is why they live on the row and not only on the party.
|
||||
const now = registrySummary(lookup.facts.map((f) => ({ ...f, source: 'registry_scb' as const })))?.contact
|
||||
const filled: Record<string, string[]> = {}
|
||||
if (now && (now.email || now.phone || now.address)) {
|
||||
for (const table of ['suppliers', 'customers'] as const) {
|
||||
const { data: rows } = await supabase
|
||||
.from(table)
|
||||
.select('id, email, phone, address_line1, address_line2, postal_code, city')
|
||||
.eq('company_id', companyId)
|
||||
.eq('party_id', id)
|
||||
for (const row of (rows ?? []) as Array<ContactRow & { id: string }>) {
|
||||
const update = contactFill(row, now, before)
|
||||
if (Object.keys(update).length === 0) continue
|
||||
const { error: fillError } = await supabase.from(table).update(update).eq('company_id', companyId).eq('id', row.id)
|
||||
if (fillError) log.warn('contact fill failed', { table, rowId: row.id, message: fillError.message })
|
||||
else filled[table] = [...(filled[table] ?? []), ...Object.keys(update)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const r = (summary ?? {}) as Partial<Record<'inserted' | 'superseded' | 'refreshed', number>>
|
||||
return NextResponse.json({
|
||||
data: { found: true, orgNumber: p.org_number, inserted: r.inserted ?? 0, superseded: r.superseded ?? 0, refreshed: r.refreshed ?? 0, facts: lookup.facts, renamedTo },
|
||||
data: { found: true, orgNumber: p.org_number, inserted: r.inserted ?? 0, superseded: r.superseded ?? 0, refreshed: r.refreshed ?? 0, facts: lookup.facts, renamedTo, filled },
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
Reference in New Issue
Block a user