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
@@ -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')
}