Ai/full autonomous flow (#359)

* Refactor bookkeeping error handling and introduce new error classes

- Introduced new error classes for better error categorization:
  - JournalEntryNotBalancedError
  - FiscalPeriodNotFoundError
  - EntryDateOutsideFiscalPeriodError
  - JournalEntryNotFoundError
  - CannotReverseNonPostedError
  - CannotCorrectNonPostedError
  - EntryAlreadyReversedError
  - CurrencyRevaluationAlreadyExistsError
  - InvalidMappingResultError
  - BookkeepingDatabaseError

- Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones.
- Enhanced error response handling in get-error-message.ts to provide localized messages for new error types.
- Added unit tests for new error classes and error handling functions to ensure correctness and coverage.

* feat(ai): implement AI proposal application and persistence

- Add apply.ts to handle the application of AI proposals, including match and booking steps.
- Introduce persist.ts for inserting and managing AI requests and proposals, ensuring unique constraints.
- Create re-validate.ts for validating proposals before acceptance, checking for stale conditions.
- Define database migrations for ai_requests and ai_proposals tables, including constraints and indexes.
- Enhance journal_entries with AI provenance tracking, linking entries to AI proposals.
- Update categorization_templates to distinguish AI-corrected templates.
- Add company settings for toggling AI flow and managing backfill processes.
- Extend processing_history to include AI-related events for better tracking.

* feat: add uncategorized transactions API and UI for transaction selection

- Implemented a new API endpoint for fetching uncategorized transactions with pagination and filtering options.
- Created ChangeTransactionDialog component for selecting alternative transactions based on AI proposals.
- Developed ReceiptDetailDialog to display detailed information about receipts, including upload functionality.
- Added TransactionDetailDialog for viewing transaction details with links to the transaction list.
- Introduced receipt quality assessment logic to evaluate extracted receipt data.
- Implemented feature flagging for the AI bookkeeping agent to control availability in different environments.

* feat: add manual receipt extraction dialog and integrate AWS Textract for expense analysis

- Added ManualExtractDialog component for user input when AI fails to extract receipt data.
- Implemented ReceiptsList component to manage and display uploaded receipts, including upload and rescan functionalities.
- Introduced Textract integration for analyzing expenses, extracting fields like total, vendor, and date.
- Updated package.json to include @aws-sdk/client-textract dependency.

* fix(ai): handle livsmedel VAT transition (12% → 6%) in booking prompt and re-validate guard

Add date-aware guidance to BOOKING_SYSTEM_PROMPT for the temporary livsmedel
VAT cut (Prop. 2025/26:55, 2026-04-01 to 2027-12-31), with restaurang/servering
carve-out at 12%. Add a re-validate safety net that rejects clearly-stale rate
labels for grocery-chain merchants relative to the entry date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-04-27 10:32:15 +02:00
committed by GitHub
parent ab63da8324
commit 1af977950b
70 changed files with 9735 additions and 451 deletions
+171
View File
@@ -0,0 +1,171 @@
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 ett
granskningsförslag istället för automatisk bokföring.
</p>
<Button asChild>
<Link href="/settings/bookkeeping"> 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} />
}
+55 -27
View File
@@ -1,31 +1,59 @@
'use client'
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'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Receipt } from 'lucide-react'
ensureInitialized()
export default function ReceiptsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Kvitton</h1>
<p className="text-muted-foreground">
Hantera och granska dina kvitton
</p>
</div>
export type ReceiptRow = InvoiceInboxItem & { document: DocumentAttachment | null }
export type ReceiptRowWithPreview = ReceiptRow & { preview_url: string | null }
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Kvitton
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Kvittoscanning är inte tillgängligt just nu.
</p>
</CardContent>
</Card>
</div>
)
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} />
}
-14
View File
@@ -1,14 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function ScanReceiptPage() {
const router = useRouter()
useEffect(() => {
router.replace('/receipts')
}, [router])
return null
}
+37 -1
View File
@@ -7,13 +7,17 @@ import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettin
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
import { useSettings } from '@/components/settings/useSettings'
import { Label } from '@/components/ui/label'
import { ExternalLink } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
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'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
export default function BookkeepingSettingsPage() {
const { settings, isLoading, updateSettings } = useSettings()
const aiAgentAvailable = ENABLED_EXTENSION_IDS.has('ai-agent') && isAgentInboxEnabled()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
@@ -22,6 +26,7 @@ export default function BookkeepingSettingsPage() {
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
const defaultVoucherSeries = (formData.get('default_voucher_series') as string) || 'A'
const aiFlowEnabled = formData.get('ai_flow_enabled') === 'on'
const updates: Record<string, unknown> = {
bookkeeping_locked_through: lockedThrough,
@@ -29,6 +34,9 @@ export default function BookkeepingSettingsPage() {
accounting_method: accountingMethod,
default_voucher_series: defaultVoucherSeries,
}
if (aiAgentAvailable) {
updates.ai_flow_enabled = aiFlowEnabled
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
@@ -93,6 +101,34 @@ export default function BookkeepingSettingsPage() {
<div className="border-t border-border/8 pt-8">
<PeriodLockingSettings settings={settings} />
</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>
{/* Voucher series — read-only display */}
+38
View File
@@ -0,0 +1,38 @@
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 } })
}
+222
View File
@@ -0,0 +1,222 @@
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)
}
@@ -0,0 +1,154 @@
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,
},
})
}
@@ -0,0 +1,217 @@
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 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 = process.env.NEXT_PUBLIC_APP_URL ?? 'https://gnubok.se'
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')
}
+127
View File
@@ -0,0 +1,127 @@
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 } })
}
+248
View File
@@ -0,0 +1,248 @@
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
}
@@ -0,0 +1,177 @@
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 } })
}
+117
View File
@@ -0,0 +1,117 @@
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 } })
}
+79
View File
@@ -0,0 +1,79 @@
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,
},
})
}
+142
View File
@@ -0,0 +1,142 @@
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 }
}
+53
View File
@@ -0,0 +1,53 @@
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 })
}
+106
View File
@@ -0,0 +1,106 @@
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 } })
}
@@ -0,0 +1,93 @@
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,
},
})
}
+562
View File
@@ -0,0 +1,562 @@
'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>
)
}
@@ -0,0 +1,378 @@
'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 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">&ldquo;{reasoning}&rdquo;</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>
)
}
@@ -0,0 +1,155 @@
'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>
)
}
@@ -0,0 +1,43 @@
'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>
)
}
+346
View File
@@ -0,0 +1,346 @@
'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">&ldquo;{reasoning}&rdquo;</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>
)
}
@@ -0,0 +1,408 @@
'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>
)
}
+93
View File
@@ -0,0 +1,93 @@
'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"> 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>
)
}
@@ -0,0 +1,122 @@
'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>
)
}
+55
View File
@@ -0,0 +1,55 @@
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(', ')}.`,
}
}
+14
View File
@@ -27,7 +27,10 @@ import {
TrendingUp,
ClipboardCheck,
HandCoins,
Sparkles,
} from 'lucide-react'
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 { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
@@ -57,6 +60,7 @@ interface NavItem {
modes?: EntityType[] // If set, only visible for these entity types. If not set, visible to all.
hidden?: boolean // Temporarily hide from sidebar
comingSoon?: boolean // Visible but disabled; shows "Kommer snart" badge
devBadge?: boolean // Shows a "Dev" badge to indicate dev-only feature
}
// All nav items for sidebar and mobile drawer
@@ -74,6 +78,8 @@ const navItems: NavItem[] = [
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
// General accounting
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/receipts', label: 'Kvitton', icon: Receipt, group: 'redovisning', hidden: !ENABLED_EXTENSION_IDS.has('invoice-inbox'), 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: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
@@ -265,6 +271,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
@@ -592,6 +602,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : item.devBadge ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Dev
</span>
) : badge !== null && (
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
{badge > 99 ? '99+' : badge}
+177
View File
@@ -0,0 +1,177 @@
'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 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>
)
}
+540
View File
@@ -0,0 +1,540 @@
'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 510 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) 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>
)
}
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup"]}
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","invoice-inbox","ai-agent"]}
+15
View File
@@ -0,0 +1,15 @@
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',
}
@@ -0,0 +1,30 @@
/**
* 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)
}
@@ -0,0 +1,44 @@
/**
* 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)
}
}
@@ -0,0 +1,328 @@
/**
* 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}`
}
@@ -0,0 +1,194 @@
/**
* 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))
}
@@ -0,0 +1,166 @@
/**
* 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: {} },
}
@@ -0,0 +1,80 @@
/**
* 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: {} },
}
+23
View File
@@ -0,0 +1,23 @@
{
"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."
}
}
+519 -18
View File
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { classifyDocument } from './lib/classify-document'
import { analyzeExpenseWithTextract, checkTotalsAgreement } from './lib/textract-expense'
import type { TextractExpenseResult, AgreementResult } from './lib/textract-expense'
import {
verifyInboundWebhook,
fetchReceivingEmail,
@@ -88,20 +90,13 @@ async function uploadAndClassify(
console.error('[invoice-inbox] Failed to append DocumentIngested:', err)
}
// Classify with AI
let classificationResult
let classificationError: string | null = null
try {
classificationResult = await classifyDocument({
fileBuffer: Buffer.from(file.buffer),
mimeType: file.type,
fileName: file.name,
})
} catch (err) {
// Keep the technical message in the server log; present Swedish to users.
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
classificationError = toSwedishInboxError(err)
}
// Classify with AI (Claude) + OCR (Textract) in parallel, then cross-check.
const verification = await extractWithVerification(
Buffer.from(file.buffer),
file.type,
file.name
)
const { classificationResult, classificationError, textract, agreement, needsReview } = verification
// Audit: DocumentExtractionAttempted (fires whether classification succeeded or failed)
try {
@@ -163,11 +158,13 @@ async function uploadAndClassify(
.insert({
company_id: companyId,
user_id: userId,
status: classificationError ? 'error' : 'ready',
// OCR-only rows land as 'ready' with the Textract numbers populated —
// still usable, just without Claude's semantic layer.
status: classificationError && !textract ? 'error' : 'ready',
source,
document_id: doc.id,
document_type: classificationResult?.documentType || 'unknown',
extracted_data: classificationResult?.extractedData || null,
document_type: classificationResult?.documentType || (textract ? 'receipt' : 'unknown'),
extracted_data: enrichExtractedData(classificationResult, textract, agreement),
raw_llm_response: classificationResult?.rawResponse || null,
confidence: classificationResult?.confidence
? classificationResult.confidence / 100
@@ -182,13 +179,18 @@ async function uploadAndClassify(
raw_email_payload: emailMeta?.messageId
? { messageId: emailMeta.messageId, filename: file.name }
: null,
error_message: classificationError,
// Only surface the error when both reads failed. OCR fallback is a
// successful outcome from the user's perspective.
error_message: classificationError && !textract ? classificationError : null,
correlation_id: correlationId,
})
.select('*')
.single()
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
if (needsReview) {
console.log('[invoice-inbox] OCR disagrees with Claude on inbox', inbox.id, agreement)
}
// Audit: DocumentClassified (only when classification succeeded)
if (!classificationError && classificationResult) {
@@ -260,6 +262,225 @@ async function uploadAndClassify(
}
}
// ── Extraction + OCR cross-check helper ──────────────────────
//
// Runs Claude (vision) and Textract (receipt-specialized OCR) in parallel on
// the same file and returns an agreement verdict. Claude owns structure and
// semantics (merchant, line items, VAT treatment); Textract owns the raw
// numbers as a hallucination anchor. Disagreement on the total by more than
// 1 öre downgrades the row to needs_review so the UI can flag it.
//
// The two calls are independent; either can fail without killing the other.
// Claude-only is the historical default and still works if Textract skips
// (unsupported mime, too-large file, missing AWS perms).
interface ExtractionWithVerification {
classificationResult: Awaited<ReturnType<typeof classifyDocument>> | undefined
classificationError: string | null
textract: TextractExpenseResult | null
agreement: AgreementResult | null
needsReview: boolean
}
async function extractWithVerification(
fileBuffer: Buffer,
mimeType: string,
fileName: string
): Promise<ExtractionWithVerification> {
const claudePromise = classifyDocument({ fileBuffer, mimeType, fileName })
.then((r) => ({ ok: true as const, value: r }))
.catch((err) => {
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
return { ok: false as const, error: toSwedishInboxError(err) }
})
const textractPromise = analyzeExpenseWithTextract(fileBuffer, mimeType)
const [claudeResult, textract] = await Promise.all([claudePromise, textractPromise])
const classificationResult = claudeResult.ok ? claudeResult.value : undefined
const classificationError = claudeResult.ok ? null : claudeResult.error
// Pull Claude's receipt/invoice total for comparison. Handles both shapes.
const data = classificationResult?.extractedData as
| { totals?: { total?: number | null } }
| null
| undefined
const claudeTotal = data?.totals?.total ?? null
const agreement = checkTotalsAgreement(claudeTotal, textract)
// needs_review when the two reads disagree. Missing-total cases are handled
// elsewhere (rowNeedsRescan) and shouldn't double-flag here.
const needsReview = agreement !== null && !agreement.agrees
return { classificationResult, classificationError, textract, agreement, needsReview }
}
// Build the enriched extracted_data JSON: Claude's output plus an _ocr and
// _verification block. Stays non-breaking — existing consumers read the
// top-level fields unchanged; new consumers (UI, audit) can read the nested
// verification block.
function enrichExtractedData(
classificationResult: Awaited<ReturnType<typeof classifyDocument>> | undefined,
textract: TextractExpenseResult | null,
agreement: AgreementResult | null
): Record<string, unknown> | null {
if (!classificationResult?.extractedData) {
// Claude failed but Textract may have run. Surface OCR as a fallback so
// the UI can still show _something_ from the receipt.
if (!textract) return null
return {
merchant: textract.vendor ? { name: textract.vendor.value } : null,
receipt: textract.date ? { date: textract.date.value, currency: textract.currency } : null,
totals: textract.total
? { total: textract.total.value, vatAmount: textract.tax?.value ?? null, subtotal: textract.subtotal?.value ?? null }
: null,
_ocr: textract,
_verification: { claude_available: false },
_source: 'ocr_only',
}
}
return {
...(classificationResult.extractedData as unknown as Record<string, unknown>),
_ocr: textract,
_verification: agreement,
}
}
// ── Rescan helper ────────────────────────────────────────────
// Re-runs classification on an existing inbox item's source file. Used by the
// per-row "Skanna igen" button and the batch "Skanna oskannade" action so
// users can recover rows that errored or came back with incomplete data
// without reuploading. The row is updated in place — same id, same document,
// refreshed extracted_data and status.
async function rescanInboxItem(
supabase: import('@supabase/supabase-js').SupabaseClient,
companyId: string,
inboxItemId: string
): Promise<{ ok: true; id: string } | { ok: false; id: string; error: string }> {
const { data: item } = await supabase
.from('invoice_inbox_items')
.select('id, company_id, document_id, correlation_id, status')
.eq('id', inboxItemId)
.eq('company_id', companyId)
.maybeSingle()
if (!item) return { ok: false, id: inboxItemId, error: 'Inbox item not found' }
if (!item.document_id) return { ok: false, id: inboxItemId, error: 'No source document attached' }
if (item.status === 'confirmed') return { ok: false, id: inboxItemId, error: 'Already booked' }
const { data: doc } = await supabase
.from('document_attachments')
.select('storage_path, mime_type, file_name')
.eq('id', item.document_id)
.eq('company_id', companyId)
.maybeSingle()
if (!doc || !doc.storage_path) return { ok: false, id: inboxItemId, error: 'Document file missing' }
const { data: blob, error: dlError } = await supabase.storage
.from('documents')
.download(doc.storage_path)
if (dlError || !blob) return { ok: false, id: inboxItemId, error: dlError?.message || 'Download failed' }
const buffer = Buffer.from(await blob.arrayBuffer())
const verification = await extractWithVerification(
buffer,
doc.mime_type ?? 'application/octet-stream',
doc.file_name ?? 'document'
)
const { classificationResult, classificationError, textract, agreement } = verification
const { error: updateError } = await supabase
.from('invoice_inbox_items')
.update({
status: classificationError && !textract ? 'error' : 'ready',
document_type: classificationResult?.documentType ?? (textract ? 'receipt' : 'unknown'),
extracted_data: enrichExtractedData(classificationResult, textract, agreement),
raw_llm_response: classificationResult?.rawResponse ?? null,
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
error_message: classificationError && !textract ? classificationError : null,
})
.eq('id', inboxItemId)
.eq('company_id', companyId)
if (updateError) return { ok: false, id: inboxItemId, error: updateError.message }
// Audit — same event shape as initial classify so the history timeline is consistent.
if (item.correlation_id) {
try {
await appendProcessingHistory({
companyId,
correlationId: item.correlation_id,
aggregateType: 'Document',
aggregateId: item.document_id,
eventType: 'DocumentExtractionAttempted',
payload: {
document_id: item.document_id,
inbox_item_id: inboxItemId,
succeeded: !classificationError,
document_type: classificationResult?.documentType ?? null,
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
llm_input_tokens: classificationResult?.usage?.inputTokens ?? 0,
llm_output_tokens: classificationResult?.usage?.outputTokens ?? 0,
error: classificationError,
retry: true,
},
actor: { type: 'llm', id: 'classify-document' },
occurredAt: new Date(),
})
} catch (err) {
console.error('[invoice-inbox/rescan] appendProcessingHistory failed:', err)
}
}
// Re-emit classified event on any successful extraction (Claude or OCR
// fallback) so the AI orchestrator can generate a match proposal.
const succeeded = classificationResult != null || textract != null
if (succeeded) {
try {
const { data: refreshed } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', inboxItemId)
.maybeSingle()
if (refreshed) {
await eventBus.emit({
type: 'inbox_item.classified',
payload: {
inboxItem: refreshed as unknown as InvoiceInboxItem,
documentType: refreshed.document_type,
confidence: refreshed.confidence,
correlationId: item.correlation_id ?? crypto.randomUUID(),
userId: (refreshed as { user_id: string }).user_id,
companyId,
},
})
}
} catch { /* non-blocking */ }
}
return succeeded
? { ok: true, id: inboxItemId }
: { ok: false, id: inboxItemId, error: classificationError ?? 'Extraction failed' }
}
// A row needs rescanning when extraction failed, or when it succeeded but
// came back without the fields we actually need to propose a match (total +
// date). Keep the heuristic permissive — false positives just mean a user
// clicking "Skanna igen" on a good row, which is harmless.
function needsRescan(row: {
status: string | null
extracted_data: unknown
}): boolean {
if (row.status === 'error') return true
const data = row.extracted_data as { totals?: { total?: number | null } } | null
if (!data) return true
if (data.totals?.total == null) return true
return false
}
// ── Admin/owner check helper ──────────────────────────────────
async function isCompanyAdmin(
@@ -329,6 +550,179 @@ export const invoiceInboxExtension: Extension = {
},
},
// ── Rescan (per-row or batch) ───────────────────────────
// Re-runs the LLM classifier against the already-uploaded source file.
// Body shapes:
// { inbox_item_ids: uuid[] } → rescan those rows (must belong to company)
// {} → rescan all receipt rows that look stuck
// (status='error' or missing extracted totals)
{
method: 'POST',
path: '/rescan',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: { inbox_item_ids?: string[] } = {}
try { body = await request.json() } catch { /* allow empty body */ }
let ids: string[] = []
if (Array.isArray(body.inbox_item_ids) && body.inbox_item_ids.length > 0) {
ids = body.inbox_item_ids.filter((id): id is string => typeof id === 'string')
} else {
const { data: rows } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, status, extracted_data')
.eq('company_id', ctx.companyId)
.eq('document_type', 'receipt')
.not('status', 'eq', 'confirmed')
.not('document_id', 'is', null)
.limit(100)
ids = (rows ?? []).filter((r) => needsRescan(r)).map((r) => r.id)
}
if (ids.length === 0) {
return NextResponse.json({ data: { rescanned: 0, failed: 0, outcomes: [] } })
}
// Serial rather than parallel — Bedrock rate limits kick in hard past
// ~5 concurrent. 100-receipt rescan × 3s each = 5min, acceptable as a
// backgrounded action. The UI fires and refreshes, doesn't block.
const outcomes: Array<{ id: string; ok: boolean; error?: string }> = []
for (const id of ids) {
const res = await rescanInboxItem(ctx.supabase, ctx.companyId, id)
outcomes.push(res.ok ? { id, ok: true } : { id, ok: false, error: res.error })
}
const rescanned = outcomes.filter((o) => o.ok).length
const failed = outcomes.length - rescanned
return NextResponse.json({ data: { rescanned, failed, outcomes } })
},
},
// ── Manual extraction fallback ──────────────────────────
// Lets the user type merchant/date/total when the LLM can't read the image.
// The picture is untouched (still attached as source document); we just
// overwrite extracted_data with user-supplied values and flip status to
// 'ready' so downstream matching can proceed.
{
method: 'POST',
path: '/manual-extract',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: {
inbox_item_id?: string
merchant?: string
date?: string
total?: number
currency?: string
vat_amount?: number | null
} = {}
try { body = await request.json() } catch { /* fall through to validation */ }
const { inbox_item_id, merchant, date, total, currency } = body
if (!inbox_item_id || !merchant || !date || typeof total !== 'number') {
return NextResponse.json(
{ error: 'inbox_item_id, merchant, date och total krävs.' },
{ status: 400 }
)
}
const { data: item } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, document_id, correlation_id, status')
.eq('id', inbox_item_id)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (!item) return NextResponse.json({ error: 'Kvittot hittades inte.' }, { status: 404 })
if (!item.document_id) {
return NextResponse.json({ error: 'Kvittobild saknas — ladda upp en bild först.' }, { status: 400 })
}
if (item.status === 'confirmed') {
return NextResponse.json({ error: 'Redan bokfört.' }, { status: 409 })
}
const extracted_data = {
merchant: { name: merchant },
receipt: { date, currency: currency ?? 'SEK' },
totals: {
total,
vatAmount: typeof body.vat_amount === 'number' ? body.vat_amount : null,
subtotal: null,
},
lineItems: null,
flags: null,
_entry_method: 'manual',
}
const { error: updateError } = await ctx.supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: 'receipt',
extracted_data,
confidence: 1,
error_message: null,
})
.eq('id', inbox_item_id)
.eq('company_id', ctx.companyId)
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
if (item.correlation_id) {
try {
await appendProcessingHistory({
companyId: ctx.companyId,
correlationId: item.correlation_id,
aggregateType: 'Document',
aggregateId: item.document_id,
eventType: 'DocumentExtractionAttempted',
payload: {
document_id: item.document_id,
inbox_item_id,
succeeded: true,
document_type: 'receipt',
confidence: 1,
llm_input_tokens: 0,
llm_output_tokens: 0,
error: null,
manual: true,
},
actor: { type: 'user', id: ctx.userId },
occurredAt: new Date(),
})
} catch (err) {
console.error('[invoice-inbox/manual-extract] appendProcessingHistory failed:', err)
}
}
// Fire match-proposal generation via the classified event.
try {
const { data: refreshed } = await ctx.supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', inbox_item_id)
.maybeSingle()
if (refreshed) {
await eventBus.emit({
type: 'inbox_item.classified',
payload: {
inboxItem: refreshed as unknown as InvoiceInboxItem,
documentType: 'receipt',
confidence: 1,
correlationId: item.correlation_id ?? crypto.randomUUID(),
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
} catch { /* non-blocking */ }
return NextResponse.json({ data: { ok: true } })
},
},
// ── List inbox items ────────────────────────────────────
{
method: 'GET',
@@ -427,6 +821,113 @@ export const invoiceInboxExtension: Extension = {
},
},
// ── Attach a source document to an existing inbox item ──
// For rows that ended up without a picture (e.g. email came through but
// attachment extraction failed, or manually-created rows). Uploads the
// file, links document_id on the row, then runs classify so the numbers
// get extracted in the same request.
{
method: 'POST',
path: '/items/:id/attach-document',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
}
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
{ status: 400 }
)
}
const { data: item } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, document_id, status, correlation_id')
.eq('id', id)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (!item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
if (item.status === 'confirmed') {
return NextResponse.json({ error: 'Redan bokfört — kan inte ersätta bilden.' }, { status: 409 })
}
if (item.document_id) {
return NextResponse.json({ error: 'Kvittot har redan en bild.' }, { status: 409 })
}
try {
const buffer = await file.arrayBuffer()
const doc = await uploadDocument(ctx.supabase, ctx.userId, ctx.companyId, {
name: file.name,
buffer,
type: file.type,
}, {
upload_source: 'file_upload',
})
const { error: linkError } = await ctx.supabase
.from('invoice_inbox_items')
.update({ document_id: doc.id })
.eq('id', id)
.eq('company_id', ctx.companyId)
if (linkError) {
return NextResponse.json({ error: linkError.message }, { status: 500 })
}
// Audit the ingest — mirrors the initial-upload path.
if (item.correlation_id) {
try {
await appendProcessingHistory({
companyId: ctx.companyId,
correlationId: item.correlation_id,
aggregateType: 'Document',
aggregateId: doc.id,
eventType: 'DocumentIngested',
payload: {
channel: 'upload',
document_id: doc.id,
inbox_item_id: id,
mime_type: file.type,
size_bytes: file.size,
attached_to_existing: true,
},
actor: { type: 'user', id: ctx.userId },
occurredAt: new Date(),
})
} catch (err) {
console.error('[invoice-inbox/attach-document] appendProcessingHistory failed:', err)
}
}
// Now run classification on the freshly-attached image.
const rescan = await rescanInboxItem(ctx.supabase, ctx.companyId, id)
return NextResponse.json({
data: {
document_id: doc.id,
inbox_item_id: id,
classified: rescan.ok,
error: rescan.ok ? null : rescan.error,
},
})
} catch (error) {
console.error('[invoice-inbox/attach-document] Failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Attach failed' },
{ status: 500 }
)
}
},
},
// ── Get this company's inbox address ────────────────────
{
method: 'GET',
@@ -0,0 +1,191 @@
/**
* 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,
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Agent-inkorg feature flag.
*
* The AI bookkeeping agent isn't ready for general availability in production.
* This helper gates the whole feature — sidebar link, page, API routes, and
* orchestrator event handlers — behind either:
*
* 1. NODE_ENV === 'development' (local dev: always on)
* 2. NEXT_PUBLIC_AGENT_INBOX_ENABLED=true (opt-in for staging/prod QA)
*
* The escape hatch lets us flip the feature on for a specific Vercel
* deployment (staging) without a code change, and keeps prod deployments
* safely dark until we explicitly enable it.
*
* Mirrors the pattern used for Salary in components/dashboard/DashboardNav.tsx.
*/
import { NextResponse } from 'next/server'
export function isAgentInboxEnabled(): boolean {
if (process.env.NODE_ENV === 'development') return true
return process.env.NEXT_PUBLIC_AGENT_INBOX_ENABLED === 'true'
}
/**
* 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 })
}
+385
View File
@@ -0,0 +1,385 @@
/**
* 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 }
+123
View File
@@ -0,0 +1,123 @@
/**
* 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()
}
+296
View File
@@ -0,0 +1,296 @@
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',
})
})
})
@@ -0,0 +1,422 @@
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'])
}
})
})
+188
View File
@@ -0,0 +1,188 @@
/**
* 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,
}
}
+264
View File
@@ -0,0 +1,264 @@
/**
* 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')
}
+346
View File
@@ -0,0 +1,346 @@
/**
* 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
}
+86
View File
@@ -394,6 +394,8 @@ export const UpdateSettingsSchema = z.object({
invoice_show_plusgiro: z.boolean().optional(),
invoice_late_fee_text: z.string().nullable().optional(),
invoice_credit_terms_text: z.string().nullable().optional(),
// AI agent flow
ai_flow_enabled: z.boolean().optional(),
}).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
@@ -768,3 +770,87 @@ export const CreateSalaryLineItemSchema = z.object({
})
export const UpdateSalaryLineItemSchema = CreateSalaryLineItemSchema.partial().omit({ salary_run_employee_id: true })
// ============================================================
// AI agent flow schemas
// ============================================================
const BookingProposalLineSchema = z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount,
credit_amount: nonNegativeAmount,
description: z.string().min(1).max(500),
})
const BookingProposalCounterpartyTemplateSchema = z.object({
counterparty_name: z.string().min(1).max(200),
debit_account: accountNumber,
credit_account: accountNumber,
vat_treatment: VatTreatmentSchema.nullable(),
category: TransactionCategorySchema.nullable(),
})
// Edit payload: the user's edited version of a booking proposal. Used in
// the /accept endpoint when the user adjusted accounts/VAT before approving.
export const EditBookingProposalSchema = z.object({
lines: z.array(BookingProposalLineSchema).min(2),
vat_treatment: VatTreatmentSchema.nullable(),
default_private: z.boolean(),
counterparty_template_proposal: BookingProposalCounterpartyTemplateSchema.nullable(),
fiscal_period_id: uuid,
entry_date: isoDate,
description: z.string().min(1).max(500),
})
// For match proposals, editing just means picking a different transaction.
export const EditMatchProposalSchema = z.object({
matched_transaction_id: uuid,
})
export const AcceptProposalSchema = z.object({
version: z.number().int().nonnegative(),
edits: z.union([EditBookingProposalSchema, EditMatchProposalSchema]).optional(),
})
// Change the matched transaction on a pending match proposal without
// accepting it. Source tells us whether the user picked one of the AI's
// own alternatives, an AI-regenerated suggestion, or a manually-chosen
// transaction — kept on edit_diff for learning signal.
export const ChangeMatchProposalSchema = z.object({
version: z.number().int().nonnegative(),
matched_transaction_id: uuid,
source: z.enum(['user_alternative', 'user_manual', 'ai_regenerated']),
})
export const RejectProposalSchema = z.object({
version: z.number().int().nonnegative(),
reason: z.string().max(500).optional(),
})
export const BatchAcceptSchema = z.object({
proposal_ids: z.array(uuid).min(1).max(50),
})
export const ResolveRequestSchema = z.object({
response: z.record(z.string(), z.unknown()).optional(),
})
export const StartBackfillSchema = z.object({}).strict()
export const RememberLearningSchema = z.object({
proposal_id: uuid,
counterparty_name: z.string().min(1).max(200),
debit_account: accountNumber,
credit_account: accountNumber,
vat_treatment: VatTreatmentSchema.nullable(),
category: TransactionCategorySchema.nullable(),
})
export const ListProposalsQuerySchema = z.object({
status: z
.enum(['pending', 'accepted', 'rejected', 'skipped', 'invalidated'])
.optional(),
step_type: z.enum(['match', 'booking']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
})
@@ -80,6 +80,11 @@ const SOURCE_PRIORITY: Record<CategorizationTemplateSource, number> = {
auto_learned: 1,
sie_import: 2,
user_approved: 3,
// AI-corrected templates carry explicit user validation (they edited the
// AI's proposal, then confirmed "remember this"), so rank equal to
// user_approved. Fresh incoming AI corrections still win over older
// templates of the same rank (>= in resolveSource).
ai_corrected: 3,
}
export function resolveSource(
+2
View File
@@ -223,6 +223,8 @@ export async function createDraftEntry(
source_id: input.source_id || null,
notes: input.notes || null,
status: 'draft',
created_via: input.created_via || 'manual',
source_proposal_id: input.source_proposal_id || null,
})
.select()
.single()
+7
View File
@@ -10,6 +10,8 @@ import type {
ReconciliationMethod,
InvoiceInboxItem,
SupplierInvoice,
AIProposal,
AIRequest,
} from '@/types'
// ============================================================
@@ -86,6 +88,11 @@ export type CoreEvent =
// Company & account lifecycle
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: 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
+3 -3
View File
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
expect(SECTORS.length).toBe(1)
})
it('should have 11 total extensions', () => {
expect(getAllExtensions().length).toBe(11)
it('should have 12 total extensions', () => {
expect(getAllExtensions().length).toBe(12)
})
it('should have unique slugs within each sector', () => {
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
it('getExtensionsBySector returns extensions for a sector', () => {
const extensions = getExtensionsBySector('general')
expect(extensions.length).toBe(11)
expect(extensions.length).toBe(12)
})
it('all extensions have required fields', () => {
@@ -7,4 +7,6 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'tic',
'mcp-server',
'cloud-backup',
'invoice-inbox',
'ai-agent',
])
@@ -6,6 +6,8 @@ import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
import { ticExtension } from '@/extensions/general/tic'
import { mcpServerExtension } from '@/extensions/general/mcp-server'
import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { aiAgentExtension } from '@/extensions/general/ai-agent'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
@@ -14,4 +16,6 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
ticExtension,
mcpServerExtension,
cloudBackupExtension,
invoiceInboxExtension,
aiAgentExtension,
]
@@ -80,5 +80,38 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"hasOwnData": true,
"subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive — ingen data lagras hos tredje part utöver Google."
},
{
"slug": "invoice-inbox",
"name": "Dokumentinkorg",
"sector": "general",
"category": "import",
"icon": "Inbox",
"dataPattern": "both",
"description": "AI-klassificering och extraktion av leverantörsfakturor och kvitton",
"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.",
"readsCoreTables": [
"document_attachments",
"suppliers",
"transactions"
],
"hasOwnData": true
},
{
"slug": "ai-agent",
"name": "AI-agent (beta)",
"sector": "general",
"category": "operations",
"icon": "Sparkles",
"dataPattern": "core",
"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.",
"readsCoreTables": [
"invoice_inbox_items",
"transactions",
"ai_proposals",
"ai_requests",
"processing_history"
]
},
],
}
@@ -8,4 +8,5 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')),
'general/cloud-backup': dynamic(() => import('@/components/extensions/general/CloudBackupWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
}
+3
View File
@@ -3,6 +3,8 @@ import { setContextFactory } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler'
import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler'
import { registerAIProposalHandler } from '@/lib/ai/orchestrator'
import { isAgentInboxEnabled } from '@/lib/ai/feature-flag'
import { createLogger } from '@/lib/logger'
const log = createLogger('init')
@@ -75,6 +77,7 @@ export function ensureInitialized(): void {
setContextFactory(createExtensionContext)
registerSupplierInvoiceHandler()
registerEventLogHandler()
if (isAgentInboxEnabled()) registerAIProposalHandler()
loadExtensions()
initialized = true
@@ -414,6 +414,8 @@ describe('ingestTransactions', () => {
enqueue({ data: [], error: null })
// Transaction 1: insert OK
enqueue({ data: inserted1, error: null })
// AI flow flag lookup (lazy, fires once on first auto-categorize branch)
enqueue({ data: { ai_flow_enabled: false }, error: null })
// Transaction 2: insert OK
enqueue({ data: inserted2, error: null })
+25 -1
View File
@@ -120,6 +120,28 @@ export async function ingestTransactions(
// by incoming enable_banking rows to avoid blocking unrelated CSV imports.
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, rawTransactions)
// AI agent gate: when the company has opted into the agent flow, every
// uncategorized transaction becomes a review proposal — no silent auto-book.
// Matching/suggestion still runs (it only sets potential_*_id fields), but
// the mapping-rule auto-categorize branch below is disabled. Fetched lazily
// the first time the auto-categorize branch is about to run, and cached for
// the rest of the batch so we don't hit the DB per-transaction.
let aiFlowEnabledCache: boolean | null = null
const isAiFlowEnabled = async (): Promise<boolean> => {
if (aiFlowEnabledCache !== null) return aiFlowEnabledCache
try {
const { data: aiSettings } = await supabase
.from('company_settings')
.select('ai_flow_enabled')
.eq('company_id', companyId)
.maybeSingle()
aiFlowEnabledCache = Boolean(aiSettings?.ai_flow_enabled)
} catch {
aiFlowEnabledCache = false
}
return aiFlowEnabledCache
}
// When rawInsertOnly is set (viewer imports), skip pre-fetching GL lines,
// supplier invoices, and exchange rates — they are not used.
let glLinePool: UnlinkedGLLine[] = []
@@ -372,7 +394,9 @@ export async function ingestTransactions(
// Skipped when SIE-imported entries overlap the sync range — prevents
// double-booking. Reconciliation (step 2.5) still links transactions to
// existing GL lines; only the "create new journal entry" path is suppressed.
if (!options?.skipAutoCategorization) {
// Also skipped when the company has opted into the AI agent flow — every
// uncategorized transaction must become a proposal, not a silent post.
if (!options?.skipAutoCategorization && !(await isAiFlowEnabled())) {
try {
const mappingResult = await evaluateMappingRules(
supabase,
+506 -385
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1022.0",
"@aws-sdk/client-textract": "^3.1036.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -0,0 +1,77 @@
-- ai_requests: structured asks from the AI agent to the user.
--
-- When the AI agent cannot produce a proposal because something is missing or
-- ambiguous (blurry receipt, no candidate transactions, uncertain VAT), it
-- creates an ai_requests row instead of an ai_proposals row. The UI renders
-- these as actionable cards with typed forms.
--
-- One open request per (subject, request_type) enforced by a partial unique
-- index so the orchestrator can safely re-issue on retries without creating
-- duplicates.
CREATE TABLE IF NOT EXISTS public.ai_requests (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- What the request is about
subject_type text NOT NULL
CHECK (subject_type IN ('inbox_item')),
subject_id uuid NOT NULL,
-- What the AI is asking for
request_type text NOT NULL
CHECK (request_type IN (
'reupload_document',
'pick_transaction',
'specify_vat',
'clarify_business_private',
'needs_manual'
)),
message text NOT NULL,
required_fields jsonb,
options jsonb,
-- Lifecycle
status text NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'resolved', 'dismissed')),
response_json jsonb,
resolved_at timestamptz,
resolved_by_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
-- Provenance
model text,
prompt_version text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Only one open request per (subject, request_type) at a time
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_requests_one_open_per_subject_type
ON public.ai_requests (subject_type, subject_id, request_type)
WHERE status = 'open';
-- Lookup by company
CREATE INDEX IF NOT EXISTS idx_ai_requests_company_status
ON public.ai_requests (company_id, status);
-- Lookup by subject (for cascading when the inbox item is processed)
CREATE INDEX IF NOT EXISTS idx_ai_requests_subject
ON public.ai_requests (subject_type, subject_id);
-- RLS: company-scoped using user_company_ids()
ALTER TABLE public.ai_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY "ai_requests_select" ON public.ai_requests
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "ai_requests_insert" ON public.ai_requests
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "ai_requests_update" ON public.ai_requests
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- updated_at trigger
CREATE TRIGGER ai_requests_updated_at
BEFORE UPDATE ON public.ai_requests
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,92 @@
-- ai_proposals: the staging layer for AI-generated bookkeeping proposals.
--
-- When the AI agent can produce a concrete suggestion for a step in the
-- receipt flow (match, booking), it writes a row here with status='pending'.
-- The user accepts, rejects, edits, or skips via the /agent-inbox UI.
-- Nothing touches the ledger until a pending proposal is explicitly accepted;
-- at that point the apply path calls the engine and links applied_entry_id.
--
-- A partial unique index enforces "one pending proposal per (subject, step)"
-- so concurrent generation is idempotent — a new proposal for an already-
-- pending (subject, step) pair invalidates the prior one first.
CREATE TABLE IF NOT EXISTS public.ai_proposals (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Subject: what this proposal is about
subject_type text NOT NULL
CHECK (subject_type IN ('inbox_item')),
subject_id uuid NOT NULL,
-- Step in the agent pipeline: 'match' (document -> transaction) then 'booking' (journal entry)
step_type text NOT NULL
CHECK (step_type IN ('match', 'booking')),
-- Lifecycle
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'rejected', 'skipped', 'invalidated')),
version integer NOT NULL DEFAULT 1, -- optimistic-lock counter
-- Payload: step-shaped JSON (MatchProposalPayload | BookingProposalPayload)
proposal_json jsonb NOT NULL,
-- Confidence is informational only — user always confirms
confidence numeric(5,4)
CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1)),
reasoning text,
-- Link to an open ai_request when the AI would rather ask than guess
ai_request_id uuid REFERENCES public.ai_requests(id) ON DELETE SET NULL,
-- Provenance (for audit + prompt/model drift analysis)
model text NOT NULL,
prompt_version text NOT NULL,
input_token_count integer NOT NULL DEFAULT 0,
output_token_count integer NOT NULL DEFAULT 0,
-- Outcome tracking
edit_diff jsonb, -- set when user edited before accept
applied_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL,
invalidated_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
accepted_at timestamptz,
accepted_by_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
rejected_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- One pending proposal per (subject, step) — idempotency guard
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_proposals_one_pending_per_step
ON public.ai_proposals (subject_type, subject_id, step_type)
WHERE status = 'pending';
-- List queries
CREATE INDEX IF NOT EXISTS idx_ai_proposals_company_status
ON public.ai_proposals (company_id, status);
CREATE INDEX IF NOT EXISTS idx_ai_proposals_company_created_at
ON public.ai_proposals (company_id, created_at DESC);
-- Subject lookup (cascade when the inbox item is processed manually)
CREATE INDEX IF NOT EXISTS idx_ai_proposals_subject
ON public.ai_proposals (subject_type, subject_id);
-- RLS: company-scoped using user_company_ids()
ALTER TABLE public.ai_proposals ENABLE ROW LEVEL SECURITY;
CREATE POLICY "ai_proposals_select" ON public.ai_proposals
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "ai_proposals_insert" ON public.ai_proposals
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "ai_proposals_update" ON public.ai_proposals
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()));
-- updated_at trigger
CREATE TRIGGER ai_proposals_updated_at
BEFORE UPDATE ON public.ai_proposals
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,47 @@
-- AI provenance on journal entries.
--
-- Adds two columns to journal_entries so that entries posted via the AI
-- agent flow carry a BFL-defensible audit trail: who created this entry,
-- and which AI proposal did the user approve to produce it?
--
-- `created_via` is informational — it describes the *method* of creation,
-- not the business event. The existing `source_type` column still describes
-- the business event (bank_transaction, invoice_created, supplier_invoice_
-- registered, etc.). An AI-proposed booking for a bank transaction will
-- have source_type='bank_transaction' AND created_via='ai_proposed'.
--
-- The existing immutability trigger (migration 017) prevents changes to
-- posted entries. These new columns are set while the entry is still in
-- draft status and frozen at commit — consistent with how the trigger
-- already treats other fields.
ALTER TABLE public.journal_entries
ADD COLUMN IF NOT EXISTS created_via text NOT NULL DEFAULT 'manual';
ALTER TABLE public.journal_entries
ALTER COLUMN created_via SET DEFAULT 'manual';
-- Re-apply NOT NULL for environments where the column was added out-of-band
UPDATE public.journal_entries SET created_via = 'manual' WHERE created_via IS NULL;
ALTER TABLE public.journal_entries
ALTER COLUMN created_via SET NOT NULL;
ALTER TABLE public.journal_entries
DROP CONSTRAINT IF EXISTS journal_entries_created_via_check;
ALTER TABLE public.journal_entries
ADD CONSTRAINT journal_entries_created_via_check
CHECK (created_via IN ('manual', 'ai_proposed', 'imported', 'system'));
-- Nullable FK — only AI-proposed entries link back to a proposal
ALTER TABLE public.journal_entries
ADD COLUMN IF NOT EXISTS source_proposal_id uuid
REFERENCES public.ai_proposals(id) ON DELETE SET NULL;
-- Audit lookup: "show me all AI-proposed entries from last month"
CREATE INDEX IF NOT EXISTS idx_journal_entries_created_via_ai
ON public.journal_entries (company_id, created_at DESC)
WHERE created_via = 'ai_proposed';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,27 @@
-- Extend categorization_templates.source to include 'ai_corrected'.
--
-- When a user edits an AI-generated booking proposal and then agrees to
-- "remember this for <counterparty> in future", the resulting
-- categorization_templates row is inserted with source='ai_corrected' so
-- the origin of the template is distinguishable from the existing
-- silent-learning paths (user_approved, auto_learned, sie_import, sni_default).
--
-- This distinction matters for downstream confidence calibration: AI-
-- corrected templates carry stronger user-validation signal than auto_learned
-- (which is inferred purely by the AI without explicit user review) and
-- comparable signal to user_approved.
ALTER TABLE public.categorization_templates
DROP CONSTRAINT IF EXISTS categorization_templates_source_check;
ALTER TABLE public.categorization_templates
ADD CONSTRAINT categorization_templates_source_check
CHECK (source IN (
'sie_import',
'user_approved',
'sni_default',
'auto_learned',
'ai_corrected'
));
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,41 @@
-- Per-company toggle for the AI agent flow.
--
-- `ai_flow_enabled` is the master switch. When true:
-- * newly-classified receipts generate AI proposals (via orchestrator);
-- * the auto-book path in lib/transactions/ingest.ts is disabled — every
-- uncategorized transaction becomes a review item instead of being
-- silently posted at >=0.8 mapping-rule confidence;
-- * the /agent-inbox page becomes available.
--
-- `ai_backfill_cancel_requested` is the kill switch for in-flight backfill
-- loops. The backfill endpoint kicks off a fire-and-forget iteration over
-- pending receipts; each iteration checks this flag between items so the
-- loop can be stopped without a separate job queue.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS ai_flow_enabled boolean NOT NULL DEFAULT false;
ALTER TABLE public.company_settings
ALTER COLUMN ai_flow_enabled SET DEFAULT false;
UPDATE public.company_settings
SET ai_flow_enabled = false
WHERE ai_flow_enabled IS NULL;
ALTER TABLE public.company_settings
ALTER COLUMN ai_flow_enabled SET NOT NULL;
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS ai_backfill_cancel_requested boolean NOT NULL DEFAULT false;
ALTER TABLE public.company_settings
ALTER COLUMN ai_backfill_cancel_requested SET DEFAULT false;
UPDATE public.company_settings
SET ai_backfill_cancel_requested = false
WHERE ai_backfill_cancel_requested IS NULL;
ALTER TABLE public.company_settings
ALTER COLUMN ai_backfill_cancel_requested SET NOT NULL;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,41 @@
-- Extend processing_history to cover the AI agent streams.
--
-- 1) Add 'AIProposal' and 'AIRequest' to the aggregate_type CHECK constraint.
-- 2) Register the new event types in processing_event_types:
-- AIProposalGenerated, AIProposalAccepted, AIProposalRejected,
-- AIProposalSkipped, AIProposalInvalidated, AIRequestCreated,
-- AIRequestResolved.
--
-- Events are written by the orchestrator (lib/ai/orchestrator.ts) and the
-- proposal API routes, using the same correlation_id that was threaded
-- through the document from ingest onward.
ALTER TABLE public.processing_history
DROP CONSTRAINT IF EXISTS processing_history_aggregate_type_check;
ALTER TABLE public.processing_history
ADD CONSTRAINT processing_history_aggregate_type_check
CHECK (aggregate_type IN (
'Document',
'BankTransaction',
'MatchProposal',
'Verifikation',
'CounterpartyTemplate',
'Period',
'Migration',
'System',
'AIProposal',
'AIRequest'
));
INSERT INTO public.processing_event_types (event_type) VALUES
('AIProposalGenerated'),
('AIProposalAccepted'),
('AIProposalRejected'),
('AIProposalSkipped'),
('AIProposalInvalidated'),
('AIRequestCreated'),
('AIRequestResolved')
ON CONFLICT (event_type) DO NOTHING;
NOTIFY pgrst, 'reload schema';
+4
View File
@@ -244,6 +244,8 @@ export function makeJournalEntry(overrides: Partial<JournalEntry> = {}): Journal
notes: null,
commit_method: null,
rubric_version: null,
created_via: 'manual',
source_proposal_id: null,
created_at: '2024-06-15T14:30:00Z',
updated_at: '2024-06-15T14:30:00Z',
...overrides,
@@ -537,6 +539,8 @@ export function makeCompanySettings(
onboarding_complete: true,
sector_slug: null,
is_sandbox: false,
ai_flow_enabled: false,
ai_backfill_cancel_requested: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
+147 -1
View File
@@ -132,6 +132,8 @@ export type ProcessingHistoryAggregateType =
| 'Period'
| 'Migration'
| 'System'
| 'AIProposal'
| 'AIRequest'
export interface ProcessingHistoryEvent {
event_id: string
@@ -254,6 +256,12 @@ export interface CompanySettings {
// Sandbox
is_sandbox: boolean
// AI agent (receipts v1). When ai_flow_enabled is true, the auto-book
// path in lib/transactions/ingest.ts is disabled and every uncategorized
// transaction becomes an AI proposal the user must accept.
ai_flow_enabled: boolean
ai_backfill_cancel_requested: boolean
// Timestamps
created_at: string
updated_at: string
@@ -982,6 +990,8 @@ export interface JournalEntry {
rubric_version: string | null
source_voucher_series: string | null
source_voucher_number: number | null
created_via: CreatedVia
source_proposal_id: string | null
created_at: string
updated_at: string
// Relations
@@ -1073,7 +1083,7 @@ export interface VatJournalLine {
}
// Categorization template source
export type CategorizationTemplateSource = 'sie_import' | 'user_approved' | 'sni_default' | 'auto_learned'
export type CategorizationTemplateSource = 'sie_import' | 'user_approved' | 'sni_default' | 'auto_learned' | 'ai_corrected'
// Multi-line booking pattern entry
export interface LinePatternEntry {
@@ -1225,6 +1235,10 @@ export interface CreateJournalEntryInput {
voucher_series?: string
notes?: string
lines: CreateJournalEntryLineInput[]
// AI agent provenance — set on the draft INSERT, then frozen at commit.
// Manual/imported/system callers omit these; the engine defaults created_via to 'manual'.
created_via?: CreatedVia
source_proposal_id?: string
}
export interface CreateJournalEntryLineInput {
@@ -2572,3 +2586,135 @@ export interface AGIDeclaration {
created_at: string
updated_at: string
}
// ============================================================================
// AI agent flow (receipts v1)
// ============================================================================
// How was a journal entry created? `source_type` describes the business event;
// `created_via` describes the method.
export type CreatedVia = 'manual' | 'ai_proposed' | 'imported' | 'system'
// The only subject type in v1 is inbox_item; invoices and bare transactions come later.
export type AISubjectType = 'inbox_item'
// Proposal step in the agent pipeline. A receipt goes match -> booking.
export type AIProposalStepType = 'match' | 'booking'
// Proposal lifecycle. Next step chains on accepted/skipped; rejected terminates the chain.
export type AIProposalStatus =
| 'pending'
| 'accepted'
| 'rejected'
| 'skipped' // user went manual on this step
| 'invalidated' // re-validation failed or superseded
// Structured requests the AI makes when it can't produce a proposal.
export type AIRequestType =
| 'reupload_document'
| 'pick_transaction'
| 'specify_vat'
| 'clarify_business_private'
| 'needs_manual'
export type AIRequestStatus = 'open' | 'resolved' | 'dismissed'
// Proposal payload shapes (what lives in ai_proposals.proposal_json).
export interface MatchProposalAlternative {
transaction_id: string
confidence: number
reasoning: string
}
export interface MatchProposalPayload {
matched_transaction_id: string
alternatives: MatchProposalAlternative[]
top_confidence: number
}
export interface BookingProposalLine {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
export interface BookingProposalCounterpartyTemplate {
counterparty_name: string
debit_account: string
credit_account: string
vat_treatment: VatTreatment | null
category: TransactionCategory | null
}
export interface BookingProposalPayload {
lines: BookingProposalLine[]
vat_treatment: VatTreatment | null
default_private: boolean
counterparty_template_proposal: BookingProposalCounterpartyTemplate | null
fiscal_period_id: string
entry_date: string
description: string
}
export type AIProposalPayload = MatchProposalPayload | BookingProposalPayload
// AI proposal row (ai_proposals table)
export interface AIProposal {
id: string
company_id: string
user_id: string
subject_type: AISubjectType
subject_id: string
step_type: AIProposalStepType
status: AIProposalStatus
version: number
proposal_json: AIProposalPayload
confidence: number | null
reasoning: string | null
ai_request_id: string | null
model: string
prompt_version: string
input_token_count: number
output_token_count: number
edit_diff: Record<string, unknown> | null
applied_entry_id: string | null
invalidated_reason: string | null
created_at: string
accepted_at: string | null
accepted_by_user_id: string | null
rejected_at: string | null
updated_at: string
}
// AI request row (ai_requests table)
export interface AIRequest {
id: string
company_id: string
subject_type: AISubjectType
subject_id: string
request_type: AIRequestType
message: string
required_fields: Record<string, unknown> | null
options: Record<string, unknown> | null
status: AIRequestStatus
response_json: Record<string, unknown> | null
resolved_at: string | null
resolved_by_user_id: string | null
model: string | null
prompt_version: string | null
created_at: string
updated_at: string
}
// Candidate transaction summary used in pick_transaction requests' options array.
export interface PickTransactionOption {
transaction_id: string
date: string
description: string
amount: number
currency: string
merchant_name: string | null
}