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
parent f33628f005
commit 0ad83b8d71
21 changed files with 881 additions and 85 deletions
+1
View File
@@ -1600,3 +1600,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] Provider OAuth returns to the initiating validated app/brand origin through a two-minute provider_otc handoff. State rows and handoffs have disjoint consume predicates, handoff consumption also binds the destination origin, and both success and provider denial require the original user on hop 2. The token exchange keeps the original configured provider redirect URI. Staging prerequisites 20260831111519, 20260902090000 and 20260902100000 were replayed from the existing SQL files before 20260905094806; MCP-assigned history timestamps were reconciled to those repository versions. Production and the live amnas Fortnox connect remain pending deployment and specific production-write approval.
[2026-09-05] PR #2305 review: encrypt both OAuth handoff payload columns with AES-256-GCM using a purpose-scoped derivation of the existing server-only service-role secret, matching other extension credential storage. Authenticate the handoff token, consent, initiating user, destination origin and column as additional data; reject plaintext or unreadable payloads after atomic consume. This resolves the at-rest encryption finding without new configuration, dependencies, or edits to the already-applied migration.
[2026-09-05] PR #2305 cleanup review: expire provider_otc rows through a service-role cron every five minutes, including abandoned states and encrypted handoffs whose consents remain. Use the existing cron-auth wrapper and generated hosted/self-hosted schedules; the migration is already applied on staging and remains unchanged.
[2026-09-05] Supplier and customer pages: the register fills the row's own contact fields (e-mail, phone, postal address, VAT number) when they are empty or still carry what the register said last time, marked "från SCB" by equality with the registry fact, never a value a person typed. Chosen over a read-only fallback because the row is what payment files and documents use; provenance by equality instead of a source column because it needs no schema and a person's edit ends it by itself. Företagsuppgifter keeps only what the register alone knows (status line, industry, seat, size). Agents get the party read-only first: ?expand=party on v1 supplier/customer detail, party_id on list rows, and gnubok_get_party in MCP; the parties resource (suggest, promote, enrich) comes as its own v1 surface next.
+30 -3
View File
@@ -27,6 +27,8 @@ import { getCountryName } from '@/lib/vat/country-codes'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
import { PartyFactsSection } from '@/components/parties/PartyFactsSection'
import { usePartyDossier } from '@/components/parties/use-party-dossier'
import { fromRegistry, addressRowsFromRegistry } from '@/lib/parties/registry-summary'
const CUSTOMER_TYPE_KEY: Record<CustomerType, string> = {
individual: 'type_individual',
@@ -60,8 +62,13 @@ export default function CustomerDetailPage({
const { toast } = useToast()
const { canWrite } = useCanWrite()
const t = useTranslations('customer_detail')
const tParties = useTranslations('parties')
const errorLocale = useLocale() as ErrorLocale
const [customer, setCustomer] = useState<CustomerWithRelations | null>(null)
const partyId = customer && customer.customer_type !== 'individual' ? ((customer as { party_id?: string | null }).party_id ?? null) : null
const party = usePartyDossier(partyId)
const registryAddress = party.registry?.contact.address ? addressRowsFromRegistry(party.registry.contact.address) : null
const scbNote = (isFromRegistry: boolean) => (isFromRegistry ? <span className="block text-xs text-muted-foreground">{tParties('facts_from_registry')}</span> : null)
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
@@ -264,8 +271,12 @@ export default function CustomerDetailPage({
) : (
<DefEmpty />
)}
{scbNote(fromRegistry(customer.email, party.registry?.contact.email))}
</DefRow>
<DefRow label={t('def_phone')}>
{customer.phone || <DefEmpty />}
{scbNote(fromRegistry(customer.phone, party.registry?.contact.phone))}
</DefRow>
<DefRow label={t('def_phone')}>{customer.phone || <DefEmpty />}</DefRow>
<DefRow label={t('def_address')}>
{customer.address_line1 || customer.city ? (
<div>
@@ -274,6 +285,11 @@ export default function CustomerDetailPage({
{(customer.postal_code || customer.city) && (
<p>{[customer.postal_code, customer.city].filter(Boolean).join(' ')}</p>
)}
{scbNote(
!!registryAddress &&
fromRegistry(customer.address_line1, registryAddress.address_line1) &&
fromRegistry(customer.city, registryAddress.city),
)}
{customer.country && <p>{getCountryName(customer.country, errorLocale)}</p>}
</div>
) : (
@@ -282,8 +298,19 @@ export default function CustomerDetailPage({
</DefRow>
</DetailSection>
{(customer as { party_id?: string | null }).party_id && customer.customer_type !== 'individual' ? (
<PartyFactsSection partyId={(customer as { party_id?: string | null }).party_id as string} canWrite={canWrite} onChanged={() => void fetchCustomer()} />
{partyId && party.dossier ? (
<PartyFactsSection
partyId={partyId}
rowName={customer.name}
canWrite={canWrite}
dossier={party.dossier}
registry={party.registry}
scbEnabled={party.scbEnabled}
onChanged={async () => {
await party.reload()
await fetchCustomer()
}}
/>
) : null}
<DetailSection kicker={t('section_business')}>
+41 -6
View File
@@ -20,6 +20,9 @@ import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
import { PartyFactsSection } from '@/components/parties/PartyFactsSection'
import { usePartyDossier } from '@/components/parties/use-party-dossier'
import { fromRegistry, addressRowsFromRegistry } from '@/lib/parties/registry-summary'
import { formatOrgNumber } from '@/lib/utils'
// Supplier invoices carry their own currency; "kr" is only correct for SEK.
function amountWithCurrency(amount: number, currency?: string | null): string {
@@ -43,7 +46,12 @@ export default function SupplierDetailPage() {
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('supplier_detail')
const tParties = useTranslations('parties')
const [supplier, setSupplier] = useState<Supplier & { stats?: SupplierStats } | null>(null)
const partyId = (supplier as { party_id?: string | null } | null)?.party_id ?? null
const party = usePartyDossier(partyId)
const registryAddress = party.registry?.contact.address ? addressRowsFromRegistry(party.registry.contact.address) : null
const scbNote = (isFromRegistry: boolean) => (isFromRegistry ? <span className="block text-xs text-muted-foreground">{tParties('facts_from_registry')}</span> : null)
const [invoices, setInvoices] = useState<SupplierInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
@@ -197,7 +205,7 @@ export default function SupplierDetailPage() {
<h1 className="font-display text-2xl leading-8 tracking-tight">{supplier.name}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{supplierTypeLabels[supplier.supplier_type]}
{supplier.org_number ? ` · ${t('kicker_org', { number: supplier.org_number })}` : ''}
{supplier.org_number ? ` · ${t('kicker_org', { number: formatOrgNumber(supplier.org_number) })}` : ''}
</p>
</div>
@@ -264,13 +272,30 @@ export default function SupplierDetailPage() {
</div>
</div>
{(supplier as { party_id?: string | null }).party_id ? (
<PartyFactsSection partyId={(supplier as { party_id?: string | null }).party_id as string} canWrite={canWrite} onChanged={() => void fetchSupplier()} />
{partyId && party.dossier ? (
<PartyFactsSection
partyId={partyId}
rowName={supplier.name}
canWrite={canWrite}
dossier={party.dossier}
registry={party.registry}
scbEnabled={party.scbEnabled}
onChanged={async () => {
await party.reload()
await fetchSupplier()
}}
/>
) : null}
<DetailSection kicker={t('contact_section_title')}>
<DefRow label={t('def_email')}>{supplier.email || <DefEmpty />}</DefRow>
<DefRow label={t('def_phone')}>{supplier.phone || <DefEmpty />}</DefRow>
<DefRow label={t('def_email')}>
{supplier.email || <DefEmpty />}
{scbNote(fromRegistry(supplier.email, party.registry?.contact.email))}
</DefRow>
<DefRow label={t('def_phone')}>
{supplier.phone || <DefEmpty />}
{scbNote(fromRegistry(supplier.phone, party.registry?.contact.phone))}
</DefRow>
<DefRow label={t('def_address')}>
{supplier.address_line1 || supplier.city ? (
<div>
@@ -279,12 +304,22 @@ export default function SupplierDetailPage() {
{(supplier.postal_code || supplier.city) && (
<p>{[supplier.postal_code, supplier.city].filter(Boolean).join(' ')}</p>
)}
{scbNote(
!!registryAddress &&
fromRegistry(supplier.address_line1, registryAddress.address_line1) &&
fromRegistry(supplier.city, registryAddress.city),
)}
</div>
) : (
<DefEmpty />
)}
</DefRow>
{supplier.vat_number && <DefRow label={t('def_vat')}>{supplier.vat_number}</DefRow>}
{supplier.vat_number && (
<DefRow label={t('def_vat')}>
{supplier.vat_number}
{scbNote(fromRegistry(supplier.vat_number, party.registry?.vat_number))}
</DefRow>
)}
</DetailSection>
<DetailSection kicker={t('payment_section_title')}>
@@ -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',
+106 -59
View File
@@ -1,63 +1,53 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
import { DetailSection, DefRow } from '@/components/ui/detail-section'
import { useToast } from '@/components/ui/use-toast'
import type { Dossier } from '@/lib/parties/register'
import type { RegistrySummary } from '@/lib/parties/registry-summary'
import { sameName } from '@/lib/parties/registry-name'
import { isLegalPersonOrgNumber } from '@/lib/parties/scb/org-number'
import type { ScbCandidate } from '@/lib/parties/scb/client'
import { formatDate, formatOrgNumber } from '@/lib/utils'
import { registryFacts, registryLabel, registryValue } from './RegistryFacts'
import { formatDate } from '@/lib/utils'
import { ScbPickerDialog } from './ScbPickerDialog'
import { regionName } from './SuggestionQueue'
/**
* "Företagsuppgifter" on a supplier or customer page: what the register
* knows about the company behind the row. Legal name, org number and VAT
* number first, then the facts SCB gave under one source line, and one
* action: fetch by org number when there is one, find the company in the
* register when there is not. The same facts the party dossier shows; this
* is where people look for them.
* "Företagsuppgifter" on a supplier or customer page: what only the register
* knows, in a few lines. Identity (org number, VAT number) lives in the
* page header and the contact section, and contact details the register
* gave land on the row itself, so this block does not repeat them. It
* carries the status line (legal form, active or not, registrations, and a
* Bolagsverket warning when there is one), industry, seat, size, and one
* action: fetch by org number, or find the company in the register.
*/
export function PartyFactsSection({
partyId,
rowName,
canWrite,
dossier,
registry,
scbEnabled,
onChanged,
}: {
partyId: string
/** The supplier's or customer's own name, so the legal name shows only when it differs. */
rowName: string
canWrite: boolean
/** The party was enriched or renamed; the owning row may have changed too. */
onChanged?: () => void
dossier: Dossier
registry: RegistrySummary | null
scbEnabled: boolean
/** The party was fetched, renamed or filled in; the owning row may have changed too. */
onChanged: () => Promise<void> | void
}) {
const t = useTranslations('parties')
const locale = useLocale()
const { toast } = useToast()
const [dossier, setDossier] = useState<Dossier | null>(null)
const [scbEnabled, setScbEnabled] = useState(false)
const [loaded, setLoaded] = useState(false)
const [busy, setBusy] = useState(false)
const [picker, setPicker] = useState(false)
const load = useCallback(async () => {
try {
const res = await fetch(`/api/parties/${partyId}`)
if (!res.ok) throw new Error(String(res.status))
const json = (await res.json()) as { data: Dossier | null; scbConfigured?: boolean }
setDossier(json.data)
setScbEnabled(!!json.scbConfigured)
} catch {
setDossier(null)
} finally {
setLoaded(true)
}
}, [partyId])
useEffect(() => {
void load()
}, [load])
async function fetchRegistry(orgNumber?: string) {
setBusy(true)
try {
@@ -67,7 +57,7 @@ export function PartyFactsSection({
body: orgNumber ? JSON.stringify({ orgNumber }) : undefined,
})
const json = (await res.json()) as {
data?: { found: boolean; orgNumber: string; inserted: number; superseded: number; refreshed: number; renamedTo?: string | null }
data?: { found: boolean; orgNumber: string; inserted: number; superseded: number; refreshed: number; renamedTo?: string | null; filled?: Record<string, string[]> }
error?: { details?: { reason?: string; displayName?: string } }
}
if (!res.ok || !json.data) {
@@ -83,12 +73,15 @@ export function PartyFactsSection({
toast({ title: t('registry_not_found_title'), description: t('registry_not_found_description', { org: json.data.orgNumber }) })
return
}
const filledFields = [...new Set(Object.values(json.data.filled ?? {}).flat())]
const filledText = filledFields.length
? t('facts_filled_description', { fields: filledFields.map((f) => fieldLabel(t, f)).join(', ') })
: t('registry_fetched_description', { inserted: json.data.inserted, superseded: json.data.superseded, refreshed: json.data.refreshed })
toast({
title: json.data.renamedTo ? t('facts_renamed_title', { name: json.data.renamedTo }) : t('registry_fetched_title'),
description: t('registry_fetched_description', { inserted: json.data.inserted, superseded: json.data.superseded, refreshed: json.data.refreshed }),
title: json.data.renamedTo ? t('facts_renamed_title', { name: json.data.renamedTo }) : filledFields.length ? t('facts_filled_title') : t('registry_fetched_title'),
description: filledText,
})
await load()
onChanged?.()
await onChanged()
} catch {
toast({ title: t('registry_unavailable_title'), variant: 'destructive' })
} finally {
@@ -96,15 +89,27 @@ export function PartyFactsSection({
}
}
if (!loaded || !dossier) return null
const p = dossier.party
const registry = registryFacts(dossier.facts)
const fetchedAt = dossier.facts.find((f) => f.source === 'registry_scb')?.fetchedAt ?? null
const registryVat = dossier.facts.find((f) => f.field === 'vat_number' && f.source === 'registry_scb')?.value
const countryRaw = dossier.facts.find((f) => f.field === 'country')?.value
const country = typeof countryRaw === 'string' && /^[A-Za-z]{2}$/.test(countryRaw) ? countryRaw.toUpperCase() : null
const country = p.country
const foreign = !!country && country !== 'SE'
const canFetch = scbEnabled && canWrite && isLegalPersonOrgNumber(p.orgNumber)
const canFind = scbEnabled && canWrite && !p.orgNumber && p.kind !== 'person' && (!country || country === 'SE')
const canFind = scbEnabled && canWrite && !p.orgNumber && p.kind !== 'person' && !foreign
const legalDiffers = !!p.legalName && !sameName(p.legalName, rowName)
const registrations = registry
? (
[
[registry.registrations.f_tax, t('facts_reg_f_tax')],
[registry.registrations.vat, t('facts_reg_vat')],
[registry.registrations.employer, t('facts_reg_employer')],
] as const
)
.filter(([on]) => on === true)
.map(([, label]) => label)
: []
const statusLine = registry
? [registry.legal_form, registry.status?.label].filter(Boolean).join(' · ')
: null
const attention = !!registry && (registry.warning !== null || registry.status?.active === false)
return (
<>
@@ -113,7 +118,7 @@ export function PartyFactsSection({
aside={
canFetch ? (
<Button type="button" size="sm" variant="outline" onClick={() => void fetchRegistry()} disabled={busy}>
{busy ? t('fetching_registry') : registry.length > 0 ? t('facts_refresh') : t('fetch_registry')}
{busy ? t('fetching_registry') : registry ? t('facts_refresh') : t('fetch_registry')}
</Button>
) : canFind ? (
<Button type="button" size="sm" variant="outline" onClick={() => setPicker(true)} disabled={busy}>
@@ -122,20 +127,46 @@ export function PartyFactsSection({
) : undefined
}
>
<DefRow label={t('fact_legal_name')}>{p.legalName ?? <DefEmpty />}</DefRow>
<DefRow label={t('fact_org')}>{p.orgNumber ? <span className="tabular-nums">{formatOrgNumber(p.orgNumber)}</span> : <DefEmpty />}</DefRow>
<DefRow label={t('fact_vat')}>{p.vatNumber ?? (registryVat ? String(registryVat) : <DefEmpty />)}</DefRow>
{country ? <DefRow label={t('fact_country')}>{regionName(country, locale)}</DefRow> : null}
{registry.map((f) => (
<DefRow key={f.id} label={f.field === 'postal_address' && !(f.value as { street?: string | null })?.street ? t('fact_postal_code_city') : registryLabel(t, f.field)}>
{registryValue(f.value)}
</DefRow>
))}
{legalDiffers ? <DefRow label={t('fact_legal_name')}>{p.legalName}</DefRow> : null}
{country && (foreign || !registry) ? <DefRow label={t('fact_country')}>{regionName(country, locale)}</DefRow> : null}
{registry ? (
<>
<DefRow label={t('facts_status')}>
<span className={attention ? 'text-warning' : undefined}>
{registry.warning ? [statusLine, registry.warning].filter(Boolean).join(' · ') : statusLine}
</span>
{registrations.length > 0 ? (
<span className="block text-xs text-muted-foreground">{t('facts_registered_for', { items: registrations.join(', ') })}</span>
) : registry.registrations.f_tax === false && registry.registrations.vat === false ? (
<span className="block text-xs text-warning">{t('facts_not_registered')}</span>
) : null}
</DefRow>
{registry.industry ? <DefRow label={t('facts_industry')}>{registry.industry.label}</DefRow> : null}
{registry.seat || registry.registered_at ? (
<DefRow label={t('facts_seat')}>
{registry.seat && registry.registered_at
? t('facts_seat_registered', { seat: registry.seat, date: formatDate(registry.registered_at) })
: (registry.seat ?? formatDate(registry.registered_at as string))}
</DefRow>
) : null}
{registry.employees_band || registry.turnover || registry.workplaces ? (
<DefRow label={t('facts_size')}>
{[
registry.employees_band,
registry.turnover ? (registry.turnover.year ? `${registry.turnover.band} (${registry.turnover.year})` : registry.turnover.band) : null,
registry.workplaces && registry.workplaces > 1 ? t('facts_workplaces', { count: registry.workplaces }) : null,
]
.filter(Boolean)
.join(' · ')}
</DefRow>
) : null}
</>
) : null}
<p className="pt-2 text-xs text-muted-foreground">
{fetchedAt
? t('registry_group', { date: formatDate(fetchedAt) })
: country && country !== 'SE'
? t('facts_foreign', { country: regionName(country, locale) })
{registry?.fetched_at
? t('registry_group', { date: formatDate(registry.fetched_at) })
: foreign
? t('facts_foreign', { country: regionName(country as string, locale) })
: p.orgNumber
? t('facts_none_org')
: t('facts_none')}
@@ -157,3 +188,19 @@ export function PartyFactsSection({
</>
)
}
function fieldLabel(t: (k: string) => string, field: string): string {
switch (field) {
case 'email':
return t('fact_email')
case 'phone':
return t('fact_phone')
case 'address_line1':
case 'address_line2':
case 'postal_code':
case 'city':
return t('fact_postal_address')
default:
return field
}
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { Dossier } from '@/lib/parties/register'
import { registrySummary, type RegistrySummary } from '@/lib/parties/registry-summary'
/**
* The party behind a supplier or customer row, for the row's own page. One
* fetch feeds the Företagsuppgifter block and the "från SCB" notes on the
* contact rows, so the two never disagree about what the register said.
*/
export function usePartyDossier(partyId: string | null | undefined): {
dossier: Dossier | null
registry: RegistrySummary | null
scbEnabled: boolean
loaded: boolean
reload: () => Promise<void>
} {
const [dossier, setDossier] = useState<Dossier | null>(null)
const [scbEnabled, setScbEnabled] = useState(false)
const [loaded, setLoaded] = useState(!partyId)
const reload = useCallback(async () => {
if (!partyId) return
try {
const res = await fetch(`/api/parties/${partyId}`)
if (!res.ok) throw new Error(String(res.status))
const json = (await res.json()) as { data: Dossier | null; scbConfigured?: boolean }
setDossier(json.data)
setScbEnabled(!!json.scbConfigured)
} catch {
setDossier(null)
} finally {
setLoaded(true)
}
}, [partyId])
useEffect(() => {
void reload()
}, [reload])
const registry = useMemo(() => (dossier ? registrySummary(dossier.facts) : null), [dossier])
return { dossier, registry, scbEnabled, loaded, reload }
}
@@ -0,0 +1,54 @@
/**
* gnubok_get_party: the party behind a supplier or customer for an agent.
* The dossier itself is built by lib/parties (tested there); this checks the
* argument contract and the row-to-party resolution.
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const expandParty = vi.fn()
vi.mock('@/lib/parties/party-api', () => ({ expandParty: (...args: unknown[]) => expandParty(...args) }))
import { tools } from '../server'
const getParty = () => tools.find((t) => t.name === 'gnubok_get_party')!
const PARTY = { id: 'p-1', display_name: 'Webhallen Sverige AB', org_number: '5565588224', registry: { status: { label: 'Verksamt', active: true } } }
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_get_party', () => {
it('is read-only and demands exactly one id', async () => {
const { supabase } = createQueuedMockSupabase()
expect(getParty().annotations).toMatchObject({ readOnlyHint: true })
await expect(getParty().execute({}, 'company-1', 'user-1', supabase as never)).rejects.toThrow(/exactly one/)
await expect(getParty().execute({ party_id: 'p-1', supplier_id: 's-1' }, 'company-1', 'user-1', supabase as never)).rejects.toThrow(/exactly one/)
expect(expandParty).not.toHaveBeenCalled()
})
it('resolves a supplier to its party and scopes the lookup to the company', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 's-1', party_id: 'p-1' } })
expandParty.mockResolvedValue(PARTY)
const result = await getParty().execute({ supplier_id: 's-1' }, 'company-1', 'user-1', supabase as never)
expect(result).toEqual({ party: PARTY, found: true })
expect(findCalls('suppliers', 'eq')).toEqual([
['company_id', 'company-1'],
['id', 's-1'],
])
expect(expandParty).toHaveBeenCalledWith(supabase, 'company-1', 'p-1')
})
it('answers found:false for an unknown row, a row without a party, and a dismissed party', async () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
enqueue({ data: null })
expect(await getParty().execute({ customer_id: 'c-9' }, 'company-1', 'user-1', supabase as never)).toEqual({ party: null, found: false })
reset()
enqueue({ data: { id: 'c-1', party_id: null } })
expect(await getParty().execute({ customer_id: 'c-1' }, 'company-1', 'user-1', supabase as never)).toEqual({ party: null, found: false })
expect(expandParty).not.toHaveBeenCalled()
expandParty.mockResolvedValue(null)
expect(await getParty().execute({ party_id: 'p-gone' }, 'company-1', 'user-1', supabase as never)).toEqual({ party: null, found: false })
})
})
+61 -2
View File
@@ -101,6 +101,7 @@ import {
} from '@/lib/reports/vat-filing-gate'
import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { expandParty } from '@/lib/parties/party-api'
import { listForCompany as listCashAccountsForCompany } from '@/lib/cash-accounts/service'
import {
looksLikeSwedishPersonalNumber,
@@ -5948,7 +5949,7 @@ export const tools: McpTool[] = [
rows = await fetchAllRows<ListedCustomer>(({ from, to }) => {
const query = supabase
.from('customers')
.select('id, name, customer_type, email, org_number, vat_number, personal_number, default_payment_terms, city, country, archived_at')
.select('id, name, customer_type, email, org_number, vat_number, personal_number, default_payment_terms, city, country, party_id, archived_at')
.eq('company_id', companyId)
return (includeArchived ? query : query.is('archived_at', null))
.order('id', { ascending: true })
@@ -8481,7 +8482,7 @@ export const tools: McpTool[] = [
suppliers = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => {
const query = supabase
.from('suppliers')
.select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country, archived_at')
.select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country, party_id, archived_at')
.eq('company_id', companyId)
return (includeArchived ? query : query.is('archived_at', null))
.order('id', { ascending: true })
@@ -8496,6 +8497,64 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_get_party',
// Specialized: found through gnubok_search_tools and named from the list
// tools' party_id; keeps the default tools/list under its byte budget.
catalogVisibility: 'search',
keywords: ['motpart', 'part', 'leverantör', 'kund', 'företagsregistret', 'scb', 'org.nr', 'organisationsnummer', 'bolagsform', 'f-skatt'],
title: 'Get Party (Motpart) Behind a Supplier or Customer',
description:
'The party (motpart) behind a supplier or customer: legal name, org/VAT number, country, the SCB register summary (status, legal form, industry, seat, size, registrations, contact) and what the ledger has seen. Pass exactly one of party_id, supplier_id, customer_id. Read-only.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
party_id: { type: 'string', format: 'uuid', description: 'The party id.' },
supplier_id: { type: 'string', format: 'uuid', description: 'A supplier id; its party is returned.' },
customer_id: { type: 'string', format: 'uuid', description: 'A customer id; its party is returned. Private individuals have none.' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
party: { type: ['object', 'null'] },
found: { type: 'boolean' },
},
required: ['party', 'found'],
},
annotations: ANNOTATIONS_READ_ONLY,
async execute(args, companyId, userId, supabase) {
const given = (['party_id', 'supplier_id', 'customer_id'] as const).filter((k) => typeof args[k] === 'string' && (args[k] as string).trim())
if (given.length !== 1) {
throw new Error('Pass exactly one of party_id, supplier_id or customer_id.')
}
let partyId: string | null = null
if (given[0] === 'party_id') partyId = String(args.party_id)
else {
const table = given[0] === 'supplier_id' ? 'suppliers' : 'customers'
const { data: row, error } = await supabase
.from(table)
.select('id, party_id')
.eq('company_id', companyId)
.eq('id', String(args[given[0]]))
.maybeSingle()
if (error) throw dbError(error)
if (!row) return { party: null, found: false }
partyId = (row as { party_id: string | null }).party_id
}
if (!partyId) return { party: null, found: false }
let party
try {
party = await expandParty(supabase, companyId, partyId)
} catch (error) {
throw dbError(error)
}
return { party, found: party !== null }
},
},
{
name: 'gnubok_create_supplier',
keywords: ['leverantör', 'ny leverantör'],
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { addressRowsFromRegistry, contactFill, fromRegistry, registrySummary } from '../registry-summary'
const scb = (field: string, value: unknown, fetchedAt = '2026-09-05T10:00:00Z') => ({ field, value, source: 'registry_scb', fetchedAt })
const WEBHALLEN = [
scb('legal_name', 'WEBHALLEN SVERIGE AB'),
scb('legal_form', { code: '49', label: 'Övriga aktiebolag' }),
scb('company_status', { code: '1', label: 'Verksamt' }),
scb('bolagsverket_status', { code: '0', label: 'Normalläge', warning: false }),
scb('f_tax', { code: '1', label: 'Godkänd för F-skatt' }),
scb('vat_registration', { code: '1', label: 'Momsregistrerad' }),
scb('employer_registration', { code: '1', label: 'Registrerad som arbetsgivare' }),
scb('industry', { code: '47410', label: 'Detaljhandel med datorer, programvara, data- och tv-spel' }),
scb('seat', { municipality: 'Stockholm', county: 'Stockholm' }),
scb('employees_band', { code: '6', label: '100-199 anställda' }),
scb('turnover_band', { code: '10', label: '1 000 000 - 4 999 999 tkr', year: '2025' }),
scb('workplaces', 15),
scb('registered_at', '1999-02-19'),
scb('postal_address', { co: null, street: 'TELEGRAFGATAN 4', postal_code: '169 72', city: 'SOLNA' }),
scb('phone', '086736000'),
scb('email', 'info@webhallen.com'),
scb('vat_number', 'SE556558822401', '2026-09-05T11:00:00Z'),
{ field: 'voucher_text', value: ['x'], source: 'ledger', fetchedAt: null },
]
describe('registrySummary', () => {
it('reads the coded facts into one summary', () => {
const s = registrySummary(WEBHALLEN)!
expect(s.legal_name).toBe('WEBHALLEN SVERIGE AB')
expect(s.legal_form).toBe('Övriga aktiebolag')
expect(s.status).toEqual({ label: 'Verksamt', active: true })
expect(s.warning).toBeNull()
expect(s.registrations).toEqual({ f_tax: true, vat: true, employer: true })
expect(s.industry?.label).toContain('Detaljhandel')
expect(s.seat).toBe('Stockholm')
expect(s.employees_band).toBe('100-199 anställda')
expect(s.turnover).toEqual({ band: '1 000 000 - 4 999 999 tkr', year: '2025' })
expect(s.workplaces).toBe(15)
expect(s.contact).toEqual({ email: 'info@webhallen.com', phone: '086736000', address: { co: null, street: 'TELEGRAFGATAN 4', postal_code: '169 72', city: 'SOLNA' } })
expect(s.vat_number).toBe('SE556558822401')
expect(s.fetched_at).toBe('2026-09-05T11:00:00Z')
})
it('surfaces a Bolagsverket warning and an inactive status, and is null without registry facts', () => {
const s = registrySummary([
scb('company_status', { code: '9', label: 'Ej verksamt' }),
scb('bolagsverket_status', { code: '31', label: 'Konkurs inledd', warning: true }),
scb('f_tax', { code: '9', label: 'Avregistrerad för F-skatt' }),
])!
expect(s.status).toEqual({ label: 'Ej verksamt', active: false })
expect(s.warning).toBe('Konkurs inledd')
expect(s.registrations.f_tax).toBe(false)
expect(s.registrations.vat).toBeNull()
expect(registrySummary([{ field: 'country', value: 'NL', source: 'ledger' }])).toBeNull()
})
})
describe('contactFill', () => {
const now = { email: 'info@webhallen.com', phone: '086736000', address: { co: null, street: 'Telegrafgatan 4', postal_code: '169 72', city: 'Solna' } }
const empty = { email: null, phone: null, address_line1: null, address_line2: null, postal_code: null, city: null }
it('fills empty fields and never a value a person typed', () => {
expect(contactFill(empty, now, null)).toEqual({ email: 'info@webhallen.com', phone: '086736000', address_line1: 'Telegrafgatan 4', address_line2: null, postal_code: '169 72', city: 'Solna' })
const typed = { ...empty, email: 'faktura@webhallen.com', address_line1: 'Box 12', postal_code: '101 20', city: 'Stockholm' }
expect(contactFill(typed, now, null)).toEqual({ phone: '086736000' })
})
it('replaces what the register said last time when the register changed, and nothing when it did not', () => {
const before = { email: 'old@webhallen.com', phone: '086736000', address: { co: null, street: 'Gamla gatan 1', postal_code: '111 11', city: 'Stockholm' } }
const row = { email: 'old@webhallen.com', phone: '086736000', address_line1: 'Gamla gatan 1', address_line2: null, postal_code: '111 11', city: 'Stockholm' }
expect(contactFill(row, now, before)).toEqual({ email: 'info@webhallen.com', address_line1: 'Telegrafgatan 4', address_line2: null, postal_code: '169 72', city: 'Solna' })
expect(contactFill(row, before, before)).toEqual({})
})
it('puts a c/o line first and knows what came from the register', () => {
expect(addressRowsFromRegistry({ co: 'c/o Ekonomi AB', street: 'Storgatan 1', postal_code: '111 22', city: 'Stockholm' })).toEqual({ address_line1: 'c/o Ekonomi AB', address_line2: 'Storgatan 1', postal_code: '111 22', city: 'Stockholm' })
expect(fromRegistry('info@webhallen.com', 'INFO@webhallen.com')).toBe(true)
expect(fromRegistry('', 'x')).toBe(false)
expect(fromRegistry('a', null)).toBe(false)
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* Parties: the party behind a supplier or customer, as the v1 REST API and
* the MCP server hand it to an agent.
*
* One shape for both surfaces: identity, where the party sits (roles,
* status), the register's summary and what the ledger has seen. Read-only;
* the write paths (promote, enrich, merge) stay in the app until the
* parties resource lands in v1.
*/
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getDossier, type Dossier } from './register'
import { registrySummary, type RegistrySummary } from './registry-summary'
export interface PartyForApi {
id: string
display_name: string
legal_name: string | null
org_number: string | null
vat_number: string | null
/** ISO 3166-1 alpha-2 read out of vouchers or a register; null when unknown. */
country: string | null
kind: string
status: 'confirmed' | 'suggested'
roles: { supplier_id: string | null; customer_id: string | null }
/** What SCB's company register says, or null when it was never asked. */
registry: RegistrySummary | null
/** What the ledger has seen under this party's keys in the dossier's period. */
ledger: {
occurrences: number
expense_sek: number
revenue_sek: number
first_seen: string | null
last_seen: string | null
dominant_account: string | null
} | null
/** Payment identities seen on documents: bankgiro, plusgiro. */
identities: Array<{ scheme: string; value: string; status: string; seen_count: number }>
}
export function partyForApi(dossier: Dossier): PartyForApi {
const p = dossier.party
const s = p.stats
return {
id: p.id,
display_name: p.displayName,
legal_name: p.legalName,
org_number: p.orgNumber,
vat_number: p.vatNumber,
country: p.country,
kind: p.kind,
status: p.status,
roles: { supplier_id: p.roles.supplierId, customer_id: p.roles.customerId },
registry: registrySummary(dossier.facts),
ledger: s
? {
occurrences: s.occurrences,
expense_sek: s.expenseSek,
revenue_sek: s.revenueSek,
first_seen: s.firstSeen,
last_seen: s.lastSeen,
dominant_account: s.dominantAccount ?? null,
}
: null,
identities: dossier.identities.map((i) => ({ scheme: i.scheme, value: i.value, status: i.status, seen_count: i.seenCount })),
}
}
/** The v1 REST schema of the party, for the OpenAPI spec and the agent skill. */
export const PartyForApiSchema = z.object({
id: z.string().uuid(),
display_name: z.string(),
legal_name: z.string().nullable(),
org_number: z.string().nullable(),
vat_number: z.string().nullable(),
country: z.string().nullable(),
kind: z.string(),
status: z.enum(['confirmed', 'suggested']),
roles: z.object({ supplier_id: z.string().uuid().nullable(), customer_id: z.string().uuid().nullable() }),
registry: z
.object({
legal_name: z.string().nullable(),
legal_form: z.string().nullable(),
status: z.object({ label: z.string(), active: z.boolean() }).nullable(),
warning: z.string().nullable(),
registrations: z.object({ f_tax: z.boolean().nullable(), vat: z.boolean().nullable(), employer: z.boolean().nullable() }),
industry: z.object({ code: z.string(), label: z.string() }).nullable(),
seat: z.string().nullable(),
registered_at: z.string().nullable(),
active_since: z.string().nullable(),
active_until: z.string().nullable(),
employees_band: z.string().nullable(),
turnover: z.object({ band: z.string(), year: z.string().nullable() }).nullable(),
workplaces: z.number().nullable(),
contact: z.object({
email: z.string().nullable(),
phone: z.string().nullable(),
address: z.object({ co: z.string().nullable(), street: z.string().nullable(), postal_code: z.string().nullable(), city: z.string().nullable() }).nullable(),
}),
vat_number: z.string().nullable(),
fetched_at: z.string().nullable(),
})
.nullable(),
ledger: z
.object({
occurrences: z.number(),
expense_sek: z.number(),
revenue_sek: z.number(),
first_seen: z.string().nullable(),
last_seen: z.string().nullable(),
dominant_account: z.string().nullable(),
})
.nullable(),
identities: z.array(z.object({ scheme: z.string(), value: z.string(), status: z.string(), seen_count: z.number() })),
})
/** The party behind a row, for ?expand=party: null when the row has none or it was dismissed. */
export async function expandParty(supabase: SupabaseClient, companyId: string, partyId: string | null): Promise<PartyForApi | null> {
if (!partyId) return null
const dossier = await getDossier(supabase, companyId, partyId)
return dossier ? partyForApi(dossier) : null
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Parties: the register's facts as one summary.
*
* SCB answers with twenty-odd coded columns. Three surfaces need them as
* a few plain values: the Företagsuppgifter block on a supplier or customer
* page, the v1 REST `party` expansion, and the MCP party tool. One reading
* of the facts, so the three cannot drift. Pure: facts in, summary out.
*/
export interface RegistryFactLike {
field: string
value: unknown
source: string
fetchedAt?: string | null
}
export interface RegistryAddress {
co: string | null
street: string | null
postal_code: string | null
city: string | null
}
export interface RegistrySummary {
legal_name: string | null
legal_form: string | null
/** SCB's company status: label and whether it means "active". */
status: { label: string; active: boolean } | null
/** Bolagsverket status only when it is a warning (likvidation, konkurs, ...). */
warning: string | null
registrations: { f_tax: boolean | null; vat: boolean | null; employer: boolean | null }
industry: { code: string; label: string } | null
seat: string | null
registered_at: string | null
active_since: string | null
active_until: string | null
employees_band: string | null
turnover: { band: string; year: string | null } | null
workplaces: number | null
contact: { email: string | null; phone: string | null; address: RegistryAddress | null }
vat_number: string | null
fetched_at: string | null
}
type Coded = { code?: unknown; label?: unknown; warning?: unknown; year?: unknown }
function coded(value: unknown): Coded | null {
return value && typeof value === 'object' ? (value as Coded) : null
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
/** Registered under SCB's coding: 1 registered, 3 via representant; 0 never, 9 deregistered. */
function registered(value: unknown): boolean | null {
const code = text(coded(value)?.code)
if (code === null) return null
return code === '1' || code === '2' || code === '3'
}
/** The registry facts of a party as one summary, or null when the register has said nothing. */
export function registrySummary(facts: RegistryFactLike[]): RegistrySummary | null {
const scb = facts.filter((f) => f.source === 'registry_scb')
if (scb.length === 0) return null
const get = (field: string) => scb.find((f) => f.field === field)?.value
const status = coded(get('company_status'))
const bolagsverket = coded(get('bolagsverket_status'))
const industry = coded(get('industry'))
const seat = get('seat') as { municipality?: string | null; county?: string | null } | undefined
const turnover = coded(get('turnover_band'))
const address = get('postal_address') as Partial<RegistryAddress> | undefined
const fetchedAt = scb.map((f) => f.fetchedAt ?? null).filter((d): d is string => !!d).sort().at(-1) ?? null
return {
legal_name: text(get('legal_name')),
legal_form: text(coded(get('legal_form'))?.label),
status: status ? { label: text(status.label) ?? '', active: text(status.code) === '1' } : null,
warning: bolagsverket?.warning === true ? (text(bolagsverket.label) ?? null) : null,
registrations: { f_tax: registered(get('f_tax')), vat: registered(get('vat_registration')), employer: registered(get('employer_registration')) },
industry: industry && text(industry.label) ? { code: text(industry.code) ?? '', label: text(industry.label) ?? '' } : null,
seat: seat ? ([seat.municipality, seat.county].filter((x, i, arr): x is string => !!x && (i === 0 || x !== arr[0])).join(', ') || null) : null,
registered_at: text(get('registered_at')),
active_since: text(get('active_since')),
active_until: text(get('active_until')),
employees_band: text(coded(get('employees_band'))?.label),
turnover: turnover && text(turnover.label) ? { band: text(turnover.label) ?? '', year: text(turnover.year) } : null,
workplaces: typeof get('workplaces') === 'number' ? (get('workplaces') as number) : null,
contact: {
email: text(get('email')),
phone: text(get('phone')),
address:
address && (text(address.street) || text(address.postal_code) || text(address.city))
? { co: text(address.co), street: text(address.street), postal_code: text(address.postal_code), city: text(address.city) }
: null,
},
vat_number: text(get('vat_number')),
fetched_at: fetchedAt,
}
}
/**
* How a registry address lands on a supplier or customer row. The c/o line
* goes first, as Swedish post wants it; the street follows.
*/
export function addressRowsFromRegistry(address: RegistryAddress): { address_line1: string | null; address_line2: string | null; postal_code: string | null; city: string | null } {
return address.co
? { address_line1: address.co, address_line2: address.street, postal_code: address.postal_code, city: address.city }
: { address_line1: address.street, address_line2: null, postal_code: address.postal_code, city: address.city }
}
export interface ContactRow {
email: string | null
phone: string | null
address_line1: string | null
address_line2: string | null
postal_code: string | null
city: string | null
}
const norm = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
/**
* Which contact fields to write on a row after a fetch. A field is filled
* when it is empty, or when it still carries what the register said last
* time (the person never touched it) and the register now says something
* else. A value a person typed is never replaced.
*/
export function contactFill(row: ContactRow, now: RegistrySummary['contact'], before: RegistrySummary['contact'] | null): Partial<ContactRow> {
const out: Partial<ContactRow> = {}
const untouched = (current: string | null, previous: string | null | undefined) => !norm(current) || (previous != null && norm(current) === norm(previous))
if (now.email && untouched(row.email, before?.email) && norm(row.email) !== norm(now.email)) out.email = now.email
if (now.phone && untouched(row.phone, before?.phone) && norm(row.phone) !== norm(now.phone)) out.phone = now.phone
if (now.address) {
const next = addressRowsFromRegistry(now.address)
const prev = before?.address ? addressRowsFromRegistry(before.address) : null
const addressUntouched =
untouched(row.address_line1, prev?.address_line1) &&
untouched(row.address_line2, prev?.address_line2) &&
untouched(row.postal_code, prev?.postal_code) &&
untouched(row.city, prev?.city)
const changed = (['address_line1', 'address_line2', 'postal_code', 'city'] as const).some((k) => norm(row[k]) !== norm(next[k]))
if (addressUntouched && changed) Object.assign(out, next)
}
return out
}
/** True when the row's value is what the register said: shown as "från SCB". */
export function fromRegistry(rowValue: string | null | undefined, registryValue: string | null | undefined): boolean {
return !!norm(rowValue) && norm(rowValue) === norm(registryValue)
}
+15
View File
@@ -8707,6 +8707,21 @@
"facts_none_org": "Nothing from the company register yet. Fetch them by org number.",
"facts_foreign": "Foreign company ({country}), not in the SCB register. Name and VAT number come from the documents.",
"facts_renamed_title": "Details fetched, now called {name}",
"facts_status_line": "{form} · {status}",
"facts_registered_for": "Registered for {items}",
"facts_reg_f_tax": "F-tax",
"facts_reg_vat": "VAT",
"facts_reg_employer": "employer",
"facts_not_registered": "Not registered for F-tax or VAT",
"facts_industry": "Industry",
"facts_seat": "Seat",
"facts_seat_registered": "{seat} · registered {date}",
"facts_size": "Size",
"facts_workplaces": "{count, plural, one {1 workplace} other {# workplaces}}",
"facts_status": "Status",
"facts_from_registry": "from SCB",
"facts_filled_title": "Contact details filled in from SCB",
"facts_filled_description": "{fields}. Edit freely; what you type is not replaced on the next fetch.",
"fact_trade_name": "Trade name",
"open_dossier": "Open {name}",
"attn_create": "Create suggestions",
+15
View File
@@ -8707,6 +8707,21 @@
"facts_none_org": "Inga uppgifter från företagsregistret än. Hämta dem med org.nr.",
"facts_foreign": "Utländskt bolag ({country}), finns inte i SCB:s register. Namn och momsnummer kommer från underlagen.",
"facts_renamed_title": "Uppgifter hämtade, heter nu {name}",
"facts_status_line": "{form} · {status}",
"facts_registered_for": "Registrerad för {items}",
"facts_reg_f_tax": "F-skatt",
"facts_reg_vat": "moms",
"facts_reg_employer": "arbetsgivare",
"facts_not_registered": "Ej registrerad för F-skatt eller moms",
"facts_industry": "Bransch",
"facts_seat": "Säte",
"facts_seat_registered": "{seat} · registrerat {date}",
"facts_size": "Storlek",
"facts_workplaces": "{count, plural, one {1 arbetsställe} other {# arbetsställen}}",
"facts_status": "Status",
"facts_from_registry": "från SCB",
"facts_filled_title": "Kontaktuppgifter ifyllda från SCB",
"facts_filled_description": "{fields}. Ändra fritt; det du skriver ersätts inte vid nästa hämtning.",
"fact_trade_name": "Firma",
"open_dossier": "Öppna {name}",
"attn_create": "Skapa förslag",
+6 -2
View File
@@ -101,7 +101,7 @@ Returns active customers in created-first order. Pass ?include_archived=true to
Response `200`:
```ts
{
data: { id: string, name: string, customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", email: string, org_number: string, vat_number: string, default_payment_terms: number, archived_at: string, created_at: string }[],
data: { id: string, name: string, customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", email: string, org_number: string, vat_number: string, default_payment_terms: number, party_id?: string, archived_at: string, created_at: string }[],
meta: {
request_id: string,
api_version: string,
@@ -266,7 +266,7 @@ Example response `200`:
**Retrieve a single customer by id.**
`scope:customers:read · risk:low · idempotent`
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.
**Use when:** You need the full customer record: address, payment terms, VAT validation status, contact details: before invoicing or syncing to another system.
**Do not use for:** Listing customers (use the list endpoint). Looking up arbitrary supplier or employee records (different resources).
@@ -305,6 +305,8 @@ Response `200`:
personal_number: string,
default_payment_terms: number,
notes: string,
party_id: string,
party?: { id: string, display_name: string, legal_name: string, org_number: string, vat_number: string, country: string, kind: string, status: "confirmed" | "suggested", roles: { supplier_id: string, customer_id: string }, registry: { legal_name: string, legal_form: string, status: { label: string, active: boolean }, warning: string, registrations: { f_tax: boolean, vat: boolean, employer: boolean }, industry: { code: string, label: string }, seat: string, registered_at: string, active_since: string, active_until: string, employees_band: string, turnover: { band: string, year: string }, workplaces: number, contact: { email: string, phone: string, address: { co: string, street: string, postal_code: string, city: string } }, vat_number: string, fetched_at: string }, ledger: { occurrences: number, expense_sek: number, revenue_sek: number, first_seen: string, last_seen: string, dominant_account: string }, identities: { scheme: string, value: string, status: string, seen_count: number }[] },
archived_at: string,
created_at: string,
updated_at: string
@@ -425,6 +427,8 @@ Response `200`:
personal_number: string,
default_payment_terms: number,
notes: string,
party_id: string,
party?: { id: string, display_name: string, legal_name: string, org_number: string, vat_number: string, country: string, kind: string, status: "confirmed" | "suggested", roles: { supplier_id: string, customer_id: string }, registry: { legal_name: string, legal_form: string, status: { label: string, active: boolean }, warning: string, registrations: { f_tax: boolean, vat: boolean, employer: boolean }, industry: { code: string, label: string }, seat: string, registered_at: string, active_since: string, active_until: string, employees_band: string, turnover: { band: string, year: string }, workplaces: number, contact: { email: string, phone: string, address: { co: string, street: string, postal_code: string, city: string } }, vat_number: string, fetched_at: string }, ledger: { occurrences: number, expense_sek: number, revenue_sek: number, first_seen: string, last_seen: string, dominant_account: string }, identities: { scheme: string, value: string, status: string, seen_count: number }[] },
archived_at: string,
created_at: string,
updated_at: string
+6 -2
View File
@@ -606,7 +606,7 @@ Returns active suppliers in created-first order. Pass ?include_archived=true to
Response `200`:
```ts
{
data: { id: string, name: string, supplier_type: "swedish_business" | "eu_business" | "non_eu_business", email: string, org_number: string, vat_number: string, default_payment_terms: number, default_currency: string, archived_at: string, created_at: string }[],
data: { id: string, name: string, supplier_type: "swedish_business" | "eu_business" | "non_eu_business", email: string, org_number: string, vat_number: string, default_payment_terms: number, default_currency: string, party_id?: string, archived_at: string, created_at: string }[],
meta: {
request_id: string,
api_version: string,
@@ -778,7 +778,7 @@ Example response `200`:
**Retrieve a single supplier by id.**
`scope:suppliers:read · risk:low · idempotent`
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.
**Use when:** 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.
**Do not use for:** Listing suppliers (use the list endpoint). Looking up customer or employee records (different resources).
@@ -817,6 +817,8 @@ Response `200`:
default_payment_terms: number,
default_currency: string,
notes: string,
party_id: string,
party?: { id: string, display_name: string, legal_name: string, org_number: string, vat_number: string, country: string, kind: string, status: "confirmed" | "suggested", roles: { supplier_id: string, customer_id: string }, registry: { legal_name: string, legal_form: string, status: { label: string, active: boolean }, warning: string, registrations: { f_tax: boolean, vat: boolean, employer: boolean }, industry: { code: string, label: string }, seat: string, registered_at: string, active_since: string, active_until: string, employees_band: string, turnover: { band: string, year: string }, workplaces: number, contact: { email: string, phone: string, address: { co: string, street: string, postal_code: string, city: string } }, vat_number: string, fetched_at: string }, ledger: { occurrences: number, expense_sek: number, revenue_sek: number, first_seen: string, last_seen: string, dominant_account: string }, identities: { scheme: string, value: string, status: string, seen_count: number }[] },
archived_at: string,
created_at: string,
updated_at: string
@@ -939,6 +941,8 @@ Response `200`:
default_payment_terms: number,
default_currency: string,
notes: string,
party_id: string,
party?: { id: string, display_name: string, legal_name: string, org_number: string, vat_number: string, country: string, kind: string, status: "confirmed" | "suggested", roles: { supplier_id: string, customer_id: string }, registry: { legal_name: string, legal_form: string, status: { label: string, active: boolean }, warning: string, registrations: { f_tax: boolean, vat: boolean, employer: boolean }, industry: { code: string, label: string }, seat: string, registered_at: string, active_since: string, active_until: string, employees_band: string, turnover: { band: string, year: string }, workplaces: number, contact: { email: string, phone: string, address: { co: string, street: string, postal_code: string, city: string } }, vat_number: string, fetched_at: string }, ledger: { occurrences: number, expense_sek: number, revenue_sek: number, first_seen: string, last_seen: string, dominant_account: string }, identities: { scheme: string, value: string, status: string, seen_count: number }[] },
archived_at: string,
created_at: string,
updated_at: string