feat(parties): Förslag från bokföringen, confirmed straight into Leverantörer and Kunder (#2206)

* feat(parties): Kontakter register, suggestion queue, dossier and merge

Phase 1's two surfaces on top of the parties substrate:

- /parties page: one list with the five-way switch (Alla, Kunder,
  Leverantörer, Förslag, Bara i bokföringen), search, a 12-month/all
  period picker, and at most one attention line. Confirmed rows show
  roles as muted text, rhythm, underlag, dominant account and money.
  Observed rows are computed and never stored; a generic band keeps
  unattributed spend visible.
- Suggestion queue: a reason per row, hard-key rows pre-ticked, bulk
  confirm behind one dialog, dismiss on hover, undo on the toast.
- Dossier slide-over: Pengar, Bokföring, Vad Accounted vet (facts and
  identities with source and count), Underlag och verifikat, Historik.
- Merge dialog with a visible, swappable survivor and undo.
- API: GET /api/parties, GET /api/parties/[id], POST suggest, decide,
  decide/undo, merge, merge/undo (withRouteContext, Zod, 15 tests).
- Migration 20260903090000: decide_parties snapshots the reason it
  clears; undo_party_decisions reverses confirm/dismiss within 30 days;
  decision kind 'undo'.
- The pipeline runs after SIE import and provider migration (non-blocking)
  so a migrant's register is full on arrival.
- Nav entry under Register; sv/en strings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): pass explicit interpolation values to next-intl

next build's type check rejects a typed interface where the translator
wants an index-signature record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): retry label on the load-failed state

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): hard keys for companies without org number, readable names, look-alikes at read time

- get_ledger_key_evidence dropped every document for a company whose own
  org number is NULL (the self check compared against NULL). Replaced in
  20260903100000 with a coalesced comparison; pg test covers it.
- Display names come from the printed name on documents, otherwise from
  the voucher text with the AP/AR prefix and supplier number removed.
- Look-alike parties (same core, or one core extending the other by whole
  words: Fortnox / Fortnox Finans) are detected when the register is read,
  never stored, and feed the Dubblett? chip and the merge dialog.
- Queue shows Intäkt beside Kostnad; dossier hides zero money rows and
  formats bankgiro/plusgiro; merge dialog cancels with Avbryt; no
  synchronous setState inside effects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(parties): link every new supplier and customer to a party on write

The backfill covered the rows that existed on 2026-09-02; 108 rows
created since had no party and never reached the register. A BEFORE
INSERT/UPDATE trigger on customers and suppliers now calls ensure_party
on every write path at once: find-or-create by org number inside the
company, never by name; a private customer gets a kind=person party
without any number; a nameless row stays unlinked; a foreign party id is
refused with the same error as the composite foreign key; a link to a
merged party follows the chain to the survivor; the clear that ON DELETE
SET NULL performs is kept. ensure_party lets the trigger act for the
row's owner (pg_trigger_depth() > 0); the RPC path is unchanged. The
migration also links the rows created since the backfill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): dossier hides dismissed parties and follows merges to the survivor

The register hid archived parties while the dossier still served them by
id, and a merged party's dossier pointed at a dead row. Superagent P2 on
#2206; three unit tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(parties): move the role-link migration past main's 20260903110000

Two files with one version would collide in schema_migrations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(parties): confirm suggestions into Leverantörer and Kunder, no third noun

Founder decision after the walkthrough: users know two words. The page
becomes the queue 'Förslag från bokföringen' with 'Bara i bokföringen'
beside it; the Kontakter nav entry and the Alla/Kunder/Leverantörer
views go. Each suggestion shows what it becomes (Blir), read from the
ledger side and changeable per row; confirming calls promote_parties,
which creates the supplier and/or customer row from the party's facts,
never a duplicate, and is undoable for 30 days through
undo_party_promotions (the created rows are archived, the party returns
to the queue). Leverantörer and Kunder carry the one attention line that
leads here. The dossier offers Lägg upp som leverantör / som kund.

Migration 20260903130000, 5 pg tests, route and unit tests updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): write bankgiro and plusgiro the way the supplier form does

Identities are stored as digits; suppliers carry 5317-0900.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(parties): move the four queue migrations past main's 20260903170000

Main merged 20260903120000_skattekonto_transactions_realtime_publication
with the same version as the role-link trigger; the preview database
refused the duplicate key. All four now sit after main's newest so the
set applies in one ordered run on prod.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 19:49:25 +02:00
committed by GitHub
parent e2d38b0ab3
commit b996da60ee
40 changed files with 3568 additions and 12 deletions
+4
View File
@@ -1520,11 +1520,15 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-02] Stock GoTrue email-change redirects detected via a flow=email_change marker on emailRedirectTo rather than by sniffing ?message= / ?code= on every callback: GoTrue's PKCE redirect carries no type, and the same marker rides along on hook-built token_hash links, so one flag covers both link styles without touching signup/recovery/OAuth paths.
[2026-09-02] Sign-off refusals: registered the ReconciliationSignoffError codes in structured-errors with a new thrown_message_sv flag instead of returning err.message from the routes: check:guards forbids raw caught-error messages in user-visible sinks, and the registry keeps the codes discoverable for agents while the dialog still gets the runtime text (dates, amounts).
[2026-09-02] Nyckeltal "Resultat per månad" shows the exact per-month figures as an always-on list under the bars (#2198), not behind an "Anpassa" toggle: a preference would touch the type, the PUT schema, the strict preferences-body validator, the dialog and its tests for a switch nobody turns off. Per-bar compact labels are conditional on a glyph-width fit rule and fall back to the single latest label, so they never overlap. Left alone: the monthly path counts only posted entries while the year-total path also counts reversed originals (pinned as intended in tests/pg/kpi-report-aggregates-rpc.pg.test.ts), so a same-year storno makes the sum of months differ from Nettoresultat; visible as numbers now, founder call whether to align the two.
[2026-09-03] Kontakter register reads money and rhythm through parties.alias_keys joined to get_observed_parties rather than a stored per-party rollup: the numbers stay identical to the import reveal and nothing is written on read; dismissed parties keep their keys claimed so a hidden suggestion never resurfaces under Bara i bokföringen
[2026-09-03] Email-change double-submit gate is a dedicated per-user claim table + SECURITY DEFINER RPC (migration 20260903083000), not idempotency_keys and not an advisory lock: idempotency_keys requires a company_id the account-level route does not have, and a transaction-scoped advisory lock cannot cover the GoTrue call that happens outside the transaction.
[2026-09-03] Suppliers and customers get their party from a BEFORE INSERT/UPDATE trigger (link_party_on_role_write), not from each write path: 108 rows created in the day after the backfill had no party, and dialogs, v1 API, MCP, imports and provider migrations all write these tables. Private customers get a kind=person party with no number at all, so a personnummer never reaches parties
[2026-09-03] Kundorder PGRST201 fixed by hinting the three sales_order_items embeds AND by teaching scripts/checks/ambiguous-embed.mjs to parse composite FOREIGN KEY (a, b) constraints, instead of a bespoke source-scan test: the guard is the sanctioned owner of this class (decision 2026-09-01) and it missed the pair only because its parser was single-column; with the fix it derives the same 17 pairs prod reports and flags all three shipped sites on main.
[2026-09-03] Email-change double-submit gate is a dedicated per-user claim table + SECURITY DEFINER RPC (migration 20260903083000), not idempotency_keys and not an advisory lock: idempotency_keys requires a company_id the account-level route does not have, and a transaction-scoped advisory lock cannot cover the GoTrue call that happens outside the transaction.
[2026-09-03] Cursor MCP OAuth callbacks are built-in allowlist entries, exact-matched: cursor://anysphere.cursor-mcp/oauth/callback and https://www.cursor.com/agents/mcp/oauth/callback (the loopback http://localhost:8787/callback already passes the local rule). Cursor's DCR sends all three in one request and /register rejects the whole set on any unknown URI, so the Cursor path advertised in Settings never worked (reported by a byrå user 2026-09-03). Exact match, not a cursor.com prefix, so no other cursor.com path can receive codes; the custom scheme is accepted despite RFC 8252 section 8.4 because the code is PKCE-bound and the loopback form carries the same local-machine trust. Users cannot self-register the cursor:// form (the settings panel requires https), which is why it is built in. Grok Bot rides on Cursor's MCP stack and stays broken on Cursor's side (forum thread 168052, open as of 2026-09-02); nothing server-side fixes that.
[2026-09-03] Skattekonto through Connect = the existing data proxy plus CONNECT_SKV_CANARY_COMPANIES, not a separate sync operation: the provider logic is two GETs and the dedup keys stay on the ledger; system (certificate) auth is still not brokered because hosted has no certificate configured, so every hosted skattekonto read is a user-token call the proxy already carries.
[2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact.
[2026-09-03] Kontakter is not a user-facing noun (founder, after the register walkthrough): the registers people see stay Leverantörer and Kunder, the new page is the queue 'Förslag från bokföringen' plus 'Bara i bokföringen', and confirming a suggestion creates the leverantör or kund row directly (promote_parties). A confirmed party with no role never appears in the UI. The party model underneath is unchanged
[2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191.
[2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client.
[2026-09-03] settleInvoicePayment writes the invoice_payments row BEFORE the CAS status update and removes it in both failure branches, instead of inserting after the update: the kontantmetod cut-off reads invoice_payments only, so a paid invoice without a row is the #2019 defect itself; failing closed on the insert (voucher storno + INVOICE_PAID_BOOK_FAILED) keeps GL, sub-ledger and invoice status in step. The #2019 backfill inserts only where exactly one posted payment voucher exists (invoice_paid / invoice_cash_payment with source_id = invoice); zero or several vouchers are reported, never guessed, and every row is tagged backfill:#2019 in notes so one DELETE reverts a run.
+2
View File
@@ -20,6 +20,7 @@ import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { SuggestionsAttn } from '@/components/parties/SuggestionsAttn'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
import { customerListIdentifier } from '@/lib/customers/mask-personal-number'
@@ -287,6 +288,7 @@ function CustomersPageInner() {
</Dialog>
</div>
</div>
<SuggestionsAttn side="customer" />
{/* Toolbar: search (concept) */}
<div className="flex flex-wrap items-center gap-2">
+412
View File
@@ -0,0 +1,412 @@
'use client'
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Lock } from 'lucide-react'
import { ContextPicker, type ContextPickerItem } from '@/components/common/ContextPicker'
import { AttnLine } from '@/components/ui/attn-line'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { EmptyState } from '@/components/ui/empty-state'
import { HelpPopover } from '@/components/ui/help-popover'
import { PageHeader } from '@/components/ui/page-header'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Skeleton } from '@/components/ui/skeleton'
import { ToastAction } from '@/components/ui/toast'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { useToast } from '@/components/ui/use-toast'
import { MergeDialog, type MergeCandidate } from '@/components/parties/MergeDialog'
import { ObservedTable } from '@/components/parties/ObservedTable'
import { PartyDossier } from '@/components/parties/PartyDossier'
import { SuggestionQueue } from '@/components/parties/SuggestionQueue'
import { hasHardKey } from '@/components/parties/format'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { PartyRole, Register, RegisterPeriod, RegisterRow, RegisterView } from '@/lib/parties/register'
const VIEWS: RegisterView[] = ['suggested', 'observed']
async function post<T>(url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!res.ok) throw new Error(String(res.status))
const json = (await res.json()) as { data: T }
return json.data
}
function roleSummary(t: (k: string, v?: Record<string, string | number>) => string, items: Array<{ roles: PartyRole[] }>): string {
const suppliers = items.filter((i) => i.roles.includes('supplier')).length
const customers = items.filter((i) => i.roles.includes('customer')).length
const parts: string[] = []
if (suppliers) parts.push(t('summary_suppliers', { count: suppliers }))
if (customers) parts.push(t('summary_customers', { count: customers }))
return parts.join(' · ')
}
/**
* Förslag från bokföringen: the queue in front of Leverantörer and Kunder.
* Nothing here is a register of its own; confirming creates the supplier or
* customer row, and "Bara i bokföringen" shows what the vouchers name that
* nothing owns yet.
*/
function SuggestionsPage() {
const t = useTranslations('parties')
const tCommon = useTranslations('common')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const router = useRouter()
const searchParams = useSearchParams()
const initialView: RegisterView = searchParams.get('view') === 'observed' ? 'observed' : 'suggested'
const [view, setView] = useState<RegisterView>(initialView)
const [query, setQuery] = useState('')
const [debounced, setDebounced] = useState('')
const [period, setPeriod] = useState<RegisterPeriod>('12m')
const [register, setRegister] = useState<Register | null>(null)
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
const [reloadKey, setReloadKey] = useState(0)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [roleOverrides, setRoleOverrides] = useState<Record<string, PartyRole[]>>({})
const [busy, setBusy] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const [dossierId, setDossierId] = useState<string | null>(null)
const [dossierReload, setDossierReload] = useState(0)
const [merge, setMerge] = useState<{ subject: MergeCandidate; suggested: MergeCandidate[] } | null>(null)
const preselected = useRef<string | null>(null)
useEffect(() => {
const id = setTimeout(() => setDebounced(query.trim()), 250)
return () => clearTimeout(id)
}, [query])
useEffect(() => {
let cancelled = false
setLoading(true)
const params = new URLSearchParams({ view, period })
if (debounced) params.set('q', debounced)
fetch(`/api/parties?${params.toString()}`)
.then(async (res) => {
if (!res.ok) throw new Error(String(res.status))
const json = (await res.json()) as { data: Register }
if (cancelled) return
setRegister(json.data)
setFailed(false)
// Only rows with a hard key arrive pre-ticked, once per queue load.
if (view === 'suggested' && preselected.current !== `${reloadKey}:${debounced}`) {
preselected.current = `${reloadKey}:${debounced}`
setSelected(new Set(json.data.rows.filter(hasHardKey).map((r) => r.id)))
}
})
.catch(() => {
if (!cancelled) setFailed(true)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [view, debounced, period, reloadKey])
const reload = useCallback(() => {
setReloadKey((k) => k + 1)
setDossierReload((k) => k + 1)
}, [])
const counts = register?.counts
const viewOptions = useMemo(
() =>
VIEWS.map((v) => ({
value: v,
label: t(`view_${v}`),
count: counts ? counts[v] : undefined,
})),
[counts, t],
)
const periodItems: ContextPickerItem[] = useMemo(
() => [
{ id: '12m', label: t('period_12m') },
{ id: 'all', label: t('period_all') },
],
[t],
)
const fail = useCallback(() => toast({ title: t('action_failed'), variant: 'destructive' }), [toast, t])
const rolesFor = useCallback((row: RegisterRow): PartyRole[] => roleOverrides[row.id] ?? row.defaultRoles, [roleOverrides])
async function refreshSuggestions() {
if (refreshing) return
setRefreshing(true)
try {
const summary = await post<{ created: number; attached: number }>('/api/parties/suggest')
toast({ title: t('refreshed_title'), description: t('refreshed_description', { created: summary.created, attached: summary.attached }) })
reload()
} catch {
fail()
} finally {
setRefreshing(false)
}
}
function undoToast(title: string, undoUrl: string, ids: string[], undoneTitle: string) {
toast({
title,
action: (
<ToastAction
altText={t('undo')}
onClick={() => {
post(undoUrl, { partyIds: ids })
.then(() => {
toast({ title: undoneTitle })
reload()
})
.catch(fail)
}}
>
{t('undo')}
</ToastAction>
),
})
}
async function promote(items: Array<{ partyId: string; roles: PartyRole[] }>) {
if (items.length === 0) return
setBusy(true)
try {
const r = await post<{ parties: number; suppliers: number; customers: number }>('/api/parties/promote', { items })
undoToast(t('promoted_title', { count: r.parties, detail: roleSummary(t, items) }), '/api/parties/promote/undo', items.map((i) => i.partyId), t('undone_title'))
setSelected((prev) => {
const next = new Set(prev)
for (const i of items) next.delete(i.partyId)
return next
})
reload()
} catch {
fail()
} finally {
setBusy(false)
}
}
async function dismiss(ids: string[]) {
if (ids.length === 0) return
setBusy(true)
try {
const { count } = await post<{ count: number }>('/api/parties/decide', { partyIds: ids, kind: 'dismiss' })
undoToast(t('dismissed_title', { count }), '/api/parties/decide/undo', ids, t('undone_title'))
setSelected((prev) => {
const next = new Set(prev)
for (const id of ids) next.delete(id)
return next
})
reload()
} catch {
fail()
} finally {
setBusy(false)
}
}
async function runMerge(survivorId: string, mergedIds: string[]) {
setBusy(true)
try {
const { decisionId } = await post<{ decisionId: string }>('/api/parties/merge', { survivorId, mergedIds })
toast({
title: t('merged_title'),
action: (
<ToastAction
altText={t('undo')}
onClick={() => {
post('/api/parties/merge/undo', { decisionId })
.then(() => {
toast({ title: t('merge_undone_title') })
reload()
})
.catch(fail)
}}
>
{t('undo')}
</ToastAction>
),
})
setMerge(null)
if (dossierId && mergedIds.includes(dossierId)) setDossierId(survivorId)
reload()
} catch {
fail()
} finally {
setBusy(false)
}
}
const rows = register?.rows ?? []
const searching = debounced.length > 0
const selectedItems = rows.filter((r) => selected.has(r.id)).map((r) => ({ partyId: r.id, roles: rolesFor(r) }))
let attn: React.ReactNode = null
if (counts && counts.suggested === 0 && counts.observed > 0 && canWrite && view === 'suggested') {
attn = <AttnLine action={{ label: t('refresh'), onClick: () => void refreshSuggestions() }}>{t('attn_observed', { count: counts.observed })}</AttnLine>
}
function empty() {
if (searching) return <EmptyState title={t('empty_search_title')} description={t('empty_search_description')} />
if (view === 'observed') return <EmptyState title={t('empty_observed_title')} description={t('empty_observed_description')} />
return (
<EmptyState
title={t('empty_suggested_title')}
description={t('empty_suggested_description')}
actionLabel={canWrite ? t('refresh') : undefined}
onAction={canWrite ? () => void refreshSuggestions() : undefined}
secondaryActionLabel={t('go_suppliers')}
secondaryActionHref="/suppliers"
/>
)
}
function content() {
if (failed) return <EmptyState title={t('load_failed')} description={t('action_failed')} actionLabel={tCommon('retry')} onAction={reload} />
if (loading && !register)
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
)
if (!register) return null
if (view === 'observed') {
if (register.observed.length === 0 && register.generic.count === 0) return empty()
return <ObservedTable rows={register.observed} generic={register.generic} />
}
if (rows.length === 0) return empty()
return (
<SuggestionQueue
rows={rows}
selected={selected}
roles={rolesFor}
canWrite={canWrite}
busy={busy}
onToggle={(id) =>
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
onSelectAll={() => setSelected(new Set(rows.map((r) => r.id)))}
onClear={() => setSelected(new Set())}
onRoles={(id, roles) => setRoleOverrides((prev) => ({ ...prev, [id]: roles }))}
onConfirmSelected={() => setConfirmOpen(true)}
onDismiss={(row) => void dismiss([row.id])}
onOpen={setDossierId}
/>
)
}
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
description={counts ? t('summary', { suggested: counts.suggested, observed: counts.observed }) : undefined}
help={
<HelpPopover>
<p>{t('help')}</p>
</HelpPopover>
}
action={
<Button
type="button"
variant="outline"
onClick={() => void refreshSuggestions()}
disabled={!canWrite || refreshing}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" aria-hidden="true" /> : null}
{refreshing ? t('refreshing') : t('refresh')}
</Button>
}
/>
{attn}
<div className="flex flex-wrap items-center gap-2">
<SegmentedControl<RegisterView>
value={view}
onChange={(v) => {
setView(v)
router.replace(v === 'observed' ? '/parties?view=observed' : '/parties', { scroll: false })
}}
options={viewOptions}
/>
<ToolbarSearch value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t('search_placeholder')} aria-label={t('search_placeholder')} />
<div className="ml-auto flex items-center gap-2">
<ContextPicker
items={periodItems}
value={period}
onChange={(id) => setPeriod(id as RegisterPeriod)}
triggerLabel={periodItems.find((i) => i.id === period)?.label ?? t('period_12m')}
ariaLabel={t('period_label')}
/>
</div>
</div>
{content()}
{register && view === 'suggested' && rows.length > 0 ? (
<p className="px-1 text-xs text-muted-foreground tabular-nums">{t('count_summary', { count: rows.length })}</p>
) : null}
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('promote_dialog_title', { count: selectedItems.length })}
description={t('promote_dialog_body', { detail: roleSummary(t, selectedItems) })}
confirmLabel={t('promote_n', { count: selectedItems.length })}
onConfirm={async () => {
setConfirmOpen(false)
await promote(selectedItems)
}}
/>
<PartyDossier
partyId={dossierId}
period={period}
canWrite={canWrite}
busy={busy}
reloadKey={dossierReload}
onClose={() => setDossierId(null)}
onPromote={(id, roles) => void promote([{ partyId: id, roles }])}
onDismiss={(id) => {
void dismiss([id])
setDossierId(null)
}}
onMerge={(subject, suggested) => setMerge({ subject, suggested })}
/>
{merge ? (
<MergeDialog
open
onOpenChange={(open) => (!open ? setMerge(null) : undefined)}
subject={merge.subject}
suggested={merge.suggested}
busy={busy}
onMerge={runMerge}
/>
) : null}
</div>
)
}
export default function PartiesPage() {
return (
<Suspense fallback={null}>
<SuggestionsPage />
</Suspense>
)
}
+2
View File
@@ -18,6 +18,7 @@ import Link from 'next/link'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { SuggestionsAttn } from '@/components/parties/SuggestionsAttn'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const SupplierForm = dynamic(
@@ -170,6 +171,7 @@ export default function SuppliersPage() {
</Dialog>
</div>
</div>
<SuggestionsAttn side="supplier" />
{/* Toolbar: search (concept) */}
<div className="flex flex-wrap items-center gap-2">
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getDossier } from '@/lib/parties/register'
/** GET /api/parties/[id]: the dossier for one party (facts, identities, decisions, vouchers). */
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'parties.get',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
if (!/^[0-9a-f-]{36}$/i.test(id)) return errorResponseFromCode('NOT_FOUND', log, { requestId })
const dossier = await getDossier(supabase, companyId, id)
if (!dossier) return errorResponseFromCode('NOT_FOUND', log, { requestId })
return NextResponse.json({ data: dossier })
},
)
+263
View File
@@ -0,0 +1,263 @@
/**
* /api/parties: the Kontakter register and its write actions. The read model
* and the pipeline are unit-tested in lib/parties; here the routes are
* checked for auth, validation and the shape they hand the page.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const getRegister = vi.fn()
const getDossier = vi.fn()
vi.mock('@/lib/parties/register', () => ({
getRegister: (...args: unknown[]) => getRegister(...args),
getDossier: (...args: unknown[]) => getDossier(...args),
}))
const suggestPartiesForCompany = vi.fn()
vi.mock('@/lib/parties/suggest', () => ({
suggestPartiesForCompany: (...args: unknown[]) => suggestPartiesForCompany(...args),
}))
import { GET as listGet } from '../route'
import { GET as dossierGet } from '../[id]/route'
import { POST as suggestPost } from '../suggest/route'
import { POST as decidePost } from '../decide/route'
import { POST as undoDecidePost } from '../decide/undo/route'
import { POST as mergePost } from '../merge/route'
import { POST as undoMergePost } from '../merge/undo/route'
import { POST as promotePost } from '../promote/route'
import { POST as undoPromotePost } from '../promote/undo/route'
const user = { id: 'user-1', email: 'test@test.se' }
const noParams = { params: Promise.resolve({}) }
const PARTY = '11111111-1111-4111-8111-111111111111'
const OTHER = '22222222-2222-4222-8222-222222222222'
const DECISION = '33333333-3333-4333-8333-333333333333'
function json(url: string, body: unknown) {
return createMockRequest(url, { method: 'POST', body })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user } })
})
describe('GET /api/parties', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const { status } = await parseJsonResponse(await listGet(createMockRequest('/api/parties'), noParams))
expect(status).toBe(401)
})
it('rejects an unknown view with 400', async () => {
const { status } = await parseJsonResponse(await listGet(createMockRequest('/api/parties?view=nope'), noParams))
expect(status).toBe(400)
expect(getRegister).not.toHaveBeenCalled()
})
it('passes view, query and period to the read model for the active company', async () => {
const register = { counts: { suggested: 1, suggestedSuppliers: 1, suggestedCustomers: 0, observed: 0, confirmed: 3 }, rows: [], observed: [], generic: { count: 0, expenseSek: 0, examples: [] }, period: 'all' }
getRegister.mockResolvedValue(register)
const { status, body } = await parseJsonResponse<{ data: typeof register }>(
await listGet(createMockRequest('/api/parties?view=observed&q=beijer&period=all'), noParams),
)
expect(status).toBe(200)
expect(body.data).toEqual(register)
expect(getRegister).toHaveBeenCalledWith(mockSupabase, 'company-1', { view: 'observed', q: 'beijer', period: 'all' })
})
it('no longer accepts the register views that moved to Leverantörer and Kunder', async () => {
const { status } = await parseJsonResponse(await listGet(createMockRequest('/api/parties?view=suppliers'), noParams))
expect(status).toBe(400)
})
})
describe('GET /api/parties/[id]', () => {
it('returns 404 for a party that is not in the company', async () => {
getDossier.mockResolvedValue(null)
const { status } = await parseJsonResponse(
await dossierGet(createMockRequest(`/api/parties/${PARTY}`), { params: Promise.resolve({ id: PARTY }) }),
)
expect(status).toBe(404)
})
it('returns 404 for a malformed id without touching the database', async () => {
const { status } = await parseJsonResponse(
await dossierGet(createMockRequest('/api/parties/not-a-uuid'), { params: Promise.resolve({ id: 'not-a-uuid' }) }),
)
expect(status).toBe(404)
expect(getDossier).not.toHaveBeenCalled()
})
it('returns the dossier', async () => {
const dossier = { party: { id: PARTY, displayName: 'Loopia AB' }, facts: [], identities: [], decisions: [], vouchers: [], similar: [] }
getDossier.mockResolvedValue(dossier)
const { status, body } = await parseJsonResponse<{ data: typeof dossier }>(
await dossierGet(createMockRequest(`/api/parties/${PARTY}`), { params: Promise.resolve({ id: PARTY }) }),
)
expect(status).toBe(200)
expect(body.data.party.displayName).toBe('Loopia AB')
expect(getDossier).toHaveBeenCalledWith(mockSupabase, 'company-1', PARTY)
})
})
describe('POST /api/parties/suggest', () => {
it('runs the pipeline for the caller and company', async () => {
const summary = { observed: 10, suggested: 4, skipped: 6, created: 3, attached: 1, identities: 2, facts: 5 }
suggestPartiesForCompany.mockResolvedValue(summary)
const { status, body } = await parseJsonResponse<{ data: typeof summary }>(await suggestPost(createMockRequest('/api/parties/suggest', { method: 'POST' }), noParams))
expect(status).toBe(200)
expect(body.data).toEqual(summary)
expect(suggestPartiesForCompany).toHaveBeenCalledWith(mockSupabase, 'company-1', 'user-1')
})
})
describe('POST /api/parties/decide', () => {
it('rejects an empty id list and an unknown kind', async () => {
const a = await parseJsonResponse(await decidePost(json('/api/parties/decide', { partyIds: [], kind: 'confirm' }), noParams))
expect(a.status).toBe(400)
const b = await parseJsonResponse(await decidePost(json('/api/parties/decide', { partyIds: [PARTY], kind: 'merge' }), noParams))
expect(b.status).toBe(400)
})
it('calls decide_parties with the caller identity and returns the count', async () => {
enqueue({ data: 2 })
const { status, body } = await parseJsonResponse<{ data: { count: number; kind: string } }>(
await decidePost(json('/api/parties/decide', { partyIds: [PARTY, OTHER], kind: 'confirm', note: 'from queue' }), noParams),
)
expect(status).toBe(200)
expect(body.data).toEqual({ count: 2, kind: 'confirm' })
expect(mockSupabase.rpc).toHaveBeenCalledWith('decide_parties', {
p_company_id: 'company-1',
p_user_id: 'user-1',
p_party_ids: [PARTY, OTHER],
p_kind: 'confirm',
p_note: 'from queue',
})
})
})
describe('POST /api/parties/decide/undo', () => {
it('reverses the latest decisions for the given parties', async () => {
enqueue({ data: 1 })
const { status, body } = await parseJsonResponse<{ data: { count: number } }>(
await undoDecidePost(json('/api/parties/decide/undo', { partyIds: [PARTY] }), noParams),
)
expect(status).toBe(200)
expect(body.data).toEqual({ count: 1 })
expect(mockSupabase.rpc).toHaveBeenCalledWith('undo_party_decisions', { p_company_id: 'company-1', p_user_id: 'user-1', p_party_ids: [PARTY] })
})
})
describe('POST /api/parties/merge', () => {
it('rejects a survivor listed among the merged ids', async () => {
const { status } = await parseJsonResponse(await mergePost(json('/api/parties/merge', { survivorId: PARTY, mergedIds: [PARTY] }), noParams))
expect(status).toBe(400)
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('maps a foreign or merged party to 404', async () => {
enqueue({ data: null, error: { code: '23503', message: 'survivor is not a live party of this company' } })
const { status } = await parseJsonResponse(await mergePost(json('/api/parties/merge', { survivorId: PARTY, mergedIds: [OTHER] }), noParams))
expect(status).toBe(404)
})
it('returns the decision id for undo', async () => {
enqueue({ data: DECISION })
const { status, body } = await parseJsonResponse<{ data: { decisionId: string; survivorId: string; mergedIds: string[] } }>(
await mergePost(json('/api/parties/merge', { survivorId: PARTY, mergedIds: [OTHER], note: 'same supplier' }), noParams),
)
expect(status).toBe(200)
expect(body.data).toEqual({ decisionId: DECISION, survivorId: PARTY, mergedIds: [OTHER] })
expect(mockSupabase.rpc).toHaveBeenCalledWith('merge_parties', {
p_company_id: 'company-1',
p_user_id: 'user-1',
p_survivor: PARTY,
p_merged: [OTHER],
p_note: 'same supplier',
})
})
})
describe('POST /api/parties/merge/undo', () => {
it('validates the decision id', async () => {
const { status } = await parseJsonResponse(await undoMergePost(json('/api/parties/merge/undo', { decisionId: 'x' }), noParams))
expect(status).toBe(400)
})
it('restores the merged parties', async () => {
enqueue({ data: 2 })
const { status, body } = await parseJsonResponse<{ data: { restored: number } }>(
await undoMergePost(json('/api/parties/merge/undo', { decisionId: DECISION }), noParams),
)
expect(status).toBe(200)
expect(body.data).toEqual({ restored: 2 })
expect(mockSupabase.rpc).toHaveBeenCalledWith('undo_party_merge', { p_company_id: 'company-1', p_user_id: 'user-1', p_decision_id: DECISION })
})
})
describe('POST /api/parties/promote', () => {
it('rejects an item without roles', async () => {
const { status } = await parseJsonResponse(await promotePost(json('/api/parties/promote', { items: [{ partyId: PARTY, roles: [] }] }), noParams))
expect(status).toBe(400)
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('maps a foreign or archived party to 404', async () => {
enqueue({ data: null, error: { code: '23503', message: 'not a live party' } })
const { status } = await parseJsonResponse(await promotePost(json('/api/parties/promote', { items: [{ partyId: PARTY, roles: ['supplier'] }] }), noParams))
expect(status).toBe(404)
})
it('calls promote_parties with snake_case items and returns the counts', async () => {
enqueue({ data: { parties: 2, suppliers: 1, customers: 1 } })
const { status, body } = await parseJsonResponse<{ data: { parties: number; suppliers: number; customers: number } }>(
await promotePost(
json('/api/parties/promote', {
items: [
{ partyId: PARTY, roles: ['supplier'] },
{ partyId: OTHER, roles: ['customer'] },
],
}),
noParams,
),
)
expect(status).toBe(200)
expect(body.data).toEqual({ parties: 2, suppliers: 1, customers: 1 })
expect(mockSupabase.rpc).toHaveBeenCalledWith('promote_parties', {
p_company_id: 'company-1',
p_user_id: 'user-1',
p_items: [
{ party_id: PARTY, roles: ['supplier'] },
{ party_id: OTHER, roles: ['customer'] },
],
})
})
})
describe('POST /api/parties/promote/undo', () => {
it('reverses promotions for the given parties', async () => {
enqueue({ data: 2 })
const { status, body } = await parseJsonResponse<{ data: { count: number } }>(
await undoPromotePost(json('/api/parties/promote/undo', { partyIds: [PARTY, OTHER] }), noParams),
)
expect(status).toBe(200)
expect(body.data).toEqual({ count: 2 })
expect(mockSupabase.rpc).toHaveBeenCalledWith('undo_party_promotions', { p_company_id: 'company-1', p_user_id: 'user-1', p_party_ids: [PARTY, OTHER] })
})
})
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { PartyDecideSchema } from '@/lib/api/schemas'
/** POST /api/parties/decide: bulk confirm or dismiss suggested parties. */
export const POST = withRouteContext(
'parties.decide',
async (request, { supabase, companyId, user, log }) => {
const validation = await validateBody(request, PartyDecideSchema, { log, operation: 'parties.decide' })
if (!validation.success) return validation.response
const { partyIds, kind, note } = validation.data
const { data, error } = await supabase.rpc('decide_parties', {
p_company_id: companyId,
p_user_id: user.id,
p_party_ids: partyIds,
p_kind: kind,
p_note: note ?? null,
})
if (error) throw new Error(`decide_parties failed: ${error.message}`)
return NextResponse.json({ data: { count: Number(data) || 0, kind } })
},
{ requireWrite: true },
)
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { PartyUndoDecisionsSchema } from '@/lib/api/schemas'
/** POST /api/parties/decide/undo: reverse the latest confirm or dismiss per party (30 days). */
export const POST = withRouteContext(
'parties.decide.undo',
async (request, { supabase, companyId, user, log }) => {
const validation = await validateBody(request, PartyUndoDecisionsSchema, { log, operation: 'parties.decide.undo' })
if (!validation.success) return validation.response
const { data, error } = await supabase.rpc('undo_party_decisions', {
p_company_id: companyId,
p_user_id: user.id,
p_party_ids: validation.data.partyIds,
})
if (error) throw new Error(`undo_party_decisions failed: ${error.message}`)
return NextResponse.json({ data: { count: Number(data) || 0 } })
},
{ requireWrite: true },
)
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { PartyMergeSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/** POST /api/parties/merge: soft-merge parties into a survivor; returns the decision id for undo. */
export const POST = withRouteContext(
'parties.merge',
async (request, { supabase, companyId, user, log, requestId }) => {
const validation = await validateBody(request, PartyMergeSchema, { log, operation: 'parties.merge' })
if (!validation.success) return validation.response
const { survivorId, mergedIds, note } = validation.data
if (mergedIds.includes(survivorId)) {
return errorResponseFromCode('VALIDATION_ERROR', log, { requestId, details: { field: 'mergedIds', reason: 'survivor_in_merged' } })
}
const { data, error } = await supabase.rpc('merge_parties', {
p_company_id: companyId,
p_user_id: user.id,
p_survivor: survivorId,
p_merged: mergedIds,
p_note: note ?? null,
})
if (error) {
if (error.code === '23503') return errorResponseFromCode('NOT_FOUND', log, { requestId })
throw new Error(`merge_parties failed: ${error.message}`)
}
return NextResponse.json({ data: { decisionId: data as string, survivorId, mergedIds } })
},
{ requireWrite: true },
)
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { PartyUndoMergeSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/** POST /api/parties/merge/undo: restore the parties one merge decision folded, within 30 days. */
export const POST = withRouteContext(
'parties.merge.undo',
async (request, { supabase, companyId, user, log, requestId }) => {
const validation = await validateBody(request, PartyUndoMergeSchema, { log, operation: 'parties.merge.undo' })
if (!validation.success) return validation.response
const { data, error } = await supabase.rpc('undo_party_merge', {
p_company_id: companyId,
p_user_id: user.id,
p_decision_id: validation.data.decisionId,
})
if (error) {
if (error.code === '23503') return errorResponseFromCode('NOT_FOUND', log, { requestId })
throw new Error(`undo_party_merge failed: ${error.message}`)
}
return NextResponse.json({ data: { restored: Number(data) || 0 } })
},
{ requireWrite: true },
)
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { PartyPromoteSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* POST /api/parties/promote: confirm suggestions into roles. Each item
* becomes a supplier and/or customer row filled from the party's facts;
* undo through /api/parties/promote/undo for 30 days.
*/
export const POST = withRouteContext(
'parties.promote',
async (request, { supabase, companyId, user, log, requestId }) => {
const validation = await validateBody(request, PartyPromoteSchema, { log, operation: 'parties.promote' })
if (!validation.success) return validation.response
const { data, error } = await supabase.rpc('promote_parties', {
p_company_id: companyId,
p_user_id: user.id,
p_items: validation.data.items.map((i) => ({ party_id: i.partyId, roles: i.roles })),
})
if (error) {
if (error.code === '23503') return errorResponseFromCode('NOT_FOUND', log, { requestId })
throw new Error(`promote_parties failed: ${error.message}`)
}
const r = (data ?? {}) as Partial<Record<'parties' | 'suppliers' | 'customers', number>>
return NextResponse.json({ data: { parties: r.parties ?? 0, suppliers: r.suppliers ?? 0, customers: r.customers ?? 0 } })
},
{ requireWrite: true },
)
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { PartyUndoDecisionsSchema } from '@/lib/api/schemas'
/** POST /api/parties/promote/undo: reverse the latest promotion per party (30 days). */
export const POST = withRouteContext(
'parties.promote.undo',
async (request, { supabase, companyId, user, log }) => {
const validation = await validateBody(request, PartyUndoDecisionsSchema, { log, operation: 'parties.promote.undo' })
if (!validation.success) return validation.response
const { data, error } = await supabase.rpc('undo_party_promotions', {
p_company_id: companyId,
p_user_id: user.id,
p_party_ids: validation.data.partyIds,
})
if (error) throw new Error(`undo_party_promotions failed: ${error.message}`)
return NextResponse.json({ data: { count: Number(data) || 0 } })
},
{ requireWrite: true },
)
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { PartiesRegisterQuerySchema } from '@/lib/api/schemas'
import { getRegister } from '@/lib/parties/register'
/**
* GET /api/parties: the Kontakter register for the active company. One
* response carries the counts for every view plus the rows of the requested
* view, so the segmented switch never waits on a second request.
*/
export const GET = withRouteContext('parties.list', async (request, { supabase, companyId, log }) => {
const validated = validateQuery(request, PartiesRegisterQuerySchema, { log, operation: 'parties.list' })
if (!validated.success) return validated.response
const { view, q, period } = validated.data
const register = await getRegister(supabase, companyId, { view, q, period })
return NextResponse.json({ data: register })
})
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { suggestPartiesForCompany } from '@/lib/parties/suggest'
/**
* POST /api/parties/suggest: run the suggestion pipeline for the active
* company. Idempotent: re-running attaches new evidence and creates nothing
* a person already decided on.
*/
export const POST = withRouteContext(
'parties.suggest',
async (_request, { supabase, companyId, user, log }) => {
const summary = await suggestPartiesForCompany(supabase, companyId, user.id)
log.info('party suggestions refreshed', summary)
return NextResponse.json({ data: summary })
},
{ requireWrite: true },
)
+4
View File
@@ -0,0 +1,4 @@
export function AccountNub({ account }: { account: string | null }) {
if (!account) return <span className="text-muted-foreground">·</span>
return <span className="rounded-sm bg-secondary px-1 py-px font-mono text-[12px] tabular-nums">{account}</span>
}
+159
View File
@@ -0,0 +1,159 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import type { Register, RegisterRow } from '@/lib/parties/register'
import { formatOrgNumber } from '@/lib/utils'
export interface MergeCandidate {
id: string
displayName: string
orgNumber: string | null
status: string
}
/**
* Merge with a visible survivor that can be swapped. Everything merged keeps
* its rows; the survivor gains the aliases. Undo lives on the toast.
*/
export function MergeDialog({
open,
onOpenChange,
subject,
suggested,
busy,
onMerge,
}: {
open: boolean
onOpenChange: (open: boolean) => void
subject: MergeCandidate
suggested: MergeCandidate[]
busy: boolean
onMerge: (survivorId: string, mergedIds: string[]) => Promise<void>
}) {
const t = useTranslations('parties')
const tCommon = useTranslations('common')
const [picked, setPicked] = useState<Set<string>>(new Set(suggested.map((s) => s.id)))
const [survivor, setSurvivor] = useState<string>(subject.id)
const [query, setQuery] = useState('')
const [found, setFound] = useState<MergeCandidate[]>([])
useEffect(() => {
const q = query.trim()
if (q.length < 2) return
const ctrl = new AbortController()
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/parties?view=all&q=${encodeURIComponent(q)}`, { signal: ctrl.signal })
if (!res.ok) return
const json = (await res.json()) as { data: Register }
setFound(
json.data.rows
.filter((r: RegisterRow) => r.id !== subject.id)
.slice(0, 8)
.map((r: RegisterRow) => ({ id: r.id, displayName: r.displayName, orgNumber: r.orgNumber, status: r.status })),
)
} catch {
// aborted or offline: the list simply does not update
}
}, 250)
return () => {
clearTimeout(timer)
ctrl.abort()
}
}, [query, subject.id])
const candidates = useMemo(() => {
const seen = new Set<string>()
const out: MergeCandidate[] = []
const searched = query.trim().length >= 2 ? found : []
for (const c of [...suggested, ...searched]) {
if (seen.has(c.id) || c.id === subject.id) continue
seen.add(c.id)
out.push(c)
}
return out
}, [suggested, found, query, subject.id])
const members = [subject, ...candidates.filter((c) => picked.has(c.id))]
const mergedIds = members.filter((m) => m.id !== survivor).map((m) => m.id)
function toggle(id: string) {
setPicked((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
if (survivor === id) setSurvivor(subject.id)
} else next.add(id)
return next
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('merge_dialog_title')}</DialogTitle>
<DialogDescription>{t('merge_body')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<ul className="divide-y divide-border rounded-lg border border-border">
{[subject, ...candidates].map((c) => {
const isSubject = c.id === subject.id
const included = isSubject || picked.has(c.id)
return (
<li key={c.id} className="flex items-center gap-3 px-4 py-3 text-[13px]">
<Checkbox
checked={included}
disabled={isSubject}
onCheckedChange={() => toggle(c.id)}
aria-label={c.displayName}
/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{c.displayName}</div>
<div className="text-xs text-muted-foreground tabular-nums">
{c.orgNumber ? formatOrgNumber(c.orgNumber) : ''}
{c.status === 'suggested' ? (c.orgNumber ? ' · ' : '') + t('view_suggested') : ''}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="radio"
name="party-survivor"
className="h-4 w-4 accent-foreground"
checked={survivor === c.id}
disabled={!included}
onChange={() => setSurvivor(c.id)}
/>
{survivor === c.id ? t('merge_kept') : t('merge_keep')}
</label>
</li>
)
})}
</ul>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('merge_search_placeholder')}
aria-label={t('merge_search_placeholder')}
/>
{candidates.length === 0 && query.trim().length >= 2 ? (
<p className="text-xs text-muted-foreground">{t('merge_none')}</p>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
{tCommon('cancel')}
</Button>
<Button type="button" onClick={() => void onMerge(survivor, mergedIds)} disabled={busy || mergedIds.length === 0}>
{t('merge_confirm', { count: mergedIds.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { useTranslations } from 'next-intl'
import { TD_CLASS, TH_CLASS } from '@/components/ui/dry-table'
import type { ObservedRow, Register } from '@/lib/parties/register'
import { formatCurrency } from '@/lib/utils'
import { AccountNub } from './AccountNub'
import { rhythmLabel } from './format'
function money(n: number): string {
return n ? formatCurrency(n) : ''
}
/**
* "Bara i bokföringen": counterparts the ledger names that no party claims.
* Computed, never stored; the generic band keeps unattributed spend visible
* without inventing a supplier for "Inköp av varor".
*/
export function ObservedTable({ rows, generic }: { rows: ObservedRow[]; generic: Register['generic'] }) {
const t = useTranslations('parties')
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('th_name')}</th>
<th className={TH_CLASS}>{t('th_rhythm')}</th>
<th className={TH_CLASS}>{t('th_vouchers')}</th>
<th className={TH_CLASS}>{t('th_account')}</th>
<th className={`${TH_CLASS} text-right`}>{t('th_revenue')}</th>
<th className={`${TH_CLASS} text-right`}>{t('th_expense')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{rows.map((row) => (
<tr key={row.key} className="transition-colors duration-150 hover:bg-secondary/35">
<td className={`${TD_CLASS} whitespace-nowrap`}>
<span className="text-foreground">{row.name}</span>
{row.label === 'unsure' ? <span className="ml-2 text-xs text-muted-foreground">{t('uncertain_name')}</span> : null}
</td>
<td className={`${TD_CLASS} whitespace-nowrap text-muted-foreground`}>{rhythmLabel(t, row.stats.rhythm)}</td>
<td className={`${TD_CLASS} whitespace-nowrap text-muted-foreground tabular-nums`}>{t('vouchers_count', { count: row.stats.occurrences })}</td>
<td className={TD_CLASS}>
<AccountNub account={row.stats.dominantAccount} />
</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>{money(row.stats.revenueSek)}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>{money(row.stats.expenseSek)}</td>
</tr>
))}
{generic.count > 0 ? (
<tr className="bg-muted/30">
<td colSpan={5} className="px-4 py-2 text-[12px] text-muted-foreground">
<span className="font-semibold">{t('generic_band', { count: generic.count })}</span>
<span className="ml-2">{t('generic_band_description', { examples: generic.examples.join(' · ') })}</span>
</td>
<td className="px-4 py-2 text-right text-[12px] text-muted-foreground tabular-nums">{money(generic.expenseSek)}</td>
</tr>
) : null}
</tbody>
</table>
</div>
)
}
+288
View File
@@ -0,0 +1,288 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { VTD_CLASS, VTH_CLASS } from '@/components/ui/dry-table'
import { Skeleton } from '@/components/ui/skeleton'
import { SlideOver, SlideOverBody, SlideOverContent, SlideOverHeader } from '@/components/ui/slide-over'
import type { Dossier, PartyRole, RegisterPeriod } from '@/lib/parties/register'
import { formatCurrency, formatDate, formatOrgNumber } from '@/lib/utils'
import { AccountNub } from './AccountNub'
import { formatPaymentIdentity, rhythmLabel, roleLabel } from './format'
import type { MergeCandidate } from './MergeDialog'
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">{children}</h2>
}
function Row({ label, value, note }: { label: string; value: React.ReactNode; note?: React.ReactNode }) {
return (
<tr>
<th className={`${VTH_CLASS} w-36`}>{label}</th>
<td className={VTD_CLASS}>
<div className="tabular-nums">{value}</div>
{note ? <div className="text-xs text-muted-foreground">{note}</div> : null}
</td>
</tr>
)
}
/**
* Moment 3: the dossier. Money and booking knowledge come from the ledger;
* "Vad Accounted vet" lists every fact with its source; promotion and
* merge are one action each and always confirm up front.
*/
export function PartyDossier({
partyId,
period,
canWrite,
busy,
onClose,
onPromote,
onDismiss,
onMerge,
reloadKey,
}: {
partyId: string | null
period: RegisterPeriod
canWrite: boolean
busy: boolean
onClose: () => void
onPromote: (id: string, roles: PartyRole[]) => void
onDismiss: (id: string) => void
onMerge: (subject: MergeCandidate, suggested: MergeCandidate[]) => void
reloadKey: number
}) {
const t = useTranslations('parties')
// { partyId, reloadKey } stamps the loaded dossier, so "loading" and
// "failed" are derived instead of set from inside the effect.
const [loaded, setLoaded] = useState<{ partyId: string; reloadKey: number; dossier: Dossier | null } | null>(null)
const current = loaded && loaded.partyId === partyId && loaded.reloadKey === reloadKey ? loaded : null
const dossier = partyId ? (current?.dossier ?? (loaded?.partyId === partyId ? loaded.dossier : null)) : null
const loading = Boolean(partyId) && current === null
const failed = Boolean(partyId) && current !== null && current.dossier === null
useEffect(() => {
if (!partyId) return
let cancelled = false
const key = reloadKey
fetch(`/api/parties/${partyId}`)
.then(async (res) => {
if (!res.ok) throw new Error(String(res.status))
const json = (await res.json()) as { data: Dossier }
if (!cancelled) setLoaded({ partyId, reloadKey: key, dossier: json.data })
})
.catch(() => {
if (!cancelled) setLoaded({ partyId, reloadKey: key, dossier: null })
})
return () => {
cancelled = true
}
}, [partyId, reloadKey])
const p = dossier?.party
const stats = p?.stats ?? null
const suggested = p?.status === 'suggested'
const kicker = p ? (suggested ? t('dossier_kicker_suggested') : roleLabel(t, p.roles)) : ''
const subtitle = stats
? [
t('dossier_seen', { count: stats.occurrences }),
rhythmLabel(t, stats.rhythm),
stats.lastSeen ? t('dossier_last', { date: formatDate(stats.lastSeen) }) : '',
stats.variants.length > 1 ? t('dossier_variants', { count: stats.variants.length }) : '',
]
.filter(Boolean)
.join(' · ')
: ''
const legalName = p?.legalName ?? (dossier?.facts.find((f) => f.field === 'legal_name')?.value as string | undefined) ?? null
const orgFact = dossier?.facts.find((f) => f.field === 'org_number')
const docsFor = (field: string) => {
const f = dossier?.facts.find((x) => x.field === field)
const n = (f?.reference as { docs?: number } | null)?.docs
return n ? t('fact_from_documents', { count: n }) : f?.source === 'ledger' ? t('fact_from_ledger') : f?.source === 'user' ? t('fact_from_user') : ''
}
const dominant = dossier?.facts.find((f) => f.field === 'dominant_account')?.value as { account?: string; count?: number } | undefined
return (
<SlideOver open={Boolean(partyId)} onOpenChange={(open) => (!open ? onClose() : undefined)}>
<SlideOverContent>
<SlideOverHeader kicker={kicker} title={p?.displayName ?? (loading ? '…' : '')} />
<SlideOverBody>
{loading && !dossier ? (
<div className="space-y-3">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-24 w-full" />
</div>
) : failed || !dossier || !p ? (
<p className="text-sm text-muted-foreground">{t('load_failed')}</p>
) : (
<div className="space-y-8">
<div className="space-y-3">
{subtitle ? <p className="text-[13px] text-muted-foreground">{subtitle}</p> : null}
<div className="flex flex-wrap gap-2">
{suggested || !p.roles.supplierId ? (
<Button
type="button"
size="sm"
variant={p.defaultRoles.includes('supplier') ? 'default' : 'outline'}
onClick={() => onPromote(p.id, ['supplier'])}
disabled={!canWrite || busy}
>
{t('promote_supplier')}
</Button>
) : null}
{suggested || !p.roles.customerId ? (
<Button
type="button"
size="sm"
variant={p.defaultRoles.includes('customer') && !p.defaultRoles.includes('supplier') ? 'default' : 'outline'}
onClick={() => onPromote(p.id, ['customer'])}
disabled={!canWrite || busy}
>
{t('promote_customer')}
</Button>
) : null}
<Button
type="button"
size="sm"
variant="outline"
disabled={!canWrite || busy}
onClick={() =>
onMerge(
{ id: p.id, displayName: p.displayName, orgNumber: p.orgNumber, status: p.status },
dossier.similar.map((s) => ({ id: s.id, displayName: s.displayName, orgNumber: s.orgNumber, status: s.status })),
)
}
>
{t('merge')}
</Button>
{suggested ? (
<Button type="button" size="sm" variant="ghost" onClick={() => onDismiss(p.id)} disabled={!canWrite || busy}>
{t('dismiss')}
</Button>
) : null}
</div>
</div>
<section className="space-y-3">
<SectionTitle>{t('section_money')}</SectionTitle>
<table className="w-full text-[13px]">
<tbody>
{stats?.expenseSek || !stats?.revenueSek ? (
<Row
label={t('money_expense')}
value={formatCurrency(stats?.expenseSek ?? 0)}
note={period === '12m' ? t('money_period_12m') : t('money_period_all')}
/>
) : null}
{stats?.revenueSek ? (
<Row
label={t('money_revenue')}
value={formatCurrency(stats.revenueSek)}
note={period === '12m' ? t('money_period_12m') : t('money_period_all')}
/>
) : null}
{stats?.firstSeen ? <Row label={t('money_first')} value={formatDate(stats.firstSeen)} /> : null}
{stats?.lastSeen ? <Row label={t('money_last')} value={formatDate(stats.lastSeen)} /> : null}
</tbody>
</table>
</section>
<section className="space-y-3">
<SectionTitle>{t('section_bookkeeping')}</SectionTitle>
<table className="w-full text-[13px]">
<tbody>
<Row
label={t('bk_account')}
value={<AccountNub account={stats?.dominantAccount ?? dominant?.account ?? null} />}
note={
stats?.dominantAccount && stats.occurrences
? t('bk_account_share', {
count: Math.round((stats.dominantShare ?? 0) * (stats.occurrences + 2) - 1) || dominant?.count || 0,
total: stats.occurrences,
})
: undefined
}
/>
<Row label={t('bk_when')} value={t('bk_when_value')} />
</tbody>
</table>
</section>
<section className="space-y-3">
<SectionTitle>{t('section_facts')}</SectionTitle>
<table className="w-full text-[13px]">
<tbody>
<Row
label={t('fact_name')}
value={p.displayName}
note={stats && stats.variants.length > 1 ? stats.variants.slice(0, 3).join(', ') : undefined}
/>
<Row label={t('fact_legal_name')} value={legalName ?? <span className="text-muted-foreground">{t('fact_missing')}</span>} note={legalName ? docsFor('legal_name') : undefined} />
<Row
label={t('fact_org')}
value={p.orgNumber ? formatOrgNumber(p.orgNumber) : <span className="text-muted-foreground">{t('fact_missing')}</span>}
note={p.orgNumber && orgFact ? docsFor('org_number') : undefined}
/>
<Row label={t('fact_vat')} value={p.vatNumber ?? <span className="text-muted-foreground">{t('fact_missing')}</span>} />
{dossier.identities.map((i) => (
<Row
key={i.id}
label={i.scheme === 'bankgiro' ? t('fact_bankgiro') : i.scheme === 'plusgiro' ? t('fact_plusgiro') : i.scheme}
value={formatPaymentIdentity(i.scheme, i.value)}
note={`${t('fact_from_documents', { count: i.seenCount })} · ${i.status === 'known' ? t('identity_known') : t('identity_unverified')}`}
/>
))}
</tbody>
</table>
</section>
<section className="space-y-3">
<SectionTitle>{t('section_vouchers')}</SectionTitle>
{dossier.vouchers.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('vouchers_none')}</p>
) : (
<table className="w-full text-[13px]">
<tbody>
{dossier.vouchers.map((v) => (
<tr key={v.id}>
<td className={`${VTD_CLASS} w-24 whitespace-nowrap text-muted-foreground tabular-nums`}>{formatDate(v.entryDate)}</td>
<td className={`${VTD_CLASS} truncate`}>
{v.voucher ? <span className="text-muted-foreground">{v.voucher} · </span> : null}
{v.description}
</td>
<td className={`${VTD_CLASS} text-right tabular-nums`}>{v.amount ? formatCurrency(v.amount) : ''}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{dossier.decisions.length > 0 ? (
<section className="space-y-3">
<SectionTitle>{t('section_history')}</SectionTitle>
<table className="w-full text-[13px]">
<tbody>
{dossier.decisions.map((d) => (
<tr key={d.id}>
<td className={`${VTD_CLASS} w-24 whitespace-nowrap text-muted-foreground tabular-nums`}>{formatDate(d.createdAt)}</td>
<td className={VTD_CLASS}>
{['confirm', 'dismiss', 'merge', 'split', 'undo'].includes(d.kind) ? t(`decision_${d.kind}`) : d.kind}
{d.note ? <span className="text-muted-foreground"> · {d.note}</span> : null}
</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
</div>
)}
</SlideOverBody>
</SlideOverContent>
</SlideOver>
)
}
+157
View File
@@ -0,0 +1,157 @@
'use client'
import { useTranslations } from 'next-intl'
import { Check, ChevronDown } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { HOVER_REVEAL_CLASS, TD_CLASS, TH_CLASS } from '@/components/ui/dry-table'
import type { PartyRole, RegisterRow } from '@/lib/parties/register'
import { formatCurrency } from '@/lib/utils'
import { AccountNub } from './AccountNub'
import { isDuplicateCandidate, reasonText, rolesLabel } from './format'
/**
* The queue in front of Leverantörer and Kunder. Every row states why it is
* here and what it becomes; only rows with a hard key arrive pre-ticked;
* bulk confirm opens one dialog that says what happens.
*/
export function SuggestionQueue({
rows,
selected,
roles,
canWrite,
busy,
onToggle,
onSelectAll,
onClear,
onRoles,
onConfirmSelected,
onDismiss,
onOpen,
}: {
rows: RegisterRow[]
selected: Set<string>
roles: (row: RegisterRow) => PartyRole[]
canWrite: boolean
busy: boolean
onToggle: (id: string) => void
onSelectAll: () => void
onClear: () => void
onRoles: (id: string, roles: PartyRole[]) => void
onConfirmSelected: () => void
onDismiss: (row: RegisterRow) => void
onOpen: (id: string) => void
}) {
const t = useTranslations('parties')
const count = selected.size
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id))
function toggleRole(row: RegisterRow, role: PartyRole) {
const current = roles(row)
const next = current.includes(role) ? current.filter((r) => r !== role) : [...current, role]
if (next.length === 0) return
onRoles(row.id, next)
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-[13px]">
<span className="tabular-nums text-muted-foreground">{t('selected_n', { count })}</span>
<span className="text-muted-foreground">{t('selected_hint')}</span>
<div className="ml-auto flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={allSelected ? onClear : onSelectAll} disabled={rows.length === 0}>
{allSelected ? t('deselect') : t('select_all')}
</Button>
<Button type="button" size="sm" onClick={onConfirmSelected} disabled={!canWrite || busy || count === 0}>
{t('promote_n', { count })}
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-8`} />
<th className={TH_CLASS}>{t('th_name')}</th>
<th className={TH_CLASS}>{t('th_why')}</th>
<th className={TH_CLASS}>{t('th_becomes')}</th>
<th className={TH_CLASS}>{t('th_account')}</th>
<th className={`${TH_CLASS} text-right`}>{t('th_revenue')}</th>
<th className={`${TH_CLASS} text-right`}>{t('th_expense')}</th>
<th className={`${TH_CLASS} w-16`} />
</tr>
</thead>
<tbody className="stagger-enter">
{rows.map((row) => {
const checked = selected.has(row.id)
const current = roles(row)
return (
<tr key={row.id} className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={`${TD_CLASS} w-8`}>
<Checkbox checked={checked} onCheckedChange={() => onToggle(row.id)} aria-label={row.displayName} disabled={!canWrite} />
</td>
<td className={`${TD_CLASS} whitespace-nowrap`}>
<button
type="button"
className="text-left font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onOpen(row.id)}
aria-label={t('open_dossier', { name: row.displayName })}
>
{row.displayName}
</button>
{isDuplicateCandidate(row) ? (
<Badge variant="warning" className="ml-2">
{t('chip_duplicate')}
</Badge>
) : null}
</td>
<td className={`${TD_CLASS} text-muted-foreground`}>{reasonText(t, row.reason, row.stats?.rhythm ?? null)}</td>
<td className={`${TD_CLASS} whitespace-nowrap`}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:text-muted-foreground"
disabled={!canWrite}
aria-label={t('becomes_aria', { name: row.displayName })}
>
{rolesLabel(t, current)}
<ChevronDown className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(['supplier', 'customer'] as const).map((role) => (
<DropdownMenuItem key={role} onSelect={(e) => { e.preventDefault(); toggleRole(row, role) }} className="gap-2">
<Check className={`h-3.5 w-3.5 ${current.includes(role) ? '' : 'invisible'}`} aria-hidden="true" />
{role === 'supplier' ? t('role_supplier') : t('role_customer')}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</td>
<td className={TD_CLASS}>
<AccountNub account={row.stats?.dominantAccount ?? null} />
</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>{row.stats?.revenueSek ? formatCurrency(row.stats.revenueSek) : ''}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>{row.stats?.expenseSek ? formatCurrency(row.stats.expenseSek) : ''}</td>
<td className={`${TD_CLASS} text-right`}>
<button
type="button"
className={`${HOVER_REVEAL_CLASS} text-xs text-muted-foreground underline-offset-2 hover:underline`}
onClick={() => onDismiss(row)}
disabled={!canWrite || busy}
>
{t('dismiss')}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { AttnLine } from '@/components/ui/attn-line'
import type { RegisterCounts } from '@/lib/parties/register'
/**
* The one line on Leverantörer and Kunder that leads to the queue: how many
* suggestions from the books are waiting for this side. Renders nothing
* when there is nothing to do, so the pages stay as they were.
*/
export function SuggestionsAttn({ side }: { side: 'supplier' | 'customer' }) {
const t = useTranslations('parties')
const router = useRouter()
const [counts, setCounts] = useState<RegisterCounts | null>(null)
useEffect(() => {
let cancelled = false
fetch('/api/parties?view=suggested')
.then(async (res) => {
if (!res.ok) return
const json = (await res.json()) as { data: { counts: RegisterCounts } }
if (!cancelled) setCounts(json.data.counts)
})
.catch(() => {
// The line is a convenience; a failed count shows nothing.
})
return () => {
cancelled = true
}
}, [])
if (!counts) return null
const n = side === 'supplier' ? counts.suggestedSuppliers : counts.suggestedCustomers
if (n > 0) {
return (
<AttnLine action={{ label: t('attn_review'), onClick: () => router.push('/parties') }}>
{side === 'supplier' ? t('attn_suppliers', { count: n }) : t('attn_customers', { count: n })}
</AttnLine>
)
}
if (side === 'supplier' && counts.suggested === 0 && counts.observed > 0) {
return (
<AttnLine action={{ label: t('attn_fetch'), onClick: () => router.push('/parties?view=observed') }}>
{t('attn_observed', { count: counts.observed })}
</AttnLine>
)
}
return null
}
+56
View File
@@ -0,0 +1,56 @@
import type { LedgerStats, PartyRole, RegisterRow } from '@/lib/parties/register'
import type { SuggestionReason } from '@/lib/parties/suggest'
import { formatOrgNumber } from '@/lib/utils'
/** next-intl translator shape the register components accept. */
export type Translate = (key: string, values?: Record<string, string | number>) => string
export function rhythmLabel(t: Translate, rhythm: LedgerStats['rhythm']): string {
return rhythm ? t(`rhythm_${rhythm}`) : ''
}
export function roleLabel(t: Translate, roles: RegisterRow['roles']): string {
const parts: string[] = []
if (roles.supplierId) parts.push(t('role_supplier'))
if (roles.customerId) parts.push(t('role_customer'))
return parts.length ? parts.join(' · ') : t('role_none')
}
/** "Leverantör", "Kund" or "Leverantör · Kund": what a suggestion becomes. */
export function rolesLabel(t: Translate, roles: PartyRole[]): string {
const parts: string[] = []
if (roles.includes('supplier')) parts.push(t('role_supplier'))
if (roles.includes('customer')) parts.push(t('role_customer'))
return parts.join(' · ')
}
/** "Org.nr 556354-5185 i 3 underlag · 12 verifikat · varje månad": why a row is in the queue. */
export function reasonText(t: Translate, reason: SuggestionReason | null, rhythm: LedgerStats['rhythm']): string {
if (!reason) return ''
const parts: string[] = []
const docs = Math.max(0, (reason.docs ?? 0) - (reason.self_docs ?? 0))
if (reason.org_number) parts.push(t('reason_org', { org: formatOrgNumber(reason.org_number), docs }))
else if (reason.ambiguous_orgs?.length) parts.push(t('reason_ambiguous'))
else if (docs > 0) parts.push(t('reason_docs', { docs }))
parts.push(t('reason_vouchers', { count: reason.occurrences ?? 0 }))
if (rhythm && rhythm !== 'irregular') parts.push(rhythmLabel(t, rhythm))
if (!reason.org_number && !reason.ambiguous_orgs?.length && docs === 0) parts.push(t('reason_ledger_only'))
if (reason.similar_to?.length) parts.push(t('reason_similar', { name: reason.similar_to[0]!.display_name }))
return parts.join(' · ')
}
/** 53170900 reads 5317-0900; 7-digit bankgiro 531-7090; plusgiro keeps its check digit after the hyphen. */
export function formatPaymentIdentity(scheme: string, value: string): string {
const d = value.replace(/[^0-9]/g, '')
if (scheme === 'bankgiro' && (d.length === 7 || d.length === 8)) return `${d.slice(0, d.length - 4)}-${d.slice(-4)}`
if (scheme === 'plusgiro' && d.length >= 2) return `${d.slice(0, -1)}-${d.slice(-1)}`
return value
}
export function isDuplicateCandidate(row: RegisterRow): boolean {
return row.similar.length > 0 || Boolean(row.reason?.similar_to?.length)
}
export function hasHardKey(row: RegisterRow): boolean {
return Boolean(row.reason?.org_number)
}
@@ -37,6 +37,7 @@ import {
fetchSupplierInvoicesHydrated,
} from '@/lib/providers/provider-data-fetcher'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { suggestPartiesForCompany } from '@/lib/parties/suggest'
import { createLogger } from '@/lib/logger'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
import {
@@ -1117,6 +1118,15 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
// Fill the Kontakter register from the migrated vouchers and documents
// (non-blocking): suggested parties only, confirmed by the user later.
try {
const summary = await suggestPartiesForCompany(supabase, companyId, userId)
console.log(`[migration] party suggestions: ${summary.created} new, ${summary.attached} attached, ${summary.skipped} skipped`)
} catch (err) {
console.error('Failed to suggest parties after migration:', err)
}
emitProgress(options, { status: 'completed', progress: 100, results })
return results
} catch (error) {
+40
View File
@@ -3993,3 +3993,43 @@ export const SalesOrderListQuerySchema = z.object({
customer_id: uuid.optional(),
q: z.string().max(200).optional(),
})
// ── Parties (Kontakter register) ───────────────────────────────────────────
export const PartiesRegisterQuerySchema = z.object({
view: z.enum(['suggested', 'observed']).optional(),
q: z.string().max(120).optional(),
period: z.enum(['12m', 'all']).optional(),
})
export const PartyDecideSchema = z.object({
partyIds: z.array(uuid).min(1).max(500),
kind: z.enum(['confirm', 'dismiss']),
note: z.string().max(500).optional(),
})
export const PartyPromoteSchema = z.object({
items: z
.array(
z.object({
partyId: uuid,
roles: z.array(z.enum(['supplier', 'customer'])).min(1).max(2),
}),
)
.min(1)
.max(500),
})
export const PartyUndoDecisionsSchema = z.object({
partyIds: z.array(uuid).min(1).max(500),
})
export const PartyMergeSchema = z.object({
survivorId: uuid,
mergedIds: z.array(uuid).min(1).max(50),
note: z.string().max(500).optional(),
})
export const PartyUndoMergeSchema = z.object({
decisionId: uuid,
})
+12
View File
@@ -39,6 +39,7 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
import { suggestPartiesForCompany } from '@/lib/parties/suggest'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { monthsBetween, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { findUntransferredResults } from '@/lib/reports/imbalance-diagnosis'
@@ -2992,6 +2993,17 @@ export async function executeSIEImport(
}
}
// Fill the Kontakter register from the imported vouchers (non-blocking):
// suggested parties only, nothing is confirmed on the user's behalf.
if (result.success && parsed.vouchers.length > 0) {
try {
const summary = await suggestPartiesForCompany(supabase, companyId, userId)
console.info(`[sie-import] party suggestions: ${summary.created} new, ${summary.attached} attached, ${summary.skipped} skipped`)
} catch (partyError) {
console.error('[sie-import] Failed to suggest parties:', partyError)
}
}
// Opt-in: mark imported verifikat as "Inget underlag krävs" (non-blocking).
// Migrated vouchers carry their underlag in the source system, so the user
// can choose to keep all of them out of "Att hantera: saknade underlag" in
+84
View File
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { defaultRoles, getDossier } from '../register'
import type { LedgerStats } from '../register'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const PARTY = '11111111-1111-4111-8111-111111111111'
const SURVIVOR = '22222222-2222-4222-8222-222222222222'
function row(over: Record<string, unknown>) {
return {
id: PARTY,
display_name: 'Loopia AB',
legal_name: null,
org_number: null,
vat_number: null,
kind: 'company',
status: 'suggested',
alias_keys: ['loopia'],
suggested_reason: null,
created_at: '2026-09-03T00:00:00Z',
merged_into: null,
archived_at: null,
...over,
}
}
describe('getDossier', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('hides a dismissed (archived) party like the register does', async () => {
enqueue({ data: row({ archived_at: '2026-09-03T10:00:00Z' }) })
expect(await getDossier(supabase as never, 'company-1', PARTY)).toBeNull()
expect(supabase.from).toHaveBeenCalledTimes(1)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('follows a merged party to its survivor', async () => {
enqueue({ data: row({ merged_into: SURVIVOR }) })
enqueue({ data: SURVIVOR }) // canonical_party_id
enqueue({ data: null }) // survivor lookup: absent, so the walk ends here
expect(await getDossier(supabase as never, 'company-1', PARTY)).toBeNull()
expect(supabase.rpc).toHaveBeenCalledWith('canonical_party_id', { p_party_id: PARTY })
expect(supabase.from).toHaveBeenCalledTimes(2)
expect(supabase.from).toHaveBeenNthCalledWith(2, 'parties')
})
it('returns null for an unknown id without touching anything else', async () => {
enqueue({ data: null })
expect(await getDossier(supabase as never, 'company-1', PARTY)).toBeNull()
expect(supabase.from).toHaveBeenCalledTimes(1)
})
})
describe('defaultRoles', () => {
const stats = (over: Partial<LedgerStats>): LedgerStats => ({
occurrences: 3,
expenseSek: 0,
revenueSek: 0,
firstSeen: null,
lastSeen: null,
cadenceDays: null,
rhythm: null,
dominantAccount: null,
dominantShare: null,
variants: [],
...over,
})
it('reads the ledger side', () => {
expect(defaultRoles(stats({ expenseSek: 1000 }))).toEqual(['supplier'])
expect(defaultRoles(stats({ revenueSek: 1000 }))).toEqual(['customer'])
expect(defaultRoles(stats({ expenseSek: 10, revenueSek: 10 }))).toEqual(['supplier', 'customer'])
})
it('falls back to the dominant account, then to supplier', () => {
expect(defaultRoles(stats({ dominantAccount: '3011' }))).toEqual(['customer'])
expect(defaultRoles(stats({ dominantAccount: '6212' }))).toEqual(['supplier'])
expect(defaultRoles(null)).toEqual(['supplier'])
})
})
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { coreKey } from '../ledger-key'
import { coreKey, displayNameFromVoucherText } from '../ledger-key'
import type { ObservedParty } from '../observed'
import { buildSuggestions, suggestPartiesForCompany, type ExistingParty, type LedgerKeyEvidence } from '../suggest'
@@ -38,6 +38,17 @@ describe('coreKey', () => {
})
})
describe('displayNameFromVoucherText', () => {
it('drops AP/AR prefixes and supplier numbers but keeps casing and legal form', () => {
expect(displayNameFromVoucherText('Levfakt BEIJER BYGGMATERIAL AB (2089)')).toBe('BEIJER BYGGMATERIAL AB')
expect(displayNameFromVoucherText('Levfakt Beijer Byggmaterial AB, 097')).toBe('Beijer Byggmaterial AB')
expect(displayNameFromVoucherText('Kundbet Acme Konsult AB')).toBe('Acme Konsult AB')
expect(displayNameFromVoucherText('Leverantörsfaktura från 18 Loopia')).toBe('Loopia')
expect(displayNameFromVoucherText('UBER *TRIP HELP.UBER.COM')).toBe('UBER *TRIP HELP.UBER.COM')
expect(displayNameFromVoucherText('Inköp av varor')).toBe('Inköp av varor')
})
})
describe('buildSuggestions', () => {
it('skips keys the pre-classifier does not call party', () => {
const r = buildSuggestions({
@@ -192,3 +203,23 @@ describe('suggestPartiesForCompany', () => {
await expect(suggestPartiesForCompany({ rpc } as never, 'co', 'user')).rejects.toThrow(/get_observed_parties failed: boom/)
})
})
describe('similarAmong', () => {
it('pairs same-core and whole-word-extended names, never unrelated ones', async () => {
const { similarAmong } = await import('../register')
const m = similarAmong([
{ id: 'a', display_name: 'Fortnox AB', alias_keys: ['fortnox'] },
{ id: 'b', display_name: 'Fortnox Finans AB', alias_keys: ['fortnox finans'] },
{ id: 'c', display_name: 'Rikshem Uppsala KB', alias_keys: [] },
{ id: 'd', display_name: 'Rikshem', alias_keys: [] },
{ id: 'e', display_name: 'Fortum Markets AB', alias_keys: [] },
{ id: 'f', display_name: 'Levfakt Beijer Byggmaterial AB 2089', alias_keys: ['beijer byggmaterial'] },
{ id: 'g', display_name: 'BEIJER BYGGMATERIAL', alias_keys: [] },
])
expect(m.get('a')!.map((s) => s.id)).toEqual(['b'])
expect(m.get('b')!.map((s) => s.id)).toEqual(['a'])
expect(m.get('c')!.map((s) => s.id)).toEqual(['d'])
expect(m.get('e')).toEqual([])
expect(m.get('f')!.map((s) => s.id)).toEqual(['g'])
})
})
+16
View File
@@ -54,3 +54,19 @@ export function coreKey(key: string): string {
.filter(Boolean)
.join(' ')
}
const DISPLAY_PREFIX =
/^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura från\s+\d*|leverantörsfaktura|levbet\.?|kundbet\.?|kundfaktura|kundfakt|inbetalning från|inbetalning|utbetalning till|utbetalning|betalning till|betalning|faktura från|faktura|kvitto|utgift)\s+/i
const DISPLAY_SUFFIX = /(\s*[,(]\s*\d{1,6}\s*\)?|\s+\d{1,4})+$/
/**
* A display name from raw voucher text: the AP/AR prefix and the supplier
* number go, the casing and the legal form stay. "Levfakt BEIJER
* BYGGMATERIAL AB (2089)" reads "BEIJER BYGGMATERIAL AB"; "Kundbet Acme
* Konsult AB" reads "Acme Konsult AB". Used only when no document carries
* a printed name; nothing here is generated, only removed.
*/
export function displayNameFromVoucherText(raw: string): string {
const cleaned = raw.trim().replace(DISPLAY_PREFIX, '').replace(DISPLAY_SUFFIX, '').trim()
return cleaned.length >= 2 ? cleaned : raw.trim()
}
+543
View File
@@ -0,0 +1,543 @@
/**
* Parties, phase 1: the read model behind "Förslag från bokföringen".
*
* The user's registers are Leverantörer and Kunder (founder decision
* 2026-09-03: no third noun). This model serves the queue in front of them:
* suggested parties with a reason per row and the role each will become,
* and observed parties the ledger names but nothing owns yet. Money, rhythm
* and the dominant account come from get_observed_parties joined through the
* party's alias keys, so a suggestion shows the numbers a migrant saw in
* the reveal. Confirmed parties are read here only for counts and for the
* dossier; the user sees them as suppliers and customers.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { coreKey } from './ledger-key'
import { getObservedParties, type ObservedParty } from './observed'
import type { SuggestionReason } from './suggest'
export type RegisterView = 'suggested' | 'observed'
export type PartyRole = 'supplier' | 'customer'
export type RegisterPeriod = '12m' | 'all'
export interface PartyRoleLink {
customerId: string | null
supplierId: string | null
}
export interface LedgerStats {
occurrences: number
expenseSek: number
revenueSek: number
firstSeen: string | null
lastSeen: string | null
cadenceDays: number | null
rhythm: ObservedParty['rhythm']
dominantAccount: string | null
dominantShare: number | null
variants: string[]
}
export interface RegisterRow {
id: string
displayName: string
orgNumber: string | null
kind: string
status: 'confirmed' | 'suggested'
roles: PartyRoleLink
stats: LedgerStats | null
/** Sales invoices for a customer role; supplier invoices for a supplier role. */
invoiceCount: number
reason: SuggestionReason | null
/** Live parties sharing this party's core name: a merge question, never a merge. */
similar: Array<{ id: string; displayName: string }>
/** What confirming this suggestion creates, read from which side of the ledger it sits on. */
defaultRoles: PartyRole[]
createdAt: string
}
export interface ObservedRow {
key: string
name: string
stats: LedgerStats
label: ObservedParty['label']
}
export interface RegisterCounts {
suggested: number
/** Suggestions whose default role includes supplier / customer (a row can count in both). */
suggestedSuppliers: number
suggestedCustomers: number
observed: number
confirmed: number
}
export interface Register {
counts: RegisterCounts
rows: RegisterRow[]
observed: ObservedRow[]
/** Observed keys the pre-classifier calls a category: unattributed spend. */
generic: { count: number; expenseSek: number; examples: string[] }
period: RegisterPeriod
}
interface PartyRecord {
id: string
display_name: string
org_number: string | null
kind: string
status: 'confirmed' | 'suggested'
alias_keys: string[]
suggested_reason: SuggestionReason | null
created_at: string
archived_at?: string | null
}
export function statsFrom(o: ObservedParty): LedgerStats {
return {
occurrences: o.occurrences,
expenseSek: Number(o.expense_sek) || 0,
revenueSek: Number(o.revenue_sek) || 0,
firstSeen: o.first_seen ?? null,
lastSeen: o.last_seen ?? null,
cadenceDays: o.cadence_days,
rhythm: o.rhythm,
dominantAccount: o.dominant_account_number,
dominantShare: o.dominant_account_share,
variants: o.variants ?? [],
}
}
/** Sum the observed keys a party owns into one stats block. */
export function mergeStats(parts: ObservedParty[]): LedgerStats | null {
if (parts.length === 0) return null
const sorted = [...parts].sort((a, b) => b.occurrences - a.occurrences)
const lead = statsFrom(sorted[0]!)
const out: LedgerStats = { ...lead, variants: [] }
let first: string | null = null
let last: string | null = null
for (const p of sorted) {
if (p !== sorted[0]) {
out.occurrences += p.occurrences
out.expenseSek += Number(p.expense_sek) || 0
out.revenueSek += Number(p.revenue_sek) || 0
}
if (p.first_seen && (!first || p.first_seen < first)) first = p.first_seen
if (p.last_seen && (!last || p.last_seen > last)) last = p.last_seen
for (const v of p.variants ?? []) if (!out.variants.includes(v)) out.variants.push(v)
}
out.firstSeen = first
out.lastSeen = last
out.expenseSek = roundOre(out.expenseSek)
out.revenueSek = roundOre(out.revenueSek)
return out
}
/**
* Same-core parties among the live rows. Read-time, never stored: the
* selection eval measured 9% false merges on shared trade names, so this
* only ever asks.
*/
export function similarAmong(parties: Array<{ id: string; display_name: string; alias_keys: string[] }>): Map<string, Array<{ id: string; displayName: string }>> {
const byCore = new Map<string, Array<{ id: string; displayName: string }>>()
const coresOf = new Map<string, Set<string>>()
for (const p of parties) {
const cores = new Set<string>()
const c = coreKey(p.display_name)
if (c) cores.add(c)
for (const a of p.alias_keys) {
const ac = coreKey(a)
if (ac) cores.add(ac)
}
coresOf.set(p.id, cores)
for (const core of cores) byCore.set(core, [...(byCore.get(core) ?? []), { id: p.id, displayName: p.display_name }])
}
// Cores that extend another core by whole words share its first word:
// "fortnox finans" reaches "fortnox" and the other way round.
const byFirstWord = new Map<string, string[]>()
for (const core of byCore.keys()) {
const first = core.split(' ')[0]!
byFirstWord.set(first, [...(byFirstWord.get(first) ?? []), core])
}
const extendsCore = (a: string, b: string) => a === b || a.startsWith(b + ' ') || b.startsWith(a + ' ')
const out = new Map<string, Array<{ id: string; displayName: string }>>()
for (const p of parties) {
const seen = new Set<string>([p.id])
const similar: Array<{ id: string; displayName: string }> = []
for (const core of coresOf.get(p.id) ?? []) {
for (const candidate of byFirstWord.get(core.split(' ')[0]!) ?? []) {
if (!extendsCore(core, candidate)) continue
for (const other of byCore.get(candidate) ?? []) {
if (seen.has(other.id)) continue
seen.add(other.id)
similar.push(other)
}
}
}
out.set(p.id, similar)
}
return out
}
/**
* Expense side becomes a supplier, revenue side a customer, both sides both.
* A row with no money at all (documents only) is a supplier: the documents
* that carry hard keys are supplier invoices.
*/
export function defaultRoles(stats: LedgerStats | null): PartyRole[] {
if (!stats) return ['supplier']
const roles: PartyRole[] = []
if (stats.expenseSek > 0) roles.push('supplier')
if (stats.revenueSek > 0) roles.push('customer')
if (roles.length === 0) {
if (stats.dominantAccount && /^3/.test(stats.dominantAccount)) return ['customer']
return ['supplier']
}
return roles
}
function periodStart(period: RegisterPeriod, now = new Date()): string | null {
if (period === 'all') return null
const d = new Date(now)
d.setUTCFullYear(d.getUTCFullYear() - 1)
return d.toISOString().slice(0, 10)
}
function normalizeQuery(q: string | undefined): string {
return (q ?? '').trim().toLowerCase()
}
function matches(q: string, ...fields: Array<string | null | undefined>): boolean {
if (!q) return true
const digits = q.replace(/[^0-9]/g, '')
return fields.some((f) => {
if (!f) return false
const s = f.toLowerCase()
if (s.includes(q)) return true
return digits.length >= 4 && s.replace(/[^0-9]/g, '').includes(digits)
})
}
/**
* Load the register for one company. Reads only; the pipeline that fills the
* suggested tier is suggestPartiesForCompany.
*/
export async function getRegister(
supabase: SupabaseClient,
companyId: string,
options: { view?: RegisterView; q?: string; period?: RegisterPeriod } = {},
): Promise<Register> {
const period = options.period ?? '12m'
const q = normalizeQuery(options.q)
const [parties, customers, suppliers, observed, customerCounts, supplierCounts] = await Promise.all([
// Archived (dismissed) parties stay out of the list but keep their keys
// claimed, so a dismissed suggestion does not resurface as observed.
fetchAllRows<PartyRecord>(({ from, to }) =>
supabase
.from('parties')
.select('id, display_name, org_number, kind, status, alias_keys, suggested_reason, created_at, archived_at')
.eq('company_id', companyId)
.is('merged_into', null)
.order('display_name', { ascending: true })
.range(from, to),
),
fetchAllRows<{ id: string; party_id: string | null }>(({ from, to }) =>
supabase.from('customers').select('id, party_id').eq('company_id', companyId).not('party_id', 'is', null).range(from, to),
),
fetchAllRows<{ id: string; party_id: string | null }>(({ from, to }) =>
supabase.from('suppliers').select('id, party_id').eq('company_id', companyId).not('party_id', 'is', null).range(from, to),
),
getObservedParties(supabase, companyId, { fromDate: periodStart(period), limit: 5000 }),
fetchAllRows<{ customer_id: string }>(({ from, to }) =>
supabase.from('invoices').select('customer_id').eq('company_id', companyId).not('customer_id', 'is', null).range(from, to),
),
fetchAllRows<{ supplier_id: string }>(({ from, to }) =>
supabase.from('supplier_invoices').select('supplier_id').eq('company_id', companyId).not('supplier_id', 'is', null).range(from, to),
),
])
const customerByParty = new Map<string, string>()
for (const c of customers) if (c.party_id && !customerByParty.has(c.party_id)) customerByParty.set(c.party_id, c.id)
const supplierByParty = new Map<string, string>()
for (const s of suppliers) if (s.party_id && !supplierByParty.has(s.party_id)) supplierByParty.set(s.party_id, s.id)
const invoicesByCustomer = new Map<string, number>()
for (const r of customerCounts) invoicesByCustomer.set(r.customer_id, (invoicesByCustomer.get(r.customer_id) ?? 0) + 1)
const invoicesBySupplier = new Map<string, number>()
for (const r of supplierCounts) invoicesBySupplier.set(r.supplier_id, (invoicesBySupplier.get(r.supplier_id) ?? 0) + 1)
const observedByKey = new Map(observed.map((o) => [o.key, o]))
const claimedKeys = new Set<string>()
const similarById = similarAmong(parties.filter((p) => !p.archived_at))
const rows: RegisterRow[] = []
for (const p of parties) {
const parts: ObservedParty[] = []
for (const k of p.alias_keys) {
const o = observedByKey.get(k)
if (o) {
parts.push(o)
claimedKeys.add(k)
}
}
if (p.archived_at) continue
const customerId = customerByParty.get(p.id) ?? null
const supplierId = supplierByParty.get(p.id) ?? null
const stats = mergeStats(parts)
rows.push({
id: p.id,
displayName: p.display_name,
orgNumber: p.org_number,
kind: p.kind,
status: p.status,
roles: { customerId, supplierId },
stats,
invoiceCount: (customerId ? (invoicesByCustomer.get(customerId) ?? 0) : 0) + (supplierId ? (invoicesBySupplier.get(supplierId) ?? 0) : 0),
reason: p.status === 'suggested' ? p.suggested_reason : null,
similar: similarById.get(p.id) ?? [],
defaultRoles: p.kind === 'person' ? ['customer'] : defaultRoles(stats),
createdAt: p.created_at,
})
}
const observedRows: ObservedRow[] = []
const generic = { count: 0, expenseSek: 0, examples: [] as string[] }
for (const o of observed) {
if (claimedKeys.has(o.key)) continue
if (o.label === 'party' || o.label === 'unsure') {
observedRows.push({ key: o.key, name: o.name || o.key, stats: statsFrom(o), label: o.label })
} else if (o.label === 'category') {
generic.count += 1
generic.expenseSek += Number(o.expense_sek) || 0
if (generic.examples.length < 3) generic.examples.push(o.name || o.key)
}
}
generic.expenseSek = roundOre(generic.expenseSek)
const suggestedRows = rows.filter((r) => r.status === 'suggested')
const counts: RegisterCounts = {
suggested: suggestedRows.length,
suggestedSuppliers: suggestedRows.filter((r) => r.defaultRoles.includes('supplier')).length,
suggestedCustomers: suggestedRows.filter((r) => r.defaultRoles.includes('customer')).length,
observed: observedRows.length,
confirmed: rows.length - suggestedRows.length,
}
const view = options.view ?? 'suggested'
const byMoney = (a: RegisterRow, b: RegisterRow) =>
(b.stats?.expenseSek ?? 0) + (b.stats?.revenueSek ?? 0) - ((a.stats?.expenseSek ?? 0) + (a.stats?.revenueSek ?? 0)) ||
a.displayName.localeCompare(b.displayName, 'sv')
const selected = (view === 'suggested' ? suggestedRows : []).filter((r) => matches(q, r.displayName, r.orgNumber)).sort(byMoney)
const observedSelected =
view === 'observed'
? observedRows
.filter((r) => matches(q, r.name, r.key))
.sort((a, b) => b.stats.expenseSek + b.stats.revenueSek - (a.stats.expenseSek + a.stats.revenueSek))
: []
return { counts, rows: selected, observed: observedSelected, generic, period }
}
// ── Dossier ─────────────────────────────────────────────────────────────────
export interface PartyFact {
id: string
field: string
value: unknown
rank: string
source: string
reference: Record<string, unknown> | null
fetchedAt: string | null
recordedAt: string
}
export interface PartyIdentity {
id: string
scheme: string
value: string
status: 'known' | 'unverified'
source: string
firstSeen: string | null
lastSeen: string | null
seenCount: number
}
export interface PartyDecision {
id: string
kind: string
note: string | null
createdAt: string
before: Record<string, unknown> | null
after: Record<string, unknown> | null
}
export interface PartyVoucher {
id: string
entryDate: string
description: string
voucher: string
amount: number
}
export interface Dossier {
party: RegisterRow & { aliasKeys: string[]; legalName: string | null; vatNumber: string | null }
facts: PartyFact[]
identities: PartyIdentity[]
decisions: PartyDecision[]
vouchers: PartyVoucher[]
similar: Array<{ id: string; displayName: string; orgNumber: string | null; status: string }>
}
/**
* The dossier for one party. A dismissed (archived) party is hidden here as
* it is in the register; a merged party resolves to its survivor, so a
* stale link lands on the right dossier instead of a dead one.
*/
export async function getDossier(supabase: SupabaseClient, companyId: string, partyId: string, hops = 0): Promise<Dossier | null> {
const { data: party, error } = await supabase
.from('parties')
.select('id, display_name, legal_name, org_number, vat_number, kind, status, alias_keys, suggested_reason, created_at, merged_into, archived_at')
.eq('company_id', companyId)
.eq('id', partyId)
.maybeSingle()
if (error) throw new Error(`parties lookup failed: ${error.message}`)
if (!party) return null
const p = party as PartyRecord & { legal_name: string | null; vat_number: string | null; merged_into: string | null }
if (p.merged_into) {
if (hops >= 16) return null
const { data: survivor, error: chainError } = await supabase.rpc('canonical_party_id', { p_party_id: p.id })
if (chainError) throw new Error(`canonical_party_id failed: ${chainError.message}`)
if (!survivor || survivor === p.id) return null
return getDossier(supabase, companyId, survivor as string, hops + 1)
}
if (p.archived_at) return null
const [facts, identities, decisions, customer, supplier, observed] = await Promise.all([
supabase
.from('party_facts')
.select('id, field, value, rank, source, reference, fetched_at, recorded_at')
.eq('company_id', companyId)
.eq('party_id', partyId)
.is('superseded_at', null)
.order('recorded_at', { ascending: false }),
supabase
.from('party_identities')
.select('id, scheme, value, status, source, first_seen, last_seen, seen_count')
.eq('company_id', companyId)
.eq('party_id', partyId)
.order('seen_count', { ascending: false }),
supabase
.from('party_decisions')
.select('id, kind, note, created_at, before, after')
.eq('company_id', companyId)
.eq('party_id', partyId)
.order('created_at', { ascending: false })
.limit(50),
supabase.from('customers').select('id').eq('company_id', companyId).eq('party_id', partyId).limit(1).maybeSingle(),
supabase.from('suppliers').select('id').eq('company_id', companyId).eq('party_id', partyId).limit(1).maybeSingle(),
getObservedParties(supabase, companyId, { limit: 5000 }),
])
for (const r of [facts, identities, decisions]) if (r.error) throw new Error(`dossier read failed: ${r.error.message}`)
const parts = observed.filter((o) => p.alias_keys.includes(o.key))
const stats = mergeStats(parts)
// Recent vouchers: the observed variants are the raw descriptions.
let vouchers: PartyVoucher[] = []
if (stats && stats.variants.length > 0) {
const { data } = await supabase
.from('journal_entries')
.select('id, entry_date, description, voucher_series, voucher_number, journal_entry_lines(debit_amount, account_number)')
.eq('company_id', companyId)
.eq('status', 'posted')
.in('description', stats.variants.slice(0, 200))
.order('entry_date', { ascending: false })
.limit(12)
vouchers = ((data ?? []) as Array<{
id: string
entry_date: string
description: string
voucher_series: string | null
voucher_number: number | null
journal_entry_lines: Array<{ debit_amount: number | null; account_number: string }> | null
}>).map((e) => ({
id: e.id,
entryDate: e.entry_date,
description: e.description,
voucher: e.voucher_series && e.voucher_number != null ? `${e.voucher_series}-${e.voucher_number}` : '',
amount: roundOre(
(e.journal_entry_lines ?? [])
.filter((l) => /^[3-8][0-9]{3}$/.test(l.account_number))
.reduce((s, l) => s + (Number(l.debit_amount) || 0), 0),
),
}))
}
const reason = p.status === 'suggested' ? p.suggested_reason : null
const live = await fetchAllRows<{ id: string; display_name: string; org_number: string | null; status: string; alias_keys: string[] }>(({ from, to }) =>
supabase
.from('parties')
.select('id, display_name, org_number, status, alias_keys')
.eq('company_id', companyId)
.is('merged_into', null)
.is('archived_at', null)
.range(from, to),
)
const liveById = new Map(live.map((l) => [l.id, l]))
const similarIds = new Set<string>((similarAmong(live).get(p.id) ?? []).map((s) => s.id))
for (const s of reason?.similar_to ?? []) if (liveById.has(s.party_id) && s.party_id !== p.id) similarIds.add(s.party_id)
const similar: Dossier['similar'] = [...similarIds].map((id) => {
const l = liveById.get(id)!
return { id: l.id, displayName: l.display_name, orgNumber: l.org_number, status: l.status }
})
return {
party: {
id: p.id,
displayName: p.display_name,
legalName: p.legal_name,
vatNumber: p.vat_number,
orgNumber: p.org_number,
kind: p.kind,
status: p.status,
aliasKeys: p.alias_keys,
roles: { customerId: (customer.data as { id: string } | null)?.id ?? null, supplierId: (supplier.data as { id: string } | null)?.id ?? null },
stats,
invoiceCount: 0,
reason,
similar: similar.map((s) => ({ id: s.id, displayName: s.displayName })),
defaultRoles: p.kind === 'person' ? ['customer'] : defaultRoles(stats),
createdAt: p.created_at,
},
facts: ((facts.data ?? []) as Array<Record<string, unknown>>).map((f) => ({
id: f.id as string,
field: f.field as string,
value: f.value,
rank: f.rank as string,
source: f.source as string,
reference: (f.reference as Record<string, unknown> | null) ?? null,
fetchedAt: (f.fetched_at as string | null) ?? null,
recordedAt: f.recorded_at as string,
})),
identities: ((identities.data ?? []) as Array<Record<string, unknown>>).map((i) => ({
id: i.id as string,
scheme: i.scheme as string,
value: i.value as string,
status: i.status as 'known' | 'unverified',
source: i.source as string,
firstSeen: (i.first_seen as string | null) ?? null,
lastSeen: (i.last_seen as string | null) ?? null,
seenCount: Number(i.seen_count) || 0,
})),
decisions: ((decisions.data ?? []) as Array<Record<string, unknown>>).map((d) => ({
id: d.id as string,
kind: d.kind as string,
note: (d.note as string | null) ?? null,
createdAt: d.created_at as string,
before: (d.before as Record<string, unknown> | null) ?? null,
after: (d.after as Record<string, unknown> | null) ?? null,
})),
vouchers,
similar,
}
}
+2 -2
View File
@@ -17,7 +17,7 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { coreKey } from './ledger-key'
import { coreKey, displayNameFromVoucherText } from './ledger-key'
import { getObservedParties, type ObservedParty } from './observed'
export interface IdentityEvidence {
@@ -110,7 +110,7 @@ function pickName(observed: ObservedParty, evidence: LedgerKeyEvidence | undefin
// upper-cased, truncated and prefixed by whatever the source system did.
const printed = evidence?.names[0]?.name
if (printed && printed.length >= 2) return { display: printed, legal: printed }
return { display: observed.name || observed.key }
return { display: displayNameFromVoucherText(observed.name || observed.key) }
}
function identitiesFrom(evidence: LedgerKeyEvidence | undefined): SuggestionIdentity[] {
+125
View File
@@ -8223,5 +8223,130 @@
"signed": "{who}, {when}",
"signed_other_date": "through {date} ({who}), not the balance sheet date",
"unsigned": "Not signed off"
},
"parties": {
"title": "Suggestions from the books",
"help": "Counterparts your vouchers and documents point to that are not among your suppliers and customers yet. Confirm and they are added, with the details from the documents. Nothing is posted, and it can be undone for 30 days.",
"summary": "{suggested} suggestions · {observed} only in the books",
"refresh": "Fetch suggestions",
"refreshing": "Fetching…",
"refreshed_title": "Suggestions updated",
"refreshed_description": "{created} new suggestions, {attached} attached to existing ones.",
"attn_suppliers": "{count} supplier suggestions are waiting from the books.",
"attn_customers": "{count} customer suggestions are waiting from the books.",
"attn_review": "Review",
"attn_fetch": "Show",
"attn_observed": "The books know {count} counterparts that are not in the register.",
"view_suggested": "Suggestions",
"view_observed": "Only in the books",
"search_placeholder": "Search name or org number",
"period_label": "Period",
"period_12m": "Last 12 months",
"period_all": "All history",
"th_name": "Name",
"th_why": "Why",
"th_becomes": "Becomes",
"th_rhythm": "Rhythm",
"th_account": "Account",
"th_revenue": "Revenue",
"th_expense": "Cost",
"th_vouchers": "Vouchers",
"becomes_aria": "What {name} becomes",
"role_customer": "Customer",
"role_supplier": "Supplier",
"role_none": "In the register",
"rhythm_weekly": "weekly",
"rhythm_monthly": "monthly",
"rhythm_quarterly": "quarterly",
"rhythm_yearly": "yearly",
"rhythm_irregular": "irregular",
"vouchers_count": "{count} vouchers",
"invoices_count": "{count} invoices",
"chip_duplicate": "Duplicate?",
"uncertain_name": "Uncertain name",
"reason_org": "Org number {org} in {docs} documents",
"reason_docs": "Name in {docs} documents",
"reason_ledger_only": "no org number or bankgiro in documents",
"reason_ambiguous": "several org numbers in the documents",
"reason_similar": "looks like {name}",
"reason_vouchers": "{count} vouchers",
"dismiss": "Hide",
"deselect": "Deselect",
"select_all": "Select all",
"selected_n": "{count} selected",
"selected_hint": "Added with the details from the documents. Undoable for 30 days; nothing is posted.",
"promote_n": "Add {count}",
"promote_dialog_title": "Add {count} to the register?",
"promote_dialog_body": "{detail}. Name, org number and bankgiro come from your documents and can be edited afterwards. Undoable for 30 days; no vouchers change and nothing is posted.",
"summary_suppliers": "{count} become suppliers",
"summary_customers": "{count} become customers",
"promoted_title": "{count} added · {detail}",
"promote_supplier": "Add as supplier",
"promote_customer": "Add as customer",
"dismissed_title": "{count} suggestions hidden",
"undo": "Undo",
"undone_title": "Undone",
"merge": "Merge",
"merge_dialog_title": "Merge",
"merge_body": "Vouchers, documents and invoices stay where they are. Undoable for 30 days.",
"merge_keep": "Keep",
"merge_kept": "kept",
"merge_confirm": "Merge {count}",
"merged_title": "Merged",
"merge_undone_title": "Merge undone",
"merge_none": "No similar ones found.",
"merge_search_placeholder": "Search a supplier or customer to merge with",
"dossier_kicker_suggested": "Suggestion",
"dossier_seen": "Seen {count} times",
"dossier_last": "last {date}",
"dossier_variants": "{count} name variants",
"section_money": "Money",
"section_bookkeeping": "Bookkeeping",
"section_facts": "What Accounted knows",
"section_vouchers": "Documents and vouchers",
"section_history": "History",
"money_expense": "Cost",
"money_revenue": "Revenue",
"money_period_12m": "last 12 months",
"money_period_all": "all history",
"money_first": "First seen",
"money_last": "Last seen",
"bk_account": "Account",
"bk_account_share": "Posted here in {count} of {total} cases",
"bk_when": "When posting",
"bk_when_value": "Suggested, never posted without you",
"fact_name": "Name",
"fact_legal_name": "Legal name",
"fact_org": "Org number",
"fact_vat": "VAT number",
"fact_bankgiro": "Bankgiro",
"fact_plusgiro": "Plusgiro",
"fact_missing": "Missing",
"fact_from_documents": "{count} documents",
"fact_from_ledger": "the books",
"fact_from_user": "you",
"identity_known": "known",
"identity_unverified": "unverified",
"vouchers_none": "No vouchers found.",
"decision_confirm": "Confirmed",
"decision_role": "Added",
"decision_dismiss": "Hidden",
"decision_merge": "Merged",
"decision_split": "Merge undone",
"decision_undo": "Undone",
"empty_suggested_title": "No suggestions right now",
"empty_suggested_description": "Fetch suggestions once you have imported vouchers or uploaded documents.",
"empty_observed_title": "No more counterparts in the books",
"empty_observed_description": "Everything in the vouchers is already among your suppliers and customers.",
"empty_search_title": "No matches",
"empty_search_description": "Try another name or org number.",
"go_suppliers": "To suppliers",
"generic_band": "Without a name in the text ({count})",
"generic_band_description": "{examples}. Your old system only wrote the category.",
"load_failed": "Could not load the suggestions.",
"action_failed": "That did not work. Try again.",
"viewer_disabled_tooltip": "You have read access in this company.",
"count_summary": "{count} suggestions",
"open_dossier": "Open {name}"
}
}
+125
View File
@@ -8223,5 +8223,130 @@
"signed": "{who}, {when}",
"signed_other_date": "t.o.m. {date} ({who}), inte balansdagen",
"unsigned": "Ej signerad"
},
"parties": {
"title": "Förslag från bokföringen",
"help": "Motparter som dina verifikat och underlag pekar ut men som inte finns bland dina leverantörer och kunder. Bekräfta så läggs de upp, med uppgifterna från underlagen. Ingenting bokförs, och det går att ångra i 30 dagar.",
"summary": "{suggested} förslag · {observed} bara i bokföringen",
"refresh": "Hämta förslag",
"refreshing": "Hämtar…",
"refreshed_title": "Förslag uppdaterade",
"refreshed_description": "{created} nya förslag, {attached} kopplade till befintliga.",
"attn_suppliers": "{count} förslag på leverantörer väntar från bokföringen.",
"attn_customers": "{count} förslag på kunder väntar från bokföringen.",
"attn_review": "Granska",
"attn_fetch": "Visa",
"attn_observed": "Bokföringen känner {count} motparter som inte finns i registret.",
"view_suggested": "Förslag",
"view_observed": "Bara i bokföringen",
"search_placeholder": "Sök namn eller org.nr",
"period_label": "Period",
"period_12m": "Senaste 12 mån",
"period_all": "Hela historiken",
"th_name": "Namn",
"th_why": "Varför",
"th_becomes": "Blir",
"th_rhythm": "Rytm",
"th_account": "Konto",
"th_revenue": "Intäkt",
"th_expense": "Kostnad",
"th_vouchers": "Verifikat",
"becomes_aria": "Vad {name} blir",
"role_customer": "Kund",
"role_supplier": "Leverantör",
"role_none": "I registret",
"rhythm_weekly": "varje vecka",
"rhythm_monthly": "varje månad",
"rhythm_quarterly": "varje kvartal",
"rhythm_yearly": "varje år",
"rhythm_irregular": "oregelbundet",
"vouchers_count": "{count} verifikat",
"invoices_count": "{count} fakturor",
"chip_duplicate": "Dubblett?",
"uncertain_name": "Osäkert namn",
"reason_org": "Org.nr {org} i {docs} underlag",
"reason_docs": "Namn i {docs} underlag",
"reason_ledger_only": "inget org.nr eller bankgiro i underlag",
"reason_ambiguous": "flera org.nr i underlagen",
"reason_similar": "liknar {name}",
"reason_vouchers": "{count} verifikat",
"dismiss": "Dölj",
"deselect": "Avmarkera",
"select_all": "Markera alla",
"selected_n": "{count} valda",
"selected_hint": "Läggs upp med uppgifterna från underlagen. Går att ångra i 30 dagar; ingenting bokförs.",
"promote_n": "Lägg upp {count}",
"promote_dialog_title": "Lägg upp {count} i registret?",
"promote_dialog_body": "{detail}. Namn, org.nr och bankgiro kommer från dina underlag och går att ändra efteråt. Går att ångra i 30 dagar; inga verifikat ändras och ingenting bokförs.",
"summary_suppliers": "{count} blir leverantörer",
"summary_customers": "{count} blir kunder",
"promoted_title": "{count} upplagda · {detail}",
"promote_supplier": "Lägg upp som leverantör",
"promote_customer": "Lägg upp som kund",
"dismissed_title": "{count} förslag dolda",
"undo": "Ångra",
"undone_title": "Ångrat",
"merge": "Slå ihop",
"merge_dialog_title": "Slå ihop",
"merge_body": "Verifikat, underlag och fakturor ligger kvar där de är. Går att ångra i 30 dagar.",
"merge_keep": "Behåll",
"merge_kept": "behålls",
"merge_confirm": "Slå ihop {count}",
"merged_title": "Ihopslagna",
"merge_undone_title": "Ihopslagning ångrad",
"merge_none": "Inga liknande hittade.",
"merge_search_placeholder": "Sök leverantör eller kund att slå ihop med",
"dossier_kicker_suggested": "Förslag",
"dossier_seen": "Sedd {count} gånger",
"dossier_last": "senast {date}",
"dossier_variants": "{count} namnvarianter",
"section_money": "Pengar",
"section_bookkeeping": "Bokföring",
"section_facts": "Vad Accounted vet",
"section_vouchers": "Underlag och verifikat",
"section_history": "Historik",
"money_expense": "Kostnad",
"money_revenue": "Intäkt",
"money_period_12m": "senaste 12 mån",
"money_period_all": "hela historiken",
"money_first": "Första gången",
"money_last": "Senast",
"bk_account": "Konto",
"bk_account_share": "Bokförd hit i {count} av {total} fall",
"bk_when": "Vid bokföring",
"bk_when_value": "Föreslås, bokförs aldrig utan dig",
"fact_name": "Namn",
"fact_legal_name": "Juridiskt namn",
"fact_org": "Org.nr",
"fact_vat": "Momsnr",
"fact_bankgiro": "Bankgiro",
"fact_plusgiro": "Plusgiro",
"fact_missing": "Saknas",
"fact_from_documents": "{count} underlag",
"fact_from_ledger": "bokföringen",
"fact_from_user": "du",
"identity_known": "känd",
"identity_unverified": "ej verifierad",
"vouchers_none": "Inga verifikat hittade.",
"decision_confirm": "Bekräftad",
"decision_role": "Upplagd",
"decision_dismiss": "Dold",
"decision_merge": "Ihopslagen",
"decision_split": "Ihopslagning ångrad",
"decision_undo": "Ångrad",
"empty_suggested_title": "Inga förslag just nu",
"empty_suggested_description": "Hämta förslag när du har importerat verifikat eller laddat upp underlag.",
"empty_observed_title": "Inga fler motparter i bokföringen",
"empty_observed_description": "Allt som syns i verifikaten finns redan bland dina leverantörer och kunder.",
"empty_search_title": "Inga träffar",
"empty_search_description": "Prova ett annat namn eller org.nr.",
"go_suppliers": "Till leverantörer",
"generic_band": "Utan namn i texten ({count})",
"generic_band_description": "{examples}. Ditt gamla system skrev bara kategori.",
"load_failed": "Kunde inte hämta förslagen.",
"action_failed": "Det gick inte. Försök igen.",
"viewer_disabled_tooltip": "Du har läsbehörighet i det här bolaget.",
"count_summary": "{count} förslag",
"open_dossier": "Öppna {name}"
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
]
},
"naiveOreRound": {
"count": 617
"count": 615
},
"handRolledInvariants": {
"count": 113
@@ -0,0 +1,141 @@
-- Parties, phase 1e: the queue's confirm and dismiss are undoable for 30 days.
--
-- decide_parties now snapshots the suggested_reason it clears, so a confirm
-- can be reversed without losing why the row was suggested. A new decision
-- kind 'undo' records the reversal; merges keep their own 'split' record
-- (undo_party_merge, 20260902210000).
ALTER TABLE public.party_decisions DROP CONSTRAINT IF EXISTS party_decisions_kind_check;
ALTER TABLE public.party_decisions ADD CONSTRAINT party_decisions_kind_check
CHECK (kind IN ('confirm', 'merge', 'split', 'rename', 'role', 'dismiss', 'pin', 'ignore', 'label', 'undo'));
CREATE OR REPLACE FUNCTION public.decide_parties(
p_company_id uuid,
p_user_id uuid,
p_party_ids uuid[],
p_kind text,
p_note text DEFAULT NULL
)
RETURNS integer
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_count integer := 0;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'decide_parties: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
IF p_kind NOT IN ('confirm', 'dismiss') THEN
RAISE EXCEPTION 'decide_parties: kind must be confirm or dismiss, got %', p_kind USING ERRCODE = '22023';
END IF;
IF p_kind = 'confirm' THEN
WITH target AS (
SELECT p.id, p.suggested_reason
FROM public.parties p
WHERE p.company_id = p_company_id AND p.id = ANY(p_party_ids) AND p.merged_into IS NULL
AND (p.status <> 'confirmed' OR p.archived_at IS NOT NULL)
FOR UPDATE
), changed AS (
UPDATE public.parties p
SET status = 'confirmed', suggested_reason = NULL, archived_at = NULL
FROM target t
WHERE p.id = t.id
RETURNING p.id, t.suggested_reason AS old_reason
), logged AS (
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
SELECT c.id, p_company_id, p_user_id, 'confirm',
jsonb_build_object('status', 'suggested', 'suggested_reason', c.old_reason),
jsonb_build_object('status', 'confirmed'), p_note
FROM changed c
RETURNING 1
)
SELECT count(*) INTO v_count FROM logged;
ELSE
-- Dismiss is the queue's "not a party" answer: it only touches suggested
-- rows. A confirmed party is archived through its own action later.
WITH changed AS (
UPDATE public.parties p
SET archived_at = now()
WHERE p.company_id = p_company_id AND p.id = ANY(p_party_ids) AND p.merged_into IS NULL
AND p.status = 'suggested' AND p.archived_at IS NULL
RETURNING p.id, p.status
), logged AS (
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
SELECT c.id, p_company_id, p_user_id, 'dismiss',
jsonb_build_object('status', c.status, 'archived', false), jsonb_build_object('status', c.status, 'archived', true), p_note
FROM changed c
RETURNING 1
)
SELECT count(*) INTO v_count FROM logged;
END IF;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION public.decide_parties(uuid, uuid, uuid[], text, text) IS
'Bulk confirm (suggested -> confirmed, reason snapshotted in the decision) or dismiss (archive a suggested party), one party_decisions row each. Reversible for 30 days through undo_party_decisions.';
-- ── Undo the latest confirm or dismiss per party ────────────────────────────
CREATE OR REPLACE FUNCTION public.undo_party_decisions(
p_company_id uuid,
p_user_id uuid,
p_party_ids uuid[]
)
RETURNS integer
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_count integer := 0;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'undo_party_decisions: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
-- The latest confirm/dismiss per party, unless a later decision of any
-- kind already followed it: undo only ever reverses the most recent step.
WITH latest AS (
SELECT DISTINCT ON (d.party_id) d.id, d.party_id, d.kind, d.before, d.created_at
FROM public.party_decisions d
WHERE d.company_id = p_company_id AND d.party_id = ANY(p_party_ids)
ORDER BY d.party_id, d.created_at DESC, d.id DESC
),
eligible AS (
SELECT l.* FROM latest l
WHERE l.kind IN ('confirm', 'dismiss') AND l.created_at >= now() - interval '30 days'
),
reverted AS (
UPDATE public.parties p
SET status = CASE WHEN e.kind = 'confirm' THEN 'suggested' ELSE p.status END,
suggested_reason = CASE WHEN e.kind = 'confirm' THEN e.before->'suggested_reason' ELSE p.suggested_reason END,
archived_at = CASE WHEN e.kind = 'dismiss' THEN NULL ELSE p.archived_at END
FROM eligible e
WHERE p.id = e.party_id AND p.company_id = p_company_id AND p.merged_into IS NULL
RETURNING p.id, e.id AS decision_id, e.kind
),
logged AS (
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
SELECT r.id, p_company_id, p_user_id, 'undo',
jsonb_build_object('decision_id', r.decision_id, 'kind', r.kind),
jsonb_build_object('status', 'suggested', 'archived', false),
'undo ' || r.kind
FROM reverted r
RETURNING 1
)
SELECT count(*) INTO v_count FROM logged;
RETURN v_count;
END;
$$;
REVOKE ALL ON FUNCTION public.undo_party_decisions(uuid, uuid, uuid[]) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.undo_party_decisions(uuid, uuid, uuid[]) TO authenticated, service_role;
COMMENT ON FUNCTION public.undo_party_decisions(uuid, uuid, uuid[]) IS
'Reverses the latest confirm or dismiss per party within 30 days (confirm -> suggested with its reason restored; dismiss -> unarchived). Logs an undo decision. Returns the number of parties reverted.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,92 @@
-- get_ledger_key_evidence: a company without an org number lost every hard
-- key. (d.org = own.org) is NULL when own.org is NULL, NOT NULL is NULL, and
-- the row fell out of the useful set, so orgs, names, bankgiro and plusgiro
-- came back empty while docs still counted. Sole traders and every company
-- that has not filled in its org number were affected. The comparison is now
-- coalesced to false: with no own org number nothing can be self-extracted.
-- Same body as 20260902200000 otherwise.
-- pg-test: covered-by tests/pg/party-suggestions.pg.test.ts
CREATE OR REPLACE FUNCTION public.get_ledger_key_evidence(p_company_id uuid)
RETURNS jsonb
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path TO 'public'
AS $$
WITH own AS (
SELECT public.normalize_org_number(c.org_number) AS org
FROM public.companies c WHERE c.id = p_company_id
),
entries AS (
SELECT je.id, je.entry_date, public.ledger_key(je.description) AS k
FROM public.journal_entries je
WHERE je.company_id = p_company_id
AND je.status = 'posted'
AND je.source_type NOT IN ('storno', 'opening_balance', 'year_end', 'vat_settlement')
AND je.description IS NOT NULL
AND btrim(je.description) <> ''
AND NOT EXISTS (
SELECT 1 FROM public.transactions t
WHERE t.journal_entry_id = je.id
AND t.merchant_name IS NOT NULL
AND btrim(t.merchant_name) <> ''
)
),
docs AS (
SELECT e.k, e.entry_date, d.id AS document_id,
public.normalize_org_number(d.extracted_data->'supplier'->>'orgNumber') AS org,
nullif(upper(regexp_replace(coalesce(d.extracted_data->'supplier'->>'vatNumber', ''), '[^0-9A-Za-z]', '', 'g')), '') AS vat,
nullif(regexp_replace(coalesce(d.extracted_data->'supplier'->>'bankgiro', ''), '[^0-9]', '', 'g'), '') AS bankgiro,
nullif(regexp_replace(coalesce(d.extracted_data->'supplier'->>'plusgiro', ''), '[^0-9]', '', 'g'), '') AS plusgiro,
nullif(btrim(d.extracted_data->'supplier'->>'name'), '') AS name
FROM entries e
JOIN public.document_attachments d
ON d.journal_entry_id = e.id
AND d.company_id = p_company_id
AND d.is_current_version
AND jsonb_typeof(d.extracted_data->'supplier') = 'object'
WHERE e.k <> ''
),
classified AS (
SELECT d.*, coalesce(d.org IS NOT NULL AND d.org = own.org, false) AS is_self
FROM docs d CROSS JOIN own
),
useful AS (SELECT * FROM classified WHERE NOT is_self),
orgs AS (
SELECT k, org, count(*) AS n FROM useful WHERE org IS NOT NULL GROUP BY k, org
),
vats AS (
SELECT k, vat, count(*) AS n FROM useful WHERE vat IS NOT NULL GROUP BY k, vat
),
names AS (
SELECT k, name, count(*) AS n FROM useful WHERE name IS NOT NULL GROUP BY k, name
),
bg AS (
SELECT k, bankgiro AS value, count(*) AS n, min(entry_date) AS first_seen, max(entry_date) AS last_seen
FROM useful WHERE bankgiro IS NOT NULL AND length(bankgiro) BETWEEN 7 AND 8 GROUP BY k, bankgiro
),
pg AS (
SELECT k, plusgiro AS value, count(*) AS n, min(entry_date) AS first_seen, max(entry_date) AS last_seen
FROM useful WHERE plusgiro IS NOT NULL AND length(plusgiro) BETWEEN 5 AND 8 GROUP BY k, plusgiro
),
per_key AS (
SELECT c.k,
count(*) AS docs,
count(*) FILTER (WHERE c.is_self) AS self_docs
FROM classified c GROUP BY c.k
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'key', p.k,
'docs', p.docs,
'self_docs', p.self_docs,
'orgs', coalesce((SELECT jsonb_agg(jsonb_build_object('org', o.org, 'n', o.n) ORDER BY o.n DESC, o.org) FROM orgs o WHERE o.k = p.k), '[]'::jsonb),
'vat_numbers', coalesce((SELECT jsonb_agg(jsonb_build_object('vat', v.vat, 'n', v.n) ORDER BY v.n DESC, v.vat) FROM vats v WHERE v.k = p.k), '[]'::jsonb),
'names', coalesce((SELECT jsonb_agg(jsonb_build_object('name', x.name, 'n', x.n) ORDER BY x.n DESC, x.name) FROM names x WHERE x.k = p.k), '[]'::jsonb),
'bankgiro', coalesce((SELECT jsonb_agg(jsonb_build_object('value', b.value, 'n', b.n, 'first_seen', b.first_seen, 'last_seen', b.last_seen) ORDER BY b.n DESC, b.value) FROM bg b WHERE b.k = p.k), '[]'::jsonb),
'plusgiro', coalesce((SELECT jsonb_agg(jsonb_build_object('value', g.value, 'n', g.n, 'first_seen', g.first_seen, 'last_seen', g.last_seen) ORDER BY g.n DESC, g.value) FROM pg g WHERE g.k = p.k), '[]'::jsonb)
) ORDER BY p.docs DESC, p.k), '[]'::jsonb)
FROM per_key p;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,182 @@
-- Parties, phase 1f: every supplier and customer gets a party on write.
--
-- The backfill in 20260902160000 linked the rows that existed on that day;
-- rows created since (108 on prod within a day) had no party and never
-- reached the Kontakter register. A trigger closes every write path at
-- once: the dialogs, the v1 API, MCP tools, imports and provider
-- migrations. It runs BEFORE INSERT and BEFORE UPDATE OF party_id, name,
-- org_number, customer_type on customers (supplier_type on suppliers), so
-- a row whose party was cleared, or whose org number arrived later, is
-- linked or re-keyed too.
--
-- Rules, all inherited from ensure_party:
-- * find-or-create by org number inside the company; never by name;
-- * a private customer (customer_type = 'individual') carries a person's
-- identity, and a personal number is not an org number: the party is
-- created without org_number, kind = 'person', so nothing of the
-- personnummer lands in parties (customers keeps its own masked field);
-- * an empty name gets no party (ensure_party refuses one);
-- * rows in a company archived by a migration reset are immutable and
-- the trigger never fires on them (the archive is not written to).
-- Origin is 'manual' for interactive writes and 'import' when the row
-- arrives inside a migration or import: the row does not know, so the
-- trigger records 'manual' and the pipeline attaches evidence later.
CREATE OR REPLACE FUNCTION public.link_party_on_role_write()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_is_person boolean := false;
v_org text;
v_kind text;
v_party_org text;
v_merged uuid;
BEGIN
IF NEW.company_id IS NULL OR nullif(btrim(coalesce(NEW.name, '')), '') IS NULL THEN
RETURN NEW;
END IF;
-- The party was deleted and the foreign key cleared the link (ON DELETE
-- SET NULL arrives here as an UPDATE): keep it cleared.
IF TG_OP = 'UPDATE' AND NEW.party_id IS NULL AND OLD.party_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM public.parties p WHERE p.id = OLD.party_id) THEN
RETURN NEW;
END IF;
IF TG_TABLE_NAME = 'customers' THEN
v_is_person := (NEW.customer_type = 'individual');
END IF;
v_org := CASE WHEN v_is_person THEN NULL ELSE public.normalize_org_number(NEW.org_number) END;
v_kind := CASE WHEN v_is_person THEN 'person' ELSE 'company' END;
-- Already linked: keep the link unless the org number now points at a
-- different live party (the user corrected it). A party without an org
-- number simply learns it.
IF NEW.party_id IS NOT NULL THEN
SELECT p.org_number, p.merged_into INTO v_party_org, v_merged
FROM public.parties p
WHERE p.id = NEW.party_id AND p.company_id = NEW.company_id;
IF NOT FOUND THEN
-- A party of another company: the composite foreign key would refuse
-- it too; refusing here keeps the error the same on every path.
RAISE EXCEPTION 'party % is not a party of this company', NEW.party_id USING ERRCODE = '23503';
END IF;
IF v_merged IS NOT NULL THEN
-- Linked to a merged party: follow the chain to the survivor.
NEW.party_id := public.canonical_party_id(NEW.party_id);
SELECT p.org_number INTO v_party_org FROM public.parties p WHERE p.id = NEW.party_id;
END IF;
IF v_org IS NOT NULL AND v_party_org IS NULL THEN
UPDATE public.parties p SET org_number = v_org
WHERE p.id = NEW.party_id
AND NOT EXISTS (SELECT 1 FROM public.parties q WHERE q.company_id = NEW.company_id AND q.org_number = v_org AND q.merged_into IS NULL AND q.id <> NEW.party_id);
RETURN NEW;
ELSIF v_org IS NOT NULL AND v_party_org <> v_org THEN
NEW.party_id := NULL;
ELSE
RETURN NEW;
END IF;
END IF;
NEW.party_id := public.ensure_party(NEW.company_id, NEW.user_id, NEW.name, v_org, v_kind, 'manual');
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.link_party_on_role_write() FROM PUBLIC, anon;
-- SECURITY DEFINER because ensure_party checks auth.uid() against p_user_id
-- and a supplier may be created for a company by a colleague: the row's
-- user_id is the owner of record, the caller is whoever is writing. Inside
-- a trigger the write itself is already authorised by RLS on the role table.
DROP TRIGGER IF EXISTS customers_link_party ON public.customers;
CREATE TRIGGER customers_link_party
BEFORE INSERT OR UPDATE OF party_id, name, org_number, customer_type ON public.customers
FOR EACH ROW EXECUTE FUNCTION public.link_party_on_role_write();
DROP TRIGGER IF EXISTS suppliers_link_party ON public.suppliers;
CREATE TRIGGER suppliers_link_party
BEFORE INSERT OR UPDATE OF party_id, name, org_number, supplier_type ON public.suppliers
FOR EACH ROW EXECUTE FUNCTION public.link_party_on_role_write();
-- ensure_party runs under the definer here, where auth.uid() is still the
-- caller's; relax its identity check for trigger context by letting the
-- definer pass. The RPC path (auth.uid() = caller) is unchanged.
CREATE OR REPLACE FUNCTION public.ensure_party(
p_company_id uuid,
p_user_id uuid,
p_name text,
p_org_number text DEFAULT NULL,
p_kind text DEFAULT 'company',
p_origin text DEFAULT 'manual'
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_org text := public.normalize_org_number(p_org_number);
v_name text := btrim(coalesce(p_name, ''));
v_id uuid;
BEGIN
-- Authenticated callers write under their own identity; the service role
-- (auth.uid() NULL) and the role-link trigger (pg_trigger_depth() > 0,
-- the row's owner is the recorded user) may act for another user.
IF pg_trigger_depth() = 0 AND auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'ensure_party: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
IF v_name = '' THEN
RAISE EXCEPTION 'ensure_party: name is required';
END IF;
IF v_org IS NOT NULL THEN
SELECT id INTO v_id FROM public.parties
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL
LIMIT 1;
IF v_id IS NOT NULL THEN RETURN v_id; END IF;
END IF;
INSERT INTO public.parties (company_id, user_id, display_name, org_number, kind, origin)
VALUES (p_company_id, p_user_id, v_name, v_org, p_kind, p_origin)
ON CONFLICT DO NOTHING
RETURNING id INTO v_id;
IF v_id IS NULL THEN
-- Lost a race on the live org-number index: return the winner.
SELECT id INTO v_id FROM public.parties
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL
LIMIT 1;
END IF;
RETURN v_id;
END;
$$;
-- Catch-up for the rows created between the backfill and this trigger.
-- Same skip rules as the backfill: nameless rows and archived companies.
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT id FROM public.suppliers
WHERE party_id IS NULL AND company_id IS NOT NULL
AND nullif(btrim(name), '') IS NOT NULL
AND company_id NOT IN (SELECT source_company_id FROM public.company_migration_resets)
ORDER BY created_at, id
LOOP
UPDATE public.suppliers SET party_id = NULL WHERE id = r.id;
END LOOP;
FOR r IN
SELECT id FROM public.customers
WHERE party_id IS NULL AND company_id IS NOT NULL
AND nullif(btrim(name), '') IS NOT NULL
AND company_id NOT IN (SELECT source_company_id FROM public.company_migration_resets)
ORDER BY created_at, id
LOOP
UPDATE public.customers SET party_id = NULL WHERE id = r.id;
END LOOP;
END $$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,200 @@
-- Parties, phase 1g: a suggestion is confirmed INTO a role.
--
-- Founder decision 2026-09-03: users know two words, kund and leverantör,
-- and a "kontakt" with no role is a state nobody has seen in Fortnox or
-- Bokio. Confirming a suggestion therefore creates the supplier and/or
-- customer row straight away, filled from what the documents said (org
-- number, VAT number, bankgiro, plusgiro). The party model stays underneath
-- unchanged: the role rows link to the party through party_id and the
-- role-link trigger; the register the user sees is Leverantörer and Kunder.
--
-- promote_parties(company, user, items) items: [{party_id, roles: [...]}]
-- undo_party_promotions(company, user, party_ids) within 30 days
--
-- Undo archives the role rows the promotion created (archived_at, and
-- is_active = false on suppliers, the same state the v1 API's archive
-- leaves) and puts the party back in the queue. Rows the promotion did not
-- create are left alone.
CREATE OR REPLACE FUNCTION public.promote_parties(
p_company_id uuid,
p_user_id uuid,
p_items jsonb
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_item jsonb;
v_party public.parties%ROWTYPE;
v_roles text[];
v_supplier_id uuid;
v_customer_id uuid;
v_created text[];
v_bankgiro text;
v_plusgiro text;
v_suppliers integer := 0;
v_customers integer := 0;
v_parties integer := 0;
v_supplier_type text;
v_customer_type text;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'promote_parties: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
IF p_items IS NULL OR jsonb_typeof(p_items) <> 'array' THEN
RAISE EXCEPTION 'promote_parties: p_items must be a JSON array' USING ERRCODE = '22023';
END IF;
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) LOOP
v_roles := ARRAY(SELECT DISTINCT r FROM jsonb_array_elements_text(coalesce(v_item->'roles', '[]'::jsonb)) AS r WHERE r IN ('supplier', 'customer'));
IF coalesce(array_length(v_roles, 1), 0) = 0 THEN
RAISE EXCEPTION 'promote_parties: every item needs at least one of supplier, customer' USING ERRCODE = '22023';
END IF;
SELECT * INTO v_party FROM public.parties
WHERE id = (v_item->>'party_id')::uuid AND company_id = p_company_id AND merged_into IS NULL AND archived_at IS NULL
FOR UPDATE;
IF v_party.id IS NULL THEN
RAISE EXCEPTION 'promote_parties: party % is not a live party of this company', v_item->>'party_id' USING ERRCODE = '23503';
END IF;
v_created := '{}';
SELECT s.id INTO v_supplier_id FROM public.suppliers s
WHERE s.company_id = p_company_id AND s.party_id = v_party.id AND s.archived_at IS NULL
ORDER BY s.created_at LIMIT 1;
SELECT c.id INTO v_customer_id FROM public.customers c
WHERE c.company_id = p_company_id AND c.party_id = v_party.id AND c.archived_at IS NULL
ORDER BY c.created_at LIMIT 1;
-- Identities are stored as digits; the supplier form writes them the way
-- they are printed (5317-0900, 12 34 56-7 style plusgiro as 123456-7).
SELECT CASE WHEN length(i.value) IN (7, 8) THEN left(i.value, length(i.value) - 4) || '-' || right(i.value, 4) ELSE i.value END
INTO v_bankgiro FROM public.party_identities i
WHERE i.party_id = v_party.id AND i.scheme = 'bankgiro' ORDER BY i.seen_count DESC, i.last_seen DESC NULLS LAST LIMIT 1;
SELECT CASE WHEN length(i.value) >= 2 THEN left(i.value, length(i.value) - 1) || '-' || right(i.value, 1) ELSE i.value END
INTO v_plusgiro FROM public.party_identities i
WHERE i.party_id = v_party.id AND i.scheme = 'plusgiro' ORDER BY i.seen_count DESC, i.last_seen DESC NULLS LAST LIMIT 1;
-- Type from the numbers we hold: a Swedish org number means a Swedish
-- business; an EU VAT number without one means an EU business; nothing
-- means we do not know, and swedish_business is the form's own default.
v_supplier_type := CASE
WHEN v_party.org_number IS NOT NULL THEN 'swedish_business'
WHEN v_party.vat_number IS NOT NULL AND v_party.vat_number !~* '^SE' THEN 'eu_business'
ELSE 'swedish_business' END;
v_customer_type := CASE
WHEN v_party.kind = 'person' THEN 'individual'
ELSE v_supplier_type END;
IF 'supplier' = ANY(v_roles) AND v_supplier_id IS NULL THEN
INSERT INTO public.suppliers (company_id, user_id, name, supplier_type, org_number, vat_number, bankgiro, plusgiro, party_id)
VALUES (p_company_id, p_user_id, v_party.display_name, v_supplier_type, v_party.org_number, v_party.vat_number, v_bankgiro, v_plusgiro, v_party.id)
RETURNING id INTO v_supplier_id;
v_created := array_append(v_created, 'supplier');
v_suppliers := v_suppliers + 1;
END IF;
IF 'customer' = ANY(v_roles) AND v_customer_id IS NULL THEN
INSERT INTO public.customers (company_id, user_id, name, customer_type, org_number, vat_number, party_id)
VALUES (p_company_id, p_user_id, v_party.display_name, v_customer_type, v_party.org_number, v_party.vat_number, v_party.id)
RETURNING id INTO v_customer_id;
v_created := array_append(v_created, 'customer');
v_customers := v_customers + 1;
END IF;
UPDATE public.parties SET status = 'confirmed', suggested_reason = NULL WHERE id = v_party.id;
v_parties := v_parties + 1;
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
VALUES (v_party.id, p_company_id, p_user_id, 'role',
jsonb_build_object('status', v_party.status, 'suggested_reason', v_party.suggested_reason),
jsonb_build_object('status', 'confirmed', 'roles', to_jsonb(v_roles), 'created', to_jsonb(v_created),
'supplier_id', v_supplier_id, 'customer_id', v_customer_id),
NULL);
END LOOP;
RETURN jsonb_build_object('parties', v_parties, 'suppliers', v_suppliers, 'customers', v_customers);
END;
$$;
REVOKE ALL ON FUNCTION public.promote_parties(uuid, uuid, jsonb) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.promote_parties(uuid, uuid, jsonb) TO authenticated, service_role;
COMMENT ON FUNCTION public.promote_parties(uuid, uuid, jsonb) IS
'Confirms suggested parties into roles: creates the supplier and/or customer row from the party''s facts (no duplicate when a live role row already points at the party), sets status confirmed, logs a role decision. Undo through undo_party_promotions within 30 days.';
CREATE OR REPLACE FUNCTION public.undo_party_promotions(
p_company_id uuid,
p_user_id uuid,
p_party_ids uuid[]
)
RETURNS integer
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_count integer := 0;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'undo_party_promotions: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
WITH latest AS (
SELECT DISTINCT ON (d.party_id) d.id, d.party_id, d.kind, d.before, d.after, d.created_at
FROM public.party_decisions d
WHERE d.company_id = p_company_id AND d.party_id = ANY(p_party_ids)
ORDER BY d.party_id, d.created_at DESC, d.id DESC
),
eligible AS (
SELECT l.* FROM latest l WHERE l.kind = 'role' AND l.created_at >= now() - interval '30 days'
),
archived_suppliers AS (
UPDATE public.suppliers s
SET archived_at = now(), is_active = false
FROM eligible e
WHERE s.company_id = p_company_id AND s.id = (e.after->>'supplier_id')::uuid
AND e.after->'created' ? 'supplier' AND s.archived_at IS NULL
AND NOT EXISTS (SELECT 1 FROM public.supplier_invoices si WHERE si.supplier_id = s.id)
RETURNING s.id
),
archived_customers AS (
UPDATE public.customers c
SET archived_at = now()
FROM eligible e
WHERE c.company_id = p_company_id AND c.id = (e.after->>'customer_id')::uuid
AND e.after->'created' ? 'customer' AND c.archived_at IS NULL
AND NOT EXISTS (SELECT 1 FROM public.invoices i WHERE i.customer_id = c.id)
RETURNING c.id
),
reverted AS (
UPDATE public.parties p
SET status = 'suggested', suggested_reason = e.before->'suggested_reason'
FROM eligible e
WHERE p.id = e.party_id AND p.company_id = p_company_id AND p.merged_into IS NULL
RETURNING p.id, e.id AS decision_id
),
logged AS (
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
SELECT r.id, p_company_id, p_user_id, 'undo',
jsonb_build_object('decision_id', r.decision_id, 'kind', 'role'),
jsonb_build_object('status', 'suggested',
'archived_suppliers', (SELECT count(*) FROM archived_suppliers),
'archived_customers', (SELECT count(*) FROM archived_customers)),
'undo role'
FROM reverted r
RETURNING 1
)
SELECT count(*) INTO v_count FROM logged;
RETURN v_count;
END;
$$;
REVOKE ALL ON FUNCTION public.undo_party_promotions(uuid, uuid, uuid[]) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.undo_party_promotions(uuid, uuid, uuid[]) TO authenticated, service_role;
COMMENT ON FUNCTION public.undo_party_promotions(uuid, uuid, uuid[]) IS
'Reverses the latest promotion per party within 30 days: archives the supplier/customer rows that promotion created (unless invoices already point at them), returns the party to the queue with its reason, logs an undo decision.';
NOTIFY pgrst, 'reload schema';
+3 -8
View File
@@ -151,15 +151,10 @@ describe('parties substrate (pg)', () => {
VALUES ($1, $2, $3, 'pg test: the party backfill must skip archived companies', '{}'::jsonb, '{}'::jsonb)`,
[frozen.companyId, replacement.companyId, frozen.userId],
)
const partyId = await getPool().query<{ id: string }>(
`SELECT public.ensure_party($1, $2, 'Frozen Supplier', NULL, 'company', 'backfill') AS id`,
[replacement.companyId, replacement.userId],
)
await expect(
getPool().query(`UPDATE public.suppliers SET party_id = $1 WHERE id = $2`, [partyId.rows[0]!.id, rows[0]!.id]),
).rejects.toMatchObject({ code: 'P0001' })
const before = await getPool().query<{ party_id: string | null }>(`SELECT party_id FROM public.suppliers WHERE id = $1`, [rows[0]!.id])
await expect(getPool().query(`UPDATE public.suppliers SET party_id = NULL WHERE id = $1`, [rows[0]!.id])).rejects.toMatchObject({ code: 'P0001' })
const still = await getPool().query<{ party_id: string | null }>(`SELECT party_id FROM public.suppliers WHERE id = $1`, [rows[0]!.id])
expect(still.rows[0]!.party_id).toBeNull()
expect(still.rows[0]!.party_id).toBe(before.rows[0]!.party_id)
})
it('ensure_party refuses a p_user_id that is not the authenticated caller', async () => {
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import { seedCompany } from './fixtures'
const ORG = '5564300142'
async function suggested(companyId: string, userId: string, name: string, over: { org?: string; vat?: string; bankgiro?: string; kind?: string } = {}): Promise<string> {
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.parties (company_id, user_id, display_name, org_number, vat_number, kind, status, suggested_reason)
VALUES ($1, $2, $3, $4, $5, $6, 'suggested', '{"attach":"new","occurrences":3}'::jsonb) RETURNING id`,
[companyId, userId, name, over.org ?? null, over.vat ?? null, over.kind ?? 'company'],
)
if (over.bankgiro) {
await getPool().query(
`INSERT INTO public.party_identities (party_id, company_id, user_id, scheme, value, source, seen_count) VALUES ($1, $2, $3, 'bankgiro', $4, 'document', 3)`,
[rows[0]!.id, companyId, userId, over.bankgiro],
)
}
return rows[0]!.id
}
async function promote(companyId: string, userId: string, items: unknown[]) {
const { rows } = await getPool().query<{ r: Record<string, number> }>(`SELECT public.promote_parties($1, $2, $3::jsonb) AS r`, [companyId, userId, JSON.stringify(items)])
return rows[0]!.r
}
async function undo(companyId: string, userId: string, ids: string[]): Promise<number> {
const { rows } = await getPool().query<{ n: number }>(`SELECT public.undo_party_promotions($1, $2, $3::uuid[]) AS n`, [companyId, userId, ids])
return rows[0]!.n
}
describe('promote_parties / undo_party_promotions (pg)', () => {
it('creates a supplier from the party facts, confirms the party, and logs a role decision', async () => {
const c = await seedCompany()
const id = await suggested(c.companyId, c.userId, 'Beijer Byggmaterial AB', { org: ORG, vat: 'SE556430014201', bankgiro: '53170900' })
expect(await promote(c.companyId, c.userId, [{ party_id: id, roles: ['supplier'] }])).toEqual({ parties: 1, suppliers: 1, customers: 0 })
const s = await getPool().query<{ name: string; supplier_type: string; org_number: string; vat_number: string; bankgiro: string; party_id: string }>(
`SELECT name, supplier_type, org_number, vat_number, bankgiro, party_id FROM public.suppliers WHERE company_id = $1`,
[c.companyId],
)
expect(s.rows).toEqual([{ name: 'Beijer Byggmaterial AB', supplier_type: 'swedish_business', org_number: ORG, vat_number: 'SE556430014201', bankgiro: '5317-0900', party_id: id }])
const p = await getPool().query<{ status: string; reason: unknown }>(`SELECT status, suggested_reason AS reason FROM public.parties WHERE id = $1`, [id])
expect(p.rows[0]).toEqual({ status: 'confirmed', reason: null })
const d = await getPool().query<{ kind: string; created: string[] }>(
`SELECT kind, ARRAY(SELECT jsonb_array_elements_text(after->'created')) AS created FROM public.party_decisions WHERE party_id = $1`,
[id],
)
expect(d.rows).toEqual([{ kind: 'role', created: ['supplier'] }])
})
it('creates both roles for one party, never a duplicate for a role that already exists', async () => {
const c = await seedCompany()
const id = await suggested(c.companyId, c.userId, 'Nordic Studio HB', { org: ORG })
await promote(c.companyId, c.userId, [{ party_id: id, roles: ['supplier', 'customer'] }])
const again = await promote(c.companyId, c.userId, [{ party_id: id, roles: ['supplier', 'customer'] }])
expect(again).toEqual({ parties: 1, suppliers: 0, customers: 0 })
const n = await getPool().query<{ s: string; k: string }>(
`SELECT (SELECT count(*) FROM public.suppliers WHERE party_id = $1)::text AS s, (SELECT count(*) FROM public.customers WHERE party_id = $1)::text AS k`,
[id],
)
expect(n.rows[0]).toEqual({ s: '1', k: '1' })
})
it('types an EU party without org number as eu_business and a person party as an individual customer', async () => {
const c = await seedCompany()
const eu = await suggested(c.companyId, c.userId, 'Adobe Systems Software Ireland Ltd', { vat: 'IE6364992H' })
const person = await suggested(c.companyId, c.userId, 'Anna Andersson', { kind: 'person' })
await promote(c.companyId, c.userId, [
{ party_id: eu, roles: ['supplier'] },
{ party_id: person, roles: ['customer'] },
])
const s = await getPool().query<{ supplier_type: string }>(`SELECT supplier_type FROM public.suppliers WHERE party_id = $1`, [eu])
expect(s.rows[0]!.supplier_type).toBe('eu_business')
const k = await getPool().query<{ customer_type: string; org_number: string | null }>(`SELECT customer_type, org_number FROM public.customers WHERE party_id = $1`, [person])
expect(k.rows[0]).toEqual({ customer_type: 'individual', org_number: null })
})
it('undo archives the rows the promotion created and returns the party to the queue; a second undo is a no-op', async () => {
const c = await seedCompany()
const id = await suggested(c.companyId, c.userId, 'Loopia AB', { org: ORG })
await promote(c.companyId, c.userId, [{ party_id: id, roles: ['supplier'] }])
expect(await undo(c.companyId, c.userId, [id])).toBe(1)
const s = await getPool().query<{ archived: boolean; is_active: boolean }>(`SELECT archived_at IS NOT NULL AS archived, is_active FROM public.suppliers WHERE party_id = $1`, [id])
expect(s.rows[0]).toEqual({ archived: true, is_active: false })
const p = await getPool().query<{ status: string; reason: { occurrences: number } }>(`SELECT status, suggested_reason AS reason FROM public.parties WHERE id = $1`, [id])
expect(p.rows[0]!.status).toBe('suggested')
expect(p.rows[0]!.reason.occurrences).toBe(3)
expect(await undo(c.companyId, c.userId, [id])).toBe(0)
// Promoting again creates a fresh live supplier (the archived one no longer counts).
expect(await promote(c.companyId, c.userId, [{ party_id: id, roles: ['supplier'] }])).toEqual({ parties: 1, suppliers: 1, customers: 0 })
})
it('refuses an archived or foreign party, an empty role list, a spoofed user, and undo after 30 days', async () => {
const mine = await seedCompany()
const theirs = await seedCompany()
const foreign = await suggested(theirs.companyId, theirs.userId, 'Theirs AB')
const id = await suggested(mine.companyId, mine.userId, 'Mine AB')
await expect(promote(mine.companyId, mine.userId, [{ party_id: foreign, roles: ['supplier'] }])).rejects.toMatchObject({ code: '23503' })
await expect(promote(mine.companyId, mine.userId, [{ party_id: id, roles: [] }])).rejects.toMatchObject({ code: '22023' })
await expect(
withUserContext(mine.userId, (client) =>
client.query(`SELECT public.promote_parties($1, $2, $3::jsonb)`, [mine.companyId, theirs.userId, JSON.stringify([{ party_id: id, roles: ['supplier'] }])]),
),
).rejects.toMatchObject({ code: '42501' })
await getPool().query(`UPDATE public.parties SET archived_at = now() WHERE id = $1`, [id])
await expect(promote(mine.companyId, mine.userId, [{ party_id: id, roles: ['supplier'] }])).rejects.toMatchObject({ code: '23503' })
await getPool().query(`UPDATE public.parties SET archived_at = NULL WHERE id = $1`, [id])
await promote(mine.companyId, mine.userId, [{ party_id: id, roles: ['supplier'] }])
await getPool().query(`UPDATE public.party_decisions SET created_at = now() - interval '31 days' WHERE party_id = $1`, [id])
expect(await undo(mine.companyId, mine.userId, [id])).toBe(0)
})
})
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import { seedCompany } from './fixtures'
const ORG = '5564300142'
async function party(id: string) {
const { rows } = await getPool().query<{ id: string; display_name: string; org_number: string | null; kind: string; status: string }>(
`SELECT id, display_name, org_number, kind, status FROM public.parties WHERE id = $1`,
[id],
)
return rows[0] ?? null
}
describe('link_party_on_role_write (pg)', () => {
it('gives a new supplier a confirmed party keyed on its org number, and reuses it for a customer with the same org', async () => {
const c = await seedCompany()
const s = await getPool().query<{ party_id: string }>(
`INSERT INTO public.suppliers (company_id, user_id, name, org_number) VALUES ($1, $2, 'Beijer Byggmaterial AB', '556430-0142') RETURNING party_id`,
[c.companyId, c.userId],
)
expect(s.rows[0]!.party_id).toBeTruthy()
const p = await party(s.rows[0]!.party_id)
expect(p).toMatchObject({ display_name: 'Beijer Byggmaterial AB', org_number: ORG, kind: 'company', status: 'confirmed' })
const k = await getPool().query<{ party_id: string }>(
`INSERT INTO public.customers (company_id, user_id, name, org_number, customer_type) VALUES ($1, $2, 'Beijer Bygg', $3, 'swedish_business') RETURNING party_id`,
[c.companyId, c.userId, ORG],
)
expect(k.rows[0]!.party_id).toBe(s.rows[0]!.party_id)
const n = await getPool().query<{ n: string }>(`SELECT count(*)::text AS n FROM public.parties WHERE company_id = $1`, [c.companyId])
expect(n.rows[0]!.n).toBe('1')
})
it('never merges on name: two suppliers without org numbers become two parties', async () => {
const c = await seedCompany()
await getPool().query(`INSERT INTO public.suppliers (company_id, user_id, name) VALUES ($1, $2, 'Fortnox')`, [c.companyId, c.userId])
await getPool().query(`INSERT INTO public.suppliers (company_id, user_id, name) VALUES ($1, $2, 'Fortnox')`, [c.companyId, c.userId])
const n = await getPool().query<{ n: string }>(`SELECT count(*)::text AS n FROM public.parties WHERE company_id = $1`, [c.companyId])
expect(n.rows[0]!.n).toBe('2')
})
it('creates a person party without any number for a private customer', async () => {
const c = await seedCompany()
const k = await getPool().query<{ party_id: string }>(
`INSERT INTO public.customers (company_id, user_id, name, customer_type, personal_number, org_number)
VALUES ($1, $2, 'Anna Andersson', 'individual', repeat('ab', 40), '19800101-1234') RETURNING party_id`,
[c.companyId, c.userId],
)
const p = await party(k.rows[0]!.party_id)
expect(p).toMatchObject({ display_name: 'Anna Andersson', org_number: null, kind: 'person' })
const leak = await getPool().query<{ n: string }>(
`SELECT count(*)::text AS n FROM public.parties WHERE company_id = $1 AND (org_number IS NOT NULL OR display_name ~ '[0-9]{6}')`,
[c.companyId],
)
expect(leak.rows[0]!.n).toBe('0')
})
it('re-keys when the org number changes to another live party, and teaches a party its org number', async () => {
const c = await seedCompany()
const a = await getPool().query<{ id: string; party_id: string }>(
`INSERT INTO public.suppliers (company_id, user_id, name) VALUES ($1, $2, 'Loopia') RETURNING id, party_id`,
[c.companyId, c.userId],
)
expect((await party(a.rows[0]!.party_id))!.org_number).toBeNull()
// The party learns the org number.
await getPool().query(`UPDATE public.suppliers SET org_number = '556666-1012' WHERE id = $1`, [a.rows[0]!.id])
const after = await getPool().query<{ party_id: string }>(`SELECT party_id FROM public.suppliers WHERE id = $1`, [a.rows[0]!.id])
expect(after.rows[0]!.party_id).toBe(a.rows[0]!.party_id)
expect((await party(a.rows[0]!.party_id))!.org_number).toBe('5566661012')
// A different org number that belongs to another live party moves the link.
const b = await getPool().query<{ party_id: string }>(
`INSERT INTO public.suppliers (company_id, user_id, name, org_number) VALUES ($1, $2, 'Beijer', $3) RETURNING party_id`,
[c.companyId, c.userId, ORG],
)
await getPool().query(`UPDATE public.suppliers SET org_number = $2 WHERE id = $1`, [a.rows[0]!.id, ORG])
const moved = await getPool().query<{ party_id: string }>(`SELECT party_id FROM public.suppliers WHERE id = $1`, [a.rows[0]!.id])
expect(moved.rows[0]!.party_id).toBe(b.rows[0]!.party_id)
})
it('leaves a nameless row unlinked and relinks a row whose party_id was cleared', async () => {
const c = await seedCompany()
const s = await getPool().query<{ id: string; party_id: string | null }>(
`INSERT INTO public.suppliers (company_id, user_id, name) VALUES ($1, $2, '') RETURNING id, party_id`,
[c.companyId, c.userId],
)
expect(s.rows[0]!.party_id).toBeNull()
const t = await getPool().query<{ id: string; party_id: string }>(
`INSERT INTO public.suppliers (company_id, user_id, name) VALUES ($1, $2, 'Telia') RETURNING id, party_id`,
[c.companyId, c.userId],
)
await getPool().query(`UPDATE public.suppliers SET party_id = NULL WHERE id = $1`, [t.rows[0]!.id])
const relinked = await getPool().query<{ party_id: string | null }>(`SELECT party_id FROM public.suppliers WHERE id = $1`, [t.rows[0]!.id])
expect(relinked.rows[0]!.party_id).toBeTruthy()
})
it('works for an authenticated member writing a supplier owned by a colleague', async () => {
const c = await seedCompany()
const { rows } = await getPool().query<{ id: string }>(`SELECT id FROM auth.users WHERE id <> $1 LIMIT 1`, [c.userId])
const colleague = rows[0]?.id ?? c.userId
const inserted = await withUserContext(c.userId, async (client) => {
const r = await client.query<{ party_id: string | null }>(
`INSERT INTO public.suppliers (company_id, user_id, name, org_number) VALUES ($1, $2, 'Skellefteå Plåt AB', $3) RETURNING party_id`,
[c.companyId, colleague, ORG],
)
return r.rows[0]!.party_id
})
expect(inserted).toBeTruthy()
})
})
+72
View File
@@ -88,6 +88,20 @@ describe('get_ledger_key_evidence (pg)', () => {
expect(loopia!.plusgiro).toEqual([])
})
it('keeps hard keys for a company that has no org number of its own', async () => {
const c = await seedCompany()
await getPool().query(`UPDATE public.companies SET org_number = NULL WHERE id = $1`, [c.companyId])
const e = await insertPostedJournalEntry({ userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId, sourceType: 'import', description: 'Levfakt Loopia AB', lines: expense('6540', 100) })
await linkDocument({ ...c, journalEntryId: e, supplier: { name: 'Loopia AB', orgNumber: '556666-1012', vatNumber: null, bankgiro: '5317-0900', plusgiro: null } })
const rows = await evidence(c.companyId, c.userId)
const loopia = rows.find((r) => r.key === 'loopia')
expect(loopia).toBeDefined()
expect(loopia!.self_docs).toBe(0)
expect(loopia!.orgs).toEqual([{ org: LOOPIA_ORG, n: 1 }])
expect(loopia!.names).toEqual([{ name: 'Loopia AB', n: 1 }])
expect(loopia!.bankgiro.map((b) => b.value)).toEqual(['53170900'])
})
it('is invisible across companies', async () => {
const mine = await seedCompany()
const theirs = await seedCompany()
@@ -252,3 +266,61 @@ describe('decide_parties (pg)', () => {
expect(still.rows[0]!.status).toBe('suggested')
})
})
describe('undo_party_decisions (pg)', () => {
async function undoDecisions(companyId: string, userId: string, ids: string[]): Promise<number> {
const { rows } = await getPool().query<{ n: number }>(`SELECT public.undo_party_decisions($1, $2, $3::uuid[]) AS n`, [companyId, userId, ids])
return rows[0]!.n
}
it('reverses a confirm (reason restored) and a dismiss, logs undo, and refuses a second undo', async () => {
const c = await seedCompany()
await apply(c.companyId, c.userId, [
{ key: 'loopia', display_name: 'Loopia AB', reason: { attach: 'new', occurrences: 3 } },
{ key: 'noise', display_name: 'Noise', reason: { attach: 'new', occurrences: 1 } },
])
const ids = await getPool().query<{ id: string; display_name: string }>(`SELECT id, display_name FROM public.parties WHERE company_id = $1`, [c.companyId])
const byName = Object.fromEntries(ids.rows.map((r) => [r.display_name, r.id]))
expect(await decide(c.companyId, c.userId, [byName['Loopia AB']!], 'confirm')).toBe(1)
expect(await decide(c.companyId, c.userId, [byName['Noise']!], 'dismiss')).toBe(1)
const snap = await getPool().query<{ before: { suggested_reason: { occurrences: number } } }>(
`SELECT before FROM public.party_decisions WHERE party_id = $1 AND kind = 'confirm'`,
[byName['Loopia AB']],
)
expect(snap.rows[0]!.before.suggested_reason.occurrences).toBe(3)
expect(await undoDecisions(c.companyId, c.userId, [byName['Loopia AB']!, byName['Noise']!])).toBe(2)
const state = await getPool().query<{ display_name: string; status: string; archived: boolean; reason: { occurrences: number } | null }>(
`SELECT display_name, status, archived_at IS NOT NULL AS archived, suggested_reason AS reason FROM public.parties WHERE company_id = $1 ORDER BY display_name`,
[c.companyId],
)
expect(state.rows).toEqual([
{ display_name: 'Loopia AB', status: 'suggested', archived: false, reason: { attach: 'new', occurrences: 3 } },
{ display_name: 'Noise', status: 'suggested', archived: false, reason: { attach: 'new', occurrences: 1 } },
])
const kinds = await getPool().query<{ kind: string; n: string }>(
`SELECT kind, count(*)::text AS n FROM public.party_decisions WHERE company_id = $1 GROUP BY kind ORDER BY kind`,
[c.companyId],
)
expect(kinds.rows).toEqual([
{ kind: 'confirm', n: '1' },
{ kind: 'dismiss', n: '1' },
{ kind: 'undo', n: '2' },
])
// The latest decision is now the undo itself: nothing left to reverse.
expect(await undoDecisions(c.companyId, c.userId, [byName['Loopia AB']!])).toBe(0)
})
it('refuses after 30 days and ignores other companies', async () => {
const c = await seedCompany()
const other = await seedCompany()
await apply(c.companyId, c.userId, [{ key: 'loopia', display_name: 'Loopia AB', reason: { attach: 'new' } }])
const { rows } = await getPool().query<{ id: string }>(`SELECT id FROM public.parties WHERE company_id = $1`, [c.companyId])
await decide(c.companyId, c.userId, [rows[0]!.id], 'confirm')
expect(await undoDecisions(other.companyId, other.userId, [rows[0]!.id])).toBe(0)
await getPool().query(`UPDATE public.party_decisions SET created_at = now() - interval '31 days' WHERE party_id = $1`, [rows[0]!.id])
expect(await undoDecisions(c.companyId, c.userId, [rows[0]!.id])).toBe(0)
const still = await getPool().query<{ status: string }>(`SELECT status FROM public.parties WHERE id = $1`, [rows[0]!.id])
expect(still.rows[0]!.status).toBe('confirmed')
})
})