diff --git a/DECISIONS.md b/DECISIONS.md index 038f5f39..12cf451d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1600,3 +1600,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index d0a46574..b09b6f02 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -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 = { 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(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 ? {tParties('facts_from_registry')} : null) const [isLoading, setIsLoading] = useState(true) const [isEditOpen, setIsEditOpen] = useState(false) const [isUpdating, setIsUpdating] = useState(false) @@ -264,8 +271,12 @@ export default function CustomerDetailPage({ ) : ( )} + {scbNote(fromRegistry(customer.email, party.registry?.contact.email))} + + + {customer.phone || } + {scbNote(fromRegistry(customer.phone, party.registry?.contact.phone))} - {customer.phone || } {customer.address_line1 || customer.city ? (
@@ -274,6 +285,11 @@ export default function CustomerDetailPage({ {(customer.postal_code || customer.city) && (

{[customer.postal_code, customer.city].filter(Boolean).join(' ')}

)} + {scbNote( + !!registryAddress && + fromRegistry(customer.address_line1, registryAddress.address_line1) && + fromRegistry(customer.city, registryAddress.city), + )} {customer.country &&

{getCountryName(customer.country, errorLocale)}

}
) : ( @@ -282,8 +298,19 @@ export default function CustomerDetailPage({
- {(customer as { party_id?: string | null }).party_id && customer.customer_type !== 'individual' ? ( - void fetchCustomer()} /> + {partyId && party.dossier ? ( + { + await party.reload() + await fetchCustomer() + }} + /> ) : null} diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index c9d744f7..84eda383 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -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(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 ? {tParties('facts_from_registry')} : null) const [invoices, setInvoices] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isEditOpen, setIsEditOpen] = useState(false) @@ -197,7 +205,7 @@ export default function SupplierDetailPage() {

{supplier.name}

{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) })}` : ''}

@@ -264,13 +272,30 @@ export default function SupplierDetailPage() { - {(supplier as { party_id?: string | null }).party_id ? ( - void fetchSupplier()} /> + {partyId && party.dossier ? ( + { + await party.reload() + await fetchSupplier() + }} + /> ) : null} - {supplier.email || } - {supplier.phone || } + + {supplier.email || } + {scbNote(fromRegistry(supplier.email, party.registry?.contact.email))} + + + {supplier.phone || } + {scbNote(fromRegistry(supplier.phone, party.registry?.contact.phone))} + {supplier.address_line1 || supplier.city ? (
@@ -279,12 +304,22 @@ export default function SupplierDetailPage() { {(supplier.postal_code || supplier.city) && (

{[supplier.postal_code, supplier.city].filter(Boolean).join(' ')}

)} + {scbNote( + !!registryAddress && + fromRegistry(supplier.address_line1, registryAddress.address_line1) && + fromRegistry(supplier.city, registryAddress.city), + )}
) : ( )}
- {supplier.vat_number && {supplier.vat_number}} + {supplier.vat_number && ( + + {supplier.vat_number} + {scbNote(fromRegistry(supplier.vat_number, party.registry?.vat_number))} + + )}
diff --git a/app/api/parties/[id]/enrich/__tests__/route.test.ts b/app/api/parties/[id]/enrich/__tests__/route.test.ts index d130ba95..56cdceb5 100644 --- a/app/api/parties/[id]/enrich/__tests__/route.test.ts +++ b/app/api/parties/[id]/enrich/__tests__/route.test.ts @@ -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; 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 diff --git a/app/api/parties/[id]/enrich/route.ts b/app/api/parties/[id]/enrich/route.ts index 7ababf59..8aff4600 100644 --- a/app/api/parties/[id]/enrich/route.ts +++ b/app/api/parties/[id]/enrich/route.ts @@ -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 = {} + 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) { + 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> 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 }, diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 8c36820a..6db7205a 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/customers/route.ts b/app/api/v1/companies/[companyId]/customers/route.ts index 7b3857f2..905f463a 100644 --- a/app/api/v1/companies/[companyId]/customers/route.ts +++ b/app/api/v1/companies/[companyId]/customers/route.ts @@ -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', diff --git a/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts b/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts index 763be86e..00917ec2 100644 --- a/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/[id]/route.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/suppliers/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/suppliers/__tests__/route.test.ts index b0e4e693..bab38f19 100644 --- a/app/api/v1/companies/[companyId]/suppliers/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/suppliers/__tests__/route.test.ts @@ -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('@/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( diff --git a/app/api/v1/companies/[companyId]/suppliers/route.ts b/app/api/v1/companies/[companyId]/suppliers/route.ts index 8070ce17..48ee518a 100644 --- a/app/api/v1/companies/[companyId]/suppliers/route.ts +++ b/app/api/v1/companies/[companyId]/suppliers/route.ts @@ -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', diff --git a/components/parties/PartyFactsSection.tsx b/components/parties/PartyFactsSection.tsx index 70a6692d..ec7a504d 100644 --- a/components/parties/PartyFactsSection.tsx +++ b/components/parties/PartyFactsSection.tsx @@ -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 }) { const t = useTranslations('parties') const locale = useLocale() const { toast } = useToast() - const [dossier, setDossier] = useState(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 } 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 ? ( ) : canFind ? (