Supp/invoice bfl errors (#390)

* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma

* Remove AI subsystem and related code

- Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`.
- Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`.
- Cleaned up schemas related to AI flows in `lib/api/schemas.ts`.
- Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`.
- Eliminated AI event types from `lib/events/types.ts`.
- Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`.
- Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration.
- Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks.
- Updated helper functions in `tests/helpers.ts` to remove AI-related settings.
- Removed AI-related types and interfaces from `types/index.ts`.
- Added migration script to drop AI-related tables and settings from the database.

* fix(migrations): ensure foreign key constraint is dropped before removing AI tables

* feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning

- Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier.
- Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs.
- Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API.
- Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions.
- Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`.

* feat(invoice-inbox): remove AI-specific columns and tighten status enum

* fix(skattekonto): remove manual entry creation reference from transaction input

* fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema
This commit is contained in:
Mattsson
2026-05-05 09:53:37 +02:00
committed by GitHub
parent f3fd4c0822
commit fa7d4075cf
108 changed files with 1491 additions and 13234 deletions
-38
View File
@@ -1,38 +0,0 @@
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
@@ -1,222 +0,0 @@
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)
}
@@ -1,154 +0,0 @@
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,
},
})
}
@@ -1,218 +0,0 @@
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 { getBranding } from '@/lib/branding/service'
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 = getBranding().appUrl
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
@@ -1,127 +0,0 @@
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
@@ -1,248 +0,0 @@
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
}
@@ -1,177 +0,0 @@
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
@@ -1,117 +0,0 @@
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
@@ -1,79 +0,0 @@
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
@@ -1,142 +0,0 @@
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
@@ -1,53 +0,0 @@
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
@@ -1,106 +0,0 @@
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 } })
}
-38
View File
@@ -1,38 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function POST(request: Request) {
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)
// Optional: pass specific inbox item IDs to match
let inboxItemIds: string[] | undefined
try {
const body = await request.json()
if (Array.isArray(body?.inboxItemIds)) {
inboxItemIds = body.inboxItemIds
}
} catch {
// No body or invalid JSON — sweep all unmatched items
}
try {
const result = await runDocumentMatchingSweep(supabase, companyId, inboxItemIds)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[match-sweep] Failed:', error)
return NextResponse.json({ error: 'Match sweep failed' }, { status: 500 })
}
}
-8
View File
@@ -84,14 +84,6 @@ export async function PUT(request: Request) {
)
}
// Validate: aktiebolag must use accrual accounting (BFNAR 2006:1)
if (effectiveEntityType === 'aktiebolag' && body.accounting_method === 'cash') {
return NextResponse.json(
{ error: 'Aktiebolag måste använda faktureringsmetoden (BFNAR 2006:1)' },
{ status: 400 }
)
}
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
if (effectiveVatRegistered === true) {
@@ -360,21 +360,15 @@ export async function POST(
}
}
// Confirm matched inbox item and link its document to the journal entry
// Link the matched inbox item's document to the journal entry
if (body.inbox_item_id) {
try {
await supabase
.from('invoice_inbox_items')
.update({ status: 'confirmed' })
.eq('id', body.inbox_item_id)
.eq('company_id', companyId)
// Link inbox item's document to the journal entry
if (journalEntryId) {
const { data: inboxItem } = await supabase
.from('invoice_inbox_items')
.select('document_id')
.eq('id', body.inbox_item_id)
.eq('company_id', companyId)
.single()
if (inboxItem?.document_id) {
@@ -113,50 +113,6 @@ export async function POST(request: Request) {
template_suggestions[tx.id] = [cpSuggestion, ...existing]
}
// Inject document template suggestions from matched inbox items
try {
const { data: matchedInboxItems } = await supabase
.from('invoice_inbox_items')
.select('matched_transaction_id, suggested_template_id, suggested_template_confidence')
.eq('company_id', companyId)
.in('matched_transaction_id', ids)
.not('suggested_template_id', 'is', null)
if (matchedInboxItems && matchedInboxItems.length > 0) {
console.log(`[suggest-categories] Found ${matchedInboxItems.length} matched inbox items with template suggestions`)
const { getTemplateById } = await import('@/lib/bookkeeping/booking-templates')
for (const item of matchedInboxItems) {
const txId = item.matched_transaction_id as string
const templateId = item.suggested_template_id as string
const template = getTemplateById(templateId)
if (!template) {
console.log(`[suggest-categories] Template "${templateId}" not found, skipping`)
continue
}
console.log(`[suggest-categories] Injecting document template: tx=${txId} → ${templateId} (${template.name_sv}, debit=${template.debit_account}, confidence=${item.suggested_template_confidence})`)
// Add to template_suggestions at the top with boosted confidence
const existing = template_suggestions[txId] || []
const docTemplate: SuggestedTemplate = {
template_id: templateId,
name_sv: template.name_sv,
name_en: template.name_en,
group: template.group,
debit_account: template.debit_account,
credit_account: template.credit_account,
confidence: Math.min((item.suggested_template_confidence as number) || 0.8, 1),
description_sv: template.description_sv,
risk_level: template.risk_level,
requires_review: template.requires_review,
}
template_suggestions[txId] = [docTemplate, ...existing.filter((t) => t.template_id !== templateId)]
}
}
} catch {
// Non-blocking
}
return NextResponse.json({ suggestions, template_suggestions })
}
@@ -1,93 +0,0 @@
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,
},
})
}