Ai/full autonomous flow (#359)

* Refactor bookkeeping error handling and introduce new error classes

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-04-27 10:32:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ab63da8324
commit 1af977950b
70 changed files with 9735 additions and 451 deletions
+171
View File
@@ -0,0 +1,171 @@
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { isAgentInboxEnabled } from '@/lib/ai/feature-flag'
import { requireCompanyId } from '@/lib/company/context'
import { PageHeader } from '@/components/ui/page-header'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Sparkles } from 'lucide-react'
import AgentInbox from '@/components/agent-inbox/AgentInbox'
import type { AIProposal, AIRequest, InvoiceInboxItem, Transaction, DocumentAttachment, MatchProposalPayload } from '@/types'
ensureInitialized()
// Expanded card data the client component needs to render each proposal's
// context (receipt thumbnail + matched transaction summary).
export interface AgentInboxItemView {
proposal: AIProposal | null
request: AIRequest | null
inbox_item: InvoiceInboxItem & { document: DocumentAttachment | null }
transaction: Transaction | null
}
export default async function AgentInboxPage() {
// Hard gate: extension not enabled at build time, OR the feature flag is
// off in this environment (prod by default) → 404.
if (!ENABLED_EXTENSION_IDS.has('ai-agent') || !isAgentInboxEnabled()) {
notFound()
}
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const companyId = await requireCompanyId(supabase, user.id)
// Soft gate: per-company toggle. If not enabled, show an empty-state
// pointing to settings rather than 404 — the extension exists, the user
// just hasn't opted in yet.
const { data: settings } = await supabase
.from('company_settings')
.select('ai_flow_enabled')
.eq('company_id', companyId)
.maybeSingle()
if (!settings?.ai_flow_enabled) {
return (
<div className="container mx-auto p-4 sm:p-8 max-w-5xl">
<PageHeader
title="Agent-inkorg"
description="AI föreslår bokföring — du godkänner varje steg."
/>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-5 rounded-full bg-muted mb-6">
<Sparkles className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">AI-agenten är inte aktiverad</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-6">
Aktivera AI-agenten under bokföringsinställningar. Varje transaktion blir då ett
granskningsförslag istället för automatisk bokföring.
</p>
<Button asChild>
<Link href="/settings/bookkeeping">Gå till inställningar</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Load all pending proposals + open requests for the company, newest first.
const [{ data: proposals }, { data: requests }] = await Promise.all([
supabase
.from('ai_proposals')
.select('*')
.eq('company_id', companyId)
.in('status', ['pending'])
.order('created_at', { ascending: false })
.limit(50),
supabase
.from('ai_requests')
.select('*')
.eq('company_id', companyId)
.eq('status', 'open')
.order('created_at', { ascending: false })
.limit(50),
])
const typedProposals = (proposals ?? []) as AIProposal[]
const typedRequests = (requests ?? []) as AIRequest[]
// Collect subject_ids and fetch inbox items + transactions in one pass.
const subjectIds = new Set<string>([
...typedProposals.map((p) => p.subject_id),
...typedRequests.map((r) => r.subject_id),
])
const items: AgentInboxItemView[] = []
if (subjectIds.size > 0) {
const { data: inboxRows } = await supabase
.from('invoice_inbox_items')
.select('*, document:document_attachments!document_id(*)')
.in('id', [...subjectIds])
.eq('company_id', companyId)
const inboxMap = new Map<string, InvoiceInboxItem & { document: DocumentAttachment | null }>()
for (const row of inboxRows ?? []) {
inboxMap.set(row.id, row as InvoiceInboxItem & { document: DocumentAttachment | null })
}
// Collect transaction IDs from two sources:
// 1. inbox_item.matched_transaction_id — set after a match is accepted
// (used by booking cards to show the paired transaction).
// 2. proposal_json.matched_transaction_id on pending match proposals —
// the transaction the AI is *proposing*; needed so match cards can
// show a human-readable description instead of a raw UUID.
const matchedTxIds = [
...new Set([
...[...inboxMap.values()]
.map((i) => i.matched_transaction_id)
.filter((id): id is string => Boolean(id)),
...typedProposals
.filter((p) => p.step_type === 'match')
.map((p) => (p.proposal_json as MatchProposalPayload).matched_transaction_id)
.filter((id): id is string => Boolean(id)),
]),
]
const txMap = new Map<string, Transaction>()
if (matchedTxIds.length > 0) {
const { data: txRows } = await supabase
.from('transactions')
.select('*')
.in('id', matchedTxIds)
.eq('company_id', companyId)
for (const tx of txRows ?? []) txMap.set(tx.id, tx as Transaction)
}
// Build the view: one card per (subject, step). Proposals first, requests second.
for (const proposal of typedProposals) {
const inbox = inboxMap.get(proposal.subject_id)
if (!inbox) continue
// Match cards render the transaction being *proposed*; booking cards render
// the transaction already accepted on the inbox item.
const txId = proposal.step_type === 'match'
? (proposal.proposal_json as MatchProposalPayload).matched_transaction_id
: inbox.matched_transaction_id
items.push({
proposal,
request: null,
inbox_item: inbox,
transaction: txId ? txMap.get(txId) ?? null : null,
})
}
for (const request of typedRequests) {
const inbox = inboxMap.get(request.subject_id)
if (!inbox) continue
items.push({
proposal: null,
request,
inbox_item: inbox,
transaction: inbox.matched_transaction_id ? txMap.get(inbox.matched_transaction_id) ?? null : null,
})
}
}
return <AgentInbox initialItems={items} />
}
+55 -27
View File
@@ -1,31 +1,59 @@
'use client'
import { notFound, redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { requireCompanyId } from '@/lib/company/context'
import ReceiptsList from '@/components/receipts/ReceiptsList'
import type { InvoiceInboxItem, DocumentAttachment } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Receipt } from 'lucide-react'
ensureInitialized()
export default function ReceiptsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Kvitton</h1>
<p className="text-muted-foreground">
Hantera och granska dina kvitton
</p>
</div>
export type ReceiptRow = InvoiceInboxItem & { document: DocumentAttachment | null }
export type ReceiptRowWithPreview = ReceiptRow & { preview_url: string | null }
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Kvitton
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Kvittoscanning är inte tillgängligt just nu.
</p>
</CardContent>
</Card>
</div>
)
export default async function ReceiptsPage() {
// Hard gate: if the invoice-inbox extension isn't loaded, there's no
// upload pipeline and nothing would work here.
if (!ENABLED_EXTENSION_IDS.has('invoice-inbox')) notFound()
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const companyId = await requireCompanyId(supabase, user.id)
const { data } = await supabase
.from('invoice_inbox_items')
.select('*, document:document_attachments!document_id(*)')
.eq('company_id', companyId)
.eq('document_type', 'receipt')
.order('created_at', { ascending: false })
.limit(200)
const rows = (data ?? []) as ReceiptRow[]
// Batch-sign all document paths so each row renders with a thumbnail.
// Supabase exposes createSignedUrls (plural) for exactly this use case.
const paths = rows
.map((r) => r.document?.storage_path)
.filter((p): p is string => Boolean(p))
const urlByPath = new Map<string, string>()
if (paths.length > 0) {
const { data: signed } = await supabase.storage
.from('documents')
.createSignedUrls(paths, 3600)
for (const entry of signed ?? []) {
if (entry.path && entry.signedUrl) urlByPath.set(entry.path, entry.signedUrl)
}
}
const items = rows.map((row) => ({
...row,
preview_url: row.document?.storage_path
? urlByPath.get(row.document.storage_path) ?? null
: null,
}))
return <ReceiptsList initialItems={items} />
}
-14
View File
@@ -1,14 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function ScanReceiptPage() {
const router = useRouter()
useEffect(() => {
router.replace('/receipts')
}, [router])
return null
}
+37 -1
View File
@@ -7,13 +7,17 @@ import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettin
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
import { useSettings } from '@/components/settings/useSettings'
import { Label } from '@/components/ui/label'
import { ExternalLink } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { ExternalLink, Sparkles } from 'lucide-react'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { isAgentInboxEnabled } from '@/lib/ai/feature-flag'
import type { CompanySettings } from '@/types'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
export default function BookkeepingSettingsPage() {
const { settings, isLoading, updateSettings } = useSettings()
const aiAgentAvailable = ENABLED_EXTENSION_IDS.has('ai-agent') && isAgentInboxEnabled()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
@@ -22,6 +26,7 @@ export default function BookkeepingSettingsPage() {
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
const defaultVoucherSeries = (formData.get('default_voucher_series') as string) || 'A'
const aiFlowEnabled = formData.get('ai_flow_enabled') === 'on'
const updates: Record<string, unknown> = {
bookkeeping_locked_through: lockedThrough,
@@ -29,6 +34,9 @@ export default function BookkeepingSettingsPage() {
accounting_method: accountingMethod,
default_voucher_series: defaultVoucherSeries,
}
if (aiAgentAvailable) {
updates.ai_flow_enabled = aiFlowEnabled
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
@@ -93,6 +101,34 @@ export default function BookkeepingSettingsPage() {
<div className="border-t border-border/8 pt-8">
<PeriodLockingSettings settings={settings} />
</div>
{/* AI agent (beta) — gated on extension availability */}
{aiAgentAvailable && (
<div className="border-t border-border/8 pt-8">
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5" />
AI-agent (beta)
</h2>
<div className="flex items-start gap-3">
<Switch
id="ai_flow_enabled"
name="ai_flow_enabled"
defaultChecked={Boolean(settings.ai_flow_enabled)}
/>
<div className="space-y-1">
<Label htmlFor="ai_flow_enabled">Aktivera agent-inkorgen</Label>
<p className="text-xs text-muted-foreground max-w-prose">
När aktiv: varje ny banktransaktion blir ett AI-förslag du granskar i
<Link href="/agent-inbox" className="underline ml-1">agent-inkorgen</Link>.
Den automatiska bokföringen (≥80% regelmatchning) stängs av — inget bokförs
utan din bekräftelse.
</p>
</div>
</div>
</section>
</div>
)}
</SettingsFormWrapper>
{/* Voucher series — read-only display */}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
ensureInitialized()
/**
* POST /api/ai/backfill/cancel
*
* Set the kill switch flag on company_settings. The running backfill loop
* checks this between items and exits cleanly. Already-generated proposals
* stay — the cancel just stops further generation.
*/
export async function POST() {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
.from('company_settings')
.update({ ai_backfill_cancel_requested: true })
.eq('company_id', companyId)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { cancelled: true } })
}
+222
View File
@@ -0,0 +1,222 @@
import { NextResponse } from 'next/server'
import { createClient as createServiceClient } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import {
generateMatchProposalFor,
generateBookingProposalFor,
} from '@/lib/ai/orchestrator'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type {
InvoiceInboxItem,
Transaction,
CategorizationTemplate,
} from '@/types'
import type { SupabaseClient } from '@supabase/supabase-js'
ensureInitialized()
/**
* POST /api/ai/backfill/receipts
*
* Generate AI proposals for the existing receipt backlog:
* - inbox items with document_type='receipt' and status='ready' that
* have no pending match proposal → generate match
* - items with matched_transaction_id and no booked journal entry but
* no pending booking proposal → generate booking
*
* Fire-and-forget: returns immediately with `{ queued }`. The loop
* iterates in the background, checking `company_settings.ai_backfill_cancel_requested`
* between items so the user can stop it. Idempotent via the partial
* unique index on (subject, step) WHERE pending — re-clicking does no harm.
*
* NOTE: relies on long-lived Node/Vercel worker to complete the loop. For
* v1 dev-only this is acceptable; a proper job queue is a follow-up.
*/
export async function POST() {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Gate on the per-company flag.
const { data: settings } = await supabase
.from('company_settings')
.select('ai_flow_enabled, ai_backfill_cancel_requested')
.eq('company_id', companyId)
.maybeSingle()
if (!settings?.ai_flow_enabled) {
return NextResponse.json(
{ error: 'AI-agenten är inte aktiverad.' },
{ status: 400 }
)
}
// Reset the cancel flag so a previous cancel doesn't kill this run.
await supabase
.from('company_settings')
.update({ ai_backfill_cancel_requested: false })
.eq('company_id', companyId)
// Count eligible items up front for the response.
const { data: eligibleMatch } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('company_id', companyId)
.eq('document_type', 'receipt')
.eq('status', 'ready')
.is('matched_transaction_id', null)
const { data: eligibleBooking } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('company_id', companyId)
.eq('document_type', 'receipt')
.eq('status', 'ready')
.not('matched_transaction_id', 'is', null)
const matchCount = eligibleMatch?.length ?? 0
const bookingCount = eligibleBooking?.length ?? 0
// Kick off the background loop. Intentionally NOT awaited.
runBackfill(companyId, user.id).catch((err) => {
console.error('[ai/backfill/receipts] loop failed:', err)
})
return NextResponse.json({
data: {
queued_match: matchCount,
queued_booking: bookingCount,
},
})
}
/**
* Run the backfill loop using a service-role client so the orchestrator's
* inserts bypass RLS (mirrors how orchestrator writes from event handlers).
*/
async function runBackfill(companyId: string, userId: string): Promise<void> {
const service: SupabaseClient = createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
// Pass 1: generate match proposals.
const { data: matchItems } = await service
.from('invoice_inbox_items')
.select('*')
.eq('company_id', companyId)
.eq('document_type', 'receipt')
.eq('status', 'ready')
.is('matched_transaction_id', null)
for (const item of (matchItems || []) as InvoiceInboxItem[]) {
if (await isCancelled(service, companyId)) return
// Skip if a pending match proposal already exists.
const { data: existing } = await service
.from('ai_proposals')
.select('id')
.eq('subject_type', 'inbox_item')
.eq('subject_id', item.id)
.eq('step_type', 'match')
.eq('status', 'pending')
.maybeSingle()
if (existing) continue
try {
await generateMatchProposalFor(service, {
inboxItem: item,
correlationId: item.correlation_id ?? undefined,
userId,
companyId,
})
} catch (err) {
console.error(`[ai/backfill] match failed for ${item.id}:`, err)
}
}
// Pass 2: generate booking proposals for already-matched items.
const { data: bookingItems } = await service
.from('invoice_inbox_items')
.select('*')
.eq('company_id', companyId)
.eq('document_type', 'receipt')
.eq('status', 'ready')
.not('matched_transaction_id', 'is', null)
const { data: settings } = await service
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType: 'enskild_firma' | 'aktiebolag' =
(settings?.entity_type as 'enskild_firma' | 'aktiebolag') || 'enskild_firma'
const { data: templates } = await service
.from('categorization_templates')
.select('*')
.eq('company_id', companyId)
.eq('is_active', true)
for (const item of (bookingItems || []) as InvoiceInboxItem[]) {
if (await isCancelled(service, companyId)) return
const { data: existing } = await service
.from('ai_proposals')
.select('id')
.eq('subject_type', 'inbox_item')
.eq('subject_id', item.id)
.eq('step_type', 'booking')
.eq('status', 'pending')
.maybeSingle()
if (existing) continue
const { data: tx } = await service
.from('transactions')
.select('*')
.eq('id', item.matched_transaction_id!)
.eq('company_id', companyId)
.maybeSingle()
if (!tx || (tx as Transaction).journal_entry_id) continue
try {
await generateBookingProposalFor(service, {
inboxItem: item,
matchedTransaction: tx as Transaction,
existingTemplates: (templates || []) as CategorizationTemplate[],
entityType,
correlationId: item.correlation_id ?? undefined,
userId,
companyId,
})
} catch (err) {
console.error(`[ai/backfill] booking failed for ${item.id}:`, err)
}
}
}
async function isCancelled(
service: SupabaseClient,
companyId: string
): Promise<boolean> {
const { data } = await service
.from('company_settings')
.select('ai_backfill_cancel_requested')
.eq('company_id', companyId)
.maybeSingle()
return Boolean(data?.ai_backfill_cancel_requested)
}
@@ -0,0 +1,154 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type { InvoiceInboxItem } from '@/types'
ensureInitialized()
const MAX_BYTES = 15 * 1024 * 1024 // 15 MB — matches invoice-inbox workspace
const ALLOWED_MIME = new Set([
'application/pdf',
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
])
/**
* POST /api/ai/inbox-items/[id]/attach-file
*
* Attach a receipt image/PDF to an existing inbox item that was created
* without a file (e.g. a row seeded for testing, or an email receipt where
* the attachment was stripped). Stores the file in the WORM documents bucket
* and links it via invoice_inbox_items.document_id. Does not re-run
* classification — the extracted_data is left as-is.
*
* Only allowed when the inbox item currently has no document_id; we never
* replace an existing attachment (WORM policy).
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const { data: inboxRow } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!inboxRow) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const inbox = inboxRow as InvoiceInboxItem
if (inbox.document_id) {
return NextResponse.json(
{ error: 'Kvittot har redan en bifogad fil.' },
{ status: 409 }
)
}
// Parse multipart form-data.
let formData: FormData
try {
formData = await request.formData()
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 })
}
const file = formData.get('file')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 })
}
if (file.size === 0) {
return NextResponse.json({ error: 'Filen är tom.' }, { status: 400 })
}
if (file.size > MAX_BYTES) {
return NextResponse.json(
{ error: `Filen är för stor (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB).` },
{ status: 413 }
)
}
if (!ALLOWED_MIME.has(file.type)) {
return NextResponse.json(
{ error: 'Filtypen stöds inte. Tillåtna: PDF, JPG, PNG, WebP.' },
{ status: 415 }
)
}
const buffer = await file.arrayBuffer()
let doc
try {
doc = await uploadDocument(
supabase,
user.id,
companyId,
{ name: file.name, buffer, type: file.type },
{ upload_source: 'file_upload' }
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Kunde inte ladda upp filen.'
return NextResponse.json({ error: message }, { status: 500 })
}
const { error: linkError } = await supabase
.from('invoice_inbox_items')
.update({ document_id: doc.id })
.eq('id', inbox.id)
.eq('company_id', companyId)
if (linkError) {
return NextResponse.json({ error: linkError.message }, { status: 500 })
}
try {
if (inbox.correlation_id) {
await appendProcessingHistory({
companyId,
correlationId: inbox.correlation_id,
aggregateType: 'Document',
aggregateId: doc.id,
eventType: 'ReceiptFileAttached',
payload: {
inbox_item_id: inbox.id,
document_id: doc.id,
file_name: file.name,
mime_type: file.type,
size_bytes: file.size,
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
}
} catch (err) {
console.error('[ai/inbox-items/attach-file] processing_history append failed:', err)
}
return NextResponse.json({
data: {
inbox_item_id: inbox.id,
document_id: doc.id,
},
})
}
@@ -0,0 +1,217 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { getEmailService } from '@/lib/email/service'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type { InvoiceInboxItem } from '@/types'
ensureInitialized()
/**
* POST /api/ai/inbox-items/[id]/request-receipt
*
* Ask every member of the company to upload a receipt file for this inbox
* item. Used when the AI couldn't book because no source document is
* attached (BFL compliance gate) or the existing image is too poor to read.
*
* Sends one email per member with a deep link back to agent-inkorg.
* No-op when the email service isn't configured — returns 503 so the UI
* can explain.
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const { data: inboxRow } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!inboxRow) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const inbox = inboxRow as InvoiceInboxItem
const emailService = getEmailService()
if (!emailService.isConfigured()) {
return NextResponse.json(
{ error: 'E-posttjänsten är inte konfigurerad.' },
{ status: 503 }
)
}
// Load every member's email address. profiles.email is populated by the
// handle_new_user trigger and kept in sync with auth.users.
const { data: members, error: memberError } = await supabase
.from('company_members')
.select('user_id, profiles:user_id(email, full_name)')
.eq('company_id', companyId)
if (memberError) {
return NextResponse.json({ error: memberError.message }, { status: 500 })
}
// Shape of the joined profiles column depends on RLS/relationship —
// defensively support both single-object and array.
const recipients: Array<{ email: string; name: string | null }> = []
for (const row of members ?? []) {
type ProfileShape = { email?: string | null; full_name?: string | null }
const profile: ProfileShape | ProfileShape[] | null | undefined =
(row as { profiles?: ProfileShape | ProfileShape[] | null }).profiles
const profileRow = Array.isArray(profile) ? profile[0] : profile
const email = profileRow?.email
if (email) recipients.push({ email, name: profileRow?.full_name ?? null })
}
if (recipients.length === 0) {
return NextResponse.json(
{ error: 'Inga medlemmar med e-postadress hittades.' },
{ status: 404 }
)
}
const { data: companyRow } = await supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.maybeSingle()
const companyName = companyRow?.company_name ?? 'ditt företag'
// Pull a short summary of the receipt so recipients know which one to fix.
const extracted = inbox.extracted_data as {
merchant?: { name?: string | null } | null
totals?: { total?: number | null } | null
receipt?: { date?: string | null; currency?: string | null } | null
} | null
const merchant = extracted?.merchant?.name ?? 'okänd handlare'
const total = extracted?.totals?.total ?? null
const currency = extracted?.receipt?.currency ?? 'SEK'
const date = extracted?.receipt?.date ?? null
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://gnubok.se'
const deepLink = `${appUrl.replace(/\/$/, '')}/agent-inbox`
const subject = `[${companyName}] Kvittobild behövs för bokföring`
const summaryLine = [
merchant,
total != null ? `${total} ${currency}` : null,
date,
]
.filter(Boolean)
.join(' · ')
const html = buildHtml({ companyName, summaryLine, deepLink, senderName: user.email ?? null })
const text = buildText({ companyName, summaryLine, deepLink, senderName: user.email ?? null })
// Fire emails in parallel. Track successes and failures separately so a
// single bad address doesn't block the rest.
const results = await Promise.allSettled(
recipients.map((r) =>
emailService.sendEmail({
to: r.email,
subject,
html,
text,
})
)
)
let sent = 0
let failed = 0
for (const r of results) {
if (r.status === 'fulfilled' && r.value.success) sent += 1
else failed += 1
}
// Audit trail so the user can see "Emil requested receipt from 3 members".
try {
if (inbox.correlation_id) {
await appendProcessingHistory({
companyId,
correlationId: inbox.correlation_id,
aggregateType: 'Document',
aggregateId: inbox.document_id ?? inbox.id,
eventType: 'ReceiptRequested',
payload: {
inbox_item_id: inbox.id,
recipients: recipients.length,
sent,
failed,
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
}
} catch (err) {
console.error('[ai/request-receipt] processing_history append failed:', err)
}
return NextResponse.json({
data: {
sent,
failed,
total: recipients.length,
},
})
}
interface TemplateArgs {
companyName: string
summaryLine: string
deepLink: string
senderName: string | null
}
function buildHtml({ companyName, summaryLine, deepLink, senderName }: TemplateArgs): string {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111;max-width:560px;margin:0 auto;padding:24px;">
<h2 style="margin:0 0 12px;">Source documents required for receipt and transaction mapping</h2>
<p style="margin:0 0 16px;color:#555;">
${senderName ? `${senderName} ` : ''}behöver ett kvittounderlag för ${companyName} innan en transaktion kan bokföras.
</p>
<p style="margin:0 0 20px;padding:12px;background:#f4f4f5;border-radius:6px;font-family:monospace;font-size:14px;">
${summaryLine || 'Kvitto utan extraherade uppgifter'}
</p>
<p style="margin:0 0 16px;">Öppna agent-inkorgen och ladda upp en tydlig bild eller PDF av kvittot:</p>
<p style="margin:0 0 24px;">
<a href="${deepLink}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:10px 16px;border-radius:6px;">Öppna agent-inkorg</a>
</p>
<p style="margin:0;color:#888;font-size:13px;">
Send in receipts. Utan källunderlag kan bokföringen inte slutföras enligt BFL 5 kap 7§.
</p>
</body></html>`
}
function buildText({ companyName, summaryLine, deepLink, senderName }: TemplateArgs): string {
return [
'Source documents required for receipt and transaction mapping.',
'',
`${senderName ? `${senderName} ` : ''}behöver ett kvittounderlag för ${companyName} innan en transaktion kan bokföras.`,
'',
summaryLine || 'Kvitto utan extraherade uppgifter',
'',
'Öppna agent-inkorgen och ladda upp en tydlig bild eller PDF av kvittot:',
deepLink,
'',
'Send in receipts. Utan källunderlag kan bokföringen inte slutföras enligt BFL 5 kap 7§.',
].join('\n')
}
+127
View File
@@ -0,0 +1,127 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { RememberLearningSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { calculateConfidence } from '@/lib/bookkeeping/counterparty-templates'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type { AIProposal } from '@/types'
ensureInitialized()
/**
* POST /api/ai/learning/remember
*
* Called from the UI's learning-prompt dialog after a user edited and
* accepted an AI booking proposal. Upserts a categorization_templates row
* with source='ai_corrected' so next time's proposal for the same
* counterparty starts from the user's preference.
*
* This is the ONLY path that creates an ai_corrected template — the
* "silent learning" rule means every template with this source represents
* an explicit user choice.
*/
export async function POST(request: Request) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, RememberLearningSchema)
if (!validation.success) return validation.response
const {
proposal_id,
counterparty_name,
debit_account,
credit_account,
vat_treatment,
category,
} = validation.data
// Verify the proposal is accepted + belongs to this company.
const { data: proposal } = await supabase
.from('ai_proposals')
.select('*')
.eq('id', proposal_id)
.eq('company_id', companyId)
.maybeSingle()
if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const typed = proposal as AIProposal
if (typed.status !== 'accepted') {
return NextResponse.json(
{ error: 'Endast accepterade förslag kan lagras som mall.' },
{ status: 400 }
)
}
if (typed.step_type !== 'booking') {
return NextResponse.json(
{ error: 'Endast bokföringssteget kan lagras som mall.' },
{ status: 400 }
)
}
// Upsert the template. Existing row for the same (user_id, counterparty_name)
// gets its source bumped up and occurrence incremented.
const { data: existing } = await supabase
.from('categorization_templates')
.select('*')
.eq('user_id', user.id)
.eq('counterparty_name', counterparty_name)
.maybeSingle()
const today = new Date().toISOString().slice(0, 10)
if (existing) {
const newOccurrence = existing.occurrence_count + 1
await supabase
.from('categorization_templates')
.update({
debit_account,
credit_account,
vat_treatment,
category,
source: 'ai_corrected',
occurrence_count: newOccurrence,
confidence: calculateConfidence(newOccurrence),
last_seen_date: today,
is_active: true,
})
.eq('id', existing.id)
return NextResponse.json({ data: { template_id: existing.id, updated: true } })
}
const { data: created, error: insertError } = await supabase
.from('categorization_templates')
.insert({
user_id: user.id,
company_id: companyId,
counterparty_name,
counterparty_aliases: [counterparty_name],
debit_account,
credit_account,
vat_treatment,
category,
source: 'ai_corrected',
occurrence_count: 1,
confidence: calculateConfidence(1),
last_seen_date: today,
is_active: true,
})
.select()
.single()
if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 })
return NextResponse.json({ data: { template_id: created.id, updated: false } })
}
+248
View File
@@ -0,0 +1,248 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { AcceptProposalSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { reValidateProposal } from '@/lib/ai/proposals/re-validate'
import { applyProposal } from '@/lib/ai/proposals/apply'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type {
AIProposal,
BookingProposalPayload,
InvoiceInboxItem,
MatchProposalPayload,
} from '@/types'
ensureInitialized()
/**
* POST /api/ai/proposals/[id]/accept
*
* Accept a pending proposal:
* 1. Optimistic lock on version to catch concurrent clicks.
* 2. Re-validate (period open, transaction still unbooked, accounts active).
* 3. Apply via lib/ai/proposals/apply.ts (engine call happens there).
* 4. Mark status='accepted', set applied_entry_id, bump version.
* 5. If `edits` provided and differ from proposal_json, record edit_diff
* and return a `learning_prompt` hint so the UI can ask
* "remember this booking for <counterparty>?".
* 6. Emit ai_proposal.accepted so the orchestrator can chain match -> booking.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const validation = await validateBody(request, AcceptProposalSchema)
if (!validation.success) return validation.response
const { version, edits } = validation.data
// Fetch the proposal (also enforces company scope).
const { data: proposal, error: fetchError } = await supabase
.from('ai_proposals')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (fetchError) return NextResponse.json({ error: fetchError.message }, { status: 500 })
if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const typed = proposal as AIProposal
if (typed.status !== 'pending') {
return NextResponse.json(
{ error: 'Förslaget har redan hanterats.', status: typed.status },
{ status: 409 }
)
}
if (typed.version !== version) {
return NextResponse.json(
{ error: 'Förslaget har ändrats av en annan användare — ladda om.' },
{ status: 409 }
)
}
// Re-validate current state — proposal might be stale.
const check = await reValidateProposal(supabase, companyId, typed)
if (!check.ok) {
// Mark invalidated so it drops out of the pending inbox.
await supabase
.from('ai_proposals')
.update({
status: 'invalidated',
invalidated_reason: check.code,
})
.eq('id', typed.id)
.eq('version', version)
return NextResponse.json(
{ error: check.message, code: check.code, details: check.details ?? null },
{ status: 409 }
)
}
const inboxItem = check.inboxItem as InvoiceInboxItem
// Merge edits into the original payload shape (edits are partial).
let editedPayload: MatchProposalPayload | BookingProposalPayload | undefined
if (edits) {
if (typed.step_type === 'match') {
const matchEdit = edits as { matched_transaction_id: string }
const original = typed.proposal_json as MatchProposalPayload
editedPayload = {
...original,
matched_transaction_id: matchEdit.matched_transaction_id,
}
} else {
editedPayload = edits as BookingProposalPayload
}
}
// Compute edit diff if edits were supplied and differ.
const editDiff = computeEditDiff(typed, editedPayload)
// Apply (calls the engine for booking steps).
let outcome
try {
outcome = await applyProposal(
supabase,
companyId,
user.id,
typed,
inboxItem,
editedPayload
)
} catch (err) {
const typedResp = bookkeepingErrorResponse(err)
if (typedResp) return typedResp
const message = err instanceof Error ? err.message : 'Kunde inte tillämpa förslaget'
return NextResponse.json({ error: message }, { status: 500 })
}
// Mark proposal accepted with CAS on version.
const appliedEntryId =
outcome.kind === 'booking_applied' ? outcome.journalEntry.id : null
const { data: updatedRows, error: updateError } = await supabase
.from('ai_proposals')
.update({
status: 'accepted',
accepted_at: new Date().toISOString(),
accepted_by_user_id: user.id,
version: typed.version + 1,
applied_entry_id: appliedEntryId,
edit_diff: editDiff,
})
.eq('id', typed.id)
.eq('version', version)
.select()
if (updateError || !updatedRows || updatedRows.length === 0) {
// The domain-level apply already happened; log loud but don't unwind
// (storno would be disproportionate for a race on a status bit).
console.error('[ai/accept] proposal status update failed after apply', updateError)
}
const finalProposal = (updatedRows?.[0] as AIProposal | undefined) ?? typed
// Audit trail.
try {
if (inboxItem.correlation_id) {
await appendProcessingHistory({
companyId,
correlationId: inboxItem.correlation_id,
aggregateType: 'AIProposal',
aggregateId: typed.id,
eventType: 'AIProposalAccepted',
payload: {
proposal_id: typed.id,
step_type: typed.step_type,
edited: Boolean(editDiff),
applied_entry_id: appliedEntryId,
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
}
} catch (err) {
console.error('[ai/accept] Failed to append AIProposalAccepted:', err)
}
// Emit event so orchestrator can chain match -> booking.
try {
await eventBus.emit({
type: 'ai_proposal.accepted',
payload: {
proposal: finalProposal,
appliedEntry: outcome.kind === 'booking_applied' ? outcome.journalEntry : null,
userId: user.id,
companyId,
},
})
} catch (err) {
console.error('[ai/accept] Event emit failed:', err)
}
return NextResponse.json({
data: {
proposal: finalProposal,
applied_entry_id: appliedEntryId,
learning_prompt:
editDiff && typed.step_type === 'booking' && editedPayload
? buildLearningPromptHint(editedPayload as BookingProposalPayload, typed)
: null,
},
})
}
// ── helpers ─────────────────────────────────────────────────────────
function computeEditDiff(
proposal: AIProposal,
edits: MatchProposalPayload | BookingProposalPayload | undefined
): Record<string, unknown> | null {
if (!edits) return null
const before = proposal.proposal_json as unknown
const after = edits as unknown
if (JSON.stringify(before) === JSON.stringify(after)) return null
return { before, after }
}
/**
* When the user edited a booking proposal, offer to save the corrected
* shape as a counterparty template so next time's proposal starts from
* the user's preference.
*/
function buildLearningPromptHint(
edits: BookingProposalPayload,
proposal: AIProposal
): { counterparty_name: string; debit_account: string; credit_account: string; vat_treatment: string | null } | null {
const tpl = edits.counterparty_template_proposal
if (!tpl) return null
return {
counterparty_name: tpl.counterparty_name,
debit_account: tpl.debit_account,
credit_account: tpl.credit_account,
vat_treatment: tpl.vat_treatment,
}
// proposal is in signature for future refinement (e.g. embed original accounts for diff UI)
void proposal
}
@@ -0,0 +1,177 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { ChangeMatchProposalSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type { AIProposal, InvoiceInboxItem, MatchProposalPayload } from '@/types'
ensureInitialized()
/**
* POST /api/ai/proposals/[id]/change-match
*
* Swap the matched_transaction_id on a pending match proposal without
* accepting it. Lets the user verify a different candidate (AI alternative,
* AI-regenerated, or manually picked) before hitting Godkänn.
*
* - Keeps status='pending' so the user still has to explicitly accept.
* - Bumps version (optimistic lock) and records edit_diff with before/after +
* source so we can later measure how often the AI's top pick gets overridden
* and by which merchant/path.
* - Sets confidence to 1.0 (user-picked transactions are certain).
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const validation = await validateBody(request, ChangeMatchProposalSchema)
if (!validation.success) return validation.response
const { version, matched_transaction_id, source } = validation.data
const { data: proposalRow } = await supabase
.from('ai_proposals')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!proposalRow) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const proposal = proposalRow as AIProposal
if (proposal.status !== 'pending') {
return NextResponse.json(
{ error: 'Förslaget har redan hanterats.', status: proposal.status },
{ status: 409 }
)
}
if (proposal.step_type !== 'match') {
return NextResponse.json(
{ error: 'Bara match-förslag kan byta transaktion.' },
{ status: 400 }
)
}
if (proposal.version !== version) {
return NextResponse.json(
{ error: 'Förslaget har ändrats av en annan användare — ladda om.' },
{ status: 409 }
)
}
// Validate the new transaction: same company, uncategorized, no journal entry.
const { data: tx } = await supabase
.from('transactions')
.select('id, company_id, journal_entry_id')
.eq('id', matched_transaction_id)
.eq('company_id', companyId)
.maybeSingle()
if (!tx) {
return NextResponse.json(
{ error: 'Transaktionen hittades inte.' },
{ status: 404 }
)
}
if (tx.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaktionen är redan bokförd.' },
{ status: 409 }
)
}
const originalPayload = proposal.proposal_json as MatchProposalPayload
// No-op? Return current state.
if (originalPayload.matched_transaction_id === matched_transaction_id) {
return NextResponse.json({ data: { proposal } })
}
const newPayload: MatchProposalPayload = {
...originalPayload,
matched_transaction_id,
top_confidence: 1,
}
const editDiff = {
before: originalPayload,
after: newPayload,
source,
changed_at: new Date().toISOString(),
changed_by_user_id: user.id,
}
const { data: updatedRows, error: updateError } = await supabase
.from('ai_proposals')
.update({
proposal_json: newPayload,
confidence: 1,
edit_diff: editDiff,
version: proposal.version + 1,
})
.eq('id', proposal.id)
.eq('version', version)
.select()
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
if (!updatedRows || updatedRows.length === 0) {
return NextResponse.json(
{ error: 'Förslaget har ändrats av en annan användare — ladda om.' },
{ status: 409 }
)
}
const finalProposal = updatedRows[0] as AIProposal
// Audit trail.
try {
const { data: inboxItem } = await supabase
.from('invoice_inbox_items')
.select('correlation_id')
.eq('id', proposal.subject_id)
.maybeSingle()
const item = inboxItem as Pick<InvoiceInboxItem, 'correlation_id'> | null
if (item?.correlation_id) {
await appendProcessingHistory({
companyId,
correlationId: item.correlation_id,
aggregateType: 'AIProposal',
aggregateId: proposal.id,
eventType: 'AIProposalMatchChanged',
payload: {
proposal_id: proposal.id,
from_transaction_id: originalPayload.matched_transaction_id,
to_transaction_id: matched_transaction_id,
source,
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
}
} catch (err) {
console.error('[ai/change-match] Failed to append AIProposalMatchChanged:', err)
}
return NextResponse.json({ data: { proposal: finalProposal } })
}
+117
View File
@@ -0,0 +1,117 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { RejectProposalSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type { AIProposal, InvoiceInboxItem } from '@/types'
ensureInitialized()
/**
* POST /api/ai/proposals/[id]/reject
*
* Mark a pending proposal as rejected. The orchestrator will NOT chain the
* next step — the user has signalled the AI got this one wrong. Subsequent
* action (upload new doc, manually categorize, etc.) is up to the user.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const validation = await validateBody(request, RejectProposalSchema)
if (!validation.success) return validation.response
const { version, reason } = validation.data
const { data: proposal } = await supabase
.from('ai_proposals')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const typed = proposal as AIProposal
if (typed.status !== 'pending') {
return NextResponse.json(
{ error: 'Förslaget har redan hanterats.', status: typed.status },
{ status: 409 }
)
}
const { data: updatedRows, error: updateError } = await supabase
.from('ai_proposals')
.update({
status: 'rejected',
rejected_at: new Date().toISOString(),
invalidated_reason: reason ?? null,
version: typed.version + 1,
})
.eq('id', typed.id)
.eq('version', version)
.select()
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
if (!updatedRows || updatedRows.length === 0) {
return NextResponse.json(
{ error: 'Förslaget har ändrats av en annan användare — ladda om.' },
{ status: 409 }
)
}
const finalProposal = updatedRows[0] as AIProposal
// Audit trail.
try {
const { data: inboxItem } = await supabase
.from('invoice_inbox_items')
.select('correlation_id')
.eq('id', typed.subject_id)
.maybeSingle()
const item = inboxItem as Pick<InvoiceInboxItem, 'correlation_id'> | null
if (item?.correlation_id) {
await appendProcessingHistory({
companyId,
correlationId: item.correlation_id,
aggregateType: 'AIProposal',
aggregateId: typed.id,
eventType: 'AIProposalRejected',
payload: { proposal_id: typed.id, step_type: typed.step_type, reason: reason ?? null },
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
}
} catch (err) {
console.error('[ai/reject] Failed to append AIProposalRejected:', err)
}
try {
await eventBus.emit({
type: 'ai_proposal.rejected',
payload: { proposal: finalProposal, userId: user.id, companyId },
})
} catch { /* non-blocking */ }
return NextResponse.json({ data: { proposal: finalProposal } })
}
+79
View File
@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
ensureInitialized()
/**
* GET /api/ai/proposals/[id]
*
* Returns the full proposal row plus the linked inbox item and, when
* applicable, the matched transaction and already-applied journal entry.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const { data: proposal, error } = await supabase
.from('ai_proposals')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!proposal) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// Load the inbox item (only subject_type in v1) for context.
const { data: inboxItem } = await supabase
.from('invoice_inbox_items')
.select('*, document:document_attachments!document_id(*)')
.eq('id', proposal.subject_id)
.eq('company_id', companyId)
.maybeSingle()
// Load the matched transaction if the inbox item has one.
let transaction = null
if (inboxItem?.matched_transaction_id) {
const { data: tx } = await supabase
.from('transactions')
.select('*')
.eq('id', inboxItem.matched_transaction_id)
.eq('company_id', companyId)
.maybeSingle()
transaction = tx
}
// For accepted booking proposals, fetch the applied journal entry.
let journalEntry = null
if (proposal.applied_entry_id) {
const { data: entry } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', proposal.applied_entry_id)
.eq('company_id', companyId)
.maybeSingle()
journalEntry = entry
}
return NextResponse.json({
data: {
proposal,
inbox_item: inboxItem ?? null,
transaction,
journal_entry: journalEntry,
},
})
}
+142
View File
@@ -0,0 +1,142 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { BatchAcceptSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { reValidateProposal } from '@/lib/ai/proposals/re-validate'
import { applyProposal } from '@/lib/ai/proposals/apply'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type { AIProposal, InvoiceInboxItem } from '@/types'
ensureInitialized()
interface BatchOutcomePerProposal {
proposal_id: string
ok: boolean
error?: string
code?: string
applied_entry_id?: string | null
}
/**
* POST /api/ai/proposals/batch-accept
*
* Accept multiple pending proposals in one click. Best-effort: each item is
* independently re-validated and applied. The response contains per-item
* outcomes so the UI can show checkmarks + specific failure messages
* (e.g., "fiscal period closed since you loaded the page").
*
* No edits are supported in batch mode — edits require the user to open the
* individual proposal and approve from there.
*/
export async function POST(request: Request) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BatchAcceptSchema)
if (!validation.success) return validation.response
const { proposal_ids } = validation.data
const outcomes: BatchOutcomePerProposal[] = []
for (const proposalId of proposal_ids) {
const outcome = await acceptOne(supabase, companyId, user.id, proposalId)
outcomes.push(outcome)
}
return NextResponse.json({
data: {
outcomes,
accepted: outcomes.filter((o) => o.ok).length,
failed: outcomes.filter((o) => !o.ok).length,
},
})
}
async function acceptOne(
supabase: Awaited<ReturnType<typeof createClient>>,
companyId: string,
userId: string,
proposalId: string
): Promise<BatchOutcomePerProposal> {
const { data: proposal } = await supabase
.from('ai_proposals')
.select('*')
.eq('id', proposalId)
.eq('company_id', companyId)
.maybeSingle()
if (!proposal) return { proposal_id: proposalId, ok: false, error: 'Not found', code: 'not_found' }
const typed = proposal as AIProposal
if (typed.status !== 'pending') {
return { proposal_id: proposalId, ok: false, error: `Already ${typed.status}`, code: 'not_pending' }
}
const check = await reValidateProposal(supabase, companyId, typed)
if (!check.ok) {
await supabase
.from('ai_proposals')
.update({ status: 'invalidated', invalidated_reason: check.code })
.eq('id', typed.id)
.eq('version', typed.version)
return { proposal_id: proposalId, ok: false, error: check.message, code: check.code }
}
const inboxItem = check.inboxItem as InvoiceInboxItem
let outcome
try {
outcome = await applyProposal(supabase, companyId, userId, typed, inboxItem)
} catch (err) {
return {
proposal_id: proposalId,
ok: false,
error: err instanceof Error ? err.message : 'apply_failed',
code: 'apply_failed',
}
}
const appliedEntryId =
outcome.kind === 'booking_applied' ? outcome.journalEntry.id : null
const { data: updated } = await supabase
.from('ai_proposals')
.update({
status: 'accepted',
accepted_at: new Date().toISOString(),
accepted_by_user_id: userId,
version: typed.version + 1,
applied_entry_id: appliedEntryId,
})
.eq('id', typed.id)
.eq('version', typed.version)
.select()
.maybeSingle()
try {
await eventBus.emit({
type: 'ai_proposal.accepted',
payload: {
proposal: (updated as AIProposal | null) ?? typed,
appliedEntry: outcome.kind === 'booking_applied' ? outcome.journalEntry : null,
userId,
companyId,
},
})
} catch { /* non-blocking */ }
return { proposal_id: proposalId, ok: true, applied_entry_id: appliedEntryId }
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { validateQuery } from '@/lib/api/validate'
import { ListProposalsQuerySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
ensureInitialized()
/**
* GET /api/ai/proposals
*
* List AI proposals for the active company, newest first.
* Query params:
* status?: pending | accepted | rejected | skipped | invalidated
* step_type?: match | booking
* limit?: default 20, max 100
* offset?: default 0
*
* Returns { data: AIProposal[], count: number }.
*/
export async function GET(request: Request) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const qs = validateQuery(request, ListProposalsQuerySchema)
if (!qs.success) return qs.response
const { status, step_type, limit, offset } = qs.data
let query = supabase
.from('ai_proposals')
.select('*', { count: 'exact' })
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (status) query = query.eq('status', status)
if (step_type) query = query.eq('step_type', step_type)
const { data, error, count } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data, count: count ?? data?.length ?? 0 })
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { ResolveRequestSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
import type { AIRequest, InvoiceInboxItem } from '@/types'
ensureInitialized()
/**
* POST /api/ai/requests/[id]/resolve
*
* Mark an open ai_request as resolved. The response body is stored on the
* row for audit, but the actual follow-up action (re-upload doc, pick a
* transaction manually, set a VAT rate) is wired through the existing
* domain endpoints — the UI calls those separately. This endpoint just
* closes out the request card.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
const validation = await validateBody(request, ResolveRequestSchema)
if (!validation.success) return validation.response
const { response } = validation.data
const { data: req } = await supabase
.from('ai_requests')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!req) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const typed = req as AIRequest
if (typed.status !== 'open') {
return NextResponse.json(
{ error: 'Begäran är redan hanterad.', status: typed.status },
{ status: 409 }
)
}
const { data: updated, error: updateError } = await supabase
.from('ai_requests')
.update({
status: 'resolved',
resolved_at: new Date().toISOString(),
resolved_by_user_id: user.id,
response_json: response ?? null,
})
.eq('id', typed.id)
.eq('status', 'open')
.select()
.maybeSingle()
if (updateError || !updated) {
return NextResponse.json({ error: 'Kunde inte uppdatera' }, { status: 500 })
}
// Audit.
try {
const { data: inbox } = await supabase
.from('invoice_inbox_items')
.select('correlation_id')
.eq('id', typed.subject_id)
.maybeSingle()
const item = inbox as Pick<InvoiceInboxItem, 'correlation_id'> | null
if (item?.correlation_id) {
await appendProcessingHistory({
companyId,
correlationId: item.correlation_id,
aggregateType: 'AIRequest',
aggregateId: typed.id,
eventType: 'AIRequestResolved',
payload: {
request_id: typed.id,
request_type: typed.request_type,
has_response: Boolean(response),
},
actor: { type: 'user', id: user.id },
occurredAt: new Date(),
})
}
} catch (err) {
console.error('[ai/requests/resolve] Failed to append AIRequestResolved:', err)
}
return NextResponse.json({ data: { request: updated } })
}
@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { gateAgentInbox } from '@/lib/ai/feature-flag'
ensureInitialized()
/**
* GET /api/transactions/uncategorized
*
* Paginated list of uncategorized expense transactions for a picker UI
* (e.g. agent-inkorg's "Byt transaktion" flow). Returns expenses only —
* amount < 0 — since match proposals always pair receipts to outgoing
* payments. Includes basic range filters so callers can narrow to matches
* within ±window of a target amount/date.
*
* Query params:
* search Free-text against description/merchant_name (ILIKE)
* amount_center Target amount (signed). Must be accompanied by amount_window.
* amount_window Half-window in SEK — e.g. 50 means amount_center ± 50.
* date_center Target ISO date. Must be accompanied by date_window.
* date_window Half-window in days — e.g. 30 means ±30 days.
* limit Max rows (1-50, default 20).
* offset Row offset for pagination (default 0).
*/
export async function GET(request: Request) {
const gate = gateAgentInbox()
if (gate) return gate
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const url = new URL(request.url)
const search = url.searchParams.get('search')?.trim() ?? ''
const amountCenterRaw = url.searchParams.get('amount_center')
const amountWindowRaw = url.searchParams.get('amount_window')
const dateCenterRaw = url.searchParams.get('date_center')
const dateWindowRaw = url.searchParams.get('date_window')
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 20), 50)
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0)
let query = supabase
.from('transactions')
.select('id, date, description, amount, currency, merchant_name, category, is_business', { count: 'exact' })
.eq('company_id', companyId)
.is('journal_entry_id', null)
.lt('amount', 0)
.order('date', { ascending: false })
.range(offset, offset + limit - 1)
if (search.length > 0) {
const escaped = search.replace(/[%_]/g, '\\$&')
query = query.or(`description.ilike.%${escaped}%,merchant_name.ilike.%${escaped}%`)
}
if (amountCenterRaw && amountWindowRaw) {
const center = Number(amountCenterRaw)
const window = Math.abs(Number(amountWindowRaw))
if (Number.isFinite(center) && Number.isFinite(window) && window > 0) {
query = query.gte('amount', center - window).lte('amount', center + window)
}
}
if (dateCenterRaw && dateWindowRaw) {
const windowDays = Math.abs(Number(dateWindowRaw))
if (Number.isFinite(windowDays) && windowDays > 0) {
const center = new Date(dateCenterRaw)
if (!Number.isNaN(center.getTime())) {
const msPerDay = 86_400_000
const from = new Date(center.getTime() - windowDays * msPerDay)
const to = new Date(center.getTime() + windowDays * msPerDay)
query = query.gte('date', from.toISOString().slice(0, 10))
query = query.lte('date', to.toISOString().slice(0, 10))
}
}
}
const { data, error, count } = await query
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({
data: {
transactions: data ?? [],
count: count ?? 0,
limit,
offset,
},
})
}