diff --git a/DECISIONS.md b/DECISIONS.md index 9260b586..a696926f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1520,11 +1520,15 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index f6f4b18f..deeafdfe 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -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() { + {/* Toolbar: search (concept) */}
diff --git a/app/(dashboard)/parties/page.tsx b/app/(dashboard)/parties/page.tsx new file mode 100644 index 00000000..2e33608a --- /dev/null +++ b/app/(dashboard)/parties/page.tsx @@ -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(url: string, body?: unknown): Promise { + 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, 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(initialView) + const [query, setQuery] = useState('') + const [debounced, setDebounced] = useState('') + const [period, setPeriod] = useState('12m') + const [register, setRegister] = useState(null) + const [loading, setLoading] = useState(true) + const [failed, setFailed] = useState(false) + const [reloadKey, setReloadKey] = useState(0) + const [selected, setSelected] = useState>(new Set()) + const [roleOverrides, setRoleOverrides] = useState>({}) + const [busy, setBusy] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + const [dossierId, setDossierId] = useState(null) + const [dossierReload, setDossierReload] = useState(0) + const [merge, setMerge] = useState<{ subject: MergeCandidate; suggested: MergeCandidate[] } | null>(null) + const preselected = useRef(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: ( + { + post(undoUrl, { partyIds: ids }) + .then(() => { + toast({ title: undoneTitle }) + reload() + }) + .catch(fail) + }} + > + {t('undo')} + + ), + }) + } + + 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: ( + { + post('/api/parties/merge/undo', { decisionId }) + .then(() => { + toast({ title: t('merge_undone_title') }) + reload() + }) + .catch(fail) + }} + > + {t('undo')} + + ), + }) + 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 = void refreshSuggestions() }}>{t('attn_observed', { count: counts.observed })} + } + + function empty() { + if (searching) return + if (view === 'observed') return + return ( + void refreshSuggestions() : undefined} + secondaryActionLabel={t('go_suppliers')} + secondaryActionHref="/suppliers" + /> + ) + } + + function content() { + if (failed) return + if (loading && !register) + return ( +
+ + + +
+ ) + if (!register) return null + if (view === 'observed') { + if (register.observed.length === 0 && register.generic.count === 0) return empty() + return + } + if (rows.length === 0) return empty() + return ( + + 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 ( +
+ +

{t('help')}

+ + } + action={ + + } + /> + + {attn} + +
+ + value={view} + onChange={(v) => { + setView(v) + router.replace(v === 'observed' ? '/parties?view=observed' : '/parties', { scroll: false }) + }} + options={viewOptions} + /> + setQuery(e.target.value)} placeholder={t('search_placeholder')} aria-label={t('search_placeholder')} /> +
+ setPeriod(id as RegisterPeriod)} + triggerLabel={periodItems.find((i) => i.id === period)?.label ?? t('period_12m')} + ariaLabel={t('period_label')} + /> +
+
+ + {content()} + + {register && view === 'suggested' && rows.length > 0 ? ( +

{t('count_summary', { count: rows.length })}

+ ) : null} + + { + setConfirmOpen(false) + await promote(selectedItems) + }} + /> + + setDossierId(null)} + onPromote={(id, roles) => void promote([{ partyId: id, roles }])} + onDismiss={(id) => { + void dismiss([id]) + setDossierId(null) + }} + onMerge={(subject, suggested) => setMerge({ subject, suggested })} + /> + + {merge ? ( + (!open ? setMerge(null) : undefined)} + subject={merge.subject} + suggested={merge.suggested} + busy={busy} + onMerge={runMerge} + /> + ) : null} +
+ ) +} + +export default function PartiesPage() { + return ( + + + + ) +} diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx index 9eb491ee..c98d1498 100644 --- a/app/(dashboard)/suppliers/page.tsx +++ b/app/(dashboard)/suppliers/page.tsx @@ -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() {
+ {/* Toolbar: search (concept) */}
diff --git a/app/api/parties/[id]/route.ts b/app/api/parties/[id]/route.ts new file mode 100644 index 00000000..7028b20e --- /dev/null +++ b/app/api/parties/[id]/route.ts @@ -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 }) + }, +) diff --git a/app/api/parties/__tests__/route.test.ts b/app/api/parties/__tests__/route.test.ts new file mode 100644 index 00000000..6e52dc25 --- /dev/null +++ b/app/api/parties/__tests__/route.test.ts @@ -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] }) + }) +}) diff --git a/app/api/parties/decide/route.ts b/app/api/parties/decide/route.ts new file mode 100644 index 00000000..f6a9e761 --- /dev/null +++ b/app/api/parties/decide/route.ts @@ -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 }, +) diff --git a/app/api/parties/decide/undo/route.ts b/app/api/parties/decide/undo/route.ts new file mode 100644 index 00000000..e51b56fc --- /dev/null +++ b/app/api/parties/decide/undo/route.ts @@ -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 }, +) diff --git a/app/api/parties/merge/route.ts b/app/api/parties/merge/route.ts new file mode 100644 index 00000000..40b16d6a --- /dev/null +++ b/app/api/parties/merge/route.ts @@ -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 }, +) diff --git a/app/api/parties/merge/undo/route.ts b/app/api/parties/merge/undo/route.ts new file mode 100644 index 00000000..945f7e77 --- /dev/null +++ b/app/api/parties/merge/undo/route.ts @@ -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 }, +) diff --git a/app/api/parties/promote/route.ts b/app/api/parties/promote/route.ts new file mode 100644 index 00000000..5bf34e93 --- /dev/null +++ b/app/api/parties/promote/route.ts @@ -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> + return NextResponse.json({ data: { parties: r.parties ?? 0, suppliers: r.suppliers ?? 0, customers: r.customers ?? 0 } }) + }, + { requireWrite: true }, +) diff --git a/app/api/parties/promote/undo/route.ts b/app/api/parties/promote/undo/route.ts new file mode 100644 index 00000000..388641d9 --- /dev/null +++ b/app/api/parties/promote/undo/route.ts @@ -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 }, +) diff --git a/app/api/parties/route.ts b/app/api/parties/route.ts new file mode 100644 index 00000000..f923bed7 --- /dev/null +++ b/app/api/parties/route.ts @@ -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 }) +}) diff --git a/app/api/parties/suggest/route.ts b/app/api/parties/suggest/route.ts new file mode 100644 index 00000000..ccea8535 --- /dev/null +++ b/app/api/parties/suggest/route.ts @@ -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 }, +) diff --git a/components/parties/AccountNub.tsx b/components/parties/AccountNub.tsx new file mode 100644 index 00000000..34c5bf95 --- /dev/null +++ b/components/parties/AccountNub.tsx @@ -0,0 +1,4 @@ +export function AccountNub({ account }: { account: string | null }) { + if (!account) return · + return {account} +} diff --git a/components/parties/MergeDialog.tsx b/components/parties/MergeDialog.tsx new file mode 100644 index 00000000..05dedfec --- /dev/null +++ b/components/parties/MergeDialog.tsx @@ -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 +}) { + const t = useTranslations('parties') + const tCommon = useTranslations('common') + const [picked, setPicked] = useState>(new Set(suggested.map((s) => s.id))) + const [survivor, setSurvivor] = useState(subject.id) + const [query, setQuery] = useState('') + const [found, setFound] = useState([]) + + 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() + 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 ( + + + + {t('merge_dialog_title')} + {t('merge_body')} + +
+
    + {[subject, ...candidates].map((c) => { + const isSubject = c.id === subject.id + const included = isSubject || picked.has(c.id) + return ( +
  • + toggle(c.id)} + aria-label={c.displayName} + /> +
    +
    {c.displayName}
    +
    + {c.orgNumber ? formatOrgNumber(c.orgNumber) : ''} + {c.status === 'suggested' ? (c.orgNumber ? ' · ' : '') + t('view_suggested') : ''} +
    +
    + +
  • + ) + })} +
+ setQuery(e.target.value)} + placeholder={t('merge_search_placeholder')} + aria-label={t('merge_search_placeholder')} + /> + {candidates.length === 0 && query.trim().length >= 2 ? ( +

{t('merge_none')}

+ ) : null} +
+ + + + +
+
+ ) +} diff --git a/components/parties/ObservedTable.tsx b/components/parties/ObservedTable.tsx new file mode 100644 index 00000000..ff6a8870 --- /dev/null +++ b/components/parties/ObservedTable.tsx @@ -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 ( +
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + {generic.count > 0 ? ( + + + + + ) : null} + +
{t('th_name')}{t('th_rhythm')}{t('th_vouchers')}{t('th_account')}{t('th_revenue')}{t('th_expense')}
+ {row.name} + {row.label === 'unsure' ? {t('uncertain_name')} : null} + {rhythmLabel(t, row.stats.rhythm)}{t('vouchers_count', { count: row.stats.occurrences })} + + {money(row.stats.revenueSek)}{money(row.stats.expenseSek)}
+ {t('generic_band', { count: generic.count })} + {t('generic_band_description', { examples: generic.examples.join(' · ') })} + {money(generic.expenseSek)}
+
+ ) +} diff --git a/components/parties/PartyDossier.tsx b/components/parties/PartyDossier.tsx new file mode 100644 index 00000000..2a17b44c --- /dev/null +++ b/components/parties/PartyDossier.tsx @@ -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

{children}

+} + +function Row({ label, value, note }: { label: string; value: React.ReactNode; note?: React.ReactNode }) { + return ( + + {label} + +
{value}
+ {note ?
{note}
: null} + + + ) +} + +/** + * 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 ( + (!open ? onClose() : undefined)}> + + + + {loading && !dossier ? ( +
+ + + +
+ ) : failed || !dossier || !p ? ( +

{t('load_failed')}

+ ) : ( +
+
+ {subtitle ?

{subtitle}

: null} +
+ {suggested || !p.roles.supplierId ? ( + + ) : null} + {suggested || !p.roles.customerId ? ( + + ) : null} + + {suggested ? ( + + ) : null} +
+
+ +
+ {t('section_money')} + + + {stats?.expenseSek || !stats?.revenueSek ? ( + + ) : null} + {stats?.revenueSek ? ( + + ) : null} + {stats?.firstSeen ? : null} + {stats?.lastSeen ? : null} + +
+
+ +
+ {t('section_bookkeeping')} + + + } + 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 + } + /> + + +
+
+ +
+ {t('section_facts')} + + + 1 ? stats.variants.slice(0, 3).join(', ') : undefined} + /> + {t('fact_missing')}} note={legalName ? docsFor('legal_name') : undefined} /> + {t('fact_missing')}} + note={p.orgNumber && orgFact ? docsFor('org_number') : undefined} + /> + {t('fact_missing')}} /> + {dossier.identities.map((i) => ( + + ))} + +
+
+ +
+ {t('section_vouchers')} + {dossier.vouchers.length === 0 ? ( +

{t('vouchers_none')}

+ ) : ( + + + {dossier.vouchers.map((v) => ( + + + + + + ))} + +
{formatDate(v.entryDate)} + {v.voucher ? {v.voucher} · : null} + {v.description} + {v.amount ? formatCurrency(v.amount) : ''}
+ )} +
+ + {dossier.decisions.length > 0 ? ( +
+ {t('section_history')} + + + {dossier.decisions.map((d) => ( + + + + + ))} + +
{formatDate(d.createdAt)} + {['confirm', 'dismiss', 'merge', 'split', 'undo'].includes(d.kind) ? t(`decision_${d.kind}`) : d.kind} + {d.note ? · {d.note} : null} +
+
+ ) : null} +
+ )} +
+
+
+ ) +} diff --git a/components/parties/SuggestionQueue.tsx b/components/parties/SuggestionQueue.tsx new file mode 100644 index 00000000..09bc8912 --- /dev/null +++ b/components/parties/SuggestionQueue.tsx @@ -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 + 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 ( +
+
+ {t('selected_n', { count })} + {t('selected_hint')} +
+ + +
+
+
+ + + + + + + + + + + + + {rows.map((row) => { + const checked = selected.has(row.id) + const current = roles(row) + return ( + + + + + + + + + + + ) + })} + +
+ {t('th_name')}{t('th_why')}{t('th_becomes')}{t('th_account')}{t('th_revenue')}{t('th_expense')} +
+ onToggle(row.id)} aria-label={row.displayName} disabled={!canWrite} /> + + + {isDuplicateCandidate(row) ? ( + + {t('chip_duplicate')} + + ) : null} + {reasonText(t, row.reason, row.stats?.rhythm ?? null)} + + + + + + {(['supplier', 'customer'] as const).map((role) => ( + { e.preventDefault(); toggleRole(row, role) }} className="gap-2"> + + ))} + + + + + {row.stats?.revenueSek ? formatCurrency(row.stats.revenueSek) : ''}{row.stats?.expenseSek ? formatCurrency(row.stats.expenseSek) : ''} + +
+
+
+ ) +} diff --git a/components/parties/SuggestionsAttn.tsx b/components/parties/SuggestionsAttn.tsx new file mode 100644 index 00000000..e7d01d38 --- /dev/null +++ b/components/parties/SuggestionsAttn.tsx @@ -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(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 ( + router.push('/parties') }}> + {side === 'supplier' ? t('attn_suppliers', { count: n }) : t('attn_customers', { count: n })} + + ) + } + if (side === 'supplier' && counts.suggested === 0 && counts.observed > 0) { + return ( + router.push('/parties?view=observed') }}> + {t('attn_observed', { count: counts.observed })} + + ) + } + return null +} diff --git a/components/parties/format.ts b/components/parties/format.ts new file mode 100644 index 00000000..479e42a5 --- /dev/null +++ b/components/parties/format.ts @@ -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 + +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) +} diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts index 8b75bdaa..3bab877a 100644 --- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts +++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts @@ -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 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 diff --git a/lib/parties/__tests__/register.test.ts b/lib/parties/__tests__/register.test.ts new file mode 100644 index 00000000..b8f042b1 --- /dev/null +++ b/lib/parties/__tests__/register.test.ts @@ -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) { + 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 => ({ + 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']) + }) +}) diff --git a/lib/parties/__tests__/suggest.test.ts b/lib/parties/__tests__/suggest.test.ts index 9526e678..cc7bb8cc 100644 --- a/lib/parties/__tests__/suggest.test.ts +++ b/lib/parties/__tests__/suggest.test.ts @@ -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']) + }) +}) diff --git a/lib/parties/ledger-key.ts b/lib/parties/ledger-key.ts index 977b6a28..803d5d7f 100644 --- a/lib/parties/ledger-key.ts +++ b/lib/parties/ledger-key.ts @@ -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() +} diff --git a/lib/parties/register.ts b/lib/parties/register.ts new file mode 100644 index 00000000..c6d7f5f5 --- /dev/null +++ b/lib/parties/register.ts @@ -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> { + const byCore = new Map>() + const coresOf = new Map>() + for (const p of parties) { + const cores = new Set() + 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() + 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>() + for (const p of parties) { + const seen = new Set([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): 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 { + 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(({ 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() + 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() + 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() + for (const r of customerCounts) invoicesByCustomer.set(r.customer_id, (invoicesByCustomer.get(r.customer_id) ?? 0) + 1) + const invoicesBySupplier = new Map() + 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() + 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 | 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 | null + after: Record | 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 { + 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((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>).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 | null) ?? null, + fetchedAt: (f.fetched_at as string | null) ?? null, + recordedAt: f.recorded_at as string, + })), + identities: ((identities.data ?? []) as Array>).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>).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 | null) ?? null, + after: (d.after as Record | null) ?? null, + })), + vouchers, + similar, + } +} diff --git a/lib/parties/suggest.ts b/lib/parties/suggest.ts index ecb62535..1ced347f 100644 --- a/lib/parties/suggest.ts +++ b/lib/parties/suggest.ts @@ -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[] { diff --git a/messages/en.json b/messages/en.json index 855bbb52..71881c51 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}" } } diff --git a/messages/sv.json b/messages/sv.json index 06807325..e7d05c27 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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}" } } diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index b1ec6c35..15e86b89 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -7,7 +7,7 @@ ] }, "naiveOreRound": { - "count": 617 + "count": 615 }, "handRolledInvariants": { "count": 113 diff --git a/supabase/migrations/20260903180000_party_decision_undo.sql b/supabase/migrations/20260903180000_party_decision_undo.sql new file mode 100644 index 00000000..60853596 --- /dev/null +++ b/supabase/migrations/20260903180000_party_decision_undo.sql @@ -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'; diff --git a/supabase/migrations/20260903181000_party_evidence_self_check_null_org.sql b/supabase/migrations/20260903181000_party_evidence_self_check_null_org.sql new file mode 100644 index 00000000..84928a89 --- /dev/null +++ b/supabase/migrations/20260903181000_party_evidence_self_check_null_org.sql @@ -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'; diff --git a/supabase/migrations/20260903182000_party_role_link_trigger.sql b/supabase/migrations/20260903182000_party_role_link_trigger.sql new file mode 100644 index 00000000..51627379 --- /dev/null +++ b/supabase/migrations/20260903182000_party_role_link_trigger.sql @@ -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'; diff --git a/supabase/migrations/20260903183000_party_promotion.sql b/supabase/migrations/20260903183000_party_promotion.sql new file mode 100644 index 00000000..378711c6 --- /dev/null +++ b/supabase/migrations/20260903183000_party_promotion.sql @@ -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'; diff --git a/tests/pg/parties-substrate.pg.test.ts b/tests/pg/parties-substrate.pg.test.ts index 564abe14..c73b8127 100644 --- a/tests/pg/parties-substrate.pg.test.ts +++ b/tests/pg/parties-substrate.pg.test.ts @@ -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 () => { diff --git a/tests/pg/party-promotion.pg.test.ts b/tests/pg/party-promotion.pg.test.ts new file mode 100644 index 00000000..1f6b1fab --- /dev/null +++ b/tests/pg/party-promotion.pg.test.ts @@ -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 { + 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 }>(`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 { + 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) + }) +}) diff --git a/tests/pg/party-role-link-trigger.pg.test.ts b/tests/pg/party-role-link-trigger.pg.test.ts new file mode 100644 index 00000000..4f864b94 --- /dev/null +++ b/tests/pg/party-role-link-trigger.pg.test.ts @@ -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() + }) +}) diff --git a/tests/pg/party-suggestions.pg.test.ts b/tests/pg/party-suggestions.pg.test.ts index b17d7030..308412ed 100644 --- a/tests/pg/party-suggestions.pg.test.ts +++ b/tests/pg/party-suggestions.pg.test.ts @@ -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 { + 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') + }) +})