Supp/invoice bfl errors (#390)
* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma * Remove AI subsystem and related code - Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`. - Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`. - Cleaned up schemas related to AI flows in `lib/api/schemas.ts`. - Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`. - Eliminated AI event types from `lib/events/types.ts`. - Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`. - Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration. - Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks. - Updated helper functions in `tests/helpers.ts` to remove AI-related settings. - Removed AI-related types and interfaces from `types/index.ts`. - Added migration script to drop AI-related tables and settings from the database. * fix(migrations): ensure foreign key constraint is dropped before removing AI tables * feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning - Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier. - Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs. - Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API. - Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions. - Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`. * feat(invoice-inbox): remove AI-specific columns and tighten status enum * fix(skattekonto): remove manual entry creation reference from transaction input * fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema
This commit is contained in:
@@ -1,171 +0,0 @@
|
|||||||
import { notFound, redirect } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
|
||||||
import { isAgentInboxEnabled } from '@/lib/ai/feature-flag'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { PageHeader } from '@/components/ui/page-header'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Sparkles } from 'lucide-react'
|
|
||||||
import AgentInbox from '@/components/agent-inbox/AgentInbox'
|
|
||||||
import type { AIProposal, AIRequest, InvoiceInboxItem, Transaction, DocumentAttachment, MatchProposalPayload } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
// Expanded card data the client component needs to render each proposal's
|
|
||||||
// context (receipt thumbnail + matched transaction summary).
|
|
||||||
export interface AgentInboxItemView {
|
|
||||||
proposal: AIProposal | null
|
|
||||||
request: AIRequest | null
|
|
||||||
inbox_item: InvoiceInboxItem & { document: DocumentAttachment | null }
|
|
||||||
transaction: Transaction | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AgentInboxPage() {
|
|
||||||
// Hard gate: extension not enabled at build time, OR the feature flag is
|
|
||||||
// off in this environment (prod by default) → 404.
|
|
||||||
if (!ENABLED_EXTENSION_IDS.has('ai-agent') || !isAgentInboxEnabled()) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) redirect('/login')
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
|
|
||||||
// Soft gate: per-company toggle. If not enabled, show an empty-state
|
|
||||||
// pointing to settings rather than 404 — the extension exists, the user
|
|
||||||
// just hasn't opted in yet.
|
|
||||||
const { data: settings } = await supabase
|
|
||||||
.from('company_settings')
|
|
||||||
.select('ai_flow_enabled')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!settings?.ai_flow_enabled) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto p-4 sm:p-8 max-w-5xl">
|
|
||||||
<PageHeader
|
|
||||||
title="Agent-inkorg"
|
|
||||||
description="AI föreslår bokföring — du godkänner varje steg."
|
|
||||||
/>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<div className="p-5 rounded-full bg-muted mb-6">
|
|
||||||
<Sparkles className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium mb-2">AI-agenten är inte aktiverad</h3>
|
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-sm mb-6">
|
|
||||||
Aktivera AI-agenten under bokföringsinställningar. Varje transaktion blir då ett
|
|
||||||
granskningsförslag istället för automatisk bokföring.
|
|
||||||
</p>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/settings/bookkeeping">Gå till inställningar</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all pending proposals + open requests for the company, newest first.
|
|
||||||
const [{ data: proposals }, { data: requests }] = await Promise.all([
|
|
||||||
supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.in('status', ['pending'])
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(50),
|
|
||||||
supabase
|
|
||||||
.from('ai_requests')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('status', 'open')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(50),
|
|
||||||
])
|
|
||||||
|
|
||||||
const typedProposals = (proposals ?? []) as AIProposal[]
|
|
||||||
const typedRequests = (requests ?? []) as AIRequest[]
|
|
||||||
|
|
||||||
// Collect subject_ids and fetch inbox items + transactions in one pass.
|
|
||||||
const subjectIds = new Set<string>([
|
|
||||||
...typedProposals.map((p) => p.subject_id),
|
|
||||||
...typedRequests.map((r) => r.subject_id),
|
|
||||||
])
|
|
||||||
|
|
||||||
const items: AgentInboxItemView[] = []
|
|
||||||
if (subjectIds.size > 0) {
|
|
||||||
const { data: inboxRows } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*, document:document_attachments!document_id(*)')
|
|
||||||
.in('id', [...subjectIds])
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
|
|
||||||
const inboxMap = new Map<string, InvoiceInboxItem & { document: DocumentAttachment | null }>()
|
|
||||||
for (const row of inboxRows ?? []) {
|
|
||||||
inboxMap.set(row.id, row as InvoiceInboxItem & { document: DocumentAttachment | null })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect transaction IDs from two sources:
|
|
||||||
// 1. inbox_item.matched_transaction_id — set after a match is accepted
|
|
||||||
// (used by booking cards to show the paired transaction).
|
|
||||||
// 2. proposal_json.matched_transaction_id on pending match proposals —
|
|
||||||
// the transaction the AI is *proposing*; needed so match cards can
|
|
||||||
// show a human-readable description instead of a raw UUID.
|
|
||||||
const matchedTxIds = [
|
|
||||||
...new Set([
|
|
||||||
...[...inboxMap.values()]
|
|
||||||
.map((i) => i.matched_transaction_id)
|
|
||||||
.filter((id): id is string => Boolean(id)),
|
|
||||||
...typedProposals
|
|
||||||
.filter((p) => p.step_type === 'match')
|
|
||||||
.map((p) => (p.proposal_json as MatchProposalPayload).matched_transaction_id)
|
|
||||||
.filter((id): id is string => Boolean(id)),
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
|
|
||||||
const txMap = new Map<string, Transaction>()
|
|
||||||
if (matchedTxIds.length > 0) {
|
|
||||||
const { data: txRows } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('*')
|
|
||||||
.in('id', matchedTxIds)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
for (const tx of txRows ?? []) txMap.set(tx.id, tx as Transaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the view: one card per (subject, step). Proposals first, requests second.
|
|
||||||
for (const proposal of typedProposals) {
|
|
||||||
const inbox = inboxMap.get(proposal.subject_id)
|
|
||||||
if (!inbox) continue
|
|
||||||
// Match cards render the transaction being *proposed*; booking cards render
|
|
||||||
// the transaction already accepted on the inbox item.
|
|
||||||
const txId = proposal.step_type === 'match'
|
|
||||||
? (proposal.proposal_json as MatchProposalPayload).matched_transaction_id
|
|
||||||
: inbox.matched_transaction_id
|
|
||||||
items.push({
|
|
||||||
proposal,
|
|
||||||
request: null,
|
|
||||||
inbox_item: inbox,
|
|
||||||
transaction: txId ? txMap.get(txId) ?? null : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const request of typedRequests) {
|
|
||||||
const inbox = inboxMap.get(request.subject_id)
|
|
||||||
if (!inbox) continue
|
|
||||||
items.push({
|
|
||||||
proposal: null,
|
|
||||||
request,
|
|
||||||
inbox_item: inbox,
|
|
||||||
transaction: inbox.matched_transaction_id ? txMap.get(inbox.matched_transaction_id) ?? null : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AgentInbox initialItems={items} />
|
|
||||||
}
|
|
||||||
@@ -38,6 +38,7 @@ export default function ExpenseDetailPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
|
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
|
||||||
const [payAmount, setPayAmount] = useState('')
|
const [payAmount, setPayAmount] = useState('')
|
||||||
|
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export default function ExpenseDetailPage() {
|
|||||||
} else {
|
} else {
|
||||||
setInvoice(data)
|
setInvoice(data)
|
||||||
setPayAmount(String(data.remaining_amount))
|
setPayAmount(String(data.remaining_amount))
|
||||||
|
setPaymentDate(new Date().toISOString().split('T')[0])
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -89,7 +91,7 @@ export default function ExpenseDetailPage() {
|
|||||||
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
|
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ amount: parseFloat(payAmount) }),
|
body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate }),
|
||||||
})
|
})
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -473,8 +475,20 @@ export default function ExpenseDetailPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Belopp att betala</Label>
|
<Label htmlFor="payment-date">Betalningsdatum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="payment-date"
|
||||||
|
type="date"
|
||||||
|
value={paymentDate}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
onChange={(e) => setPaymentDate(e.target.value)}
|
||||||
|
className="w-full sm:w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="payment-amount">Belopp att betala</Label>
|
||||||
|
<Input
|
||||||
|
id="payment-amount"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={payAmount}
|
value={payAmount}
|
||||||
|
|||||||
@@ -219,9 +219,16 @@ export default async function DashboardLayout({
|
|||||||
extensionNavItems={getExtensionNavItems()}
|
extensionNavItems={getExtensionNavItems()}
|
||||||
/>
|
/>
|
||||||
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
|
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
|
||||||
<div key={companyId} className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
{pathname.startsWith('/e/') ? (
|
||||||
{children}
|
// Extension workspaces opt out of the centered max-w-5xl chrome
|
||||||
</div>
|
// because their content (file viewers, dashboards) wants the full
|
||||||
|
// viewport width.
|
||||||
|
<div key={companyId} className="h-full">{children}</div>
|
||||||
|
) : (
|
||||||
|
<div key={companyId} className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
{!isSandbox && (
|
{!isSandbox && (
|
||||||
<RecaptIdentify
|
<RecaptIdentify
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export default function PendingOperationsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Granskning"
|
title="Granskning"
|
||||||
description="Operationer från din AI-agent som väntar på godkännande"
|
description="Operationer som väntar på godkännande"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{newAutoCommits.length > 0 && (
|
{newAutoCommits.length > 0 && (
|
||||||
@@ -382,8 +382,8 @@ export default function PendingOperationsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{activeTab === 'pending'
|
{activeTab === 'pending'
|
||||||
? 'När din AI-agent skapar bokföring visas den här för granskning.'
|
? 'När en operation kräver godkännande visas den här för granskning.'
|
||||||
: 'Historik för AI-agentens operationer visas här.'}
|
: 'Operationer du har godkänt eller avvisat visas här.'}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { notFound, redirect } from 'next/navigation'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import ReceiptsList from '@/components/receipts/ReceiptsList'
|
|
||||||
import type { InvoiceInboxItem, DocumentAttachment } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
export type ReceiptRow = InvoiceInboxItem & { document: DocumentAttachment | null }
|
|
||||||
export type ReceiptRowWithPreview = ReceiptRow & { preview_url: string | null }
|
|
||||||
|
|
||||||
export default async function ReceiptsPage() {
|
|
||||||
// Hard gate: if the invoice-inbox extension isn't loaded, there's no
|
|
||||||
// upload pipeline and nothing would work here.
|
|
||||||
if (!ENABLED_EXTENSION_IDS.has('invoice-inbox')) notFound()
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) redirect('/login')
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*, document:document_attachments!document_id(*)')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('document_type', 'receipt')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(200)
|
|
||||||
|
|
||||||
const rows = (data ?? []) as ReceiptRow[]
|
|
||||||
|
|
||||||
// Batch-sign all document paths so each row renders with a thumbnail.
|
|
||||||
// Supabase exposes createSignedUrls (plural) for exactly this use case.
|
|
||||||
const paths = rows
|
|
||||||
.map((r) => r.document?.storage_path)
|
|
||||||
.filter((p): p is string => Boolean(p))
|
|
||||||
|
|
||||||
const urlByPath = new Map<string, string>()
|
|
||||||
if (paths.length > 0) {
|
|
||||||
const { data: signed } = await supabase.storage
|
|
||||||
.from('documents')
|
|
||||||
.createSignedUrls(paths, 3600)
|
|
||||||
for (const entry of signed ?? []) {
|
|
||||||
if (entry.path && entry.signedUrl) urlByPath.set(entry.path, entry.signedUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = rows.map((row) => ({
|
|
||||||
...row,
|
|
||||||
preview_url: row.document?.storage_path
|
|
||||||
? urlByPath.get(row.document.storage_path) ?? null
|
|
||||||
: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return <ReceiptsList initialItems={items} />
|
|
||||||
}
|
|
||||||
@@ -7,17 +7,13 @@ import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettin
|
|||||||
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
|
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
|
||||||
import { useSettings } from '@/components/settings/useSettings'
|
import { useSettings } from '@/components/settings/useSettings'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { ExternalLink } from 'lucide-react'
|
||||||
import { ExternalLink, Sparkles } from 'lucide-react'
|
|
||||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
|
||||||
import { isAgentInboxEnabled } from '@/lib/ai/feature-flag'
|
|
||||||
import type { CompanySettings } from '@/types'
|
import type { CompanySettings } from '@/types'
|
||||||
|
|
||||||
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||||
|
|
||||||
export default function BookkeepingSettingsPage() {
|
export default function BookkeepingSettingsPage() {
|
||||||
const { settings, isLoading, updateSettings } = useSettings()
|
const { settings, isLoading, updateSettings } = useSettings()
|
||||||
const aiAgentAvailable = ENABLED_EXTENSION_IDS.has('ai-agent') && isAgentInboxEnabled()
|
|
||||||
|
|
||||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||||
|
|
||||||
@@ -26,7 +22,6 @@ export default function BookkeepingSettingsPage() {
|
|||||||
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
|
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
|
||||||
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
|
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
|
||||||
const defaultVoucherSeries = (formData.get('default_voucher_series') as string) || 'A'
|
const defaultVoucherSeries = (formData.get('default_voucher_series') as string) || 'A'
|
||||||
const aiFlowEnabled = formData.get('ai_flow_enabled') === 'on'
|
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {
|
const updates: Record<string, unknown> = {
|
||||||
bookkeeping_locked_through: lockedThrough,
|
bookkeeping_locked_through: lockedThrough,
|
||||||
@@ -34,9 +29,6 @@ export default function BookkeepingSettingsPage() {
|
|||||||
accounting_method: accountingMethod,
|
accounting_method: accountingMethod,
|
||||||
default_voucher_series: defaultVoucherSeries,
|
default_voucher_series: defaultVoucherSeries,
|
||||||
}
|
}
|
||||||
if (aiAgentAvailable) {
|
|
||||||
updates.ai_flow_enabled = aiFlowEnabled
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
updates,
|
updates,
|
||||||
onSuccess: (data: Record<string, unknown>) => {
|
onSuccess: (data: Record<string, unknown>) => {
|
||||||
@@ -65,9 +57,9 @@ export default function BookkeepingSettingsPage() {
|
|||||||
<option value="cash">Kontantmetoden</option>
|
<option value="cash">Kontantmetoden</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{settings.entity_type === 'aktiebolag'
|
Kontantmetoden får användas om årlig nettoomsättning normalt är högst
|
||||||
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.'
|
3 MSEK (BFL 5 kap. 2 §). Obetalda fordringar och skulder ska bokföras
|
||||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
vid räkenskapsårets utgång.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -101,34 +93,6 @@ export default function BookkeepingSettingsPage() {
|
|||||||
<div className="border-t border-border/8 pt-8">
|
<div className="border-t border-border/8 pt-8">
|
||||||
<PeriodLockingSettings settings={settings} />
|
<PeriodLockingSettings settings={settings} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI agent (beta) — gated on extension availability */}
|
|
||||||
{aiAgentAvailable && (
|
|
||||||
<div className="border-t border-border/8 pt-8">
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
AI-agent (beta)
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Switch
|
|
||||||
id="ai_flow_enabled"
|
|
||||||
name="ai_flow_enabled"
|
|
||||||
defaultChecked={Boolean(settings.ai_flow_enabled)}
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="ai_flow_enabled">Aktivera agent-inkorgen</Label>
|
|
||||||
<p className="text-xs text-muted-foreground max-w-prose">
|
|
||||||
När aktiv: varje ny banktransaktion blir ett AI-förslag du granskar i
|
|
||||||
<Link href="/agent-inbox" className="underline ml-1">agent-inkorgen</Link>.
|
|
||||||
Den automatiska bokföringen (≥80% regelmatchning) stängs av — inget bokförs
|
|
||||||
utan din bekräftelse.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsFormWrapper>
|
</SettingsFormWrapper>
|
||||||
|
|
||||||
{/* Voucher series — read-only display */}
|
{/* Voucher series — read-only display */}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function SupplierInvoiceDetailPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
|
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
|
||||||
const [payAmount, setPayAmount] = useState('')
|
const [payAmount, setPayAmount] = useState('')
|
||||||
|
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export default function SupplierInvoiceDetailPage() {
|
|||||||
} else {
|
} else {
|
||||||
setInvoice(data)
|
setInvoice(data)
|
||||||
setPayAmount(String(data.remaining_amount))
|
setPayAmount(String(data.remaining_amount))
|
||||||
|
setPaymentDate(new Date().toISOString().split('T')[0])
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -89,7 +91,7 @@ export default function SupplierInvoiceDetailPage() {
|
|||||||
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
|
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ amount: parseFloat(payAmount) }),
|
body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate }),
|
||||||
})
|
})
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -561,8 +563,20 @@ export default function SupplierInvoiceDetailPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Belopp att betala</Label>
|
<Label htmlFor="payment-date">Betalningsdatum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="payment-date"
|
||||||
|
type="date"
|
||||||
|
value={paymentDate}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
onChange={(e) => setPaymentDate(e.target.value)}
|
||||||
|
className="w-full sm:w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="payment-amount">Belopp att betala</Label>
|
||||||
|
<Input
|
||||||
|
id="payment-amount"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={payAmount}
|
value={payAmount}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/comp
|
|||||||
import { useCompany } from '@/contexts/CompanyContext'
|
import { useCompany } from '@/contexts/CompanyContext'
|
||||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
|
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, EntityType, LinePatternEntry } from '@/types'
|
||||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||||
|
|
||||||
interface QuickReviewState {
|
interface QuickReviewState {
|
||||||
@@ -137,19 +137,9 @@ export default function TransactionsPage() {
|
|||||||
.filter((t) => t.potential_invoice_id)
|
.filter((t) => t.potential_invoice_id)
|
||||||
.map((t) => t.potential_invoice_id)
|
.map((t) => t.potential_invoice_id)
|
||||||
|
|
||||||
const unbookedTxIds = (txData || [])
|
const invoiceResult = potentialInvoiceIds.length > 0
|
||||||
.filter((t) => !t.journal_entry_id && t.is_business === null)
|
? await supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
|
||||||
.map((t) => t.id)
|
: { data: null }
|
||||||
|
|
||||||
// Fetch invoices and inbox items in parallel
|
|
||||||
const [invoiceResult, inboxResult] = await Promise.all([
|
|
||||||
potentialInvoiceIds.length > 0
|
|
||||||
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
|
|
||||||
: Promise.resolve({ data: null }),
|
|
||||||
unbookedTxIds.length > 0
|
|
||||||
? supabase.from('invoice_inbox_items').select('*').in('matched_transaction_id', unbookedTxIds).in('status', ['ready', 'processing'])
|
|
||||||
: Promise.resolve({ data: null }),
|
|
||||||
])
|
|
||||||
|
|
||||||
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
|
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
|
||||||
if (invoiceResult.data) {
|
if (invoiceResult.data) {
|
||||||
@@ -159,20 +149,9 @@ export default function TransactionsPage() {
|
|||||||
}, {} as Record<string, Invoice & { customer?: Customer }>)
|
}, {} as Record<string, Invoice & { customer?: Customer }>)
|
||||||
}
|
}
|
||||||
|
|
||||||
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
|
|
||||||
if (inboxResult.data) {
|
|
||||||
inboxItemMap = inboxResult.data.reduce((acc, item) => {
|
|
||||||
if (item.matched_transaction_id) {
|
|
||||||
acc[item.matched_transaction_id] = item as InvoiceInboxItem
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, {} as Record<string, InvoiceInboxItem>)
|
|
||||||
}
|
|
||||||
|
|
||||||
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
|
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
|
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
|
||||||
matched_inbox_item: inboxItemMap[t.id] || undefined,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setTransactions(transactionsWithInvoices)
|
setTransactions(transactionsWithInvoices)
|
||||||
@@ -203,18 +182,9 @@ export default function TransactionsPage() {
|
|||||||
.filter((t) => t.potential_invoice_id)
|
.filter((t) => t.potential_invoice_id)
|
||||||
.map((t) => t.potential_invoice_id)
|
.map((t) => t.potential_invoice_id)
|
||||||
|
|
||||||
const unbookedTxIds = txData
|
const invoiceResult = potentialInvoiceIds.length > 0
|
||||||
.filter((t) => !t.journal_entry_id && t.is_business === null)
|
? await supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
|
||||||
.map((t) => t.id)
|
: { data: null }
|
||||||
|
|
||||||
const [invoiceResult, inboxResult] = await Promise.all([
|
|
||||||
potentialInvoiceIds.length > 0
|
|
||||||
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
|
|
||||||
: Promise.resolve({ data: null }),
|
|
||||||
unbookedTxIds.length > 0
|
|
||||||
? supabase.from('invoice_inbox_items').select('*').in('matched_transaction_id', unbookedTxIds).in('status', ['ready', 'processing'])
|
|
||||||
: Promise.resolve({ data: null }),
|
|
||||||
])
|
|
||||||
|
|
||||||
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
|
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
|
||||||
if (invoiceResult.data) {
|
if (invoiceResult.data) {
|
||||||
@@ -224,20 +194,9 @@ export default function TransactionsPage() {
|
|||||||
}, {} as Record<string, Invoice & { customer?: Customer }>)
|
}, {} as Record<string, Invoice & { customer?: Customer }>)
|
||||||
}
|
}
|
||||||
|
|
||||||
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
|
|
||||||
if (inboxResult.data) {
|
|
||||||
inboxItemMap = inboxResult.data.reduce((acc, item) => {
|
|
||||||
if (item.matched_transaction_id) {
|
|
||||||
acc[item.matched_transaction_id] = item as InvoiceInboxItem
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, {} as Record<string, InvoiceInboxItem>)
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
|
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
|
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
|
||||||
matched_inbox_item: inboxItemMap[t.id] || undefined,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setTransactions((prev) => [...prev, ...newTransactions])
|
setTransactions((prev) => [...prev, ...newTransactions])
|
||||||
@@ -655,16 +614,6 @@ export default function TransactionsPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// Non-critical
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
// Run document matching sweep for latest inbox matches
|
|
||||||
await fetch('/api/documents/match-sweep', { method: 'POST' })
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.data?.matched > 0) fetchTransactions()
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Non-critical
|
|
||||||
}
|
|
||||||
const uncatIds = uncategorizedTransactions.map((t) => t.id)
|
const uncatIds = uncategorizedTransactions.map((t) => t.id)
|
||||||
await fetchCategorySuggestions(uncatIds)
|
await fetchCategorySuggestions(uncatIds)
|
||||||
setShowSwipeView(true)
|
setShowSwipeView(true)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/backfill/cancel
|
|
||||||
*
|
|
||||||
* Set the kill switch flag on company_settings. The running backfill loop
|
|
||||||
* checks this between items and exits cleanly. Already-generated proposals
|
|
||||||
* stay — the cancel just stops further generation.
|
|
||||||
*/
|
|
||||||
export async function POST() {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('company_settings')
|
|
||||||
.update({ ai_backfill_cancel_requested: true })
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
|
|
||||||
return NextResponse.json({ data: { cancelled: true } })
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient as createServiceClient } from '@supabase/supabase-js'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import {
|
|
||||||
generateMatchProposalFor,
|
|
||||||
generateBookingProposalFor,
|
|
||||||
} from '@/lib/ai/orchestrator'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import type {
|
|
||||||
InvoiceInboxItem,
|
|
||||||
Transaction,
|
|
||||||
CategorizationTemplate,
|
|
||||||
} from '@/types'
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/backfill/receipts
|
|
||||||
*
|
|
||||||
* Generate AI proposals for the existing receipt backlog:
|
|
||||||
* - inbox items with document_type='receipt' and status='ready' that
|
|
||||||
* have no pending match proposal → generate match
|
|
||||||
* - items with matched_transaction_id and no booked journal entry but
|
|
||||||
* no pending booking proposal → generate booking
|
|
||||||
*
|
|
||||||
* Fire-and-forget: returns immediately with `{ queued }`. The loop
|
|
||||||
* iterates in the background, checking `company_settings.ai_backfill_cancel_requested`
|
|
||||||
* between items so the user can stop it. Idempotent via the partial
|
|
||||||
* unique index on (subject, step) WHERE pending — re-clicking does no harm.
|
|
||||||
*
|
|
||||||
* NOTE: relies on long-lived Node/Vercel worker to complete the loop. For
|
|
||||||
* v1 dev-only this is acceptable; a proper job queue is a follow-up.
|
|
||||||
*/
|
|
||||||
export async function POST() {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
|
|
||||||
// Gate on the per-company flag.
|
|
||||||
const { data: settings } = await supabase
|
|
||||||
.from('company_settings')
|
|
||||||
.select('ai_flow_enabled, ai_backfill_cancel_requested')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!settings?.ai_flow_enabled) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'AI-agenten är inte aktiverad.' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the cancel flag so a previous cancel doesn't kill this run.
|
|
||||||
await supabase
|
|
||||||
.from('company_settings')
|
|
||||||
.update({ ai_backfill_cancel_requested: false })
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
|
|
||||||
// Count eligible items up front for the response.
|
|
||||||
const { data: eligibleMatch } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('id')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('document_type', 'receipt')
|
|
||||||
.eq('status', 'ready')
|
|
||||||
.is('matched_transaction_id', null)
|
|
||||||
|
|
||||||
const { data: eligibleBooking } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('id')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('document_type', 'receipt')
|
|
||||||
.eq('status', 'ready')
|
|
||||||
.not('matched_transaction_id', 'is', null)
|
|
||||||
|
|
||||||
const matchCount = eligibleMatch?.length ?? 0
|
|
||||||
const bookingCount = eligibleBooking?.length ?? 0
|
|
||||||
|
|
||||||
// Kick off the background loop. Intentionally NOT awaited.
|
|
||||||
runBackfill(companyId, user.id).catch((err) => {
|
|
||||||
console.error('[ai/backfill/receipts] loop failed:', err)
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
data: {
|
|
||||||
queued_match: matchCount,
|
|
||||||
queued_booking: bookingCount,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the backfill loop using a service-role client so the orchestrator's
|
|
||||||
* inserts bypass RLS (mirrors how orchestrator writes from event handlers).
|
|
||||||
*/
|
|
||||||
async function runBackfill(companyId: string, userId: string): Promise<void> {
|
|
||||||
const service: SupabaseClient = createServiceClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pass 1: generate match proposals.
|
|
||||||
const { data: matchItems } = await service
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('document_type', 'receipt')
|
|
||||||
.eq('status', 'ready')
|
|
||||||
.is('matched_transaction_id', null)
|
|
||||||
|
|
||||||
for (const item of (matchItems || []) as InvoiceInboxItem[]) {
|
|
||||||
if (await isCancelled(service, companyId)) return
|
|
||||||
|
|
||||||
// Skip if a pending match proposal already exists.
|
|
||||||
const { data: existing } = await service
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('id')
|
|
||||||
.eq('subject_type', 'inbox_item')
|
|
||||||
.eq('subject_id', item.id)
|
|
||||||
.eq('step_type', 'match')
|
|
||||||
.eq('status', 'pending')
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (existing) continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
await generateMatchProposalFor(service, {
|
|
||||||
inboxItem: item,
|
|
||||||
correlationId: item.correlation_id ?? undefined,
|
|
||||||
userId,
|
|
||||||
companyId,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[ai/backfill] match failed for ${item.id}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 2: generate booking proposals for already-matched items.
|
|
||||||
const { data: bookingItems } = await service
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('document_type', 'receipt')
|
|
||||||
.eq('status', 'ready')
|
|
||||||
.not('matched_transaction_id', 'is', null)
|
|
||||||
|
|
||||||
const { data: settings } = await service
|
|
||||||
.from('company_settings')
|
|
||||||
.select('entity_type')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
const entityType: 'enskild_firma' | 'aktiebolag' =
|
|
||||||
(settings?.entity_type as 'enskild_firma' | 'aktiebolag') || 'enskild_firma'
|
|
||||||
|
|
||||||
const { data: templates } = await service
|
|
||||||
.from('categorization_templates')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('is_active', true)
|
|
||||||
|
|
||||||
for (const item of (bookingItems || []) as InvoiceInboxItem[]) {
|
|
||||||
if (await isCancelled(service, companyId)) return
|
|
||||||
|
|
||||||
const { data: existing } = await service
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('id')
|
|
||||||
.eq('subject_type', 'inbox_item')
|
|
||||||
.eq('subject_id', item.id)
|
|
||||||
.eq('step_type', 'booking')
|
|
||||||
.eq('status', 'pending')
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (existing) continue
|
|
||||||
|
|
||||||
const { data: tx } = await service
|
|
||||||
.from('transactions')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', item.matched_transaction_id!)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!tx || (tx as Transaction).journal_entry_id) continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
await generateBookingProposalFor(service, {
|
|
||||||
inboxItem: item,
|
|
||||||
matchedTransaction: tx as Transaction,
|
|
||||||
existingTemplates: (templates || []) as CategorizationTemplate[],
|
|
||||||
entityType,
|
|
||||||
correlationId: item.correlation_id ?? undefined,
|
|
||||||
userId,
|
|
||||||
companyId,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[ai/backfill] booking failed for ${item.id}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isCancelled(
|
|
||||||
service: SupabaseClient,
|
|
||||||
companyId: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
const { data } = await service
|
|
||||||
.from('company_settings')
|
|
||||||
.select('ai_backfill_cancel_requested')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
return Boolean(data?.ai_backfill_cancel_requested)
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
|
||||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import type { InvoiceInboxItem } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
const MAX_BYTES = 15 * 1024 * 1024 // 15 MB — matches invoice-inbox workspace
|
|
||||||
const ALLOWED_MIME = new Set([
|
|
||||||
'application/pdf',
|
|
||||||
'image/jpeg',
|
|
||||||
'image/jpg',
|
|
||||||
'image/png',
|
|
||||||
'image/webp',
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/inbox-items/[id]/attach-file
|
|
||||||
*
|
|
||||||
* Attach a receipt image/PDF to an existing inbox item that was created
|
|
||||||
* without a file (e.g. a row seeded for testing, or an email receipt where
|
|
||||||
* the attachment was stripped). Stores the file in the WORM documents bucket
|
|
||||||
* and links it via invoice_inbox_items.document_id. Does not re-run
|
|
||||||
* classification — the extracted_data is left as-is.
|
|
||||||
*
|
|
||||||
* Only allowed when the inbox item currently has no document_id; we never
|
|
||||||
* replace an existing attachment (WORM policy).
|
|
||||||
*/
|
|
||||||
export async function POST(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const { data: inboxRow } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!inboxRow) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
const inbox = inboxRow as InvoiceInboxItem
|
|
||||||
|
|
||||||
if (inbox.document_id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Kvittot har redan en bifogad fil.' },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse multipart form-data.
|
|
||||||
let formData: FormData
|
|
||||||
try {
|
|
||||||
formData = await request.formData()
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = formData.get('file')
|
|
||||||
if (!(file instanceof File)) {
|
|
||||||
return NextResponse.json({ error: 'Missing file' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size === 0) {
|
|
||||||
return NextResponse.json({ error: 'Filen är tom.' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > MAX_BYTES) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Filen är för stor (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB).` },
|
|
||||||
{ status: 413 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ALLOWED_MIME.has(file.type)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Filtypen stöds inte. Tillåtna: PDF, JPG, PNG, WebP.' },
|
|
||||||
{ status: 415 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await file.arrayBuffer()
|
|
||||||
|
|
||||||
let doc
|
|
||||||
try {
|
|
||||||
doc = await uploadDocument(
|
|
||||||
supabase,
|
|
||||||
user.id,
|
|
||||||
companyId,
|
|
||||||
{ name: file.name, buffer, type: file.type },
|
|
||||||
{ upload_source: 'file_upload' }
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Kunde inte ladda upp filen.'
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: linkError } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({ document_id: doc.id })
|
|
||||||
.eq('id', inbox.id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
|
|
||||||
if (linkError) {
|
|
||||||
return NextResponse.json({ error: linkError.message }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (inbox.correlation_id) {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId,
|
|
||||||
correlationId: inbox.correlation_id,
|
|
||||||
aggregateType: 'Document',
|
|
||||||
aggregateId: doc.id,
|
|
||||||
eventType: 'ReceiptFileAttached',
|
|
||||||
payload: {
|
|
||||||
inbox_item_id: inbox.id,
|
|
||||||
document_id: doc.id,
|
|
||||||
file_name: file.name,
|
|
||||||
mime_type: file.type,
|
|
||||||
size_bytes: file.size,
|
|
||||||
},
|
|
||||||
actor: { type: 'user', id: user.id },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai/inbox-items/attach-file] processing_history append failed:', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
data: {
|
|
||||||
inbox_item_id: inbox.id,
|
|
||||||
document_id: doc.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { getEmailService } from '@/lib/email/service'
|
|
||||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import { getBranding } from '@/lib/branding/service'
|
|
||||||
import type { InvoiceInboxItem } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/inbox-items/[id]/request-receipt
|
|
||||||
*
|
|
||||||
* Ask every member of the company to upload a receipt file for this inbox
|
|
||||||
* item. Used when the AI couldn't book because no source document is
|
|
||||||
* attached (BFL compliance gate) or the existing image is too poor to read.
|
|
||||||
*
|
|
||||||
* Sends one email per member with a deep link back to agent-inkorg.
|
|
||||||
* No-op when the email service isn't configured — returns 503 so the UI
|
|
||||||
* can explain.
|
|
||||||
*/
|
|
||||||
export async function POST(
|
|
||||||
_request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const { data: inboxRow } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!inboxRow) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
const inbox = inboxRow as InvoiceInboxItem
|
|
||||||
|
|
||||||
const emailService = getEmailService()
|
|
||||||
if (!emailService.isConfigured()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'E-posttjänsten är inte konfigurerad.' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load every member's email address. profiles.email is populated by the
|
|
||||||
// handle_new_user trigger and kept in sync with auth.users.
|
|
||||||
const { data: members, error: memberError } = await supabase
|
|
||||||
.from('company_members')
|
|
||||||
.select('user_id, profiles:user_id(email, full_name)')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
|
|
||||||
if (memberError) {
|
|
||||||
return NextResponse.json({ error: memberError.message }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shape of the joined profiles column depends on RLS/relationship —
|
|
||||||
// defensively support both single-object and array.
|
|
||||||
const recipients: Array<{ email: string; name: string | null }> = []
|
|
||||||
for (const row of members ?? []) {
|
|
||||||
type ProfileShape = { email?: string | null; full_name?: string | null }
|
|
||||||
const profile: ProfileShape | ProfileShape[] | null | undefined =
|
|
||||||
(row as { profiles?: ProfileShape | ProfileShape[] | null }).profiles
|
|
||||||
const profileRow = Array.isArray(profile) ? profile[0] : profile
|
|
||||||
const email = profileRow?.email
|
|
||||||
if (email) recipients.push({ email, name: profileRow?.full_name ?? null })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipients.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Inga medlemmar med e-postadress hittades.' },
|
|
||||||
{ status: 404 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: companyRow } = await supabase
|
|
||||||
.from('company_settings')
|
|
||||||
.select('company_name')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
const companyName = companyRow?.company_name ?? 'ditt företag'
|
|
||||||
|
|
||||||
// Pull a short summary of the receipt so recipients know which one to fix.
|
|
||||||
const extracted = inbox.extracted_data as {
|
|
||||||
merchant?: { name?: string | null } | null
|
|
||||||
totals?: { total?: number | null } | null
|
|
||||||
receipt?: { date?: string | null; currency?: string | null } | null
|
|
||||||
} | null
|
|
||||||
const merchant = extracted?.merchant?.name ?? 'okänd handlare'
|
|
||||||
const total = extracted?.totals?.total ?? null
|
|
||||||
const currency = extracted?.receipt?.currency ?? 'SEK'
|
|
||||||
const date = extracted?.receipt?.date ?? null
|
|
||||||
|
|
||||||
const appUrl = getBranding().appUrl
|
|
||||||
const deepLink = `${appUrl.replace(/\/$/, '')}/agent-inbox`
|
|
||||||
|
|
||||||
const subject = `[${companyName}] Kvittobild behövs för bokföring`
|
|
||||||
|
|
||||||
const summaryLine = [
|
|
||||||
merchant,
|
|
||||||
total != null ? `${total} ${currency}` : null,
|
|
||||||
date,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')
|
|
||||||
|
|
||||||
const html = buildHtml({ companyName, summaryLine, deepLink, senderName: user.email ?? null })
|
|
||||||
const text = buildText({ companyName, summaryLine, deepLink, senderName: user.email ?? null })
|
|
||||||
|
|
||||||
// Fire emails in parallel. Track successes and failures separately so a
|
|
||||||
// single bad address doesn't block the rest.
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
recipients.map((r) =>
|
|
||||||
emailService.sendEmail({
|
|
||||||
to: r.email,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
text,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
let sent = 0
|
|
||||||
let failed = 0
|
|
||||||
for (const r of results) {
|
|
||||||
if (r.status === 'fulfilled' && r.value.success) sent += 1
|
|
||||||
else failed += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit trail so the user can see "Emil requested receipt from 3 members".
|
|
||||||
try {
|
|
||||||
if (inbox.correlation_id) {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId,
|
|
||||||
correlationId: inbox.correlation_id,
|
|
||||||
aggregateType: 'Document',
|
|
||||||
aggregateId: inbox.document_id ?? inbox.id,
|
|
||||||
eventType: 'ReceiptRequested',
|
|
||||||
payload: {
|
|
||||||
inbox_item_id: inbox.id,
|
|
||||||
recipients: recipients.length,
|
|
||||||
sent,
|
|
||||||
failed,
|
|
||||||
},
|
|
||||||
actor: { type: 'user', id: user.id },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai/request-receipt] processing_history append failed:', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
data: {
|
|
||||||
sent,
|
|
||||||
failed,
|
|
||||||
total: recipients.length,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TemplateArgs {
|
|
||||||
companyName: string
|
|
||||||
summaryLine: string
|
|
||||||
deepLink: string
|
|
||||||
senderName: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHtml({ companyName, summaryLine, deepLink, senderName }: TemplateArgs): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html><head><meta charset="utf-8"></head>
|
|
||||||
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111;max-width:560px;margin:0 auto;padding:24px;">
|
|
||||||
<h2 style="margin:0 0 12px;">Source documents required for receipt and transaction mapping</h2>
|
|
||||||
<p style="margin:0 0 16px;color:#555;">
|
|
||||||
${senderName ? `${senderName} ` : ''}behöver ett kvittounderlag för ${companyName} innan en transaktion kan bokföras.
|
|
||||||
</p>
|
|
||||||
<p style="margin:0 0 20px;padding:12px;background:#f4f4f5;border-radius:6px;font-family:monospace;font-size:14px;">
|
|
||||||
${summaryLine || 'Kvitto utan extraherade uppgifter'}
|
|
||||||
</p>
|
|
||||||
<p style="margin:0 0 16px;">Öppna agent-inkorgen och ladda upp en tydlig bild eller PDF av kvittot:</p>
|
|
||||||
<p style="margin:0 0 24px;">
|
|
||||||
<a href="${deepLink}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:10px 16px;border-radius:6px;">Öppna agent-inkorg</a>
|
|
||||||
</p>
|
|
||||||
<p style="margin:0;color:#888;font-size:13px;">
|
|
||||||
Send in receipts. Utan källunderlag kan bokföringen inte slutföras enligt BFL 5 kap 7§.
|
|
||||||
</p>
|
|
||||||
</body></html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildText({ companyName, summaryLine, deepLink, senderName }: TemplateArgs): string {
|
|
||||||
return [
|
|
||||||
'Source documents required for receipt and transaction mapping.',
|
|
||||||
'',
|
|
||||||
`${senderName ? `${senderName} ` : ''}behöver ett kvittounderlag för ${companyName} innan en transaktion kan bokföras.`,
|
|
||||||
'',
|
|
||||||
summaryLine || 'Kvitto utan extraherade uppgifter',
|
|
||||||
'',
|
|
||||||
'Öppna agent-inkorgen och ladda upp en tydlig bild eller PDF av kvittot:',
|
|
||||||
deepLink,
|
|
||||||
'',
|
|
||||||
'Send in receipts. Utan källunderlag kan bokföringen inte slutföras enligt BFL 5 kap 7§.',
|
|
||||||
].join('\n')
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { validateBody } from '@/lib/api/validate'
|
|
||||||
import { RememberLearningSchema } from '@/lib/api/schemas'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { calculateConfidence } from '@/lib/bookkeeping/counterparty-templates'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import type { AIProposal } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/learning/remember
|
|
||||||
*
|
|
||||||
* Called from the UI's learning-prompt dialog after a user edited and
|
|
||||||
* accepted an AI booking proposal. Upserts a categorization_templates row
|
|
||||||
* with source='ai_corrected' so next time's proposal for the same
|
|
||||||
* counterparty starts from the user's preference.
|
|
||||||
*
|
|
||||||
* This is the ONLY path that creates an ai_corrected template — the
|
|
||||||
* "silent learning" rule means every template with this source represents
|
|
||||||
* an explicit user choice.
|
|
||||||
*/
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
|
|
||||||
const validation = await validateBody(request, RememberLearningSchema)
|
|
||||||
if (!validation.success) return validation.response
|
|
||||||
const {
|
|
||||||
proposal_id,
|
|
||||||
counterparty_name,
|
|
||||||
debit_account,
|
|
||||||
credit_account,
|
|
||||||
vat_treatment,
|
|
||||||
category,
|
|
||||||
} = validation.data
|
|
||||||
|
|
||||||
// Verify the proposal is accepted + belongs to this company.
|
|
||||||
const { data: proposal } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', proposal_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
const typed = proposal as AIProposal
|
|
||||||
if (typed.status !== 'accepted') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Endast accepterade förslag kan lagras som mall.' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (typed.step_type !== 'booking') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Endast bokföringssteget kan lagras som mall.' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert the template. Existing row for the same (user_id, counterparty_name)
|
|
||||||
// gets its source bumped up and occurrence incremented.
|
|
||||||
const { data: existing } = await supabase
|
|
||||||
.from('categorization_templates')
|
|
||||||
.select('*')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.eq('counterparty_name', counterparty_name)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const newOccurrence = existing.occurrence_count + 1
|
|
||||||
await supabase
|
|
||||||
.from('categorization_templates')
|
|
||||||
.update({
|
|
||||||
debit_account,
|
|
||||||
credit_account,
|
|
||||||
vat_treatment,
|
|
||||||
category,
|
|
||||||
source: 'ai_corrected',
|
|
||||||
occurrence_count: newOccurrence,
|
|
||||||
confidence: calculateConfidence(newOccurrence),
|
|
||||||
last_seen_date: today,
|
|
||||||
is_active: true,
|
|
||||||
})
|
|
||||||
.eq('id', existing.id)
|
|
||||||
|
|
||||||
return NextResponse.json({ data: { template_id: existing.id, updated: true } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: created, error: insertError } = await supabase
|
|
||||||
.from('categorization_templates')
|
|
||||||
.insert({
|
|
||||||
user_id: user.id,
|
|
||||||
company_id: companyId,
|
|
||||||
counterparty_name,
|
|
||||||
counterparty_aliases: [counterparty_name],
|
|
||||||
debit_account,
|
|
||||||
credit_account,
|
|
||||||
vat_treatment,
|
|
||||||
category,
|
|
||||||
source: 'ai_corrected',
|
|
||||||
occurrence_count: 1,
|
|
||||||
confidence: calculateConfidence(1),
|
|
||||||
last_seen_date: today,
|
|
||||||
is_active: true,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 })
|
|
||||||
|
|
||||||
return NextResponse.json({ data: { template_id: created.id, updated: false } })
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { eventBus } from '@/lib/events/bus'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { validateBody } from '@/lib/api/validate'
|
|
||||||
import { AcceptProposalSchema } from '@/lib/api/schemas'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { reValidateProposal } from '@/lib/ai/proposals/re-validate'
|
|
||||||
import { applyProposal } from '@/lib/ai/proposals/apply'
|
|
||||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
|
||||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import type {
|
|
||||||
AIProposal,
|
|
||||||
BookingProposalPayload,
|
|
||||||
InvoiceInboxItem,
|
|
||||||
MatchProposalPayload,
|
|
||||||
} from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/proposals/[id]/accept
|
|
||||||
*
|
|
||||||
* Accept a pending proposal:
|
|
||||||
* 1. Optimistic lock on version to catch concurrent clicks.
|
|
||||||
* 2. Re-validate (period open, transaction still unbooked, accounts active).
|
|
||||||
* 3. Apply via lib/ai/proposals/apply.ts (engine call happens there).
|
|
||||||
* 4. Mark status='accepted', set applied_entry_id, bump version.
|
|
||||||
* 5. If `edits` provided and differ from proposal_json, record edit_diff
|
|
||||||
* and return a `learning_prompt` hint so the UI can ask
|
|
||||||
* "remember this booking for <counterparty>?".
|
|
||||||
* 6. Emit ai_proposal.accepted so the orchestrator can chain match -> booking.
|
|
||||||
*/
|
|
||||||
export async function POST(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const validation = await validateBody(request, AcceptProposalSchema)
|
|
||||||
if (!validation.success) return validation.response
|
|
||||||
const { version, edits } = validation.data
|
|
||||||
|
|
||||||
// Fetch the proposal (also enforces company scope).
|
|
||||||
const { data: proposal, error: fetchError } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (fetchError) return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
|
||||||
if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
|
|
||||||
const typed = proposal as AIProposal
|
|
||||||
|
|
||||||
if (typed.status !== 'pending') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Förslaget har redan hanterats.', status: typed.status },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typed.version !== version) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Förslaget har ändrats av en annan användare — ladda om.' },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-validate current state — proposal might be stale.
|
|
||||||
const check = await reValidateProposal(supabase, companyId, typed)
|
|
||||||
if (!check.ok) {
|
|
||||||
// Mark invalidated so it drops out of the pending inbox.
|
|
||||||
await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.update({
|
|
||||||
status: 'invalidated',
|
|
||||||
invalidated_reason: check.code,
|
|
||||||
})
|
|
||||||
.eq('id', typed.id)
|
|
||||||
.eq('version', version)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: check.message, code: check.code, details: check.details ?? null },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inboxItem = check.inboxItem as InvoiceInboxItem
|
|
||||||
|
|
||||||
// Merge edits into the original payload shape (edits are partial).
|
|
||||||
let editedPayload: MatchProposalPayload | BookingProposalPayload | undefined
|
|
||||||
if (edits) {
|
|
||||||
if (typed.step_type === 'match') {
|
|
||||||
const matchEdit = edits as { matched_transaction_id: string }
|
|
||||||
const original = typed.proposal_json as MatchProposalPayload
|
|
||||||
editedPayload = {
|
|
||||||
...original,
|
|
||||||
matched_transaction_id: matchEdit.matched_transaction_id,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editedPayload = edits as BookingProposalPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute edit diff if edits were supplied and differ.
|
|
||||||
const editDiff = computeEditDiff(typed, editedPayload)
|
|
||||||
|
|
||||||
// Apply (calls the engine for booking steps).
|
|
||||||
let outcome
|
|
||||||
try {
|
|
||||||
outcome = await applyProposal(
|
|
||||||
supabase,
|
|
||||||
companyId,
|
|
||||||
user.id,
|
|
||||||
typed,
|
|
||||||
inboxItem,
|
|
||||||
editedPayload
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
const typedResp = bookkeepingErrorResponse(err)
|
|
||||||
if (typedResp) return typedResp
|
|
||||||
const message = err instanceof Error ? err.message : 'Kunde inte tillämpa förslaget'
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark proposal accepted with CAS on version.
|
|
||||||
const appliedEntryId =
|
|
||||||
outcome.kind === 'booking_applied' ? outcome.journalEntry.id : null
|
|
||||||
|
|
||||||
const { data: updatedRows, error: updateError } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.update({
|
|
||||||
status: 'accepted',
|
|
||||||
accepted_at: new Date().toISOString(),
|
|
||||||
accepted_by_user_id: user.id,
|
|
||||||
version: typed.version + 1,
|
|
||||||
applied_entry_id: appliedEntryId,
|
|
||||||
edit_diff: editDiff,
|
|
||||||
})
|
|
||||||
.eq('id', typed.id)
|
|
||||||
.eq('version', version)
|
|
||||||
.select()
|
|
||||||
|
|
||||||
if (updateError || !updatedRows || updatedRows.length === 0) {
|
|
||||||
// The domain-level apply already happened; log loud but don't unwind
|
|
||||||
// (storno would be disproportionate for a race on a status bit).
|
|
||||||
console.error('[ai/accept] proposal status update failed after apply', updateError)
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalProposal = (updatedRows?.[0] as AIProposal | undefined) ?? typed
|
|
||||||
|
|
||||||
// Audit trail.
|
|
||||||
try {
|
|
||||||
if (inboxItem.correlation_id) {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId,
|
|
||||||
correlationId: inboxItem.correlation_id,
|
|
||||||
aggregateType: 'AIProposal',
|
|
||||||
aggregateId: typed.id,
|
|
||||||
eventType: 'AIProposalAccepted',
|
|
||||||
payload: {
|
|
||||||
proposal_id: typed.id,
|
|
||||||
step_type: typed.step_type,
|
|
||||||
edited: Boolean(editDiff),
|
|
||||||
applied_entry_id: appliedEntryId,
|
|
||||||
},
|
|
||||||
actor: { type: 'user', id: user.id },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai/accept] Failed to append AIProposalAccepted:', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit event so orchestrator can chain match -> booking.
|
|
||||||
try {
|
|
||||||
await eventBus.emit({
|
|
||||||
type: 'ai_proposal.accepted',
|
|
||||||
payload: {
|
|
||||||
proposal: finalProposal,
|
|
||||||
appliedEntry: outcome.kind === 'booking_applied' ? outcome.journalEntry : null,
|
|
||||||
userId: user.id,
|
|
||||||
companyId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai/accept] Event emit failed:', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
data: {
|
|
||||||
proposal: finalProposal,
|
|
||||||
applied_entry_id: appliedEntryId,
|
|
||||||
learning_prompt:
|
|
||||||
editDiff && typed.step_type === 'booking' && editedPayload
|
|
||||||
? buildLearningPromptHint(editedPayload as BookingProposalPayload, typed)
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function computeEditDiff(
|
|
||||||
proposal: AIProposal,
|
|
||||||
edits: MatchProposalPayload | BookingProposalPayload | undefined
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
if (!edits) return null
|
|
||||||
const before = proposal.proposal_json as unknown
|
|
||||||
const after = edits as unknown
|
|
||||||
if (JSON.stringify(before) === JSON.stringify(after)) return null
|
|
||||||
return { before, after }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the user edited a booking proposal, offer to save the corrected
|
|
||||||
* shape as a counterparty template so next time's proposal starts from
|
|
||||||
* the user's preference.
|
|
||||||
*/
|
|
||||||
function buildLearningPromptHint(
|
|
||||||
edits: BookingProposalPayload,
|
|
||||||
proposal: AIProposal
|
|
||||||
): { counterparty_name: string; debit_account: string; credit_account: string; vat_treatment: string | null } | null {
|
|
||||||
const tpl = edits.counterparty_template_proposal
|
|
||||||
if (!tpl) return null
|
|
||||||
return {
|
|
||||||
counterparty_name: tpl.counterparty_name,
|
|
||||||
debit_account: tpl.debit_account,
|
|
||||||
credit_account: tpl.credit_account,
|
|
||||||
vat_treatment: tpl.vat_treatment,
|
|
||||||
}
|
|
||||||
// proposal is in signature for future refinement (e.g. embed original accounts for diff UI)
|
|
||||||
void proposal
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { validateBody } from '@/lib/api/validate'
|
|
||||||
import { ChangeMatchProposalSchema } from '@/lib/api/schemas'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import type { AIProposal, InvoiceInboxItem, MatchProposalPayload } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/proposals/[id]/change-match
|
|
||||||
*
|
|
||||||
* Swap the matched_transaction_id on a pending match proposal without
|
|
||||||
* accepting it. Lets the user verify a different candidate (AI alternative,
|
|
||||||
* AI-regenerated, or manually picked) before hitting Godkänn.
|
|
||||||
*
|
|
||||||
* - Keeps status='pending' so the user still has to explicitly accept.
|
|
||||||
* - Bumps version (optimistic lock) and records edit_diff with before/after +
|
|
||||||
* source so we can later measure how often the AI's top pick gets overridden
|
|
||||||
* and by which merchant/path.
|
|
||||||
* - Sets confidence to 1.0 (user-picked transactions are certain).
|
|
||||||
*/
|
|
||||||
export async function POST(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const validation = await validateBody(request, ChangeMatchProposalSchema)
|
|
||||||
if (!validation.success) return validation.response
|
|
||||||
const { version, matched_transaction_id, source } = validation.data
|
|
||||||
|
|
||||||
const { data: proposalRow } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!proposalRow) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
|
|
||||||
const proposal = proposalRow as AIProposal
|
|
||||||
|
|
||||||
if (proposal.status !== 'pending') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Förslaget har redan hanterats.', status: proposal.status },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proposal.step_type !== 'match') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Bara match-förslag kan byta transaktion.' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proposal.version !== version) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Förslaget har ändrats av en annan användare — ladda om.' },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the new transaction: same company, uncategorized, no journal entry.
|
|
||||||
const { data: tx } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('id, company_id, journal_entry_id')
|
|
||||||
.eq('id', matched_transaction_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!tx) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Transaktionen hittades inte.' },
|
|
||||||
{ status: 404 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tx.journal_entry_id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Transaktionen är redan bokförd.' },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalPayload = proposal.proposal_json as MatchProposalPayload
|
|
||||||
|
|
||||||
// No-op? Return current state.
|
|
||||||
if (originalPayload.matched_transaction_id === matched_transaction_id) {
|
|
||||||
return NextResponse.json({ data: { proposal } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPayload: MatchProposalPayload = {
|
|
||||||
...originalPayload,
|
|
||||||
matched_transaction_id,
|
|
||||||
top_confidence: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
const editDiff = {
|
|
||||||
before: originalPayload,
|
|
||||||
after: newPayload,
|
|
||||||
source,
|
|
||||||
changed_at: new Date().toISOString(),
|
|
||||||
changed_by_user_id: user.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: updatedRows, error: updateError } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.update({
|
|
||||||
proposal_json: newPayload,
|
|
||||||
confidence: 1,
|
|
||||||
edit_diff: editDiff,
|
|
||||||
version: proposal.version + 1,
|
|
||||||
})
|
|
||||||
.eq('id', proposal.id)
|
|
||||||
.eq('version', version)
|
|
||||||
.select()
|
|
||||||
|
|
||||||
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
|
|
||||||
if (!updatedRows || updatedRows.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Förslaget har ändrats av en annan användare — ladda om.' },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalProposal = updatedRows[0] as AIProposal
|
|
||||||
|
|
||||||
// Audit trail.
|
|
||||||
try {
|
|
||||||
const { data: inboxItem } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('correlation_id')
|
|
||||||
.eq('id', proposal.subject_id)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
const item = inboxItem as Pick<InvoiceInboxItem, 'correlation_id'> | null
|
|
||||||
|
|
||||||
if (item?.correlation_id) {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId,
|
|
||||||
correlationId: item.correlation_id,
|
|
||||||
aggregateType: 'AIProposal',
|
|
||||||
aggregateId: proposal.id,
|
|
||||||
eventType: 'AIProposalMatchChanged',
|
|
||||||
payload: {
|
|
||||||
proposal_id: proposal.id,
|
|
||||||
from_transaction_id: originalPayload.matched_transaction_id,
|
|
||||||
to_transaction_id: matched_transaction_id,
|
|
||||||
source,
|
|
||||||
},
|
|
||||||
actor: { type: 'user', id: user.id },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai/change-match] Failed to append AIProposalMatchChanged:', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ data: { proposal: finalProposal } })
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { eventBus } from '@/lib/events/bus'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { validateBody } from '@/lib/api/validate'
|
|
||||||
import { RejectProposalSchema } from '@/lib/api/schemas'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import type { AIProposal, InvoiceInboxItem } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/proposals/[id]/reject
|
|
||||||
*
|
|
||||||
* Mark a pending proposal as rejected. The orchestrator will NOT chain the
|
|
||||||
* next step — the user has signalled the AI got this one wrong. Subsequent
|
|
||||||
* action (upload new doc, manually categorize, etc.) is up to the user.
|
|
||||||
*/
|
|
||||||
export async function POST(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const validation = await validateBody(request, RejectProposalSchema)
|
|
||||||
if (!validation.success) return validation.response
|
|
||||||
const { version, reason } = validation.data
|
|
||||||
|
|
||||||
const { data: proposal } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
|
|
||||||
const typed = proposal as AIProposal
|
|
||||||
|
|
||||||
if (typed.status !== 'pending') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Förslaget har redan hanterats.', status: typed.status },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: updatedRows, error: updateError } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.update({
|
|
||||||
status: 'rejected',
|
|
||||||
rejected_at: new Date().toISOString(),
|
|
||||||
invalidated_reason: reason ?? null,
|
|
||||||
version: typed.version + 1,
|
|
||||||
})
|
|
||||||
.eq('id', typed.id)
|
|
||||||
.eq('version', version)
|
|
||||||
.select()
|
|
||||||
|
|
||||||
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
|
|
||||||
if (!updatedRows || updatedRows.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Förslaget har ändrats av en annan användare — ladda om.' },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalProposal = updatedRows[0] as AIProposal
|
|
||||||
|
|
||||||
// Audit trail.
|
|
||||||
try {
|
|
||||||
const { data: inboxItem } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('correlation_id')
|
|
||||||
.eq('id', typed.subject_id)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
const item = inboxItem as Pick<InvoiceInboxItem, 'correlation_id'> | null
|
|
||||||
|
|
||||||
if (item?.correlation_id) {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId,
|
|
||||||
correlationId: item.correlation_id,
|
|
||||||
aggregateType: 'AIProposal',
|
|
||||||
aggregateId: typed.id,
|
|
||||||
eventType: 'AIProposalRejected',
|
|
||||||
payload: { proposal_id: typed.id, step_type: typed.step_type, reason: reason ?? null },
|
|
||||||
actor: { type: 'user', id: user.id },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai/reject] Failed to append AIProposalRejected:', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await eventBus.emit({
|
|
||||||
type: 'ai_proposal.rejected',
|
|
||||||
payload: { proposal: finalProposal, userId: user.id, companyId },
|
|
||||||
})
|
|
||||||
} catch { /* non-blocking */ }
|
|
||||||
|
|
||||||
return NextResponse.json({ data: { proposal: finalProposal } })
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/ai/proposals/[id]
|
|
||||||
*
|
|
||||||
* Returns the full proposal row plus the linked inbox item and, when
|
|
||||||
* applicable, the matched transaction and already-applied journal entry.
|
|
||||||
*/
|
|
||||||
export async function GET(
|
|
||||||
_request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const { data: proposal, error } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
|
|
||||||
// Load the inbox item (only subject_type in v1) for context.
|
|
||||||
const { data: inboxItem } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*, document:document_attachments!document_id(*)')
|
|
||||||
.eq('id', proposal.subject_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
// Load the matched transaction if the inbox item has one.
|
|
||||||
let transaction = null
|
|
||||||
if (inboxItem?.matched_transaction_id) {
|
|
||||||
const { data: tx } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', inboxItem.matched_transaction_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
transaction = tx
|
|
||||||
}
|
|
||||||
|
|
||||||
// For accepted booking proposals, fetch the applied journal entry.
|
|
||||||
let journalEntry = null
|
|
||||||
if (proposal.applied_entry_id) {
|
|
||||||
const { data: entry } = await supabase
|
|
||||||
.from('journal_entries')
|
|
||||||
.select('*, lines:journal_entry_lines(*)')
|
|
||||||
.eq('id', proposal.applied_entry_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
journalEntry = entry
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
data: {
|
|
||||||
proposal,
|
|
||||||
inbox_item: inboxItem ?? null,
|
|
||||||
transaction,
|
|
||||||
journal_entry: journalEntry,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { eventBus } from '@/lib/events/bus'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { validateBody } from '@/lib/api/validate'
|
|
||||||
import { BatchAcceptSchema } from '@/lib/api/schemas'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { reValidateProposal } from '@/lib/ai/proposals/re-validate'
|
|
||||||
import { applyProposal } from '@/lib/ai/proposals/apply'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import type { AIProposal, InvoiceInboxItem } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
interface BatchOutcomePerProposal {
|
|
||||||
proposal_id: string
|
|
||||||
ok: boolean
|
|
||||||
error?: string
|
|
||||||
code?: string
|
|
||||||
applied_entry_id?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/proposals/batch-accept
|
|
||||||
*
|
|
||||||
* Accept multiple pending proposals in one click. Best-effort: each item is
|
|
||||||
* independently re-validated and applied. The response contains per-item
|
|
||||||
* outcomes so the UI can show checkmarks + specific failure messages
|
|
||||||
* (e.g., "fiscal period closed since you loaded the page").
|
|
||||||
*
|
|
||||||
* No edits are supported in batch mode — edits require the user to open the
|
|
||||||
* individual proposal and approve from there.
|
|
||||||
*/
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
|
|
||||||
const validation = await validateBody(request, BatchAcceptSchema)
|
|
||||||
if (!validation.success) return validation.response
|
|
||||||
const { proposal_ids } = validation.data
|
|
||||||
|
|
||||||
const outcomes: BatchOutcomePerProposal[] = []
|
|
||||||
|
|
||||||
for (const proposalId of proposal_ids) {
|
|
||||||
const outcome = await acceptOne(supabase, companyId, user.id, proposalId)
|
|
||||||
outcomes.push(outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
data: {
|
|
||||||
outcomes,
|
|
||||||
accepted: outcomes.filter((o) => o.ok).length,
|
|
||||||
failed: outcomes.filter((o) => !o.ok).length,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acceptOne(
|
|
||||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
|
||||||
companyId: string,
|
|
||||||
userId: string,
|
|
||||||
proposalId: string
|
|
||||||
): Promise<BatchOutcomePerProposal> {
|
|
||||||
const { data: proposal } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', proposalId)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!proposal) return { proposal_id: proposalId, ok: false, error: 'Not found', code: 'not_found' }
|
|
||||||
|
|
||||||
const typed = proposal as AIProposal
|
|
||||||
if (typed.status !== 'pending') {
|
|
||||||
return { proposal_id: proposalId, ok: false, error: `Already ${typed.status}`, code: 'not_pending' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const check = await reValidateProposal(supabase, companyId, typed)
|
|
||||||
if (!check.ok) {
|
|
||||||
await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.update({ status: 'invalidated', invalidated_reason: check.code })
|
|
||||||
.eq('id', typed.id)
|
|
||||||
.eq('version', typed.version)
|
|
||||||
return { proposal_id: proposalId, ok: false, error: check.message, code: check.code }
|
|
||||||
}
|
|
||||||
|
|
||||||
const inboxItem = check.inboxItem as InvoiceInboxItem
|
|
||||||
|
|
||||||
let outcome
|
|
||||||
try {
|
|
||||||
outcome = await applyProposal(supabase, companyId, userId, typed, inboxItem)
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
proposal_id: proposalId,
|
|
||||||
ok: false,
|
|
||||||
error: err instanceof Error ? err.message : 'apply_failed',
|
|
||||||
code: 'apply_failed',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedEntryId =
|
|
||||||
outcome.kind === 'booking_applied' ? outcome.journalEntry.id : null
|
|
||||||
|
|
||||||
const { data: updated } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.update({
|
|
||||||
status: 'accepted',
|
|
||||||
accepted_at: new Date().toISOString(),
|
|
||||||
accepted_by_user_id: userId,
|
|
||||||
version: typed.version + 1,
|
|
||||||
applied_entry_id: appliedEntryId,
|
|
||||||
})
|
|
||||||
.eq('id', typed.id)
|
|
||||||
.eq('version', typed.version)
|
|
||||||
.select()
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await eventBus.emit({
|
|
||||||
type: 'ai_proposal.accepted',
|
|
||||||
payload: {
|
|
||||||
proposal: (updated as AIProposal | null) ?? typed,
|
|
||||||
appliedEntry: outcome.kind === 'booking_applied' ? outcome.journalEntry : null,
|
|
||||||
userId,
|
|
||||||
companyId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch { /* non-blocking */ }
|
|
||||||
|
|
||||||
return { proposal_id: proposalId, ok: true, applied_entry_id: appliedEntryId }
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { validateQuery } from '@/lib/api/validate'
|
|
||||||
import { ListProposalsQuerySchema } from '@/lib/api/schemas'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/ai/proposals
|
|
||||||
*
|
|
||||||
* List AI proposals for the active company, newest first.
|
|
||||||
* Query params:
|
|
||||||
* status?: pending | accepted | rejected | skipped | invalidated
|
|
||||||
* step_type?: match | booking
|
|
||||||
* limit?: default 20, max 100
|
|
||||||
* offset?: default 0
|
|
||||||
*
|
|
||||||
* Returns { data: AIProposal[], count: number }.
|
|
||||||
*/
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
|
|
||||||
const qs = validateQuery(request, ListProposalsQuerySchema)
|
|
||||||
if (!qs.success) return qs.response
|
|
||||||
const { status, step_type, limit, offset } = qs.data
|
|
||||||
|
|
||||||
let query = supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.select('*', { count: 'exact' })
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.range(offset, offset + limit - 1)
|
|
||||||
|
|
||||||
if (status) query = query.eq('status', status)
|
|
||||||
if (step_type) query = query.eq('step_type', step_type)
|
|
||||||
|
|
||||||
const { data, error, count } = await query
|
|
||||||
if (error) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ data, count: count ?? data?.length ?? 0 })
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { validateBody } from '@/lib/api/validate'
|
|
||||||
import { ResolveRequestSchema } from '@/lib/api/schemas'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
import type { AIRequest, InvoiceInboxItem } from '@/types'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/ai/requests/[id]/resolve
|
|
||||||
*
|
|
||||||
* Mark an open ai_request as resolved. The response body is stored on the
|
|
||||||
* row for audit, but the actual follow-up action (re-upload doc, pick a
|
|
||||||
* transaction manually, set a VAT rate) is wired through the existing
|
|
||||||
* domain endpoints — the UI calls those separately. This endpoint just
|
|
||||||
* closes out the request card.
|
|
||||||
*/
|
|
||||||
export async function POST(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
const validation = await validateBody(request, ResolveRequestSchema)
|
|
||||||
if (!validation.success) return validation.response
|
|
||||||
const { response } = validation.data
|
|
||||||
|
|
||||||
const { data: req } = await supabase
|
|
||||||
.from('ai_requests')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!req) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
const typed = req as AIRequest
|
|
||||||
if (typed.status !== 'open') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Begäran är redan hanterad.', status: typed.status },
|
|
||||||
{ status: 409 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: updated, error: updateError } = await supabase
|
|
||||||
.from('ai_requests')
|
|
||||||
.update({
|
|
||||||
status: 'resolved',
|
|
||||||
resolved_at: new Date().toISOString(),
|
|
||||||
resolved_by_user_id: user.id,
|
|
||||||
response_json: response ?? null,
|
|
||||||
})
|
|
||||||
.eq('id', typed.id)
|
|
||||||
.eq('status', 'open')
|
|
||||||
.select()
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (updateError || !updated) {
|
|
||||||
return NextResponse.json({ error: 'Kunde inte uppdatera' }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit.
|
|
||||||
try {
|
|
||||||
const { data: inbox } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('correlation_id')
|
|
||||||
.eq('id', typed.subject_id)
|
|
||||||
.maybeSingle()
|
|
||||||
const item = inbox as Pick<InvoiceInboxItem, 'correlation_id'> | null
|
|
||||||
if (item?.correlation_id) {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId,
|
|
||||||
correlationId: item.correlation_id,
|
|
||||||
aggregateType: 'AIRequest',
|
|
||||||
aggregateId: typed.id,
|
|
||||||
eventType: 'AIRequestResolved',
|
|
||||||
payload: {
|
|
||||||
request_id: typed.id,
|
|
||||||
request_type: typed.request_type,
|
|
||||||
has_response: Boolean(response),
|
|
||||||
},
|
|
||||||
actor: { type: 'user', id: user.id },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai/requests/resolve] Failed to append AIRequestResolved:', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ data: { request: updated } })
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
||||||
if (!writeCheck.ok) return writeCheck.response
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
|
|
||||||
// Optional: pass specific inbox item IDs to match
|
|
||||||
let inboxItemIds: string[] | undefined
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
if (Array.isArray(body?.inboxItemIds)) {
|
|
||||||
inboxItemIds = body.inboxItemIds
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No body or invalid JSON — sweep all unmatched items
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await runDocumentMatchingSweep(supabase, companyId, inboxItemIds)
|
|
||||||
return NextResponse.json({ data: result })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[match-sweep] Failed:', error)
|
|
||||||
return NextResponse.json({ error: 'Match sweep failed' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,14 +84,6 @@ export async function PUT(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate: aktiebolag must use accrual accounting (BFNAR 2006:1)
|
|
||||||
if (effectiveEntityType === 'aktiebolag' && body.accounting_method === 'cash') {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Aktiebolag måste använda faktureringsmetoden (BFNAR 2006:1)' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
|
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
|
||||||
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
|
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
|
||||||
if (effectiveVatRegistered === true) {
|
if (effectiveVatRegistered === true) {
|
||||||
|
|||||||
@@ -360,21 +360,15 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm matched inbox item and link its document to the journal entry
|
// Link the matched inbox item's document to the journal entry
|
||||||
if (body.inbox_item_id) {
|
if (body.inbox_item_id) {
|
||||||
try {
|
try {
|
||||||
await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({ status: 'confirmed' })
|
|
||||||
.eq('id', body.inbox_item_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
|
|
||||||
// Link inbox item's document to the journal entry
|
|
||||||
if (journalEntryId) {
|
if (journalEntryId) {
|
||||||
const { data: inboxItem } = await supabase
|
const { data: inboxItem } = await supabase
|
||||||
.from('invoice_inbox_items')
|
.from('invoice_inbox_items')
|
||||||
.select('document_id')
|
.select('document_id')
|
||||||
.eq('id', body.inbox_item_id)
|
.eq('id', body.inbox_item_id)
|
||||||
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (inboxItem?.document_id) {
|
if (inboxItem?.document_id) {
|
||||||
|
|||||||
@@ -113,50 +113,6 @@ export async function POST(request: Request) {
|
|||||||
template_suggestions[tx.id] = [cpSuggestion, ...existing]
|
template_suggestions[tx.id] = [cpSuggestion, ...existing]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject document template suggestions from matched inbox items
|
|
||||||
try {
|
|
||||||
const { data: matchedInboxItems } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('matched_transaction_id, suggested_template_id, suggested_template_confidence')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.in('matched_transaction_id', ids)
|
|
||||||
.not('suggested_template_id', 'is', null)
|
|
||||||
|
|
||||||
if (matchedInboxItems && matchedInboxItems.length > 0) {
|
|
||||||
console.log(`[suggest-categories] Found ${matchedInboxItems.length} matched inbox items with template suggestions`)
|
|
||||||
const { getTemplateById } = await import('@/lib/bookkeeping/booking-templates')
|
|
||||||
|
|
||||||
for (const item of matchedInboxItems) {
|
|
||||||
const txId = item.matched_transaction_id as string
|
|
||||||
const templateId = item.suggested_template_id as string
|
|
||||||
const template = getTemplateById(templateId)
|
|
||||||
if (!template) {
|
|
||||||
console.log(`[suggest-categories] Template "${templateId}" not found, skipping`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[suggest-categories] Injecting document template: tx=${txId} → ${templateId} (${template.name_sv}, debit=${template.debit_account}, confidence=${item.suggested_template_confidence})`)
|
|
||||||
|
|
||||||
// Add to template_suggestions at the top with boosted confidence
|
|
||||||
const existing = template_suggestions[txId] || []
|
|
||||||
const docTemplate: SuggestedTemplate = {
|
|
||||||
template_id: templateId,
|
|
||||||
name_sv: template.name_sv,
|
|
||||||
name_en: template.name_en,
|
|
||||||
group: template.group,
|
|
||||||
debit_account: template.debit_account,
|
|
||||||
credit_account: template.credit_account,
|
|
||||||
confidence: Math.min((item.suggested_template_confidence as number) || 0.8, 1),
|
|
||||||
description_sv: template.description_sv,
|
|
||||||
risk_level: template.risk_level,
|
|
||||||
requires_review: template.requires_review,
|
|
||||||
}
|
|
||||||
template_suggestions[txId] = [docTemplate, ...existing.filter((t) => t.template_id !== templateId)]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Non-blocking
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ suggestions, template_suggestions })
|
return NextResponse.json({ suggestions, template_suggestions })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { ensureInitialized } from '@/lib/init'
|
|
||||||
import { requireCompanyId } from '@/lib/company/context'
|
|
||||||
import { gateAgentInbox } from '@/lib/ai/feature-flag'
|
|
||||||
|
|
||||||
ensureInitialized()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/transactions/uncategorized
|
|
||||||
*
|
|
||||||
* Paginated list of uncategorized expense transactions for a picker UI
|
|
||||||
* (e.g. agent-inkorg's "Byt transaktion" flow). Returns expenses only —
|
|
||||||
* amount < 0 — since match proposals always pair receipts to outgoing
|
|
||||||
* payments. Includes basic range filters so callers can narrow to matches
|
|
||||||
* within ±window of a target amount/date.
|
|
||||||
*
|
|
||||||
* Query params:
|
|
||||||
* search Free-text against description/merchant_name (ILIKE)
|
|
||||||
* amount_center Target amount (signed). Must be accompanied by amount_window.
|
|
||||||
* amount_window Half-window in SEK — e.g. 50 means amount_center ± 50.
|
|
||||||
* date_center Target ISO date. Must be accompanied by date_window.
|
|
||||||
* date_window Half-window in days — e.g. 30 means ±30 days.
|
|
||||||
* limit Max rows (1-50, default 20).
|
|
||||||
* offset Row offset for pagination (default 0).
|
|
||||||
*/
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const gate = gateAgentInbox()
|
|
||||||
if (gate) return gate
|
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
|
||||||
const url = new URL(request.url)
|
|
||||||
|
|
||||||
const search = url.searchParams.get('search')?.trim() ?? ''
|
|
||||||
const amountCenterRaw = url.searchParams.get('amount_center')
|
|
||||||
const amountWindowRaw = url.searchParams.get('amount_window')
|
|
||||||
const dateCenterRaw = url.searchParams.get('date_center')
|
|
||||||
const dateWindowRaw = url.searchParams.get('date_window')
|
|
||||||
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 20), 50)
|
|
||||||
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0)
|
|
||||||
|
|
||||||
let query = supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('id, date, description, amount, currency, merchant_name, category, is_business', { count: 'exact' })
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.is('journal_entry_id', null)
|
|
||||||
.lt('amount', 0)
|
|
||||||
.order('date', { ascending: false })
|
|
||||||
.range(offset, offset + limit - 1)
|
|
||||||
|
|
||||||
if (search.length > 0) {
|
|
||||||
const escaped = search.replace(/[%_]/g, '\\$&')
|
|
||||||
query = query.or(`description.ilike.%${escaped}%,merchant_name.ilike.%${escaped}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (amountCenterRaw && amountWindowRaw) {
|
|
||||||
const center = Number(amountCenterRaw)
|
|
||||||
const window = Math.abs(Number(amountWindowRaw))
|
|
||||||
if (Number.isFinite(center) && Number.isFinite(window) && window > 0) {
|
|
||||||
query = query.gte('amount', center - window).lte('amount', center + window)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateCenterRaw && dateWindowRaw) {
|
|
||||||
const windowDays = Math.abs(Number(dateWindowRaw))
|
|
||||||
if (Number.isFinite(windowDays) && windowDays > 0) {
|
|
||||||
const center = new Date(dateCenterRaw)
|
|
||||||
if (!Number.isNaN(center.getTime())) {
|
|
||||||
const msPerDay = 86_400_000
|
|
||||||
const from = new Date(center.getTime() - windowDays * msPerDay)
|
|
||||||
const to = new Date(center.getTime() + windowDays * msPerDay)
|
|
||||||
query = query.gte('date', from.toISOString().slice(0, 10))
|
|
||||||
query = query.lte('date', to.toISOString().slice(0, 10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error, count } = await query
|
|
||||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
data: {
|
|
||||||
transactions: data ?? [],
|
|
||||||
count: count ?? 0,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ export function RecaptIdentify({
|
|||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (window.Recapt) {
|
if (typeof window.Recapt?.session?.setIdentity === 'function') {
|
||||||
window.Recapt.session.setIdentity({
|
window.Recapt.session.setIdentity({
|
||||||
uid: userId,
|
uid: userId,
|
||||||
email: email,
|
email: email,
|
||||||
|
|||||||
@@ -1,562 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
|
||||||
import { PageHeader } from '@/components/ui/page-header'
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { Sparkles, PlayCircle, XCircle, Loader2 } from 'lucide-react'
|
|
||||||
import ProposalCard from './ProposalCard'
|
|
||||||
import RequestCard from './RequestCard'
|
|
||||||
import EditBookingDialog from './EditBookingDialog'
|
|
||||||
import LearningPromptDialog from './LearningPromptDialog'
|
|
||||||
import ChangeTransactionDialog from './ChangeTransactionDialog'
|
|
||||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
|
||||||
import type { AIProposal, BookingProposalPayload } from '@/types'
|
|
||||||
|
|
||||||
type FilterKey = 'all' | 'match' | 'booking'
|
|
||||||
|
|
||||||
// The match step encompasses two UI states: actual match proposals waiting
|
|
||||||
// for approval AND ai_requests where Claude couldn't find a candidate and
|
|
||||||
// asked the user to pick manually. Both belong under the "Matchning" tab.
|
|
||||||
function filterKeyFor(item: AgentInboxItemView): Exclude<FilterKey, 'all'> {
|
|
||||||
if (item.proposal?.step_type === 'booking') return 'booking'
|
|
||||||
return 'match'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentInboxProps {
|
|
||||||
initialItems: AgentInboxItemView[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LearningPromptState {
|
|
||||||
proposalId: string
|
|
||||||
counterparty_name: string
|
|
||||||
debit_account: string
|
|
||||||
credit_account: string
|
|
||||||
vat_treatment: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AgentInbox({ initialItems }: AgentInboxProps) {
|
|
||||||
const [items, setItems] = useState(initialItems)
|
|
||||||
|
|
||||||
// After router.refresh() the server re-runs and passes a new initialItems
|
|
||||||
// prop. useState only reads its arg on mount, so sync explicitly — otherwise
|
|
||||||
// newly-chained booking proposals stay invisible after a match accept.
|
|
||||||
useEffect(() => {
|
|
||||||
setItems(initialItems)
|
|
||||||
}, [initialItems])
|
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
||||||
const [busyProposalId, setBusyProposalId] = useState<string | null>(null)
|
|
||||||
const [editProposal, setEditProposal] = useState<AIProposal | null>(null)
|
|
||||||
const [changeMatchItem, setChangeMatchItem] = useState<AgentInboxItemView | null>(null)
|
|
||||||
const [learningPrompt, setLearningPrompt] = useState<LearningPromptState | null>(null)
|
|
||||||
const [backfillRunning, setBackfillRunning] = useState(false)
|
|
||||||
const [batchRunning, setBatchRunning] = useState(false)
|
|
||||||
const [filter, setFilter] = useState<FilterKey>('all')
|
|
||||||
const [backfillProgress, setBackfillProgress] = useState<{
|
|
||||||
target: number
|
|
||||||
startPending: number
|
|
||||||
currentPending: number
|
|
||||||
} | null>(null)
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
const stableTicksRef = useRef(0)
|
|
||||||
const { toast } = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Cleanup any running poller on unmount.
|
|
||||||
useEffect(() => () => {
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Counts per filter bucket, used on the tab triggers.
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
let match = 0, booking = 0
|
|
||||||
for (const i of items) {
|
|
||||||
if (filterKeyFor(i) === 'booking') booking++
|
|
||||||
else match++
|
|
||||||
}
|
|
||||||
return { all: items.length, match, booking }
|
|
||||||
}, [items])
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
if (filter === 'all') return items
|
|
||||||
return items.filter((i) => filterKeyFor(i) === filter)
|
|
||||||
}, [items, filter])
|
|
||||||
|
|
||||||
const selectableProposalIds = useMemo(
|
|
||||||
() =>
|
|
||||||
filteredItems
|
|
||||||
.filter((i) => i.proposal && i.proposal.status === 'pending')
|
|
||||||
.map((i) => i.proposal!.id),
|
|
||||||
[filteredItems]
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleSelect = useCallback((id: string) => {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const selectAll = useCallback(() => {
|
|
||||||
setSelectedIds(new Set(selectableProposalIds))
|
|
||||||
}, [selectableProposalIds])
|
|
||||||
|
|
||||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
|
||||||
|
|
||||||
const removeItem = useCallback((proposalId: string | null, requestId: string | null) => {
|
|
||||||
setItems((prev) =>
|
|
||||||
prev.filter((i) => {
|
|
||||||
if (proposalId && i.proposal?.id === proposalId) return false
|
|
||||||
if (requestId && i.request?.id === requestId) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
)
|
|
||||||
if (proposalId) {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.delete(proposalId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ── Accept ─────────────────────────────────────────────────────────
|
|
||||||
const handleAccept = async (
|
|
||||||
proposal: AIProposal,
|
|
||||||
edits?: BookingProposalPayload | { matched_transaction_id: string }
|
|
||||||
) => {
|
|
||||||
setBusyProposalId(proposal.id)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/ai/proposals/${proposal.id}/accept`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ version: proposal.version, edits }),
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Kunde inte godkänna', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast({ title: proposal.step_type === 'match' ? 'Matchning godkänd' : 'Bokförd' })
|
|
||||||
|
|
||||||
if (body.data?.learning_prompt) {
|
|
||||||
setLearningPrompt({
|
|
||||||
proposalId: proposal.id,
|
|
||||||
counterparty_name: body.data.learning_prompt.counterparty_name,
|
|
||||||
debit_account: body.data.learning_prompt.debit_account,
|
|
||||||
credit_account: body.data.learning_prompt.credit_account,
|
|
||||||
vat_treatment: body.data.learning_prompt.vat_treatment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
removeItem(proposal.id, null)
|
|
||||||
// Accepting a match proposal chains to a new booking proposal (generated
|
|
||||||
// synchronously inside the event handler during accept). Accepting a
|
|
||||||
// booking proposal produces the terminal state. Refresh the server
|
|
||||||
// component either way so the new state lands on screen.
|
|
||||||
router.refresh()
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Fel',
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setBusyProposalId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reject ─────────────────────────────────────────────────────────
|
|
||||||
const handleReject = async (proposal: AIProposal) => {
|
|
||||||
setBusyProposalId(proposal.id)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/ai/proposals/${proposal.id}/reject`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ version: proposal.version }),
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Kunde inte avvisa', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast({ title: 'Avvisad' })
|
|
||||||
removeItem(proposal.id, null)
|
|
||||||
router.refresh()
|
|
||||||
} finally {
|
|
||||||
setBusyProposalId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Batch accept ───────────────────────────────────────────────────
|
|
||||||
const handleBatchAccept = async () => {
|
|
||||||
if (selectedIds.size === 0) return
|
|
||||||
setBatchRunning(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai/proposals/batch-accept', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ proposal_ids: [...selectedIds] }),
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Batch-godkännande misslyckades', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { accepted, failed, outcomes } = body.data
|
|
||||||
toast({
|
|
||||||
title: `${accepted} godkända${failed > 0 ? `, ${failed} misslyckades` : ''}`,
|
|
||||||
})
|
|
||||||
// Remove only the ones that succeeded.
|
|
||||||
const successIds = new Set<string>(
|
|
||||||
(outcomes as Array<{ proposal_id: string; ok: boolean }>)
|
|
||||||
.filter((o) => o.ok)
|
|
||||||
.map((o) => o.proposal_id)
|
|
||||||
)
|
|
||||||
setItems((prev) => prev.filter((i) => !(i.proposal && successIds.has(i.proposal.id))))
|
|
||||||
clearSelection()
|
|
||||||
router.refresh()
|
|
||||||
} finally {
|
|
||||||
setBatchRunning(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Backfill ───────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// The server runs a fire-and-forget loop that drafts proposals one at a
|
|
||||||
// time. Without feedback the user clicks the button and sees nothing. So
|
|
||||||
// here we poll the proposal count every 2s and show a progress card: the
|
|
||||||
// target is "pending proposals count at start + queued_match + queued_booking",
|
|
||||||
// the delta against that is progress. Poller stops when: (a) target hit,
|
|
||||||
// (b) count hasn't moved for 3 consecutive polls (drafted everything it
|
|
||||||
// could), or (c) user clicks cancel.
|
|
||||||
const stopPolling = useCallback(() => {
|
|
||||||
if (pollRef.current) {
|
|
||||||
clearInterval(pollRef.current)
|
|
||||||
pollRef.current = null
|
|
||||||
}
|
|
||||||
stableTicksRef.current = 0
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchPendingCount = useCallback(async (): Promise<number | null> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/ai/proposals?status=pending&limit=1')
|
|
||||||
if (!res.ok) return null
|
|
||||||
const body = await res.json()
|
|
||||||
return typeof body.count === 'number' ? body.count : null
|
|
||||||
} catch { return null }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleBackfill = async () => {
|
|
||||||
setBackfillRunning(true)
|
|
||||||
try {
|
|
||||||
// Snapshot current pending count before kicking off. The target is
|
|
||||||
// this + the queued counts the server reports back.
|
|
||||||
const startPending = (await fetchPendingCount()) ?? 0
|
|
||||||
|
|
||||||
const res = await fetch('/api/ai/backfill/receipts', { method: 'POST' })
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Kunde inte starta backfill', description: body.error, variant: 'destructive' })
|
|
||||||
setBackfillRunning(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const queuedTotal = (body.data.queued_match || 0) + (body.data.queued_booking || 0)
|
|
||||||
if (queuedTotal === 0) {
|
|
||||||
toast({ title: 'Inget att bearbeta', description: 'Alla kvitton har redan förslag.' })
|
|
||||||
setBackfillRunning(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setBackfillProgress({
|
|
||||||
target: queuedTotal,
|
|
||||||
startPending,
|
|
||||||
currentPending: startPending,
|
|
||||||
})
|
|
||||||
toast({
|
|
||||||
title: 'Bearbetar befintliga',
|
|
||||||
description: `${queuedTotal} kvitton i kö. Det tar ca ${Math.ceil(queuedTotal * 5 / 60)} min.`,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start polling. First tick fires after 2s so the initial state
|
|
||||||
// matches what the server saw on the snapshot.
|
|
||||||
stableTicksRef.current = 0
|
|
||||||
pollRef.current = setInterval(async () => {
|
|
||||||
const current = await fetchPendingCount()
|
|
||||||
if (current == null) return
|
|
||||||
|
|
||||||
setBackfillProgress((prev) => {
|
|
||||||
if (!prev) return prev
|
|
||||||
const newState = { ...prev, currentPending: current }
|
|
||||||
const done = current - prev.startPending
|
|
||||||
if (done >= prev.target) {
|
|
||||||
// Hit the expected target — wrap up.
|
|
||||||
stopPolling()
|
|
||||||
setBackfillRunning(false)
|
|
||||||
toast({ title: 'Klart', description: `${done} förslag skapade.` })
|
|
||||||
router.refresh()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (current === prev.currentPending) {
|
|
||||||
stableTicksRef.current += 1
|
|
||||||
} else {
|
|
||||||
stableTicksRef.current = 0
|
|
||||||
}
|
|
||||||
// Stability threshold: 6 ticks * 2s = 12s no change → assume loop
|
|
||||||
// ran out of eligible items (some failed, skipped, etc).
|
|
||||||
if (stableTicksRef.current >= 6) {
|
|
||||||
stopPolling()
|
|
||||||
setBackfillRunning(false)
|
|
||||||
const short = prev.target - done
|
|
||||||
toast({
|
|
||||||
title: 'Backfill klar',
|
|
||||||
description: short > 0
|
|
||||||
? `${done} av ${prev.target} lyckades. ${short} kunde inte bearbetas (kontrollera kvittobilderna).`
|
|
||||||
: `${done} förslag skapade.`,
|
|
||||||
})
|
|
||||||
router.refresh()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// Refresh every poll so new cards appear as they're drafted.
|
|
||||||
router.refresh()
|
|
||||||
return newState
|
|
||||||
})
|
|
||||||
}, 2000)
|
|
||||||
} catch (err) {
|
|
||||||
toast({ title: 'Fel', description: String(err), variant: 'destructive' })
|
|
||||||
setBackfillRunning(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancelBackfill = async () => {
|
|
||||||
await fetch('/api/ai/backfill/cancel', { method: 'POST' })
|
|
||||||
stopPolling()
|
|
||||||
setBackfillProgress(null)
|
|
||||||
setBackfillRunning(false)
|
|
||||||
toast({ title: 'Backfill stoppades' })
|
|
||||||
router.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Learning prompt ────────────────────────────────────────────────
|
|
||||||
const handleRememberYes = async () => {
|
|
||||||
if (!learningPrompt) return
|
|
||||||
await fetch('/api/ai/learning/remember', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
proposal_id: learningPrompt.proposalId,
|
|
||||||
counterparty_name: learningPrompt.counterparty_name,
|
|
||||||
debit_account: learningPrompt.debit_account,
|
|
||||||
credit_account: learningPrompt.credit_account,
|
|
||||||
vat_treatment: learningPrompt.vat_treatment,
|
|
||||||
category: null,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
toast({ title: 'Sparad som mall' })
|
|
||||||
setLearningPrompt(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto p-4 sm:p-8 max-w-5xl">
|
|
||||||
<PageHeader
|
|
||||||
title="Agent-inkorg"
|
|
||||||
description="AI föreslår bokföring — du godkänner varje steg."
|
|
||||||
action={
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{!backfillRunning ? (
|
|
||||||
<Button variant="outline" onClick={handleBackfill} disabled={backfillRunning}>
|
|
||||||
<PlayCircle className="mr-2 h-4 w-4" />
|
|
||||||
Bearbeta befintliga
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" onClick={handleCancelBackfill}>
|
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
|
||||||
Stoppa backfill
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{backfillProgress && (
|
|
||||||
<BackfillProgressCard progress={backfillProgress} onCancel={handleCancelBackfill} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length > 0 && (
|
|
||||||
<Tabs value={filter} onValueChange={(v) => setFilter(v as FilterKey)} className="mb-4">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="all">Allt ({counts.all})</TabsTrigger>
|
|
||||||
<TabsTrigger value="match">Matchning ({counts.match})</TabsTrigger>
|
|
||||||
<TabsTrigger value="booking">Bokföring ({counts.booking})</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<div className="p-5 rounded-full bg-muted mb-6">
|
|
||||||
<Sparkles className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium mb-2">Inga väntande förslag</h3>
|
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
|
||||||
När nya kvitton klassas i inkorgen kommer AI-förslagen att visas här.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{selectableProposalIds.length > 1 && (
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<Button variant="ghost" size="sm" onClick={selectAll}>
|
|
||||||
Markera alla ({selectableProposalIds.length})
|
|
||||||
</Button>
|
|
||||||
{selectedIds.size > 0 && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
|
||||||
Avmarkera ({selectedIds.size})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{filteredItems.length === 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
Inga kort i denna vy.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{filteredItems.map((item) => {
|
|
||||||
if (item.proposal) {
|
|
||||||
return (
|
|
||||||
<ProposalCard
|
|
||||||
key={`p-${item.proposal.id}`}
|
|
||||||
item={item}
|
|
||||||
isSelected={selectedIds.has(item.proposal.id)}
|
|
||||||
isBusy={busyProposalId === item.proposal.id}
|
|
||||||
onToggleSelect={() => toggleSelect(item.proposal!.id)}
|
|
||||||
onAccept={() => handleAccept(item.proposal!)}
|
|
||||||
onReject={() => handleReject(item.proposal!)}
|
|
||||||
onEdit={() => setEditProposal(item.proposal!)}
|
|
||||||
onChangeMatch={() => setChangeMatchItem(item)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (item.request) {
|
|
||||||
return (
|
|
||||||
<RequestCard
|
|
||||||
key={`r-${item.request.id}`}
|
|
||||||
item={item}
|
|
||||||
onDismiss={() => removeItem(null, item.request!.id)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedIds.size > 0 && (
|
|
||||||
<div className="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 bg-background border shadow-lg rounded-full px-4 py-3 flex items-center gap-3 z-40">
|
|
||||||
<span className="text-sm font-medium">{selectedIds.size} valda</span>
|
|
||||||
<Button size="sm" onClick={handleBatchAccept} disabled={batchRunning}>
|
|
||||||
{batchRunning ? 'Godkänner…' : `Godkänn ${selectedIds.size} st`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editProposal && (
|
|
||||||
<EditBookingDialog
|
|
||||||
proposal={editProposal}
|
|
||||||
onClose={() => setEditProposal(null)}
|
|
||||||
onSubmit={async (edits) => {
|
|
||||||
const proposal = editProposal
|
|
||||||
setEditProposal(null)
|
|
||||||
await handleAccept(proposal, edits)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{changeMatchItem?.proposal && (
|
|
||||||
<ChangeTransactionDialog
|
|
||||||
open={true}
|
|
||||||
onOpenChange={(open) => { if (!open) setChangeMatchItem(null) }}
|
|
||||||
proposal={changeMatchItem.proposal}
|
|
||||||
receiptTotal={
|
|
||||||
(changeMatchItem.inbox_item.extracted_data as { totals?: { total?: number | null } } | null)
|
|
||||||
?.totals?.total ?? null
|
|
||||||
}
|
|
||||||
receiptDate={
|
|
||||||
(changeMatchItem.inbox_item.extracted_data as { receipt?: { date?: string | null } } | null)
|
|
||||||
?.receipt?.date ?? null
|
|
||||||
}
|
|
||||||
onChanged={() => {
|
|
||||||
setChangeMatchItem(null)
|
|
||||||
router.refresh()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{learningPrompt && (
|
|
||||||
<LearningPromptDialog
|
|
||||||
counterpartyName={learningPrompt.counterparty_name}
|
|
||||||
debitAccount={learningPrompt.debit_account}
|
|
||||||
creditAccount={learningPrompt.credit_account}
|
|
||||||
onYes={handleRememberYes}
|
|
||||||
onNo={() => setLearningPrompt(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress indicator shown while "Bearbeta befintliga" runs. Target is the
|
|
||||||
// number of proposals queued at start; `done` is the delta against the
|
|
||||||
// initial pending count. Caps visible done at target to avoid flicker above
|
|
||||||
// 100% when other proposals happen to land during the run.
|
|
||||||
function BackfillProgressCard({
|
|
||||||
progress,
|
|
||||||
onCancel,
|
|
||||||
}: {
|
|
||||||
progress: { target: number; startPending: number; currentPending: number }
|
|
||||||
onCancel: () => void
|
|
||||||
}) {
|
|
||||||
const done = Math.max(0, progress.currentPending - progress.startPending)
|
|
||||||
const capped = Math.min(done, progress.target)
|
|
||||||
const pct = progress.target > 0 ? Math.round((capped / progress.target) * 100) : 0
|
|
||||||
return (
|
|
||||||
<Card className="mb-4 border-primary/30 bg-primary/[0.02]">
|
|
||||||
<CardContent className="p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
||||||
<span className="text-sm font-medium">Bearbetar befintliga kvitton</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm tabular-nums text-muted-foreground">
|
|
||||||
{capped} av {progress.target}
|
|
||||||
</span>
|
|
||||||
<Button size="sm" variant="ghost" onClick={onCancel}>
|
|
||||||
<XCircle className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Stoppa
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Progress value={pct} />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
AI-agenten skapar förslag ett kvitto i taget. Nya kort dyker upp här automatiskt.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Loader2, Search, Sparkles, Check } from 'lucide-react'
|
|
||||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
|
||||||
import type { AIProposal, MatchProposalPayload } from '@/types'
|
|
||||||
|
|
||||||
type ChangeSource = 'user_alternative' | 'user_manual' | 'ai_regenerated'
|
|
||||||
|
|
||||||
interface ChangeTransactionDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
proposal: AIProposal
|
|
||||||
receiptTotal: number | null
|
|
||||||
receiptDate: string | null
|
|
||||||
onChanged: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PickerTx {
|
|
||||||
id: string
|
|
||||||
date: string
|
|
||||||
description: string | null
|
|
||||||
amount: number
|
|
||||||
currency: string | null
|
|
||||||
merchant_name: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChangeTransactionDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
proposal,
|
|
||||||
receiptTotal,
|
|
||||||
receiptDate,
|
|
||||||
onChanged,
|
|
||||||
}: ChangeTransactionDialogProps) {
|
|
||||||
const payload = proposal.proposal_json as MatchProposalPayload
|
|
||||||
const alternatives = payload.alternatives ?? []
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState<{ id: string; source: ChangeSource } | null>(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [showAll, setShowAll] = useState(alternatives.length === 0)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [allTx, setAllTx] = useState<PickerTx[]>([])
|
|
||||||
const [alternativeTx, setAlternativeTx] = useState<Record<string, PickerTx>>({})
|
|
||||||
const [loadingList, setLoadingList] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Reset when dialog opens/closes or proposal changes.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setSelected(null)
|
|
||||||
setSearch('')
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
}, [open, proposal.id])
|
|
||||||
|
|
||||||
// Fetch human-readable context for the AI's alternatives so the user
|
|
||||||
// can compare description/amount/date, not bare UUIDs.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || alternatives.length === 0) return
|
|
||||||
const ids = alternatives.map((a) => a.transaction_id)
|
|
||||||
fetch(`/api/transactions/uncategorized?limit=50&offset=0`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
const list: PickerTx[] = body?.data?.transactions ?? []
|
|
||||||
const map: Record<string, PickerTx> = {}
|
|
||||||
for (const tx of list) if (ids.includes(tx.id)) map[tx.id] = tx
|
|
||||||
setAlternativeTx(map)
|
|
||||||
})
|
|
||||||
.catch(() => { /* alternatives still show with reasoning text */ })
|
|
||||||
}, [open, alternatives])
|
|
||||||
|
|
||||||
// Fetch the full picker list when the user opens "Visa alla".
|
|
||||||
const loadAllTransactions = useCallback(async () => {
|
|
||||||
setLoadingList(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('limit', '30')
|
|
||||||
if (search) params.set('search', search)
|
|
||||||
if (receiptTotal) {
|
|
||||||
params.set('amount_center', String(-Math.abs(receiptTotal)))
|
|
||||||
params.set('amount_window', String(Math.max(5, Math.abs(receiptTotal) * 0.1)))
|
|
||||||
}
|
|
||||||
if (receiptDate) {
|
|
||||||
params.set('date_center', receiptDate)
|
|
||||||
params.set('date_window', '60')
|
|
||||||
}
|
|
||||||
const res = await fetch(`/api/transactions/uncategorized?${params.toString()}`)
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
setError(body?.error ?? 'Kunde inte hämta transaktioner')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAllTx(body?.data?.transactions ?? [])
|
|
||||||
} catch {
|
|
||||||
setError('Nätverksfel')
|
|
||||||
} finally {
|
|
||||||
setLoadingList(false)
|
|
||||||
}
|
|
||||||
}, [search, receiptTotal, receiptDate])
|
|
||||||
|
|
||||||
// Refetch whenever the "show all" pane is open and the search changes.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !showAll) return
|
|
||||||
loadAllTransactions()
|
|
||||||
}, [open, showAll, loadAllTransactions])
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (!selected) return
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/ai/proposals/${proposal.id}/change-match`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
version: proposal.version,
|
|
||||||
matched_transaction_id: selected.id,
|
|
||||||
source: selected.source,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
setError(body?.error ?? 'Kunde inte uppdatera förslaget')
|
|
||||||
setSaving(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onChanged()
|
|
||||||
onOpenChange(false)
|
|
||||||
} catch {
|
|
||||||
setError('Nätverksfel')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMatchId = payload.matched_transaction_id
|
|
||||||
|
|
||||||
const alternativesWithContext = useMemo(
|
|
||||||
() =>
|
|
||||||
alternatives.map((alt) => ({
|
|
||||||
...alt,
|
|
||||||
tx: alternativeTx[alt.transaction_id] ?? null,
|
|
||||||
})),
|
|
||||||
[alternatives, alternativeTx]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Byt transaktion</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Välj en annan transaktion att koppla kvittot till. Du kan välja bland AI:ns
|
|
||||||
alternativ eller söka i alla okategoriserade transaktioner.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-6 py-2">
|
|
||||||
{/* AI alternatives */}
|
|
||||||
{alternatives.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2 flex items-center gap-2">
|
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
AI:ns alternativ ({alternatives.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{alternativesWithContext.map((alt) => (
|
|
||||||
<AlternativeRow
|
|
||||||
key={alt.transaction_id}
|
|
||||||
tx={alt.tx}
|
|
||||||
transactionId={alt.transaction_id}
|
|
||||||
confidence={alt.confidence}
|
|
||||||
reasoning={alt.reasoning}
|
|
||||||
isCurrent={alt.transaction_id === currentMatchId}
|
|
||||||
isSelected={selected?.id === alt.transaction_id}
|
|
||||||
onSelect={() =>
|
|
||||||
setSelected({ id: alt.transaction_id, source: 'user_alternative' })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toggle manual picker */}
|
|
||||||
{alternatives.length > 0 && !showAll && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAll(true)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Search className="h-3.5 w-3.5 mr-2" />
|
|
||||||
Visa alla transaktioner
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Manual picker */}
|
|
||||||
{showAll && (
|
|
||||||
<section>
|
|
||||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
|
||||||
Alla okategoriserade transaktioner
|
|
||||||
</h3>
|
|
||||||
<div className="relative mb-2">
|
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Sök beskrivning eller handlare…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="pl-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{receiptTotal && (
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
|
||||||
Filtrerat på belopp runt {formatCurrency(-Math.abs(receiptTotal), 'SEK')} och datum runt{' '}
|
|
||||||
{receiptDate ? formatDate(receiptDate) : '—'}. Rensa sökrutan för att se fler.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{loadingList ? (
|
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : allTx.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-6">
|
|
||||||
Inga matchande transaktioner
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{allTx.map((tx) => (
|
|
||||||
<PickerRow
|
|
||||||
key={tx.id}
|
|
||||||
tx={tx}
|
|
||||||
isCurrent={tx.id === currentMatchId}
|
|
||||||
isSelected={selected?.id === tx.id}
|
|
||||||
onSelect={() => setSelected({ id: tx.id, source: 'user_manual' })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
|
||||||
Avbryt
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleConfirm} disabled={!selected || saving}>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Sparar…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Använd denna transaktion'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlternativeRow({
|
|
||||||
tx,
|
|
||||||
transactionId,
|
|
||||||
confidence,
|
|
||||||
reasoning,
|
|
||||||
isCurrent,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
tx: PickerTx | null
|
|
||||||
transactionId: string
|
|
||||||
confidence: number
|
|
||||||
reasoning: string
|
|
||||||
isCurrent: boolean
|
|
||||||
isSelected: boolean
|
|
||||||
onSelect: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
disabled={isCurrent}
|
|
||||||
className={cn(
|
|
||||||
'w-full text-left rounded border p-3 transition-colors',
|
|
||||||
isSelected && 'border-primary bg-primary/5',
|
|
||||||
!isSelected && 'hover:bg-muted/40',
|
|
||||||
isCurrent && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3 mb-1">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{tx ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="text-sm truncate">{tx.description ?? 'Okänd'}</span>
|
|
||||||
<span className="text-sm tabular-nums font-medium">
|
|
||||||
{formatCurrency(tx.amount, tx.currency ?? 'SEK')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{formatDate(tx.date)}</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-muted-foreground font-mono">
|
|
||||||
{transactionId.slice(0, 8)}…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{isCurrent && <Badge variant="outline" className="text-xs">Nuvarande</Badge>}
|
|
||||||
<Badge className="text-xs">{Math.round(confidence * 100)}%</Badge>
|
|
||||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground italic">“{reasoning}”</p>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PickerRow({
|
|
||||||
tx,
|
|
||||||
isCurrent,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
tx: PickerTx
|
|
||||||
isCurrent: boolean
|
|
||||||
isSelected: boolean
|
|
||||||
onSelect: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
disabled={isCurrent}
|
|
||||||
className={cn(
|
|
||||||
'w-full text-left rounded border p-2.5 transition-colors',
|
|
||||||
isSelected && 'border-primary bg-primary/5',
|
|
||||||
!isSelected && 'hover:bg-muted/40',
|
|
||||||
isCurrent && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="text-sm truncate">{tx.description ?? 'Okänd'}</span>
|
|
||||||
<span className="text-sm tabular-nums font-medium">
|
|
||||||
{formatCurrency(tx.amount, tx.currency ?? 'SEK')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-0.5">
|
|
||||||
<span>{formatDate(tx.date)}</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{isCurrent && <Badge variant="outline" className="text-xs">Nuvarande</Badge>}
|
|
||||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import type { AIProposal, BookingProposalLine, BookingProposalPayload } from '@/types'
|
|
||||||
|
|
||||||
interface EditBookingDialogProps {
|
|
||||||
proposal: AIProposal
|
|
||||||
onClose: () => void
|
|
||||||
onSubmit: (edits: BookingProposalPayload) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal in-place editor for a booking proposal.
|
|
||||||
*
|
|
||||||
* v1 lets the user change account numbers + amounts per line; the full
|
|
||||||
* AccountCombobox + VatTreatmentSelect UX comes in a polish pass. The
|
|
||||||
* point of this dialog is proving the edit-accept-learning-prompt path
|
|
||||||
* works end-to-end before we invest in the richer form.
|
|
||||||
*/
|
|
||||||
export default function EditBookingDialog({ proposal, onClose, onSubmit }: EditBookingDialogProps) {
|
|
||||||
const original = proposal.proposal_json as BookingProposalPayload
|
|
||||||
const [lines, setLines] = useState<BookingProposalLine[]>(original.lines)
|
|
||||||
const [description, setDescription] = useState(original.description)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
const totalDebit = lines.reduce((s, l) => s + (Number(l.debit_amount) || 0), 0)
|
|
||||||
const totalCredit = lines.reduce((s, l) => s + (Number(l.credit_amount) || 0), 0)
|
|
||||||
const balanced = Math.abs(totalDebit - totalCredit) < 0.005 && totalDebit > 0
|
|
||||||
|
|
||||||
const updateLine = (index: number, patch: Partial<BookingProposalLine>) => {
|
|
||||||
setLines((prev) => prev.map((l, i) => (i === index ? { ...l, ...patch } : l)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!balanced) return
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
await onSubmit({
|
|
||||||
...original,
|
|
||||||
lines,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Redigera bokföringsförslag</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description">Beskrivning</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Rader</Label>
|
|
||||||
<div className="mt-1 border rounded">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="text-xs text-muted-foreground bg-muted/40">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left p-2 font-normal">Konto</th>
|
|
||||||
<th className="text-left p-2 font-normal">Beskrivning</th>
|
|
||||||
<th className="text-right p-2 font-normal">Debet</th>
|
|
||||||
<th className="text-right p-2 font-normal">Kredit</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{lines.map((line, i) => (
|
|
||||||
<tr key={i} className="border-t">
|
|
||||||
<td className="p-2">
|
|
||||||
<Input
|
|
||||||
value={line.account_number}
|
|
||||||
onChange={(e) => updateLine(i, { account_number: e.target.value })}
|
|
||||||
className="h-8 font-mono w-20"
|
|
||||||
maxLength={4}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
|
||||||
<Input
|
|
||||||
value={line.description}
|
|
||||||
onChange={(e) => updateLine(i, { description: e.target.value })}
|
|
||||||
className="h-8"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={line.debit_amount || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateLine(i, { debit_amount: parseFloat(e.target.value) || 0 })
|
|
||||||
}
|
|
||||||
className="h-8 text-right tabular-nums w-24 ml-auto"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={line.credit_amount || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateLine(i, { credit_amount: parseFloat(e.target.value) || 0 })
|
|
||||||
}
|
|
||||||
className="h-8 text-right tabular-nums w-24 ml-auto"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="border-t text-xs">
|
|
||||||
<td colSpan={2} className="p-2 text-muted-foreground">
|
|
||||||
Summa
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-right tabular-nums">{totalDebit.toFixed(2)}</td>
|
|
||||||
<td className="p-2 text-right tabular-nums">{totalCredit.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{!balanced && (
|
|
||||||
<p className="text-xs text-destructive mt-1">
|
|
||||||
Debet och kredit måste summera till samma belopp.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>
|
|
||||||
Avbryt
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSubmit} disabled={!balanced || submitting}>
|
|
||||||
{submitting ? 'Bokför…' : 'Godkänn med ändringar'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
|
|
||||||
interface LearningPromptDialogProps {
|
|
||||||
counterpartyName: string
|
|
||||||
debitAccount: string
|
|
||||||
creditAccount: string
|
|
||||||
onYes: () => void
|
|
||||||
onNo: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LearningPromptDialog({
|
|
||||||
counterpartyName,
|
|
||||||
debitAccount,
|
|
||||||
creditAccount,
|
|
||||||
onYes,
|
|
||||||
onNo,
|
|
||||||
}: LearningPromptDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(open) => !open && onNo()}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Kom ihåg denna bokföring?</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<p className="text-sm">
|
|
||||||
Vill du att AI:n använder samma kontering nästa gång ett kvitto från{' '}
|
|
||||||
<strong>{counterpartyName}</strong> dyker upp?
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">
|
|
||||||
Debet {debitAccount} · Kredit {creditAccount}
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="ghost" onClick={onNo}>
|
|
||||||
Bara den här gången
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onYes}>Ja, kom ihåg</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Receipt as ReceiptIcon, Landmark } from 'lucide-react'
|
|
||||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
|
||||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
|
||||||
import type { BookingProposalPayload, MatchProposalPayload } from '@/types'
|
|
||||||
import ReceiptDetailDialog from './ReceiptDetailDialog'
|
|
||||||
import TransactionDetailDialog from './TransactionDetailDialog'
|
|
||||||
import { assessReceiptQuality } from './receipt-quality'
|
|
||||||
|
|
||||||
interface ProposalCardProps {
|
|
||||||
item: AgentInboxItemView
|
|
||||||
isSelected: boolean
|
|
||||||
isBusy: boolean
|
|
||||||
onToggleSelect: () => void
|
|
||||||
onAccept: () => void
|
|
||||||
onReject: () => void
|
|
||||||
onEdit: () => void
|
|
||||||
onChangeMatch?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function confidenceLabel(c: number | null): string {
|
|
||||||
if (c === null) return 'Ingen säkerhet'
|
|
||||||
const pct = Math.round(c * 100)
|
|
||||||
return `${pct}% säkerhet`
|
|
||||||
}
|
|
||||||
|
|
||||||
function confidenceColor(c: number | null): string {
|
|
||||||
if (c === null) return 'bg-muted'
|
|
||||||
if (c >= 0.9) return 'bg-success/15 text-success-foreground'
|
|
||||||
if (c >= 0.6) return 'bg-warning/15 text-warning-foreground'
|
|
||||||
return 'bg-destructive/15 text-destructive-foreground'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProposalCard({
|
|
||||||
item,
|
|
||||||
isSelected,
|
|
||||||
isBusy,
|
|
||||||
onToggleSelect,
|
|
||||||
onAccept,
|
|
||||||
onReject,
|
|
||||||
onEdit,
|
|
||||||
onChangeMatch,
|
|
||||||
}: ProposalCardProps) {
|
|
||||||
const proposal = item.proposal!
|
|
||||||
const inbox = item.inbox_item
|
|
||||||
const tx = item.transaction
|
|
||||||
const isMatch = proposal.step_type === 'match'
|
|
||||||
const isUserEdited = Boolean(proposal.edit_diff)
|
|
||||||
// BFL compliance: can't book without a source document. Block match-accept
|
|
||||||
// when no receipt file is attached — the server-side validator enforces this
|
|
||||||
// too, but disabling the button client-side avoids a round-trip error.
|
|
||||||
const receiptMissing = isMatch && !inbox.document_id
|
|
||||||
|
|
||||||
const matchPayload = isMatch ? (proposal.proposal_json as MatchProposalPayload) : null
|
|
||||||
const bookingPayload = !isMatch ? (proposal.proposal_json as BookingProposalPayload) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="pt-1">
|
|
||||||
<Checkbox checked={isSelected} onCheckedChange={onToggleSelect} aria-label="Markera" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
||||||
<Badge variant="outline">{isMatch ? 'Match' : 'Bokföring'}</Badge>
|
|
||||||
<Badge className={confidenceColor(proposal.confidence)}>
|
|
||||||
{confidenceLabel(proposal.confidence)}
|
|
||||||
</Badge>
|
|
||||||
{isUserEdited && (
|
|
||||||
<Badge variant="outline" className="text-xs border-success/40 text-success-foreground">
|
|
||||||
Ändrad av användare
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{inbox.document && (
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{inbox.document.file_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isMatch && matchPayload && (
|
|
||||||
<MatchProposalBody
|
|
||||||
payload={matchPayload}
|
|
||||||
reasoning={proposal.reasoning}
|
|
||||||
transaction={tx}
|
|
||||||
inbox={inbox}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isMatch && bookingPayload && (
|
|
||||||
<BookingProposalBody payload={bookingPayload} reasoning={proposal.reasoning} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{receiptMissing && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-3 flex items-center gap-1.5">
|
|
||||||
<span className="inline-block w-1 h-1 rounded-full bg-warning" />
|
|
||||||
Kvittobild saknas — ladda upp i kvittodialogen innan du kan godkänna.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4 flex-wrap">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={onAccept}
|
|
||||||
disabled={isBusy || receiptMissing}
|
|
||||||
title={receiptMissing ? 'Kvittobild krävs för att bokföra' : undefined}
|
|
||||||
>
|
|
||||||
{isBusy ? '…' : 'Godkänn'}
|
|
||||||
</Button>
|
|
||||||
{isMatch && onChangeMatch && (
|
|
||||||
<Button size="sm" variant="outline" onClick={onChangeMatch} disabled={isBusy}>
|
|
||||||
Byt transaktion
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isMatch && (
|
|
||||||
<Button size="sm" variant="outline" onClick={onEdit} disabled={isBusy}>
|
|
||||||
Redigera
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button size="sm" variant="ghost" onClick={onReject} disabled={isBusy}>
|
|
||||||
Avvisa
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MatchProposalBody({
|
|
||||||
payload,
|
|
||||||
reasoning,
|
|
||||||
transaction,
|
|
||||||
inbox,
|
|
||||||
}: {
|
|
||||||
payload: MatchProposalPayload
|
|
||||||
reasoning: string | null
|
|
||||||
transaction: AgentInboxItemView['transaction']
|
|
||||||
inbox: AgentInboxItemView['inbox_item']
|
|
||||||
}) {
|
|
||||||
const proposedTx = transaction && transaction.id === payload.matched_transaction_id ? transaction : null
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
<ReceiptBox inbox={inbox} />
|
|
||||||
{proposedTx ? (
|
|
||||||
<TransactionBox tx={proposedTx} />
|
|
||||||
) : (
|
|
||||||
<div className="rounded border bg-muted/40 p-3 text-sm text-muted-foreground">
|
|
||||||
Föreslagen transaktion: {payload.matched_transaction_id}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ReasoningDisclosure reasoning={reasoning} alternatives={payload.alternatives} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReasoningDisclosure({
|
|
||||||
reasoning,
|
|
||||||
alternatives,
|
|
||||||
}: {
|
|
||||||
reasoning: string | null
|
|
||||||
alternatives?: MatchProposalPayload['alternatives']
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const hasReasoning = Boolean(reasoning)
|
|
||||||
const hasAlternatives = alternatives && alternatives.length > 0
|
|
||||||
if (!hasReasoning && !hasAlternatives) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
|
|
||||||
>
|
|
||||||
{open ? 'Dölj AI:ns resonemang' : 'Visa AI:ns resonemang'}
|
|
||||||
{hasAlternatives && ` (${alternatives!.length} alternativ)`}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{reasoning && (
|
|
||||||
<p className="text-xs text-muted-foreground italic">“{reasoning}”</p>
|
|
||||||
)}
|
|
||||||
{hasAlternatives && (
|
|
||||||
<ul className="space-y-1 text-xs">
|
|
||||||
{alternatives!.map((alt) => (
|
|
||||||
<li key={alt.transaction_id} className="text-muted-foreground">
|
|
||||||
<span className="tabular-nums">{Math.round(alt.confidence * 100)}%</span> — {alt.reasoning}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReceiptBox({
|
|
||||||
inbox,
|
|
||||||
}: {
|
|
||||||
inbox: AgentInboxItemView['inbox_item']
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const data = (inbox.extracted_data as {
|
|
||||||
merchant?: { name?: string | null }
|
|
||||||
receipt?: { date?: string | null; currency?: string | null }
|
|
||||||
totals?: { total?: number | null }
|
|
||||||
} | null) ?? {}
|
|
||||||
const merchant = data.merchant?.name ?? inbox.document?.file_name ?? 'Okänt kvitto'
|
|
||||||
const total = data.totals?.total ?? null
|
|
||||||
const currency = data.receipt?.currency ?? 'SEK'
|
|
||||||
const date = data.receipt?.date ?? null
|
|
||||||
const quality = assessReceiptQuality(inbox)
|
|
||||||
const hasFile = Boolean(inbox.document_id)
|
|
||||||
const needsAttention = !hasFile || !quality.ok
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className={cn(
|
|
||||||
'w-full text-left rounded border bg-muted/40 p-3 text-sm transition-colors hover:bg-muted/60 hover:border-primary/40',
|
|
||||||
needsAttention && 'border-warning/50 bg-warning/5 hover:bg-warning/10'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="truncate">{merchant}</span>
|
|
||||||
{total != null && (
|
|
||||||
<span className="tabular-nums font-medium">
|
|
||||||
{formatCurrency(total, currency)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
|
||||||
<ReceiptIcon className="h-3 w-3" />
|
|
||||||
<span>Kvitto · {date ? formatDate(date) : 'Okänt datum'}</span>
|
|
||||||
</div>
|
|
||||||
{!hasFile && (
|
|
||||||
<p className="mt-2 text-xs text-warning-foreground">
|
|
||||||
Ingen kvittobild — klicka för att ladda upp
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{hasFile && !quality.ok && (
|
|
||||||
<p className="mt-2 text-xs text-warning-foreground">
|
|
||||||
{quality.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<ReceiptDetailDialog open={open} onOpenChange={setOpen} inbox={inbox} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TransactionBox({
|
|
||||||
tx,
|
|
||||||
}: {
|
|
||||||
tx: NonNullable<AgentInboxItemView['transaction']>
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className="w-full text-left rounded border bg-muted/40 p-3 text-sm transition-colors hover:bg-muted/60 hover:border-primary/40"
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="truncate">{tx.description || 'Okänd'}</span>
|
|
||||||
<span className="tabular-nums font-medium">
|
|
||||||
{formatCurrency(tx.amount, tx.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
|
||||||
<Landmark className="h-3 w-3" />
|
|
||||||
<span>Banktransaktion · {formatDate(tx.date)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<TransactionDetailDialog open={open} onOpenChange={setOpen} tx={tx} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BookingProposalBody({
|
|
||||||
payload,
|
|
||||||
reasoning,
|
|
||||||
}: {
|
|
||||||
payload: BookingProposalPayload
|
|
||||||
reasoning: string | null
|
|
||||||
}) {
|
|
||||||
const totalDebit = payload.lines.reduce((s, l) => s + l.debit_amount, 0)
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="rounded border bg-muted/40 p-3 text-sm">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-muted-foreground">
|
|
||||||
<th className="text-left font-normal pb-1">Konto</th>
|
|
||||||
<th className="text-right font-normal pb-1">Debet</th>
|
|
||||||
<th className="text-right font-normal pb-1">Kredit</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{payload.lines.map((line, i) => (
|
|
||||||
<tr key={i} className="border-t border-border/40">
|
|
||||||
<td className="py-1">
|
|
||||||
<span className="font-mono">{line.account_number}</span>{' '}
|
|
||||||
<span className="text-muted-foreground">{line.description}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-right tabular-nums">
|
|
||||||
{line.debit_amount > 0 ? line.debit_amount.toFixed(2) : ''}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-right tabular-nums">
|
|
||||||
{line.credit_amount > 0 ? line.credit_amount.toFixed(2) : ''}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot className="text-xs text-muted-foreground">
|
|
||||||
<tr className="border-t">
|
|
||||||
<td className="pt-1">
|
|
||||||
{payload.vat_treatment && <span>Moms: {payload.vat_treatment}</span>}
|
|
||||||
{payload.default_private && <span className="ml-2">Privat uttag</span>}
|
|
||||||
</td>
|
|
||||||
<td className="pt-1 text-right tabular-nums">{totalDebit.toFixed(2)}</td>
|
|
||||||
<td className="pt-1 text-right tabular-nums">{totalDebit.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<ReasoningDisclosure reasoning={reasoning} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
|
||||||
import { Loader2, ExternalLink, FileText, Upload, ImagePlus, MailPlus } from 'lucide-react'
|
|
||||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
|
||||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
|
||||||
import { assessReceiptQuality } from './receipt-quality'
|
|
||||||
|
|
||||||
// Mirrors ReceiptExtractionResult, kept local + forgiving since legacy rows
|
|
||||||
// may be sparse.
|
|
||||||
interface ExtractedReceipt {
|
|
||||||
merchant?: {
|
|
||||||
name?: string | null
|
|
||||||
orgNumber?: string | null
|
|
||||||
vatNumber?: string | null
|
|
||||||
isForeign?: boolean
|
|
||||||
} | null
|
|
||||||
receipt?: {
|
|
||||||
date?: string | null
|
|
||||||
time?: string | null
|
|
||||||
currency?: string | null
|
|
||||||
} | null
|
|
||||||
totals?: {
|
|
||||||
subtotal?: number | null
|
|
||||||
vatAmount?: number | null
|
|
||||||
total?: number | null
|
|
||||||
} | null
|
|
||||||
lineItems?: Array<{
|
|
||||||
description?: string
|
|
||||||
quantity?: number
|
|
||||||
unitPrice?: number | null
|
|
||||||
lineTotal?: number
|
|
||||||
vatRate?: number | null
|
|
||||||
}> | null
|
|
||||||
flags?: {
|
|
||||||
isRestaurant?: boolean
|
|
||||||
isSystembolaget?: boolean
|
|
||||||
isForeignMerchant?: boolean
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
inbox: AgentInboxItemView['inbox_item']
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReceiptDetailDialog({ open, onOpenChange, inbox }: Props) {
|
|
||||||
const data = (inbox.extracted_data as ExtractedReceipt | null) ?? {}
|
|
||||||
const merchant = data.merchant?.name ?? inbox.document?.file_name ?? 'Okänt kvitto'
|
|
||||||
const currency = data.receipt?.currency ?? 'SEK'
|
|
||||||
const lineItems = data.lineItems ?? []
|
|
||||||
const flags = data.flags ?? {}
|
|
||||||
|
|
||||||
// Lazily fetch a signed download URL so we can preview the file.
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null)
|
|
||||||
const [loadingUrl, setLoadingUrl] = useState(false)
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
||||||
const [requesting, setRequesting] = useState(false)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
||||||
const router = useRouter()
|
|
||||||
const { toast } = useToast()
|
|
||||||
const quality = assessReceiptQuality(inbox)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !inbox.document?.id || downloadUrl) return
|
|
||||||
setLoadingUrl(true)
|
|
||||||
fetch(`/api/documents/${inbox.document.id}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
if (body?.data?.download_url) setDownloadUrl(body.data.download_url)
|
|
||||||
})
|
|
||||||
.catch(() => { /* fall back to no preview */ })
|
|
||||||
.finally(() => setLoadingUrl(false))
|
|
||||||
}, [open, inbox.document?.id, downloadUrl])
|
|
||||||
|
|
||||||
const mime = inbox.document?.mime_type ?? null
|
|
||||||
const isImage = mime?.startsWith('image/') ?? false
|
|
||||||
const isPdf = mime === 'application/pdf'
|
|
||||||
|
|
||||||
const handleFilePicked = async (file: File) => {
|
|
||||||
setUploading(true)
|
|
||||||
setUploadError(null)
|
|
||||||
try {
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('file', file)
|
|
||||||
const res = await fetch(`/api/ai/inbox-items/${inbox.id}/attach-file`, {
|
|
||||||
method: 'POST',
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
const json = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
setUploadError(json?.error ?? 'Kunde inte ladda upp filen.')
|
|
||||||
setUploading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast({ title: 'Kvittobild uppladdad' })
|
|
||||||
// Force the server component to re-run so the new inbox.document
|
|
||||||
// propagates into the card + modal.
|
|
||||||
router.refresh()
|
|
||||||
onOpenChange(false)
|
|
||||||
} catch {
|
|
||||||
setUploadError('Nätverksfel.')
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRequestReceipt = async () => {
|
|
||||||
setRequesting(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/ai/inbox-items/${inbox.id}/request-receipt`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({
|
|
||||||
title: 'Kunde inte skicka begäran',
|
|
||||||
description: body?.error ?? 'Försök igen.',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
setRequesting(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast({
|
|
||||||
title: 'Begäran skickad',
|
|
||||||
description: `Mejl skickat till ${body.data.sent} av ${body.data.total} medlemmar.`,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Nätverksfel',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setRequesting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{merchant}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{data.receipt?.date ? formatDate(data.receipt.date) : 'Okänt datum'}
|
|
||||||
{data.receipt?.time && ` · ${data.receipt.time}`}
|
|
||||||
{data.totals?.total != null && (
|
|
||||||
<span> · {formatCurrency(data.totals.total, currency)}</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{/* File preview */}
|
|
||||||
<section className="min-h-[280px] bg-muted/40 rounded border flex items-center justify-center overflow-hidden">
|
|
||||||
{!inbox.document ? (
|
|
||||||
<div className="flex flex-col items-center gap-3 p-6 text-center">
|
|
||||||
<div className="p-3 rounded-full bg-muted">
|
|
||||||
<ImagePlus className="h-6 w-6 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">Ingen kvittobild</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
|
||||||
Utan bildbevis kan bokföringen inte verifieras. Ladda upp kvittot (PDF, JPG, PNG, WebP — max 15 MB).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const f = e.target.files?.[0]
|
|
||||||
if (f) handleFilePicked(f)
|
|
||||||
e.target.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2 w-full max-w-[240px]">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploading || requesting}
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
|
||||||
Laddar upp…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="h-3.5 w-3.5 mr-2" />
|
|
||||||
Ladda upp kvittobild
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRequestReceipt}
|
|
||||||
disabled={uploading || requesting}
|
|
||||||
>
|
|
||||||
{requesting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
|
||||||
Skickar…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MailPlus className="h-3.5 w-3.5 mr-2" />
|
|
||||||
Begär kvitto från teamet
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{uploadError && (
|
|
||||||
<p className="text-xs text-destructive">{uploadError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : loadingUrl ? (
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
) : !downloadUrl ? (
|
|
||||||
<div className="text-sm text-muted-foreground text-center p-6">
|
|
||||||
Kunde inte ladda filen
|
|
||||||
</div>
|
|
||||||
) : isImage ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={downloadUrl}
|
|
||||||
alt={inbox.document.file_name}
|
|
||||||
className="max-w-full max-h-[60vh] object-contain"
|
|
||||||
/>
|
|
||||||
) : isPdf ? (
|
|
||||||
<iframe
|
|
||||||
src={downloadUrl}
|
|
||||||
title={inbox.document.file_name}
|
|
||||||
className="w-full h-[60vh]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center gap-2 p-6">
|
|
||||||
<FileText className="h-10 w-10 text-muted-foreground" />
|
|
||||||
<span className="text-sm">{inbox.document.file_name}</span>
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<a href={downloadUrl} target="_blank" rel="noreferrer">
|
|
||||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
|
||||||
Öppna fil
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Extracted data */}
|
|
||||||
<section className="space-y-4 text-sm">
|
|
||||||
{/* Quality warning — shown when a file exists but the data is weak */}
|
|
||||||
{inbox.document && !quality.ok && (
|
|
||||||
<div className="rounded border border-warning/50 bg-warning/5 p-3">
|
|
||||||
<p className="text-sm font-medium mb-1">Kvittot verkar otydligt</p>
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
|
||||||
{quality.message} Be teamet skicka en tydligare bild för att kunna bokföra säkert.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRequestReceipt}
|
|
||||||
disabled={requesting}
|
|
||||||
>
|
|
||||||
{requesting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
|
||||||
Skickar…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MailPlus className="h-3.5 w-3.5 mr-2" />
|
|
||||||
Begär nytt kvitto
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Merchant */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1.5">
|
|
||||||
Handlare
|
|
||||||
</h3>
|
|
||||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-sm">
|
|
||||||
<dt className="text-muted-foreground">Namn</dt>
|
|
||||||
<dd>{data.merchant?.name ?? '—'}</dd>
|
|
||||||
{data.merchant?.orgNumber && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Org.nr</dt>
|
|
||||||
<dd className="font-mono">{data.merchant.orgNumber}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{data.merchant?.vatNumber && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">VAT-nr</dt>
|
|
||||||
<dd className="font-mono">{data.merchant.vatNumber}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(flags.isRestaurant || flags.isSystembolaget || flags.isForeignMerchant) && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Flagga</dt>
|
|
||||||
<dd className="flex flex-wrap gap-1">
|
|
||||||
{flags.isRestaurant && <Badge variant="outline" className="text-xs">Restaurang</Badge>}
|
|
||||||
{flags.isSystembolaget && <Badge variant="outline" className="text-xs">Systembolaget</Badge>}
|
|
||||||
{flags.isForeignMerchant && <Badge variant="outline" className="text-xs">Utländsk handlare</Badge>}
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
{(data.totals?.subtotal != null || data.totals?.total != null) && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1.5">
|
|
||||||
Belopp
|
|
||||||
</h3>
|
|
||||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-sm">
|
|
||||||
{data.totals?.subtotal != null && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Netto</dt>
|
|
||||||
<dd className="tabular-nums">{formatCurrency(data.totals.subtotal, currency)}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{data.totals?.vatAmount != null && data.totals.vatAmount > 0 && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Moms</dt>
|
|
||||||
<dd className="tabular-nums">{formatCurrency(data.totals.vatAmount, currency)}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{data.totals?.total != null && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Totalt</dt>
|
|
||||||
<dd className="tabular-nums font-medium">{formatCurrency(data.totals.total, currency)}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Line items */}
|
|
||||||
{lineItems.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1.5">
|
|
||||||
Rader ({lineItems.length})
|
|
||||||
</h3>
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-muted-foreground">
|
|
||||||
<th className="text-left font-normal pb-1">Beskrivning</th>
|
|
||||||
<th className="text-right font-normal pb-1">Antal</th>
|
|
||||||
<th className="text-right font-normal pb-1">Moms</th>
|
|
||||||
<th className="text-right font-normal pb-1">Summa</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{lineItems.map((li, i) => (
|
|
||||||
<tr key={i} className="border-t border-border/40">
|
|
||||||
<td className="py-1">{li.description ?? 'Rad'}</td>
|
|
||||||
<td className="py-1 text-right tabular-nums">
|
|
||||||
{li.quantity ?? '—'}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-right tabular-nums text-muted-foreground">
|
|
||||||
{li.vatRate != null ? `${li.vatRate}%` : '—'}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-right tabular-nums">
|
|
||||||
{li.lineTotal != null ? formatCurrency(li.lineTotal, currency) : '—'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Meta */}
|
|
||||||
<div className="pt-2 border-t text-xs text-muted-foreground space-y-0.5">
|
|
||||||
<div>Källa: {inbox.source}{inbox.email_from ? ` (${inbox.email_from})` : ''}</div>
|
|
||||||
{inbox.confidence != null && (
|
|
||||||
<div>Extraktionskonfidens: {Math.round(Number(inbox.confidence) * 100)}%</div>
|
|
||||||
)}
|
|
||||||
{inbox.document?.file_name && (
|
|
||||||
<div>Fil: {inbox.document.file_name}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
|
||||||
import { AlertCircle } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
|
||||||
import type { AIRequestType } from '@/types'
|
|
||||||
|
|
||||||
interface RequestCardProps {
|
|
||||||
item: AgentInboxItemView
|
|
||||||
onDismiss: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const REQUEST_LABEL: Record<AIRequestType, string> = {
|
|
||||||
reupload_document: 'Oläslig bild',
|
|
||||||
pick_transaction: 'Saknar matchning',
|
|
||||||
specify_vat: 'Momssats',
|
|
||||||
clarify_business_private: 'Privat eller business?',
|
|
||||||
needs_manual: 'Hantera manuellt',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RequestCard({ item, onDismiss }: RequestCardProps) {
|
|
||||||
const req = item.request!
|
|
||||||
const { toast } = useToast()
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
|
|
||||||
const handleResolve = async () => {
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/ai/requests/${req.id}/resolve`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json()
|
|
||||||
toast({ title: 'Fel', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast({ title: 'Markerad som hanterad' })
|
|
||||||
onDismiss()
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guidance varies by request type.
|
|
||||||
let action: React.ReactNode = null
|
|
||||||
if (req.request_type === 'reupload_document') {
|
|
||||||
action = (
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link href="/e/general/invoice-inbox">Ladda upp ny bild</Link>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
} else if (req.request_type === 'pick_transaction' || req.request_type === 'needs_manual') {
|
|
||||||
action = (
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link href="/transactions">Gå till transaktioner</Link>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="border-warning/50">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertCircle className="h-5 w-5 text-warning mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Badge variant="outline">{REQUEST_LABEL[req.request_type]}</Badge>
|
|
||||||
{item.inbox_item.document && (
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{item.inbox_item.document.file_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">{req.message}</p>
|
|
||||||
<div className="flex gap-2 mt-3 flex-wrap">
|
|
||||||
{action}
|
|
||||||
<Button size="sm" variant="ghost" onClick={handleResolve} disabled={busy}>
|
|
||||||
Markera som hanterad
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { ExternalLink } from 'lucide-react'
|
|
||||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
|
||||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
tx: NonNullable<AgentInboxItemView['transaction']>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TransactionDetailDialog({ open, onOpenChange, tx }: Props) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-baseline justify-between gap-3">
|
|
||||||
<span className="truncate">{tx.description || 'Okänd transaktion'}</span>
|
|
||||||
<span className="tabular-nums text-base font-medium flex-shrink-0">
|
|
||||||
{formatCurrency(tx.amount, tx.currency)}
|
|
||||||
</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{formatDate(tx.date)} · Banktransaktion
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 text-sm">
|
|
||||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5">
|
|
||||||
{tx.merchant_name && tx.merchant_name !== tx.description && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Handlare</dt>
|
|
||||||
<dd>{tx.merchant_name}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<dt className="text-muted-foreground">Kategori</dt>
|
|
||||||
<dd>{tx.category ?? '—'}</dd>
|
|
||||||
<dt className="text-muted-foreground">Affärs/privat</dt>
|
|
||||||
<dd>
|
|
||||||
{tx.is_business === true
|
|
||||||
? 'Affärs'
|
|
||||||
: tx.is_business === false
|
|
||||||
? 'Privat'
|
|
||||||
: 'Okänt'}
|
|
||||||
</dd>
|
|
||||||
{tx.currency && tx.currency !== 'SEK' && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Valuta</dt>
|
|
||||||
<dd>
|
|
||||||
{tx.currency}
|
|
||||||
{tx.amount_sek != null && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{' '}
|
|
||||||
({formatCurrency(tx.amount_sek, 'SEK')})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
{tx.exchange_rate != null && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Växelkurs</dt>
|
|
||||||
<dd className="tabular-nums">{tx.exchange_rate}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tx.exchange_rate_date && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Kursdatum</dt>
|
|
||||||
<dd>{formatDate(tx.exchange_rate_date)}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tx.mcc_code != null && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">MCC-kod</dt>
|
|
||||||
<dd className="font-mono">{tx.mcc_code}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tx.external_id && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Externt ID</dt>
|
|
||||||
<dd className="font-mono text-xs text-muted-foreground break-all">
|
|
||||||
{tx.external_id}
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tx.bank_connection_id && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Bankanslutning</dt>
|
|
||||||
<dd className="font-mono text-xs text-muted-foreground">
|
|
||||||
{tx.bank_connection_id.slice(0, 8)}…
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<dt className="text-muted-foreground">Transaktions-ID</dt>
|
|
||||||
<dd className="font-mono text-xs text-muted-foreground break-all">
|
|
||||||
{tx.id}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="pt-3 border-t">
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href={`/transactions?highlight=${tx.id}`}>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
|
||||||
Öppna i transaktionslistan
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { AgentInboxItemView } from '@/app/(dashboard)/agent-inbox/page'
|
|
||||||
|
|
||||||
export type ReceiptQualityIssue =
|
|
||||||
| 'missing_merchant'
|
|
||||||
| 'missing_total'
|
|
||||||
| 'missing_date'
|
|
||||||
| 'low_confidence'
|
|
||||||
|
|
||||||
export interface ReceiptQualityAssessment {
|
|
||||||
ok: boolean
|
|
||||||
issues: ReceiptQualityIssue[]
|
|
||||||
message: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const ISSUE_LABELS: Record<ReceiptQualityIssue, string> = {
|
|
||||||
missing_merchant: 'handlare saknas',
|
|
||||||
missing_total: 'belopp saknas',
|
|
||||||
missing_date: 'datum saknas',
|
|
||||||
low_confidence: 'låg extraktionssäkerhet',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heuristic quality check on a classified receipt. Until the classification
|
|
||||||
// prompt returns an explicit quality_score, we infer it from which critical
|
|
||||||
// fields came back and the LLM's self-reported confidence (stored on
|
|
||||||
// invoice_inbox_items.confidence after classification). 0.6 is the cutoff
|
|
||||||
// where accepted vs. edited rates diverge noticeably in practice.
|
|
||||||
export function assessReceiptQuality(
|
|
||||||
inbox: AgentInboxItemView['inbox_item']
|
|
||||||
): ReceiptQualityAssessment {
|
|
||||||
const data = inbox.extracted_data as {
|
|
||||||
merchant?: { name?: string | null } | null
|
|
||||||
receipt?: { date?: string | null } | null
|
|
||||||
totals?: { total?: number | null } | null
|
|
||||||
} | null
|
|
||||||
|
|
||||||
const issues: ReceiptQualityIssue[] = []
|
|
||||||
|
|
||||||
if (!data?.merchant?.name) issues.push('missing_merchant')
|
|
||||||
if (data?.totals?.total == null) issues.push('missing_total')
|
|
||||||
if (!data?.receipt?.date) issues.push('missing_date')
|
|
||||||
|
|
||||||
const confidence = inbox.confidence == null ? null : Number(inbox.confidence)
|
|
||||||
if (confidence != null && confidence < 0.6) issues.push('low_confidence')
|
|
||||||
|
|
||||||
if (issues.length === 0) {
|
|
||||||
return { ok: true, issues, message: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = issues.map((i) => ISSUE_LABELS[i])
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
issues,
|
|
||||||
message: `Kvittot verkar otydligt — ${labels.join(', ')}.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Camera,
|
|
||||||
Users,
|
Users,
|
||||||
Landmark,
|
Landmark,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -163,28 +162,6 @@ export default function DashboardContent({ firstName, companyId, settings, summa
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development' && summary.receiptQueue && (summary.receiptQueue.pending_review_count > 0 || summary.receiptQueue.unmatched_receipts_count > 0)) {
|
|
||||||
alertItems.push(
|
|
||||||
<Link key="receipts" href="/receipts" className="group">
|
|
||||||
<Card className="h-full border-primary/30 hover:bg-primary/[0.03] transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">Kvitton</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{summary.receiptQueue.pending_review_count > 0
|
|
||||||
? `${summary.receiptQueue.pending_review_count} att granska`
|
|
||||||
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.missingUnderlagCount > 0) {
|
if (summary.missingUnderlagCount > 0) {
|
||||||
alertItems.push(
|
alertItems.push(
|
||||||
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
|
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
|
||||||
|
|||||||
@@ -27,11 +27,9 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
HandCoins,
|
HandCoins,
|
||||||
Sparkles,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getBranding } from '@/lib/branding/service'
|
import { getBranding } from '@/lib/branding/service'
|
||||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||||
import { isAgentInboxEnabled } from '@/lib/ai/feature-flag'
|
|
||||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||||
import { SupportLink } from '@/components/ui/support-link'
|
import { SupportLink } from '@/components/ui/support-link'
|
||||||
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
|
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
|
||||||
@@ -79,8 +77,6 @@ const navItems: NavItem[] = [
|
|||||||
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
|
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
|
||||||
// General accounting
|
// General accounting
|
||||||
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
|
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
|
||||||
{ href: '/receipts', label: 'Kvitton', icon: Receipt, group: 'redovisning', hidden: !ENABLED_EXTENSION_IDS.has('invoice-inbox') || process.env.NODE_ENV !== 'development', devBadge: true },
|
|
||||||
{ href: '/agent-inbox', label: 'Agent-inkorg', icon: Sparkles, group: 'redovisning', hidden: !ENABLED_EXTENSION_IDS.has('ai-agent') || !isAgentInboxEnabled(), devBadge: true },
|
|
||||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
|
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
|
||||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
|
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
|
||||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
|
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { getWorkspaceComponent } from '@/lib/extensions/workspace-registry'
|
|||||||
import ExtensionWorkspaceShell from './ExtensionWorkspaceShell'
|
import ExtensionWorkspaceShell from './ExtensionWorkspaceShell'
|
||||||
import EmptyExtensionState from './shared/EmptyExtensionState'
|
import EmptyExtensionState from './shared/EmptyExtensionState'
|
||||||
|
|
||||||
|
// Full-screen workspaces render their own chrome (top bar, title) and opt
|
||||||
|
// out of the shared ExtensionWorkspaceShell header.
|
||||||
|
const FULLSCREEN_WORKSPACES = new Set(['general/invoice-inbox'])
|
||||||
|
|
||||||
export default function ExtensionWorkspaceLoader({
|
export default function ExtensionWorkspaceLoader({
|
||||||
sector,
|
sector,
|
||||||
slug,
|
slug,
|
||||||
@@ -16,8 +20,12 @@ export default function ExtensionWorkspaceLoader({
|
|||||||
definition: ExtensionDefinition
|
definition: ExtensionDefinition
|
||||||
userId: string
|
userId: string
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const WorkspaceComponent = getWorkspaceComponent(sector, slug)
|
const WorkspaceComponent = getWorkspaceComponent(sector, slug)
|
||||||
|
const isFullScreen = FULLSCREEN_WORKSPACES.has(`${sector}/${slug}`)
|
||||||
|
|
||||||
|
if (isFullScreen && WorkspaceComponent) {
|
||||||
|
return <WorkspaceComponent userId={userId} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExtensionWorkspaceShell definition={definition}>
|
<ExtensionWorkspaceShell definition={definition}>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Wand } from 'lucide-react'
|
|||||||
export default function AiCategorizationWorkspace({ userId }: WorkspaceComponentProps) {
|
export default function AiCategorizationWorkspace({ userId }: WorkspaceComponentProps) {
|
||||||
return (
|
return (
|
||||||
<EmptyExtensionState
|
<EmptyExtensionState
|
||||||
title="AI-kategorisering"
|
title="Transaktionskategorisering"
|
||||||
description="AI-kategorisering körs automatiskt när nya transaktioner synkas. Gå till Transaktioner för att se förslag."
|
description="Transaktioner kategoriseras automatiskt baserat på dina regler när nya synkas. Gå till Transaktioner för att se matchningar."
|
||||||
icon={<Wand className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
icon={<Wand className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -263,11 +263,10 @@ export default function Step4VatAccounting({
|
|||||||
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
|
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
|
||||||
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
|
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
|
||||||
</p>
|
</p>
|
||||||
{entityType === 'aktiebolag' && (
|
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
|
Kontantmetoden får användas om årlig nettoomsättning normalt är högst
|
||||||
Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
|
3 MSEK (BFL 5 kap. 2 §).
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
|
||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
import type { ReceiptRowWithPreview } from '@/app/(dashboard)/receipts/page'
|
|
||||||
|
|
||||||
// Fallback when AI can't read the image. The source document stays attached;
|
|
||||||
// we just let the user type the fields AI would have extracted so the row
|
|
||||||
// can move to 'ready' and be matched to a bank transaction.
|
|
||||||
export default function ManualExtractDialog({
|
|
||||||
row,
|
|
||||||
onClose,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
row: ReceiptRowWithPreview
|
|
||||||
onClose: () => void
|
|
||||||
onSaved: () => void
|
|
||||||
}) {
|
|
||||||
const data = row.extracted_data as {
|
|
||||||
merchant?: { name?: string | null }
|
|
||||||
receipt?: { date?: string | null; currency?: string | null }
|
|
||||||
totals?: { total?: number | null; vatAmount?: number | null }
|
|
||||||
} | null
|
|
||||||
const [merchant, setMerchant] = useState(data?.merchant?.name ?? '')
|
|
||||||
const [date, setDate] = useState(data?.receipt?.date ?? new Date().toISOString().slice(0, 10))
|
|
||||||
const [total, setTotal] = useState<string>(data?.totals?.total != null ? String(data.totals.total) : '')
|
|
||||||
const [vatAmount, setVatAmount] = useState<string>(data?.totals?.vatAmount != null ? String(data.totals.vatAmount) : '')
|
|
||||||
const [currency, setCurrency] = useState(data?.receipt?.currency ?? 'SEK')
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const totalNum = Number(total)
|
|
||||||
if (!merchant.trim() || !date || !Number.isFinite(totalNum) || totalNum <= 0) {
|
|
||||||
toast({
|
|
||||||
title: 'Kontrollera fälten',
|
|
||||||
description: 'Butiksnamn, datum och giltigt totalbelopp krävs.',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const vatNum = vatAmount.trim() === '' ? null : Number(vatAmount)
|
|
||||||
if (vatNum !== null && !Number.isFinite(vatNum)) {
|
|
||||||
toast({ title: 'Ogiltigt momsbelopp', variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/extensions/ext/invoice-inbox/manual-extract', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
inbox_item_id: row.id,
|
|
||||||
merchant: merchant.trim(),
|
|
||||||
date,
|
|
||||||
total: totalNum,
|
|
||||||
currency,
|
|
||||||
vat_amount: vatNum,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Kunde inte spara', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast({ title: 'Kvitto sparat' })
|
|
||||||
onSaved()
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(open) => { if (!open) onClose() }}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Skriv in kvittouppgifter</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Använd det här när AI inte kan läsa bilden. Bilden behålls som underlag —
|
|
||||||
du anger bara siffrorna så kan kvittot matchas mot en banktransaktion.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="merchant">Butik / leverantör</Label>
|
|
||||||
<Input
|
|
||||||
id="merchant"
|
|
||||||
value={merchant}
|
|
||||||
onChange={(e) => setMerchant(e.target.value)}
|
|
||||||
placeholder="t.ex. ICA Maxi"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="date">Datum</Label>
|
|
||||||
<Input
|
|
||||||
id="date"
|
|
||||||
type="date"
|
|
||||||
value={date}
|
|
||||||
onChange={(e) => setDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="currency">Valuta</Label>
|
|
||||||
<Input
|
|
||||||
id="currency"
|
|
||||||
value={currency}
|
|
||||||
onChange={(e) => setCurrency(e.target.value.toUpperCase().slice(0, 3))}
|
|
||||||
maxLength={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="total">Totalbelopp</Label>
|
|
||||||
<Input
|
|
||||||
id="total"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={total}
|
|
||||||
onChange={(e) => setTotal(e.target.value)}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="vat">Varav moms (valfritt)</Label>
|
|
||||||
<Input
|
|
||||||
id="vat"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={vatAmount}
|
|
||||||
onChange={(e) => setVatAmount(e.target.value)}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
|
||||||
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>
|
|
||||||
Avbryt
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={saving}>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Sparar…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Spara'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,540 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useMemo } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
|
||||||
import { PageHeader } from '@/components/ui/page-header'
|
|
||||||
import { Upload, Receipt as ReceiptIcon, Loader2, FileText, AlertTriangle, RefreshCw, Pencil, ShieldCheck, ShieldAlert } from 'lucide-react'
|
|
||||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
|
||||||
import ManualExtractDialog from './ManualExtractDialog'
|
|
||||||
import type { ReceiptRowWithPreview } from '@/app/(dashboard)/receipts/page'
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
pending: 'Väntar',
|
|
||||||
processing: 'Bearbetar',
|
|
||||||
ready: 'Klar',
|
|
||||||
confirmed: 'Bokförd',
|
|
||||||
rejected: 'Avvisad',
|
|
||||||
error: 'Fel',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
|
|
||||||
pending: 'outline',
|
|
||||||
processing: 'warning',
|
|
||||||
ready: 'secondary',
|
|
||||||
confirmed: 'success',
|
|
||||||
rejected: 'outline',
|
|
||||||
error: 'destructive',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALLOWED_MIME = 'application/pdf,image/jpeg,image/png,image/heic,image/heif,image/webp'
|
|
||||||
|
|
||||||
interface ExtractedReceiptShape {
|
|
||||||
merchant?: { name?: string | null } | null
|
|
||||||
receipt?: { date?: string | null; currency?: string | null } | null
|
|
||||||
totals?: { total?: number | null } | null
|
|
||||||
_verification?: {
|
|
||||||
agrees?: boolean
|
|
||||||
claude_total?: number | null
|
|
||||||
ocr_total?: number | null
|
|
||||||
delta?: number | null
|
|
||||||
ocr_confidence?: number | null
|
|
||||||
} | null
|
|
||||||
_source?: 'ocr_only' | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the verification state the UI should render.
|
|
||||||
// - 'agreed' Claude and Textract read the same total → green badge
|
|
||||||
// - 'disagreed' they disagree > 1 öre → yellow warning + numbers
|
|
||||||
// - 'ocr-only' Claude failed but Textract succeeded → neutral
|
|
||||||
// - 'unverified' Textract didn't run (HEIC, large file, no AWS perms)
|
|
||||||
// or agreement data is absent → show nothing
|
|
||||||
type VerificationState = 'agreed' | 'disagreed' | 'ocr-only' | 'unverified'
|
|
||||||
|
|
||||||
function getVerificationState(row: ReceiptRowWithPreview): VerificationState {
|
|
||||||
const data = row.extracted_data as ExtractedReceiptShape | null
|
|
||||||
if (!data) return 'unverified'
|
|
||||||
if (data._source === 'ocr_only') return 'ocr-only'
|
|
||||||
const v = data._verification
|
|
||||||
if (!v || v.agrees == null) return 'unverified'
|
|
||||||
return v.agrees ? 'agreed' : 'disagreed'
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(row: ReceiptRowWithPreview): { merchant: string; total: number | null; currency: string; date: string | null } {
|
|
||||||
const data = (row.extracted_data as ExtractedReceiptShape | null) ?? {}
|
|
||||||
return {
|
|
||||||
merchant: data.merchant?.name ?? row.document?.file_name ?? 'Okänt kvitto',
|
|
||||||
total: data.totals?.total ?? null,
|
|
||||||
currency: data.receipt?.currency ?? 'SEK',
|
|
||||||
date: data.receipt?.date ?? null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirror of the server-side needsRescan heuristic — a row looks stuck when
|
|
||||||
// extraction failed or the total we need to propose a match is missing.
|
|
||||||
// Server is still authoritative; this just gates UI affordances.
|
|
||||||
function rowNeedsRescan(row: ReceiptRowWithPreview): boolean {
|
|
||||||
if (!row.document_id) return false // nothing to rescan without a file
|
|
||||||
if (row.status === 'confirmed') return false
|
|
||||||
if (row.status === 'error') return true
|
|
||||||
const data = row.extracted_data as ExtractedReceiptShape | null
|
|
||||||
if (!data) return true
|
|
||||||
if (data.totals?.total == null) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thumbnail resolves to: image preview | PDF placeholder | missing-source warning.
|
|
||||||
// The last case is legally important (BFL 5 kap 7§) — a receipt without a
|
|
||||||
// source document cannot be booked, so we surface it visibly.
|
|
||||||
function Thumbnail({ row }: { row: ReceiptRowWithPreview }) {
|
|
||||||
const mime = row.document?.mime_type ?? ''
|
|
||||||
const isImage = mime.startsWith('image/') && !mime.includes('heic') && !mime.includes('heif')
|
|
||||||
const isPdf = mime === 'application/pdf'
|
|
||||||
|
|
||||||
if (!row.document) {
|
|
||||||
return (
|
|
||||||
<div className="w-20 h-20 rounded border border-warning/40 bg-warning/5 flex flex-col items-center justify-center shrink-0 text-warning-foreground">
|
|
||||||
<AlertTriangle className="h-5 w-5" />
|
|
||||||
<span className="text-[10px] mt-1 text-center leading-tight">Saknar bild</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isImage && row.preview_url) {
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={row.preview_url}
|
|
||||||
alt={row.document.file_name ?? 'Kvitto'}
|
|
||||||
className="w-20 h-20 rounded object-cover border bg-muted shrink-0"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="w-20 h-20 rounded border bg-muted flex items-center justify-center shrink-0">
|
|
||||||
<FileText className="h-6 w-6 text-muted-foreground" />
|
|
||||||
<span className="sr-only">{isPdf ? 'PDF' : 'Fil'}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistic card shown while the upload request is in flight. Replaced by
|
|
||||||
// the persisted row on router.refresh(). Keeps the page from looking empty
|
|
||||||
// during the 5-10 s classify call.
|
|
||||||
function PendingUploadCard({ upload }: { upload: PendingUpload }) {
|
|
||||||
return (
|
|
||||||
<Card className="border-primary/20 bg-primary/[0.02]">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-20 h-20 rounded border bg-muted flex items-center justify-center shrink-0">
|
|
||||||
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
||||||
<span className="font-medium truncate">{upload.file_name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Badge variant="warning" className="gap-1.5">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
AI läser kvittot…
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Det här brukar ta 5–10 sekunder.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerificationBadge({ row }: { row: ReceiptRowWithPreview }) {
|
|
||||||
const state = getVerificationState(row)
|
|
||||||
if (state === 'agreed') {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-success/40 text-success-foreground gap-1">
|
|
||||||
<ShieldCheck className="h-3 w-3" />
|
|
||||||
OCR verifierad
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state === 'disagreed') {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="border-warning/40 text-warning-foreground gap-1">
|
|
||||||
<ShieldAlert className="h-3 w-3" />
|
|
||||||
Behöver granskning
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state === 'ocr-only') {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="text-muted-foreground">
|
|
||||||
Endast OCR
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// When Claude and Textract disagree on the total, show the raw numbers so
|
|
||||||
// the user can see which read to trust before accepting downstream.
|
|
||||||
function DisagreementDetail({ row }: { row: ReceiptRowWithPreview }) {
|
|
||||||
const data = row.extracted_data as ExtractedReceiptShape | null
|
|
||||||
const v = data?._verification
|
|
||||||
if (!v || v.agrees !== false) return null
|
|
||||||
const currency = data?.receipt?.currency ?? 'SEK'
|
|
||||||
return (
|
|
||||||
<p className="text-xs text-warning-foreground mt-2">
|
|
||||||
AI läste {v.claude_total != null ? formatCurrency(v.claude_total, currency) : '—'}, OCR läste{' '}
|
|
||||||
{v.ocr_total != null ? formatCurrency(v.ocr_total, currency) : '—'}. Granska bilden innan du godkänner.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistic placeholder shown in the list while a manual upload is in
|
|
||||||
// flight. The upload handler is synchronous (classify + store + insert
|
|
||||||
// happen before the response returns), so a 5-10 s gap otherwise leaves the
|
|
||||||
// user staring at nothing. We insert a fake row here so the UI shows a real
|
|
||||||
// card immediately and router.refresh() replaces it with the persisted row.
|
|
||||||
interface PendingUpload {
|
|
||||||
key: string
|
|
||||||
file_name: string
|
|
||||||
size_bytes: number
|
|
||||||
mime_type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReceiptsList({ initialItems }: { initialItems: ReceiptRowWithPreview[] }) {
|
|
||||||
const [items, setItems] = useState(initialItems)
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
|
||||||
const [batchScanning, setBatchScanning] = useState(false)
|
|
||||||
const [rescanId, setRescanId] = useState<string | null>(null)
|
|
||||||
const [attachingId, setAttachingId] = useState<string | null>(null)
|
|
||||||
const [manualRow, setManualRow] = useState<ReceiptRowWithPreview | null>(null)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const attachInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const attachTargetRef = useRef<string | null>(null)
|
|
||||||
const { toast } = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
useEffect(() => { setItems(initialItems) }, [initialItems])
|
|
||||||
|
|
||||||
// Count of rows eligible for rescan — drives the "Skanna oskannade (N)" CTA.
|
|
||||||
const rescanCount = useMemo(() => items.filter(rowNeedsRescan).length, [items])
|
|
||||||
|
|
||||||
const handlePickFile = () => fileInputRef.current?.click()
|
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
e.target.value = ''
|
|
||||||
|
|
||||||
const pending: PendingUpload = {
|
|
||||||
key: `pending-${Date.now()}-${file.name}`,
|
|
||||||
file_name: file.name,
|
|
||||||
size_bytes: file.size,
|
|
||||||
mime_type: file.type,
|
|
||||||
}
|
|
||||||
setPendingUploads((p) => [pending, ...p])
|
|
||||||
setUploading(true)
|
|
||||||
try {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', file)
|
|
||||||
const res = await fetch('/api/extensions/ext/invoice-inbox/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Uppladdning misslyckades', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast({ title: 'Kvitto sparat' })
|
|
||||||
router.refresh()
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Fel',
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
// Drop the placeholder on both success and failure. On success the
|
|
||||||
// real row arrives via router.refresh(); on failure the user gets a
|
|
||||||
// toast and an empty list state instead of a stuck "AI läser..." card.
|
|
||||||
setPendingUploads((p) => p.filter((x) => x.key !== pending.key))
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRescanOne = async (row: ReceiptRowWithPreview) => {
|
|
||||||
setRescanId(row.id)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/extensions/ext/invoice-inbox/rescan', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ inbox_item_ids: [row.id] }),
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Skanning misslyckades', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const outcome = body.data.outcomes?.[0]
|
|
||||||
if (outcome?.ok) {
|
|
||||||
toast({ title: 'Skanning klar' })
|
|
||||||
} else {
|
|
||||||
toast({ title: 'Skanning misslyckades', description: outcome?.error, variant: 'destructive' })
|
|
||||||
}
|
|
||||||
router.refresh()
|
|
||||||
} finally {
|
|
||||||
setRescanId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePickAttachFile = (rowId: string) => {
|
|
||||||
attachTargetRef.current = rowId
|
|
||||||
attachInputRef.current?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAttachFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
const rowId = attachTargetRef.current
|
|
||||||
e.target.value = ''
|
|
||||||
attachTargetRef.current = null
|
|
||||||
if (!file || !rowId) return
|
|
||||||
|
|
||||||
setAttachingId(rowId)
|
|
||||||
try {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', file)
|
|
||||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${rowId}/attach-document`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Kunde inte koppla bild', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast({
|
|
||||||
title: 'Bild kopplad',
|
|
||||||
description: body.data.classified ? 'Bearbetar siffrorna…' : 'Kunde inte läsa siffror — skanna igen eller skriv in själv.',
|
|
||||||
})
|
|
||||||
router.refresh()
|
|
||||||
} finally {
|
|
||||||
setAttachingId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBatchRescan = async () => {
|
|
||||||
if (rescanCount === 0) return
|
|
||||||
setBatchScanning(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/extensions/ext/invoice-inbox/rescan', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
})
|
|
||||||
const body = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({ title: 'Batch-skanning misslyckades', description: body.error, variant: 'destructive' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { rescanned, failed } = body.data
|
|
||||||
toast({
|
|
||||||
title: `${rescanned} skannade${failed > 0 ? `, ${failed} misslyckades` : ''}`,
|
|
||||||
})
|
|
||||||
router.refresh()
|
|
||||||
} finally {
|
|
||||||
setBatchScanning(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto p-4 sm:p-8 max-w-5xl">
|
|
||||||
<PageHeader
|
|
||||||
title="Kvitton"
|
|
||||||
description="Ladda upp kvitton. AI klassificerar och matchar mot banktransaktioner."
|
|
||||||
action={
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{rescanCount > 0 && (
|
|
||||||
<Button variant="outline" onClick={handleBatchRescan} disabled={batchScanning}>
|
|
||||||
{batchScanning ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Skannar…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
|
||||||
Skanna oskannade ({rescanCount})
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept={ALLOWED_MIME}
|
|
||||||
onChange={handleUpload}
|
|
||||||
/>
|
|
||||||
<Button onClick={handlePickFile} disabled={uploading}>
|
|
||||||
{uploading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Laddar upp…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Ladda upp kvitto
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{items.length === 0 && pendingUploads.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<div className="p-5 rounded-full bg-muted mb-6">
|
|
||||||
<ReceiptIcon className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium mb-2">Inga kvitton än</h3>
|
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
|
||||||
Ladda upp ett kvitto (PDF, JPG, PNG, HEIC eller WebP) så tar AI hand om resten.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{pendingUploads.map((upload) => (
|
|
||||||
<PendingUploadCard key={upload.key} upload={upload} />
|
|
||||||
))}
|
|
||||||
{items.map((row) => {
|
|
||||||
const s = summarize(row)
|
|
||||||
const statusKey = row.status ?? 'pending'
|
|
||||||
const canRescan = rowNeedsRescan(row)
|
|
||||||
const isRescanning = rescanId === row.id
|
|
||||||
const needsImage = !row.document_id && row.status !== 'confirmed'
|
|
||||||
const isAttaching = attachingId === row.id
|
|
||||||
return (
|
|
||||||
<Card key={row.id}>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<Thumbnail row={row} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
||||||
<span className="font-medium truncate">{s.merchant}</span>
|
|
||||||
{s.total != null && (
|
|
||||||
<span className="tabular-nums font-medium">
|
|
||||||
{formatCurrency(s.total, s.currency)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
||||||
<Badge variant={STATUS_VARIANTS[statusKey] ?? 'outline'} className="gap-1.5">
|
|
||||||
{(statusKey === 'processing' || statusKey === 'pending') && (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
)}
|
|
||||||
{STATUS_LABELS[statusKey] ?? statusKey}
|
|
||||||
</Badge>
|
|
||||||
<VerificationBadge row={row} />
|
|
||||||
{s.date && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(s.date)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{row.document?.file_name && (
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
· {row.document.file_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{row.source === 'email' && (
|
|
||||||
<span className="text-xs text-muted-foreground">· via e-post</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{row.error_message && (
|
|
||||||
<p className="text-xs text-destructive mt-1">{row.error_message}</p>
|
|
||||||
)}
|
|
||||||
<DisagreementDetail row={row} />
|
|
||||||
|
|
||||||
{needsImage && (
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handlePickAttachFile(row.id)}
|
|
||||||
disabled={isAttaching}
|
|
||||||
>
|
|
||||||
{isAttaching ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
|
||||||
Laddar upp…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="mr-2 h-3 w-3" />
|
|
||||||
Ladda upp bild
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!needsImage && canRescan && (
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => handleRescanOne(row)} disabled={isRescanning}>
|
|
||||||
{isRescanning ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
|
||||||
Skannar…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="mr-2 h-3 w-3" />
|
|
||||||
Skanna igen
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setManualRow(row)}>
|
|
||||||
<Pencil className="mr-2 h-3 w-3" />
|
|
||||||
Skriv in själv
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={attachInputRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept={ALLOWED_MIME}
|
|
||||||
onChange={handleAttachFile}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{manualRow && (
|
|
||||||
<ManualExtractDialog
|
|
||||||
row={manualRow}
|
|
||||||
onClose={() => setManualRow(null)}
|
|
||||||
onSaved={() => {
|
|
||||||
setManualRow(null)
|
|
||||||
router.refresh()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ const SCOPE_GROUPS = [
|
|||||||
domain: 'transactions',
|
domain: 'transactions',
|
||||||
label: 'Transaktioner',
|
label: 'Transaktioner',
|
||||||
read: 'transactions:read' as const,
|
read: 'transactions:read' as const,
|
||||||
readLabel: 'Läs — lista transaktioner, mallförslag, kategoriförslag',
|
readLabel: 'Läs — lista transaktioner, mallar, kategorier',
|
||||||
readTools: 3,
|
readTools: 3,
|
||||||
write: 'transactions:write' as const,
|
write: 'transactions:write' as const,
|
||||||
writeLabel: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura',
|
writeLabel: 'Skriv — kategorisera, kvittomatchning, koppling mot faktura',
|
||||||
|
|||||||
@@ -519,19 +519,8 @@ export default function SwipeCategorizationView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Document upload / pre-attached document */}
|
{/* Document upload */}
|
||||||
{pendingInboxItemId && currentTransaction.matched_inbox_item?.document_id ? (
|
{(
|
||||||
<div className="rounded-lg border bg-success/5 border-success/30 px-3 py-2.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Paperclip className="h-4 w-4 text-success" />
|
|
||||||
<span className="text-sm font-medium">Underlag bifogat</span>
|
|
||||||
<Check className="h-4 w-4 text-success" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Dokumentet från inkorgen länkas automatiskt till verifikationen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border">
|
<div className="rounded-lg border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -711,55 +700,6 @@ export default function SwipeCategorizationView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Document Match from Inbox */}
|
|
||||||
{currentTransaction.matched_inbox_item && (
|
|
||||||
<div className="p-4 rounded-lg border-2 border-primary/40 bg-primary/5 space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 text-primary">
|
|
||||||
<Paperclip className="h-5 w-5" />
|
|
||||||
<span className="font-semibold text-sm">
|
|
||||||
{currentTransaction.matched_inbox_item.document_type === 'receipt'
|
|
||||||
? 'Matchat kvitto'
|
|
||||||
: currentTransaction.matched_inbox_item.document_type === 'supplier_invoice'
|
|
||||||
? 'Matchad leverantörsfaktura'
|
|
||||||
: 'Matchat dokument'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{currentTransaction.matched_inbox_item.match_confidence != null && (
|
|
||||||
<Badge variant="outline" className="text-primary border-primary">
|
|
||||||
{Math.round(currentTransaction.matched_inbox_item.match_confidence * 100)}%
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
{(() => {
|
|
||||||
const ext = currentTransaction.matched_inbox_item.extracted_data as Record<string, unknown> | null
|
|
||||||
if (!ext) return null
|
|
||||||
const supplierName = (ext as { supplier?: { name?: string } })?.supplier?.name
|
|
||||||
const merchantName = (ext as { merchant?: { name?: string } })?.merchant?.name
|
|
||||||
const totals = ext as { totals?: { total?: number } }
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{(supplierName || merchantName) && (
|
|
||||||
<p className="font-medium">{supplierName || merchantName}</p>
|
|
||||||
)}
|
|
||||||
{totals?.totals?.total != null && (
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{formatCurrency(totals.totals.total)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{currentTransaction.matched_inbox_item.suggested_template_id && (
|
|
||||||
<p className="text-xs text-primary mt-1">
|
|
||||||
Mall: {currentTransaction.matched_inbox_item.suggested_template_id}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* Warnings */}
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<div className="space-y-2 pt-4 border-t">
|
<div className="space-y-2 pt-4 border-t">
|
||||||
@@ -799,25 +739,6 @@ export default function SwipeCategorizationView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Document template match — primary action when inbox item has a suggested template */}
|
|
||||||
{currentTransaction.matched_inbox_item?.suggested_template_id && (() => {
|
|
||||||
const inboxItem = currentTransaction.matched_inbox_item
|
|
||||||
if (!inboxItem?.suggested_template_id) return null
|
|
||||||
const tmplId = inboxItem.suggested_template_id
|
|
||||||
const template = getTemplateById(tmplId)
|
|
||||||
if (!template) return null
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => handleTemplateSelect(tmplId, inboxItem.id)}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
<Paperclip className="mr-2 h-4 w-4" />
|
|
||||||
Bokför som {template.name_sv}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Invoice match button - primary action when there's a match */}
|
{/* Invoice match button - primary action when there's a match */}
|
||||||
{currentTransaction.potential_invoice && onMatchInvoice && (
|
{currentTransaction.potential_invoice && onMatchInvoice && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||||
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, Paperclip, Trash2 } from 'lucide-react'
|
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, Trash2 } from 'lucide-react'
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
|
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
|
||||||
import { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
import { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||||
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
|
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
|
||||||
@@ -59,7 +59,6 @@ export default function TransactionInboxCard({
|
|||||||
const topSuggestion = suggestions?.[0]
|
const topSuggestion = suggestions?.[0]
|
||||||
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
||||||
const showCheckbox = isBatchMode && isUncategorized
|
const showCheckbox = isBatchMode && isUncategorized
|
||||||
const hasDocumentMatch = !!transaction.matched_inbox_item
|
|
||||||
const isDeletable = !transaction.journal_entry_id
|
const isDeletable = !transaction.journal_entry_id
|
||||||
|
|
||||||
function handleSuggestionClick(suggestion: SuggestedCategory) {
|
function handleSuggestionClick(suggestion: SuggestedCategory) {
|
||||||
@@ -116,16 +115,7 @@ export default function TransactionInboxCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium truncate">{transaction.description}</p>
|
<p className="font-medium truncate">{transaction.description}</p>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||||
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
|
|
||||||
{hasDocumentMatch && (
|
|
||||||
<Badge variant="secondary" className="text-xs gap-1">
|
|
||||||
<Paperclip className="h-3 w-3" />
|
|
||||||
{transaction.matched_inbox_item!.document_type === 'receipt' ? 'Kvitto' :
|
|
||||||
transaction.matched_inbox_item!.document_type === 'supplier_invoice' ? 'Faktura' : 'Dokument'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment, InvoiceInboxItem } from '@/types'
|
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment } from '@/types'
|
||||||
|
|
||||||
// Shared transaction type with potential invoice data
|
// Shared transaction type with potential invoice data
|
||||||
export interface TransactionWithInvoice extends Transaction {
|
export interface TransactionWithInvoice extends Transaction {
|
||||||
potential_invoice?: Invoice & { customer?: Customer }
|
potential_invoice?: Invoice & { customer?: Customer }
|
||||||
potential_supplier_invoice?: SupplierInvoice
|
potential_supplier_invoice?: SupplierInvoice
|
||||||
matched_inbox_item?: InvoiceInboxItem
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page view modes
|
// Page view modes
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket"]}
|
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox"]}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { Extension } from '@/lib/extensions/types'
|
|
||||||
import { registerAIProposalService } from '@/lib/ai/proposal-service'
|
|
||||||
import { BedrockAIProposalService } from './lib/bedrock-service'
|
|
||||||
|
|
||||||
// Register the Bedrock-backed implementation at extension load time.
|
|
||||||
// The orchestrator (lib/ai/orchestrator.ts) calls getAIProposalService() at
|
|
||||||
// event-handle time and will get this instance whenever the extension is
|
|
||||||
// enabled in extensions.config.json.
|
|
||||||
registerAIProposalService(new BedrockAIProposalService())
|
|
||||||
|
|
||||||
export const aiAgentExtension: Extension = {
|
|
||||||
id: 'ai-agent',
|
|
||||||
name: 'AI-agent (beta)',
|
|
||||||
version: '0.1.0',
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared Bedrock Converse client for the ai-agent extension.
|
|
||||||
* Mirrors inbox-smart-match's setup so the model + env var conventions stay
|
|
||||||
* consistent across all LLM-backed extensions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'
|
|
||||||
|
|
||||||
let _client: BedrockRuntimeClient | null = null
|
|
||||||
|
|
||||||
export function getBedrockClient(): BedrockRuntimeClient {
|
|
||||||
if (!_client) {
|
|
||||||
_client = new BedrockRuntimeClient({
|
|
||||||
region: process.env.AWS_REGION || 'eu-north-1',
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return _client
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModelId(): string {
|
|
||||||
return process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMaxTokens(): number {
|
|
||||||
return parseInt(process.env.BEDROCK_MAX_TOKENS || '2048', 10)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* BedrockAIProposalService — the AIProposalService implementation registered
|
|
||||||
* by the ai-agent extension. Each method dispatches to the relevant generator
|
|
||||||
* and returns whatever the generator produced (proposal / request / null).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AIProposalService,
|
|
||||||
AIRequestResult,
|
|
||||||
BookingProposalResult,
|
|
||||||
GenerateBookingContext,
|
|
||||||
GenerateMatchContext,
|
|
||||||
MatchProposalResult,
|
|
||||||
} from '@/lib/ai/proposal-service'
|
|
||||||
import { generateMatchForExtension } from './generate-match'
|
|
||||||
import { generateBookingForExtension } from './generate-booking'
|
|
||||||
|
|
||||||
export class BedrockAIProposalService implements AIProposalService {
|
|
||||||
isEnabled(): boolean {
|
|
||||||
// The extension only loads when enabled in extensions.config.json, so any
|
|
||||||
// registered instance is enabled by definition. We still gate on AWS
|
|
||||||
// credentials so a misconfigured env surfaces as "null -> needs_manual"
|
|
||||||
// rather than a Bedrock exception per call.
|
|
||||||
return Boolean(
|
|
||||||
process.env.AWS_ACCESS_KEY_ID &&
|
|
||||||
process.env.AWS_SECRET_ACCESS_KEY &&
|
|
||||||
process.env.AWS_REGION
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateMatchProposal(
|
|
||||||
ctx: GenerateMatchContext
|
|
||||||
): Promise<MatchProposalResult | AIRequestResult | null> {
|
|
||||||
if (!this.isEnabled()) return null
|
|
||||||
return generateMatchForExtension(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateBookingProposal(
|
|
||||||
ctx: GenerateBookingContext
|
|
||||||
): Promise<BookingProposalResult | AIRequestResult | null> {
|
|
||||||
if (!this.isEnabled()) return null
|
|
||||||
return generateBookingForExtension(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
/**
|
|
||||||
* Booking proposal generator for the ai-agent extension.
|
|
||||||
*
|
|
||||||
* Takes a matched receipt + transaction and returns a balanced journal-entry
|
|
||||||
* proposal in the BookingProposalPayload shape. Uses existing counterparty
|
|
||||||
* templates as seeds in the prompt so recurring merchants converge fast.
|
|
||||||
*
|
|
||||||
* Returns an AIRequestResult when the LLM chooses to clarify (e.g.,
|
|
||||||
* can't tell business vs private), or null on outage.
|
|
||||||
*
|
|
||||||
* Also verifies the proposed lines balance (sum debits = sum credits); when
|
|
||||||
* the LLM returns unbalanced lines the result is degraded to a clarify ask
|
|
||||||
* rather than being silently wrong.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
ConverseCommand,
|
|
||||||
type ContentBlock,
|
|
||||||
type Message,
|
|
||||||
} from '@aws-sdk/client-bedrock-runtime'
|
|
||||||
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
|
||||||
import { createClient as createServiceClient } from '@supabase/supabase-js'
|
|
||||||
import type {
|
|
||||||
AIRequestResult,
|
|
||||||
BookingProposalResult,
|
|
||||||
GenerateBookingContext,
|
|
||||||
} from '@/lib/ai/proposal-service'
|
|
||||||
import type {
|
|
||||||
BookingProposalLine,
|
|
||||||
BookingProposalCounterpartyTemplate,
|
|
||||||
BookingProposalPayload,
|
|
||||||
VatTreatment,
|
|
||||||
} from '@/types'
|
|
||||||
import { getBedrockClient, getModelId, getMaxTokens } from './bedrock-client'
|
|
||||||
import {
|
|
||||||
BOOKING_PROMPT_VERSION,
|
|
||||||
BOOKING_SYSTEM_PROMPT,
|
|
||||||
BOOKING_TOOL_CONFIG,
|
|
||||||
} from './prompts/booking-prompt'
|
|
||||||
|
|
||||||
export async function generateBookingForExtension(
|
|
||||||
ctx: GenerateBookingContext
|
|
||||||
): Promise<BookingProposalResult | AIRequestResult | null> {
|
|
||||||
const serviceClient = createServiceClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
||||||
)
|
|
||||||
|
|
||||||
// Resolve fiscal period for the transaction date — a prerequisite for any booking.
|
|
||||||
const fiscalPeriodId = await findFiscalPeriod(
|
|
||||||
serviceClient,
|
|
||||||
ctx.companyId,
|
|
||||||
ctx.matchedTransaction.date
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!fiscalPeriodId) {
|
|
||||||
return {
|
|
||||||
kind: 'request',
|
|
||||||
request: {
|
|
||||||
request_type: 'needs_manual',
|
|
||||||
message:
|
|
||||||
'Ingen öppen räkenskapsperiod täcker transaktionens datum. Skapa perioden eller bokför manuellt.',
|
|
||||||
},
|
|
||||||
provenance: { prompt_version: BOOKING_PROMPT_VERSION },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brief the LLM with receipt + transaction + relevant templates.
|
|
||||||
const extracted = ctx.inboxItem.extracted_data as Record<string, unknown> | null
|
|
||||||
const relevantTemplates = ctx.existingTemplates
|
|
||||||
.filter((t) => t.is_active)
|
|
||||||
.slice(0, 20)
|
|
||||||
.map((t) => ({
|
|
||||||
counterparty: t.counterparty_name,
|
|
||||||
debit: t.debit_account,
|
|
||||||
credit: t.credit_account,
|
|
||||||
vat_treatment: t.vat_treatment,
|
|
||||||
category: t.category,
|
|
||||||
source: t.source,
|
|
||||||
occurrences: t.occurrence_count,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const userPrompt = `Kvittodata (extraherad):
|
|
||||||
${JSON.stringify(extracted, null, 2)}
|
|
||||||
|
|
||||||
Matchad banktransaktion:
|
|
||||||
${JSON.stringify(
|
|
||||||
{
|
|
||||||
id: ctx.matchedTransaction.id,
|
|
||||||
date: ctx.matchedTransaction.date,
|
|
||||||
description: ctx.matchedTransaction.description,
|
|
||||||
amount: ctx.matchedTransaction.amount,
|
|
||||||
amount_sek: ctx.matchedTransaction.amount_sek,
|
|
||||||
currency: ctx.matchedTransaction.currency,
|
|
||||||
merchant_name: ctx.matchedTransaction.merchant_name,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}
|
|
||||||
|
|
||||||
Företagstyp: ${ctx.entityType}
|
|
||||||
|
|
||||||
Befintliga motpartsmallar (upp till 20):
|
|
||||||
${JSON.stringify(relevantTemplates, null, 2)}
|
|
||||||
|
|
||||||
Föreslå ett balanserat verifikat. Transaktionens belopp är bruttobeloppet som betalas från 1930.`
|
|
||||||
|
|
||||||
const messages: Message[] = [{ role: 'user', content: [{ text: userPrompt }] }]
|
|
||||||
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await getBedrockClient().send(
|
|
||||||
new ConverseCommand({
|
|
||||||
modelId: getModelId(),
|
|
||||||
messages,
|
|
||||||
system: [{ text: BOOKING_SYSTEM_PROMPT }],
|
|
||||||
toolConfig: BOOKING_TOOL_CONFIG,
|
|
||||||
inferenceConfig: { maxTokens: getMaxTokens(), temperature: 0 },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai-agent/booking] Bedrock call failed:', err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = {
|
|
||||||
input_tokens: response.usage?.inputTokens ?? 0,
|
|
||||||
output_tokens: response.usage?.outputTokens ?? 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolUse = response.output?.message?.content?.find(
|
|
||||||
(b): b is ContentBlock.ToolUseMember => 'toolUse' in b && b.toolUse !== undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!toolUse?.toolUse?.input) return null
|
|
||||||
|
|
||||||
const raw = toolUse.toolUse.input as Record<string, unknown>
|
|
||||||
const action = raw.action === 'clarify_business_private' ? 'clarify_business_private' : 'propose'
|
|
||||||
const confidence = clampConfidence(Number(raw.confidence))
|
|
||||||
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
|
|
||||||
|
|
||||||
if (action === 'clarify_business_private') {
|
|
||||||
return {
|
|
||||||
kind: 'request',
|
|
||||||
request: {
|
|
||||||
request_type: 'clarify_business_private',
|
|
||||||
message:
|
|
||||||
typeof raw.clarify_message === 'string' && raw.clarify_message.trim().length > 0
|
|
||||||
? raw.clarify_message.trim()
|
|
||||||
: 'Är detta en affärsutgift eller privat?',
|
|
||||||
required_fields: { is_business: 'boolean' },
|
|
||||||
},
|
|
||||||
provenance: {
|
|
||||||
model: getModelId(),
|
|
||||||
prompt_version: BOOKING_PROMPT_VERSION,
|
|
||||||
input_tokens: usage.input_tokens,
|
|
||||||
output_tokens: usage.output_tokens,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const proposalRaw = raw.proposal as Record<string, unknown> | null | undefined
|
|
||||||
if (!proposalRaw) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawLines = extractLines(proposalRaw.lines)
|
|
||||||
const vatTreatment = extractVatTreatment(proposalRaw.vat_treatment)
|
|
||||||
const defaultPrivate = Boolean(proposalRaw.default_private)
|
|
||||||
const counterpartyTpl = extractCounterpartyTemplate(proposalRaw.counterparty_template_proposal)
|
|
||||||
|
|
||||||
// Claude often returns lines that are off by a cent or two due to the way
|
|
||||||
// it does 25% VAT math on awkward totals (e.g. 183,30 split as net 146,64
|
|
||||||
// + VAT 36,66 — fine — but sometimes 146,64 + 36,67 from rounding up).
|
|
||||||
// Repair those silently; the journal engine can't post unbalanced entries
|
|
||||||
// anyway, and the human-facing answer (same accounts, same rate) is identical.
|
|
||||||
const { lines, repaired } = repairRounding(rawLines)
|
|
||||||
|
|
||||||
if (!linesBalanced(lines)) {
|
|
||||||
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
|
|
||||||
const totalCredit = lines.reduce((s, l) => s + l.credit_amount, 0)
|
|
||||||
console.warn('[ai-agent/generate-booking] unbalanced proposal', {
|
|
||||||
totalDebit, totalCredit, diff: totalDebit - totalCredit, lines,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
kind: 'request',
|
|
||||||
request: {
|
|
||||||
request_type: 'needs_manual',
|
|
||||||
message:
|
|
||||||
`AI:n producerade ett obalanserat verifikat (debet ${totalDebit.toFixed(2)} vs kredit ${totalCredit.toFixed(2)}). Bokför manuellt eller försök igen via Bearbeta befintliga.`,
|
|
||||||
},
|
|
||||||
provenance: {
|
|
||||||
model: getModelId(),
|
|
||||||
prompt_version: BOOKING_PROMPT_VERSION,
|
|
||||||
input_tokens: usage.input_tokens,
|
|
||||||
output_tokens: usage.output_tokens,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (repaired) {
|
|
||||||
console.log('[ai-agent/generate-booking] auto-repaired rounding on booking lines')
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: BookingProposalPayload = {
|
|
||||||
lines,
|
|
||||||
vat_treatment: vatTreatment,
|
|
||||||
default_private: defaultPrivate,
|
|
||||||
counterparty_template_proposal: counterpartyTpl,
|
|
||||||
fiscal_period_id: fiscalPeriodId,
|
|
||||||
entry_date: ctx.matchedTransaction.date,
|
|
||||||
description: buildDescription(ctx),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 'proposal',
|
|
||||||
proposal: payload,
|
|
||||||
confidence,
|
|
||||||
reasoning,
|
|
||||||
provenance: {
|
|
||||||
model: getModelId(),
|
|
||||||
prompt_version: BOOKING_PROMPT_VERSION,
|
|
||||||
input_tokens: usage.input_tokens,
|
|
||||||
output_tokens: usage.output_tokens,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampConfidence(raw: number): number {
|
|
||||||
if (!isFinite(raw)) return 0
|
|
||||||
return Math.min(1, Math.max(0, raw / 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractLines(raw: unknown): BookingProposalLine[] {
|
|
||||||
if (!Array.isArray(raw)) return []
|
|
||||||
return raw
|
|
||||||
.map((item) => item as Record<string, unknown>)
|
|
||||||
.filter((item) => typeof item.account_number === 'string')
|
|
||||||
.map((item) => ({
|
|
||||||
account_number: String(item.account_number),
|
|
||||||
debit_amount: Number(item.debit_amount) || 0,
|
|
||||||
credit_amount: Number(item.credit_amount) || 0,
|
|
||||||
description: typeof item.description === 'string' ? item.description : '',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractVatTreatment(raw: unknown): VatTreatment | null {
|
|
||||||
const allowed: VatTreatment[] = [
|
|
||||||
'standard_25',
|
|
||||||
'reduced_12',
|
|
||||||
'reduced_6',
|
|
||||||
'reverse_charge',
|
|
||||||
'export',
|
|
||||||
'exempt',
|
|
||||||
]
|
|
||||||
if (typeof raw !== 'string') return null
|
|
||||||
return (allowed as string[]).includes(raw) ? (raw as VatTreatment) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCounterpartyTemplate(
|
|
||||||
raw: unknown
|
|
||||||
): BookingProposalCounterpartyTemplate | null {
|
|
||||||
if (!raw || typeof raw !== 'object') return null
|
|
||||||
const r = raw as Record<string, unknown>
|
|
||||||
if (
|
|
||||||
typeof r.counterparty_name !== 'string' ||
|
|
||||||
typeof r.debit_account !== 'string' ||
|
|
||||||
typeof r.credit_account !== 'string'
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
counterparty_name: r.counterparty_name,
|
|
||||||
debit_account: r.debit_account,
|
|
||||||
credit_account: r.credit_account,
|
|
||||||
vat_treatment: extractVatTreatment(r.vat_treatment),
|
|
||||||
category:
|
|
||||||
typeof r.category === 'string' && r.category.length > 0
|
|
||||||
? (r.category as BookingProposalCounterpartyTemplate['category'])
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function linesBalanced(lines: BookingProposalLine[]): boolean {
|
|
||||||
if (lines.length < 2) return false
|
|
||||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
||||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
||||||
return Math.abs(totalDebit - totalCredit) < 0.005 && totalDebit > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust sub-5-öre discrepancies silently by nudging the largest debit
|
|
||||||
// line. Only repairs imbalances up to 0.05 kr — anything larger is treated
|
|
||||||
// as a real error (Claude got confused, not just a rounding quirk) and
|
|
||||||
// bubbles up via the existing needs_manual fallback.
|
|
||||||
function repairRounding(lines: BookingProposalLine[]): { lines: BookingProposalLine[]; repaired: boolean } {
|
|
||||||
if (lines.length < 2) return { lines, repaired: false }
|
|
||||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
||||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
|
||||||
const diff = totalDebit - totalCredit
|
|
||||||
const absDiff = Math.abs(diff)
|
|
||||||
|
|
||||||
if (absDiff < 0.005) return { lines, repaired: false }
|
|
||||||
if (absDiff > 0.05) return { lines, repaired: false }
|
|
||||||
|
|
||||||
// Pick the single biggest debit line to absorb the adjustment — usually
|
|
||||||
// the expense account, not the VAT line. Subtract if debit is over,
|
|
||||||
// add if debit is under. Round to öre precision.
|
|
||||||
const withIndex = lines.map((l, idx) => ({ l, idx }))
|
|
||||||
const biggestDebit = withIndex
|
|
||||||
.filter((x) => x.l.debit_amount > 0)
|
|
||||||
.sort((a, b) => b.l.debit_amount - a.l.debit_amount)[0]
|
|
||||||
if (!biggestDebit) return { lines, repaired: false }
|
|
||||||
|
|
||||||
const adjusted = [...lines]
|
|
||||||
const current = adjusted[biggestDebit.idx]
|
|
||||||
adjusted[biggestDebit.idx] = {
|
|
||||||
...current,
|
|
||||||
debit_amount: Math.round((current.debit_amount - diff) * 100) / 100,
|
|
||||||
}
|
|
||||||
return { lines: adjusted, repaired: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDescription(ctx: GenerateBookingContext): string {
|
|
||||||
const merchant =
|
|
||||||
ctx.matchedTransaction.merchant_name ||
|
|
||||||
ctx.matchedTransaction.description ||
|
|
||||||
'Okänd handlare'
|
|
||||||
return `AI-förslag: ${merchant}`
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* Match proposal generator for the ai-agent extension.
|
|
||||||
*
|
|
||||||
* Returns a MatchProposalResult when the LLM identifies a good candidate,
|
|
||||||
* an AIRequestResult when input is insufficient (bad extraction) or no
|
|
||||||
* candidates are available (user must upload the missing transaction first),
|
|
||||||
* or null on Bedrock outage so the orchestrator emits a 'needs_manual' ask.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
ConverseCommand,
|
|
||||||
type ContentBlock,
|
|
||||||
type Message,
|
|
||||||
} from '@aws-sdk/client-bedrock-runtime'
|
|
||||||
import { createClient as createServiceClient } from '@supabase/supabase-js'
|
|
||||||
import { fetchCandidateTransactions, getMatchAnchors } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
|
|
||||||
import type { ExtractedDocument } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
|
|
||||||
import type {
|
|
||||||
AIRequestResult,
|
|
||||||
GenerateMatchContext,
|
|
||||||
MatchProposalResult,
|
|
||||||
} from '@/lib/ai/proposal-service'
|
|
||||||
import type { MatchProposalAlternative } from '@/types'
|
|
||||||
import { getBedrockClient, getModelId, getMaxTokens } from './bedrock-client'
|
|
||||||
import {
|
|
||||||
MATCH_PROMPT_VERSION,
|
|
||||||
MATCH_SYSTEM_PROMPT,
|
|
||||||
MATCH_TOOL_CONFIG,
|
|
||||||
} from './prompts/match-prompt'
|
|
||||||
|
|
||||||
export async function generateMatchForExtension(
|
|
||||||
ctx: GenerateMatchContext
|
|
||||||
): Promise<MatchProposalResult | AIRequestResult | null> {
|
|
||||||
const extracted = ctx.inboxItem.extracted_data as unknown as ExtractedDocument | null
|
|
||||||
|
|
||||||
// Guard: extraction quality.
|
|
||||||
const anchors = getMatchAnchors(extracted)
|
|
||||||
if (!anchors) {
|
|
||||||
return {
|
|
||||||
kind: 'request',
|
|
||||||
request: {
|
|
||||||
request_type: 'reupload_document',
|
|
||||||
message:
|
|
||||||
'Jag kunde inte läsa av datum eller belopp från kvittot. Ladda upp en tydligare bild så försöker jag igen.',
|
|
||||||
},
|
|
||||||
provenance: { prompt_version: MATCH_PROMPT_VERSION },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch candidates using the shared deterministic narrowing.
|
|
||||||
const serviceClient = createServiceClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
||||||
)
|
|
||||||
|
|
||||||
let candidates
|
|
||||||
try {
|
|
||||||
candidates = await fetchCandidateTransactions(serviceClient, ctx.companyId, extracted)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai-agent/match] fetchCandidateTransactions failed:', err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
kind: 'request',
|
|
||||||
request: {
|
|
||||||
request_type: 'pick_transaction',
|
|
||||||
message:
|
|
||||||
'Jag hittade ingen matchande banktransaktion. Vänta på nästa banksync eller välj manuellt.',
|
|
||||||
options: { candidates: [] },
|
|
||||||
},
|
|
||||||
provenance: { prompt_version: MATCH_PROMPT_VERSION },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Bedrock.
|
|
||||||
const receiptBrief = {
|
|
||||||
merchant: anchors.counterpartyName,
|
|
||||||
amount: anchors.amount,
|
|
||||||
currency: anchors.currency,
|
|
||||||
date: anchors.date,
|
|
||||||
vat_amount: extracted?.totals?.vatAmount ?? null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateLines = candidates.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
date: c.date,
|
|
||||||
description: c.description,
|
|
||||||
amount: c.amount,
|
|
||||||
amount_sek: c.amount_sek,
|
|
||||||
currency: c.currency,
|
|
||||||
merchant_name: c.merchant_name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const userPrompt = `Kvitto:
|
|
||||||
${JSON.stringify(receiptBrief, null, 2)}
|
|
||||||
|
|
||||||
Kandidat-transaktioner:
|
|
||||||
${JSON.stringify(candidateLines, null, 2)}
|
|
||||||
|
|
||||||
Vilken matchar? Om ingen matchar, returnera matched=false.`
|
|
||||||
|
|
||||||
const messages: Message[] = [{ role: 'user', content: [{ text: userPrompt }] }]
|
|
||||||
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await getBedrockClient().send(
|
|
||||||
new ConverseCommand({
|
|
||||||
modelId: getModelId(),
|
|
||||||
messages,
|
|
||||||
system: [{ text: MATCH_SYSTEM_PROMPT }],
|
|
||||||
toolConfig: MATCH_TOOL_CONFIG,
|
|
||||||
inferenceConfig: { maxTokens: getMaxTokens(), temperature: 0 },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ai-agent/match] Bedrock call failed:', err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = {
|
|
||||||
input_tokens: response.usage?.inputTokens ?? 0,
|
|
||||||
output_tokens: response.usage?.outputTokens ?? 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolUse = response.output?.message?.content?.find(
|
|
||||||
(b): b is ContentBlock.ToolUseMember => 'toolUse' in b && b.toolUse !== undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!toolUse?.toolUse?.input) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = toolUse.toolUse.input as Record<string, unknown>
|
|
||||||
const matched = Boolean(raw.matched)
|
|
||||||
const rawId = typeof raw.transaction_id === 'string' ? raw.transaction_id : null
|
|
||||||
const confidence = clampConfidence(Number(raw.confidence))
|
|
||||||
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
|
|
||||||
|
|
||||||
// Resolve alternatives, filtering to only valid candidate IDs.
|
|
||||||
const candidateIds = new Set(candidates.map((c) => c.id))
|
|
||||||
const rawAlts = Array.isArray(raw.alternatives) ? raw.alternatives : []
|
|
||||||
const alternatives: MatchProposalAlternative[] = rawAlts
|
|
||||||
.map((a) => a as Record<string, unknown>)
|
|
||||||
.filter((a) => typeof a.transaction_id === 'string' && candidateIds.has(a.transaction_id as string))
|
|
||||||
.map((a) => ({
|
|
||||||
transaction_id: a.transaction_id as string,
|
|
||||||
confidence: clampConfidence(Number(a.confidence)),
|
|
||||||
reasoning: typeof a.reasoning === 'string' ? a.reasoning.trim() : '',
|
|
||||||
}))
|
|
||||||
.slice(0, 3)
|
|
||||||
|
|
||||||
if (!matched || !rawId || !candidateIds.has(rawId)) {
|
|
||||||
// LLM declined or returned unresolvable ID — degrade to pick_transaction ask.
|
|
||||||
return {
|
|
||||||
kind: 'request',
|
|
||||||
request: {
|
|
||||||
request_type: 'pick_transaction',
|
|
||||||
message:
|
|
||||||
'AI:n är osäker på matchning. Välj manuellt bland kandidaterna eller vänta på fler banktransaktioner.',
|
|
||||||
options: { candidates: candidateLines },
|
|
||||||
},
|
|
||||||
provenance: {
|
|
||||||
model: getModelId(),
|
|
||||||
prompt_version: MATCH_PROMPT_VERSION,
|
|
||||||
input_tokens: usage.input_tokens,
|
|
||||||
output_tokens: usage.output_tokens,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 'proposal',
|
|
||||||
proposal: {
|
|
||||||
matched_transaction_id: rawId,
|
|
||||||
alternatives,
|
|
||||||
top_confidence: confidence,
|
|
||||||
},
|
|
||||||
confidence,
|
|
||||||
reasoning,
|
|
||||||
provenance: {
|
|
||||||
model: getModelId(),
|
|
||||||
prompt_version: MATCH_PROMPT_VERSION,
|
|
||||||
input_tokens: usage.input_tokens,
|
|
||||||
output_tokens: usage.output_tokens,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampConfidence(raw: number): number {
|
|
||||||
if (!isFinite(raw)) return 0
|
|
||||||
return Math.min(1, Math.max(0, raw / 100))
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* Booking prompt — given an extracted receipt + matched transaction + any
|
|
||||||
* existing counterparty templates, propose a complete journal entry.
|
|
||||||
*
|
|
||||||
* The v1 schema is deliberately narrow: standard expense with input VAT
|
|
||||||
* (optional) paid from 1930. Reverse-charge / EU / import paths are out
|
|
||||||
* of scope for the first receipts-only release; those still funnel to
|
|
||||||
* manual via a clarify_business_private request if the LLM is unsure.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ToolConfiguration } from '@aws-sdk/client-bedrock-runtime'
|
|
||||||
|
|
||||||
export const BOOKING_PROMPT_VERSION = '2026-04-27-v3'
|
|
||||||
|
|
||||||
export const BOOKING_SYSTEM_PROMPT = `Du är en expert på svensk bokföring enligt BAS-kontoplanen. Du föreslår hur ett kvitto ska bokföras mot en matchad banktransaktion.
|
|
||||||
|
|
||||||
Indata:
|
|
||||||
- Extraherad kvittodata (handlare, belopp, moms, datum)
|
|
||||||
- Matchad banktransaktion (beskrivning, belopp, datum)
|
|
||||||
- Företagstyp (enskild firma eller aktiebolag)
|
|
||||||
- Befintliga mallar för samma motpart (om några)
|
|
||||||
|
|
||||||
Uppgift: föreslå ett balanserat verifikat (Debet = Kredit). Verifikatet MÅSTE balansera: summan av alla debet-rader ska vara EXAKT lika med summan av alla kredit-rader.
|
|
||||||
|
|
||||||
Mönstret för en standardutgift med svensk moms:
|
|
||||||
Debet 5xxx/6xxx (kostnadskonto, nettobelopp)
|
|
||||||
Debet 2641 Ingående moms (om standardmoms 25%, 12% eller 6%)
|
|
||||||
Kredit 1930 Företagskonto (bruttobelopp)
|
|
||||||
|
|
||||||
Mönstret för en utgift utan svensk moms (utländsk leverantör, momsfritt kvitto):
|
|
||||||
Debet 5xxx/6xxx (kostnadskonto, hela beloppet)
|
|
||||||
Kredit 1930 Företagskonto (hela beloppet)
|
|
||||||
|
|
||||||
Om privat uttag (enskild firma) — använd 2013 istället för kostnadskontot.
|
|
||||||
|
|
||||||
Riktlinjer:
|
|
||||||
- Välj lämpligt BAS-kostnadskonto utifrån typ av inköp (t.ex. 5410 IT-utrustning, 5611 Drivmedel, 5810 Representation, 6540 IT-tjänster)
|
|
||||||
- Momsavdrag: standard 25% → 2641, 12% → 2641, 6% → 2641. Sätt vat_treatment 'standard_25' / 'reduced_12' / 'reduced_6'.
|
|
||||||
- Om kvittot saknar momsspecifikation men är svensk handelsrelaterad — anta standard_25
|
|
||||||
- Om kvittot är från en utländsk leverantör (ej svensk org/momsnummer) och INTE visar någon moms — använd mönstret utan moms. Sätt vat_treatment='exempt'. Använd INTE kontona 2614, 2615, 2645, 2646, 2647, 2648 — omvänd skattskyldighet är utanför scope i v1.
|
|
||||||
- Om inköpet troligen är privat (t.ex. matvaror för hushåll, nöjen) och företagstyp = enskild firma — sätt default_private=true och använd 2013
|
|
||||||
- Representation: endast 50% moms avdragsgillt — för v1, föreslå utan reducering och flagga i reasoning att användaren bör kontrollera
|
|
||||||
- Om du är osäker på business vs private, eller om fakturan ser ut att kräva omvänd skattskyldighet (t.ex. EU-leverantör med momsnummer men 0% moms) — returnera hellre ett ai_request av typ 'clarify_business_private' än att gissa
|
|
||||||
|
|
||||||
VIKTIGT — momsövergång på livsmedel (Prop. 2025/26:55):
|
|
||||||
- Från och med 1 april 2026 (t.o.m. 31 december 2027) sänks momsen på livsmedel från 12 % till 6 %. Återgår till 12 % den 1 januari 2028.
|
|
||||||
- Avgör momssats utifrån KVITTOTS DATUM (matchad transaktionsdatum):
|
|
||||||
* Livsmedel/dagligvaror (ICA, Coop, Hemköp, Willys, Lidl, City Gross, Tempo, Mathem, Netto, Mat.se m.fl.):
|
|
||||||
- Datum < 2026-04-01: vat_treatment='reduced_12'
|
|
||||||
- Datum 2026-04-01 — 2027-12-31: vat_treatment='reduced_6'
|
|
||||||
- Datum >= 2028-01-01: vat_treatment='reduced_12'
|
|
||||||
* Restaurang/servering (eat-in på restaurang, café, lunchställe, bistro): ALLTID vat_treatment='reduced_12' (omfattas inte av sänkningen).
|
|
||||||
* Take-away/avhämtning räknas som livsmedel — följ datumlogiken ovan.
|
|
||||||
* Alkohol är alltid 25 % oavsett — om kvittot uppenbart är alkohol, vat_treatment='standard_25'.
|
|
||||||
- Om det är otydligt om kvittot är livsmedel eller servering (t.ex. ICA med både matvaror och deli), välj den dominerande posten utifrån beloppet och förklara valet i reasoning.
|
|
||||||
|
|
||||||
KONTROLLERA innan du returnerar: addera alla debit_amount, addera alla credit_amount, verifiera att summorna är EXAKT lika. Om de inte är det — räkna om.
|
|
||||||
|
|
||||||
Resonera på svenska. Var konkret: vilket konto och varför.
|
|
||||||
|
|
||||||
Anropa ALLTID verktyget propose_booking med resultatet.`
|
|
||||||
|
|
||||||
export const BOOKING_TOOL_CONFIG: ToolConfiguration = {
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
toolSpec: {
|
|
||||||
name: 'propose_booking',
|
|
||||||
description: 'Returnera ett balanserat verifikatförslag eller en fråga till användaren',
|
|
||||||
inputSchema: {
|
|
||||||
json: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['action', 'confidence', 'reasoning'],
|
|
||||||
properties: {
|
|
||||||
action: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['propose', 'clarify_business_private'],
|
|
||||||
description:
|
|
||||||
'propose = konkret förslag. clarify_business_private = be användaren avgöra om privat/business.',
|
|
||||||
},
|
|
||||||
confidence: {
|
|
||||||
type: 'integer',
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 100,
|
|
||||||
},
|
|
||||||
reasoning: {
|
|
||||||
type: 'string',
|
|
||||||
description: '1-3 meningar på svenska som förklarar förslaget.',
|
|
||||||
},
|
|
||||||
proposal: {
|
|
||||||
type: ['object', 'null'],
|
|
||||||
description: 'Endast när action=propose.',
|
|
||||||
required: ['lines', 'vat_treatment', 'default_private'],
|
|
||||||
properties: {
|
|
||||||
lines: {
|
|
||||||
type: 'array',
|
|
||||||
minItems: 2,
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['account_number', 'debit_amount', 'credit_amount', 'description'],
|
|
||||||
properties: {
|
|
||||||
account_number: {
|
|
||||||
type: 'string',
|
|
||||||
pattern: '^\\d{4}$',
|
|
||||||
description: '4-siffrigt BAS-kontonummer',
|
|
||||||
},
|
|
||||||
debit_amount: { type: 'number', minimum: 0 },
|
|
||||||
credit_amount: { type: 'number', minimum: 0 },
|
|
||||||
description: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vat_treatment: {
|
|
||||||
type: ['string', 'null'],
|
|
||||||
enum: [
|
|
||||||
'standard_25',
|
|
||||||
'reduced_12',
|
|
||||||
'reduced_6',
|
|
||||||
'reverse_charge',
|
|
||||||
'export',
|
|
||||||
'exempt',
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
default_private: {
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'true för privat uttag (enskild firma 2013)',
|
|
||||||
},
|
|
||||||
counterparty_template_proposal: {
|
|
||||||
type: ['object', 'null'],
|
|
||||||
description:
|
|
||||||
'Föreslå en motpartsmall om handlaren är återkommande och bokföringsmönstret är tydligt.',
|
|
||||||
required: ['counterparty_name', 'debit_account', 'credit_account'],
|
|
||||||
properties: {
|
|
||||||
counterparty_name: { type: 'string' },
|
|
||||||
debit_account: { type: 'string', pattern: '^\\d{4}$' },
|
|
||||||
credit_account: { type: 'string', pattern: '^\\d{4}$' },
|
|
||||||
vat_treatment: {
|
|
||||||
type: ['string', 'null'],
|
|
||||||
enum: [
|
|
||||||
'standard_25',
|
|
||||||
'reduced_12',
|
|
||||||
'reduced_6',
|
|
||||||
'reverse_charge',
|
|
||||||
'export',
|
|
||||||
'exempt',
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
category: { type: ['string', 'null'] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
clarify_message: {
|
|
||||||
type: ['string', 'null'],
|
|
||||||
description:
|
|
||||||
'Endast när action=clarify_business_private. Kort fråga på svenska till användaren.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
toolChoice: { any: {} },
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* Match prompt — given an extracted receipt + candidate transactions,
|
|
||||||
* the LLM picks the best match (or explains that none fit).
|
|
||||||
*
|
|
||||||
* Bump MATCH_PROMPT_VERSION on any prompt change so the pinned version on
|
|
||||||
* stored proposals remains accurate for audit + drift analysis.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ToolConfiguration } from '@aws-sdk/client-bedrock-runtime'
|
|
||||||
|
|
||||||
export const MATCH_PROMPT_VERSION = '2026-04-23-v1'
|
|
||||||
|
|
||||||
export const MATCH_SYSTEM_PROMPT = `Du är en expert på svensk bokföring. Du matchar kvitton mot banktransaktioner för ett företag som använder gnubok.
|
|
||||||
|
|
||||||
Indata:
|
|
||||||
- Extraherad kvittodata (handlare, belopp, valuta, datum, momsbelopp)
|
|
||||||
- Upp till 5 kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
|
|
||||||
|
|
||||||
Uppgift: identifiera vilken (om någon) banktransaktion motsvarar kvittot.
|
|
||||||
|
|
||||||
Riktlinjer:
|
|
||||||
- Belopp bör vara identiskt eller väldigt nära (valutaväxling tillkommer om olika valutor)
|
|
||||||
- Datum: banktransaktionen bokförs ofta 0-3 dagar efter kvittodatumet
|
|
||||||
- Bankbeskrivningar är ofta förkortade versaler — matcha semantiskt, inte bokstavligt
|
|
||||||
- Om inget är trovärdigt, returnera matched=false med en kort motivering
|
|
||||||
- Motivera alltid kort på svenska varför du valde (eller inte valde)
|
|
||||||
|
|
||||||
Anropa ALLTID verktyget match_receipt_for_agent med resultatet.`
|
|
||||||
|
|
||||||
export const MATCH_TOOL_CONFIG: ToolConfiguration = {
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
toolSpec: {
|
|
||||||
name: 'match_receipt_for_agent',
|
|
||||||
description: 'Returnera den bäst matchande kandidaten eller förklara att ingen matchar',
|
|
||||||
inputSchema: {
|
|
||||||
json: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['matched', 'confidence', 'reasoning', 'alternatives'],
|
|
||||||
properties: {
|
|
||||||
matched: {
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'true om en kandidat matchar, annars false',
|
|
||||||
},
|
|
||||||
transaction_id: {
|
|
||||||
type: ['string', 'null'],
|
|
||||||
description: 'id för vald kandidat (null när matched=false)',
|
|
||||||
},
|
|
||||||
confidence: {
|
|
||||||
type: 'integer',
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 100,
|
|
||||||
description: 'Säkerhet 0-100. Sätt lågt när matched=false.',
|
|
||||||
},
|
|
||||||
reasoning: {
|
|
||||||
type: 'string',
|
|
||||||
description: '1-2 meningar på svenska som förklarar valet.',
|
|
||||||
},
|
|
||||||
alternatives: {
|
|
||||||
type: 'array',
|
|
||||||
description:
|
|
||||||
'Upp till 3 övriga kandidater som användaren kan välja istället, rankade efter sannolikhet (endast tillagda om matched=true).',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['transaction_id', 'confidence', 'reasoning'],
|
|
||||||
properties: {
|
|
||||||
transaction_id: { type: 'string' },
|
|
||||||
confidence: { type: 'integer', minimum: 0, maximum: 100 },
|
|
||||||
reasoning: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
toolChoice: { any: {} },
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "ai-agent",
|
|
||||||
"sector": "general",
|
|
||||||
"exportName": "aiAgentExtension",
|
|
||||||
"entryPoint": "@/extensions/general/ai-agent",
|
|
||||||
"requiredEnvVars": [
|
|
||||||
"AWS_ACCESS_KEY_ID",
|
|
||||||
"AWS_SECRET_ACCESS_KEY",
|
|
||||||
"AWS_REGION"
|
|
||||||
],
|
|
||||||
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS"],
|
|
||||||
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
|
|
||||||
"definition": {
|
|
||||||
"name": "AI-agent (beta)",
|
|
||||||
"category": "operations",
|
|
||||||
"icon": "Sparkles",
|
|
||||||
"dataPattern": "core",
|
|
||||||
"hasOwnData": false,
|
|
||||||
"readsCoreTables": ["invoice_inbox_items", "transactions", "ai_proposals", "ai_requests", "processing_history"],
|
|
||||||
"description": "Autonom bokföring — AI föreslår match + bokföring, du godkänner.",
|
|
||||||
"longDescription": "När ett kvitto kommer in föreslår AI-agenten först vilken banktransaktion som matchar, sedan hur det ska bokföras. Du granskar och godkänner varje steg — inget bokförs automatiskt. Om AI:n inte kan producera ett förslag (oläslig bild, ingen matchande transaktion, osäker moms) frågar den dig specifikt vad som behövs."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,14 +30,27 @@ export default function BankingSettingsPanel() {
|
|||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [connectingBankName, setConnectingBankName] = useState<string | null>(null)
|
const [connectingBankName, setConnectingBankName] = useState<string | null>(null)
|
||||||
const connectingRef = useRef(false)
|
const connectingRef = useRef(false)
|
||||||
|
const releaseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [showCsvFallback, setShowCsvFallback] = useState(false)
|
const [showCsvFallback, setShowCsvFallback] = useState(false)
|
||||||
const [psuType, setPsuType] = useState<'personal' | 'business'>('business')
|
const [psuType, setPsuType] = useState<'personal' | 'business'>('business')
|
||||||
|
|
||||||
|
// Must match STALE_THRESHOLD_MS in extensions/general/enable-banking/index.ts
|
||||||
|
const PENDING_LOCK_MS = 30 * 1000
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConnections()
|
fetchConnections()
|
||||||
|
return () => {
|
||||||
|
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
function releaseConnectingLock() {
|
||||||
|
connectingRef.current = false
|
||||||
|
setIsConnecting(false)
|
||||||
|
setConnectingBankName(null)
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchConnections() {
|
async function fetchConnections() {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
@@ -51,6 +64,22 @@ export default function BankingSettingsPanel() {
|
|||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
setBankConnections(connections || [])
|
setBankConnections(connections || [])
|
||||||
|
|
||||||
|
// If a pending connection exists from a recent attempt (e.g. user bounced back from
|
||||||
|
// the bank's auth page), keep the connect button disabled until the server-side lock expires.
|
||||||
|
const freshPending = (connections || []).find((c) => c.status === 'pending')
|
||||||
|
if (freshPending) {
|
||||||
|
const age = Date.now() - new Date(freshPending.created_at).getTime()
|
||||||
|
const remaining = PENDING_LOCK_MS - age
|
||||||
|
if (remaining > 0) {
|
||||||
|
connectingRef.current = true
|
||||||
|
setIsConnecting(true)
|
||||||
|
setConnectingBankName(freshPending.bank_name)
|
||||||
|
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current)
|
||||||
|
releaseTimerRef.current = setTimeout(releaseConnectingLock, remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export const enableBankingExtension: Extension = {
|
|||||||
|
|
||||||
if (recentPending) {
|
if (recentPending) {
|
||||||
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
|
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
|
||||||
const STALE_THRESHOLD_MS = 5 * 60 * 1000 // 5 minutes
|
const STALE_THRESHOLD_MS = 30 * 1000 // 30 seconds — long enough to cover the redirect handoff, short enough that an abandoned attempt doesn't block the user
|
||||||
|
|
||||||
if (pendingAge < STALE_THRESHOLD_MS) {
|
if (pendingAge < STALE_THRESHOLD_MS) {
|
||||||
log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
|
log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
fetchCandidateTransactions,
|
|
||||||
getMatchAnchors,
|
|
||||||
} from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
|
|
||||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
||||||
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
|
||||||
|
|
||||||
function makeReceipt(overrides?: Partial<ReceiptExtractionResult>): ReceiptExtractionResult {
|
|
||||||
return {
|
|
||||||
merchant: { name: 'Willys Hemma', orgNumber: null, vatNumber: null, isForeign: false },
|
|
||||||
receipt: { date: '2026-04-15', time: null, currency: 'SEK' },
|
|
||||||
lineItems: [],
|
|
||||||
totals: { subtotal: 239, vatAmount: 60, total: 299 },
|
|
||||||
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
|
|
||||||
confidence: 0.9,
|
|
||||||
...overrides,
|
|
||||||
} as ReceiptExtractionResult
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('fetchCandidateTransactions', () => {
|
|
||||||
it('returns empty list when extracted data is missing', async () => {
|
|
||||||
const { supabase } = createQueuedMockSupabase()
|
|
||||||
const result = await fetchCandidateTransactions(supabase as never, 'company-1', null)
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty list when no anchors could be derived', async () => {
|
|
||||||
const { supabase } = createQueuedMockSupabase()
|
|
||||||
const result = await fetchCandidateTransactions(
|
|
||||||
supabase as never,
|
|
||||||
'company-1',
|
|
||||||
makeReceipt({ totals: { subtotal: 0, vatAmount: 0, total: 0 } } as never)
|
|
||||||
)
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ranks candidates by amount proximity and returns top 5', async () => {
|
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
||||||
// 1. already-matched lookup (none)
|
|
||||||
enqueue({ data: [] })
|
|
||||||
// 2. candidate transactions
|
|
||||||
enqueue({
|
|
||||||
data: [
|
|
||||||
{ id: 't1', date: '2026-04-15', description: 'A', amount: -400, amount_sek: null, currency: 'SEK', merchant_name: null },
|
|
||||||
{ id: 't2', date: '2026-04-14', description: 'B', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: null }, // perfect match
|
|
||||||
{ id: 't3', date: '2026-04-16', description: 'C', amount: -305, amount_sek: null, currency: 'SEK', merchant_name: null }, // close
|
|
||||||
{ id: 't4', date: '2026-04-15', description: 'D', amount: -150, amount_sek: null, currency: 'SEK', merchant_name: null },
|
|
||||||
{ id: 't5', date: '2026-04-13', description: 'E', amount: -298, amount_sek: null, currency: 'SEK', merchant_name: null },
|
|
||||||
{ id: 't6', date: '2026-04-15', description: 'F', amount: -600, amount_sek: null, currency: 'SEK', merchant_name: null },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await fetchCandidateTransactions(supabase as never, 'company-1', makeReceipt())
|
|
||||||
|
|
||||||
expect(result).toHaveLength(5)
|
|
||||||
expect(result[0].id).toBe('t2') // exact match sorted first
|
|
||||||
expect(result[1].id).toBe('t5') // ±1 next
|
|
||||||
})
|
|
||||||
|
|
||||||
it('excludes transactions already claimed by other inbox items', async () => {
|
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
||||||
// 1. already-matched lookup — t2 is already taken
|
|
||||||
enqueue({ data: [{ matched_transaction_id: 't2' }] })
|
|
||||||
// 2. candidate transactions
|
|
||||||
enqueue({
|
|
||||||
data: [
|
|
||||||
{ id: 't1', date: '2026-04-15', description: 'A', amount: -400, amount_sek: null, currency: 'SEK', merchant_name: null },
|
|
||||||
{ id: 't2', date: '2026-04-14', description: 'B', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: null },
|
|
||||||
{ id: 't3', date: '2026-04-16', description: 'C', amount: -305, amount_sek: null, currency: 'SEK', merchant_name: null },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await fetchCandidateTransactions(supabase as never, 'company-1', makeReceipt())
|
|
||||||
const ids = result.map((c) => c.id)
|
|
||||||
expect(ids).not.toContain('t2')
|
|
||||||
expect(ids).toContain('t3')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('anchors invoices on dueDate with a ±14d window', () => {
|
|
||||||
const invoice: InvoiceExtractionResult = {
|
|
||||||
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
|
||||||
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: '2026-03-31', paymentReference: null, currency: 'SEK' },
|
|
||||||
lineItems: [],
|
|
||||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
|
||||||
vatBreakdown: [],
|
|
||||||
confidence: 0.9,
|
|
||||||
}
|
|
||||||
const anchors = getMatchAnchors(invoice)
|
|
||||||
expect(anchors).not.toBeNull()
|
|
||||||
expect(anchors!.date).toBe('2026-03-31')
|
|
||||||
expect(anchors!.windowDaysBefore).toBe(14)
|
|
||||||
expect(anchors!.windowDaysAfter).toBe(14)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('anchors invoices without dueDate on invoiceDate with a -7/+45 day window', () => {
|
|
||||||
const invoice: InvoiceExtractionResult = {
|
|
||||||
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
|
||||||
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: null, paymentReference: null, currency: 'SEK' },
|
|
||||||
lineItems: [],
|
|
||||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
|
||||||
vatBreakdown: [],
|
|
||||||
confidence: 0.9,
|
|
||||||
}
|
|
||||||
const anchors = getMatchAnchors(invoice)
|
|
||||||
expect(anchors).not.toBeNull()
|
|
||||||
expect(anchors!.date).toBe('2026-03-01')
|
|
||||||
expect(anchors!.windowDaysBefore).toBe(7)
|
|
||||||
expect(anchors!.windowDaysAfter).toBe(45)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('anchors receipts on receipt date with a ±7d window', () => {
|
|
||||||
const anchors = getMatchAnchors(makeReceipt())
|
|
||||||
expect(anchors).not.toBeNull()
|
|
||||||
expect(anchors!.date).toBe('2026-04-15')
|
|
||||||
expect(anchors!.windowDaysBefore).toBe(7)
|
|
||||||
expect(anchors!.windowDaysAfter).toBe(7)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses amount_sek when receipt is foreign currency', async () => {
|
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
||||||
enqueue({ data: [] }) // no already-matched
|
|
||||||
enqueue({
|
|
||||||
data: [
|
|
||||||
{ id: 't1', date: '2026-04-15', description: 'USD-denom', amount: -895, amount_sek: -895, currency: 'SEK', merchant_name: null },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const usdReceipt = makeReceipt({
|
|
||||||
receipt: { date: '2026-04-15', time: null, currency: 'USD' },
|
|
||||||
totals: { subtotal: 80, vatAmount: 0, total: 85 },
|
|
||||||
} as never)
|
|
||||||
|
|
||||||
const result = await fetchCandidateTransactions(supabase as never, 'company-1', usdReceipt)
|
|
||||||
// Amount doesn't perfectly match but the function should return it anyway
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0].id).toBe('t1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
// Mock Bedrock SDK before importing the module under test
|
|
||||||
const mockSend = vi.fn()
|
|
||||||
vi.mock('@aws-sdk/client-bedrock-runtime', () => {
|
|
||||||
class ConverseCommand {
|
|
||||||
public input: unknown
|
|
||||||
constructor(input: unknown) { this.input = input }
|
|
||||||
}
|
|
||||||
class BedrockRuntimeClient {
|
|
||||||
send(command: unknown) { return mockSend(command) }
|
|
||||||
}
|
|
||||||
return { BedrockRuntimeClient, ConverseCommand }
|
|
||||||
})
|
|
||||||
|
|
||||||
import { matchReceiptToCandidate } from '@/extensions/general/inbox-smart-match/lib/match-receipt'
|
|
||||||
import type { ReceiptExtractionResult } from '@/types'
|
|
||||||
import type { CandidateTransaction } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
|
|
||||||
|
|
||||||
function makeExtracted(): ReceiptExtractionResult {
|
|
||||||
return {
|
|
||||||
merchant: { name: 'Willys Hemma', orgNumber: null, vatNumber: null, isForeign: false },
|
|
||||||
receipt: { date: '2026-04-15', time: null, currency: 'SEK' },
|
|
||||||
lineItems: [],
|
|
||||||
totals: { subtotal: 239, vatAmount: 60, total: 299 },
|
|
||||||
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
|
|
||||||
confidence: 0.9,
|
|
||||||
} as ReceiptExtractionResult
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCandidates(): CandidateTransaction[] {
|
|
||||||
return [
|
|
||||||
{ id: 't1', date: '2026-04-15', description: 'WILLYS SÖDERM', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: 'Willys' },
|
|
||||||
{ id: 't2', date: '2026-04-14', description: 'ICA MAXI', amount: -312, amount_sek: null, currency: 'SEK', merchant_name: 'ICA' },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockBedrockResponse(toolInput: Record<string, unknown>) {
|
|
||||||
mockSend.mockResolvedValue({
|
|
||||||
output: {
|
|
||||||
message: {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
toolUse: {
|
|
||||||
toolUseId: 'id',
|
|
||||||
name: 'match_receipt',
|
|
||||||
input: toolInput,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
usage: { inputTokens: 100, outputTokens: 20 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('matchReceiptToCandidate', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockSend.mockReset()
|
|
||||||
process.env.AWS_ACCESS_KEY_ID = 'test'
|
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = 'test'
|
|
||||||
process.env.AWS_REGION = 'eu-north-1'
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns a matched result when LLM picks a valid candidate', async () => {
|
|
||||||
mockBedrockResponse({
|
|
||||||
matched: true,
|
|
||||||
transaction_id: 't1',
|
|
||||||
confidence: 96,
|
|
||||||
reasoning: 'Exakt belopp och datum. Willys matchar WILLYS SÖDERM.',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchReceiptToCandidate({
|
|
||||||
extracted: makeExtracted(),
|
|
||||||
candidates: makeCandidates(),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.matched).toBe(true)
|
|
||||||
expect(result.transactionId).toBe('t1')
|
|
||||||
expect(result.confidence).toBeCloseTo(0.96, 2)
|
|
||||||
expect(result.reasoning).toContain('Willys')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns no match when LLM says matched=false', async () => {
|
|
||||||
mockBedrockResponse({
|
|
||||||
matched: false,
|
|
||||||
transaction_id: null,
|
|
||||||
confidence: 10,
|
|
||||||
reasoning: 'Ingen kandidat har rätt belopp eller handlare.',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchReceiptToCandidate({
|
|
||||||
extracted: makeExtracted(),
|
|
||||||
candidates: makeCandidates(),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.matched).toBe(false)
|
|
||||||
expect(result.transactionId).toBeNull()
|
|
||||||
expect(result.confidence).toBeCloseTo(0.1, 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('degrades to no-match when LLM returns unknown transaction_id', async () => {
|
|
||||||
mockBedrockResponse({
|
|
||||||
matched: true,
|
|
||||||
transaction_id: 'hallucinated-id-not-in-candidates',
|
|
||||||
confidence: 80,
|
|
||||||
reasoning: 'Detta är fel id',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchReceiptToCandidate({
|
|
||||||
extracted: makeExtracted(),
|
|
||||||
candidates: makeCandidates(),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.matched).toBe(false)
|
|
||||||
expect(result.transactionId).toBeNull()
|
|
||||||
expect(result.confidence).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns safe default when no tool use in response', async () => {
|
|
||||||
mockSend.mockResolvedValue({
|
|
||||||
output: { message: { content: [] } },
|
|
||||||
usage: { inputTokens: 0, outputTokens: 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchReceiptToCandidate({
|
|
||||||
extracted: makeExtracted(),
|
|
||||||
candidates: makeCandidates(),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.matched).toBe(false)
|
|
||||||
expect(result.transactionId).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import type { Extension } from '@/lib/extensions/types'
|
|
||||||
import type { EventPayload } from '@/lib/events/types'
|
|
||||||
import type { InvoiceInboxItem } from '@/types'
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import { createClient } from '@supabase/supabase-js'
|
|
||||||
import { processInboxItemMatch } from './lib/process-match'
|
|
||||||
|
|
||||||
const EXTENSION_ID = 'inbox-smart-match'
|
|
||||||
|
|
||||||
// The handler always uses a service-role client:
|
|
||||||
// - processing_history has no INSERT RLS policy (audit integrity) — only service-role can append
|
|
||||||
// - every query is scoped by company_id from the event payload
|
|
||||||
// - we write pseudonymous IDs only (PII validator enforces this at the append layer)
|
|
||||||
// Using ctx.supabase is unsafe because the registry wrapper may build an anon-key
|
|
||||||
// client when no user session exists (e.g. webhook path).
|
|
||||||
function getServiceSupabase(): SupabaseClient {
|
|
||||||
return createClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const inboxSmartMatchExtension: Extension = {
|
|
||||||
id: EXTENSION_ID,
|
|
||||||
name: 'Smart matchning',
|
|
||||||
version: '0.1.0',
|
|
||||||
|
|
||||||
eventHandlers: [
|
|
||||||
// When an inbox item is freshly classified, try to match it to a transaction
|
|
||||||
{
|
|
||||||
eventType: 'inbox_item.classified',
|
|
||||||
handler: async (payload: EventPayload<'inbox_item.classified'>) => {
|
|
||||||
// Match both receipts and supplier invoices — other document types
|
|
||||||
// (government letters, unknown) have nothing to match against.
|
|
||||||
if (payload.documentType !== 'receipt' && payload.documentType !== 'supplier_invoice') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = getServiceSupabase()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await processInboxItemMatch(
|
|
||||||
{
|
|
||||||
supabase,
|
|
||||||
companyId: payload.companyId,
|
|
||||||
userId: payload.userId,
|
|
||||||
extensionId: EXTENSION_ID,
|
|
||||||
triggerReason: 'classified',
|
|
||||||
},
|
|
||||||
payload.inboxItem
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`[${EXTENSION_ID}] Failed to process classified inbox item ${payload.inboxItem.id}:`,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// When new transactions land, retry matching on any receipts still waiting
|
|
||||||
{
|
|
||||||
eventType: 'transaction.synced',
|
|
||||||
handler: async (payload: EventPayload<'transaction.synced'>) => {
|
|
||||||
const newTransactionIds = payload.transactions.map((t) => t.id).filter(Boolean)
|
|
||||||
if (newTransactionIds.length === 0) return
|
|
||||||
|
|
||||||
const supabase = getServiceSupabase()
|
|
||||||
|
|
||||||
// Find pending receipts/invoices for this company. Cap at 10 per sync
|
|
||||||
// so one big bank import doesn't time out the handler; leftover pending
|
|
||||||
// items pick up on the next sync.
|
|
||||||
const { data: pendingItems, error } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', payload.companyId)
|
|
||||||
.in('document_type', ['receipt', 'supplier_invoice'])
|
|
||||||
.eq('status', 'ready')
|
|
||||||
.eq('match_method', 'pending_transaction')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(10)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error(`[${EXTENSION_ID}] Failed to fetch pending receipts:`, error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!pendingItems || pendingItems.length === 0) return
|
|
||||||
|
|
||||||
// Run LLM calls in parallel; one failing receipt shouldn't stop the others.
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
(pendingItems as InvoiceInboxItem[]).map((item) =>
|
|
||||||
processInboxItemMatch(
|
|
||||||
{
|
|
||||||
supabase,
|
|
||||||
companyId: payload.companyId,
|
|
||||||
userId: payload.userId,
|
|
||||||
extensionId: EXTENSION_ID,
|
|
||||||
triggerReason: 'transaction_synced',
|
|
||||||
},
|
|
||||||
item
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
results.forEach((r, i) => {
|
|
||||||
if (r.status === 'rejected') {
|
|
||||||
const itemId = (pendingItems[i] as InvoiceInboxItem).id
|
|
||||||
console.error(
|
|
||||||
`[${EXTENSION_ID}] Retroactive match failed for item ${itemId}:`,
|
|
||||||
r.reason
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* Candidate transaction fetcher — deterministic narrowing before the LLM call.
|
|
||||||
*
|
|
||||||
* Pulls unbooked expense transactions near the document's payment date,
|
|
||||||
* ordered by how close their amount is to the document total. Limits to top 5
|
|
||||||
* so the LLM has a focused candidate set and the token cost stays bounded.
|
|
||||||
*
|
|
||||||
* Anchor date selection:
|
|
||||||
* - Receipts: receipt date ±7 days (paid on the spot)
|
|
||||||
* - Invoices with dueDate: dueDate ±14 days (covers early and late payments)
|
|
||||||
* - Invoices without dueDate: invoiceDate, window shifted forward to cover
|
|
||||||
* standard 30-day terms (invoiceDate-7 .. invoiceDate+45)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
|
||||||
|
|
||||||
const MAX_CANDIDATES = 5
|
|
||||||
|
|
||||||
export interface CandidateTransaction {
|
|
||||||
id: string
|
|
||||||
date: string
|
|
||||||
description: string
|
|
||||||
amount: number
|
|
||||||
amount_sek: number | null
|
|
||||||
currency: string
|
|
||||||
merchant_name: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExtractedDocument = ReceiptExtractionResult | InvoiceExtractionResult
|
|
||||||
|
|
||||||
export interface MatchAnchors {
|
|
||||||
date: string
|
|
||||||
amount: number
|
|
||||||
currency: string
|
|
||||||
counterpartyName: string | null
|
|
||||||
windowDaysBefore: number
|
|
||||||
windowDaysAfter: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInvoiceExtraction(e: ExtractedDocument): e is InvoiceExtractionResult {
|
|
||||||
return 'invoice' in e && typeof (e as InvoiceExtractionResult).invoice === 'object'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the reference date and absolute amount from a classified document's
|
|
||||||
* extracted data. Returns null if required fields are missing.
|
|
||||||
*/
|
|
||||||
export function getMatchAnchors(extracted: ExtractedDocument | null): MatchAnchors | null {
|
|
||||||
if (!extracted) return null
|
|
||||||
|
|
||||||
let date: string | null
|
|
||||||
let currency: string
|
|
||||||
let counterpartyName: string | null
|
|
||||||
let windowDaysBefore: number
|
|
||||||
let windowDaysAfter: number
|
|
||||||
|
|
||||||
if (isInvoiceExtraction(extracted)) {
|
|
||||||
const dueDate = extracted.invoice?.dueDate ?? null
|
|
||||||
const invoiceDate = extracted.invoice?.invoiceDate ?? null
|
|
||||||
if (dueDate) {
|
|
||||||
date = dueDate
|
|
||||||
windowDaysBefore = 14
|
|
||||||
windowDaysAfter = 14
|
|
||||||
} else {
|
|
||||||
date = invoiceDate
|
|
||||||
windowDaysBefore = 7
|
|
||||||
windowDaysAfter = 45
|
|
||||||
}
|
|
||||||
currency = extracted.invoice?.currency ?? 'SEK'
|
|
||||||
counterpartyName = extracted.supplier?.name ?? null
|
|
||||||
} else {
|
|
||||||
date = extracted.receipt?.date ?? null
|
|
||||||
currency = extracted.receipt?.currency ?? 'SEK'
|
|
||||||
counterpartyName = extracted.merchant?.name ?? null
|
|
||||||
windowDaysBefore = 7
|
|
||||||
windowDaysAfter = 7
|
|
||||||
}
|
|
||||||
|
|
||||||
const amount = extracted.totals?.total ?? null
|
|
||||||
if (!date || amount == null || amount <= 0) return null
|
|
||||||
return { date, amount, currency, counterpartyName, windowDaysBefore, windowDaysAfter }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the document's
|
|
||||||
* date + amount. Ordering prefers exact amount matches first.
|
|
||||||
*/
|
|
||||||
export async function fetchCandidateTransactions(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
extracted: ExtractedDocument | null
|
|
||||||
): Promise<CandidateTransaction[]> {
|
|
||||||
const anchors = getMatchAnchors(extracted)
|
|
||||||
if (!anchors) return []
|
|
||||||
|
|
||||||
const anchorDate = new Date(anchors.date)
|
|
||||||
if (isNaN(anchorDate.getTime())) return []
|
|
||||||
|
|
||||||
const windowStart = new Date(anchorDate)
|
|
||||||
windowStart.setUTCDate(windowStart.getUTCDate() - anchors.windowDaysBefore)
|
|
||||||
const windowEnd = new Date(anchorDate)
|
|
||||||
windowEnd.setUTCDate(windowEnd.getUTCDate() + anchors.windowDaysAfter)
|
|
||||||
|
|
||||||
// Exclude transactions already claimed by any other inbox item in this
|
|
||||||
// company. The partial unique index on (company_id, matched_transaction_id)
|
|
||||||
// is the final guard against concurrent double-matches, but filtering up
|
|
||||||
// front saves an LLM roundtrip on the obvious cases.
|
|
||||||
const { data: claimed, error: claimedError } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('matched_transaction_id')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.not('matched_transaction_id', 'is', null)
|
|
||||||
|
|
||||||
if (claimedError) {
|
|
||||||
throw new Error(`Failed to load matched transactions: ${claimedError.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const excludedIds = new Set(
|
|
||||||
(claimed ?? [])
|
|
||||||
.map((row) => (row as { matched_transaction_id: string | null }).matched_transaction_id)
|
|
||||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pull negative-amount (expense) transactions without a journal entry in the window
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('id, date, description, amount, amount_sek, currency, merchant_name')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.is('journal_entry_id', null)
|
|
||||||
.lt('amount', 0)
|
|
||||||
.gte('date', windowStart.toISOString().slice(0, 10))
|
|
||||||
.lte('date', windowEnd.toISOString().slice(0, 10))
|
|
||||||
.order('date', { ascending: false })
|
|
||||||
.limit(50)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(`Failed to fetch candidate transactions: ${error.message}`)
|
|
||||||
}
|
|
||||||
if (!data || data.length === 0) return []
|
|
||||||
|
|
||||||
const filtered = excludedIds.size > 0
|
|
||||||
? data.filter((tx) => !excludedIds.has(tx.id as string))
|
|
||||||
: data
|
|
||||||
if (filtered.length === 0) return []
|
|
||||||
|
|
||||||
// Rank candidates by amount proximity. For SEK documents we compare directly,
|
|
||||||
// for other currencies we prefer amount_sek if the document amount has been converted.
|
|
||||||
const anchorAbs = Math.abs(anchors.amount)
|
|
||||||
const scored = filtered.map((tx) => {
|
|
||||||
const txAmount = Math.abs(Number(tx.amount) || 0)
|
|
||||||
const txSek = tx.amount_sek == null ? null : Math.abs(Number(tx.amount_sek))
|
|
||||||
const primaryDiff = Math.abs(txAmount - anchorAbs)
|
|
||||||
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - anchorAbs)
|
|
||||||
const bestDiff = Math.min(primaryDiff, sekDiff)
|
|
||||||
return { tx, diff: bestDiff }
|
|
||||||
})
|
|
||||||
|
|
||||||
scored.sort((a, b) => a.diff - b.diff)
|
|
||||||
|
|
||||||
return scored.slice(0, MAX_CANDIDATES).map(({ tx }) => ({
|
|
||||||
id: tx.id,
|
|
||||||
date: tx.date,
|
|
||||||
description: tx.description ?? '',
|
|
||||||
amount: Number(tx.amount),
|
|
||||||
amount_sek: tx.amount_sek == null ? null : Number(tx.amount_sek),
|
|
||||||
currency: tx.currency ?? 'SEK',
|
|
||||||
merchant_name: tx.merchant_name ?? null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
/**
|
|
||||||
* Text-only LLM matcher — decides which candidate bank transaction (if any)
|
|
||||||
* corresponds to a classified receipt. Uses Bedrock Converse with structured
|
|
||||||
* tool output. No image input: the receipt is already represented by the
|
|
||||||
* extracted data, and candidates are pure text.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
BedrockRuntimeClient,
|
|
||||||
ConverseCommand,
|
|
||||||
type ContentBlock,
|
|
||||||
type Message,
|
|
||||||
type ToolConfiguration,
|
|
||||||
} from '@aws-sdk/client-bedrock-runtime'
|
|
||||||
import type { CandidateTransaction, ExtractedDocument } from './fetch-candidates'
|
|
||||||
import { getMatchAnchors } from './fetch-candidates'
|
|
||||||
|
|
||||||
export interface ReceiptMatchResult {
|
|
||||||
matched: boolean
|
|
||||||
transactionId: string | null
|
|
||||||
confidence: number // 0..1
|
|
||||||
reasoning: string
|
|
||||||
usage: { inputTokens: number; outputTokens: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
let _client: BedrockRuntimeClient | null = null
|
|
||||||
|
|
||||||
function getClient(): BedrockRuntimeClient {
|
|
||||||
if (!_client) {
|
|
||||||
_client = new BedrockRuntimeClient({
|
|
||||||
region: process.env.AWS_REGION || 'eu-north-1',
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return _client
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska bokföringsdokument (kvitton, fakturor) mot banktransaktioner.
|
|
||||||
|
|
||||||
Du får:
|
|
||||||
- Dokumentdata (handlare/leverantör, belopp, valuta, datum) från AI-extraktion
|
|
||||||
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
|
|
||||||
|
|
||||||
Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar dokumentet.
|
|
||||||
|
|
||||||
Resonera utifrån:
|
|
||||||
- Belopp: bör vara identiskt eller mycket nära (ta hänsyn till valutaväxling om olika valutor)
|
|
||||||
- Datum: för kvitton bokförs banktransaktionen ofta 0-3 dagar efter köpet; för leverantörsfakturor kan betalningen ske flera dagar till veckor efter fakturadatum
|
|
||||||
- Handlare/leverantör: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
|
|
||||||
|
|
||||||
Om inget förslag är trovärdigt — returnera matched=false.
|
|
||||||
Anropa ALLTID verktyget match_receipt med resultatet.
|
|
||||||
Motivering ska vara kort, på svenska och förklara varför transaktionen valdes.`
|
|
||||||
|
|
||||||
const MATCH_TOOL: ToolConfiguration = {
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
toolSpec: {
|
|
||||||
name: 'match_receipt',
|
|
||||||
description: 'Returnera vilken kandidat-transaktion som matchar kvittot',
|
|
||||||
inputSchema: {
|
|
||||||
json: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['matched', 'confidence', 'reasoning'],
|
|
||||||
properties: {
|
|
||||||
matched: {
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'true om en kandidat matchar, false annars',
|
|
||||||
},
|
|
||||||
transaction_id: {
|
|
||||||
type: ['string', 'null'],
|
|
||||||
description: 'id för den matchande kandidaten (null om matched=false)',
|
|
||||||
},
|
|
||||||
confidence: {
|
|
||||||
type: 'integer',
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 100,
|
|
||||||
description: 'Säkerhet 0-100. Sätt lågt när matched=false.',
|
|
||||||
},
|
|
||||||
reasoning: {
|
|
||||||
type: 'string',
|
|
||||||
description: '1-2 meningar på svenska som förklarar beslutet.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
toolChoice: { any: {} },
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchReceiptInput {
|
|
||||||
extracted: ExtractedDocument
|
|
||||||
candidates: CandidateTransaction[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call Bedrock to choose the best matching transaction.
|
|
||||||
* Returns a neutral result (matched=false) if the model doesn't find a fit or
|
|
||||||
* the tool schema is missing from the response.
|
|
||||||
*/
|
|
||||||
export async function matchReceiptToCandidate(
|
|
||||||
input: MatchReceiptInput
|
|
||||||
): Promise<ReceiptMatchResult> {
|
|
||||||
const anchors = getMatchAnchors(input.extracted)
|
|
||||||
const receiptBrief = {
|
|
||||||
merchant: anchors?.counterpartyName ?? null,
|
|
||||||
amount: anchors?.amount ?? null,
|
|
||||||
currency: anchors?.currency ?? 'SEK',
|
|
||||||
date: anchors?.date ?? null,
|
|
||||||
vat_amount: input.extracted.totals?.vatAmount ?? null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateLines = input.candidates.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
date: c.date,
|
|
||||||
description: c.description,
|
|
||||||
amount: c.amount,
|
|
||||||
amount_sek: c.amount_sek,
|
|
||||||
currency: c.currency,
|
|
||||||
merchant_name: c.merchant_name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const userPrompt = `Dokument:
|
|
||||||
${JSON.stringify(receiptBrief, null, 2)}
|
|
||||||
|
|
||||||
Kandidat-transaktioner:
|
|
||||||
${JSON.stringify(candidateLines, null, 2)}
|
|
||||||
|
|
||||||
Vilken transaktion matchar dokumentet? Om ingen matchar, returnera matched=false.`
|
|
||||||
|
|
||||||
const messages: Message[] = [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: [{ text: userPrompt }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
|
||||||
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '1024', 10)
|
|
||||||
|
|
||||||
const command = new ConverseCommand({
|
|
||||||
modelId,
|
|
||||||
messages,
|
|
||||||
system: [{ text: SYSTEM_PROMPT }],
|
|
||||||
toolConfig: MATCH_TOOL,
|
|
||||||
inferenceConfig: { maxTokens, temperature: 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await getClient().send(command)
|
|
||||||
|
|
||||||
const usage = {
|
|
||||||
inputTokens: response.usage?.inputTokens ?? 0,
|
|
||||||
outputTokens: response.usage?.outputTokens ?? 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputMessage = response.output?.message
|
|
||||||
if (!outputMessage?.content) {
|
|
||||||
return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget LLM-svar', usage }
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolUseBlock = outputMessage.content.find(
|
|
||||||
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!toolUseBlock?.toolUse?.input) {
|
|
||||||
return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget verktygsanrop', usage }
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = toolUseBlock.toolUse.input as Record<string, unknown>
|
|
||||||
const matched = Boolean(raw.matched)
|
|
||||||
const rawId = typeof raw.transaction_id === 'string' ? raw.transaction_id : null
|
|
||||||
const transactionId = matched
|
|
||||||
? (input.candidates.find((c) => c.id === rawId)?.id ?? null)
|
|
||||||
: null
|
|
||||||
const confidenceRaw = Number(raw.confidence)
|
|
||||||
const confidence =
|
|
||||||
isFinite(confidenceRaw)
|
|
||||||
? Math.min(1, Math.max(0, confidenceRaw / 100))
|
|
||||||
: 0
|
|
||||||
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
|
|
||||||
|
|
||||||
// If LLM said matched but we can't resolve the transaction_id to a candidate,
|
|
||||||
// degrade gracefully to unmatched so downstream isn't left dangling.
|
|
||||||
if (matched && !transactionId) {
|
|
||||||
return {
|
|
||||||
matched: false,
|
|
||||||
transactionId: null,
|
|
||||||
confidence: 0,
|
|
||||||
reasoning: reasoning || 'LLM angav ogiltigt transaction_id',
|
|
||||||
usage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { matched, transactionId, confidence, reasoning, usage }
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
/**
|
|
||||||
* Core matching flow — deterministic narrowing + LLM call + persistence +
|
|
||||||
* processing_history audit events. Called from both the classify handler and
|
|
||||||
* the transaction-sync retroactive handler.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import type { InvoiceInboxItem } from '@/types'
|
|
||||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
||||||
import { fetchCandidateTransactions, type ExtractedDocument } from './fetch-candidates'
|
|
||||||
import { matchReceiptToCandidate } from './match-receipt'
|
|
||||||
|
|
||||||
export interface MatchContext {
|
|
||||||
supabase: SupabaseClient
|
|
||||||
companyId: string
|
|
||||||
userId: string
|
|
||||||
extensionId: string
|
|
||||||
triggerReason: 'classified' | 'transaction_synced'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchOutcome {
|
|
||||||
status: 'matched' | 'no_match' | 'pending_transaction' | 'skipped'
|
|
||||||
transactionId: string | null
|
|
||||||
confidence: number
|
|
||||||
reasoning: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a single classified-receipt inbox item through the matcher pipeline.
|
|
||||||
* Writes match fields + appends processing_history. Swallows internal errors
|
|
||||||
* so one failing receipt doesn't break the whole event handler.
|
|
||||||
*/
|
|
||||||
export async function processInboxItemMatch(
|
|
||||||
ctx: MatchContext,
|
|
||||||
item: InvoiceInboxItem
|
|
||||||
): Promise<MatchOutcome> {
|
|
||||||
const tag = `[inbox-smart-match] item=${item.id} trigger=${ctx.triggerReason}`
|
|
||||||
|
|
||||||
// Match both receipts and supplier invoices — both have comparable anchors
|
|
||||||
// (date, amount, counterparty, currency) and the downstream LLM prompt is
|
|
||||||
// shape-agnostic.
|
|
||||||
if (item.document_type !== 'receipt' && item.document_type !== 'supplier_invoice') {
|
|
||||||
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
|
|
||||||
}
|
|
||||||
if (item.status !== 'ready') {
|
|
||||||
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
|
|
||||||
}
|
|
||||||
if (!item.extracted_data) {
|
|
||||||
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const correlationId = item.correlation_id ?? crypto.randomUUID()
|
|
||||||
|
|
||||||
// If we just minted a fresh correlation_id (legacy row predating the column),
|
|
||||||
// persist it so retries reuse the same thread through processing_history.
|
|
||||||
if (!item.correlation_id) {
|
|
||||||
const { error: corrError } = await ctx.supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({ correlation_id: correlationId })
|
|
||||||
.eq('id', item.id)
|
|
||||||
if (corrError) {
|
|
||||||
console.error(`${tag} — failed to persist correlation_id:`, corrError)
|
|
||||||
// non-fatal — we still proceed with matching under the in-memory ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const extracted = item.extracted_data as unknown as ExtractedDocument
|
|
||||||
const candidates = await fetchCandidateTransactions(ctx.supabase, ctx.companyId, extracted)
|
|
||||||
|
|
||||||
// Append DeterministicMatch event — records that the narrowing ran
|
|
||||||
let deterministicEventId: string
|
|
||||||
try {
|
|
||||||
deterministicEventId = await appendProcessingHistory({
|
|
||||||
companyId: ctx.companyId,
|
|
||||||
correlationId,
|
|
||||||
aggregateType: 'MatchProposal',
|
|
||||||
aggregateId: item.id,
|
|
||||||
eventType: 'MatchAttemptedDeterministic',
|
|
||||||
payload: {
|
|
||||||
inbox_item_id: item.id,
|
|
||||||
candidate_count: candidates.length,
|
|
||||||
candidate_ids: candidates.map((c) => c.id),
|
|
||||||
window_days: 7,
|
|
||||||
trigger: ctx.triggerReason,
|
|
||||||
},
|
|
||||||
actor: { type: 'system', id: ctx.extensionId },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`${tag} — failed to append MatchAttemptedDeterministic:`, err)
|
|
||||||
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// No candidates → mark pending, wait for bank sync
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
await ctx.supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({
|
|
||||||
match_method: 'pending_transaction',
|
|
||||||
match_confidence: null,
|
|
||||||
matched_transaction_id: null,
|
|
||||||
match_reasoning: 'Inväntar matchande banktransaktion',
|
|
||||||
})
|
|
||||||
.eq('id', item.id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'pending_transaction',
|
|
||||||
transactionId: null,
|
|
||||||
confidence: 0,
|
|
||||||
reasoning: 'Inväntar matchande banktransaktion',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLM chooses among candidates
|
|
||||||
let llm
|
|
||||||
try {
|
|
||||||
llm = await matchReceiptToCandidate({ extracted, candidates })
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`${tag} — LLM matcher failed:`, err)
|
|
||||||
// Don't overwrite existing state on LLM failure; just log and exit
|
|
||||||
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record the LLM attempt in processing_history
|
|
||||||
try {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId: ctx.companyId,
|
|
||||||
correlationId,
|
|
||||||
causationId: deterministicEventId,
|
|
||||||
aggregateType: 'MatchProposal',
|
|
||||||
aggregateId: item.id,
|
|
||||||
eventType: 'MatchAttemptedLlm',
|
|
||||||
payload: {
|
|
||||||
inbox_item_id: item.id,
|
|
||||||
matched: llm.matched,
|
|
||||||
chosen_transaction_id: llm.transactionId,
|
|
||||||
confidence: llm.confidence,
|
|
||||||
llm_input_tokens: llm.usage.inputTokens,
|
|
||||||
llm_output_tokens: llm.usage.outputTokens,
|
|
||||||
candidate_count: candidates.length,
|
|
||||||
},
|
|
||||||
actor: { type: 'llm', id: 'match_receipt' },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`${tag} — failed to append MatchAttemptedLlm:`, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist match. The (company_id, matched_transaction_id) partial unique
|
|
||||||
// index means a concurrent second inbox item trying to claim the same
|
|
||||||
// transaction will get a 23505 — we catch that and downgrade this one
|
|
||||||
// to pending_transaction instead of overwriting the winner.
|
|
||||||
if (llm.matched && llm.transactionId) {
|
|
||||||
const { error: updateError } = await ctx.supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({
|
|
||||||
matched_transaction_id: llm.transactionId,
|
|
||||||
match_confidence: llm.confidence,
|
|
||||||
match_method: 'llm',
|
|
||||||
match_reasoning: llm.reasoning,
|
|
||||||
})
|
|
||||||
.eq('id', item.id)
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
const code = (updateError as { code?: string }).code
|
|
||||||
if (code === '23505') {
|
|
||||||
// Another receipt won the race for this transaction.
|
|
||||||
await ctx.supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({
|
|
||||||
matched_transaction_id: null,
|
|
||||||
match_method: 'pending_transaction',
|
|
||||||
match_confidence: null,
|
|
||||||
match_reasoning: 'Transaktionen matchades först till ett annat kvitto',
|
|
||||||
})
|
|
||||||
.eq('id', item.id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'pending_transaction',
|
|
||||||
transactionId: null,
|
|
||||||
confidence: 0,
|
|
||||||
reasoning: 'Transaktionen matchades först till ett annat kvitto',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error(`${tag} — failed to persist match:`, updateError)
|
|
||||||
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'matched',
|
|
||||||
transactionId: llm.transactionId,
|
|
||||||
confidence: llm.confidence,
|
|
||||||
reasoning: llm.reasoning,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLM said no match among the candidates — record explanatory reasoning
|
|
||||||
await ctx.supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({
|
|
||||||
matched_transaction_id: null,
|
|
||||||
match_confidence: llm.confidence,
|
|
||||||
match_method: 'pending_transaction',
|
|
||||||
match_reasoning: llm.reasoning || 'AI kunde inte hitta matchande transaktion bland kandidaterna',
|
|
||||||
})
|
|
||||||
.eq('id', item.id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'no_match',
|
|
||||||
transactionId: null,
|
|
||||||
confidence: llm.confidence,
|
|
||||||
reasoning: llm.reasoning,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "inbox-smart-match",
|
|
||||||
"sector": "general",
|
|
||||||
"exportName": "inboxSmartMatchExtension",
|
|
||||||
"entryPoint": "@/extensions/general/inbox-smart-match",
|
|
||||||
"requiredEnvVars": [
|
|
||||||
"AWS_ACCESS_KEY_ID",
|
|
||||||
"AWS_SECRET_ACCESS_KEY",
|
|
||||||
"AWS_REGION"
|
|
||||||
],
|
|
||||||
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS"],
|
|
||||||
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
|
|
||||||
"definition": {
|
|
||||||
"name": "Smart matchning",
|
|
||||||
"category": "operations",
|
|
||||||
"icon": "Sparkles",
|
|
||||||
"dataPattern": "core",
|
|
||||||
"hasOwnData": false,
|
|
||||||
"readsCoreTables": ["invoice_inbox_items", "transactions", "processing_history"],
|
|
||||||
"description": "AI-driven matchning av kvitton mot banktransaktioner",
|
|
||||||
"longDescription": "När ett kvitto klassificeras i inkorgen föreslår AI den mest sannolika matchande banktransaktionen, med motivering. Körs även retroaktivt när nya transaktioner synkas in. Kräver AWS Bedrock."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||||
import {
|
import {
|
||||||
createQueuedMockSupabase,
|
createQueuedMockSupabase,
|
||||||
@@ -38,7 +38,6 @@ function buildCtx(supabase: unknown, overrides: Partial<ExtensionContext> = {}):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SUPPLIER_UUID = '00000000-0000-4000-8000-000000000001'
|
const SUPPLIER_UUID = '00000000-0000-4000-8000-000000000001'
|
||||||
const ITEM_UUID = '00000000-0000-4000-8000-000000000002'
|
|
||||||
|
|
||||||
const VALID_CONVERT_BODY = {
|
const VALID_CONVERT_BODY = {
|
||||||
supplier_id: SUPPLIER_UUID,
|
supplier_id: SUPPLIER_UUID,
|
||||||
@@ -68,7 +67,7 @@ describe('POST /items/:id/convert', () => {
|
|||||||
|
|
||||||
it('returns 404 when item not found', async () => {
|
it('returns 404 when item not found', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: null, error: { message: 'Not found' } }) // fetch inbox item
|
enqueue({ data: null, error: { message: 'Not found' } })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/convert', {
|
const request = createMockRequest('/items/item-1/convert', {
|
||||||
@@ -81,9 +80,14 @@ describe('POST /items/:id/convert', () => {
|
|||||||
expect(status).toBe(404)
|
expect(status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 409 when item status is not ready', async () => {
|
it('returns 409 when item already linked to a supplier invoice', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: makeInvoiceInboxItem({ status: 'confirmed' }) }) // fetch inbox item
|
enqueue({
|
||||||
|
data: makeInvoiceInboxItem({
|
||||||
|
status: 'received',
|
||||||
|
created_supplier_invoice_id: 'existing-1',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/convert', {
|
const request = createMockRequest('/items/item-1/convert', {
|
||||||
@@ -98,12 +102,12 @@ describe('POST /items/:id/convert', () => {
|
|||||||
|
|
||||||
it('returns 400 when required fields missing', async () => {
|
it('returns 400 when required fields missing', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) }) // fetch inbox item
|
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/convert', {
|
const request = createMockRequest('/items/item-1/convert', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { items: [] }, // missing required fields
|
body: { items: [] },
|
||||||
searchParams: { _id: 'item-1' },
|
searchParams: { _id: 'item-1' },
|
||||||
})
|
})
|
||||||
const res = await route.handler(request, ctx)
|
const res = await route.handler(request, ctx)
|
||||||
@@ -113,8 +117,8 @@ describe('POST /items/:id/convert', () => {
|
|||||||
|
|
||||||
it('returns 404 when supplier not found in company', async () => {
|
it('returns 404 when supplier not found in company', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) }) // fetch inbox item
|
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
|
||||||
enqueue({ data: null, error: { message: 'Not found' } }) // fetch supplier
|
enqueue({ data: null, error: { message: 'Not found' } })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/convert', {
|
const request = createMockRequest('/items/item-1/convert', {
|
||||||
@@ -129,7 +133,7 @@ describe('POST /items/:id/convert', () => {
|
|||||||
|
|
||||||
it('successfully converts inbox item to supplier invoice', async () => {
|
it('successfully converts inbox item to supplier invoice', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
const inboxItem = makeInvoiceInboxItem({ status: 'ready', document_id: 'doc-1' })
|
const inboxItem = makeInvoiceInboxItem({ status: 'received', document_id: 'doc-1' })
|
||||||
const supplier = makeSupplier({ id: 'supplier-1' })
|
const supplier = makeSupplier({ id: 'supplier-1' })
|
||||||
const createdInvoice = {
|
const createdInvoice = {
|
||||||
id: 'invoice-1',
|
id: 'invoice-1',
|
||||||
@@ -142,13 +146,13 @@ describe('POST /items/:id/convert', () => {
|
|||||||
status: 'registered',
|
status: 'registered',
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueue({ data: inboxItem }) // fetch inbox item
|
enqueue({ data: inboxItem })
|
||||||
enqueue({ data: supplier }) // fetch supplier
|
enqueue({ data: supplier })
|
||||||
enqueue({ data: 42 }) // get_next_arrival_number RPC
|
enqueue({ data: 42 })
|
||||||
enqueue({ data: createdInvoice }) // insert supplier_invoices
|
enqueue({ data: createdInvoice })
|
||||||
enqueue({ data: null, error: null }) // insert supplier_invoice_items
|
enqueue({ data: null, error: null })
|
||||||
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) }) // company_settings
|
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) })
|
||||||
enqueue({ data: null, error: null }) // update inbox item
|
enqueue({ data: null, error: null })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/convert', {
|
const request = createMockRequest('/items/item-1/convert', {
|
||||||
@@ -166,13 +170,13 @@ describe('POST /items/:id/convert', () => {
|
|||||||
|
|
||||||
it('emits supplier_invoice.registered and supplier_invoice.confirmed events', async () => {
|
it('emits supplier_invoice.registered and supplier_invoice.confirmed events', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) })
|
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
|
||||||
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
|
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
|
||||||
enqueue({ data: 42 }) // arrival number
|
enqueue({ data: 42 })
|
||||||
enqueue({ data: { id: 'invoice-1', status: 'registered' } }) // insert
|
enqueue({ data: { id: 'invoice-1', status: 'registered' } })
|
||||||
enqueue({ data: null, error: null }) // insert items
|
enqueue({ data: null, error: null })
|
||||||
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) })
|
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) })
|
||||||
enqueue({ data: null, error: null }) // update inbox item
|
enqueue({ data: null, error: null })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/convert', {
|
const request = createMockRequest('/items/item-1/convert', {
|
||||||
@@ -192,14 +196,14 @@ describe('POST /items/:id/convert', () => {
|
|||||||
const { createSupplierInvoiceRegistrationEntry } = await import('@/lib/bookkeeping/supplier-invoice-entries')
|
const { createSupplierInvoiceRegistrationEntry } = await import('@/lib/bookkeeping/supplier-invoice-entries')
|
||||||
|
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) })
|
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
|
||||||
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
|
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
|
||||||
enqueue({ data: 42 }) // arrival number
|
enqueue({ data: 42 })
|
||||||
enqueue({ data: { id: 'invoice-1', status: 'registered' } }) // insert invoice
|
enqueue({ data: { id: 'invoice-1', status: 'registered' } })
|
||||||
enqueue({ data: null, error: null }) // insert items
|
enqueue({ data: null, error: null })
|
||||||
enqueue({ data: makeCompanySettings({ accounting_method: 'accrual' }) })
|
enqueue({ data: makeCompanySettings({ accounting_method: 'accrual' }) })
|
||||||
enqueue({ data: null, error: null }) // update registration_journal_entry_id
|
enqueue({ data: null, error: null })
|
||||||
enqueue({ data: null, error: null }) // update inbox item
|
enqueue({ data: null, error: null })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/convert', {
|
const request = createMockRequest('/items/item-1/convert', {
|
||||||
@@ -213,25 +217,17 @@ describe('POST /items/:id/convert', () => {
|
|||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
expect(body.data.registration_journal_entry_id).toBe('je-1')
|
expect(body.data.registration_journal_entry_id).toBe('je-1')
|
||||||
expect(createSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
|
expect(createSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
|
||||||
|
|
||||||
// The emitted supplier_invoice.confirmed payload must reflect the just-written
|
|
||||||
// registration_journal_entry_id so the core handler's payload-level guard
|
|
||||||
// short-circuits instead of double-posting.
|
|
||||||
const emitCalls = (ctx.emit as ReturnType<typeof vi.fn>).mock.calls
|
|
||||||
const confirmed = emitCalls.find((c) => c[0].type === 'supplier_invoice.confirmed')
|
|
||||||
expect(confirmed).toBeDefined()
|
|
||||||
expect(confirmed![0].payload.supplierInvoice.registration_journal_entry_id).toBe('je-1')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PATCH /items/:id/reject ──────────────────────────────────
|
// ── DELETE /items/:id ────────────────────────────────────────
|
||||||
|
|
||||||
describe('PATCH /items/:id/reject', () => {
|
describe('DELETE /items/:id', () => {
|
||||||
const route = findRoute('PATCH', '/items/:id/reject')
|
const route = findRoute('DELETE', '/items/:id')
|
||||||
|
|
||||||
it('returns 401 when no context', async () => {
|
it('returns 401 when no context', async () => {
|
||||||
const request = createMockRequest('/items/item-1/reject', {
|
const request = createMockRequest('/items/item-1', {
|
||||||
method: 'PATCH',
|
method: 'DELETE',
|
||||||
searchParams: { _id: 'item-1' },
|
searchParams: { _id: 'item-1' },
|
||||||
})
|
})
|
||||||
const res = await route.handler(request, undefined)
|
const res = await route.handler(request, undefined)
|
||||||
@@ -241,11 +237,11 @@ describe('PATCH /items/:id/reject', () => {
|
|||||||
|
|
||||||
it('returns 404 when item not found', async () => {
|
it('returns 404 when item not found', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: null, error: { message: 'Not found' } })
|
enqueue({ data: null })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/reject', {
|
const request = createMockRequest('/items/item-1', {
|
||||||
method: 'PATCH',
|
method: 'DELETE',
|
||||||
searchParams: { _id: 'item-1' },
|
searchParams: { _id: 'item-1' },
|
||||||
})
|
})
|
||||||
const res = await route.handler(request, ctx)
|
const res = await route.handler(request, ctx)
|
||||||
@@ -253,13 +249,13 @@ describe('PATCH /items/:id/reject', () => {
|
|||||||
expect(status).toBe(404)
|
expect(status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 409 when item already confirmed', async () => {
|
it('returns 409 when item is linked to a supplier invoice', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: { id: 'item-1', status: 'confirmed' } })
|
enqueue({ data: { id: 'item-1', created_supplier_invoice_id: 'inv-1' } })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/reject', {
|
const request = createMockRequest('/items/item-1', {
|
||||||
method: 'PATCH',
|
method: 'DELETE',
|
||||||
searchParams: { _id: 'item-1' },
|
searchParams: { _id: 'item-1' },
|
||||||
})
|
})
|
||||||
const res = await route.handler(request, ctx)
|
const res = await route.handler(request, ctx)
|
||||||
@@ -267,20 +263,19 @@ describe('PATCH /items/:id/reject', () => {
|
|||||||
expect(status).toBe(409)
|
expect(status).toBe(409)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates item status to rejected', async () => {
|
it('deletes a free-standing inbox item', async () => {
|
||||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||||
enqueue({ data: { id: 'item-1', status: 'ready' } }) // fetch
|
enqueue({ data: { id: 'item-1', created_supplier_invoice_id: null } })
|
||||||
enqueue({ data: null, error: null }) // update
|
enqueue({ data: null, error: null })
|
||||||
|
|
||||||
const ctx = buildCtx(supabase)
|
const ctx = buildCtx(supabase)
|
||||||
const request = createMockRequest('/items/item-1/reject', {
|
const request = createMockRequest('/items/item-1', {
|
||||||
method: 'PATCH',
|
method: 'DELETE',
|
||||||
searchParams: { _id: 'item-1' },
|
searchParams: { _id: 'item-1' },
|
||||||
})
|
})
|
||||||
const res = await route.handler(request, ctx)
|
const res = await route.handler(request, ctx)
|
||||||
const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(res)
|
const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(res)
|
||||||
|
|
||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
expect(body.data.status).toBe('rejected')
|
expect(body.data.deleted).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||||
|
|
||||||
|
// Mock pdfjs-dist so we can drive the regex extractors with canned text
|
||||||
|
// without building actual PDF binaries.
|
||||||
|
const mockGetDocument = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
|
||||||
|
getDocument: (...args: unknown[]) => mockGetDocument(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function fakePdf(text: string) {
|
||||||
|
return {
|
||||||
|
promise: Promise.resolve({
|
||||||
|
numPages: 1,
|
||||||
|
getPage: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
getTextContent: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
items: text.split(/\s+/).map((str) => ({ str })),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('extractInvoiceFields', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty result for non-PDF mime type', async () => {
|
||||||
|
const { data, rawText } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from(''),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
fileName: 'foo.png',
|
||||||
|
})
|
||||||
|
expect(rawText).toBeNull()
|
||||||
|
expect(data.totals.total).toBeNull()
|
||||||
|
expect(data.supplier.orgNumber).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty result when pdfjs extracts no text (image-only PDF)', async () => {
|
||||||
|
mockGetDocument.mockReturnValueOnce(fakePdf(''))
|
||||||
|
const { data, rawText } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'scan.pdf',
|
||||||
|
})
|
||||||
|
expect(rawText).toBe('')
|
||||||
|
expect(data.totals.total).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts a Luhn-valid org number', async () => {
|
||||||
|
// 5560125790 is a valid Swedish AB org-nr (Luhn-checked)
|
||||||
|
mockGetDocument.mockReturnValueOnce(fakePdf('Lev: Acme AB Org.nr 556012-5790 Faktura'))
|
||||||
|
const { data } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(data.supplier.orgNumber).toBe('5560125790')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects org-nrs with bad Luhn digit', async () => {
|
||||||
|
mockGetDocument.mockReturnValueOnce(fakePdf('Org.nr 556012-5791'))
|
||||||
|
const { data } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(data.supplier.orgNumber).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts a Luhn-valid OCR reference', async () => {
|
||||||
|
mockGetDocument.mockReturnValueOnce(fakePdf('OCR-nummer: 12345674'))
|
||||||
|
const { data } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(data.invoice.paymentReference).toBe('12345674')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts a Luhn-valid bankgiro', async () => {
|
||||||
|
// 991-2346 is the canonical test bankgiro (Luhn-valid) used in lib/bankgiro/__tests__
|
||||||
|
mockGetDocument.mockReturnValueOnce(fakePdf('Bankgiro 991-2346 Plusgiro'))
|
||||||
|
const { data } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(data.supplier.bankgiro).toBe('991-2346')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses Swedish-formatted totals', async () => {
|
||||||
|
mockGetDocument.mockReturnValueOnce(
|
||||||
|
fakePdf('Att betala 12 345,67 kr')
|
||||||
|
)
|
||||||
|
const { data } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(data.totals.total).toBe(12345.67)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses Förfallodatum and normalizes to ISO', async () => {
|
||||||
|
mockGetDocument.mockReturnValueOnce(fakePdf('Förfallodatum 2026-06-15'))
|
||||||
|
const { data } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(data.invoice.dueDate).toBe('2026-06-15')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts an invoice number after Fakturanr', async () => {
|
||||||
|
mockGetDocument.mockReturnValueOnce(fakePdf('Fakturanr F-2024-001 Datum'))
|
||||||
|
const { data } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(data.invoice.invoiceNumber).toBe('F-2024-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps SEK as default currency when no foreign code is present', async () => {
|
||||||
|
mockGetDocument.mockReturnValueOnce(fakePdf('Total 100 kr'))
|
||||||
|
const { data } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(data.invoice.currency).toBe('SEK')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty result when pdfjs throws', async () => {
|
||||||
|
mockGetDocument.mockImplementationOnce(() => {
|
||||||
|
throw new Error('boom')
|
||||||
|
})
|
||||||
|
const { data, rawText } = await extractInvoiceFields({
|
||||||
|
buffer: Buffer.from('%PDF'),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileName: 'f.pdf',
|
||||||
|
})
|
||||||
|
expect(rawText).toBeNull()
|
||||||
|
expect(data.totals.total).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,637 +0,0 @@
|
|||||||
/**
|
|
||||||
* Document Classification Pipeline
|
|
||||||
*
|
|
||||||
* Pure function: file buffer + mime type → structured classification result.
|
|
||||||
* No database, no side effects. Uses AWS Bedrock (Claude Sonnet) for vision-based
|
|
||||||
* extraction of Swedish financial documents.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
BedrockRuntimeClient,
|
|
||||||
ConverseCommand,
|
|
||||||
type ContentBlock,
|
|
||||||
type ToolConfiguration,
|
|
||||||
type Message,
|
|
||||||
} from '@aws-sdk/client-bedrock-runtime'
|
|
||||||
import sharp from 'sharp'
|
|
||||||
import type {
|
|
||||||
InvoiceExtractionResult,
|
|
||||||
ReceiptExtractionResult,
|
|
||||||
ExtractedLineItem,
|
|
||||||
ExtractedInvoiceLineItem,
|
|
||||||
VatBreakdownItem,
|
|
||||||
} from '@/types'
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type DocumentClassificationType = 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'
|
|
||||||
|
|
||||||
export interface ClassificationInput {
|
|
||||||
fileBuffer: Buffer
|
|
||||||
mimeType: string
|
|
||||||
fileName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClassificationResult {
|
|
||||||
documentType: DocumentClassificationType
|
|
||||||
extractedData: InvoiceExtractionResult | ReceiptExtractionResult | null
|
|
||||||
confidence: number
|
|
||||||
rawResponse: Record<string, unknown>
|
|
||||||
usage: { inputTokens: number; outputTokens: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bedrock client (lazy singleton) ──────────────────────────
|
|
||||||
|
|
||||||
let _client: BedrockRuntimeClient | null = null
|
|
||||||
|
|
||||||
function getClient(): BedrockRuntimeClient {
|
|
||||||
if (!_client) {
|
|
||||||
_client = new BedrockRuntimeClient({
|
|
||||||
region: process.env.AWS_REGION || 'eu-north-1',
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return _client
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Constants ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const VALID_VAT_RATES = [0, 6, 12, 25]
|
|
||||||
|
|
||||||
const MIME_TO_IMAGE_FORMAT: Record<string, string> = {
|
|
||||||
'image/jpeg': 'jpeg',
|
|
||||||
'image/png': 'png',
|
|
||||||
'image/webp': 'webp',
|
|
||||||
'image/gif': 'gif',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bedrock rejects image bytes > 5 MB. Keep headroom under that ceiling.
|
|
||||||
const BEDROCK_IMAGE_BYTE_LIMIT = 4_500_000
|
|
||||||
|
|
||||||
// Shrink an image until it fits Bedrock's 5 MB cap. Steps down the longest edge
|
|
||||||
// and JPEG quality in sequence — preserves legibility of receipt text while
|
|
||||||
// guaranteeing we stay under the limit (or throwing if a photo is so dense it
|
|
||||||
// can't be compressed enough, which in practice never happens below 500px).
|
|
||||||
async function fitImageForBedrock(
|
|
||||||
buffer: Buffer,
|
|
||||||
mimeType: string
|
|
||||||
): Promise<{ buffer: Buffer; format: 'jpeg' | 'png' | 'webp' | 'gif' }> {
|
|
||||||
const originalFormat = MIME_TO_IMAGE_FORMAT[mimeType] as 'jpeg' | 'png' | 'webp' | 'gif'
|
|
||||||
|
|
||||||
if (buffer.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
|
|
||||||
return { buffer, format: originalFormat }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-encode to JPEG while shrinking. PNG at receipt-scale is usually 3-5×
|
|
||||||
// larger than an equivalent JPEG, so JPEG is the right target format even
|
|
||||||
// for PNG input.
|
|
||||||
const dimensionSteps = [2400, 1800, 1400, 1000, 800]
|
|
||||||
const qualitySteps = [85, 75, 60]
|
|
||||||
|
|
||||||
for (const maxDim of dimensionSteps) {
|
|
||||||
for (const quality of qualitySteps) {
|
|
||||||
const candidate = await sharp(buffer)
|
|
||||||
.rotate() // respect EXIF orientation
|
|
||||||
.resize({ width: maxDim, height: maxDim, fit: 'inside', withoutEnlargement: true })
|
|
||||||
.jpeg({ quality, mozjpeg: true })
|
|
||||||
.toBuffer()
|
|
||||||
if (candidate.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
|
|
||||||
return { buffer: candidate, format: 'jpeg' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Bilden kunde inte komprimeras tillräckligt för AI-tolkning.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── System prompt ────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Du är en svensk bokföringsdokumentklassificerare och dataextraktor.
|
|
||||||
|
|
||||||
Du analyserar bilder och PDF:er av finansiella dokument (leverantörsfakturor, kvitton, skattedokument) och extraherar strukturerad data.
|
|
||||||
|
|
||||||
Kontext:
|
|
||||||
- Svensk bokföring enligt Bokföringslagen (BFL) och BFNAR
|
|
||||||
- Giltiga momssatser: 0%, 6%, 12%, 25%
|
|
||||||
- Organisationsnummer: 10 siffror, format XXXXXX-XXXX
|
|
||||||
- Vanliga betalningsmetoder: bankgiro, plusgiro, Swish, banköverföring, kort
|
|
||||||
- Valutor: SEK är standard, men EUR, USD, GBP etc förekommer
|
|
||||||
|
|
||||||
Instruktioner:
|
|
||||||
- Klassificera dokumenttypen
|
|
||||||
- Extrahera alla synliga fält — returnera null för fält som inte kan utläsas
|
|
||||||
- Belopp med max 2 decimaler. Totaler (amount_excl_vat, amount_incl_vat, vat_amount) är alltid positiva. line_items.amount är normalt positiva men KAN vara negativa för rabattrader.
|
|
||||||
- Datum i ISO-format (YYYY-MM-DD)
|
|
||||||
- Momssats som heltal (0, 6, 12, eller 25)
|
|
||||||
- Ge en confidence-poäng 0-100 för hur säker du är på klassificeringen och extraktionen
|
|
||||||
|
|
||||||
KRITISKT — Totaler och rabatter:
|
|
||||||
- amount_incl_vat MÅSTE motsvara fakturans slutbelopp ("Amount due", "Att betala", "Totalt", "Subtotal" när moms saknas). Summera ALDRIG delrader om fakturan anger ett explicit totalbelopp — använd det.
|
|
||||||
- Om en rad har en rabatt/discount under sig (t.ex. "Discount (-$10.00)", "Rabatt -100 kr"), extrahera radens NETTO-belopp (brutto − rabatt), INTE bruttobeloppet. Exempel: en rad "Compute Hours $50.68" följd av "Discount -$10.00" → line_items.amount = 40.68, inte 50.68.
|
|
||||||
- Summan av line_items.amount + moms MÅSTE bli lika med amount_incl_vat. Om det inte stämmer har du antingen missat en rabatt eller dubbelräknat en rad — kontrollera och justera.
|
|
||||||
- Om du är osäker på hur rabatter ska fördelas, lägg en enskild negativ "Rabatt"-rad i line_items så summan blir rätt.
|
|
||||||
|
|
||||||
Anropa ALLTID verktyget classify_document med resultatet.`
|
|
||||||
|
|
||||||
// ── Tool schema for structured output ────────────────────────
|
|
||||||
|
|
||||||
const CLASSIFICATION_TOOL: ToolConfiguration = {
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
toolSpec: {
|
|
||||||
name: 'classify_document',
|
|
||||||
description: 'Klassificera och extrahera data från ett svenskt finansiellt dokument',
|
|
||||||
inputSchema: {
|
|
||||||
json: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['document_type', 'confidence'],
|
|
||||||
properties: {
|
|
||||||
document_type: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['supplier_invoice', 'receipt', 'government_letter', 'unknown'],
|
|
||||||
description: 'Typ av dokument',
|
|
||||||
},
|
|
||||||
confidence: {
|
|
||||||
type: 'integer',
|
|
||||||
minimum: 0,
|
|
||||||
maximum: 100,
|
|
||||||
description: 'Säkerhet i klassificeringen (0-100)',
|
|
||||||
},
|
|
||||||
// Supplier invoice fields
|
|
||||||
supplier_name: { type: ['string', 'null'] },
|
|
||||||
supplier_org_number: { type: ['string', 'null'] },
|
|
||||||
supplier_vat_number: { type: ['string', 'null'] },
|
|
||||||
supplier_address: { type: ['string', 'null'] },
|
|
||||||
supplier_bankgiro: { type: ['string', 'null'] },
|
|
||||||
supplier_plusgiro: { type: ['string', 'null'] },
|
|
||||||
invoice_number: { type: ['string', 'null'] },
|
|
||||||
invoice_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
|
|
||||||
due_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
|
|
||||||
payment_reference: { type: ['string', 'null'], description: 'OCR-nummer eller betalningsreferens' },
|
|
||||||
currency: { type: ['string', 'null'], description: 'ISO 4217 (t.ex. SEK, EUR)' },
|
|
||||||
// Receipt fields
|
|
||||||
merchant_name: { type: ['string', 'null'] },
|
|
||||||
merchant_org_number: { type: ['string', 'null'] },
|
|
||||||
merchant_vat_number: { type: ['string', 'null'] },
|
|
||||||
merchant_is_foreign: { type: ['boolean', 'null'] },
|
|
||||||
receipt_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
|
|
||||||
receipt_time: { type: ['string', 'null'], description: 'HH:MM' },
|
|
||||||
is_restaurant: { type: ['boolean', 'null'] },
|
|
||||||
is_systembolaget: { type: ['boolean', 'null'] },
|
|
||||||
// Shared amount fields
|
|
||||||
amount_excl_vat: { type: ['number', 'null'] },
|
|
||||||
amount_incl_vat: { type: ['number', 'null'] },
|
|
||||||
vat_amount: { type: ['number', 'null'] },
|
|
||||||
// Line items
|
|
||||||
line_items: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
description: { type: 'string' },
|
|
||||||
quantity: { type: ['number', 'null'] },
|
|
||||||
unit_price: { type: ['number', 'null'] },
|
|
||||||
amount: { type: ['number', 'null'] },
|
|
||||||
vat_rate: { type: ['integer', 'null'], description: '0, 6, 12, or 25' },
|
|
||||||
},
|
|
||||||
required: ['description'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// VAT breakdown (for invoices)
|
|
||||||
vat_breakdown: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
rate: { type: 'integer' },
|
|
||||||
base: { type: 'number' },
|
|
||||||
amount: { type: 'number' },
|
|
||||||
},
|
|
||||||
required: ['rate', 'base', 'amount'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
toolChoice: { any: {} },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Core classification function ─────────────────────────────
|
|
||||||
|
|
||||||
export async function classifyDocument(input: ClassificationInput): Promise<ClassificationResult> {
|
|
||||||
const contentBlock = await buildContentBlock(input)
|
|
||||||
|
|
||||||
const messages: Message[] = [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
contentBlock,
|
|
||||||
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
|
||||||
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
|
|
||||||
|
|
||||||
const command = new ConverseCommand({
|
|
||||||
modelId,
|
|
||||||
messages,
|
|
||||||
system: [{ text: SYSTEM_PROMPT }],
|
|
||||||
toolConfig: CLASSIFICATION_TOOL,
|
|
||||||
inferenceConfig: { maxTokens },
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await getClient().send(command)
|
|
||||||
|
|
||||||
// Extract tool use result from response
|
|
||||||
const outputMessage = response.output?.message
|
|
||||||
if (!outputMessage?.content) {
|
|
||||||
throw new Error('No content in Bedrock response')
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolUseBlock = outputMessage.content.find(
|
|
||||||
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!toolUseBlock?.toolUse?.input) {
|
|
||||||
throw new Error('No tool use result in Bedrock response')
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
|
|
||||||
const usage = {
|
|
||||||
inputTokens: response.usage?.inputTokens ?? 0,
|
|
||||||
outputTokens: response.usage?.outputTokens ?? 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate and map to typed result
|
|
||||||
const result = mapToClassificationResult(rawData, usage)
|
|
||||||
|
|
||||||
// If validation found issues, retry once with correction
|
|
||||||
if (!result) {
|
|
||||||
return retryWithCorrection(input, rawData, usage)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Content block builder ────────────────────────────────────
|
|
||||||
|
|
||||||
async function buildContentBlock(input: ClassificationInput): Promise<ContentBlock> {
|
|
||||||
const { fileBuffer, mimeType } = input
|
|
||||||
|
|
||||||
// PDF → document block
|
|
||||||
if (mimeType === 'application/pdf') {
|
|
||||||
return {
|
|
||||||
document: {
|
|
||||||
format: 'pdf',
|
|
||||||
name: sanitizeDocName(input.fileName),
|
|
||||||
source: {
|
|
||||||
bytes: new Uint8Array(fileBuffer),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HEIC → convert to JPEG via sharp, then fit to Bedrock's byte limit
|
|
||||||
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
|
|
||||||
const jpegBuffer = await sharp(fileBuffer).rotate().jpeg({ quality: 90, mozjpeg: true }).toBuffer()
|
|
||||||
const fitted = await fitImageForBedrock(jpegBuffer, 'image/jpeg')
|
|
||||||
return {
|
|
||||||
image: {
|
|
||||||
format: fitted.format,
|
|
||||||
source: { bytes: new Uint8Array(fitted.buffer) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard image formats — downscale if the buffer exceeds Bedrock's 5 MB cap
|
|
||||||
const imageFormat = MIME_TO_IMAGE_FORMAT[mimeType]
|
|
||||||
if (imageFormat) {
|
|
||||||
const fitted = await fitImageForBedrock(fileBuffer, mimeType)
|
|
||||||
return {
|
|
||||||
image: {
|
|
||||||
format: fitted.format,
|
|
||||||
source: { bytes: new Uint8Array(fitted.buffer) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported MIME type: ${mimeType}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sanitize filename for Bedrock document name (alphanumeric, spaces, hyphens, brackets only) */
|
|
||||||
function sanitizeDocName(fileName: string): string {
|
|
||||||
const name = fileName.replace(/\.[^.]+$/, '') // strip extension
|
|
||||||
return name.replace(/[^a-zA-Z0-9\s\-\[\]\(\)åäöÅÄÖ]/g, '').trim() || 'document'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Response mapping + validation ────────────────────────────
|
|
||||||
|
|
||||||
function mapToClassificationResult(
|
|
||||||
raw: Record<string, unknown>,
|
|
||||||
usage: { inputTokens: number; outputTokens: number }
|
|
||||||
): ClassificationResult | null {
|
|
||||||
const documentType = raw.document_type as DocumentClassificationType
|
|
||||||
const confidence = Math.min(100, Math.max(0, Number(raw.confidence) || 0))
|
|
||||||
|
|
||||||
if (!['supplier_invoice', 'receipt', 'government_letter', 'unknown'].includes(documentType)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (documentType === 'government_letter' || documentType === 'unknown') {
|
|
||||||
return {
|
|
||||||
documentType,
|
|
||||||
extractedData: null,
|
|
||||||
confidence,
|
|
||||||
rawResponse: raw,
|
|
||||||
usage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (documentType === 'supplier_invoice') {
|
|
||||||
const extractedData = mapToInvoiceExtraction(raw)
|
|
||||||
if (!extractedData) return null
|
|
||||||
// mapToInvoiceExtraction caps its own confidence to 50% when line items
|
|
||||||
// don't reconcile with amount_incl_vat. Propagate that cap to the outer
|
|
||||||
// confidence so invoice_inbox_items.confidence (used by the UI badge)
|
|
||||||
// also reflects the reconciliation failure.
|
|
||||||
const effectiveConfidence = Math.min(confidence, Math.round(extractedData.confidence * 100))
|
|
||||||
return { documentType, extractedData, confidence: effectiveConfidence, rawResponse: raw, usage }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (documentType === 'receipt') {
|
|
||||||
const extractedData = mapToReceiptExtraction(raw)
|
|
||||||
if (!extractedData) return null
|
|
||||||
return { documentType, extractedData, confidence, rawResponse: raw, usage }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sum of line items must approximately match the extracted subtotal.
|
|
||||||
// Allows 0.02 * max(|subtotal|, 1) tolerance for rounding. Returns true if the extraction
|
|
||||||
// is internally consistent; false signals the model skipped a discount or double-counted.
|
|
||||||
function invoiceTotalsAreConsistent(raw: Record<string, unknown>): boolean {
|
|
||||||
const subtotal = Number(raw.amount_excl_vat)
|
|
||||||
const total = Number(raw.amount_incl_vat)
|
|
||||||
const vat = Number(raw.vat_amount) || 0
|
|
||||||
const items = Array.isArray(raw.line_items) ? raw.line_items : []
|
|
||||||
if (!items.length) return true // nothing to compare against
|
|
||||||
if (!isFinite(subtotal) && !isFinite(total)) return true
|
|
||||||
|
|
||||||
const sumOfLines = items.reduce((acc, item) => {
|
|
||||||
if (typeof item !== 'object' || item === null) return acc
|
|
||||||
const amount = Number((item as Record<string, unknown>).amount)
|
|
||||||
return acc + (isFinite(amount) ? amount : 0)
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
const anchor = isFinite(subtotal) ? subtotal : total - vat
|
|
||||||
const tolerance = Math.max(0.02, Math.abs(anchor) * 0.02)
|
|
||||||
return Math.abs(sumOfLines - anchor) <= tolerance
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapToInvoiceExtraction(raw: Record<string, unknown>): InvoiceExtractionResult | null {
|
|
||||||
const lineItems = mapInvoiceLineItems(raw.line_items)
|
|
||||||
const vatBreakdown = mapVatBreakdown(raw.vat_breakdown)
|
|
||||||
const totalsConsistent = invoiceTotalsAreConsistent(raw)
|
|
||||||
|
|
||||||
const result: InvoiceExtractionResult = {
|
|
||||||
supplier: {
|
|
||||||
name: strOrNull(raw.supplier_name),
|
|
||||||
orgNumber: strOrNull(raw.supplier_org_number),
|
|
||||||
vatNumber: strOrNull(raw.supplier_vat_number),
|
|
||||||
address: strOrNull(raw.supplier_address),
|
|
||||||
bankgiro: strOrNull(raw.supplier_bankgiro),
|
|
||||||
plusgiro: strOrNull(raw.supplier_plusgiro),
|
|
||||||
},
|
|
||||||
invoice: {
|
|
||||||
invoiceNumber: strOrNull(raw.invoice_number),
|
|
||||||
invoiceDate: dateOrNull(raw.invoice_date),
|
|
||||||
dueDate: dateOrNull(raw.due_date),
|
|
||||||
paymentReference: strOrNull(raw.payment_reference),
|
|
||||||
currency: strOrNull(raw.currency) || 'SEK',
|
|
||||||
},
|
|
||||||
lineItems,
|
|
||||||
totals: {
|
|
||||||
subtotal: roundAmount(raw.amount_excl_vat),
|
|
||||||
vatAmount: roundAmount(raw.vat_amount),
|
|
||||||
total: roundAmount(raw.amount_incl_vat),
|
|
||||||
},
|
|
||||||
vatBreakdown,
|
|
||||||
// If totals don't reconcile with line items, cap confidence at 50% so the UI flags it
|
|
||||||
confidence: (() => {
|
|
||||||
const raw_conf = Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0))
|
|
||||||
return totalsConsistent ? raw_conf : Math.min(raw_conf, 0.5)
|
|
||||||
})(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapToReceiptExtraction(raw: Record<string, unknown>): ReceiptExtractionResult | null {
|
|
||||||
const lineItems = mapReceiptLineItems(raw.line_items)
|
|
||||||
|
|
||||||
const result: ReceiptExtractionResult = {
|
|
||||||
merchant: {
|
|
||||||
name: strOrNull(raw.merchant_name),
|
|
||||||
orgNumber: strOrNull(raw.merchant_org_number),
|
|
||||||
vatNumber: strOrNull(raw.merchant_vat_number),
|
|
||||||
isForeign: Boolean(raw.merchant_is_foreign),
|
|
||||||
},
|
|
||||||
receipt: {
|
|
||||||
date: dateOrNull(raw.receipt_date),
|
|
||||||
time: strOrNull(raw.receipt_time),
|
|
||||||
currency: strOrNull(raw.currency) || 'SEK',
|
|
||||||
},
|
|
||||||
lineItems,
|
|
||||||
totals: {
|
|
||||||
subtotal: roundAmount(raw.amount_excl_vat),
|
|
||||||
vatAmount: roundAmount(raw.vat_amount),
|
|
||||||
total: roundAmount(raw.amount_incl_vat),
|
|
||||||
},
|
|
||||||
flags: {
|
|
||||||
isRestaurant: Boolean(raw.is_restaurant),
|
|
||||||
isSystembolaget: Boolean(raw.is_systembolaget),
|
|
||||||
isForeignMerchant: Boolean(raw.merchant_is_foreign),
|
|
||||||
},
|
|
||||||
confidence: Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0)),
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Line item mappers ────────────────────────────────────────
|
|
||||||
|
|
||||||
function mapInvoiceLineItems(items: unknown): ExtractedInvoiceLineItem[] {
|
|
||||||
if (!Array.isArray(items)) return []
|
|
||||||
return items
|
|
||||||
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
||||||
.map((item) => ({
|
|
||||||
description: String(item.description || ''),
|
|
||||||
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
|
|
||||||
unitPrice: roundAmount(item.unit_price),
|
|
||||||
lineTotal: roundAmount(item.amount) ?? 0,
|
|
||||||
vatRate: validateVatRate(item.vat_rate),
|
|
||||||
accountSuggestion: null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapReceiptLineItems(items: unknown): ExtractedLineItem[] {
|
|
||||||
if (!Array.isArray(items)) return []
|
|
||||||
return items
|
|
||||||
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
||||||
.map((item) => ({
|
|
||||||
description: String(item.description || ''),
|
|
||||||
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
|
|
||||||
unitPrice: roundAmount(item.unit_price),
|
|
||||||
lineTotal: roundAmount(item.amount) ?? 0,
|
|
||||||
vatRate: validateVatRate(item.vat_rate),
|
|
||||||
suggestedCategory: null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapVatBreakdown(items: unknown): VatBreakdownItem[] {
|
|
||||||
if (!Array.isArray(items)) return []
|
|
||||||
return items
|
|
||||||
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
||||||
.filter((item) => VALID_VAT_RATES.includes(Number(item.rate)))
|
|
||||||
.map((item) => ({
|
|
||||||
rate: Number(item.rate),
|
|
||||||
base: Math.round(Number(item.base || 0) * 100) / 100,
|
|
||||||
amount: Math.round(Number(item.amount || 0) * 100) / 100,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Retry with correction ────────────────────────────────────
|
|
||||||
|
|
||||||
async function retryWithCorrection(
|
|
||||||
input: ClassificationInput,
|
|
||||||
previousResult: Record<string, unknown>,
|
|
||||||
previousUsage: { inputTokens: number; outputTokens: number }
|
|
||||||
): Promise<ClassificationResult> {
|
|
||||||
const contentBlock = await buildContentBlock(input)
|
|
||||||
|
|
||||||
const messages: Message[] = [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
contentBlock,
|
|
||||||
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
toolUse: {
|
|
||||||
toolUseId: 'retry_1',
|
|
||||||
name: 'classify_document',
|
|
||||||
input: previousResult as Record<string, unknown>,
|
|
||||||
} as ContentBlock.ToolUseMember['toolUse'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
toolResult: {
|
|
||||||
toolUseId: 'retry_1',
|
|
||||||
status: 'error',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
text: `Valideringen misslyckades. Kontrollera:
|
|
||||||
- document_type måste vara ett av: supplier_invoice, receipt, government_letter, unknown
|
|
||||||
- Datum i format YYYY-MM-DD
|
|
||||||
- Momssatser måste vara 0, 6, 12, eller 25
|
|
||||||
- Totaler: amount_incl_vat ska vara fakturans slutbelopp. Summa av line_items.amount + vat_amount MÅSTE bli lika med amount_incl_vat. Om rader har rabatt under sig, använd NETTO-beloppet per rad, eller lägg till en separat negativ rabattrad så summan stämmer.
|
|
||||||
Försök igen med korrigerad data.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
|
||||||
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const command = new ConverseCommand({
|
|
||||||
modelId,
|
|
||||||
messages,
|
|
||||||
system: [{ text: SYSTEM_PROMPT }],
|
|
||||||
toolConfig: CLASSIFICATION_TOOL,
|
|
||||||
inferenceConfig: { maxTokens },
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await getClient().send(command)
|
|
||||||
const outputMessage = response.output?.message
|
|
||||||
const toolUseBlock = outputMessage?.content?.find(
|
|
||||||
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!toolUseBlock?.toolUse?.input) {
|
|
||||||
throw new Error('No tool use in retry response')
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
|
|
||||||
const retryUsage = {
|
|
||||||
inputTokens: previousUsage.inputTokens + (response.usage?.inputTokens ?? 0),
|
|
||||||
outputTokens: previousUsage.outputTokens + (response.usage?.outputTokens ?? 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = mapToClassificationResult(rawData, retryUsage)
|
|
||||||
if (result) return result
|
|
||||||
} catch {
|
|
||||||
// Retry failed — fall through to error return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both attempts failed — return error result with raw data
|
|
||||||
return {
|
|
||||||
documentType: 'unknown',
|
|
||||||
extractedData: null,
|
|
||||||
confidence: 0,
|
|
||||||
rawResponse: previousResult,
|
|
||||||
usage: previousUsage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Utility helpers ──────────────────────────────────────────
|
|
||||||
|
|
||||||
function strOrNull(val: unknown): string | null {
|
|
||||||
if (typeof val === 'string' && val.trim().length > 0) return val.trim()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateOrNull(val: unknown): string | null {
|
|
||||||
if (typeof val !== 'string') return null
|
|
||||||
// Validate ISO date format YYYY-MM-DD
|
|
||||||
const match = val.match(/^\d{4}-\d{2}-\d{2}$/)
|
|
||||||
if (!match) return null
|
|
||||||
const d = new Date(val)
|
|
||||||
if (isNaN(d.getTime())) return null
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundAmount(val: unknown): number | null {
|
|
||||||
if (val === null || val === undefined) return null
|
|
||||||
const n = Number(val)
|
|
||||||
if (isNaN(n)) return null
|
|
||||||
return Math.round(n * 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateVatRate(val: unknown): number | null {
|
|
||||||
if (val === null || val === undefined) return null
|
|
||||||
const n = Number(val)
|
|
||||||
if (VALID_VAT_RATES.includes(n)) return n
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Maps raw AWS Bedrock / infrastructure errors to Swedish user-facing sentences
|
|
||||||
* for the invoice-inbox error_message column. We keep this local to the
|
|
||||||
* extension rather than in lib/errors so the patterns can evolve with the
|
|
||||||
* Bedrock SDK without churning the shared helper.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PATTERNS: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
|
|
||||||
[
|
|
||||||
/image exceeds 5 MB maximum: (\d+) bytes/i,
|
|
||||||
(m) => {
|
|
||||||
const mb = (Number(m[1]) / 1024 / 1024).toFixed(1)
|
|
||||||
return `Bilden är för stor för AI-tolkning (${mb} MB, max 5 MB). Skicka ett mindre foto eller en PDF.`
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/image exceeds .+ maximum/i,
|
|
||||||
() => 'Bilden är för stor för AI-tolkning. Skicka ett mindre foto eller en PDF.',
|
|
||||||
],
|
|
||||||
[/ThrottlingException|TooManyRequestsException|Rate exceeded/i, () => 'AI-tjänsten är överbelastad just nu. Försök igen om en stund.'],
|
|
||||||
[/AccessDeniedException/i, () => 'Åtkomst till AI-tjänsten nekades. Kontakta support.'],
|
|
||||||
[/ValidationException.+modelId/i, () => 'AI-modellen är felkonfigurerad. Kontakta support.'],
|
|
||||||
[/InternalServerException|ServiceUnavailable/i, () => 'AI-tjänsten är tillfälligt otillgänglig. Försök igen om en stund.'],
|
|
||||||
[/Unsupported MIME type: (.+)/i, (m) => `Filformatet stöds inte (${m[1]}). Använd PDF, JPEG, PNG, HEIC eller WebP.`],
|
|
||||||
[/No content in Bedrock response|No tool use result in Bedrock response/i, () => 'AI-tjänsten svarade inte med strukturerad data. Försök igen.'],
|
|
||||||
[/Failed to fetch received email/i, () => 'Kunde inte hämta e-postmeddelandet från inkorgstjänsten. Försök igen.'],
|
|
||||||
[/Failed to fetch attachment|Download URL returned/i, () => 'Kunde inte ladda ner bilagan från inkorgstjänsten.'],
|
|
||||||
]
|
|
||||||
|
|
||||||
export function toSwedishInboxError(raw: unknown): string {
|
|
||||||
const message = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : 'Okänt fel'
|
|
||||||
|
|
||||||
for (const [pattern, build] of PATTERNS) {
|
|
||||||
const match = message.match(pattern)
|
|
||||||
if (match) return build(match)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve any message that's already Swedish (heuristic: contains å/ä/ö
|
|
||||||
// or a known Swedish word). Otherwise surface a generic fallback and log
|
|
||||||
// the technical detail through stderr rather than the user's screen.
|
|
||||||
if (/[åäö]|bild|faktura|inkorg|leverant/i.test(message)) {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return 'Kunde inte bearbeta dokumentet. Försök igen eller kontakta support.'
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// Deterministic Swedish invoice field extraction.
|
||||||
|
//
|
||||||
|
// Replaces the deleted AI classifier. We pull text out of the PDF with
|
||||||
|
// pdfjs-dist and run regex extractors against it. Each extractor is
|
||||||
|
// independent — a missing field stays null rather than dragging down a
|
||||||
|
// neighbour. Validators (Luhn for org-nr/OCR/bankgiro) keep false
|
||||||
|
// positives near zero.
|
||||||
|
//
|
||||||
|
// Image-only PDFs and non-PDF mime types come back with all fields null.
|
||||||
|
// The inbox item is still created so the user can register manually.
|
||||||
|
|
||||||
|
import type { InvoiceExtractionResult } from '@/types'
|
||||||
|
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||||
|
import { validateOcrReference, validateBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||||
|
|
||||||
|
// Below this we treat the document as image-only / unreadable and skip
|
||||||
|
// regex extraction. pdfjs-dist returns near-zero text for scanned PDFs.
|
||||||
|
const MIN_TEXT_CHARS_FOR_EXTRACTION = 10
|
||||||
|
|
||||||
|
export interface ExtractionInput {
|
||||||
|
buffer: Buffer
|
||||||
|
mimeType: string
|
||||||
|
fileName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractionOutput {
|
||||||
|
data: InvoiceExtractionResult
|
||||||
|
/** Pulled from the PDF; null when the file isn't a text-based PDF. */
|
||||||
|
rawText: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract invoice fields from a PDF buffer. Returns an InvoiceExtractionResult
|
||||||
|
* whether or not anything matched — empty fields are null, lineItems is [],
|
||||||
|
* and totals are null. Never throws on parse failure (returns empty result).
|
||||||
|
*/
|
||||||
|
export async function extractInvoiceFields(input: ExtractionInput): Promise<ExtractionOutput> {
|
||||||
|
const text = await tryExtractPdfText(input)
|
||||||
|
|
||||||
|
if (!text || text.length < MIN_TEXT_CHARS_FOR_EXTRACTION) {
|
||||||
|
return { data: emptyResult(), rawText: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: InvoiceExtractionResult = {
|
||||||
|
supplier: {
|
||||||
|
name: extractSupplierName(text),
|
||||||
|
orgNumber: extractOrgNumber(text),
|
||||||
|
vatNumber: extractVatNumber(text),
|
||||||
|
address: null,
|
||||||
|
bankgiro: extractBankgiro(text),
|
||||||
|
plusgiro: extractPlusgiro(text),
|
||||||
|
},
|
||||||
|
invoice: {
|
||||||
|
invoiceNumber: extractInvoiceNumber(text),
|
||||||
|
invoiceDate: extractDate(text, /faktura(?:datum|date)|utfärdat/i),
|
||||||
|
dueDate: extractDate(text, /förfallo(?:datum|dag)|due\s*date|betala\s*senast/i),
|
||||||
|
paymentReference: extractOcrReference(text),
|
||||||
|
currency: extractCurrency(text),
|
||||||
|
},
|
||||||
|
lineItems: [],
|
||||||
|
totals: extractTotals(text),
|
||||||
|
vatBreakdown: extractVatBreakdown(text),
|
||||||
|
confidence: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, rawText: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyResult(): InvoiceExtractionResult {
|
||||||
|
return {
|
||||||
|
supplier: {
|
||||||
|
name: null,
|
||||||
|
orgNumber: null,
|
||||||
|
vatNumber: null,
|
||||||
|
address: null,
|
||||||
|
bankgiro: null,
|
||||||
|
plusgiro: null,
|
||||||
|
},
|
||||||
|
invoice: {
|
||||||
|
invoiceNumber: null,
|
||||||
|
invoiceDate: null,
|
||||||
|
dueDate: null,
|
||||||
|
paymentReference: null,
|
||||||
|
currency: 'SEK',
|
||||||
|
},
|
||||||
|
lineItems: [],
|
||||||
|
totals: { subtotal: null, vatAmount: null, total: null },
|
||||||
|
vatBreakdown: [],
|
||||||
|
confidence: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF text extraction ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function tryExtractPdfText(input: ExtractionInput): Promise<string | null> {
|
||||||
|
if (input.mimeType !== 'application/pdf') return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||||
|
const loadingTask = pdfjs.getDocument({
|
||||||
|
data: new Uint8Array(input.buffer),
|
||||||
|
isEvalSupported: false,
|
||||||
|
disableFontFace: true,
|
||||||
|
})
|
||||||
|
const pdf = await loadingTask.promise
|
||||||
|
|
||||||
|
const pages: string[] = []
|
||||||
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
|
const page = await pdf.getPage(i)
|
||||||
|
const content = await page.getTextContent()
|
||||||
|
const pageText = content.items
|
||||||
|
.map((item) => ('str' in item ? item.str : ''))
|
||||||
|
.join(' ')
|
||||||
|
pages.push(pageText)
|
||||||
|
}
|
||||||
|
return pages.join('\n').replace(/[ \t]+/g, ' ').trim()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[invoice-inbox/extract] pdfjs failed:', err instanceof Error ? err.message : err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field extractors ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractOrgNumber(text: string): string | null {
|
||||||
|
const candidates = text.match(/\b\d{6}-?\d{4}\b/g) ?? []
|
||||||
|
for (const c of candidates) {
|
||||||
|
const normalized = normalizeOrgNumber(c)
|
||||||
|
if (normalized) return normalized
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVatNumber(text: string): string | null {
|
||||||
|
const m = text.match(/\bSE\d{10}\d{2}\b/i)
|
||||||
|
return m ? m[0].toUpperCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOcrReference(text: string): string | null {
|
||||||
|
// Anchor on "OCR" / "Referens" / "Bet.ref" labels; widen to any digit
|
||||||
|
// run on the same logical line if no labelled hit found.
|
||||||
|
const labelled = text.match(
|
||||||
|
/(?:OCR(?:-?nummer)?|Referens(?:nummer)?|Bet\.?\s*ref\.?|Betalningsreferens)[^\d\n]{0,40}(\d[\d\s]{3,30}\d)/i
|
||||||
|
)
|
||||||
|
if (labelled) {
|
||||||
|
const digits = labelled[1].replace(/\s/g, '')
|
||||||
|
if (validateOcrReference(digits)) return digits
|
||||||
|
}
|
||||||
|
// Fallback: look for any standalone digit run that passes Luhn (4-25 digits)
|
||||||
|
const candidates = text.match(/\b\d{4,25}\b/g) ?? []
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (validateOcrReference(c)) return c
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBankgiro(text: string): string | null {
|
||||||
|
const labelled = text.match(/Bankgiro(?:nr)?[^\d\n]{0,20}(\d{3,4}-?\d{4})/i)
|
||||||
|
if (labelled && validateBankgiroNumber(labelled[1])) {
|
||||||
|
return labelled[1].includes('-') ? labelled[1] : insertBankgiroHyphen(labelled[1])
|
||||||
|
}
|
||||||
|
// Fallback: any 7-8 digit number with hyphen that passes Luhn
|
||||||
|
const candidates = text.match(/\b\d{3,4}-\d{4}\b/g) ?? []
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (validateBankgiroNumber(c)) return c
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBankgiroHyphen(digits: string): string {
|
||||||
|
if (digits.length === 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`
|
||||||
|
if (digits.length === 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPlusgiro(text: string): string | null {
|
||||||
|
const m = text.match(/Plusgiro(?:nr)?[^\d\n]{0,20}(\d{1,8}-\d)/i)
|
||||||
|
return m ? m[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInvoiceNumber(text: string): string | null {
|
||||||
|
const m = text.match(
|
||||||
|
/(?:Faktura(?:nr|nummer)?|Invoice\s*(?:no|number|#))[^\w\n]{0,8}([A-Z0-9][A-Z0-9\-/]{2,20})/i
|
||||||
|
)
|
||||||
|
return m ? m[1].trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDate(text: string, anchor: RegExp): string | null {
|
||||||
|
// Look for a date within ~40 chars of the anchor
|
||||||
|
const re = new RegExp(
|
||||||
|
`(?:${anchor.source})[^\\d\\n]{0,40}(\\d{4}[-/.]\\d{1,2}[-/.]\\d{1,2}|\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{4})`,
|
||||||
|
'i'
|
||||||
|
)
|
||||||
|
const m = text.match(re)
|
||||||
|
if (!m) return null
|
||||||
|
return normalizeDate(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(raw: string): string | null {
|
||||||
|
const sep = raw.match(/[-/.]/)
|
||||||
|
if (!sep) return null
|
||||||
|
const parts = raw.split(/[-/.]/).map((p) => p.trim())
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
let yyyy: string, mm: string, dd: string
|
||||||
|
if (parts[0].length === 4) {
|
||||||
|
[yyyy, mm, dd] = parts
|
||||||
|
} else if (parts[2].length === 4) {
|
||||||
|
[dd, mm, yyyy] = parts
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const m = mm.padStart(2, '0')
|
||||||
|
const d = dd.padStart(2, '0')
|
||||||
|
if (!/^\d{4}$/.test(yyyy) || !/^\d{2}$/.test(m) || !/^\d{2}$/.test(d)) return null
|
||||||
|
// Sanity check
|
||||||
|
const month = parseInt(m, 10)
|
||||||
|
const day = parseInt(d, 10)
|
||||||
|
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||||
|
return `${yyyy}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCurrency(text: string): string {
|
||||||
|
// Default SEK; only switch if a 3-letter currency code appears with an amount nearby
|
||||||
|
const m = text.match(/\b(EUR|USD|GBP|NOK|DKK|CHF)\b/i)
|
||||||
|
return m ? m[1].toUpperCase() : 'SEK'
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTotals(text: string): { subtotal: number | null; vatAmount: number | null; total: number | null } {
|
||||||
|
const total = findAmountNear(text, /(?:Att\s*betala|Totalt\s*att\s*betala|Summa\s*att\s*betala|Total(?:summa)?|Belopp\s*att\s*betala)/i)
|
||||||
|
const vatAmount = findAmountNear(text, /(?:Total\s*moms|Moms(?:\s*totalt)?|VAT(?:\s*total)?)/i)
|
||||||
|
const subtotal = findAmountNear(text, /(?:Netto(?:summa)?|Subtotal|Summa\s*excl(?:\.|usive)?\s*moms|Belopp\s*excl(?:\.|usive)?\s*moms)/i)
|
||||||
|
return { subtotal, vatAmount, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAmountNear(text: string, anchor: RegExp): number | null {
|
||||||
|
const re = new RegExp(`(?:${anchor.source})[^\\d\\n-]{0,60}([0-9][\\d\\s.,]*[0-9])`, 'i')
|
||||||
|
const m = text.match(re)
|
||||||
|
if (!m) return null
|
||||||
|
return parseSwedishAmount(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSwedishAmount(raw: string): number | null {
|
||||||
|
// Swedish uses space as thousands sep and comma as decimal: "1 234,56".
|
||||||
|
// Also tolerate "1,234.56" (international) and "1234.56".
|
||||||
|
const cleaned = raw.replace(/\s/g, '')
|
||||||
|
let normalized: string
|
||||||
|
if (/,/.test(cleaned) && /\./.test(cleaned)) {
|
||||||
|
// Both present — assume thousands+decimal. Decide by last separator.
|
||||||
|
const lastComma = cleaned.lastIndexOf(',')
|
||||||
|
const lastDot = cleaned.lastIndexOf('.')
|
||||||
|
if (lastComma > lastDot) {
|
||||||
|
normalized = cleaned.replace(/\./g, '').replace(',', '.')
|
||||||
|
} else {
|
||||||
|
normalized = cleaned.replace(/,/g, '')
|
||||||
|
}
|
||||||
|
} else if (/,/.test(cleaned)) {
|
||||||
|
// Only comma — Swedish decimal
|
||||||
|
normalized = cleaned.replace(',', '.')
|
||||||
|
} else {
|
||||||
|
normalized = cleaned
|
||||||
|
}
|
||||||
|
const n = parseFloat(normalized)
|
||||||
|
return Number.isFinite(n) ? Math.round(n * 100) / 100 : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVatBreakdown(text: string): Array<{ rate: number; base: number; amount: number }> {
|
||||||
|
const out: Array<{ rate: number; base: number; amount: number }> = []
|
||||||
|
// Match patterns like "Moms 25% 800,00 200,00" or "25% moms 200,00"
|
||||||
|
const lineRe = /(?:Moms\s*)?(\d{1,2})\s*%[^\n\d-]{0,30}([0-9][\d\s.,]*[0-9])(?:[^\n\d-]{0,30}([0-9][\d\s.,]*[0-9]))?/gi
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = lineRe.exec(text)) !== null) {
|
||||||
|
const rate = parseInt(m[1], 10)
|
||||||
|
if (![25, 12, 6, 0].includes(rate)) continue
|
||||||
|
const a = parseSwedishAmount(m[2])
|
||||||
|
const b = m[3] ? parseSwedishAmount(m[3]) : null
|
||||||
|
if (a == null) continue
|
||||||
|
// Two amounts: base then VAT amount. One amount: just VAT, derive base.
|
||||||
|
if (b != null) {
|
||||||
|
out.push({ rate, base: a, amount: b })
|
||||||
|
} else if (rate > 0) {
|
||||||
|
const base = Math.round((a / (rate / 100)) * 100) / 100
|
||||||
|
out.push({ rate, base, amount: a })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dedup by rate (keep first hit)
|
||||||
|
const seen = new Set<number>()
|
||||||
|
return out.filter((row) => {
|
||||||
|
if (seen.has(row.rate)) return false
|
||||||
|
seen.add(row.rate)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSupplierName(text: string): string | null {
|
||||||
|
// Heuristic: first non-blank, non-numeric line in the first 500 chars,
|
||||||
|
// skipping obvious header words.
|
||||||
|
const head = text.slice(0, 500)
|
||||||
|
const lines = head.split(/\n|(?:\s{4,})/).map((l) => l.trim()).filter(Boolean)
|
||||||
|
const skip = /^(faktura|invoice|kvitto|receipt|sida|page|datum|date)$/i
|
||||||
|
for (const line of lines) {
|
||||||
|
if (skip.test(line)) continue
|
||||||
|
if (/^\d/.test(line)) continue
|
||||||
|
if (line.length < 3 || line.length > 80) continue
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
/**
|
|
||||||
* AWS Textract AnalyzeExpense — deterministic field extraction for receipts
|
|
||||||
* and invoices. Runs in parallel with the Claude vision pass; numbers from
|
|
||||||
* Textract act as an anti-hallucination anchor for the final cross-check.
|
|
||||||
*
|
|
||||||
* Why receipt-specialized OCR over generic AnalyzeDocument: AnalyzeExpense is
|
|
||||||
* tuned for the expense-document family (SUMMARY_FIELDS like TOTAL, TAX,
|
|
||||||
* VENDOR_NAME, INVOICE_RECEIPT_DATE with field-level confidence scores).
|
|
||||||
* Generic OCR returns raw text and positions — useful for nothing on its own.
|
|
||||||
*
|
|
||||||
* Failure model: every path is best-effort. If Textract returns an error, is
|
|
||||||
* unsupported for this mime type, or the file is over the sync-API limit,
|
|
||||||
* we return null and the caller falls back to Claude-only. Never throws.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
TextractClient,
|
|
||||||
AnalyzeExpenseCommand,
|
|
||||||
type ExpenseDocument,
|
|
||||||
type ExpenseField,
|
|
||||||
} from '@aws-sdk/client-textract'
|
|
||||||
|
|
||||||
let _client: TextractClient | null = null
|
|
||||||
|
|
||||||
function getClient(): TextractClient {
|
|
||||||
if (!_client) {
|
|
||||||
_client = new TextractClient({
|
|
||||||
region: process.env.AWS_REGION || 'eu-north-1',
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return _client
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync AnalyzeExpense caps at 5 MB per document. Anything bigger we skip
|
|
||||||
// rather than fall back to the async API — that adds S3 polling complexity
|
|
||||||
// for a tail case. The common-path receipt is <1 MB.
|
|
||||||
const MAX_SYNC_BYTES = 5 * 1024 * 1024
|
|
||||||
|
|
||||||
// Textract supports: PNG, JPEG, PDF, TIFF. HEIC/WebP → skip (Claude handles
|
|
||||||
// them fine; a second read isn't worth converting the image).
|
|
||||||
const SUPPORTED_MIMES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'])
|
|
||||||
|
|
||||||
export interface TextractExpenseResult {
|
|
||||||
total: { value: number; confidence: number } | null
|
|
||||||
subtotal: { value: number; confidence: number } | null
|
|
||||||
tax: { value: number; confidence: number } | null
|
|
||||||
vendor: { value: string; confidence: number } | null
|
|
||||||
date: { value: string; confidence: number } | null
|
|
||||||
currency: string | null
|
|
||||||
// Raw summary fields kept for audit and future use (e.g., line items).
|
|
||||||
raw_summary: Array<{ type: string; value: string; confidence: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function analyzeExpenseWithTextract(
|
|
||||||
fileBuffer: Buffer,
|
|
||||||
mimeType: string
|
|
||||||
): Promise<TextractExpenseResult | null> {
|
|
||||||
if (!SUPPORTED_MIMES.has(mimeType)) return null
|
|
||||||
if (fileBuffer.byteLength > MAX_SYNC_BYTES) return null
|
|
||||||
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = getClient()
|
|
||||||
const response = await client.send(
|
|
||||||
new AnalyzeExpenseCommand({
|
|
||||||
Document: { Bytes: fileBuffer },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const doc: ExpenseDocument | undefined = response.ExpenseDocuments?.[0]
|
|
||||||
if (!doc) return null
|
|
||||||
|
|
||||||
const summary = doc.SummaryFields ?? []
|
|
||||||
return parseSummaryFields(summary)
|
|
||||||
} catch (err) {
|
|
||||||
// Don't let OCR failure break the pipeline — the Claude pass still runs.
|
|
||||||
// Log so we can see rate-limit / auth issues but return null to caller.
|
|
||||||
console.error('[textract-expense] AnalyzeExpense failed:', err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSummaryFields(fields: ExpenseField[]): TextractExpenseResult {
|
|
||||||
const raw_summary = fields
|
|
||||||
.map((f) => ({
|
|
||||||
type: f.Type?.Text ?? 'UNKNOWN',
|
|
||||||
value: f.ValueDetection?.Text ?? '',
|
|
||||||
confidence: (f.ValueDetection?.Confidence ?? 0) / 100,
|
|
||||||
}))
|
|
||||||
.filter((f) => f.value)
|
|
||||||
|
|
||||||
const pickNumber = (type: string): { value: number; confidence: number } | null => {
|
|
||||||
const field = fields.find((f) => f.Type?.Text === type)
|
|
||||||
if (!field?.ValueDetection?.Text) return null
|
|
||||||
const parsed = parseMoneyString(field.ValueDetection.Text)
|
|
||||||
if (parsed == null) return null
|
|
||||||
return { value: parsed, confidence: (field.ValueDetection.Confidence ?? 0) / 100 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickString = (type: string): { value: string; confidence: number } | null => {
|
|
||||||
const field = fields.find((f) => f.Type?.Text === type)
|
|
||||||
if (!field?.ValueDetection?.Text) return null
|
|
||||||
return {
|
|
||||||
value: field.ValueDetection.Text.trim(),
|
|
||||||
confidence: (field.ValueDetection.Confidence ?? 0) / 100,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawDate = pickString('INVOICE_RECEIPT_DATE')
|
|
||||||
return {
|
|
||||||
total: pickNumber('TOTAL'),
|
|
||||||
subtotal: pickNumber('SUBTOTAL'),
|
|
||||||
tax: pickNumber('TAX'),
|
|
||||||
vendor: pickString('VENDOR_NAME'),
|
|
||||||
date: rawDate ? { value: normalizeDate(rawDate.value), confidence: rawDate.confidence } : null,
|
|
||||||
currency: pickString('CURRENCY')?.value ?? null,
|
|
||||||
raw_summary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Textract returns money strings like "123,45 kr", "$123.45", "1 234,56 SEK".
|
|
||||||
// Strip everything but digits + separators, then normalize to period as
|
|
||||||
// decimal. Returns null when we can't confidently parse.
|
|
||||||
function parseMoneyString(raw: string): number | null {
|
|
||||||
const cleaned = raw.replace(/[^\d.,-]/g, '').trim()
|
|
||||||
if (!cleaned) return null
|
|
||||||
|
|
||||||
// Swedish: 1 234,56 → 1234.56 (comma = decimal, space/period = thousands)
|
|
||||||
// US: 1,234.56 → 1234.56 (comma = thousands, period = decimal)
|
|
||||||
// Heuristic: if both , and . present, the rightmost is the decimal.
|
|
||||||
const lastComma = cleaned.lastIndexOf(',')
|
|
||||||
const lastDot = cleaned.lastIndexOf('.')
|
|
||||||
|
|
||||||
let normalized: string
|
|
||||||
if (lastComma === -1 && lastDot === -1) {
|
|
||||||
normalized = cleaned
|
|
||||||
} else if (lastComma > lastDot) {
|
|
||||||
// Comma is decimal separator
|
|
||||||
normalized = cleaned.replace(/\./g, '').replace(',', '.')
|
|
||||||
} else {
|
|
||||||
// Period is decimal separator
|
|
||||||
normalized = cleaned.replace(/,/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = Number(normalized)
|
|
||||||
return Number.isFinite(num) ? num : null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Textract returns dates in many formats ("2024-03-14", "14/3/24", "March 14,
|
|
||||||
// 2024"). We coerce to ISO where possible; leave the original string as a
|
|
||||||
// fallback. The Claude pass will have its own date, so imperfect parse here
|
|
||||||
// is fine — cross-check falls back to fuzzy matching if needed.
|
|
||||||
function normalizeDate(raw: string): string {
|
|
||||||
const trimmed = raw.trim()
|
|
||||||
// Already ISO
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10)
|
|
||||||
const parsed = new Date(trimmed)
|
|
||||||
if (!isNaN(parsed.getTime())) return parsed.toISOString().slice(0, 10)
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compares a Claude-extracted total against the Textract-extracted total.
|
|
||||||
// Agreement tolerance is 1 öre (0.01 SEK) — anything more is a real
|
|
||||||
// disagreement worth flagging, not rounding noise. Returns null when either
|
|
||||||
// side didn't produce a total (no basis for comparison).
|
|
||||||
export interface AgreementResult {
|
|
||||||
agrees: boolean
|
|
||||||
claude_total: number | null
|
|
||||||
ocr_total: number | null
|
|
||||||
ocr_confidence: number | null
|
|
||||||
delta: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkTotalsAgreement(
|
|
||||||
claudeTotal: number | null | undefined,
|
|
||||||
textract: TextractExpenseResult | null
|
|
||||||
): AgreementResult | null {
|
|
||||||
if (claudeTotal == null || !textract?.total) return null
|
|
||||||
const delta = Math.abs(claudeTotal - textract.total.value)
|
|
||||||
return {
|
|
||||||
agrees: delta <= 0.01,
|
|
||||||
claude_total: claudeTotal,
|
|
||||||
ocr_total: textract.total.value,
|
|
||||||
ocr_confidence: textract.total.confidence,
|
|
||||||
delta,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,23 +5,18 @@
|
|||||||
"entryPoint": "@/extensions/general/invoice-inbox",
|
"entryPoint": "@/extensions/general/invoice-inbox",
|
||||||
"workspace": "@/components/extensions/general/InvoiceInboxWorkspace",
|
"workspace": "@/components/extensions/general/InvoiceInboxWorkspace",
|
||||||
"requiredEnvVars": [
|
"requiredEnvVars": [
|
||||||
"AWS_ACCESS_KEY_ID",
|
|
||||||
"AWS_SECRET_ACCESS_KEY",
|
|
||||||
"AWS_REGION",
|
|
||||||
"RESEND_API_KEY",
|
"RESEND_API_KEY",
|
||||||
"RESEND_INBOUND_DOMAIN",
|
"RESEND_INBOUND_DOMAIN",
|
||||||
"RESEND_INBOUND_WEBHOOK_SECRET"
|
"RESEND_INBOUND_WEBHOOK_SECRET"
|
||||||
],
|
],
|
||||||
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS"],
|
|
||||||
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
|
|
||||||
"definition": {
|
"definition": {
|
||||||
"name": "Dokumentinkorg",
|
"name": "Dokumentinkorg",
|
||||||
"category": "import",
|
"category": "import",
|
||||||
"icon": "Inbox",
|
"icon": "Inbox",
|
||||||
"dataPattern": "both",
|
"dataPattern": "both",
|
||||||
"hasOwnData": true,
|
"hasOwnData": true,
|
||||||
"readsCoreTables": ["document_attachments", "suppliers", "transactions"],
|
"readsCoreTables": ["document_attachments", "suppliers"],
|
||||||
"description": "AI-klassificering och extraktion av leverantörsfakturor och kvitton",
|
"description": "Vidarebefordra leverantörsfakturor till en unik adress – dokumenten landar här med extraherade fält",
|
||||||
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt, klassificeras med AI (leverantör, belopp, moms) och matchas mot transaktioner. Kräver AWS Bedrock och Resend."
|
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum extraheras deterministiskt från PDF-texten. Inga AI-anrop, inga molntjänster utöver Resend för e-postmottagning."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const capabilitiesResource: McpResource = {
|
|||||||
|
|
||||||
const { data: settings } = await supabase
|
const { data: settings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('bookkeeping_locked_through, vat_registered, pays_salaries, ai_flow_enabled')
|
.select('bookkeeping_locked_through, vat_registered, pays_salaries')
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const companyCurrentResource: McpResource = {
|
|||||||
accounting_method, default_voucher_series,
|
accounting_method, default_voucher_series,
|
||||||
bookkeeping_locked_through, auto_lock_period_days,
|
bookkeeping_locked_through, auto_lock_period_days,
|
||||||
invoice_prefix, next_invoice_number, invoice_default_days,
|
invoice_prefix, next_invoice_number, invoice_default_days,
|
||||||
is_sandbox, ai_flow_enabled
|
is_sandbox
|
||||||
`)
|
`)
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import {
|
|||||||
generateInvoiceEmailSubject,
|
generateInvoiceEmailSubject,
|
||||||
} from '@/lib/email/invoice-templates'
|
} from '@/lib/email/invoice-templates'
|
||||||
import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service'
|
import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service'
|
||||||
// classifyDocument is dynamically imported from invoice-inbox (may not be enabled)
|
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||||
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
|
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
|
||||||
// which dispatches to this handler — no duplicate call needed here.
|
// which dispatches to this handler — no duplicate call needed here.
|
||||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem } from '@/types'
|
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem } from '@/types'
|
||||||
@@ -2298,15 +2298,14 @@ const tools: McpTool[] = [
|
|||||||
{
|
{
|
||||||
name: 'gnubok_upload_document',
|
name: 'gnubok_upload_document',
|
||||||
description:
|
description:
|
||||||
'Upload a document (invoice, receipt) to the inbox for AI classification.\n\n' +
|
'Upload a document (invoice, receipt) to the inbox. Runs deterministic field extraction (pdfjs + regex) on text-based PDFs.\n\n' +
|
||||||
'Args:\n' +
|
'Args:\n' +
|
||||||
' - file_name (string, required): File name with extension (e.g. "faktura.pdf")\n' +
|
' - file_name (string, required): File name with extension (e.g. "faktura.pdf")\n' +
|
||||||
' - file_content_base64 (string, required): Base64-encoded file content\n' +
|
' - file_content_base64 (string, required): Base64-encoded file content\n' +
|
||||||
' - mime_type (string, optional): MIME type. Inferred from extension if omitted.\n\n' +
|
' - mime_type (string, optional): MIME type. Inferred from extension if omitted.\n\n' +
|
||||||
'Returns JSON:\n' +
|
'Returns JSON:\n' +
|
||||||
' { document_id, inbox_item_id, status, document_type, extracted_data, confidence }\n\n' +
|
' { document_id, inbox_item_id, status, extracted_data }\n\n' +
|
||||||
'Supported types: PDF, JPEG, PNG, HEIC, WebP. Max 20 MB.\n' +
|
'Supported types: PDF, JPEG, PNG, HEIC, WebP. Max 20 MB.',
|
||||||
'Classification runs synchronously (~2-5 seconds).',
|
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -2353,53 +2352,42 @@ const tools: McpTool[] = [
|
|||||||
throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`)
|
throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in WORM archive
|
|
||||||
const doc = await uploadDocument(supabase, userId, companyId, {
|
const doc = await uploadDocument(supabase, userId, companyId, {
|
||||||
name: fileName,
|
name: fileName,
|
||||||
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
}, { upload_source: 'api' })
|
}, { upload_source: 'api' })
|
||||||
|
|
||||||
// Classify — skipped when invoice-inbox extension is not enabled
|
const { data: extracted } = await extractInvoiceFields({
|
||||||
// (dynamic import of classify-document pulls @aws-sdk/client-bedrock-runtime which breaks the build)
|
buffer,
|
||||||
let classificationResult: { documentType?: string; extractedData?: unknown; rawResponse?: unknown; confidence?: number } | undefined
|
mimeType,
|
||||||
let classificationError: string | null = null
|
fileName,
|
||||||
classificationError = 'invoice-inbox extension not enabled'
|
})
|
||||||
|
|
||||||
// Supplier matching
|
|
||||||
let matchedSupplierId: string | null = null
|
let matchedSupplierId: string | null = null
|
||||||
if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) {
|
if (extracted.supplier.orgNumber) {
|
||||||
const extractedData = classificationResult.extractedData as { supplier?: { orgNumber?: string | null } }
|
const { data: s } = await supabase
|
||||||
const orgNumber = extractedData.supplier?.orgNumber
|
.from('suppliers')
|
||||||
if (orgNumber) {
|
.select('id')
|
||||||
const { data: s } = await supabase
|
.eq('company_id', companyId)
|
||||||
.from('suppliers')
|
.eq('org_number', extracted.supplier.orgNumber)
|
||||||
.select('id')
|
.limit(1)
|
||||||
.eq('company_id', companyId)
|
.maybeSingle()
|
||||||
.eq('org_number', orgNumber.replace(/\D/g, ''))
|
if (s) matchedSupplierId = s.id
|
||||||
.limit(1)
|
|
||||||
.maybeSingle()
|
|
||||||
if (s) matchedSupplierId = s.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create inbox item
|
|
||||||
const { data: inbox, error: inboxError } = await supabase
|
const { data: inbox, error: inboxError } = await supabase
|
||||||
.from('invoice_inbox_items')
|
.from('invoice_inbox_items')
|
||||||
.insert({
|
.insert({
|
||||||
company_id: companyId,
|
company_id: companyId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
status: classificationError ? 'error' : 'ready',
|
status: 'received',
|
||||||
source: 'upload',
|
source: 'upload',
|
||||||
document_id: doc.id,
|
document_id: doc.id,
|
||||||
document_type: classificationResult?.documentType || 'unknown',
|
extracted_data: extracted as unknown as Record<string, unknown>,
|
||||||
extracted_data: classificationResult?.extractedData || null,
|
|
||||||
raw_llm_response: classificationResult?.rawResponse || null,
|
|
||||||
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
|
|
||||||
matched_supplier_id: matchedSupplierId,
|
matched_supplier_id: matchedSupplierId,
|
||||||
error_message: classificationError,
|
|
||||||
})
|
})
|
||||||
.select('id, status, document_type, confidence')
|
.select('id, status')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
||||||
@@ -2408,10 +2396,8 @@ const tools: McpTool[] = [
|
|||||||
document_id: doc.id,
|
document_id: doc.id,
|
||||||
inbox_item_id: inbox.id,
|
inbox_item_id: inbox.id,
|
||||||
status: inbox.status,
|
status: inbox.status,
|
||||||
document_type: inbox.document_type,
|
extracted_data: extracted,
|
||||||
extracted_data: classificationResult?.extractedData || null,
|
matched_supplier_id: matchedSupplierId,
|
||||||
confidence: inbox.confidence,
|
|
||||||
error_message: classificationError,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2419,28 +2405,22 @@ const tools: McpTool[] = [
|
|||||||
{
|
{
|
||||||
name: 'gnubok_list_inbox_items',
|
name: 'gnubok_list_inbox_items',
|
||||||
description:
|
description:
|
||||||
'List document inbox items (classified invoices, receipts, etc.).\n\n' +
|
'List document inbox items (received supplier-invoice documents).\n\n' +
|
||||||
'Args:\n' +
|
'Args:\n' +
|
||||||
' - status (string, optional): Filter by status (pending, processing, ready, confirmed, rejected, error)\n' +
|
' - status (string, optional): Filter by status (received, error)\n' +
|
||||||
' - document_type (string, optional): Filter by type (supplier_invoice, receipt, government_letter, unknown)\n' +
|
|
||||||
' - limit (number, optional): Max results, 1–50 (default 20)\n\n' +
|
' - limit (number, optional): Max results, 1–50 (default 20)\n\n' +
|
||||||
'Returns JSON:\n' +
|
'Returns JSON:\n' +
|
||||||
' { items: [{ id, status, document_type, confidence, source, created_at,\n' +
|
' { items: [{ id, status, source, created_at, vendor_name, amount,\n' +
|
||||||
' vendor_name, amount, invoice_date, matched_supplier_id }],\n' +
|
' invoice_date, matched_supplier_id, created_supplier_invoice_id }],\n' +
|
||||||
' count: number }',
|
' count: number }',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
status: {
|
status: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['pending', 'processing', 'ready', 'confirmed', 'rejected', 'error'],
|
enum: ['received', 'error'],
|
||||||
description: 'Filter by status',
|
description: 'Filter by status',
|
||||||
},
|
},
|
||||||
document_type: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['supplier_invoice', 'receipt', 'government_letter', 'unknown'],
|
|
||||||
description: 'Filter by document type',
|
|
||||||
},
|
|
||||||
limit: {
|
limit: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
description: 'Max results (default 20, max 50)',
|
description: 'Max results (default 20, max 50)',
|
||||||
@@ -2456,53 +2436,44 @@ const tools: McpTool[] = [
|
|||||||
async execute(args, companyId, userId, supabase) {
|
async execute(args, companyId, userId, supabase) {
|
||||||
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
|
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
|
||||||
const status = args.status as string | undefined
|
const status = args.status as string | undefined
|
||||||
const documentType = args.document_type as string | undefined
|
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('invoice_inbox_items')
|
.from('invoice_inbox_items')
|
||||||
.select('id, status, document_type, confidence, source, created_at, extracted_data, matched_supplier_id, email_from, email_subject, error_message')
|
.select('id, status, source, created_at, extracted_data, matched_supplier_id, created_supplier_invoice_id, email_from, email_subject, error_message')
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
||||||
if (status) query = query.eq('status', status)
|
if (status) query = query.eq('status', status)
|
||||||
if (documentType) query = query.eq('document_type', documentType)
|
|
||||||
|
|
||||||
const { data, error } = await query
|
const { data, error } = await query
|
||||||
if (error) throw new Error(`Database error: ${error.message}`)
|
if (error) throw new Error(`Database error: ${error.message}`)
|
||||||
|
|
||||||
// Extract key fields from extracted_data for summary
|
|
||||||
const items = (data || []).map((item) => {
|
const items = (data || []).map((item) => {
|
||||||
const extracted = item.extracted_data as Record<string, unknown> | null
|
const extracted = item.extracted_data as Record<string, unknown> | null
|
||||||
let vendorName: string | null = null
|
let vendorName: string | null = null
|
||||||
let amount: number | null = null
|
let amount: number | null = null
|
||||||
let invoiceDate: string | null = null
|
let invoiceDate: string | null = null
|
||||||
|
|
||||||
if (extracted && item.document_type === 'supplier_invoice') {
|
if (extracted) {
|
||||||
const supplier = extracted.supplier as Record<string, unknown> | undefined
|
const supplier = extracted.supplier as Record<string, unknown> | undefined
|
||||||
const invoice = extracted.invoice as Record<string, unknown> | undefined
|
const invoice = extracted.invoice as Record<string, unknown> | undefined
|
||||||
const totals = extracted.totals as Record<string, unknown> | undefined
|
const totals = extracted.totals as Record<string, unknown> | undefined
|
||||||
vendorName = (supplier?.name as string) || null
|
vendorName = (supplier?.name as string) || null
|
||||||
amount = (totals?.total as number) || null
|
amount = (totals?.total as number) || null
|
||||||
invoiceDate = (invoice?.invoiceDate as string) || null
|
invoiceDate = (invoice?.invoiceDate as string) || null
|
||||||
} else if (extracted && item.document_type === 'receipt') {
|
|
||||||
const merchant = extracted.merchant as Record<string, unknown> | undefined
|
|
||||||
const totals = extracted.totals as Record<string, unknown> | undefined
|
|
||||||
vendorName = (merchant?.name as string) || null
|
|
||||||
amount = (totals?.total as number) || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
document_type: item.document_type,
|
|
||||||
confidence: item.confidence,
|
|
||||||
source: item.source,
|
source: item.source,
|
||||||
created_at: item.created_at,
|
created_at: item.created_at,
|
||||||
vendor_name: vendorName,
|
vendor_name: vendorName,
|
||||||
amount,
|
amount,
|
||||||
invoice_date: invoiceDate,
|
invoice_date: invoiceDate,
|
||||||
matched_supplier_id: item.matched_supplier_id,
|
matched_supplier_id: item.matched_supplier_id,
|
||||||
|
created_supplier_invoice_id: item.created_supplier_invoice_id,
|
||||||
email_from: item.email_from,
|
email_from: item.email_from,
|
||||||
email_subject: item.email_subject,
|
email_subject: item.email_subject,
|
||||||
error_message: item.error_message,
|
error_message: item.error_message,
|
||||||
@@ -2520,8 +2491,8 @@ const tools: McpTool[] = [
|
|||||||
'Args:\n' +
|
'Args:\n' +
|
||||||
' - inbox_item_id (string, required): UUID of the inbox item\n\n' +
|
' - inbox_item_id (string, required): UUID of the inbox item\n\n' +
|
||||||
'Returns JSON:\n' +
|
'Returns JSON:\n' +
|
||||||
' Full inbox item with id, status, document_type, confidence, source,\n' +
|
' Full inbox item with id, status, source, extracted_data (complete),\n' +
|
||||||
' extracted_data (complete), matched_supplier_id, email metadata, timestamps.',
|
' matched_supplier_id, created_supplier_invoice_id, email metadata, timestamps.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function createReceiptExtractedPayload(
|
|||||||
badge: '/icons/badge-72.png',
|
badge: '/icons/badge-72.png',
|
||||||
tag: `receipt-extracted-${receiptId}`,
|
tag: `receipt-extracted-${receiptId}`,
|
||||||
data: {
|
data: {
|
||||||
url: '/receipts',
|
url: '/transactions',
|
||||||
type: 'receipt_extracted',
|
type: 'receipt_extracted',
|
||||||
id: receiptId,
|
id: receiptId,
|
||||||
},
|
},
|
||||||
@@ -107,7 +107,7 @@ export function createReceiptMatchedPayload(
|
|||||||
badge: '/icons/badge-72.png',
|
badge: '/icons/badge-72.png',
|
||||||
tag: `receipt-matched-${receiptId}`,
|
tag: `receipt-matched-${receiptId}`,
|
||||||
data: {
|
data: {
|
||||||
url: '/receipts',
|
url: '/transactions',
|
||||||
type: 'receipt_matched',
|
type: 'receipt_matched',
|
||||||
id: receiptId,
|
id: receiptId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -243,7 +243,6 @@ export async function bokforSkattekontoTransaction(
|
|||||||
source_id: tx.id,
|
source_id: tx.id,
|
||||||
notes: `Genererad från skattekonto-synk. Skatteverket-id: ${tx.transaktionsidentitet ?? '–'}`,
|
notes: `Genererad från skattekonto-synk. Skatteverket-id: ${tx.transaktionsidentitet ?? '–'}`,
|
||||||
lines,
|
lines,
|
||||||
created_via: 'manual',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = await createDraftEntry(supabase, companyId, userId, input)
|
const entry = await createDraftEntry(supabase, companyId, userId, input)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agent-inkorg feature flag.
|
|
||||||
*
|
|
||||||
* The AI bookkeeping agent isn't ready for general availability. It is
|
|
||||||
* strictly local-dev only — sidebar link, page, API routes, and orchestrator
|
|
||||||
* event handlers all return 404 / are hidden on any deployed (Vercel) build.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
export function isAgentInboxEnabled(): boolean {
|
|
||||||
return process.env.NODE_ENV === 'development'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-booking of bank transactions during ingest.
|
|
||||||
*
|
|
||||||
* Mapping-rule-driven creation of journal entries on import is a future
|
|
||||||
* feature. It must NEVER run on the deployed Vercel production build —
|
|
||||||
* users have to explicitly book each transaction. Allowed only in local
|
|
||||||
* dev (and in the test environment so the auto-book pipeline stays under
|
|
||||||
* test coverage). No env-var escape hatch.
|
|
||||||
*/
|
|
||||||
export function isAutoBookEnabled(): boolean {
|
|
||||||
return process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 404 early-return for API routes. Returns the response when disabled, null
|
|
||||||
* when enabled. Usage:
|
|
||||||
*
|
|
||||||
* const gate = gateAgentInbox()
|
|
||||||
* if (gate) return gate
|
|
||||||
*/
|
|
||||||
export function gateAgentInbox(): NextResponse | null {
|
|
||||||
if (isAgentInboxEnabled()) return null
|
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
/**
|
|
||||||
* AI agent orchestrator — event handler that wires the proposal lifecycle.
|
|
||||||
*
|
|
||||||
* Subscribes to:
|
|
||||||
* - inbox_item.classified → generate match proposal (receipts, ai_flow_enabled)
|
|
||||||
* - ai_proposal.accepted → chain match -> booking
|
|
||||||
* - transaction.categorized → skip pending proposals for that transaction's inbox item
|
|
||||||
*
|
|
||||||
* The generators themselves live in the ai-agent extension (Bedrock). When
|
|
||||||
* the extension is not loaded (prod, or feature off), the service's noop
|
|
||||||
* returns null and we issue a 'needs_manual' ai_request so the user still
|
|
||||||
* sees the item needs action — no silent failure.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createClient as createServiceClient } from '@supabase/supabase-js'
|
|
||||||
import { eventBus } from '@/lib/events/bus'
|
|
||||||
import type { EventPayload } from '@/lib/events/types'
|
|
||||||
import { getAIProposalService } from '@/lib/ai/proposal-service'
|
|
||||||
import {
|
|
||||||
insertProposal,
|
|
||||||
insertRequest,
|
|
||||||
skipPendingProposalsForSubject,
|
|
||||||
} from '@/lib/ai/proposals/persist'
|
|
||||||
import { createLogger } from '@/lib/logger'
|
|
||||||
import type {
|
|
||||||
InvoiceInboxItem,
|
|
||||||
Transaction,
|
|
||||||
CategorizationTemplate,
|
|
||||||
AIProposal,
|
|
||||||
} from '@/types'
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import type {
|
|
||||||
AIRequestResult,
|
|
||||||
BookingProposalResult,
|
|
||||||
MatchProposalResult,
|
|
||||||
} from '@/lib/ai/proposal-service'
|
|
||||||
|
|
||||||
const log = createLogger('ai-orchestrator')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service-role client for orchestrator writes.
|
|
||||||
* Mirrors inbox-smart-match — the handler runs server-side and needs to
|
|
||||||
* bypass RLS to write to ai_proposals, ai_requests, and read settings.
|
|
||||||
*/
|
|
||||||
function getServiceClient(): SupabaseClient {
|
|
||||||
return createServiceClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── inbox_item.classified handler ────────────────────────────────────
|
|
||||||
|
|
||||||
async function handleInboxItemClassified(
|
|
||||||
payload: EventPayload<'inbox_item.classified'>
|
|
||||||
): Promise<void> {
|
|
||||||
const { inboxItem, documentType, correlationId, userId, companyId } = payload
|
|
||||||
|
|
||||||
// v1 scope: only receipts.
|
|
||||||
if (documentType !== 'receipt') return
|
|
||||||
|
|
||||||
const supabase = getServiceClient()
|
|
||||||
|
|
||||||
// Per-company gate.
|
|
||||||
const { data: settings } = await supabase
|
|
||||||
.from('company_settings')
|
|
||||||
.select('ai_flow_enabled')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!settings?.ai_flow_enabled) return
|
|
||||||
|
|
||||||
await generateMatchProposalFor(supabase, {
|
|
||||||
inboxItem,
|
|
||||||
correlationId,
|
|
||||||
userId,
|
|
||||||
companyId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ai_proposal.accepted handler (chain match -> booking) ───────────
|
|
||||||
|
|
||||||
async function handleProposalAccepted(
|
|
||||||
payload: EventPayload<'ai_proposal.accepted'>
|
|
||||||
): Promise<void> {
|
|
||||||
const { proposal, userId, companyId } = payload
|
|
||||||
|
|
||||||
if (proposal.step_type !== 'match') return
|
|
||||||
if (proposal.subject_type !== 'inbox_item') return
|
|
||||||
|
|
||||||
const supabase = getServiceClient()
|
|
||||||
|
|
||||||
// Load the inbox item + the matched transaction to feed the booking prompt.
|
|
||||||
const { data: inboxItem } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', proposal.subject_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!inboxItem || !(inboxItem as InvoiceInboxItem).matched_transaction_id) {
|
|
||||||
log.warn(`match accepted but no matched_transaction_id on inbox item ${proposal.subject_id}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = inboxItem as InvoiceInboxItem
|
|
||||||
|
|
||||||
// Defense in depth: don't chain to booking without a source document.
|
|
||||||
// reValidateMatch already blocks this at accept time, but a stale accepted
|
|
||||||
// proposal (from before the gate existed) could still reach here.
|
|
||||||
if (!item.document_id) {
|
|
||||||
log.warn(`refusing to chain booking for inbox item ${item.id} — no source document attached`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: tx } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', item.matched_transaction_id!)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!tx) {
|
|
||||||
log.warn(`match accepted but transaction ${item.matched_transaction_id} not found`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Existing counterparty templates to inform the booking prompt.
|
|
||||||
const { data: templates } = await supabase
|
|
||||||
.from('categorization_templates')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('is_active', true)
|
|
||||||
|
|
||||||
// Entity type for account routing.
|
|
||||||
const { data: settings } = await supabase
|
|
||||||
.from('company_settings')
|
|
||||||
.select('entity_type')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
const entityType: 'enskild_firma' | 'aktiebolag' =
|
|
||||||
(settings?.entity_type as 'enskild_firma' | 'aktiebolag') || 'enskild_firma'
|
|
||||||
|
|
||||||
await generateBookingProposalFor(supabase, {
|
|
||||||
inboxItem: item,
|
|
||||||
matchedTransaction: tx as Transaction,
|
|
||||||
existingTemplates: (templates || []) as CategorizationTemplate[],
|
|
||||||
entityType,
|
|
||||||
correlationId: item.correlation_id ?? undefined,
|
|
||||||
userId,
|
|
||||||
companyId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── transaction.categorized handler (skip on manual takeover) ───────
|
|
||||||
|
|
||||||
async function handleTransactionCategorized(
|
|
||||||
payload: EventPayload<'transaction.categorized'>
|
|
||||||
): Promise<void> {
|
|
||||||
const { transaction, companyId } = payload
|
|
||||||
|
|
||||||
const supabase = getServiceClient()
|
|
||||||
|
|
||||||
// Find any inbox items matched to this transaction with pending proposals.
|
|
||||||
const { data: items } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('id')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.eq('matched_transaction_id', transaction.id)
|
|
||||||
|
|
||||||
if (!items || items.length === 0) return
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
await skipPendingProposalsForSubject(supabase, 'inbox_item', item.id, 'user_went_manual')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Generator dispatch ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface GenerateMatchArgs {
|
|
||||||
inboxItem: InvoiceInboxItem
|
|
||||||
correlationId?: string
|
|
||||||
userId: string
|
|
||||||
companyId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateMatchProposalFor(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
args: GenerateMatchArgs
|
|
||||||
): Promise<void> {
|
|
||||||
const { inboxItem, correlationId, userId, companyId } = args
|
|
||||||
|
|
||||||
const service = getAIProposalService()
|
|
||||||
const result = await service.generateMatchProposal({ inboxItem, userId, companyId })
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
// Service outage or no extension loaded → needs_manual ask.
|
|
||||||
await insertRequest(supabase, {
|
|
||||||
companyId,
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: inboxItem.id,
|
|
||||||
requestType: 'needs_manual',
|
|
||||||
message: 'AI-agenten är inte tillgänglig just nu — hantera manuellt.',
|
|
||||||
correlationId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.kind === 'request') {
|
|
||||||
await insertRequest(supabase, {
|
|
||||||
companyId,
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: inboxItem.id,
|
|
||||||
requestType: result.request.request_type,
|
|
||||||
message: result.request.message,
|
|
||||||
requiredFields: result.request.required_fields,
|
|
||||||
options: result.request.options as Record<string, unknown> | undefined,
|
|
||||||
model: result.provenance.model,
|
|
||||||
promptVersion: result.provenance.prompt_version,
|
|
||||||
correlationId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const proposal = await persistMatchProposal(supabase, result, {
|
|
||||||
userId,
|
|
||||||
companyId,
|
|
||||||
subjectId: inboxItem.id,
|
|
||||||
correlationId,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Emit for metrics / audit subscribers.
|
|
||||||
try {
|
|
||||||
await eventBus.emit({
|
|
||||||
type: 'ai_proposal.generated',
|
|
||||||
payload: { proposal, userId, companyId },
|
|
||||||
})
|
|
||||||
} catch { /* non-blocking */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenerateBookingArgs {
|
|
||||||
inboxItem: InvoiceInboxItem
|
|
||||||
matchedTransaction: Transaction
|
|
||||||
existingTemplates: CategorizationTemplate[]
|
|
||||||
entityType: 'enskild_firma' | 'aktiebolag'
|
|
||||||
correlationId?: string
|
|
||||||
userId: string
|
|
||||||
companyId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateBookingProposalFor(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
args: GenerateBookingArgs
|
|
||||||
): Promise<void> {
|
|
||||||
const { inboxItem, matchedTransaction, existingTemplates, entityType, correlationId, userId, companyId } = args
|
|
||||||
|
|
||||||
const service = getAIProposalService()
|
|
||||||
const result = await service.generateBookingProposal({
|
|
||||||
inboxItem,
|
|
||||||
matchedTransaction,
|
|
||||||
existingTemplates,
|
|
||||||
entityType,
|
|
||||||
userId,
|
|
||||||
companyId,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
await insertRequest(supabase, {
|
|
||||||
companyId,
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: inboxItem.id,
|
|
||||||
requestType: 'needs_manual',
|
|
||||||
message: 'AI-agenten är inte tillgänglig just nu — bokför manuellt.',
|
|
||||||
correlationId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.kind === 'request') {
|
|
||||||
await insertRequest(supabase, {
|
|
||||||
companyId,
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: inboxItem.id,
|
|
||||||
requestType: result.request.request_type,
|
|
||||||
message: result.request.message,
|
|
||||||
requiredFields: result.request.required_fields,
|
|
||||||
options: result.request.options as Record<string, unknown> | undefined,
|
|
||||||
model: result.provenance.model,
|
|
||||||
promptVersion: result.provenance.prompt_version,
|
|
||||||
correlationId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const proposal = await persistBookingProposal(supabase, result, {
|
|
||||||
userId,
|
|
||||||
companyId,
|
|
||||||
subjectId: inboxItem.id,
|
|
||||||
correlationId,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await eventBus.emit({
|
|
||||||
type: 'ai_proposal.generated',
|
|
||||||
payload: { proposal, userId, companyId },
|
|
||||||
})
|
|
||||||
} catch { /* non-blocking */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Persist helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PersistArgs {
|
|
||||||
userId: string
|
|
||||||
companyId: string
|
|
||||||
subjectId: string
|
|
||||||
correlationId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistMatchProposal(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
result: MatchProposalResult,
|
|
||||||
args: PersistArgs
|
|
||||||
): Promise<AIProposal> {
|
|
||||||
return insertProposal(supabase, {
|
|
||||||
companyId: args.companyId,
|
|
||||||
userId: args.userId,
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: args.subjectId,
|
|
||||||
stepType: 'match',
|
|
||||||
proposalJson: result.proposal,
|
|
||||||
confidence: result.confidence,
|
|
||||||
reasoning: result.reasoning,
|
|
||||||
model: result.provenance.model,
|
|
||||||
promptVersion: result.provenance.prompt_version,
|
|
||||||
inputTokens: result.provenance.input_tokens,
|
|
||||||
outputTokens: result.provenance.output_tokens,
|
|
||||||
correlationId: args.correlationId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistBookingProposal(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
result: BookingProposalResult,
|
|
||||||
args: PersistArgs
|
|
||||||
): Promise<AIProposal> {
|
|
||||||
return insertProposal(supabase, {
|
|
||||||
companyId: args.companyId,
|
|
||||||
userId: args.userId,
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: args.subjectId,
|
|
||||||
stepType: 'booking',
|
|
||||||
proposalJson: result.proposal,
|
|
||||||
confidence: result.confidence,
|
|
||||||
reasoning: result.reasoning,
|
|
||||||
model: result.provenance.model,
|
|
||||||
promptVersion: result.provenance.prompt_version,
|
|
||||||
inputTokens: result.provenance.input_tokens,
|
|
||||||
outputTokens: result.provenance.output_tokens,
|
|
||||||
correlationId: args.correlationId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Registration ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the AI orchestrator on the core event bus. Called from lib/init.ts
|
|
||||||
* alongside the other core handlers.
|
|
||||||
*/
|
|
||||||
export function registerAIProposalHandler(): () => void {
|
|
||||||
const unsubs: Array<() => void> = [
|
|
||||||
eventBus.on('inbox_item.classified', handleInboxItemClassified),
|
|
||||||
eventBus.on('ai_proposal.accepted', handleProposalAccepted),
|
|
||||||
eventBus.on('transaction.categorized', handleTransactionCategorized),
|
|
||||||
]
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubs.forEach((u) => u())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exports for direct use from API routes (e.g., /api/ai/backfill/receipts).
|
|
||||||
export { generateMatchProposalFor, generateBookingProposalFor }
|
|
||||||
// Also re-export the unused result types so TS keeps them imported.
|
|
||||||
export type { MatchProposalResult, BookingProposalResult, AIRequestResult }
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* AI Proposal Service Interface
|
|
||||||
*
|
|
||||||
* Core defines the contract. The `ai-agent` extension registers a real
|
|
||||||
* implementation backed by Bedrock. Without the extension, the noop service
|
|
||||||
* is used — every call returns `null` and the orchestrator degrades by
|
|
||||||
* issuing a `needs_manual` ai_request so the user sees the item and knows
|
|
||||||
* they need to process it manually.
|
|
||||||
*
|
|
||||||
* Mirrors the pattern in lib/email/service.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
InvoiceInboxItem,
|
|
||||||
Transaction,
|
|
||||||
MatchProposalPayload,
|
|
||||||
BookingProposalPayload,
|
|
||||||
AIRequestType,
|
|
||||||
CategorizationTemplate,
|
|
||||||
PickTransactionOption,
|
|
||||||
} from '@/types'
|
|
||||||
|
|
||||||
// Shared fields any LLM call returns for audit.
|
|
||||||
export interface ProposalProvenance {
|
|
||||||
model: string
|
|
||||||
prompt_version: string
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the AI produces a concrete suggestion.
|
|
||||||
export interface MatchProposalResult {
|
|
||||||
kind: 'proposal'
|
|
||||||
proposal: MatchProposalPayload
|
|
||||||
confidence: number
|
|
||||||
reasoning: string
|
|
||||||
provenance: ProposalProvenance
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingProposalResult {
|
|
||||||
kind: 'proposal'
|
|
||||||
proposal: BookingProposalPayload
|
|
||||||
confidence: number
|
|
||||||
reasoning: string
|
|
||||||
provenance: ProposalProvenance
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the AI would rather ask the user than guess.
|
|
||||||
export interface AIRequestResult {
|
|
||||||
kind: 'request'
|
|
||||||
request: {
|
|
||||||
request_type: AIRequestType
|
|
||||||
message: string
|
|
||||||
required_fields?: Record<string, unknown>
|
|
||||||
options?: Record<string, unknown> | { candidates: PickTransactionOption[] }
|
|
||||||
}
|
|
||||||
provenance: Partial<ProposalProvenance>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context passed to each generator. Keeping the contract tight so extensions
|
|
||||||
// can't accidentally see more than they need.
|
|
||||||
export interface GenerateMatchContext {
|
|
||||||
inboxItem: InvoiceInboxItem
|
|
||||||
userId: string
|
|
||||||
companyId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenerateBookingContext {
|
|
||||||
inboxItem: InvoiceInboxItem
|
|
||||||
matchedTransaction: Transaction
|
|
||||||
existingTemplates: CategorizationTemplate[]
|
|
||||||
entityType: 'enskild_firma' | 'aktiebolag'
|
|
||||||
userId: string
|
|
||||||
companyId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AIProposalService {
|
|
||||||
/** True when a real (non-noop) implementation is registered and ready. */
|
|
||||||
isEnabled(): boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Propose which bank transaction matches an incoming receipt.
|
|
||||||
* Returns null on service outage (orchestrator will issue needs_manual).
|
|
||||||
*/
|
|
||||||
generateMatchProposal(
|
|
||||||
ctx: GenerateMatchContext
|
|
||||||
): Promise<MatchProposalResult | AIRequestResult | null>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Propose how to book the matched transaction (accounts, VAT, lines).
|
|
||||||
* Returns null on service outage (orchestrator will issue needs_manual).
|
|
||||||
*/
|
|
||||||
generateBookingProposal(
|
|
||||||
ctx: GenerateBookingContext
|
|
||||||
): Promise<BookingProposalResult | AIRequestResult | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
class NoopAIProposalService implements AIProposalService {
|
|
||||||
isEnabled(): boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
async generateMatchProposal(): Promise<null> {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
async generateBookingProposal(): Promise<null> {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let service: AIProposalService = new NoopAIProposalService()
|
|
||||||
|
|
||||||
export function getAIProposalService(): AIProposalService {
|
|
||||||
return service
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerAIProposalService(svc: AIProposalService): void {
|
|
||||||
service = svc
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset to noop — for tests only. */
|
|
||||||
export function _resetAIProposalService(): void {
|
|
||||||
service = new NoopAIProposalService()
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
// Mock processing-history append BEFORE importing persist — the module
|
|
||||||
// grabs `createServiceClient` at import time, which needs env vars we
|
|
||||||
// don't care about here.
|
|
||||||
vi.mock('@/lib/processing-history/append', () => ({
|
|
||||||
appendProcessingHistory: vi.fn().mockResolvedValue('evt-1'),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { insertProposal, insertRequest, skipPendingProposalsForSubject } from '../persist'
|
|
||||||
import type { MatchProposalPayload } from '@/types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a scripted supabase mock where each chained operation is tracked
|
|
||||||
* so the test can inspect what was called. Each `.from(...)` returns a new
|
|
||||||
* chain; the `.update(...)` and `.insert(...)` calls capture payloads;
|
|
||||||
* the await resolves to a scripted result via the `results` queue.
|
|
||||||
*/
|
|
||||||
interface Call {
|
|
||||||
table: string
|
|
||||||
op: 'update' | 'insert' | 'select' | 'other'
|
|
||||||
payload?: unknown
|
|
||||||
filters: Array<{ key: string; value: unknown }>
|
|
||||||
}
|
|
||||||
|
|
||||||
function scriptedSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
|
|
||||||
const calls: Call[] = []
|
|
||||||
let resultIdx = 0
|
|
||||||
|
|
||||||
const makeChain = (table: string) => {
|
|
||||||
const current: Call = { table, op: 'other', filters: [] }
|
|
||||||
calls.push(current)
|
|
||||||
const handler: ProxyHandler<object> = {
|
|
||||||
get(_target, prop) {
|
|
||||||
if (prop === 'then') {
|
|
||||||
const next = results[resultIdx++] ?? { data: null, error: null }
|
|
||||||
return (resolve: (v: unknown) => void) =>
|
|
||||||
resolve({ data: next.data ?? null, error: next.error ?? null })
|
|
||||||
}
|
|
||||||
return (...args: unknown[]) => {
|
|
||||||
if (prop === 'update') {
|
|
||||||
current.op = 'update'
|
|
||||||
current.payload = args[0]
|
|
||||||
} else if (prop === 'insert') {
|
|
||||||
current.op = 'insert'
|
|
||||||
current.payload = args[0]
|
|
||||||
} else if (prop === 'select') {
|
|
||||||
current.op = current.op === 'other' ? 'select' : current.op
|
|
||||||
} else if (prop === 'eq') {
|
|
||||||
current.filters.push({ key: String(args[0]), value: args[1] })
|
|
||||||
}
|
|
||||||
return chain
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const chain = new Proxy({}, handler)
|
|
||||||
return chain
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = {
|
|
||||||
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
|
|
||||||
}
|
|
||||||
|
|
||||||
return { client, calls }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('insertProposal', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('invalidates prior pending before inserting new', async () => {
|
|
||||||
const { client, calls } = scriptedSupabase([
|
|
||||||
// update (invalidate)
|
|
||||||
{ data: null },
|
|
||||||
// insert + select + single
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
id: 'proposal-new',
|
|
||||||
company_id: 'c1',
|
|
||||||
user_id: 'u1',
|
|
||||||
subject_type: 'inbox_item',
|
|
||||||
subject_id: 'inbox-1',
|
|
||||||
step_type: 'match',
|
|
||||||
status: 'pending',
|
|
||||||
version: 1,
|
|
||||||
proposal_json: {},
|
|
||||||
confidence: 0.9,
|
|
||||||
reasoning: 'x',
|
|
||||||
ai_request_id: null,
|
|
||||||
model: 'm',
|
|
||||||
prompt_version: 'v1',
|
|
||||||
input_token_count: 0,
|
|
||||||
output_token_count: 0,
|
|
||||||
edit_diff: null,
|
|
||||||
applied_entry_id: null,
|
|
||||||
invalidated_reason: null,
|
|
||||||
created_at: '2026-04-23T00:00:00Z',
|
|
||||||
accepted_at: null,
|
|
||||||
accepted_by_user_id: null,
|
|
||||||
rejected_at: null,
|
|
||||||
updated_at: '2026-04-23T00:00:00Z',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const payload: MatchProposalPayload = {
|
|
||||||
matched_transaction_id: 'tx-1',
|
|
||||||
alternatives: [],
|
|
||||||
top_confidence: 0.9,
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await insertProposal(
|
|
||||||
client as unknown as import('@supabase/supabase-js').SupabaseClient,
|
|
||||||
{
|
|
||||||
companyId: 'c1',
|
|
||||||
userId: 'u1',
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: 'inbox-1',
|
|
||||||
stepType: 'match',
|
|
||||||
proposalJson: payload,
|
|
||||||
confidence: 0.9,
|
|
||||||
reasoning: 'x',
|
|
||||||
model: 'm',
|
|
||||||
promptVersion: 'v1',
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.id).toBe('proposal-new')
|
|
||||||
|
|
||||||
// Expect two .from('ai_proposals') calls:
|
|
||||||
// 1. update → invalidate prior
|
|
||||||
// 2. insert → new row
|
|
||||||
const aiProposalCalls = calls.filter((c) => c.table === 'ai_proposals')
|
|
||||||
expect(aiProposalCalls).toHaveLength(2)
|
|
||||||
expect(aiProposalCalls[0].op).toBe('update')
|
|
||||||
expect(aiProposalCalls[0].payload).toMatchObject({
|
|
||||||
status: 'invalidated',
|
|
||||||
invalidated_reason: 'superseded_by_new_proposal',
|
|
||||||
})
|
|
||||||
expect(aiProposalCalls[1].op).toBe('insert')
|
|
||||||
expect(aiProposalCalls[1].payload).toMatchObject({
|
|
||||||
company_id: 'c1',
|
|
||||||
subject_id: 'inbox-1',
|
|
||||||
step_type: 'match',
|
|
||||||
status: 'pending',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws when insert returns an error', async () => {
|
|
||||||
const { client } = scriptedSupabase([
|
|
||||||
{ data: null }, // update OK
|
|
||||||
{ data: null, error: { message: 'boom' } }, // insert fails
|
|
||||||
])
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
insertProposal(
|
|
||||||
client as unknown as import('@supabase/supabase-js').SupabaseClient,
|
|
||||||
{
|
|
||||||
companyId: 'c1',
|
|
||||||
userId: 'u1',
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: 'inbox-1',
|
|
||||||
stepType: 'match',
|
|
||||||
proposalJson: { matched_transaction_id: 'tx-1', alternatives: [], top_confidence: 0.9 },
|
|
||||||
confidence: 0.9,
|
|
||||||
reasoning: 'x',
|
|
||||||
model: 'm',
|
|
||||||
promptVersion: 'v1',
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).rejects.toThrow(/Failed to insert ai_proposal/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('insertRequest', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates existing open request with the same (subject, type) instead of inserting', async () => {
|
|
||||||
const { client, calls } = scriptedSupabase([
|
|
||||||
// existing lookup
|
|
||||||
{ data: { id: 'req-existing' } },
|
|
||||||
// update
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
id: 'req-existing',
|
|
||||||
company_id: 'c1',
|
|
||||||
subject_type: 'inbox_item',
|
|
||||||
subject_id: 'inbox-1',
|
|
||||||
request_type: 'needs_manual',
|
|
||||||
message: 'updated',
|
|
||||||
required_fields: null,
|
|
||||||
options: null,
|
|
||||||
status: 'open',
|
|
||||||
response_json: null,
|
|
||||||
resolved_at: null,
|
|
||||||
resolved_by_user_id: null,
|
|
||||||
model: null,
|
|
||||||
prompt_version: null,
|
|
||||||
created_at: '2026-04-23T00:00:00Z',
|
|
||||||
updated_at: '2026-04-23T00:00:00Z',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await insertRequest(
|
|
||||||
client as unknown as import('@supabase/supabase-js').SupabaseClient,
|
|
||||||
{
|
|
||||||
companyId: 'c1',
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: 'inbox-1',
|
|
||||||
requestType: 'needs_manual',
|
|
||||||
message: 'updated',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.id).toBe('req-existing')
|
|
||||||
|
|
||||||
const updateCall = calls.find((c) => c.table === 'ai_requests' && c.op === 'update')
|
|
||||||
expect(updateCall).toBeDefined()
|
|
||||||
expect(updateCall!.payload).toMatchObject({ message: 'updated' })
|
|
||||||
|
|
||||||
// No insert was performed (would have been a second ai_requests call with op=insert).
|
|
||||||
const insertCall = calls.find((c) => c.table === 'ai_requests' && c.op === 'insert')
|
|
||||||
expect(insertCall).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inserts a new request when none exists', async () => {
|
|
||||||
const { client, calls } = scriptedSupabase([
|
|
||||||
// existing lookup → none
|
|
||||||
{ data: null },
|
|
||||||
// insert
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
id: 'req-new',
|
|
||||||
company_id: 'c1',
|
|
||||||
subject_type: 'inbox_item',
|
|
||||||
subject_id: 'inbox-1',
|
|
||||||
request_type: 'reupload_document',
|
|
||||||
message: 'new ask',
|
|
||||||
required_fields: null,
|
|
||||||
options: null,
|
|
||||||
status: 'open',
|
|
||||||
response_json: null,
|
|
||||||
resolved_at: null,
|
|
||||||
resolved_by_user_id: null,
|
|
||||||
model: null,
|
|
||||||
prompt_version: null,
|
|
||||||
created_at: '2026-04-23T00:00:00Z',
|
|
||||||
updated_at: '2026-04-23T00:00:00Z',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await insertRequest(
|
|
||||||
client as unknown as import('@supabase/supabase-js').SupabaseClient,
|
|
||||||
{
|
|
||||||
companyId: 'c1',
|
|
||||||
subjectType: 'inbox_item',
|
|
||||||
subjectId: 'inbox-1',
|
|
||||||
requestType: 'reupload_document',
|
|
||||||
message: 'new ask',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.id).toBe('req-new')
|
|
||||||
const insertCall = calls.find((c) => c.table === 'ai_requests' && c.op === 'insert')
|
|
||||||
expect(insertCall).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('skipPendingProposalsForSubject', () => {
|
|
||||||
it('updates all pending proposals for the subject to skipped', async () => {
|
|
||||||
const { client, calls } = scriptedSupabase([{ data: null }])
|
|
||||||
|
|
||||||
await skipPendingProposalsForSubject(
|
|
||||||
client as unknown as import('@supabase/supabase-js').SupabaseClient,
|
|
||||||
'inbox_item',
|
|
||||||
'inbox-1',
|
|
||||||
'user_went_manual'
|
|
||||||
)
|
|
||||||
|
|
||||||
const call = calls.find((c) => c.table === 'ai_proposals')
|
|
||||||
expect(call?.op).toBe('update')
|
|
||||||
expect(call?.payload).toMatchObject({
|
|
||||||
status: 'skipped',
|
|
||||||
invalidated_reason: 'user_went_manual',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { reValidateProposal } from '../re-validate'
|
|
||||||
import type { AIProposal, BookingProposalPayload, MatchProposalPayload, InvoiceInboxItem } from '@/types'
|
|
||||||
|
|
||||||
// Minimal proposal factory.
|
|
||||||
function makeProposal(overrides: Partial<AIProposal> = {}): AIProposal {
|
|
||||||
return {
|
|
||||||
id: 'proposal-1',
|
|
||||||
company_id: 'company-1',
|
|
||||||
user_id: 'user-1',
|
|
||||||
subject_type: 'inbox_item',
|
|
||||||
subject_id: 'inbox-1',
|
|
||||||
step_type: 'match',
|
|
||||||
status: 'pending',
|
|
||||||
version: 1,
|
|
||||||
proposal_json: {
|
|
||||||
matched_transaction_id: 'tx-1',
|
|
||||||
alternatives: [],
|
|
||||||
top_confidence: 0.9,
|
|
||||||
} as MatchProposalPayload,
|
|
||||||
confidence: 0.9,
|
|
||||||
reasoning: 'test',
|
|
||||||
ai_request_id: null,
|
|
||||||
model: 'test',
|
|
||||||
prompt_version: 'test-v1',
|
|
||||||
input_token_count: 0,
|
|
||||||
output_token_count: 0,
|
|
||||||
edit_diff: null,
|
|
||||||
applied_entry_id: null,
|
|
||||||
invalidated_reason: null,
|
|
||||||
created_at: '2026-04-23T00:00:00Z',
|
|
||||||
accepted_at: null,
|
|
||||||
accepted_by_user_id: null,
|
|
||||||
rejected_at: null,
|
|
||||||
updated_at: '2026-04-23T00:00:00Z',
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeInboxItem(overrides: Partial<InvoiceInboxItem> = {}): InvoiceInboxItem {
|
|
||||||
// Only the fields re-validate inspects need to be realistic.
|
|
||||||
return {
|
|
||||||
id: 'inbox-1',
|
|
||||||
company_id: 'company-1',
|
|
||||||
user_id: 'user-1',
|
|
||||||
status: 'ready',
|
|
||||||
source: 'upload',
|
|
||||||
document_id: 'doc-1',
|
|
||||||
document_type: 'receipt',
|
|
||||||
extracted_data: null,
|
|
||||||
confidence: null,
|
|
||||||
matched_supplier_id: null,
|
|
||||||
matched_transaction_id: null,
|
|
||||||
match_confidence: null,
|
|
||||||
match_method: null,
|
|
||||||
match_reasoning: null,
|
|
||||||
raw_llm_response: null,
|
|
||||||
email_from: null,
|
|
||||||
email_subject: null,
|
|
||||||
email_received_at: null,
|
|
||||||
email_body_text: null,
|
|
||||||
resend_email_id: null,
|
|
||||||
resend_attachment_id: null,
|
|
||||||
raw_email_payload: null,
|
|
||||||
correlation_id: null,
|
|
||||||
created_supplier_invoice_id: null,
|
|
||||||
error_message: null,
|
|
||||||
created_at: '2026-04-23T00:00:00Z',
|
|
||||||
updated_at: '2026-04-23T00:00:00Z',
|
|
||||||
...overrides,
|
|
||||||
} as unknown as InvoiceInboxItem
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a scripted supabase mock where each `.from(table)` returns a chain
|
|
||||||
* whose terminal awaits resolve in FIFO order from the `results` queue.
|
|
||||||
*/
|
|
||||||
function scriptedSupabase(results: Array<{ data: unknown; error?: unknown }>) {
|
|
||||||
let i = 0
|
|
||||||
const buildChain = (): unknown => {
|
|
||||||
const handler: ProxyHandler<object> = {
|
|
||||||
get(_target, prop) {
|
|
||||||
if (prop === 'then') {
|
|
||||||
const next = results[i++] ?? { data: null, error: null }
|
|
||||||
return (resolve: (v: unknown) => void) =>
|
|
||||||
resolve({ data: next.data ?? null, error: next.error ?? null })
|
|
||||||
}
|
|
||||||
return () => buildChain()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return new Proxy({}, handler)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
from: vi.fn().mockImplementation(() => buildChain()),
|
|
||||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
|
||||||
} as unknown as import('@supabase/supabase-js').SupabaseClient
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('reValidateProposal', () => {
|
|
||||||
it('inbox item missing → fails with inbox_item_missing', async () => {
|
|
||||||
const proposal = makeProposal()
|
|
||||||
const supabase = scriptedSupabase([{ data: null }])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.code).toBe('inbox_item_missing')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inbox item already confirmed → fails with inbox_item_already_booked', async () => {
|
|
||||||
const proposal = makeProposal()
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem({ status: 'confirmed' }) },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.code).toBe('inbox_item_already_booked')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('match proposal → transaction missing → fails', async () => {
|
|
||||||
const proposal = makeProposal({ step_type: 'match' })
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem() },
|
|
||||||
{ data: null }, // transaction lookup → not found
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.code).toBe('transaction_missing')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('match proposal → transaction already booked → fails', async () => {
|
|
||||||
const proposal = makeProposal({ step_type: 'match' })
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem() },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: 'entry-1', company_id: 'company-1' } },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.code).toBe('transaction_already_booked')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('match proposal → happy path → ok', async () => {
|
|
||||||
const proposal = makeProposal({ step_type: 'match' })
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem() },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: null, company_id: 'company-1' } },
|
|
||||||
{ data: null }, // no other inbox item claims this transaction
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('booking proposal → no matched_transaction_id → step_prerequisite_missing', async () => {
|
|
||||||
const proposal = makeProposal({
|
|
||||||
step_type: 'booking',
|
|
||||||
proposal_json: {
|
|
||||||
lines: [
|
|
||||||
{ account_number: '5410', debit_amount: 100, credit_amount: 0, description: 'x' },
|
|
||||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100, description: 'x' },
|
|
||||||
],
|
|
||||||
vat_treatment: 'exempt',
|
|
||||||
default_private: false,
|
|
||||||
counterparty_template_proposal: null,
|
|
||||||
fiscal_period_id: 'period-1',
|
|
||||||
entry_date: '2026-04-23',
|
|
||||||
description: 'test',
|
|
||||||
} as BookingProposalPayload,
|
|
||||||
})
|
|
||||||
const supabase = scriptedSupabase([{ data: makeInboxItem({ matched_transaction_id: null }) }])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.code).toBe('step_prerequisite_missing')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('booking proposal → period closed → period_missing_or_closed', async () => {
|
|
||||||
const proposal = makeProposal({
|
|
||||||
step_type: 'booking',
|
|
||||||
proposal_json: {
|
|
||||||
lines: [
|
|
||||||
{ account_number: '5410', debit_amount: 100, credit_amount: 0, description: 'x' },
|
|
||||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100, description: 'x' },
|
|
||||||
],
|
|
||||||
vat_treatment: 'exempt',
|
|
||||||
default_private: false,
|
|
||||||
counterparty_template_proposal: null,
|
|
||||||
fiscal_period_id: 'period-1',
|
|
||||||
entry_date: '2026-04-23',
|
|
||||||
description: 'test',
|
|
||||||
} as BookingProposalPayload,
|
|
||||||
})
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem({ matched_transaction_id: 'tx-1' }) },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: null } },
|
|
||||||
{ data: { id: 'period-1', is_closed: true, locked_at: null } },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.code).toBe('period_missing_or_closed')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('booking proposal → grocery merchant + reduced_12 + 2026-04-15 → livsmedel_vat_rate_stale', async () => {
|
|
||||||
const proposal = makeProposal({
|
|
||||||
step_type: 'booking',
|
|
||||||
proposal_json: {
|
|
||||||
lines: [
|
|
||||||
{ account_number: '4010', debit_amount: 80, credit_amount: 0, description: 'Matvaror ICA Maxi' },
|
|
||||||
{ account_number: '2641', debit_amount: 9.6, credit_amount: 0, description: 'Ingående moms 12%' },
|
|
||||||
{ account_number: '1930', debit_amount: 0, credit_amount: 89.6, description: 'ICA Maxi' },
|
|
||||||
],
|
|
||||||
vat_treatment: 'reduced_12',
|
|
||||||
default_private: false,
|
|
||||||
counterparty_template_proposal: null,
|
|
||||||
fiscal_period_id: 'period-1',
|
|
||||||
entry_date: '2026-04-15',
|
|
||||||
description: 'ICA Maxi — matvaror',
|
|
||||||
} as BookingProposalPayload,
|
|
||||||
})
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'ICA Maxi Lindhagen' } }) },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: null } },
|
|
||||||
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
|
|
||||||
{ data: [
|
|
||||||
{ account_number: '4010', is_active: true },
|
|
||||||
{ account_number: '2641', is_active: true },
|
|
||||||
{ account_number: '1930', is_active: true },
|
|
||||||
] },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.code).toBe('livsmedel_vat_rate_stale')
|
|
||||||
expect(result.details?.expected).toBe('reduced_6')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('booking proposal → grocery merchant + reduced_6 + 2026-04-15 → ok', async () => {
|
|
||||||
const proposal = makeProposal({
|
|
||||||
step_type: 'booking',
|
|
||||||
proposal_json: {
|
|
||||||
lines: [
|
|
||||||
{ account_number: '4010', debit_amount: 80, credit_amount: 0, description: 'Matvaror ICA' },
|
|
||||||
{ account_number: '2641', debit_amount: 4.8, credit_amount: 0, description: 'Ingående moms 6%' },
|
|
||||||
{ account_number: '1930', debit_amount: 0, credit_amount: 84.8, description: 'ICA' },
|
|
||||||
],
|
|
||||||
vat_treatment: 'reduced_6',
|
|
||||||
default_private: false,
|
|
||||||
counterparty_template_proposal: null,
|
|
||||||
fiscal_period_id: 'period-1',
|
|
||||||
entry_date: '2026-04-15',
|
|
||||||
description: 'ICA Maxi — matvaror',
|
|
||||||
} as BookingProposalPayload,
|
|
||||||
})
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'ICA Maxi' } }) },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: null } },
|
|
||||||
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
|
|
||||||
{ data: [
|
|
||||||
{ account_number: '4010', is_active: true },
|
|
||||||
{ account_number: '2641', is_active: true },
|
|
||||||
{ account_number: '1930', is_active: true },
|
|
||||||
] },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('booking proposal → grocery merchant + reduced_6 + 2025-12-15 → livsmedel_vat_rate_stale', async () => {
|
|
||||||
const proposal = makeProposal({
|
|
||||||
step_type: 'booking',
|
|
||||||
proposal_json: {
|
|
||||||
lines: [
|
|
||||||
{ account_number: '4010', debit_amount: 80, credit_amount: 0, description: 'Matvaror Coop' },
|
|
||||||
{ account_number: '2641', debit_amount: 4.8, credit_amount: 0, description: 'Ingående moms 6%' },
|
|
||||||
{ account_number: '1930', debit_amount: 0, credit_amount: 84.8, description: 'Coop' },
|
|
||||||
],
|
|
||||||
vat_treatment: 'reduced_6',
|
|
||||||
default_private: false,
|
|
||||||
counterparty_template_proposal: null,
|
|
||||||
fiscal_period_id: 'period-1',
|
|
||||||
entry_date: '2025-12-15',
|
|
||||||
description: 'Coop — matvaror',
|
|
||||||
} as BookingProposalPayload,
|
|
||||||
})
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'Coop Konsum' } }) },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: null } },
|
|
||||||
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
|
|
||||||
{ data: [
|
|
||||||
{ account_number: '4010', is_active: true },
|
|
||||||
{ account_number: '2641', is_active: true },
|
|
||||||
{ account_number: '1930', is_active: true },
|
|
||||||
] },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.code).toBe('livsmedel_vat_rate_stale')
|
|
||||||
expect(result.details?.expected).toBe('reduced_12')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('booking proposal → restaurang + reduced_6 → livsmedel_vat_rate_stale', async () => {
|
|
||||||
const proposal = makeProposal({
|
|
||||||
step_type: 'booking',
|
|
||||||
proposal_json: {
|
|
||||||
lines: [
|
|
||||||
{ account_number: '5810', debit_amount: 80, credit_amount: 0, description: 'Lunch på restaurang' },
|
|
||||||
{ account_number: '2641', debit_amount: 4.8, credit_amount: 0, description: 'Ingående moms 6%' },
|
|
||||||
{ account_number: '1930', debit_amount: 0, credit_amount: 84.8, description: 'Restaurang' },
|
|
||||||
],
|
|
||||||
vat_treatment: 'reduced_6',
|
|
||||||
default_private: false,
|
|
||||||
counterparty_template_proposal: null,
|
|
||||||
fiscal_period_id: 'period-1',
|
|
||||||
entry_date: '2026-04-15',
|
|
||||||
description: 'Restaurang Frantzén — lunch',
|
|
||||||
} as BookingProposalPayload,
|
|
||||||
})
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'Restaurang Frantzén' } }) },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: null } },
|
|
||||||
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
|
|
||||||
{ data: [
|
|
||||||
{ account_number: '5810', is_active: true },
|
|
||||||
{ account_number: '2641', is_active: true },
|
|
||||||
{ account_number: '1930', is_active: true },
|
|
||||||
] },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.code).toBe('livsmedel_vat_rate_stale')
|
|
||||||
expect(result.details?.signal).toBe('restaurang')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('booking proposal → restaurang + reduced_12 → ok (servering stays at 12%)', async () => {
|
|
||||||
const proposal = makeProposal({
|
|
||||||
step_type: 'booking',
|
|
||||||
proposal_json: {
|
|
||||||
lines: [
|
|
||||||
{ account_number: '5810', debit_amount: 80, credit_amount: 0, description: 'Lunch' },
|
|
||||||
{ account_number: '2641', debit_amount: 9.6, credit_amount: 0, description: 'Ingående moms 12%' },
|
|
||||||
{ account_number: '1930', debit_amount: 0, credit_amount: 89.6, description: 'Restaurang' },
|
|
||||||
],
|
|
||||||
vat_treatment: 'reduced_12',
|
|
||||||
default_private: false,
|
|
||||||
counterparty_template_proposal: null,
|
|
||||||
fiscal_period_id: 'period-1',
|
|
||||||
entry_date: '2026-04-15',
|
|
||||||
description: 'Restaurang — lunch',
|
|
||||||
} as BookingProposalPayload,
|
|
||||||
})
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'Restaurang Frantzén' } }) },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: null } },
|
|
||||||
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
|
|
||||||
{ data: [
|
|
||||||
{ account_number: '5810', is_active: true },
|
|
||||||
{ account_number: '2641', is_active: true },
|
|
||||||
{ account_number: '1930', is_active: true },
|
|
||||||
] },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('booking proposal → inactive account → account_missing_or_inactive', async () => {
|
|
||||||
const proposal = makeProposal({
|
|
||||||
step_type: 'booking',
|
|
||||||
proposal_json: {
|
|
||||||
lines: [
|
|
||||||
{ account_number: '5410', debit_amount: 100, credit_amount: 0, description: 'x' },
|
|
||||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100, description: 'x' },
|
|
||||||
],
|
|
||||||
vat_treatment: 'exempt',
|
|
||||||
default_private: false,
|
|
||||||
counterparty_template_proposal: null,
|
|
||||||
fiscal_period_id: 'period-1',
|
|
||||||
entry_date: '2026-04-23',
|
|
||||||
description: 'test',
|
|
||||||
} as BookingProposalPayload,
|
|
||||||
})
|
|
||||||
const supabase = scriptedSupabase([
|
|
||||||
{ data: makeInboxItem({ matched_transaction_id: 'tx-1' }) },
|
|
||||||
{ data: { id: 'tx-1', journal_entry_id: null } },
|
|
||||||
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
|
|
||||||
// Only 1930 is active; 5410 missing from results.
|
|
||||||
{ data: [{ account_number: '1930', is_active: true }] },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await reValidateProposal(supabase, 'company-1', proposal)
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) {
|
|
||||||
expect(result.code).toBe('account_missing_or_inactive')
|
|
||||||
expect(result.details?.missing_accounts).toEqual(['5410'])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
/**
|
|
||||||
* Apply path — what happens when a user accepts a pending proposal.
|
|
||||||
*
|
|
||||||
* - step='match': sets matched_transaction_id on the inbox item using the
|
|
||||||
* same columns as the existing smart-matcher (match_method, match_confidence,
|
|
||||||
* match_reasoning) so downstream consumers don't need to know whether the
|
|
||||||
* match came from AI or the deterministic matcher.
|
|
||||||
*
|
|
||||||
* - step='booking': creates a draft journal entry via the engine with
|
|
||||||
* created_via='ai_proposed' + source_proposal_id, then commits, then
|
|
||||||
* links the document. Mirrors the categorize API route's CAS guards.
|
|
||||||
*
|
|
||||||
* Re-validation MUST have already passed (call reValidateProposal() first).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import type {
|
|
||||||
AIProposal,
|
|
||||||
BookingProposalPayload,
|
|
||||||
CreateJournalEntryInput,
|
|
||||||
InvoiceInboxItem,
|
|
||||||
JournalEntry,
|
|
||||||
MatchProposalPayload,
|
|
||||||
} from '@/types'
|
|
||||||
import {
|
|
||||||
createDraftEntry,
|
|
||||||
commitEntry,
|
|
||||||
} from '@/lib/bookkeeping/engine'
|
|
||||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
|
||||||
import { createLogger } from '@/lib/logger'
|
|
||||||
|
|
||||||
const log = createLogger('ai-proposals/apply')
|
|
||||||
|
|
||||||
export interface ApplyMatchOutcome {
|
|
||||||
kind: 'match_applied'
|
|
||||||
inboxItemId: string
|
|
||||||
matchedTransactionId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApplyBookingOutcome {
|
|
||||||
kind: 'booking_applied'
|
|
||||||
inboxItemId: string
|
|
||||||
journalEntry: JournalEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApplyOutcome = ApplyMatchOutcome | ApplyBookingOutcome
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a re-validated proposal. Writes the proposal's changes to the
|
|
||||||
* domain tables (inbox_item, journal_entries, document_attachments).
|
|
||||||
*
|
|
||||||
* Callers should:
|
|
||||||
* 1. Run reValidateProposal() first.
|
|
||||||
* 2. Use this function's return value to update the proposal row
|
|
||||||
* (status='accepted', applied_entry_id).
|
|
||||||
*/
|
|
||||||
export async function applyProposal(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
userId: string,
|
|
||||||
proposal: AIProposal,
|
|
||||||
inboxItem: InvoiceInboxItem,
|
|
||||||
editedPayload?: MatchProposalPayload | BookingProposalPayload
|
|
||||||
): Promise<ApplyOutcome> {
|
|
||||||
const payload = editedPayload ?? proposal.proposal_json
|
|
||||||
|
|
||||||
if (proposal.step_type === 'match') {
|
|
||||||
return applyMatch(supabase, inboxItem, payload as MatchProposalPayload, proposal)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proposal.step_type === 'booking') {
|
|
||||||
return applyBooking(supabase, companyId, userId, inboxItem, payload as BookingProposalPayload, proposal)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unknown step_type: ${proposal.step_type}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyMatch(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
inboxItem: InvoiceInboxItem,
|
|
||||||
payload: MatchProposalPayload,
|
|
||||||
proposal: AIProposal
|
|
||||||
): Promise<ApplyMatchOutcome> {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({
|
|
||||||
matched_transaction_id: payload.matched_transaction_id,
|
|
||||||
match_method: 'llm',
|
|
||||||
match_confidence: proposal.confidence,
|
|
||||||
match_reasoning: proposal.reasoning,
|
|
||||||
})
|
|
||||||
.eq('id', inboxItem.id)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
// 23505 = the existing smart-match partial unique index (the same
|
|
||||||
// transaction was claimed by another inbox item since re-validation).
|
|
||||||
throw new Error(`Failed to apply match: ${error.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 'match_applied',
|
|
||||||
inboxItemId: inboxItem.id,
|
|
||||||
matchedTransactionId: payload.matched_transaction_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyBooking(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
userId: string,
|
|
||||||
inboxItem: InvoiceInboxItem,
|
|
||||||
payload: BookingProposalPayload,
|
|
||||||
proposal: AIProposal
|
|
||||||
): Promise<ApplyBookingOutcome> {
|
|
||||||
// 1. Draft the entry with provenance.
|
|
||||||
const input: CreateJournalEntryInput = {
|
|
||||||
fiscal_period_id: payload.fiscal_period_id,
|
|
||||||
entry_date: payload.entry_date,
|
|
||||||
description: payload.description,
|
|
||||||
source_type: 'bank_transaction',
|
|
||||||
source_id: inboxItem.matched_transaction_id!,
|
|
||||||
lines: payload.lines.map((l) => ({
|
|
||||||
account_number: l.account_number,
|
|
||||||
debit_amount: l.debit_amount,
|
|
||||||
credit_amount: l.credit_amount,
|
|
||||||
line_description: l.description,
|
|
||||||
})),
|
|
||||||
created_via: 'ai_proposed',
|
|
||||||
source_proposal_id: proposal.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft = await createDraftEntry(supabase, companyId, userId, input)
|
|
||||||
|
|
||||||
let entry: JournalEntry
|
|
||||||
try {
|
|
||||||
entry = await commitEntry(supabase, companyId, userId, draft.id)
|
|
||||||
} catch (commitError) {
|
|
||||||
// Mirror the safety net from createJournalEntry — cancel the orphan draft.
|
|
||||||
try {
|
|
||||||
await supabase
|
|
||||||
.from('journal_entries')
|
|
||||||
.update({ status: 'cancelled' })
|
|
||||||
.eq('id', draft.id)
|
|
||||||
.eq('status', 'draft')
|
|
||||||
} catch {
|
|
||||||
// Swallow — surface the original commit error
|
|
||||||
}
|
|
||||||
throw commitError
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Link the document to the entry (mirror the categorize route pattern).
|
|
||||||
if (inboxItem.document_id) {
|
|
||||||
try {
|
|
||||||
await linkToJournalEntry(supabase, companyId, inboxItem.document_id, entry.id)
|
|
||||||
} catch (err) {
|
|
||||||
log.error('Failed to link document to entry (entry stays posted):', err)
|
|
||||||
// The entry is already posted; re-linking can be retried from the UI.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Link the transaction to the entry (same CAS as categorize route).
|
|
||||||
if (inboxItem.matched_transaction_id) {
|
|
||||||
const { error: txError } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.update({
|
|
||||||
journal_entry_id: entry.id,
|
|
||||||
is_business: true,
|
|
||||||
})
|
|
||||||
.eq('id', inboxItem.matched_transaction_id)
|
|
||||||
.is('journal_entry_id', null)
|
|
||||||
|
|
||||||
if (txError) {
|
|
||||||
log.error('Failed to link transaction to entry:', txError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Mark the inbox item confirmed.
|
|
||||||
await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({ status: 'confirmed' })
|
|
||||||
.eq('id', inboxItem.id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 'booking_applied',
|
|
||||||
inboxItemId: inboxItem.id,
|
|
||||||
journalEntry: entry,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
/**
|
|
||||||
* Persistence helpers for ai_proposals and ai_requests.
|
|
||||||
*
|
|
||||||
* - Inserts new proposals, invalidating any prior pending proposal for the
|
|
||||||
* same (subject, step) first to keep the partial unique index happy.
|
|
||||||
* - Inserts new ai_requests with the same idempotency on (subject, request_type).
|
|
||||||
* - Appends processing_history audit events so the timeline on the inbox
|
|
||||||
* item tells the full story: DocumentIngested -> DocumentClassified ->
|
|
||||||
* AIProposalGenerated -> AIProposalAccepted -> JournalEntryPosted.
|
|
||||||
*
|
|
||||||
* All writes use the caller's Supabase client — service role for orchestrator
|
|
||||||
* context (RLS bypassed), user client for API route context (RLS enforced).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import type {
|
|
||||||
AIProposal,
|
|
||||||
AIProposalStepType,
|
|
||||||
AIRequest,
|
|
||||||
AIRequestType,
|
|
||||||
AISubjectType,
|
|
||||||
InvoiceInboxItem,
|
|
||||||
MatchProposalPayload,
|
|
||||||
BookingProposalPayload,
|
|
||||||
} from '@/types'
|
|
||||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
||||||
import { createLogger } from '@/lib/logger'
|
|
||||||
|
|
||||||
const log = createLogger('ai-proposals/persist')
|
|
||||||
|
|
||||||
// ── Proposal insert ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface InsertProposalInput {
|
|
||||||
companyId: string
|
|
||||||
userId: string
|
|
||||||
subjectType: AISubjectType
|
|
||||||
subjectId: string
|
|
||||||
stepType: AIProposalStepType
|
|
||||||
proposalJson: MatchProposalPayload | BookingProposalPayload
|
|
||||||
confidence: number
|
|
||||||
reasoning: string
|
|
||||||
model: string
|
|
||||||
promptVersion: string
|
|
||||||
inputTokens: number
|
|
||||||
outputTokens: number
|
|
||||||
aiRequestId?: string | null
|
|
||||||
correlationId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert a new pending proposal. Invalidates any prior pending proposal for
|
|
||||||
* the same (subject, step) first so the partial unique index accepts the
|
|
||||||
* new row and the audit trail reflects the replacement.
|
|
||||||
*/
|
|
||||||
export async function insertProposal(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
input: InsertProposalInput
|
|
||||||
): Promise<AIProposal> {
|
|
||||||
// 1. Invalidate any prior pending proposal for this (subject, step).
|
|
||||||
await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.update({
|
|
||||||
status: 'invalidated',
|
|
||||||
invalidated_reason: 'superseded_by_new_proposal',
|
|
||||||
})
|
|
||||||
.eq('subject_type', input.subjectType)
|
|
||||||
.eq('subject_id', input.subjectId)
|
|
||||||
.eq('step_type', input.stepType)
|
|
||||||
.eq('status', 'pending')
|
|
||||||
|
|
||||||
// 2. Insert the new proposal.
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.insert({
|
|
||||||
company_id: input.companyId,
|
|
||||||
user_id: input.userId,
|
|
||||||
subject_type: input.subjectType,
|
|
||||||
subject_id: input.subjectId,
|
|
||||||
step_type: input.stepType,
|
|
||||||
status: 'pending',
|
|
||||||
proposal_json: input.proposalJson,
|
|
||||||
confidence: input.confidence,
|
|
||||||
reasoning: input.reasoning,
|
|
||||||
model: input.model,
|
|
||||||
prompt_version: input.promptVersion,
|
|
||||||
input_token_count: input.inputTokens,
|
|
||||||
output_token_count: input.outputTokens,
|
|
||||||
ai_request_id: input.aiRequestId ?? null,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
throw new Error(`Failed to insert ai_proposal: ${error?.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const proposal = data as AIProposal
|
|
||||||
|
|
||||||
// 3. Audit: AIProposalGenerated
|
|
||||||
if (input.correlationId) {
|
|
||||||
try {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId: input.companyId,
|
|
||||||
correlationId: input.correlationId,
|
|
||||||
aggregateType: 'AIProposal',
|
|
||||||
aggregateId: proposal.id,
|
|
||||||
eventType: 'AIProposalGenerated',
|
|
||||||
payload: {
|
|
||||||
proposal_id: proposal.id,
|
|
||||||
subject_type: input.subjectType,
|
|
||||||
subject_id: input.subjectId,
|
|
||||||
step_type: input.stepType,
|
|
||||||
confidence: input.confidence,
|
|
||||||
model: input.model,
|
|
||||||
prompt_version: input.promptVersion,
|
|
||||||
input_tokens: input.inputTokens,
|
|
||||||
output_tokens: input.outputTokens,
|
|
||||||
},
|
|
||||||
actor: { type: 'llm', id: 'ai-agent' },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
log.error('Failed to append AIProposalGenerated:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return proposal
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request insert ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface InsertRequestInput {
|
|
||||||
companyId: string
|
|
||||||
subjectType: AISubjectType
|
|
||||||
subjectId: string
|
|
||||||
requestType: AIRequestType
|
|
||||||
message: string
|
|
||||||
requiredFields?: Record<string, unknown>
|
|
||||||
options?: Record<string, unknown>
|
|
||||||
model?: string
|
|
||||||
promptVersion?: string
|
|
||||||
correlationId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function insertRequest(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
input: InsertRequestInput
|
|
||||||
): Promise<AIRequest> {
|
|
||||||
// Idempotency: if an open request of the same (subject, request_type) exists,
|
|
||||||
// update it in place rather than erroring on the partial unique index.
|
|
||||||
const { data: existing } = await supabase
|
|
||||||
.from('ai_requests')
|
|
||||||
.select('id')
|
|
||||||
.eq('subject_type', input.subjectType)
|
|
||||||
.eq('subject_id', input.subjectId)
|
|
||||||
.eq('request_type', input.requestType)
|
|
||||||
.eq('status', 'open')
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const { data: updated, error: updateError } = await supabase
|
|
||||||
.from('ai_requests')
|
|
||||||
.update({
|
|
||||||
message: input.message,
|
|
||||||
required_fields: input.requiredFields ?? null,
|
|
||||||
options: input.options ?? null,
|
|
||||||
model: input.model ?? null,
|
|
||||||
prompt_version: input.promptVersion ?? null,
|
|
||||||
})
|
|
||||||
.eq('id', existing.id)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (updateError || !updated) {
|
|
||||||
throw new Error(`Failed to update ai_request: ${updateError?.message}`)
|
|
||||||
}
|
|
||||||
return updated as AIRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('ai_requests')
|
|
||||||
.insert({
|
|
||||||
company_id: input.companyId,
|
|
||||||
subject_type: input.subjectType,
|
|
||||||
subject_id: input.subjectId,
|
|
||||||
request_type: input.requestType,
|
|
||||||
message: input.message,
|
|
||||||
required_fields: input.requiredFields ?? null,
|
|
||||||
options: input.options ?? null,
|
|
||||||
model: input.model ?? null,
|
|
||||||
prompt_version: input.promptVersion ?? null,
|
|
||||||
status: 'open',
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
throw new Error(`Failed to insert ai_request: ${error?.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = data as AIRequest
|
|
||||||
|
|
||||||
if (input.correlationId) {
|
|
||||||
try {
|
|
||||||
await appendProcessingHistory({
|
|
||||||
companyId: input.companyId,
|
|
||||||
correlationId: input.correlationId,
|
|
||||||
aggregateType: 'AIRequest',
|
|
||||||
aggregateId: request.id,
|
|
||||||
eventType: 'AIRequestCreated',
|
|
||||||
payload: {
|
|
||||||
request_id: request.id,
|
|
||||||
subject_type: input.subjectType,
|
|
||||||
subject_id: input.subjectId,
|
|
||||||
request_type: input.requestType,
|
|
||||||
},
|
|
||||||
actor: { type: 'llm', id: 'ai-agent' },
|
|
||||||
occurredAt: new Date(),
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
log.error('Failed to append AIRequestCreated:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function fetchInboxItem(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
inboxItemId: string
|
|
||||||
): Promise<InvoiceInboxItem | null> {
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', inboxItemId)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
return data as InvoiceInboxItem | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark all pending proposals for an inbox item as skipped. Used when the
|
|
||||||
* user bypassed the AI flow and took a manual action (categorize,
|
|
||||||
* match-invoice, match-supplier-invoice) on the linked transaction.
|
|
||||||
*/
|
|
||||||
export async function skipPendingProposalsForSubject(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
subjectType: AISubjectType,
|
|
||||||
subjectId: string,
|
|
||||||
reason: string
|
|
||||||
): Promise<void> {
|
|
||||||
await supabase
|
|
||||||
.from('ai_proposals')
|
|
||||||
.update({
|
|
||||||
status: 'skipped',
|
|
||||||
invalidated_reason: reason,
|
|
||||||
})
|
|
||||||
.eq('subject_type', subjectType)
|
|
||||||
.eq('subject_id', subjectId)
|
|
||||||
.eq('status', 'pending')
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
/**
|
|
||||||
* Re-validation at accept time.
|
|
||||||
*
|
|
||||||
* A pending proposal can become stale between generation and accept:
|
|
||||||
* * matched transaction gets deleted or already booked
|
|
||||||
* * fiscal period closed or locked
|
|
||||||
* * account deactivated in the chart
|
|
||||||
* * inbox item already linked to a journal entry via a manual path
|
|
||||||
*
|
|
||||||
* This module runs the relevant checks and returns a typed error the API
|
|
||||||
* route translates to a structured response the UI can act on (e.g.,
|
|
||||||
* "period closed — reopen it or change the entry date").
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import type {
|
|
||||||
AIProposal,
|
|
||||||
BookingProposalPayload,
|
|
||||||
MatchProposalPayload,
|
|
||||||
InvoiceInboxItem,
|
|
||||||
} from '@/types'
|
|
||||||
|
|
||||||
export type ValidationFailureCode =
|
|
||||||
| 'inbox_item_missing'
|
|
||||||
| 'inbox_item_already_booked'
|
|
||||||
| 'transaction_missing'
|
|
||||||
| 'transaction_already_booked'
|
|
||||||
| 'transaction_already_matched_elsewhere'
|
|
||||||
| 'period_missing_or_closed'
|
|
||||||
| 'account_missing_or_inactive'
|
|
||||||
| 'receipt_file_missing'
|
|
||||||
| 'step_prerequisite_missing'
|
|
||||||
| 'livsmedel_vat_rate_stale'
|
|
||||||
|
|
||||||
export interface ValidationSuccess {
|
|
||||||
ok: true
|
|
||||||
inboxItem: InvoiceInboxItem
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationFailure {
|
|
||||||
ok: false
|
|
||||||
code: ValidationFailureCode
|
|
||||||
message: string
|
|
||||||
details?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ValidationResult = ValidationSuccess | ValidationFailure
|
|
||||||
|
|
||||||
export async function reValidateProposal(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
proposal: AIProposal
|
|
||||||
): Promise<ValidationResult> {
|
|
||||||
if (proposal.subject_type !== 'inbox_item') {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'step_prerequisite_missing',
|
|
||||||
message: 'Endast inkorgsobjekt stöds i denna version.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common: the inbox item still exists.
|
|
||||||
const { data: inboxItem, error: inboxError } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', proposal.subject_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (inboxError || !inboxItem) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'inbox_item_missing',
|
|
||||||
message: 'Kvittot/fakturan finns inte längre.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = inboxItem as InvoiceInboxItem
|
|
||||||
|
|
||||||
// If the document has already been booked via another path, skip.
|
|
||||||
if (item.status === 'confirmed') {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'inbox_item_already_booked',
|
|
||||||
message: 'Detta dokument är redan bokfört manuellt.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proposal.step_type === 'match') {
|
|
||||||
return reValidateMatch(supabase, companyId, item, proposal.proposal_json as MatchProposalPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proposal.step_type === 'booking') {
|
|
||||||
return reValidateBooking(supabase, companyId, item, proposal.proposal_json as BookingProposalPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'step_prerequisite_missing',
|
|
||||||
message: `Okänt stegtyp: ${proposal.step_type}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reValidateMatch(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
item: InvoiceInboxItem,
|
|
||||||
payload: MatchProposalPayload
|
|
||||||
): Promise<ValidationResult> {
|
|
||||||
// BFL 5 kap 7§: every verifikation requires an underlying source document.
|
|
||||||
// Block the match accept when no receipt file is attached so the user
|
|
||||||
// can't reach the booking step without proof. The UI shows an upload
|
|
||||||
// affordance in the receipt detail modal for this exact case.
|
|
||||||
if (!item.document_id) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'receipt_file_missing',
|
|
||||||
message: 'Kvittobild krävs innan du kan koppla transaktionen. Ladda upp en bild av kvittot först.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const txId = payload.matched_transaction_id
|
|
||||||
|
|
||||||
const { data: tx } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('id, journal_entry_id, company_id')
|
|
||||||
.eq('id', txId)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!tx) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'transaction_missing',
|
|
||||||
message: 'Den föreslagna transaktionen finns inte längre.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tx.journal_entry_id) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'transaction_already_booked',
|
|
||||||
message: 'Transaktionen är redan bokförd.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Another inbox item may have claimed this transaction via the existing
|
|
||||||
// smart-match partial unique index.
|
|
||||||
const { data: claimingInbox } = await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('id')
|
|
||||||
.eq('matched_transaction_id', txId)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.neq('id', item.id)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (claimingInbox) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'transaction_already_matched_elsewhere',
|
|
||||||
message: 'Transaktionen är redan matchad till ett annat dokument.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, inboxItem: item }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reValidateBooking(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
item: InvoiceInboxItem,
|
|
||||||
payload: BookingProposalPayload
|
|
||||||
): Promise<ValidationResult> {
|
|
||||||
if (!item.matched_transaction_id) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'step_prerequisite_missing',
|
|
||||||
message: 'Ingen matchande transaktion — stäng först matchningssteget.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The transaction still exists and is still unbooked.
|
|
||||||
const { data: tx } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('id, journal_entry_id')
|
|
||||||
.eq('id', item.matched_transaction_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!tx) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'transaction_missing',
|
|
||||||
message: 'Den matchade transaktionen finns inte längre.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tx.journal_entry_id) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'transaction_already_booked',
|
|
||||||
message: 'Transaktionen har redan bokförts.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fiscal period is open.
|
|
||||||
const { data: period } = await supabase
|
|
||||||
.from('fiscal_periods')
|
|
||||||
.select('id, is_closed, locked_at')
|
|
||||||
.eq('id', payload.fiscal_period_id)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (!period || period.is_closed || period.locked_at) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'period_missing_or_closed',
|
|
||||||
message: 'Räkenskapsåret är låst eller finns inte längre.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All accounts in the proposed lines are active in the chart.
|
|
||||||
const accountNumbers = [...new Set(payload.lines.map((l) => l.account_number))]
|
|
||||||
const { data: accounts } = await supabase
|
|
||||||
.from('chart_of_accounts')
|
|
||||||
.select('account_number, is_active')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.in('account_number', accountNumbers)
|
|
||||||
|
|
||||||
const foundActive = new Set(
|
|
||||||
(accounts || []).filter((a) => a.is_active).map((a) => a.account_number)
|
|
||||||
)
|
|
||||||
const missing = accountNumbers.filter((n) => !foundActive.has(n))
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'account_missing_or_inactive',
|
|
||||||
message: `Kontona saknas eller är inaktiva: ${missing.join(', ')}`,
|
|
||||||
details: { missing_accounts: missing },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const livsmedelMismatch = detectLivsmedelRateMismatch(item, payload)
|
|
||||||
if (livsmedelMismatch) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
code: 'livsmedel_vat_rate_stale',
|
|
||||||
message: livsmedelMismatch.message,
|
|
||||||
details: livsmedelMismatch.details,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, inboxItem: item }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sweden's livsmedel VAT temporarily drops from 12% to 6% between
|
|
||||||
// 2026-04-01 and 2027-12-31 (Prop. 2025/26:55). Restaurang/servering stays
|
|
||||||
// at 12% throughout. This guard catches AI proposals where the rate label
|
|
||||||
// is stale relative to the entry date for clearly-grocery merchants. The
|
|
||||||
// prompt is the primary defence; this is the safety net for prompt drift.
|
|
||||||
const LIVSMEDEL_REDUCED_START = '2026-04-01'
|
|
||||||
const LIVSMEDEL_REDUCED_END = '2027-12-31'
|
|
||||||
|
|
||||||
const GROCERY_CHAIN_KEYWORDS = [
|
|
||||||
'ica maxi',
|
|
||||||
'ica kvantum',
|
|
||||||
'ica supermarket',
|
|
||||||
'ica nära',
|
|
||||||
'ica',
|
|
||||||
'coop',
|
|
||||||
'hemköp',
|
|
||||||
'willys',
|
|
||||||
'lidl',
|
|
||||||
'city gross',
|
|
||||||
'tempo',
|
|
||||||
'mathem',
|
|
||||||
'mat.se',
|
|
||||||
'matse',
|
|
||||||
'netto',
|
|
||||||
'matöppet',
|
|
||||||
]
|
|
||||||
|
|
||||||
const RESTAURANG_KEYWORDS = [
|
|
||||||
'restaurang',
|
|
||||||
'servering',
|
|
||||||
'pizzeria',
|
|
||||||
'bistro',
|
|
||||||
'lunchrestaurang',
|
|
||||||
'sushi',
|
|
||||||
'café',
|
|
||||||
'kafé',
|
|
||||||
'cafe',
|
|
||||||
]
|
|
||||||
|
|
||||||
function detectLivsmedelRateMismatch(
|
|
||||||
item: InvoiceInboxItem,
|
|
||||||
payload: BookingProposalPayload
|
|
||||||
): { message: string; details: Record<string, unknown> } | null {
|
|
||||||
const treatment = payload.vat_treatment
|
|
||||||
if (treatment !== 'reduced_12' && treatment !== 'reduced_6') return null
|
|
||||||
|
|
||||||
const haystack = [
|
|
||||||
payload.description ?? '',
|
|
||||||
...payload.lines.map((l) => l.description ?? ''),
|
|
||||||
JSON.stringify(item.extracted_data ?? {}),
|
|
||||||
]
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
|
|
||||||
const isGrocery = GROCERY_CHAIN_KEYWORDS.some((k) => haystack.includes(k))
|
|
||||||
const isRestaurang = RESTAURANG_KEYWORDS.some((k) => haystack.includes(k))
|
|
||||||
|
|
||||||
// If both signals fire, treat as ambiguous and let it through — the
|
|
||||||
// user will review on the inbox card anyway.
|
|
||||||
if (isGrocery === isRestaurang) return null
|
|
||||||
|
|
||||||
const date = payload.entry_date
|
|
||||||
const inReducedWindow = date >= LIVSMEDEL_REDUCED_START && date <= LIVSMEDEL_REDUCED_END
|
|
||||||
|
|
||||||
if (isGrocery && treatment === 'reduced_12' && inReducedWindow) {
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
'Momssatsen 12 % stämmer inte — livsmedel ska bokföras med 6 % moms från 1 april 2026 t.o.m. 31 december 2027. Justera förslaget eller bokför manuellt.',
|
|
||||||
details: { signal: 'grocery', treatment, entry_date: date, expected: 'reduced_6' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGrocery && treatment === 'reduced_6' && !inReducedWindow) {
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
'Momssatsen 6 % gäller endast för livsmedel mellan 1 april 2026 och 31 december 2027. Övriga datum ska bokföras med 12 %.',
|
|
||||||
details: { signal: 'grocery', treatment, entry_date: date, expected: 'reduced_12' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRestaurang && treatment === 'reduced_6') {
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
'Restaurang- och serveringstjänster har 12 % moms (omfattas inte av livsmedelssänkningen). Justera förslaget eller bokför manuellt.',
|
|
||||||
details: { signal: 'restaurang', treatment, entry_date: date, expected: 'reduced_12' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1033,12 +1033,20 @@ describe('UpdateSettingsSchema', () => {
|
|||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects aktiebolag with kontantmetoden (BFNAR 2006:1)', () => {
|
it('allows aktiebolag with kontantmetoden (BFL 5 kap. 2 §)', () => {
|
||||||
const result = UpdateSettingsSchema.safeParse({
|
const result = UpdateSettingsSchema.safeParse({
|
||||||
entity_type: 'aktiebolag',
|
entity_type: 'aktiebolag',
|
||||||
accounting_method: 'cash',
|
accounting_method: 'cash',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows aktiebolag with faktureringsmetoden', () => {
|
||||||
|
const result = UpdateSettingsSchema.safeParse({
|
||||||
|
entity_type: 'aktiebolag',
|
||||||
|
accounting_method: 'accrual',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows enskild firma with kontantmetoden', () => {
|
it('allows enskild firma with kontantmetoden', () => {
|
||||||
|
|||||||
@@ -411,18 +411,6 @@ export const UpdateSettingsSchema = z.object({
|
|||||||
message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)',
|
message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)',
|
||||||
path: ['fiscal_year_start_month'],
|
path: ['fiscal_year_start_month'],
|
||||||
}
|
}
|
||||||
).refine(
|
|
||||||
(data) => {
|
|
||||||
// BFNAR 2006:1: Aktiebolag must use accrual accounting (faktureringsmetoden)
|
|
||||||
if (data.entity_type === 'aktiebolag' && data.accounting_method !== undefined) {
|
|
||||||
return data.accounting_method === 'accrual'
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'Aktiebolag måste använda faktureringsmetoden (BFNAR 2006:1)',
|
|
||||||
path: ['accounting_method'],
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -223,8 +223,6 @@ export async function createDraftEntry(
|
|||||||
source_id: input.source_id || null,
|
source_id: input.source_id || null,
|
||||||
notes: input.notes || null,
|
notes: input.notes || null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_via: input.created_via || 'manual',
|
|
||||||
source_proposal_id: input.source_proposal_id || null,
|
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ describe('createCompanyFromTicRole', () => {
|
|||||||
expect(settings.entity_type).toBe('enskild_firma')
|
expect(settings.entity_type).toBe('enskild_firma')
|
||||||
expect(settings.vat_registered).toBe(false)
|
expect(settings.vat_registered).toBe(false)
|
||||||
expect(settings.moms_period).toBeNull()
|
expect(settings.moms_period).toBeNull()
|
||||||
// EF entities default to cash per K1/BFNAR 2013:2; AB must use accrual (K2/K3).
|
// Default for EF is kontantmetoden (BFL 5 kap. 2 §); AB defaults to accrual but may switch.
|
||||||
expect(settings.accounting_method).toBe('cash')
|
expect(settings.accounting_method).toBe('cash')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -303,11 +303,9 @@ export async function createCompanyFromTicRole(params: {
|
|||||||
// under SFL.
|
// under SFL.
|
||||||
const momsPeriod = vatRegistered ? 'quarterly' : null
|
const momsPeriod = vatRegistered ? 'quarterly' : null
|
||||||
|
|
||||||
// EF ≤3 MSEK may use kontantmetoden under K1/BFNAR 2013:2; above that
|
// Default by entity_type: EF → kontantmetoden, AB → faktureringsmetoden.
|
||||||
// threshold, BFNAR 2017:3 requires bokföringsmässiga grunder. We default
|
// Both forms may use either method under BFL 5 kap. 2 § when annual net
|
||||||
// to cash because the vast majority of EF users are small; users above
|
// turnover is normally ≤ 3 MSEK; users can change in /settings/bookkeeping.
|
||||||
// the threshold can switch in /settings/bookkeeping. Aktiebolag must use
|
|
||||||
// accrual under K2/K3.
|
|
||||||
const accountingMethod = entityType === 'enskild_firma' ? 'cash' : 'accrual'
|
const accountingMethod = entityType === 'enskild_firma' ? 'cash' : 'accrual'
|
||||||
|
|
||||||
const settings: Record<string, unknown> = {
|
const settings: Record<string, unknown> = {
|
||||||
|
|||||||
@@ -1,352 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { matchDocumentToTransactions } from '../document-matcher'
|
|
||||||
import { makeInvoiceInboxItem, makeTransaction } from '@/tests/helpers'
|
|
||||||
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
|
||||||
|
|
||||||
describe('matchDocumentToTransactions', () => {
|
|
||||||
const mockSupabase = {} as never // Not used when candidateTransactions is provided
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('supplier_invoice matching', () => {
|
|
||||||
const baseExtraction: InvoiceExtractionResult = {
|
|
||||||
supplier: {
|
|
||||||
name: 'Telia AB',
|
|
||||||
orgNumber: '556103-4249',
|
|
||||||
vatNumber: 'SE556103424901',
|
|
||||||
address: 'Stockholm',
|
|
||||||
bankgiro: '5820-5093',
|
|
||||||
plusgiro: null,
|
|
||||||
},
|
|
||||||
invoice: {
|
|
||||||
invoiceNumber: 'INV-2024-001',
|
|
||||||
invoiceDate: '2024-06-10',
|
|
||||||
dueDate: '2024-06-20',
|
|
||||||
paymentReference: '73401284756',
|
|
||||||
currency: 'SEK',
|
|
||||||
},
|
|
||||||
lineItems: [],
|
|
||||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
|
||||||
vatBreakdown: [],
|
|
||||||
confidence: 0.95,
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns null for government_letter type', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
document_type: 'government_letter',
|
|
||||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when no extracted_data', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({ extracted_data: null })
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when no candidate transactions', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pass 1: matches by payment reference with 0.98 confidence', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: '73401284756',
|
|
||||||
date: '2024-06-20',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.confidence).toBe(0.98)
|
|
||||||
expect(result!.method).toBe('payment_reference')
|
|
||||||
expect(result!.transactionId).toBe(tx.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pass 1: matches with whitespace/dash-normalized references', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: '734 012 847 56',
|
|
||||||
date: '2024-06-20',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.confidence).toBe(0.98)
|
|
||||||
expect(result!.method).toBe('payment_reference')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pass 2: matches by exact amount + bankgiro with 0.92 confidence', async () => {
|
|
||||||
const extractionNoRef = {
|
|
||||||
...baseExtraction,
|
|
||||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
|
||||||
}
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: extractionNoRef as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: null,
|
|
||||||
description: 'BETALNING 58205093',
|
|
||||||
date: '2024-06-20',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.confidence).toBe(0.92)
|
|
||||||
expect(result!.method).toBe('payment_reference')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pass 3: matches by exact amount + date proximity with 0.85 confidence', async () => {
|
|
||||||
const extractionNoBg = {
|
|
||||||
...baseExtraction,
|
|
||||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
|
||||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
|
||||||
}
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: null,
|
|
||||||
description: 'PAYMENT',
|
|
||||||
date: '2024-06-22',
|
|
||||||
merchant_name: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.confidence).toBe(0.85)
|
|
||||||
expect(result!.method).toBe('amount_date')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pass 3: matches with lower confidence at 6–14 days', async () => {
|
|
||||||
const extractionNoBg = {
|
|
||||||
...baseExtraction,
|
|
||||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
|
||||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
|
||||||
}
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: null,
|
|
||||||
description: 'PAYMENT',
|
|
||||||
date: '2024-06-28', // 8 days after due date
|
|
||||||
merchant_name: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.confidence).toBe(0.75)
|
|
||||||
expect(result!.method).toBe('amount_date')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pass 3: does not match if date is >14 days away', async () => {
|
|
||||||
const extractionNoBg = {
|
|
||||||
...baseExtraction,
|
|
||||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
|
||||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
|
||||||
}
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: null,
|
|
||||||
description: 'PAYMENT',
|
|
||||||
date: '2024-07-06', // 16 days after due date
|
|
||||||
merchant_name: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pass 4: matches by fuzzy amount + supplier name with 0.70 confidence', async () => {
|
|
||||||
const extractionMinimal = {
|
|
||||||
...baseExtraction,
|
|
||||||
invoice: {
|
|
||||||
...baseExtraction.invoice,
|
|
||||||
paymentReference: null,
|
|
||||||
dueDate: null,
|
|
||||||
invoiceDate: null,
|
|
||||||
},
|
|
||||||
supplier: {
|
|
||||||
...baseExtraction.supplier,
|
|
||||||
bankgiro: null,
|
|
||||||
plusgiro: null,
|
|
||||||
name: 'Telia Sverige',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: extractionMinimal as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: null,
|
|
||||||
description: 'telia faktura april',
|
|
||||||
date: '2024-06-15',
|
|
||||||
merchant_name: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.confidence).toBe(0.70)
|
|
||||||
expect(result!.method).toBe('amount_merchant')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prefers higher confidence matches', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
|
|
||||||
const txWithRef = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: '73401284756',
|
|
||||||
date: '2024-06-20',
|
|
||||||
})
|
|
||||||
const txWithAmount = makeTransaction({
|
|
||||||
amount: -1000,
|
|
||||||
reference: null,
|
|
||||||
description: 'BETALNING',
|
|
||||||
date: '2024-06-20',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [txWithAmount, txWithRef])
|
|
||||||
expect(result!.confidence).toBe(0.98)
|
|
||||||
expect(result!.transactionId).toBe(txWithRef.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('receipt matching', () => {
|
|
||||||
const receiptExtraction: ReceiptExtractionResult = {
|
|
||||||
merchant: {
|
|
||||||
name: 'ICA Maxi',
|
|
||||||
orgNumber: null,
|
|
||||||
vatNumber: null,
|
|
||||||
isForeign: false,
|
|
||||||
},
|
|
||||||
receipt: {
|
|
||||||
date: '2024-06-15',
|
|
||||||
time: '14:30',
|
|
||||||
currency: 'SEK',
|
|
||||||
},
|
|
||||||
lineItems: [],
|
|
||||||
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
|
|
||||||
flags: {
|
|
||||||
isRestaurant: false,
|
|
||||||
isSystembolaget: false,
|
|
||||||
isForeignMerchant: false,
|
|
||||||
},
|
|
||||||
confidence: 0.92,
|
|
||||||
}
|
|
||||||
|
|
||||||
it('matches receipt to transaction with high confidence', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
document_type: 'receipt',
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -299,
|
|
||||||
date: '2024-06-15',
|
|
||||||
merchant_name: 'ICA Maxi',
|
|
||||||
description: 'ICA MAXI STOCKHOLM',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.method).toBe('receipt_match')
|
|
||||||
expect(result!.confidence).toBeGreaterThanOrEqual(0.60)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when amount is too different', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
document_type: 'receipt',
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -500,
|
|
||||||
date: '2024-06-15',
|
|
||||||
merchant_name: 'ICA Maxi',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when date is too far away', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
document_type: 'receipt',
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -299,
|
|
||||||
date: '2024-06-25', // 10 days after
|
|
||||||
merchant_name: 'ICA Maxi',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips transactions with existing receipt_id', async () => {
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
document_type: 'receipt',
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({
|
|
||||||
amount: -299,
|
|
||||||
date: '2024-06-15',
|
|
||||||
merchant_name: 'ICA Maxi',
|
|
||||||
receipt_id: 'existing-receipt',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when total is 0 or null', async () => {
|
|
||||||
const zeroExtraction = {
|
|
||||||
...receiptExtraction,
|
|
||||||
totals: { ...receiptExtraction.totals, total: 0 },
|
|
||||||
}
|
|
||||||
const item = makeInvoiceInboxItem({
|
|
||||||
document_type: 'receipt',
|
|
||||||
status: 'ready',
|
|
||||||
extracted_data: zeroExtraction as unknown as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
const tx = makeTransaction({ amount: -299, date: '2024-06-15' })
|
|
||||||
|
|
||||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* Batch Document Matching
|
|
||||||
*
|
|
||||||
* Orchestrates matching multiple inbox items to transactions in a single sweep.
|
|
||||||
* Fetches all unbooked transactions once, then runs per-item matching with
|
|
||||||
* greedy assignment to prevent double-matching.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import type { InvoiceInboxItem, Transaction } from '@/types'
|
|
||||||
import { matchDocumentToTransactions, type DocumentMatchResult } from './document-matcher'
|
|
||||||
|
|
||||||
export interface BatchMatchResult {
|
|
||||||
matched: number
|
|
||||||
total: number
|
|
||||||
matches: Array<{ inboxItemId: string; result: DocumentMatchResult }>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a matching sweep for all ready unmatched inbox items.
|
|
||||||
*
|
|
||||||
* 1. Fetches all ready/processing inbox items without a matched_transaction_id
|
|
||||||
* 2. Fetches all unbooked expense transactions
|
|
||||||
* 3. Runs matching per item, greedily assigning (highest confidence first)
|
|
||||||
* 4. Persists matches back to inbox items
|
|
||||||
*/
|
|
||||||
export async function runDocumentMatchingSweep(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
inboxItemIds?: string[]
|
|
||||||
): Promise<BatchMatchResult> {
|
|
||||||
// 1. Fetch unmatched inbox items
|
|
||||||
let query = supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.is('matched_transaction_id', null)
|
|
||||||
.in('status', ['ready', 'processing'])
|
|
||||||
|
|
||||||
if (inboxItemIds && inboxItemIds.length > 0) {
|
|
||||||
query = query.in('id', inboxItemIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: inboxItems, error: itemsError } = await query
|
|
||||||
|
|
||||||
if (itemsError || !inboxItems || inboxItems.length === 0) {
|
|
||||||
console.log(`[batch-match] No unmatched inbox items found`)
|
|
||||||
return { matched: 0, total: 0, matches: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[batch-match] Starting sweep: ${inboxItems.length} unmatched inbox items`)
|
|
||||||
|
|
||||||
// 2. Fetch all unbooked expense transactions (broad window: last 90 days)
|
|
||||||
const ninetyDaysAgo = new Date()
|
|
||||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
|
||||||
|
|
||||||
const { data: transactions, error: txError } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.is('journal_entry_id', null)
|
|
||||||
.is('is_business', null)
|
|
||||||
.lt('amount', 0)
|
|
||||||
.gte('date', ninetyDaysAgo.toISOString().split('T')[0])
|
|
||||||
.order('date', { ascending: false })
|
|
||||||
|
|
||||||
if (txError || !transactions || transactions.length === 0) {
|
|
||||||
console.log(`[batch-match] No candidate transactions found (last 90 days)`)
|
|
||||||
return { matched: 0, total: inboxItems.length, matches: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[batch-match] ${transactions.length} candidate transactions (last 90 days)`)
|
|
||||||
|
|
||||||
// 3. Run matching for each item and collect results
|
|
||||||
const pendingMatches: Array<{
|
|
||||||
inboxItemId: string
|
|
||||||
result: DocumentMatchResult
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const item of inboxItems as InvoiceInboxItem[]) {
|
|
||||||
const result = await matchDocumentToTransactions(
|
|
||||||
supabase,
|
|
||||||
companyId,
|
|
||||||
item,
|
|
||||||
transactions as Transaction[]
|
|
||||||
)
|
|
||||||
if (result) {
|
|
||||||
pendingMatches.push({ inboxItemId: item.id, result })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Greedy assignment: sort by confidence desc, assign each transaction at most once
|
|
||||||
pendingMatches.sort((a, b) => b.result.confidence - a.result.confidence)
|
|
||||||
|
|
||||||
const assignedTransactionIds = new Set<string>()
|
|
||||||
const finalMatches: typeof pendingMatches = []
|
|
||||||
|
|
||||||
for (const match of pendingMatches) {
|
|
||||||
if (assignedTransactionIds.has(match.result.transactionId)) {
|
|
||||||
console.log(`[batch-match] Skipped item=${match.inboxItemId} → tx=${match.result.transactionId} (already assigned to higher-confidence match)`)
|
|
||||||
continue // Transaction already assigned to a higher-confidence match
|
|
||||||
}
|
|
||||||
assignedTransactionIds.add(match.result.transactionId)
|
|
||||||
finalMatches.push(match)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[batch-match] Sweep complete: ${finalMatches.length}/${inboxItems.length} items matched, ${pendingMatches.length - finalMatches.length} skipped (greedy dedup)`)
|
|
||||||
|
|
||||||
// 5. Persist matches
|
|
||||||
for (const { inboxItemId, result } of finalMatches) {
|
|
||||||
await supabase
|
|
||||||
.from('invoice_inbox_items')
|
|
||||||
.update({
|
|
||||||
matched_transaction_id: result.transactionId,
|
|
||||||
match_confidence: result.confidence,
|
|
||||||
match_method: result.method,
|
|
||||||
})
|
|
||||||
.eq('id', inboxItemId)
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
matched: finalMatches.length,
|
|
||||||
total: inboxItems.length,
|
|
||||||
matches: finalMatches,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
/**
|
|
||||||
* Document-to-Transaction Matcher
|
|
||||||
*
|
|
||||||
* Pure matching logic that works from extracted data already stored on inbox items.
|
|
||||||
* Zero AI or extension dependencies — works entirely from structured data.
|
|
||||||
*
|
|
||||||
* Matching passes by document type:
|
|
||||||
*
|
|
||||||
* Supplier invoices:
|
|
||||||
* 1. Payment reference exact match → 0.98
|
|
||||||
* 2. Exact amount + bankgiro → 0.92
|
|
||||||
* 3. Exact amount + date ±5 days → 0.85
|
|
||||||
* 4. Fuzzy amount + supplier name → 0.70
|
|
||||||
*
|
|
||||||
* Receipts:
|
|
||||||
* Weighted scoring (amount 40%, date 25%, merchant 35%), min confidence 0.60
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
import type { InvoiceInboxItem, Transaction, InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
|
||||||
import {
|
|
||||||
calculateMerchantSimilarity,
|
|
||||||
calculateMatchConfidence,
|
|
||||||
} from './core-receipt-matcher'
|
|
||||||
|
|
||||||
export type DocumentMatchMethod =
|
|
||||||
| 'payment_reference'
|
|
||||||
| 'amount_date'
|
|
||||||
| 'amount_merchant'
|
|
||||||
| 'receipt_match'
|
|
||||||
|
|
||||||
export interface DocumentMatchResult {
|
|
||||||
transactionId: string
|
|
||||||
confidence: number
|
|
||||||
method: DocumentMatchMethod
|
|
||||||
matchReasons: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match a single inbox item to the best candidate transaction.
|
|
||||||
*
|
|
||||||
* If `candidateTransactions` is not provided, fetches unbooked expense
|
|
||||||
* transactions within ±7 days of the document date.
|
|
||||||
*/
|
|
||||||
export async function matchDocumentToTransactions(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
inboxItem: InvoiceInboxItem,
|
|
||||||
candidateTransactions?: Transaction[]
|
|
||||||
): Promise<DocumentMatchResult | null> {
|
|
||||||
const tag = `[document-matcher] item=${inboxItem.id} type=${inboxItem.document_type}`
|
|
||||||
|
|
||||||
// Only match supplier invoices and receipts
|
|
||||||
if (inboxItem.document_type === 'government_letter' || inboxItem.document_type === 'unknown') {
|
|
||||||
console.log(`${tag} — skipped (unsupported document type)`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inboxItem.extracted_data) {
|
|
||||||
console.log(`${tag} — skipped (no extracted_data)`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, companyId, inboxItem))
|
|
||||||
|
|
||||||
console.log(`${tag} — ${transactions.length} candidate transactions`)
|
|
||||||
|
|
||||||
if (transactions.length === 0) {
|
|
||||||
console.log(`${tag} — no candidates, aborting`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: DocumentMatchResult | null = null
|
|
||||||
|
|
||||||
if (inboxItem.document_type === 'supplier_invoice') {
|
|
||||||
result = matchSupplierInvoiceDocument(inboxItem, transactions)
|
|
||||||
} else if (inboxItem.document_type === 'receipt') {
|
|
||||||
result = matchReceiptDocument(inboxItem, transactions)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
console.log(`${tag} — MATCHED tx=${result.transactionId} confidence=${result.confidence} method=${result.method} reasons=[${result.matchReasons.join(', ')}]`)
|
|
||||||
} else {
|
|
||||||
console.log(`${tag} — no match found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch unbooked expense transactions within ±7 days of the document date.
|
|
||||||
*/
|
|
||||||
async function fetchCandidateTransactions(
|
|
||||||
supabase: SupabaseClient,
|
|
||||||
companyId: string,
|
|
||||||
inboxItem: InvoiceInboxItem
|
|
||||||
): Promise<Transaction[]> {
|
|
||||||
const docDate = getDocumentDate(inboxItem)
|
|
||||||
if (!docDate) return []
|
|
||||||
|
|
||||||
const startDate = new Date(docDate)
|
|
||||||
startDate.setDate(startDate.getDate() - 7)
|
|
||||||
const endDate = new Date(docDate)
|
|
||||||
endDate.setDate(endDate.getDate() + 7)
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('transactions')
|
|
||||||
.select('*')
|
|
||||||
.eq('company_id', companyId)
|
|
||||||
.is('journal_entry_id', null)
|
|
||||||
.is('is_business', null)
|
|
||||||
.lt('amount', 0)
|
|
||||||
.gte('date', startDate.toISOString().split('T')[0])
|
|
||||||
.lte('date', endDate.toISOString().split('T')[0])
|
|
||||||
.order('date', { ascending: false })
|
|
||||||
|
|
||||||
if (error || !data) return []
|
|
||||||
return data as Transaction[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the most relevant date from an inbox item's extracted data.
|
|
||||||
*/
|
|
||||||
function getDocumentDate(inboxItem: InvoiceInboxItem): string | null {
|
|
||||||
const data = inboxItem.extracted_data as Record<string, unknown> | null
|
|
||||||
if (!data) return null
|
|
||||||
|
|
||||||
if (inboxItem.document_type === 'supplier_invoice') {
|
|
||||||
const extraction = data as unknown as InvoiceExtractionResult
|
|
||||||
return extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inboxItem.document_type === 'receipt') {
|
|
||||||
const extraction = data as unknown as ReceiptExtractionResult
|
|
||||||
return extraction.receipt?.date ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match a supplier invoice inbox item to transactions using a 4-pass algorithm.
|
|
||||||
*/
|
|
||||||
function matchSupplierInvoiceDocument(
|
|
||||||
inboxItem: InvoiceInboxItem,
|
|
||||||
transactions: Transaction[]
|
|
||||||
): DocumentMatchResult | null {
|
|
||||||
const tag = `[document-matcher:supplier] item=${inboxItem.id}`
|
|
||||||
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
|
|
||||||
if (!extraction) return null
|
|
||||||
|
|
||||||
const invoiceTotal = extraction.totals?.total
|
|
||||||
if (invoiceTotal == null || invoiceTotal === 0) {
|
|
||||||
console.log(`${tag} — no invoice total in extracted data`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentRef = extraction.invoice?.paymentReference
|
|
||||||
const bankgiro = extraction.supplier?.bankgiro
|
|
||||||
const plusgiro = extraction.supplier?.plusgiro
|
|
||||||
const supplierName = extraction.supplier?.name
|
|
||||||
const dueDate = extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate
|
|
||||||
|
|
||||||
console.log(`${tag} — extracted: total=${invoiceTotal}, supplier=${supplierName || '?'}, dueDate=${dueDate || '?'}, paymentRef=${paymentRef || '?'}, bankgiro=${bankgiro || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
|
||||||
|
|
||||||
let bestMatch: DocumentMatchResult | null = null
|
|
||||||
|
|
||||||
for (const tx of transactions) {
|
|
||||||
const txAmount = Math.abs(tx.amount)
|
|
||||||
const txDesc = (tx.description || '').toLowerCase()
|
|
||||||
const txRef = tx.reference || ''
|
|
||||||
|
|
||||||
// Pass 1: Payment reference exact match → 0.98
|
|
||||||
if (paymentRef && txRef) {
|
|
||||||
const normTxRef = txRef.replace(/\D/g, '')
|
|
||||||
const normPayRef = paymentRef.replace(/\D/g, '')
|
|
||||||
if (normTxRef && normPayRef && normTxRef === normPayRef) {
|
|
||||||
console.log(`${tag} — Pass 1 HIT: tx=${tx.id} ref=${normPayRef}`)
|
|
||||||
return {
|
|
||||||
transactionId: tx.id,
|
|
||||||
confidence: 0.98,
|
|
||||||
method: 'payment_reference',
|
|
||||||
matchReasons: ['Betalningsreferens matchar'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 2: Exact amount + bankgiro/plusgiro → 0.92
|
|
||||||
const amountMatch = Math.abs(txAmount - invoiceTotal) < 0.005
|
|
||||||
if (amountMatch) {
|
|
||||||
const bgNorm = bankgiro?.replace(/\D/g, '')
|
|
||||||
const pgNorm = plusgiro?.replace(/\D/g, '')
|
|
||||||
const hasBgMatch = bgNorm && txDesc.includes(bgNorm)
|
|
||||||
const hasPgMatch = pgNorm && txDesc.includes(pgNorm)
|
|
||||||
|
|
||||||
if (hasBgMatch || hasPgMatch) {
|
|
||||||
console.log(`${tag} — Pass 2 HIT: tx=${tx.id} amount=${txAmount} bg/pg match`)
|
|
||||||
return {
|
|
||||||
transactionId: tx.id,
|
|
||||||
confidence: 0.92,
|
|
||||||
method: 'payment_reference',
|
|
||||||
matchReasons: ['Exakt belopp', hasBgMatch ? 'Bankgiro matchar' : 'Plusgiro matchar'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 3: Exact amount + date ±14 days → 0.85 (close) / 0.75 (wider)
|
|
||||||
// Invoices are often paid early or a few days late, so we use a 14-day window.
|
|
||||||
if (amountMatch && dueDate) {
|
|
||||||
const txDate = new Date(tx.date)
|
|
||||||
const docDate = new Date(dueDate)
|
|
||||||
const diffDays = Math.abs((txDate.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
if (diffDays <= 14) {
|
|
||||||
// Higher confidence for close dates, lower for wider window
|
|
||||||
const confidence = diffDays <= 5 ? 0.85 : 0.75
|
|
||||||
console.log(`${tag} — Pass 3 HIT: tx=${tx.id} amount=${txAmount} date_diff=${diffDays.toFixed(1)}d → confidence=${confidence}`)
|
|
||||||
const candidate: DocumentMatchResult = {
|
|
||||||
transactionId: tx.id,
|
|
||||||
confidence,
|
|
||||||
method: 'amount_date',
|
|
||||||
matchReasons: ['Exakt belopp', diffDays === 0 ? 'Exakt datum' : `Datum ±${Math.round(diffDays)} dagar`],
|
|
||||||
}
|
|
||||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
|
||||||
bestMatch = candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 4: Fuzzy amount (±1%) + supplier name in description → 0.70
|
|
||||||
const fuzzyAmountMatch = Math.abs(txAmount - invoiceTotal) / invoiceTotal <= 0.01
|
|
||||||
if (fuzzyAmountMatch && supplierName) {
|
|
||||||
const normalizedName = supplierName.toLowerCase().replace(/[^\w\såäöé]/g, '')
|
|
||||||
const nameWords = normalizedName.split(/\s+/).filter((w) => w.length >= 3)
|
|
||||||
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
|
|
||||||
|
|
||||||
if (nameInDesc) {
|
|
||||||
console.log(`${tag} — Pass 4 HIT: tx=${tx.id} amount=${txAmount} (~${((Math.abs(txAmount - invoiceTotal) / invoiceTotal) * 100).toFixed(1)}%) name words=[${nameWords.join(',')}]`)
|
|
||||||
const candidate: DocumentMatchResult = {
|
|
||||||
transactionId: tx.id,
|
|
||||||
confidence: 0.70,
|
|
||||||
method: 'amount_merchant',
|
|
||||||
matchReasons: ['Belopp matchar (±1%)', 'Leverantörsnamn i beskrivning'],
|
|
||||||
}
|
|
||||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
|
||||||
bestMatch = candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match a receipt inbox item to transactions using weighted scoring.
|
|
||||||
* Weights: amount 40%, date 25%, merchant 35%. Min confidence: 0.60.
|
|
||||||
*/
|
|
||||||
function matchReceiptDocument(
|
|
||||||
inboxItem: InvoiceInboxItem,
|
|
||||||
transactions: Transaction[]
|
|
||||||
): DocumentMatchResult | null {
|
|
||||||
const tag = `[document-matcher:receipt] item=${inboxItem.id}`
|
|
||||||
const extraction = inboxItem.extracted_data as unknown as ReceiptExtractionResult
|
|
||||||
if (!extraction) return null
|
|
||||||
|
|
||||||
const receiptTotal = extraction.totals?.total
|
|
||||||
const receiptDate = extraction.receipt?.date
|
|
||||||
const merchantName = extraction.merchant?.name
|
|
||||||
|
|
||||||
if (receiptTotal == null || receiptTotal === 0) {
|
|
||||||
console.log(`${tag} — no receipt total in extracted data`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`${tag} — extracted: total=${receiptTotal}, date=${receiptDate || '?'}, merchant=${merchantName || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
|
||||||
|
|
||||||
let bestMatch: DocumentMatchResult | null = null
|
|
||||||
|
|
||||||
for (const tx of transactions) {
|
|
||||||
if (tx.receipt_id) continue // Skip already matched
|
|
||||||
|
|
||||||
const txAmount = Math.abs(tx.amount)
|
|
||||||
const txDate = new Date(tx.date)
|
|
||||||
|
|
||||||
// Calculate date variance
|
|
||||||
const dateVariance = receiptDate
|
|
||||||
? Math.abs((new Date(receiptDate).getTime() - txDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
||||||
: 3 // Default to tolerance boundary if no date
|
|
||||||
|
|
||||||
if (dateVariance > 3) continue
|
|
||||||
|
|
||||||
// Calculate amount variance
|
|
||||||
const amountVariance = Math.abs(receiptTotal - txAmount) / receiptTotal
|
|
||||||
if (amountVariance > 0.05) continue // Skip if >5% off
|
|
||||||
|
|
||||||
// Calculate merchant similarity
|
|
||||||
const txMerchant = tx.merchant_name || tx.description || ''
|
|
||||||
const merchantSimilarity = merchantName
|
|
||||||
? calculateMerchantSimilarity(merchantName, txMerchant)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
|
||||||
dateVariance,
|
|
||||||
amountVariance,
|
|
||||||
merchantSimilarity
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(`${tag} — scoring tx=${tx.id} "${tx.description}": date_var=${dateVariance.toFixed(1)}d amount_var=${(amountVariance * 100).toFixed(1)}% merchant_sim=${merchantSimilarity.toFixed(2)} → confidence=${confidence}`)
|
|
||||||
|
|
||||||
if (confidence >= 0.60 && (!bestMatch || confidence > bestMatch.confidence)) {
|
|
||||||
bestMatch = {
|
|
||||||
transactionId: tx.id,
|
|
||||||
confidence,
|
|
||||||
method: 'receipt_match',
|
|
||||||
matchReasons,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@ import type {
|
|||||||
ReconciliationMethod,
|
ReconciliationMethod,
|
||||||
InvoiceInboxItem,
|
InvoiceInboxItem,
|
||||||
SupplierInvoice,
|
SupplierInvoice,
|
||||||
AIProposal,
|
|
||||||
AIRequest,
|
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -78,8 +76,6 @@ export type CoreEvent =
|
|||||||
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } }
|
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } }
|
||||||
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } }
|
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } }
|
||||||
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
||||||
// Generic inbox classification (fires for all document_types after classify)
|
|
||||||
| { type: 'inbox_item.classified'; payload: { inboxItem: InvoiceInboxItem; documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'; confidence: number | null; correlationId: string; userId: string; companyId: string } }
|
|
||||||
// Salary
|
// Salary
|
||||||
| { type: 'salary_run.created'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
| { type: 'salary_run.created'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
||||||
| { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } }
|
| { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } }
|
||||||
@@ -94,11 +90,6 @@ export type CoreEvent =
|
|||||||
// Company & account lifecycle
|
// Company & account lifecycle
|
||||||
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
||||||
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
||||||
// AI agent flow (receipts v1)
|
|
||||||
| { type: 'ai_proposal.generated'; payload: { proposal: AIProposal; userId: string; companyId: string } }
|
|
||||||
| { type: 'ai_proposal.accepted'; payload: { proposal: AIProposal; appliedEntry: JournalEntry | null; userId: string; companyId: string } }
|
|
||||||
| { type: 'ai_proposal.rejected'; payload: { proposal: AIProposal; userId: string; companyId: string } }
|
|
||||||
| { type: 'ai_request.created'; payload: { request: AIRequest; userId: string; companyId: string } }
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Helper Types
|
// Helper Types
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
|
|||||||
expect(SECTORS.length).toBe(1)
|
expect(SECTORS.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have 13 total extensions', () => {
|
it('should have 11 total extensions', () => {
|
||||||
expect(getAllExtensions().length).toBe(13)
|
expect(getAllExtensions().length).toBe(11)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have unique slugs within each sector', () => {
|
it('should have unique slugs within each sector', () => {
|
||||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
|||||||
|
|
||||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||||
const extensions = getExtensionsBySector('general')
|
const extensions = getExtensionsBySector('general')
|
||||||
expect(extensions.length).toBe(13)
|
expect(extensions.length).toBe(11)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('all extensions have required fields', () => {
|
it('all extensions have required fields', () => {
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
|||||||
'mcp-server',
|
'mcp-server',
|
||||||
'cloud-backup',
|
'cloud-backup',
|
||||||
'skatteverket',
|
'skatteverket',
|
||||||
|
'invoice-inbox',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ticExtension } from '@/extensions/general/tic'
|
|||||||
import { mcpServerExtension } from '@/extensions/general/mcp-server'
|
import { mcpServerExtension } from '@/extensions/general/mcp-server'
|
||||||
import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
|
import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
|
||||||
import { skatteverketExtension } from '@/extensions/general/skatteverket'
|
import { skatteverketExtension } from '@/extensions/general/skatteverket'
|
||||||
|
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||||
|
|
||||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||||
enableBankingExtension,
|
enableBankingExtension,
|
||||||
@@ -16,4 +17,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
|||||||
mcpServerExtension,
|
mcpServerExtension,
|
||||||
cloudBackupExtension,
|
cloudBackupExtension,
|
||||||
skatteverketExtension,
|
skatteverketExtension,
|
||||||
|
invoiceInboxExtension,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -90,5 +90,20 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
|||||||
"description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
|
"description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
|
||||||
"longDescription": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från gnubok. Spara utkast, validera, lås och signera — utan att lämna appen."
|
"longDescription": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från gnubok. Spara utkast, validera, lås och signera — utan att lämna appen."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "invoice-inbox",
|
||||||
|
"name": "Dokumentinkorg",
|
||||||
|
"sector": "general",
|
||||||
|
"category": "import",
|
||||||
|
"icon": "Inbox",
|
||||||
|
"dataPattern": "both",
|
||||||
|
"description": "Vidarebefordra leverantörsfakturor till en unik adress – dokumenten landar här med extraherade fält",
|
||||||
|
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum extraheras deterministiskt från PDF-texten. Inga AI-anrop, inga molntjänster utöver Resend för e-postmottagning.",
|
||||||
|
"readsCoreTables": [
|
||||||
|
"document_attachments",
|
||||||
|
"suppliers"
|
||||||
|
],
|
||||||
|
"hasOwnData": true
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
|
|||||||
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
|
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
|
||||||
'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')),
|
'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')),
|
||||||
'general/cloud-backup': dynamic(() => import('@/components/extensions/general/CloudBackupWorkspace')),
|
'general/cloud-backup': dynamic(() => import('@/components/extensions/general/CloudBackupWorkspace')),
|
||||||
|
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user