feat(parties): Förslag från bokföringen, confirmed straight into Leverantörer and Kunder (#2206)
* feat(parties): Kontakter register, suggestion queue, dossier and merge Phase 1's two surfaces on top of the parties substrate: - /parties page: one list with the five-way switch (Alla, Kunder, Leverantörer, Förslag, Bara i bokföringen), search, a 12-month/all period picker, and at most one attention line. Confirmed rows show roles as muted text, rhythm, underlag, dominant account and money. Observed rows are computed and never stored; a generic band keeps unattributed spend visible. - Suggestion queue: a reason per row, hard-key rows pre-ticked, bulk confirm behind one dialog, dismiss on hover, undo on the toast. - Dossier slide-over: Pengar, Bokföring, Vad Accounted vet (facts and identities with source and count), Underlag och verifikat, Historik. - Merge dialog with a visible, swappable survivor and undo. - API: GET /api/parties, GET /api/parties/[id], POST suggest, decide, decide/undo, merge, merge/undo (withRouteContext, Zod, 15 tests). - Migration 20260903090000: decide_parties snapshots the reason it clears; undo_party_decisions reverses confirm/dismiss within 30 days; decision kind 'undo'. - The pipeline runs after SIE import and provider migration (non-blocking) so a migrant's register is full on arrival. - Nav entry under Register; sv/en strings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): pass explicit interpolation values to next-intl next build's type check rejects a typed interface where the translator wants an index-signature record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): retry label on the load-failed state Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): hard keys for companies without org number, readable names, look-alikes at read time - get_ledger_key_evidence dropped every document for a company whose own org number is NULL (the self check compared against NULL). Replaced in 20260903100000 with a coalesced comparison; pg test covers it. - Display names come from the printed name on documents, otherwise from the voucher text with the AP/AR prefix and supplier number removed. - Look-alike parties (same core, or one core extending the other by whole words: Fortnox / Fortnox Finans) are detected when the register is read, never stored, and feed the Dubblett? chip and the merge dialog. - Queue shows Intäkt beside Kostnad; dossier hides zero money rows and formats bankgiro/plusgiro; merge dialog cancels with Avbryt; no synchronous setState inside effects. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): link every new supplier and customer to a party on write The backfill covered the rows that existed on 2026-09-02; 108 rows created since had no party and never reached the register. A BEFORE INSERT/UPDATE trigger on customers and suppliers now calls ensure_party on every write path at once: find-or-create by org number inside the company, never by name; a private customer gets a kind=person party without any number; a nameless row stays unlinked; a foreign party id is refused with the same error as the composite foreign key; a link to a merged party follows the chain to the survivor; the clear that ON DELETE SET NULL performs is kept. ensure_party lets the trigger act for the row's owner (pg_trigger_depth() > 0); the RPC path is unchanged. The migration also links the rows created since the backfill. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): dossier hides dismissed parties and follows merges to the survivor The register hid archived parties while the dossier still served them by id, and a merged party's dossier pointed at a dead row. Superagent P2 on #2206; three unit tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): move the role-link migration past main's 20260903110000 Two files with one version would collide in schema_migrations. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): confirm suggestions into Leverantörer and Kunder, no third noun Founder decision after the walkthrough: users know two words. The page becomes the queue 'Förslag från bokföringen' with 'Bara i bokföringen' beside it; the Kontakter nav entry and the Alla/Kunder/Leverantörer views go. Each suggestion shows what it becomes (Blir), read from the ledger side and changeable per row; confirming calls promote_parties, which creates the supplier and/or customer row from the party's facts, never a duplicate, and is undoable for 30 days through undo_party_promotions (the created rows are archived, the party returns to the queue). Leverantörer and Kunder carry the one attention line that leads here. The dossier offers Lägg upp som leverantör / som kund. Migration 20260903130000, 5 pg tests, route and unit tests updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): write bankgiro and plusgiro the way the supplier form does Identities are stored as digits; suppliers carry 5317-0900. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): move the four queue migrations past main's 20260903170000 Main merged 20260903120000_skattekonto_transactions_realtime_publication with the same version as the role-link trigger; the preview database refused the duplicate key. All four now sit after main's newest so the set applies in one ordered run on prod. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
e2d38b0ab3
commit
b996da60ee
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { defaultRoles, getDossier } from '../register'
|
||||
import type { LedgerStats } from '../register'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const PARTY = '11111111-1111-4111-8111-111111111111'
|
||||
const SURVIVOR = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function row(over: Record<string, unknown>) {
|
||||
return {
|
||||
id: PARTY,
|
||||
display_name: 'Loopia AB',
|
||||
legal_name: null,
|
||||
org_number: null,
|
||||
vat_number: null,
|
||||
kind: 'company',
|
||||
status: 'suggested',
|
||||
alias_keys: ['loopia'],
|
||||
suggested_reason: null,
|
||||
created_at: '2026-09-03T00:00:00Z',
|
||||
merged_into: null,
|
||||
archived_at: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('getDossier', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('hides a dismissed (archived) party like the register does', async () => {
|
||||
enqueue({ data: row({ archived_at: '2026-09-03T10:00:00Z' }) })
|
||||
expect(await getDossier(supabase as never, 'company-1', PARTY)).toBeNull()
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('follows a merged party to its survivor', async () => {
|
||||
enqueue({ data: row({ merged_into: SURVIVOR }) })
|
||||
enqueue({ data: SURVIVOR }) // canonical_party_id
|
||||
enqueue({ data: null }) // survivor lookup: absent, so the walk ends here
|
||||
expect(await getDossier(supabase as never, 'company-1', PARTY)).toBeNull()
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('canonical_party_id', { p_party_id: PARTY })
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
expect(supabase.from).toHaveBeenNthCalledWith(2, 'parties')
|
||||
})
|
||||
|
||||
it('returns null for an unknown id without touching anything else', async () => {
|
||||
enqueue({ data: null })
|
||||
expect(await getDossier(supabase as never, 'company-1', PARTY)).toBeNull()
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultRoles', () => {
|
||||
const stats = (over: Partial<LedgerStats>): LedgerStats => ({
|
||||
occurrences: 3,
|
||||
expenseSek: 0,
|
||||
revenueSek: 0,
|
||||
firstSeen: null,
|
||||
lastSeen: null,
|
||||
cadenceDays: null,
|
||||
rhythm: null,
|
||||
dominantAccount: null,
|
||||
dominantShare: null,
|
||||
variants: [],
|
||||
...over,
|
||||
})
|
||||
|
||||
it('reads the ledger side', () => {
|
||||
expect(defaultRoles(stats({ expenseSek: 1000 }))).toEqual(['supplier'])
|
||||
expect(defaultRoles(stats({ revenueSek: 1000 }))).toEqual(['customer'])
|
||||
expect(defaultRoles(stats({ expenseSek: 10, revenueSek: 10 }))).toEqual(['supplier', 'customer'])
|
||||
})
|
||||
|
||||
it('falls back to the dominant account, then to supplier', () => {
|
||||
expect(defaultRoles(stats({ dominantAccount: '3011' }))).toEqual(['customer'])
|
||||
expect(defaultRoles(stats({ dominantAccount: '6212' }))).toEqual(['supplier'])
|
||||
expect(defaultRoles(null)).toEqual(['supplier'])
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user