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:
@@ -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.
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user