diff --git a/app/(dashboard)/agent-inbox/page.tsx b/app/(dashboard)/agent-inbox/page.tsx
new file mode 100644
index 00000000..8b45144f
--- /dev/null
+++ b/app/(dashboard)/agent-inbox/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+ AI-agenten är inte aktiverad
+
+ Aktivera AI-agenten under bokföringsinställningar. Varje transaktion blir då ett
+ granskningsförslag istället för automatisk bokföring.
+
+
+ Gå till inställningar
+
+
+
+
+ )
+ }
+
+ // 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([
+ ...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()
+ 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()
+ 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
+}
diff --git a/app/(dashboard)/receipts/page.tsx b/app/(dashboard)/receipts/page.tsx
index 81f7c7db..8fb39805 100644
--- a/app/(dashboard)/receipts/page.tsx
+++ b/app/(dashboard)/receipts/page.tsx
@@ -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 (
-
-
-
Kvitton
-
- Hantera och granska dina kvitton
-
-
+export type ReceiptRow = InvoiceInboxItem & { document: DocumentAttachment | null }
+export type ReceiptRowWithPreview = ReceiptRow & { preview_url: string | null }
-
-
-
-
- Kvitton
-
-
-
-
- Kvittoscanning är inte tillgängligt just nu.
-
-
-
-
- )
+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()
+ 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
}
diff --git a/app/(dashboard)/receipts/scan/page.tsx b/app/(dashboard)/receipts/scan/page.tsx
deleted file mode 100644
index 8700be0e..00000000
--- a/app/(dashboard)/receipts/scan/page.tsx
+++ /dev/null
@@ -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
-}
diff --git a/app/(dashboard)/settings/bookkeeping/page.tsx b/app/(dashboard)/settings/bookkeeping/page.tsx
index 6abcdfd0..d4c87e79 100644
--- a/app/(dashboard)/settings/bookkeeping/page.tsx
+++ b/app/(dashboard)/settings/bookkeeping/page.tsx
@@ -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
@@ -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 = {
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) => {
@@ -93,6 +101,34 @@ export default function BookkeepingSettingsPage() {
+
+ {/* AI agent (beta) — gated on extension availability */}
+ {aiAgentAvailable && (
+
+
+
+
+ AI-agent (beta)
+
+
+
+
+
Aktivera agent-inkorgen
+
+ När aktiv: varje ny banktransaktion blir ett AI-förslag du granskar i
+ agent-inkorgen.
+ Den automatiska bokföringen (≥80% regelmatchning) stängs av — inget bokförs
+ utan din bekräftelse.
+
+
+
+
+
+ )}
{/* Voucher series — read-only display */}
diff --git a/app/api/ai/backfill/cancel/route.ts b/app/api/ai/backfill/cancel/route.ts
new file mode 100644
index 00000000..8bd09db5
--- /dev/null
+++ b/app/api/ai/backfill/cancel/route.ts
@@ -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 } })
+}
diff --git a/app/api/ai/backfill/receipts/route.ts b/app/api/ai/backfill/receipts/route.ts
new file mode 100644
index 00000000..b45a6075
--- /dev/null
+++ b/app/api/ai/backfill/receipts/route.ts
@@ -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 {
+ 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 {
+ const { data } = await service
+ .from('company_settings')
+ .select('ai_backfill_cancel_requested')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ return Boolean(data?.ai_backfill_cancel_requested)
+}
diff --git a/app/api/ai/inbox-items/[id]/attach-file/route.ts b/app/api/ai/inbox-items/[id]/attach-file/route.ts
new file mode 100644
index 00000000..8e3ef34c
--- /dev/null
+++ b/app/api/ai/inbox-items/[id]/attach-file/route.ts
@@ -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,
+ },
+ })
+}
diff --git a/app/api/ai/inbox-items/[id]/request-receipt/route.ts b/app/api/ai/inbox-items/[id]/request-receipt/route.ts
new file mode 100644
index 00000000..fc3be1a0
--- /dev/null
+++ b/app/api/ai/inbox-items/[id]/request-receipt/route.ts
@@ -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 `
+
+
+ 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:
+
+ Öppna agent-inkorg
+
+
+ Send in receipts. Utan källunderlag kan bokföringen inte slutföras enligt BFL 5 kap 7§.
+
+`
+}
+
+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')
+}
diff --git a/app/api/ai/learning/remember/route.ts b/app/api/ai/learning/remember/route.ts
new file mode 100644
index 00000000..b6051611
--- /dev/null
+++ b/app/api/ai/learning/remember/route.ts
@@ -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 } })
+}
diff --git a/app/api/ai/proposals/[id]/accept/route.ts b/app/api/ai/proposals/[id]/accept/route.ts
new file mode 100644
index 00000000..01d0c20e
--- /dev/null
+++ b/app/api/ai/proposals/[id]/accept/route.ts
@@ -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 ?".
+ * 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 | 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
+}
diff --git a/app/api/ai/proposals/[id]/change-match/route.ts b/app/api/ai/proposals/[id]/change-match/route.ts
new file mode 100644
index 00000000..4250bc16
--- /dev/null
+++ b/app/api/ai/proposals/[id]/change-match/route.ts
@@ -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 | 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 } })
+}
diff --git a/app/api/ai/proposals/[id]/reject/route.ts b/app/api/ai/proposals/[id]/reject/route.ts
new file mode 100644
index 00000000..3e8dfc50
--- /dev/null
+++ b/app/api/ai/proposals/[id]/reject/route.ts
@@ -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 | 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 } })
+}
diff --git a/app/api/ai/proposals/[id]/route.ts b/app/api/ai/proposals/[id]/route.ts
new file mode 100644
index 00000000..0e2924db
--- /dev/null
+++ b/app/api/ai/proposals/[id]/route.ts
@@ -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,
+ },
+ })
+}
diff --git a/app/api/ai/proposals/batch-accept/route.ts b/app/api/ai/proposals/batch-accept/route.ts
new file mode 100644
index 00000000..f08485ec
--- /dev/null
+++ b/app/api/ai/proposals/batch-accept/route.ts
@@ -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>,
+ companyId: string,
+ userId: string,
+ proposalId: string
+): Promise {
+ 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 }
+}
diff --git a/app/api/ai/proposals/route.ts b/app/api/ai/proposals/route.ts
new file mode 100644
index 00000000..8cf0112f
--- /dev/null
+++ b/app/api/ai/proposals/route.ts
@@ -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 })
+}
diff --git a/app/api/ai/requests/[id]/resolve/route.ts b/app/api/ai/requests/[id]/resolve/route.ts
new file mode 100644
index 00000000..4fa1331f
--- /dev/null
+++ b/app/api/ai/requests/[id]/resolve/route.ts
@@ -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 | 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 } })
+}
diff --git a/app/api/transactions/uncategorized/route.ts b/app/api/transactions/uncategorized/route.ts
new file mode 100644
index 00000000..f4d8620a
--- /dev/null
+++ b/app/api/transactions/uncategorized/route.ts
@@ -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,
+ },
+ })
+}
diff --git a/components/agent-inbox/AgentInbox.tsx b/components/agent-inbox/AgentInbox.tsx
new file mode 100644
index 00000000..a9e41a67
--- /dev/null
+++ b/components/agent-inbox/AgentInbox.tsx
@@ -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 {
+ 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>(new Set())
+ const [busyProposalId, setBusyProposalId] = useState(null)
+ const [editProposal, setEditProposal] = useState(null)
+ const [changeMatchItem, setChangeMatchItem] = useState(null)
+ const [learningPrompt, setLearningPrompt] = useState(null)
+ const [backfillRunning, setBackfillRunning] = useState(false)
+ const [batchRunning, setBatchRunning] = useState(false)
+ const [filter, setFilter] = useState('all')
+ const [backfillProgress, setBackfillProgress] = useState<{
+ target: number
+ startPending: number
+ currentPending: number
+ } | null>(null)
+ const pollRef = useRef | 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(
+ (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 => {
+ 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 (
+
+
+ {!backfillRunning ? (
+
+
+ Bearbeta befintliga
+
+ ) : (
+
+
+ Stoppa backfill
+
+ )}
+
+ }
+ />
+
+ {backfillProgress && (
+
+ )}
+
+ {items.length > 0 && (
+ setFilter(v as FilterKey)} className="mb-4">
+
+ Allt ({counts.all})
+ Matchning ({counts.match})
+ Bokföring ({counts.booking})
+
+
+ )}
+
+ {items.length === 0 ? (
+
+
+
+
+
+ Inga väntande förslag
+
+ När nya kvitton klassas i inkorgen kommer AI-förslagen att visas här.
+
+
+
+ ) : (
+ <>
+ {selectableProposalIds.length > 1 && (
+
+
+ Markera alla ({selectableProposalIds.length})
+
+ {selectedIds.size > 0 && (
+
+ Avmarkera ({selectedIds.size})
+
+ )}
+
+ )}
+
+
+ {filteredItems.length === 0 && (
+
+
+ Inga kort i denna vy.
+
+
+ )}
+ {filteredItems.map((item) => {
+ if (item.proposal) {
+ return (
+
toggleSelect(item.proposal!.id)}
+ onAccept={() => handleAccept(item.proposal!)}
+ onReject={() => handleReject(item.proposal!)}
+ onEdit={() => setEditProposal(item.proposal!)}
+ onChangeMatch={() => setChangeMatchItem(item)}
+ />
+ )
+ }
+ if (item.request) {
+ return (
+ removeItem(null, item.request!.id)}
+ />
+ )
+ }
+ return null
+ })}
+
+
+ {selectedIds.size > 0 && (
+
+ {selectedIds.size} valda
+
+ {batchRunning ? 'Godkänner…' : `Godkänn ${selectedIds.size} st`}
+
+
+ )}
+ >
+ )}
+
+ {editProposal && (
+ setEditProposal(null)}
+ onSubmit={async (edits) => {
+ const proposal = editProposal
+ setEditProposal(null)
+ await handleAccept(proposal, edits)
+ }}
+ />
+ )}
+
+ {changeMatchItem?.proposal && (
+ { 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 && (
+ setLearningPrompt(null)}
+ />
+ )}
+
+ )
+}
+
+// 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 (
+
+
+
+
+
+ Bearbetar befintliga kvitton
+
+
+
+ {capped} av {progress.target}
+
+
+
+ Stoppa
+
+
+
+
+
+ AI-agenten skapar förslag ett kvitto i taget. Nya kort dyker upp här automatiskt.
+
+
+
+ )
+}
diff --git a/components/agent-inbox/ChangeTransactionDialog.tsx b/components/agent-inbox/ChangeTransactionDialog.tsx
new file mode 100644
index 00000000..e182a29c
--- /dev/null
+++ b/components/agent-inbox/ChangeTransactionDialog.tsx
@@ -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([])
+ const [alternativeTx, setAlternativeTx] = useState>({})
+ const [loadingList, setLoadingList] = useState(false)
+ const [error, setError] = useState(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 = {}
+ 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 (
+
+
+
+ Byt transaktion
+
+ Välj en annan transaktion att koppla kvittot till. Du kan välja bland AI:ns
+ alternativ eller söka i alla okategoriserade transaktioner.
+
+
+
+
+ {/* AI alternatives */}
+ {alternatives.length > 0 && (
+
+
+
+ AI:ns alternativ ({alternatives.length})
+
+
+ {alternativesWithContext.map((alt) => (
+
+ setSelected({ id: alt.transaction_id, source: 'user_alternative' })
+ }
+ />
+ ))}
+
+
+ )}
+
+ {/* Toggle manual picker */}
+ {alternatives.length > 0 && !showAll && (
+
setShowAll(true)}
+ className="w-full"
+ >
+
+ Visa alla transaktioner
+
+ )}
+
+ {/* Manual picker */}
+ {showAll && (
+
+
+ Alla okategoriserade transaktioner
+
+
+
+ setSearch(e.target.value)}
+ className="pl-8"
+ />
+
+ {receiptTotal && (
+
+ Filtrerat på belopp runt {formatCurrency(-Math.abs(receiptTotal), 'SEK')} och datum runt{' '}
+ {receiptDate ? formatDate(receiptDate) : '—'}. Rensa sökrutan för att se fler.
+
+ )}
+ {loadingList ? (
+
+
+
+ ) : allTx.length === 0 ? (
+
+ Inga matchande transaktioner
+
+ ) : (
+
+ {allTx.map((tx) => (
+
setSelected({ id: tx.id, source: 'user_manual' })}
+ />
+ ))}
+
+ )}
+
+ )}
+
+
+ {error && (
+ {error}
+ )}
+
+
+ onOpenChange(false)} disabled={saving}>
+ Avbryt
+
+
+ {saving ? (
+ <>
+
+ Sparar…
+ >
+ ) : (
+ 'Använd denna transaktion'
+ )}
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+ {tx ? (
+ <>
+
+ {tx.description ?? 'Okänd'}
+
+ {formatCurrency(tx.amount, tx.currency ?? 'SEK')}
+
+
+
{formatDate(tx.date)}
+ >
+ ) : (
+
+ {transactionId.slice(0, 8)}…
+
+ )}
+
+
+ {isCurrent && Nuvarande }
+ {Math.round(confidence * 100)}%
+ {isSelected && }
+
+
+ “{reasoning}”
+
+ )
+}
+
+function PickerRow({
+ tx,
+ isCurrent,
+ isSelected,
+ onSelect,
+}: {
+ tx: PickerTx
+ isCurrent: boolean
+ isSelected: boolean
+ onSelect: () => void
+}) {
+ return (
+
+
+ {tx.description ?? 'Okänd'}
+
+ {formatCurrency(tx.amount, tx.currency ?? 'SEK')}
+
+
+
+
{formatDate(tx.date)}
+
+ {isCurrent && Nuvarande }
+ {isSelected && }
+
+
+
+ )
+}
+
diff --git a/components/agent-inbox/EditBookingDialog.tsx b/components/agent-inbox/EditBookingDialog.tsx
new file mode 100644
index 00000000..0fd63ded
--- /dev/null
+++ b/components/agent-inbox/EditBookingDialog.tsx
@@ -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
+}
+
+/**
+ * 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(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) => {
+ 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 (
+ !open && onClose()}>
+
+
+ Redigera bokföringsförslag
+
+
+
+
+ Beskrivning
+ setDescription(e.target.value)}
+ className="mt-1"
+ />
+
+
+
+
Rader
+
+ {!balanced && (
+
+ Debet och kredit måste summera till samma belopp.
+
+ )}
+
+
+
+
+ Avbryt
+
+
+ {submitting ? 'Bokför…' : 'Godkänn med ändringar'}
+
+
+
+
+
+ )
+}
diff --git a/components/agent-inbox/LearningPromptDialog.tsx b/components/agent-inbox/LearningPromptDialog.tsx
new file mode 100644
index 00000000..db59d96c
--- /dev/null
+++ b/components/agent-inbox/LearningPromptDialog.tsx
@@ -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 (
+ !open && onNo()}>
+
+
+ Kom ihåg denna bokföring?
+
+
+ Vill du att AI:n använder samma kontering nästa gång ett kvitto från{' '}
+ {counterpartyName} dyker upp?
+
+
+ Debet {debitAccount} · Kredit {creditAccount}
+
+
+
+ Bara den här gången
+
+ Ja, kom ihåg
+
+
+
+ )
+}
diff --git a/components/agent-inbox/ProposalCard.tsx b/components/agent-inbox/ProposalCard.tsx
new file mode 100644
index 00000000..6021448e
--- /dev/null
+++ b/components/agent-inbox/ProposalCard.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {isMatch ? 'Match' : 'Bokföring'}
+
+ {confidenceLabel(proposal.confidence)}
+
+ {isUserEdited && (
+
+ Ändrad av användare
+
+ )}
+ {inbox.document && (
+
+ {inbox.document.file_name}
+
+ )}
+
+
+ {isMatch && matchPayload && (
+
+ )}
+
+ {!isMatch && bookingPayload && (
+
+ )}
+
+ {receiptMissing && (
+
+
+ Kvittobild saknas — ladda upp i kvittodialogen innan du kan godkänna.
+
+ )}
+
+
+
+ {isBusy ? '…' : 'Godkänn'}
+
+ {isMatch && onChangeMatch && (
+
+ Byt transaktion
+
+ )}
+ {!isMatch && (
+
+ Redigera
+
+ )}
+
+ Avvisa
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+ {proposedTx ? (
+
+ ) : (
+
+ Föreslagen transaktion: {payload.matched_transaction_id}
+
+ )}
+
+
+
+ )
+}
+
+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 (
+
+
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)`}
+
+ {open && (
+
+ {reasoning && (
+
“{reasoning}”
+ )}
+ {hasAlternatives && (
+
+ {alternatives!.map((alt) => (
+
+ {Math.round(alt.confidence * 100)}% — {alt.reasoning}
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
+
+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 (
+ <>
+ 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'
+ )}
+ >
+
+ {merchant}
+ {total != null && (
+
+ {formatCurrency(total, currency)}
+
+ )}
+
+
+
+ Kvitto · {date ? formatDate(date) : 'Okänt datum'}
+
+ {!hasFile && (
+
+ Ingen kvittobild — klicka för att ladda upp
+
+ )}
+ {hasFile && !quality.ok && (
+
+ {quality.message}
+
+ )}
+
+
+ >
+ )
+}
+
+function TransactionBox({
+ tx,
+}: {
+ tx: NonNullable
+}) {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+ 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"
+ >
+
+ {tx.description || 'Okänd'}
+
+ {formatCurrency(tx.amount, tx.currency)}
+
+
+
+
+ Banktransaktion · {formatDate(tx.date)}
+
+
+
+ >
+ )
+}
+
+function BookingProposalBody({
+ payload,
+ reasoning,
+}: {
+ payload: BookingProposalPayload
+ reasoning: string | null
+}) {
+ const totalDebit = payload.lines.reduce((s, l) => s + l.debit_amount, 0)
+ return (
+
+
+
+
+
+ Konto
+ Debet
+ Kredit
+
+
+
+ {payload.lines.map((line, i) => (
+
+
+ {line.account_number} {' '}
+ {line.description}
+
+
+ {line.debit_amount > 0 ? line.debit_amount.toFixed(2) : ''}
+
+
+ {line.credit_amount > 0 ? line.credit_amount.toFixed(2) : ''}
+
+
+ ))}
+
+
+
+
+ {payload.vat_treatment && Moms: {payload.vat_treatment} }
+ {payload.default_private && Privat uttag }
+
+ {totalDebit.toFixed(2)}
+ {totalDebit.toFixed(2)}
+
+
+
+
+
+
+ )
+}
diff --git a/components/agent-inbox/ReceiptDetailDialog.tsx b/components/agent-inbox/ReceiptDetailDialog.tsx
new file mode 100644
index 00000000..d34672ec
--- /dev/null
+++ b/components/agent-inbox/ReceiptDetailDialog.tsx
@@ -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(null)
+ const [loadingUrl, setLoadingUrl] = useState(false)
+ const [uploading, setUploading] = useState(false)
+ const [uploadError, setUploadError] = useState(null)
+ const [requesting, setRequesting] = useState(false)
+ const fileInputRef = useRef(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 (
+
+
+
+
+ {merchant}
+
+
+ {data.receipt?.date ? formatDate(data.receipt.date) : 'Okänt datum'}
+ {data.receipt?.time && ` · ${data.receipt.time}`}
+ {data.totals?.total != null && (
+ · {formatCurrency(data.totals.total, currency)}
+ )}
+
+
+
+
+ {/* File preview */}
+
+ {!inbox.document ? (
+
+
+
+
+
+
Ingen kvittobild
+
+ Utan bildbevis kan bokföringen inte verifieras. Ladda upp kvittot (PDF, JPG, PNG, WebP — max 15 MB).
+
+
+
{
+ const f = e.target.files?.[0]
+ if (f) handleFilePicked(f)
+ e.target.value = ''
+ }}
+ />
+
+ fileInputRef.current?.click()}
+ disabled={uploading || requesting}
+ >
+ {uploading ? (
+ <>
+
+ Laddar upp…
+ >
+ ) : (
+ <>
+
+ Ladda upp kvittobild
+ >
+ )}
+
+
+ {requesting ? (
+ <>
+
+ Skickar…
+ >
+ ) : (
+ <>
+
+ Begär kvitto från teamet
+ >
+ )}
+
+
+ {uploadError && (
+
{uploadError}
+ )}
+
+ ) : loadingUrl ? (
+
+ ) : !downloadUrl ? (
+
+ Kunde inte ladda filen
+
+ ) : isImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : isPdf ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Extracted data */}
+
+ {/* Quality warning — shown when a file exists but the data is weak */}
+ {inbox.document && !quality.ok && (
+
+
Kvittot verkar otydligt
+
+ {quality.message} Be teamet skicka en tydligare bild för att kunna bokföra säkert.
+
+
+ {requesting ? (
+ <>
+
+ Skickar…
+ >
+ ) : (
+ <>
+
+ Begär nytt kvitto
+ >
+ )}
+
+
+ )}
+
+ {/* Merchant */}
+
+
+ Handlare
+
+
+ Namn
+ {data.merchant?.name ?? '—'}
+ {data.merchant?.orgNumber && (
+ <>
+ Org.nr
+ {data.merchant.orgNumber}
+ >
+ )}
+ {data.merchant?.vatNumber && (
+ <>
+ VAT-nr
+ {data.merchant.vatNumber}
+ >
+ )}
+ {(flags.isRestaurant || flags.isSystembolaget || flags.isForeignMerchant) && (
+ <>
+ Flagga
+
+ {flags.isRestaurant && Restaurang }
+ {flags.isSystembolaget && Systembolaget }
+ {flags.isForeignMerchant && Utländsk handlare }
+
+ >
+ )}
+
+
+
+ {/* Totals */}
+ {(data.totals?.subtotal != null || data.totals?.total != null) && (
+
+
+ Belopp
+
+
+ {data.totals?.subtotal != null && (
+ <>
+ Netto
+ {formatCurrency(data.totals.subtotal, currency)}
+ >
+ )}
+ {data.totals?.vatAmount != null && data.totals.vatAmount > 0 && (
+ <>
+ Moms
+ {formatCurrency(data.totals.vatAmount, currency)}
+ >
+ )}
+ {data.totals?.total != null && (
+ <>
+ Totalt
+ {formatCurrency(data.totals.total, currency)}
+ >
+ )}
+
+
+ )}
+
+ {/* Line items */}
+ {lineItems.length > 0 && (
+
+
+ Rader ({lineItems.length})
+
+
+
+
+ Beskrivning
+ Antal
+ Moms
+ Summa
+
+
+
+ {lineItems.map((li, i) => (
+
+ {li.description ?? 'Rad'}
+
+ {li.quantity ?? '—'}
+
+
+ {li.vatRate != null ? `${li.vatRate}%` : '—'}
+
+
+ {li.lineTotal != null ? formatCurrency(li.lineTotal, currency) : '—'}
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Meta */}
+
+
Källa: {inbox.source}{inbox.email_from ? ` (${inbox.email_from})` : ''}
+ {inbox.confidence != null && (
+
Extraktionskonfidens: {Math.round(Number(inbox.confidence) * 100)}%
+ )}
+ {inbox.document?.file_name && (
+
Fil: {inbox.document.file_name}
+ )}
+
+
+
+
+
+ )
+}
diff --git a/components/agent-inbox/RequestCard.tsx b/components/agent-inbox/RequestCard.tsx
new file mode 100644
index 00000000..2127dd9e
--- /dev/null
+++ b/components/agent-inbox/RequestCard.tsx
@@ -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 = {
+ 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 = (
+
+ Ladda upp ny bild
+
+ )
+ } else if (req.request_type === 'pick_transaction' || req.request_type === 'needs_manual') {
+ action = (
+
+ Gå till transaktioner
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ {REQUEST_LABEL[req.request_type]}
+ {item.inbox_item.document && (
+
+ {item.inbox_item.document.file_name}
+
+ )}
+
+
{req.message}
+
+ {action}
+
+ Markera som hanterad
+
+
+
+
+
+
+ )
+}
diff --git a/components/agent-inbox/TransactionDetailDialog.tsx b/components/agent-inbox/TransactionDetailDialog.tsx
new file mode 100644
index 00000000..48c5df44
--- /dev/null
+++ b/components/agent-inbox/TransactionDetailDialog.tsx
@@ -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
+}
+
+export default function TransactionDetailDialog({ open, onOpenChange, tx }: Props) {
+ return (
+
+
+
+
+ {tx.description || 'Okänd transaktion'}
+
+ {formatCurrency(tx.amount, tx.currency)}
+
+
+
+ {formatDate(tx.date)} · Banktransaktion
+
+
+
+
+
+ {tx.merchant_name && tx.merchant_name !== tx.description && (
+ <>
+ Handlare
+ {tx.merchant_name}
+ >
+ )}
+ Kategori
+ {tx.category ?? '—'}
+ Affärs/privat
+
+ {tx.is_business === true
+ ? 'Affärs'
+ : tx.is_business === false
+ ? 'Privat'
+ : 'Okänt'}
+
+ {tx.currency && tx.currency !== 'SEK' && (
+ <>
+ Valuta
+
+ {tx.currency}
+ {tx.amount_sek != null && (
+
+ {' '}
+ ({formatCurrency(tx.amount_sek, 'SEK')})
+
+ )}
+
+ {tx.exchange_rate != null && (
+ <>
+ Växelkurs
+ {tx.exchange_rate}
+ >
+ )}
+ {tx.exchange_rate_date && (
+ <>
+ Kursdatum
+ {formatDate(tx.exchange_rate_date)}
+ >
+ )}
+ >
+ )}
+ {tx.mcc_code != null && (
+ <>
+ MCC-kod
+ {tx.mcc_code}
+ >
+ )}
+ {tx.external_id && (
+ <>
+ Externt ID
+
+ {tx.external_id}
+
+ >
+ )}
+ {tx.bank_connection_id && (
+ <>
+ Bankanslutning
+
+ {tx.bank_connection_id.slice(0, 8)}…
+
+ >
+ )}
+ Transaktions-ID
+
+ {tx.id}
+
+
+
+
+
+
+
+ Öppna i transaktionslistan
+
+
+
+
+
+
+ )
+}
diff --git a/components/agent-inbox/receipt-quality.ts b/components/agent-inbox/receipt-quality.ts
new file mode 100644
index 00000000..a6611f98
--- /dev/null
+++ b/components/agent-inbox/receipt-quality.ts
@@ -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 = {
+ 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(', ')}.`,
+ }
+}
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index 3f1f45f3..a4f6fc8f 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -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
Kommer snart
+ ) : item.devBadge ? (
+
+ Dev
+
) : badge !== null && (
{badge > 99 ? '99+' : badge}
@@ -592,6 +602,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
Kommer snart
+ ) : item.devBadge ? (
+
+ Dev
+
) : badge !== null && (
{badge > 99 ? '99+' : badge}
diff --git a/components/receipts/ManualExtractDialog.tsx b/components/receipts/ManualExtractDialog.tsx
new file mode 100644
index 00000000..22d21a12
--- /dev/null
+++ b/components/receipts/ManualExtractDialog.tsx
@@ -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(data?.totals?.total != null ? String(data.totals.total) : '')
+ const [vatAmount, setVatAmount] = useState(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 (
+ { if (!open) onClose() }}>
+
+
+ Skriv in kvittouppgifter
+
+ Använd det här när AI inte kan läsa bilden. Bilden behålls som underlag —
+ du anger bara siffrorna så kan kvittot matchas mot en banktransaktion.
+
+
+
+
+
+
+ )
+}
diff --git a/components/receipts/ReceiptsList.tsx b/components/receipts/ReceiptsList.tsx
new file mode 100644
index 00000000..43d8565a
--- /dev/null
+++ b/components/receipts/ReceiptsList.tsx
@@ -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 = {
+ pending: 'Väntar',
+ processing: 'Bearbetar',
+ ready: 'Klar',
+ confirmed: 'Bokförd',
+ rejected: 'Avvisad',
+ error: 'Fel',
+}
+
+const STATUS_VARIANTS: Record = {
+ 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 (
+
+ )
+ }
+ if (isImage && row.preview_url) {
+ // eslint-disable-next-line @next/next/no-img-element
+ return (
+
+ )
+ }
+ return (
+
+
+ {isPdf ? 'PDF' : 'Fil'}
+
+ )
+}
+
+// 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 (
+
+
+
+
+
+
+
+
+ {upload.file_name}
+
+
+
+
+ AI läser kvittot…
+
+
+ Det här brukar ta 5–10 sekunder.
+
+
+
+
+
+
+ )
+}
+
+function VerificationBadge({ row }: { row: ReceiptRowWithPreview }) {
+ const state = getVerificationState(row)
+ if (state === 'agreed') {
+ return (
+
+
+ OCR verifierad
+
+ )
+ }
+ if (state === 'disagreed') {
+ return (
+
+
+ Behöver granskning
+
+ )
+ }
+ if (state === 'ocr-only') {
+ return (
+
+ Endast OCR
+
+ )
+ }
+ 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 (
+
+ 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.
+
+ )
+}
+
+// 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([])
+ const [batchScanning, setBatchScanning] = useState(false)
+ const [rescanId, setRescanId] = useState(null)
+ const [attachingId, setAttachingId] = useState(null)
+ const [manualRow, setManualRow] = useState(null)
+ const fileInputRef = useRef(null)
+ const attachInputRef = useRef(null)
+ const attachTargetRef = useRef(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) => {
+ 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) => {
+ 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 (
+
+ }
+ />
+
+ {items.length === 0 && pendingUploads.length === 0 ? (
+
+
+
+
+
+ Inga kvitton än
+
+ Ladda upp ett kvitto (PDF, JPG, PNG, HEIC eller WebP) så tar AI hand om resten.
+
+
+
+ ) : (
+
+ {pendingUploads.map((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 (
+
+
+
+
+
+
+ {s.merchant}
+ {s.total != null && (
+
+ {formatCurrency(s.total, s.currency)}
+
+ )}
+
+
+
+ {(statusKey === 'processing' || statusKey === 'pending') && (
+
+ )}
+ {STATUS_LABELS[statusKey] ?? statusKey}
+
+
+ {s.date && (
+
+ {formatDate(s.date)}
+
+ )}
+ {row.document?.file_name && (
+
+ · {row.document.file_name}
+
+ )}
+ {row.source === 'email' && (
+ · via e-post
+ )}
+
+ {row.error_message && (
+
{row.error_message}
+ )}
+
+
+ {needsImage && (
+
+ handlePickAttachFile(row.id)}
+ disabled={isAttaching}
+ >
+ {isAttaching ? (
+ <>
+
+ Laddar upp…
+ >
+ ) : (
+ <>
+
+ Ladda upp bild
+ >
+ )}
+
+
+ )}
+ {!needsImage && canRescan && (
+
+
handleRescanOne(row)} disabled={isRescanning}>
+ {isRescanning ? (
+ <>
+
+ Skannar…
+ >
+ ) : (
+ <>
+
+ Skanna igen
+ >
+ )}
+
+
setManualRow(row)}>
+
+ Skriv in själv
+
+
+ )}
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ {manualRow && (
+ setManualRow(null)}
+ onSaved={() => {
+ setManualRow(null)
+ router.refresh()
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/extensions.config.json b/extensions.config.json
index a54aeaf3..ffafb339 100644
--- a/extensions.config.json
+++ b/extensions.config.json
@@ -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"]}
diff --git a/extensions/general/ai-agent/index.ts b/extensions/general/ai-agent/index.ts
new file mode 100644
index 00000000..ea00a51c
--- /dev/null
+++ b/extensions/general/ai-agent/index.ts
@@ -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',
+}
diff --git a/extensions/general/ai-agent/lib/bedrock-client.ts b/extensions/general/ai-agent/lib/bedrock-client.ts
new file mode 100644
index 00000000..680c3ff7
--- /dev/null
+++ b/extensions/general/ai-agent/lib/bedrock-client.ts
@@ -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)
+}
diff --git a/extensions/general/ai-agent/lib/bedrock-service.ts b/extensions/general/ai-agent/lib/bedrock-service.ts
new file mode 100644
index 00000000..f17f73c5
--- /dev/null
+++ b/extensions/general/ai-agent/lib/bedrock-service.ts
@@ -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 {
+ if (!this.isEnabled()) return null
+ return generateMatchForExtension(ctx)
+ }
+
+ async generateBookingProposal(
+ ctx: GenerateBookingContext
+ ): Promise {
+ if (!this.isEnabled()) return null
+ return generateBookingForExtension(ctx)
+ }
+}
diff --git a/extensions/general/ai-agent/lib/generate-booking.ts b/extensions/general/ai-agent/lib/generate-booking.ts
new file mode 100644
index 00000000..8dc43ed9
--- /dev/null
+++ b/extensions/general/ai-agent/lib/generate-booking.ts
@@ -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 {
+ 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 | 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
+ 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 | 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)
+ .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
+ 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}`
+}
diff --git a/extensions/general/ai-agent/lib/generate-match.ts b/extensions/general/ai-agent/lib/generate-match.ts
new file mode 100644
index 00000000..fb211f6d
--- /dev/null
+++ b/extensions/general/ai-agent/lib/generate-match.ts
@@ -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 {
+ 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
+ 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)
+ .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))
+}
diff --git a/extensions/general/ai-agent/lib/prompts/booking-prompt.ts b/extensions/general/ai-agent/lib/prompts/booking-prompt.ts
new file mode 100644
index 00000000..d1e8b6a0
--- /dev/null
+++ b/extensions/general/ai-agent/lib/prompts/booking-prompt.ts
@@ -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: {} },
+}
diff --git a/extensions/general/ai-agent/lib/prompts/match-prompt.ts b/extensions/general/ai-agent/lib/prompts/match-prompt.ts
new file mode 100644
index 00000000..168b7620
--- /dev/null
+++ b/extensions/general/ai-agent/lib/prompts/match-prompt.ts
@@ -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: {} },
+}
diff --git a/extensions/general/ai-agent/manifest.json b/extensions/general/ai-agent/manifest.json
new file mode 100644
index 00000000..d402854e
--- /dev/null
+++ b/extensions/general/ai-agent/manifest.json
@@ -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."
+ }
+}
diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts
index 18af24f5..ac7f32fe 100644
--- a/extensions/general/invoice-inbox/index.ts
+++ b/extensions/general/invoice-inbox/index.ts
@@ -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> | undefined
+ classificationError: string | null
+ textract: TextractExpenseResult | null
+ agreement: AgreementResult | null
+ needsReview: boolean
+}
+
+async function extractWithVerification(
+ fileBuffer: Buffer,
+ mimeType: string,
+ fileName: string
+): Promise {
+ 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> | undefined,
+ textract: TextractExpenseResult | null,
+ agreement: AgreementResult | null
+): Record | 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),
+ _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',
diff --git a/extensions/general/invoice-inbox/lib/textract-expense.ts b/extensions/general/invoice-inbox/lib/textract-expense.ts
new file mode 100644
index 00000000..dbe1af69
--- /dev/null
+++ b/extensions/general/invoice-inbox/lib/textract-expense.ts
@@ -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 {
+ 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,
+ }
+}
diff --git a/lib/ai/feature-flag.ts b/lib/ai/feature-flag.ts
new file mode 100644
index 00000000..209f0e3e
--- /dev/null
+++ b/lib/ai/feature-flag.ts
@@ -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 })
+}
diff --git a/lib/ai/orchestrator.ts b/lib/ai/orchestrator.ts
new file mode 100644
index 00000000..b3c5b093
--- /dev/null
+++ b/lib/ai/orchestrator.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 | 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 {
+ 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 | 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 {
+ 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 {
+ 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 }
diff --git a/lib/ai/proposal-service.ts b/lib/ai/proposal-service.ts
new file mode 100644
index 00000000..6b68f39e
--- /dev/null
+++ b/lib/ai/proposal-service.ts
@@ -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
+ options?: Record | { candidates: PickTransactionOption[] }
+ }
+ provenance: Partial
+}
+
+// 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
+
+ /**
+ * Propose how to book the matched transaction (accounts, VAT, lines).
+ * Returns null on service outage (orchestrator will issue needs_manual).
+ */
+ generateBookingProposal(
+ ctx: GenerateBookingContext
+ ): Promise
+}
+
+class NoopAIProposalService implements AIProposalService {
+ isEnabled(): boolean {
+ return false
+ }
+ async generateMatchProposal(): Promise {
+ return null
+ }
+ async generateBookingProposal(): Promise {
+ 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()
+}
diff --git a/lib/ai/proposals/__tests__/persist.test.ts b/lib/ai/proposals/__tests__/persist.test.ts
new file mode 100644
index 00000000..46656b64
--- /dev/null
+++ b/lib/ai/proposals/__tests__/persist.test.ts
@@ -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 = {
+ 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',
+ })
+ })
+})
diff --git a/lib/ai/proposals/__tests__/re-validate.test.ts b/lib/ai/proposals/__tests__/re-validate.test.ts
new file mode 100644
index 00000000..874880d6
--- /dev/null
+++ b/lib/ai/proposals/__tests__/re-validate.test.ts
@@ -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 {
+ 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 {
+ // 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 = {
+ 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'])
+ }
+ })
+})
diff --git a/lib/ai/proposals/apply.ts b/lib/ai/proposals/apply.ts
new file mode 100644
index 00000000..9142d22d
--- /dev/null
+++ b/lib/ai/proposals/apply.ts
@@ -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 {
+ 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 {
+ 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 {
+ // 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,
+ }
+}
diff --git a/lib/ai/proposals/persist.ts b/lib/ai/proposals/persist.ts
new file mode 100644
index 00000000..5e873b76
--- /dev/null
+++ b/lib/ai/proposals/persist.ts
@@ -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 {
+ // 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
+ options?: Record
+ model?: string
+ promptVersion?: string
+ correlationId?: string
+}
+
+export async function insertRequest(
+ supabase: SupabaseClient,
+ input: InsertRequestInput
+): Promise {
+ // 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 {
+ 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 {
+ await supabase
+ .from('ai_proposals')
+ .update({
+ status: 'skipped',
+ invalidated_reason: reason,
+ })
+ .eq('subject_type', subjectType)
+ .eq('subject_id', subjectId)
+ .eq('status', 'pending')
+}
diff --git a/lib/ai/proposals/re-validate.ts b/lib/ai/proposals/re-validate.ts
new file mode 100644
index 00000000..afdc7eb7
--- /dev/null
+++ b/lib/ai/proposals/re-validate.ts
@@ -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
+}
+
+export type ValidationResult = ValidationSuccess | ValidationFailure
+
+export async function reValidateProposal(
+ supabase: SupabaseClient,
+ companyId: string,
+ proposal: AIProposal
+): Promise {
+ 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 {
+ // 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 {
+ 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 } | 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
+}
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 6f97d00b..f985bb57 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -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),
+})
diff --git a/lib/bookkeeping/counterparty-templates.ts b/lib/bookkeeping/counterparty-templates.ts
index df8a4098..45748ae6 100644
--- a/lib/bookkeeping/counterparty-templates.ts
+++ b/lib/bookkeeping/counterparty-templates.ts
@@ -80,6 +80,11 @@ const SOURCE_PRIORITY: Record = {
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(
diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts
index 4151df85..e9dc451c 100644
--- a/lib/bookkeeping/engine.ts
+++ b/lib/bookkeeping/engine.ts
@@ -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()
diff --git a/lib/events/types.ts b/lib/events/types.ts
index 1dd978b5..b6310c8b 100644
--- a/lib/events/types.ts
+++ b/lib/events/types.ts
@@ -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
diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts
index ebab2da7..7c773ec8 100644
--- a/lib/extensions/__tests__/sectors.test.ts
+++ b/lib/extensions/__tests__/sectors.test.ts
@@ -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', () => {
diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts
index 177c994e..e2e07b40 100644
--- a/lib/extensions/_generated/enabled-extensions.ts
+++ b/lib/extensions/_generated/enabled-extensions.ts
@@ -7,4 +7,6 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([
'tic',
'mcp-server',
'cloud-backup',
+ 'invoice-inbox',
+ 'ai-agent',
])
diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts
index 3c9e88f5..013f2f52 100644
--- a/lib/extensions/_generated/extension-list.ts
+++ b/lib/extensions/_generated/extension-list.ts
@@ -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,
]
diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts
index b58035ee..fee98a60 100644
--- a/lib/extensions/_generated/sector-definitions.ts
+++ b/lib/extensions/_generated/sector-definitions.ts
@@ -80,5 +80,38 @@ export const EXTENSION_DEFINITIONS: Record = {
"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"
+ ]
+ },
],
}
diff --git a/lib/extensions/_generated/workspace-map.tsx b/lib/extensions/_generated/workspace-map.tsx
index f5699696..0b84d529 100644
--- a/lib/extensions/_generated/workspace-map.tsx
+++ b/lib/extensions/_generated/workspace-map.tsx
@@ -8,4 +8,5 @@ export const WORKSPACES: Record>
'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')),
}
diff --git a/lib/init.ts b/lib/init.ts
index 7e52f57d..1435351b 100644
--- a/lib/init.ts
+++ b/lib/init.ts
@@ -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
diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts
index 2c893e61..4359df7f 100644
--- a/lib/transactions/__tests__/ingest.test.ts
+++ b/lib/transactions/__tests__/ingest.test.ts
@@ -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 })
diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts
index a939893e..8ac2ce1e 100644
--- a/lib/transactions/ingest.ts
+++ b/lib/transactions/ingest.ts
@@ -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 => {
+ 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,
diff --git a/package-lock.json b/package-lock.json
index f8496b7b..2271b3c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "AGPL-3.0-or-later",
"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",
@@ -283,23 +284,74 @@
"node": ">=20.0.0"
}
},
- "node_modules/@aws-sdk/core": {
- "version": "3.973.26",
- "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.26.tgz",
- "integrity": "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==",
+ "node_modules/@aws-sdk/client-textract": {
+ "version": "3.1036.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-textract/-/client-textract-3.1036.0.tgz",
+ "integrity": "sha512-LA/mszvOk5HYYN9j3ljKXzPbjSgUBeM5GH1rf6yvAGQ/wJKH5fdxSxuAnBj79iuWdSPzYcScfmuiUP46TzPXmg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/xml-builder": "^3.972.16",
- "@smithy/core": "^3.23.13",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/signature-v4": "^5.3.12",
- "@smithy/smithy-client": "^4.12.8",
- "@smithy/types": "^4.13.1",
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/credential-provider-node": "^3.972.36",
+ "@aws-sdk/middleware-host-header": "^3.972.10",
+ "@aws-sdk/middleware-logger": "^3.972.10",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.11",
+ "@aws-sdk/middleware-user-agent": "^3.972.35",
+ "@aws-sdk/region-config-resolver": "^3.972.13",
+ "@aws-sdk/types": "^3.973.8",
+ "@aws-sdk/util-endpoints": "^3.996.8",
+ "@aws-sdk/util-user-agent-browser": "^3.972.10",
+ "@aws-sdk/util-user-agent-node": "^3.973.21",
+ "@smithy/config-resolver": "^4.4.17",
+ "@smithy/core": "^3.23.17",
+ "@smithy/fetch-http-handler": "^5.3.17",
+ "@smithy/hash-node": "^4.2.14",
+ "@smithy/invalid-dependency": "^4.2.14",
+ "@smithy/middleware-content-length": "^4.2.14",
+ "@smithy/middleware-endpoint": "^4.4.32",
+ "@smithy/middleware-retry": "^4.5.5",
+ "@smithy/middleware-serde": "^4.2.20",
+ "@smithy/middleware-stack": "^4.2.14",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/node-http-handler": "^4.6.1",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/smithy-client": "^4.12.13",
+ "@smithy/types": "^4.14.1",
+ "@smithy/url-parser": "^4.2.14",
"@smithy/util-base64": "^4.3.2",
- "@smithy/util-middleware": "^4.2.12",
+ "@smithy/util-body-length-browser": "^4.2.2",
+ "@smithy/util-body-length-node": "^4.2.3",
+ "@smithy/util-defaults-mode-browser": "^4.3.49",
+ "@smithy/util-defaults-mode-node": "^4.2.54",
+ "@smithy/util-endpoints": "^3.4.2",
+ "@smithy/util-middleware": "^4.2.14",
+ "@smithy/util-retry": "^4.3.4",
+ "@smithy/util-utf8": "^4.2.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.974.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.5.tgz",
+ "integrity": "sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.8",
+ "@aws-sdk/xml-builder": "^3.972.19",
+ "@smithy/core": "^3.23.17",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/signature-v4": "^5.3.14",
+ "@smithy/smithy-client": "^4.12.13",
+ "@smithy/types": "^4.14.1",
+ "@smithy/util-base64": "^4.3.2",
+ "@smithy/util-middleware": "^4.2.14",
+ "@smithy/util-retry": "^4.3.4",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -308,15 +360,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.972.24",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz",
- "integrity": "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w==",
+ "version": "3.972.31",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.31.tgz",
+ "integrity": "sha512-X/yGB73LmDW/6MdDJGCDzZBUXnM3ys4vs9l+5ZTJmiEswDdP1OjeoAFlFjVGS9o4KB2wZWQ9KOfdVNSSK6Ep3w==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -324,20 +376,20 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
- "version": "3.972.26",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz",
- "integrity": "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA==",
+ "version": "3.972.33",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.33.tgz",
+ "integrity": "sha512-c0ZF+lwoWVvX5iCaGKL5T/4DnIw88CGqxA0BcBs3U86mIp5EZYPVg+KSPkMXOyokmADvNewiMUfSG2uFwjRp0g==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/fetch-http-handler": "^5.3.15",
- "@smithy/node-http-handler": "^4.5.1",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/smithy-client": "^4.12.8",
- "@smithy/types": "^4.13.1",
- "@smithy/util-stream": "^4.5.21",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/fetch-http-handler": "^5.3.17",
+ "@smithy/node-http-handler": "^4.6.1",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/smithy-client": "^4.12.13",
+ "@smithy/types": "^4.14.1",
+ "@smithy/util-stream": "^4.5.25",
"tslib": "^2.6.2"
},
"engines": {
@@ -345,24 +397,24 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.972.28",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.28.tgz",
- "integrity": "sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw==",
+ "version": "3.972.35",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.35.tgz",
+ "integrity": "sha512-jsU4u/cRkKFLKQS0k918FQ27fzXLG5ENiLWQMYE6581zLeI2hWh04ptlrvZMB3wJT/5d+vSzJk74X1CMFr4y8Q==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/credential-provider-env": "^3.972.24",
- "@aws-sdk/credential-provider-http": "^3.972.26",
- "@aws-sdk/credential-provider-login": "^3.972.28",
- "@aws-sdk/credential-provider-process": "^3.972.24",
- "@aws-sdk/credential-provider-sso": "^3.972.28",
- "@aws-sdk/credential-provider-web-identity": "^3.972.28",
- "@aws-sdk/nested-clients": "^3.996.18",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/credential-provider-imds": "^4.2.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/credential-provider-env": "^3.972.31",
+ "@aws-sdk/credential-provider-http": "^3.972.33",
+ "@aws-sdk/credential-provider-login": "^3.972.35",
+ "@aws-sdk/credential-provider-process": "^3.972.31",
+ "@aws-sdk/credential-provider-sso": "^3.972.35",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.35",
+ "@aws-sdk/nested-clients": "^3.997.3",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/credential-provider-imds": "^4.2.14",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -370,18 +422,18 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.972.28",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz",
- "integrity": "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==",
+ "version": "3.972.35",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.35.tgz",
+ "integrity": "sha512-5oa3j0cA50jPqgNhZ9XdJVopuzUf1klRb28/2MfLYWWiPi9DRVvbrBWT+DidbHTT36520VuXZJahQwR+YgSjrg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/nested-clients": "^3.996.18",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/nested-clients": "^3.997.3",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -389,22 +441,22 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.972.29",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.29.tgz",
- "integrity": "sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g==",
+ "version": "3.972.36",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.36.tgz",
+ "integrity": "sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/credential-provider-env": "^3.972.24",
- "@aws-sdk/credential-provider-http": "^3.972.26",
- "@aws-sdk/credential-provider-ini": "^3.972.28",
- "@aws-sdk/credential-provider-process": "^3.972.24",
- "@aws-sdk/credential-provider-sso": "^3.972.28",
- "@aws-sdk/credential-provider-web-identity": "^3.972.28",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/credential-provider-imds": "^4.2.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/credential-provider-env": "^3.972.31",
+ "@aws-sdk/credential-provider-http": "^3.972.33",
+ "@aws-sdk/credential-provider-ini": "^3.972.35",
+ "@aws-sdk/credential-provider-process": "^3.972.31",
+ "@aws-sdk/credential-provider-sso": "^3.972.35",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.35",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/credential-provider-imds": "^4.2.14",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -412,16 +464,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.972.24",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz",
- "integrity": "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw==",
+ "version": "3.972.31",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.31.tgz",
+ "integrity": "sha512-eKeT4MXumpBJsrDLCYcSzIkFPVTFn/es7It2oogp2OhU/ic7P/+xzFpQx9ZhwtXS57Mc5S42BPWi7lHmvs/nYg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -429,18 +481,18 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.972.28",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.28.tgz",
- "integrity": "sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A==",
+ "version": "3.972.35",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.35.tgz",
+ "integrity": "sha512-bCuBdfnj0KGDMdLp6utMTLiJcFN2ek9EgZinxQZZSc3FxjJ/HSqeqab2cjbnoNfy8RM6suDCsRkmVY1izp9I+A==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/nested-clients": "^3.996.18",
- "@aws-sdk/token-providers": "3.1021.0",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/nested-clients": "^3.997.3",
+ "@aws-sdk/token-providers": "3.1036.0",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -448,17 +500,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
- "version": "3.1021.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1021.0.tgz",
- "integrity": "sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA==",
+ "version": "3.1036.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1036.0.tgz",
+ "integrity": "sha512-aNSJ6jjDYayxN9ZA1JpycVScX93Lx03kKZ1EXt3DGOTahcWVLJj3oLAlop0xKP+vP2Ga2t49p1tEaMkTbCCaZA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/nested-clients": "^3.996.18",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/nested-clients": "^3.997.3",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -466,17 +518,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.972.28",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.28.tgz",
- "integrity": "sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ==",
+ "version": "3.972.35",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.35.tgz",
+ "integrity": "sha512-swW6Bwvl8lanyEMtZOWE/oR6yqcRQH4HTQZUVsnDVgoXvRjRywpYpLv2BWwjUFyjPrqsdX6FeTkf4tMSe/qFTQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/nested-clients": "^3.996.18",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/nested-clients": "^3.997.3",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -514,14 +566,14 @@
}
},
"node_modules/@aws-sdk/middleware-host-header": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz",
- "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==",
+ "version": "3.972.10",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz",
+ "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -529,13 +581,13 @@
}
},
"node_modules/@aws-sdk/middleware-logger": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz",
- "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==",
+ "version": "3.972.10",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz",
+ "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -543,15 +595,40 @@
}
},
"node_modules/@aws-sdk/middleware-recursion-detection": {
- "version": "3.972.9",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz",
- "integrity": "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==",
+ "version": "3.972.11",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz",
+ "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
+ "@aws-sdk/types": "^3.973.8",
"@aws/lambda-invoke-store": "^0.2.2",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/types": "^4.14.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.34",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.34.tgz",
+ "integrity": "sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/types": "^3.973.8",
+ "@aws-sdk/util-arn-parser": "^3.972.3",
+ "@smithy/core": "^3.23.17",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/signature-v4": "^5.3.14",
+ "@smithy/smithy-client": "^4.12.13",
+ "@smithy/types": "^4.14.1",
+ "@smithy/util-config-provider": "^4.2.2",
+ "@smithy/util-middleware": "^4.2.14",
+ "@smithy/util-stream": "^4.5.25",
+ "@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -559,18 +636,18 @@
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
- "version": "3.972.28",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.28.tgz",
- "integrity": "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==",
+ "version": "3.972.35",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.35.tgz",
+ "integrity": "sha512-hOFWNOjVmOocpRlrU04nYxjMOeoe0Obu5AXEuhB8zblMCPl3cG1hdluQCZERRKFyhMQjwZnDbhSHjoMUjetFGw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/util-endpoints": "^3.996.5",
- "@smithy/core": "^3.23.13",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-retry": "^4.2.13",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/types": "^3.973.8",
+ "@aws-sdk/util-endpoints": "^3.996.8",
+ "@smithy/core": "^3.23.17",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/types": "^4.14.1",
+ "@smithy/util-retry": "^4.3.4",
"tslib": "^2.6.2"
},
"engines": {
@@ -601,47 +678,48 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
- "version": "3.996.18",
- "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.18.tgz",
- "integrity": "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==",
+ "version": "3.997.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.3.tgz",
+ "integrity": "sha512-SivE6GP228IVgfsrr2c/vqTg95X0Qj39Yw4uIrcddpkUzIltNMoNOR62leHOLhODfjv9K8X2mPTwS69A5kT0nQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "^3.973.26",
- "@aws-sdk/middleware-host-header": "^3.972.8",
- "@aws-sdk/middleware-logger": "^3.972.8",
- "@aws-sdk/middleware-recursion-detection": "^3.972.9",
- "@aws-sdk/middleware-user-agent": "^3.972.28",
- "@aws-sdk/region-config-resolver": "^3.972.10",
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/util-endpoints": "^3.996.5",
- "@aws-sdk/util-user-agent-browser": "^3.972.8",
- "@aws-sdk/util-user-agent-node": "^3.973.14",
- "@smithy/config-resolver": "^4.4.13",
- "@smithy/core": "^3.23.13",
- "@smithy/fetch-http-handler": "^5.3.15",
- "@smithy/hash-node": "^4.2.12",
- "@smithy/invalid-dependency": "^4.2.12",
- "@smithy/middleware-content-length": "^4.2.12",
- "@smithy/middleware-endpoint": "^4.4.28",
- "@smithy/middleware-retry": "^4.4.46",
- "@smithy/middleware-serde": "^4.2.16",
- "@smithy/middleware-stack": "^4.2.12",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/node-http-handler": "^4.5.1",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/smithy-client": "^4.12.8",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
+ "@aws-sdk/core": "^3.974.5",
+ "@aws-sdk/middleware-host-header": "^3.972.10",
+ "@aws-sdk/middleware-logger": "^3.972.10",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.11",
+ "@aws-sdk/middleware-user-agent": "^3.972.35",
+ "@aws-sdk/region-config-resolver": "^3.972.13",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.22",
+ "@aws-sdk/types": "^3.973.8",
+ "@aws-sdk/util-endpoints": "^3.996.8",
+ "@aws-sdk/util-user-agent-browser": "^3.972.10",
+ "@aws-sdk/util-user-agent-node": "^3.973.21",
+ "@smithy/config-resolver": "^4.4.17",
+ "@smithy/core": "^3.23.17",
+ "@smithy/fetch-http-handler": "^5.3.17",
+ "@smithy/hash-node": "^4.2.14",
+ "@smithy/invalid-dependency": "^4.2.14",
+ "@smithy/middleware-content-length": "^4.2.14",
+ "@smithy/middleware-endpoint": "^4.4.32",
+ "@smithy/middleware-retry": "^4.5.5",
+ "@smithy/middleware-serde": "^4.2.20",
+ "@smithy/middleware-stack": "^4.2.14",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/node-http-handler": "^4.6.1",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/smithy-client": "^4.12.13",
+ "@smithy/types": "^4.14.1",
+ "@smithy/url-parser": "^4.2.14",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
"@smithy/util-body-length-node": "^4.2.3",
- "@smithy/util-defaults-mode-browser": "^4.3.44",
- "@smithy/util-defaults-mode-node": "^4.2.48",
- "@smithy/util-endpoints": "^3.3.3",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-retry": "^4.2.13",
+ "@smithy/util-defaults-mode-browser": "^4.3.49",
+ "@smithy/util-defaults-mode-node": "^4.2.54",
+ "@smithy/util-endpoints": "^3.4.2",
+ "@smithy/util-middleware": "^4.2.14",
+ "@smithy/util-retry": "^4.3.4",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -650,15 +728,32 @@
}
},
"node_modules/@aws-sdk/region-config-resolver": {
- "version": "3.972.10",
- "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz",
- "integrity": "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==",
+ "version": "3.972.13",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz",
+ "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/config-resolver": "^4.4.13",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/config-resolver": "^4.4.17",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/types": "^4.14.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.22",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.22.tgz",
+ "integrity": "sha512-/rXhMXteD+BqhFd0nYprAgcZ/KtU+963uftPqd3tiFcFfooHZINXUGtOmo2SQjRVauCTNqIEzkwuSETdZFqTTA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-sdk-s3": "^3.972.34",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/signature-v4": "^5.3.14",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -684,12 +779,24 @@
}
},
"node_modules/@aws-sdk/types": {
- "version": "3.973.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz",
- "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==",
+ "version": "3.973.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz",
+ "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.14.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-arn-parser": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz",
+ "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -697,15 +804,15 @@
}
},
"node_modules/@aws-sdk/util-endpoints": {
- "version": "3.996.5",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz",
- "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==",
+ "version": "3.996.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz",
+ "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
- "@smithy/util-endpoints": "^3.3.3",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/types": "^4.14.1",
+ "@smithy/url-parser": "^4.2.14",
+ "@smithy/util-endpoints": "^3.4.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -740,27 +847,27 @@
}
},
"node_modules/@aws-sdk/util-user-agent-browser": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz",
- "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==",
+ "version": "3.972.10",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz",
+ "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/types": "^4.14.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
- "version": "3.973.14",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz",
- "integrity": "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw==",
+ "version": "3.973.21",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.21.tgz",
+ "integrity": "sha512-Av4UHTcAWgdvbN0IP9pbtf4Qa1+6LtJqQdZWj5pLn5J67w0pnJJAZZ+7JPPcj2KN3378zD2JDM9DwJKEyvyMTQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/middleware-user-agent": "^3.972.28",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/types": "^4.13.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.35",
+ "@aws-sdk/types": "^3.973.8",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/types": "^4.14.1",
"@smithy/util-config-provider": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -777,13 +884,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
- "version": "3.972.16",
- "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz",
- "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==",
+ "version": "3.972.19",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.19.tgz",
+ "integrity": "sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
- "fast-xml-parser": "5.5.8",
+ "@smithy/types": "^4.14.1",
+ "fast-xml-parser": "5.7.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -2691,6 +2798,18 @@
"node": ">= 10"
}
},
+ "node_modules/@nodable/entities": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
+ "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4349,16 +4468,16 @@
}
},
"node_modules/@smithy/config-resolver": {
- "version": "4.4.13",
- "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz",
- "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==",
+ "version": "4.4.17",
+ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz",
+ "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/types": "^4.14.1",
"@smithy/util-config-provider": "^4.2.2",
- "@smithy/util-endpoints": "^3.3.3",
- "@smithy/util-middleware": "^4.2.12",
+ "@smithy/util-endpoints": "^3.4.2",
+ "@smithy/util-middleware": "^4.2.14",
"tslib": "^2.6.2"
},
"engines": {
@@ -4366,18 +4485,18 @@
}
},
"node_modules/@smithy/core": {
- "version": "3.23.13",
- "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz",
- "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==",
+ "version": "3.23.17",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz",
+ "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/types": "^4.14.1",
+ "@smithy/url-parser": "^4.2.14",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-stream": "^4.5.21",
+ "@smithy/util-middleware": "^4.2.14",
+ "@smithy/util-stream": "^4.5.25",
"@smithy/util-utf8": "^4.2.2",
"@smithy/uuid": "^1.1.2",
"tslib": "^2.6.2"
@@ -4387,15 +4506,15 @@
}
},
"node_modules/@smithy/credential-provider-imds": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz",
- "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz",
+ "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/types": "^4.14.1",
+ "@smithy/url-parser": "^4.2.14",
"tslib": "^2.6.2"
},
"engines": {
@@ -4473,14 +4592,14 @@
}
},
"node_modules/@smithy/fetch-http-handler": {
- "version": "5.3.15",
- "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz",
- "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==",
+ "version": "5.3.17",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz",
+ "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/querystring-builder": "^4.2.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/querystring-builder": "^4.2.14",
+ "@smithy/types": "^4.14.1",
"@smithy/util-base64": "^4.3.2",
"tslib": "^2.6.2"
},
@@ -4489,12 +4608,12 @@
}
},
"node_modules/@smithy/hash-node": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz",
- "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz",
+ "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"@smithy/util-buffer-from": "^4.2.2",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
@@ -4504,12 +4623,12 @@
}
},
"node_modules/@smithy/invalid-dependency": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz",
- "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz",
+ "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4529,13 +4648,13 @@
}
},
"node_modules/@smithy/middleware-content-length": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz",
- "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz",
+ "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4543,18 +4662,18 @@
}
},
"node_modules/@smithy/middleware-endpoint": {
- "version": "4.4.28",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz",
- "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==",
+ "version": "4.4.32",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz",
+ "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.23.13",
- "@smithy/middleware-serde": "^4.2.16",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
- "@smithy/util-middleware": "^4.2.12",
+ "@smithy/core": "^3.23.17",
+ "@smithy/middleware-serde": "^4.2.20",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
+ "@smithy/url-parser": "^4.2.14",
+ "@smithy/util-middleware": "^4.2.14",
"tslib": "^2.6.2"
},
"engines": {
@@ -4562,18 +4681,19 @@
}
},
"node_modules/@smithy/middleware-retry": {
- "version": "4.4.46",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz",
- "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==",
+ "version": "4.5.5",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.5.tgz",
+ "integrity": "sha512-wnYOpB5vATFKWrY2Z9Alb0KhjZI6AbzU6Fbz3Hq2GnURdRYWB4q+qWivQtSTwXcmWUA3MZ6krfwL6Cq5MAbxsA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/service-error-classification": "^4.2.12",
- "@smithy/smithy-client": "^4.12.8",
- "@smithy/types": "^4.13.1",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-retry": "^4.2.13",
+ "@smithy/core": "^3.23.17",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/service-error-classification": "^4.3.0",
+ "@smithy/smithy-client": "^4.12.13",
+ "@smithy/types": "^4.14.1",
+ "@smithy/util-middleware": "^4.2.14",
+ "@smithy/util-retry": "^4.3.4",
"@smithy/uuid": "^1.1.2",
"tslib": "^2.6.2"
},
@@ -4582,14 +4702,14 @@
}
},
"node_modules/@smithy/middleware-serde": {
- "version": "4.2.16",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz",
- "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==",
+ "version": "4.2.20",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz",
+ "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.23.13",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/core": "^3.23.17",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4597,12 +4717,12 @@
}
},
"node_modules/@smithy/middleware-stack": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz",
- "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz",
+ "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4610,14 +4730,14 @@
}
},
"node_modules/@smithy/node-config-provider": {
- "version": "4.3.12",
- "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz",
- "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==",
+ "version": "4.3.14",
+ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz",
+ "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/shared-ini-file-loader": "^4.4.9",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4625,14 +4745,14 @@
}
},
"node_modules/@smithy/node-http-handler": {
- "version": "4.5.1",
- "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz",
- "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==",
+ "version": "4.6.1",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz",
+ "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/querystring-builder": "^4.2.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/querystring-builder": "^4.2.14",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4640,12 +4760,12 @@
}
},
"node_modules/@smithy/property-provider": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz",
- "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz",
+ "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4653,12 +4773,12 @@
}
},
"node_modules/@smithy/protocol-http": {
- "version": "5.3.12",
- "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz",
- "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==",
+ "version": "5.3.14",
+ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz",
+ "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4666,12 +4786,12 @@
}
},
"node_modules/@smithy/querystring-builder": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz",
- "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz",
+ "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"@smithy/util-uri-escape": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -4680,12 +4800,12 @@
}
},
"node_modules/@smithy/querystring-parser": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz",
- "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz",
+ "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4693,24 +4813,24 @@
}
},
"node_modules/@smithy/service-error-classification": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz",
- "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.0.tgz",
+ "integrity": "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1"
+ "@smithy/types": "^4.14.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/shared-ini-file-loader": {
- "version": "4.4.7",
- "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz",
- "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==",
+ "version": "4.4.9",
+ "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz",
+ "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4718,16 +4838,16 @@
}
},
"node_modules/@smithy/signature-v4": {
- "version": "5.3.12",
- "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz",
- "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==",
+ "version": "5.3.14",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz",
+ "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.2",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/types": "^4.14.1",
"@smithy/util-hex-encoding": "^4.2.2",
- "@smithy/util-middleware": "^4.2.12",
+ "@smithy/util-middleware": "^4.2.14",
"@smithy/util-uri-escape": "^4.2.2",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
@@ -4737,17 +4857,17 @@
}
},
"node_modules/@smithy/smithy-client": {
- "version": "4.12.8",
- "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz",
- "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==",
+ "version": "4.12.13",
+ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz",
+ "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.23.13",
- "@smithy/middleware-endpoint": "^4.4.28",
- "@smithy/middleware-stack": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-stream": "^4.5.21",
+ "@smithy/core": "^3.23.17",
+ "@smithy/middleware-endpoint": "^4.4.32",
+ "@smithy/middleware-stack": "^4.2.14",
+ "@smithy/protocol-http": "^5.3.14",
+ "@smithy/types": "^4.14.1",
+ "@smithy/util-stream": "^4.5.25",
"tslib": "^2.6.2"
},
"engines": {
@@ -4755,9 +4875,9 @@
}
},
"node_modules/@smithy/types": {
- "version": "4.13.1",
- "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
- "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
+ "version": "4.14.1",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
+ "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -4767,13 +4887,13 @@
}
},
"node_modules/@smithy/url-parser": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz",
- "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz",
+ "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/querystring-parser": "^4.2.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/querystring-parser": "^4.2.14",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4844,14 +4964,14 @@
}
},
"node_modules/@smithy/util-defaults-mode-browser": {
- "version": "4.3.44",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz",
- "integrity": "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA==",
+ "version": "4.3.49",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz",
+ "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/property-provider": "^4.2.12",
- "@smithy/smithy-client": "^4.12.8",
- "@smithy/types": "^4.13.1",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/smithy-client": "^4.12.13",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4859,17 +4979,17 @@
}
},
"node_modules/@smithy/util-defaults-mode-node": {
- "version": "4.2.48",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz",
- "integrity": "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg==",
+ "version": "4.2.54",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz",
+ "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/config-resolver": "^4.4.13",
- "@smithy/credential-provider-imds": "^4.2.12",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/smithy-client": "^4.12.8",
- "@smithy/types": "^4.13.1",
+ "@smithy/config-resolver": "^4.4.17",
+ "@smithy/credential-provider-imds": "^4.2.14",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/property-provider": "^4.2.14",
+ "@smithy/smithy-client": "^4.12.13",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4877,13 +4997,13 @@
}
},
"node_modules/@smithy/util-endpoints": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz",
- "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz",
+ "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/node-config-provider": "^4.3.14",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4903,12 +5023,12 @@
}
},
"node_modules/@smithy/util-middleware": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz",
- "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz",
+ "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.1",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4916,13 +5036,13 @@
}
},
"node_modules/@smithy/util-retry": {
- "version": "4.2.13",
- "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz",
- "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==",
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.4.tgz",
+ "integrity": "sha512-FY1UQQ1VFmMwiYp1GVS4MeaGD5O0blLNYK0xCRHU+mJgeoH/hSY8Ld8sJWKQ6uznkh14HveRGQJncgPyNl9J+A==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/service-error-classification": "^4.2.12",
- "@smithy/types": "^4.13.1",
+ "@smithy/service-error-classification": "^4.3.0",
+ "@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -4930,14 +5050,14 @@
}
},
"node_modules/@smithy/util-stream": {
- "version": "4.5.21",
- "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz",
- "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==",
+ "version": "4.5.25",
+ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz",
+ "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/fetch-http-handler": "^5.3.15",
- "@smithy/node-http-handler": "^4.5.1",
- "@smithy/types": "^4.13.1",
+ "@smithy/fetch-http-handler": "^5.3.17",
+ "@smithy/node-http-handler": "^4.6.1",
+ "@smithy/types": "^4.14.1",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-buffer-from": "^4.2.2",
"@smithy/util-hex-encoding": "^4.2.2",
@@ -8501,9 +8621,9 @@
"license": "Unlicense"
},
"node_modules/fast-xml-builder": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
- "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
+ "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
"funding": [
{
"type": "github",
@@ -8516,9 +8636,9 @@
}
},
"node_modules/fast-xml-parser": {
- "version": "5.5.8",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
- "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
+ "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
"funding": [
{
"type": "github",
@@ -8527,9 +8647,10 @@
],
"license": "MIT",
"dependencies": {
- "fast-xml-builder": "^1.1.4",
- "path-expression-matcher": "^1.2.0",
- "strnum": "^2.2.0"
+ "@nodable/entities": "^2.1.0",
+ "fast-xml-builder": "^1.1.5",
+ "path-expression-matcher": "^1.5.0",
+ "strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -11548,9 +11669,9 @@
}
},
"node_modules/path-expression-matcher": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
- "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
+ "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
@@ -13081,9 +13202,9 @@
}
},
"node_modules/strnum": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
- "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
+ "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
"funding": [
{
"type": "github",
diff --git a/package.json b/package.json
index 3372e0c2..09f63925 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/supabase/migrations/20260423140000_ai_requests.sql b/supabase/migrations/20260423140000_ai_requests.sql
new file mode 100644
index 00000000..33771eb2
--- /dev/null
+++ b/supabase/migrations/20260423140000_ai_requests.sql
@@ -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';
diff --git a/supabase/migrations/20260423140100_ai_proposals.sql b/supabase/migrations/20260423140100_ai_proposals.sql
new file mode 100644
index 00000000..0fcc2fe7
--- /dev/null
+++ b/supabase/migrations/20260423140100_ai_proposals.sql
@@ -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';
diff --git a/supabase/migrations/20260423140200_journal_entries_ai_provenance.sql b/supabase/migrations/20260423140200_journal_entries_ai_provenance.sql
new file mode 100644
index 00000000..02cca1d8
--- /dev/null
+++ b/supabase/migrations/20260423140200_journal_entries_ai_provenance.sql
@@ -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';
diff --git a/supabase/migrations/20260423140300_categorization_templates_ai_corrected.sql b/supabase/migrations/20260423140300_categorization_templates_ai_corrected.sql
new file mode 100644
index 00000000..148000c8
--- /dev/null
+++ b/supabase/migrations/20260423140300_categorization_templates_ai_corrected.sql
@@ -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 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';
diff --git a/supabase/migrations/20260423140400_company_settings_ai_flow_toggle.sql b/supabase/migrations/20260423140400_company_settings_ai_flow_toggle.sql
new file mode 100644
index 00000000..e78be228
--- /dev/null
+++ b/supabase/migrations/20260423140400_company_settings_ai_flow_toggle.sql
@@ -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';
diff --git a/supabase/migrations/20260423140500_processing_history_ai_streams.sql b/supabase/migrations/20260423140500_processing_history_ai_streams.sql
new file mode 100644
index 00000000..66ce4eda
--- /dev/null
+++ b/supabase/migrations/20260423140500_processing_history_ai_streams.sql
@@ -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';
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 8eb86ad1..37785b3c 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -244,6 +244,8 @@ export function makeJournalEntry(overrides: Partial = {}): 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,
diff --git a/types/index.ts b/types/index.ts
index 323eb208..d71edec2 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -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 | 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 | null
+ options: Record | null
+ status: AIRequestStatus
+ response_json: Record | 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
+}
+