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:
Jakob Wennberg
2026-09-05 14:33:20 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent f33628f005
commit 0ad83b8d71
21 changed files with 881 additions and 85 deletions
@@ -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
+39 -1
View File
@@ -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 },
@@ -16,6 +16,7 @@ import { z } from 'zod'
import { noContent, ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { parseExpand } from '@/lib/api/v1/expand'
import { PartyForApiSchema, expandParty } from '@/lib/parties/party-api'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
@@ -71,18 +72,22 @@ const CustomerDetail = z.object({
personal_number: z.string().nullable(),
default_payment_terms: z.number(),
notes: z.string().nullable(),
/** The party (motpart) behind the customer: one per counterpart, shared with the supplier side and the ledger. Null for private individuals. */
party_id: z.string().uuid().nullable(),
/** Present with ?expand=party: identity, the SCB register summary and what the ledger has seen. */
party: PartyForApiSchema.nullable().optional(),
archived_at: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
const ALLOWED_EXPAND = ['invoices'] as const
const ALLOWED_EXPAND = ['invoices', 'party'] as const
const OPEN_INVOICE_STATUSES = ['sent', 'partially_paid', 'overdue']
// Explicit projection. Excludes user_id, company_id (internal scoping),
// and vat_number_validated_at (internal timestamp not in the public schema).
const CUSTOMER_DETAIL_COLUMNS =
'id, name, customer_type, customer_number, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, personal_number, default_payment_terms, notes, archived_at, created_at, updated_at'
'id, name, customer_type, customer_number, contact_person, email, phone, invoice_email_cc_addresses, invoice_email_bcc_addresses, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, personal_number, default_payment_terms, notes, party_id, archived_at, created_at, updated_at'
const OPEN_INVOICE_COLUMNS =
'id, invoice_number, invoice_date, due_date, status, currency, total, remaining_amount'
@@ -93,7 +98,7 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/customers/:id',
summary: 'Retrieve a single customer by id.',
description:
'Returns the full customer record. Pass ?expand=invoices to embed any open invoices (sent / partially_paid / overdue) for the customer in the same response.',
'Returns the full customer record. Pass ?expand=invoices to embed any open invoices (sent / partially_paid / overdue) for the customer in the same response. Pass ?expand=party to embed the party (motpart) behind the customer: legal name, org and VAT number, country, the SCB company-register summary and what the ledger has seen for it. Private individuals have no party.',
useWhen:
'You need the full customer record: address, payment terms, VAT validation status, contact details: before invoicing or syncing to another system.',
doNotUseFor:
@@ -226,10 +231,12 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
}
}
const party = expand.has('party') ? await expandParty(ctx.supabase, ctx.companyId!, (customer as { party_id?: string | null }).party_id ?? null) : undefined
return ok(
// The selected row carries personal_number ciphertext; mask before it
// leaves the server.
{ ...maskCustomerRow(customer as { personal_number?: string | null }), ...(invoices !== undefined ? { invoices } : {}) },
{ ...(party !== undefined ? { party } : {}), ...maskCustomerRow(customer as { personal_number?: string | null }), ...(invoices !== undefined ? { invoices } : {}) },
{
requestId: ctx.requestId,
partialExpansions: partialExpansions.length > 0 ? partialExpansions : undefined,
@@ -49,6 +49,8 @@ const CustomerSummary = z.object({
org_number: z.string().nullable(),
vat_number: z.string().nullable(),
default_payment_terms: z.number(),
/** The party (motpart) behind the row; fetch it with GET .../{id}?expand=party or the MCP tool get_party. */
party_id: z.string().uuid().nullable().optional(),
archived_at: z.string().nullable(),
created_at: z.string(),
})
@@ -58,7 +60,7 @@ const CustomersListResponse = listEnvelope(CustomerSummary)
// Explicit projection: never SELECT *. Schema migrations adding columns
// must update this list before the field becomes visible on the public API.
const CUSTOMER_SUMMARY_COLUMNS =
'id, name, customer_type, email, org_number, vat_number, default_payment_terms, archived_at, created_at'
'id, name, customer_type, email, org_number, vat_number, default_payment_terms, party_id, archived_at, created_at'
registerEndpoint({
operation: 'customers.list',
@@ -16,6 +16,7 @@ import { z } from 'zod'
import { noContent, ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { parseExpand } from '@/lib/api/v1/expand'
import { PartyForApiSchema, expandParty } from '@/lib/parties/party-api'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
@@ -51,12 +52,16 @@ const SupplierDetail = z.object({
default_payment_terms: z.number(),
default_currency: z.string(),
notes: z.string().nullable(),
/** The party (motpart) behind the supplier: one per counterpart, shared with the customer side and the ledger. */
party_id: z.string().uuid().nullable(),
/** Present with ?expand=party: identity, the SCB register summary and what the ledger has seen. */
party: PartyForApiSchema.nullable().optional(),
archived_at: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
const ALLOWED_EXPAND = ['supplier_invoices'] as const
const ALLOWED_EXPAND = ['supplier_invoices', 'party'] as const
// `disputed` is included so a held supplier invoice still blocks archive:
// the seller record may still be needed if the dispute resolves into a
// kreditfaktura or partial payment.
@@ -69,7 +74,7 @@ const OPEN_SUPPLIER_INVOICE_STATUSES = [
]
const SUPPLIER_DETAIL_COLUMNS =
'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, archived_at, created_at, updated_at'
'id, name, supplier_type, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, bankgiro, plusgiro, bank_account, iban, bic, default_expense_account, default_payment_terms, default_currency, notes, party_id, archived_at, created_at, updated_at'
const OPEN_SUPPLIER_INVOICE_COLUMNS =
'id, supplier_invoice_number, arrival_number, invoice_date, due_date, status, currency, total, remaining_amount'
@@ -80,7 +85,7 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/suppliers/:id',
summary: 'Retrieve a single supplier by id.',
description:
'Returns the full supplier record. Pass ?expand=supplier_invoices to embed any open supplier invoices (registered / approved / partially_paid / overdue / disputed) for the supplier in the same response.',
'Returns the full supplier record. Pass ?expand=supplier_invoices to embed any open supplier invoices (registered / approved / partially_paid / overdue / disputed) for the supplier in the same response. Pass ?expand=party to embed the party (motpart) behind the supplier: legal name, org and VAT number, country, the SCB company-register summary (status, legal form, industry, seat, size, registrations, contact details, fetched date) and what the ledger has seen for it.',
useWhen:
'You need the full supplier record: address, payment terms, banking details, default expense account: before booking a supplier invoice or syncing to an external AP system.',
doNotUseFor:
@@ -195,8 +200,10 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
}
}
const party = expand.has('party') ? await expandParty(ctx.supabase, ctx.companyId!, (supplier as { party_id?: string | null }).party_id ?? null) : undefined
return ok(
{ ...supplier, ...(supplier_invoices !== undefined ? { supplier_invoices } : {}) },
{ ...supplier, ...(supplier_invoices !== undefined ? { supplier_invoices } : {}), ...(party !== undefined ? { party } : {}) },
{
requestId: ctx.requestId,
partialExpansions: partialExpansions.length > 0 ? partialExpansions : undefined,
@@ -33,6 +33,12 @@ vi.mock('@supabase/supabase-js', async () => {
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const expandParty = vi.fn()
vi.mock('@/lib/parties/party-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/parties/party-api')>('@/lib/parties/party-api')
return { ...actual, expandParty: (...args: unknown[]) => expandParty(...args) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listSuppliers, POST as createSupplier } from '../route'
import {
@@ -227,6 +233,43 @@ describe('GET /api/v1/companies/:companyId/suppliers/:id', () => {
})
})
describe('GET /api/v1/companies/:companyId/suppliers/:id?expand=party', () => {
it('embeds the party behind the supplier on request, and leaves it out otherwise', async () => {
const party = { id: 'p-1', display_name: 'Office Depot AB', org_number: '5566778899', registry: { status: { label: 'Verksamt', active: true } } }
expandParty.mockResolvedValue(party)
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: { ...SAMPLE_SUPPLIER, party_id: 'p-1' }, error: null },
}),
)
const withParty = await getSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}?expand=party`),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
expect(withParty.status).toBe(200)
const body = await withParty.json()
expect(body.data.party_id).toBe('p-1')
expect(body.data.party).toEqual(party)
expect(expandParty).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'p-1')
expandParty.mockClear()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: { ...SAMPLE_SUPPLIER, party_id: 'p-1' }, error: null },
}),
)
const plain = await getSupplier(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/suppliers/${SUPPLIER_ID}`),
detailParams(COMPANY_ID, SUPPLIER_ID),
)
const plainBody = await plain.json()
expect(plainBody.data.party).toBeUndefined()
expect(expandParty).not.toHaveBeenCalled()
})
})
describe('POST /api/v1/companies/:companyId/suppliers', () => {
it('creates a supplier (happy path)', async () => {
mockServiceClient.mockReturnValue(
@@ -44,6 +44,8 @@ const SupplierSummary = z.object({
vat_number: z.string().nullable(),
default_payment_terms: z.number(),
default_currency: z.string(),
/** The party (motpart) behind the row; fetch it with GET .../{id}?expand=party or the MCP tool get_party. */
party_id: z.string().uuid().nullable().optional(),
archived_at: z.string().nullable(),
created_at: z.string(),
})
@@ -53,7 +55,7 @@ const SuppliersListResponse = listEnvelope(SupplierSummary)
// Explicit projection: never SELECT *. Schema migrations adding columns
// must update this list before the field becomes visible on the public API.
const SUPPLIER_SUMMARY_COLUMNS =
'id, name, supplier_type, email, org_number, vat_number, default_payment_terms, default_currency, archived_at, created_at'
'id, name, supplier_type, email, org_number, vat_number, default_payment_terms, default_currency, party_id, archived_at, created_at'
registerEndpoint({
operation: 'suppliers.list',