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:
@@ -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 } })
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user