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:
co-authored by
Claude Opus 4.7
parent
ab63da8324
commit
1af977950b
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user