diff --git a/CLAUDE.md b/CLAUDE.md index 584ff5c7..a87b7dbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,38 +34,43 @@ app/ (onboarding)/ 6-step setup wizard (dashboard)/ Authenticated routes (invoices, customers, transactions, bookkeeping, reports, suppliers, supplier-invoices, - receipts, settings, calendar, help, import, + receipts, deadlines, settings, help, import, extensions, e/[sector]/[slug]) (public)/ Public invoice action links (no auth) api/ API routes organized by domain components/ ui/ shadcn/ui primitives (button, card, dialog, table, etc.) - bookkeeping/ Chart of accounts manager, account combobox, add/edit dialogs - calendar/ Calendar views, deadline cards, payment summary, todo widgets + bookkeeping/ Chart of accounts manager, account combobox, add/edit dialogs, + journal entry form/list/review, document upload, correction dialog chat/ ChatWidget, ChatPanel, ChatInput, ChatMessage customers/ CustomerForm dashboard/ DashboardContent, DashboardNav, FSkattWarningCard + deadlines/ DeadlineCard, DeadlineFilters, DeadlineForm, DeadlineList, + TaxTodoWidget, UpcomingDeadlinesWidget extensions/ Extension marketplace UI (ExtensionCard, SectorCard, ExtensionToggleButton, workspace components, - per-sector subdirectories) - import/ Bank file import workflow components + per-sector subdirectories, shared reusable components) + import/ Bank file import workflow components (SIE + bank file steps) invoices/ InvoiceReviewContent onboarding/ NewUserChecklist, setup step components - reports/ Report views (including BankReconciliationView) + reports/ Report views (BankReconciliationView, charts) settings/ CalendarFeedSettings - suppliers/ Supplier-related components - transactions/ Transaction list, categorization, booking components + suppliers/ SupplierForm, SupplierInvoiceReviewContent + transactions/ Transaction list, categorization, booking, swipe review, + batch operations, invoice matching, VAT treatment extensions/ Sector-based extension hierarchy general/ General-purpose extensions (all businesses) ai-categorization/ AI-powered transaction categorization ai-chat/ Claude-based chat assistant (LangChain RAG) + calendar/ Payment calendar views, deadline cards, payment summary enable-banking/ PSD2 bank integration (opt-in, commented out in loader) example-logger/ Minimal reference extension (not loaded) invoice-inbox/ Supplier invoice intake via email/upload with AI extraction push-notifications/ Web push notification system receipt-ocr/ Receipt image OCR processing (includes components/pages) + user-description-match/ User description matching for transaction categorization restaurant/ Restaurant & cafe sector food-cost/ Food cost percentage calculator earnings-per-liter/ Revenue per liter of alcohol @@ -100,6 +105,9 @@ lib/ vat-entries.ts VAT line generation bas-reference.ts BAS account catalog (~180 accounts with metadata, SRU codes) account-descriptions.ts Human-readable account name lookup + booking-templates.ts Booking template patterns for AI suggestions + template-embeddings.ts AI embeddings for booking template matching + client-account-names.ts Custom account name management validate-period-duration.ts Fiscal period duration validation (BFL 3 kap.) handlers/ Booking handler functions (supplier-invoice-handler.ts) core/ @@ -111,6 +119,7 @@ lib/ currency/ Riksbanken exchange rates deadlines/ Tax deadline tracking, status engine email/ Email service (Resend), invoice/reminder templates + errors/ Error message utilities (get-error-message.ts) events/ Event bus (bus.ts, types.ts) extensions/ Extension system loader.ts FIRST_PARTY_EXTENSIONS array, static imports @@ -125,9 +134,13 @@ lib/ icon-resolver.tsx Dynamic icon lookup for extensions use-account-totals.ts Hook for account balance queries use-extension-data.ts Hook for extension-specific data + invoice-inbox-utils.ts Utilities for supplier invoice inbox + use-mock-data.ts Mock data utilities for development + hooks/ React hooks (use-unsaved-changes.ts) import/ SIE parser, SIE import orchestrator, bank file parser bank-file/ Bank file parser with format modules - formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank + formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank, + ica-banken, lansforsakringar, lunar, skandia invoices/ VAT rules, invoice matching, PDF template, reminder processor reconciliation/ Bank reconciliation engine (4-pass matching algorithm) reports/ Financial reports (trial-balance, income-statement, @@ -137,9 +150,12 @@ lib/ ar-ledger, ar-reconciliation, monthly-breakdown) supabase/ Client setup (client.ts = browser, server.ts = server/admin, fetch-all.ts = pagination helper, middleware.ts) - tax/ Tax calculations, deadlines, Swedish holidays, expense warnings + tax/ Tax calculations, deadlines, deadline generator, + Swedish holidays, expense warnings transactions/ Transaction processing, category suggestions + vat/ VAT utilities (VIES client for EU VAT validation) init.ts Extension loader (idempotent, called by API routes) + logger.ts Centralized logging utility utils.ts Shared utility functions types/index.ts Canonical type definitions (single source of truth) @@ -270,14 +286,16 @@ Key types (from `lib/extensions/types.ts`): ### Currently Loaded Extensions (FIRST_PARTY_EXTENSIONS) ``` -receiptOcrExtension @/extensions/general/receipt-ocr -aiCategorizationExtension @/extensions/general/ai-categorization -pushNotificationsExtension @/extensions/general/push-notifications -sruExportExtension @/extensions/sru-export -neBilagaExtension @/extensions/ne-bilaga -aiChatExtension @/extensions/general/ai-chat -invoiceInboxExtension @/extensions/general/invoice-inbox -# enableBankingExtension @/extensions/general/enable-banking (commented out, opt-in) +receiptOcrExtension @/extensions/general/receipt-ocr +aiCategorizationExtension @/extensions/general/ai-categorization +pushNotificationsExtension @/extensions/general/push-notifications +sruExportExtension @/extensions/sru-export +neBilagaExtension @/extensions/ne-bilaga +aiChatExtension @/extensions/general/ai-chat +invoiceInboxExtension @/extensions/general/invoice-inbox +calendarExtension @/extensions/general/calendar +userDescriptionMatchExtension @/extensions/general/user-description-match +# enableBankingExtension @/extensions/general/enable-banking (commented out, opt-in) ``` ### Extension Interface @@ -430,16 +448,35 @@ mockResult({ data: makeTransaction(), error: null }) - `lib/api/__tests__/validate.test.ts` — Body/query validation helpers - `lib/bookkeeping/__tests__/engine.test.ts` — Balance validation - `lib/bookkeeping/__tests__/invoice-entries.test.ts` — Per-line VAT, mixed-rate invoices, credit notes +- `lib/bookkeeping/__tests__/mapping-engine.test.ts` — Auto-categorization rule matching +- `lib/bookkeeping/__tests__/category-mapping.test.ts` — Category-to-account mapping +- `lib/bookkeeping/__tests__/booking-templates.test.ts` — Booking template patterns +- `lib/bookkeeping/__tests__/template-embeddings.test.ts` — AI embedding matching +- `lib/bookkeeping/__tests__/validate-period-duration.test.ts` — Fiscal period duration +- `lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts` — Supplier booking handler - `lib/core/bookkeeping/__tests__/storno-service.test.ts` — Complex mock queues +- `lib/core/bookkeeping/__tests__/period-service.test.ts` — Fiscal period management +- `lib/core/bookkeeping/__tests__/year-end-service.test.ts` — Year-end closing - `lib/core/documents/__tests__/document-service.test.ts` — Storage mocking +- `lib/core/tax/__tests__/tax-code-service.test.ts` — Tax code management +- `lib/currency/__tests__/riksbanken.test.ts` — Exchange rate fetching - `lib/events/__tests__/bus.test.ts` — Event bus behavior - `lib/extensions/__tests__/registry.test.ts` — Extension registration +- `lib/extensions/__tests__/loader.test.ts` — Extension loader +- `lib/extensions/__tests__/toggle-check.test.ts` — Extension toggle logic +- `lib/extensions/__tests__/validation.test.ts` — Extension data validation +- `lib/extensions/__tests__/sectors.test.ts` — Sector registry +- `lib/extensions/__tests__/context-factory.test.ts` — Extension context builder +- `lib/extensions/__tests__/invoice-inbox-utils.test.ts` — Invoice inbox utilities - `lib/import/__tests__/sie-parser.test.ts` — SIE file parsing +- `lib/import/__tests__/sie-import.test.ts` — SIE import orchestration +- `lib/import/__tests__/account-mapper.test.ts` — SIE account mapping - `lib/import/bank-file/__tests__/parser.test.ts` — Bank file format parsing - `lib/reconciliation/__tests__/bank-reconciliation.test.ts` — Reconciliation matching algorithm - `lib/reports/__tests__/vat-declaration.test.ts` — VAT declaration report - `lib/tax/__tests__/deadline-config.test.ts` — Tax deadline configuration - `lib/transactions/__tests__/ingest.test.ts` — Transaction ingestion and dedup +- `lib/vat/__tests__/vies-client.test.ts` — VIES VAT number validation --- @@ -447,11 +484,11 @@ mockResult({ data: makeTransaction(), error: null }) ### Location -`supabase/migrations/` — currently 39 files numbered `20240101000001` through `20240101000038` (note: two files share number `000033`). +`supabase/migrations/` — currently 41 files numbered `20240101000001` through `20240101000041`. ### Naming Convention -`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000039_*.sql` +`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000042_*.sql` ### Migration Rules @@ -491,12 +528,14 @@ mockResult({ data: makeTransaction(), error: null }) - **Migration 031 (`invoice_document_type`)** — Adds `document_type` column to `invoices` (CHECK: invoice/proforma/delivery_note, default 'invoice') and `converted_from_id` FK for tracking proforma-to-invoice conversions. - **Migration 032 (`add_accounting_method`)** — Adds `accounting_method` column to `company_settings` (CHECK: accrual/cash, default 'accrual') to support kontantmetoden vs faktureringsmetoden. - **Migration 033 (`ai_chat_schema`)** — AI chat conversation and message storage. -- **Migration 033 (`invoice_inbox`)** — Invoice inbox table for supplier invoice intake. (Note: shares number with ai_chat_schema.) - **Migration 034 (`fix_extension_data_trigger`)** — Fixes extension data trigger. - **Migration 035 (`fix_push_notifications`)** — Push notifications schema fix. - **Migration 036 (`fix_enable_banking`)** — Enable Banking schema fix. - **Migration 037 (`extension_toggles`)** — Extension toggle table for per-user enable/disable. - **Migration 038 (`fix_match_documents_search_path`)** — Fixes search path for document matching function. +- **Migration 039 (`invoice_inbox`)** — Invoice inbox table for supplier invoice intake via email/upload. +- **Migration 040 (`booking_template_embeddings`)** — Booking templates with AI embeddings for suggestion matching. +- **Migration 041 (`user_description_matching`)** — User description matching for transaction categorization. --- diff --git a/app/(dashboard)/extensions/[sector]/[extension]/page.tsx b/app/(dashboard)/extensions/[sector]/[extension]/page.tsx index c8d0c5f6..17737ee2 100644 --- a/app/(dashboard)/extensions/[sector]/[extension]/page.tsx +++ b/app/(dashboard)/extensions/[sector]/[extension]/page.tsx @@ -56,7 +56,11 @@ export default async function ExtensionDetailPage({ - + {/* Details */} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index a1072947..fda05f0d 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -8,18 +8,13 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' -import { Badge } from '@/components/ui/badge' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/components/ui/use-toast' -import { formatDate } from '@/lib/utils' import { Loader2, Building, CreditCard, User, - AlertTriangle, - RefreshCw, - Trash2, LogOut, Bell, Calendar, @@ -27,11 +22,14 @@ import { Moon, Monitor, Palette, + Landmark, } from 'lucide-react' import { useTheme } from 'next-themes' import type { CompanySettings, BankConnection } from '@/types' import { NotificationSettings } from '@/extensions/general/push-notifications/NotificationSettings' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' +import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector' +import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus' export default function SettingsPage() { const router = useRouter() @@ -46,9 +44,12 @@ export default function SettingsPage() { const [isSyncing, setIsSyncing] = useState(false) const [isConnecting, setIsConnecting] = useState(false) const [hasBankingExtension, setHasBankingExtension] = useState(false) + const [selectedBank, setSelectedBank] = useState(null) const { theme, setTheme } = useTheme() const [mounted, setMounted] = useState(false) + const initialTab = searchParams.get('tab') || 'company' + useEffect(() => { setMounted(true) }, []) @@ -106,18 +107,16 @@ export default function SettingsPage() { setBankConnections(connections || []) - // Check if extension API is available (will return banks list if extension is loaded) + // Check if Enable Banking extension is enabled via toggle API try { - const bankingCheck = await fetch('/api/extensions/enable-banking/callback', { - method: 'HEAD', - }) - // The extension route existing means it's deployed - but we also need - // to check if there are existing connections to show the tab - setHasBankingExtension( - (connections && connections.length > 0) || bankingCheck.status !== 404 - ) + const toggleRes = await fetch('/api/extensions/toggles/general/enable-banking') + if (toggleRes.ok) { + const { data } = await toggleRes.json() + setHasBankingExtension(data?.enabled || (connections && connections.length > 0) || false) + } else { + setHasBankingExtension((connections && connections.length > 0) || false) + } } catch { - // If the extension routes don't exist, only show tab if there are existing connections setHasBankingExtension((connections && connections.length > 0) || false) } @@ -182,7 +181,7 @@ export default function SettingsPage() { setIsConnecting(true) try { - const response = await fetch('/api/extensions/enable-banking/callback', { + const response = await fetch('/api/extensions/ext/enable-banking/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aspsp_name: bankName, aspsp_country: bankCountry }), @@ -210,7 +209,7 @@ export default function SettingsPage() { setIsSyncing(true) try { - const response = await fetch('/api/extensions/enable-banking/sync/cron', { + const response = await fetch('/api/extensions/ext/enable-banking/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId }), @@ -275,24 +274,6 @@ export default function SettingsPage() { const activeConnections = bankConnections.filter((c) => c.status === 'active') - // Helper to calculate days until consent expires - function getDaysUntilExpiry(expiresAt: string | null): number | null { - if (!expiresAt) return null - const expiryDate = new Date(expiresAt) - const now = new Date() - const diffTime = expiryDate.getTime() - now.getTime() - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) - return Math.max(0, diffDays) - } - - function isConsentExpiringSoon(expiresAt: string | null): boolean { - if (!expiresAt) return false - const expiryDate = new Date(expiresAt) - const warningDate = new Date() - warningDate.setDate(warningDate.getDate() + 7) - return expiryDate <= warningDate - } - return (
@@ -302,7 +283,7 @@ export default function SettingsPage() {

- + @@ -554,66 +535,63 @@ export default function SettingsPage() { Anslutna banker - {activeConnections.map((connection) => { - const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires) - const isExpiring = isConsentExpiringSoon(connection.consent_expires) - - return ( -
-
-
- -
-
-

{connection.bank_name}

-
- - Senast synkad: {formatDate(connection.last_synced_at || connection.created_at)} - - {isExpiring && ( - - - {daysUntilExpiry} dagar kvar - - )} -
-
-
-
- - -
-
- ) - })} + {activeConnections.map((connection) => ( + + ))}
)} + {/* Connect new bank */} + + + Anslut ny bank + + Välj din bank nedan för att koppla ditt konto via PSD2. + + + + + {selectedBank && ( +
+
+ +
+

{selectedBank.name}

+

{selectedBank.country}

+
+
+ +
+ )} +
+
+ {/* Info about PSD2 */} - Bankintegration (PSD2) + Om bankintegration (PSD2) Automatisk import av transaktioner via PSD2 open banking. Samtycket gäller i 90 dagar och behöver sedan förnyas. diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index a3b7d8b6..dfc2ac40 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -76,11 +76,14 @@ export default function DashboardNav({ companyName, entityType, enabledExtension const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded // Auto-expand Tillägg when on an extension page, or when manually toggled (persisted) const isOnExtensionPage = pathname.startsWith('/e/') - const [manualTillaggExpanded, setManualTillaggExpanded] = useState(() => { - if (typeof window === 'undefined') return false - return localStorage.getItem('tillagg-expanded') === 'true' - }) + const [manualTillaggExpanded, setManualTillaggExpanded] = useState(false) const isTillaggExpanded = isOnExtensionPage || manualTillaggExpanded + + // Restore persisted state after hydration to avoid SSR mismatch + useEffect(() => { + const stored = localStorage.getItem('tillagg-expanded') === 'true' + if (stored) setManualTillaggExpanded(true) + }, []) const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? []) const fetchExtensions = useCallback(async () => { diff --git a/components/extensions/ExtensionToggleButton.tsx b/components/extensions/ExtensionToggleButton.tsx index f39fcb98..38d7860b 100644 --- a/components/extensions/ExtensionToggleButton.tsx +++ b/components/extensions/ExtensionToggleButton.tsx @@ -1,23 +1,71 @@ 'use client' +import { useState } from 'react' import { Switch } from '@/components/ui/switch' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { useExtensionToggle } from '@/lib/extensions/hooks' export default function ExtensionToggleButton({ sectorSlug, extensionSlug, + subscriptionNotice, }: { sectorSlug: string extensionSlug: string + subscriptionNotice?: string }) { const { enabled, isLoading, toggle } = useExtensionToggle(sectorSlug, extensionSlug) + const [showConfirm, setShowConfirm] = useState(false) + + function handleToggle() { + // Show confirmation dialog when enabling an extension with a subscription notice + if (!enabled && subscriptionNotice) { + setShowConfirm(true) + return + } + toggle() + } + + function handleConfirm() { + setShowConfirm(false) + toggle() + } return ( - + <> + + + {subscriptionNotice && ( + + + + Extern prenumeration krävs + {subscriptionNotice} + + + + + + + + )} + ) } diff --git a/components/extensions/general/EnableBankingWorkspace.tsx b/components/extensions/general/EnableBankingWorkspace.tsx index 451bcaee..484610b5 100644 --- a/components/extensions/general/EnableBankingWorkspace.tsx +++ b/components/extensions/general/EnableBankingWorkspace.tsx @@ -1,15 +1,24 @@ 'use client' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' -import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState' -import { Landmark } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Landmark, Settings } from 'lucide-react' +import Link from 'next/link' export default function EnableBankingWorkspace({ userId }: WorkspaceComponentProps) { return ( - } - /> +
+ +

Bankintegration (PSD2)

+

+ Koppla ditt bankkonto under Inställningar för att synka transaktioner automatiskt. +

+ +
) } diff --git a/lib/extensions/loader.ts b/lib/extensions/loader.ts index 0160a685..bd45fb32 100644 --- a/lib/extensions/loader.ts +++ b/lib/extensions/loader.ts @@ -14,11 +14,7 @@ import { intrastatExtension } from '@/extensions/export/intrastat' import { currencyReceivablesExtension } from '@/extensions/export/currency-receivables' import type { Extension } from './types' -// ── Enable Banking (PSD2) — opt-in extension ─────────────────────────── -// Uncomment the following line to enable automatic PSD2 bank transaction sync. -// Requires ENABLE_BANKING_APP_ID and ENABLE_BANKING_PRIVATE_KEY env vars. -// -// import { enableBankingExtension } from '@/extensions/general/enable-banking' +import { enableBankingExtension } from '@/extensions/general/enable-banking' /** * Explicit list of first-party extensions. @@ -36,7 +32,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [ invoiceInboxExtension, calendarExtension, userDescriptionMatchExtension, - // enableBankingExtension, // Uncomment to activate PSD2 bank sync + enableBankingExtension, // ── Export sector ────────────────────────────────────────── euSalesListExtension, diff --git a/lib/extensions/sectors.ts b/lib/extensions/sectors.ts index 60b457fb..38d816c7 100644 --- a/lib/extensions/sectors.ts +++ b/lib/extensions/sectors.ts @@ -117,6 +117,8 @@ export const SECTORS: Sector[] = [ description: 'Automatisk banktransaktionssynk via PSD2', longDescription: 'Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.', + subscriptionNotice: + 'Denna integration kräver ett aktivt Enable Banking-abonnemang. Utan abonnemang kommer bankintegration inte att fungera.', }, ], }, diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index 4e38fdb9..93e43df0 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -39,6 +39,8 @@ export interface ExtensionDefinition { readsCoreTables?: string[] hasOwnData?: boolean quickAction?: QuickActionDefinition + /** Notice shown when enabling — e.g. external subscription requirement */ + subscriptionNotice?: string } /** Sector definition with its extensions */ diff --git a/lib/transactions/category-suggestions.ts b/lib/transactions/category-suggestions.ts index 53bae4e9..99a826e0 100644 --- a/lib/transactions/category-suggestions.ts +++ b/lib/transactions/category-suggestions.ts @@ -166,8 +166,8 @@ function accountToCategory(account: string, amount: number): string | null { } /** - * Source priority for sorting — higher-quality sources rank first. - * AI and mapping rules always beat history-based guesses. + * Source priority for tiebreaking — used when two suggestions + * have the same confidence score. */ const SOURCE_PRIORITY: Record = { mapping_rule: 3, @@ -187,19 +187,28 @@ export function mergeAiSuggestions( aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[], transactionAmount?: number ): SuggestedCategory[] { - const seen = new Set(existing.map((s) => s.category)) const merged = [...existing] for (const ai of aiSuggestions) { - if (seen.has(ai.category)) continue - // Skip suggestions that don't match transaction direction if (transactionAmount !== undefined) { if (transactionAmount > 0 && ai.category.startsWith('expense_')) continue if (transactionAmount < 0 && ai.category.startsWith('income_')) continue } - seen.add(ai.category) + const existingIdx = merged.findIndex((s) => s.category === ai.category) + if (existingIdx !== -1) { + // Upgrade existing entry if the AI has higher confidence + if (ai.confidence > merged[existingIdx].confidence) { + merged[existingIdx] = { + ...merged[existingIdx], + account: ai.basAccount || merged[existingIdx].account, + confidence: ai.confidence, + source: 'ai', + } + } + continue + } merged.push({ category: ai.category as TransactionCategory, @@ -210,12 +219,12 @@ export function mergeAiSuggestions( }) } - // Sort by source priority first, then by confidence within the same tier + // Sort by confidence first, then by source priority as tiebreaker return merged .sort((a, b) => { - const priorityDiff = SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source] - if (priorityDiff !== 0) return priorityDiff - return b.confidence - a.confidence + const confidenceDiff = b.confidence - a.confidence + if (confidenceDiff !== 0) return confidenceDiff + return SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source] }) .slice(0, 5) }