feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)

Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr,
invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/
Anthropic/OpenAI deps) to simplify core and reduce bundle size.

Restructure monolithic settings page into dedicated sub-pages (company,
bookkeeping, invoicing, tax, banking, api, account, team, templates) with
shared layout and sidebar navigation.

Add atomic commit_journal_entry RPC so voucher number increment and status
update happen in a single transaction — prevents burned numbers on constraint
failures. Add continuity check report and voucher gap explanation tracking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
@@ -1,36 +0,0 @@
import { NextResponse } from 'next/server'
import { extensionRegistry } from '@/lib/extensions/registry'
import { ensureInitialized } from '@/lib/init'
import { verifyCronSecret } from '@/lib/auth/cron'
ensureInitialized()
export async function POST(request: Request) {
const authError = verifyCronSecret(request)
if (authError) return authError
const aiExt = extensionRegistry.get('ai-categorization')
if (!aiExt?.services?.seedAllTemplateEmbeddings || !aiExt?.services?.getSchemaVersion) {
return NextResponse.json(
{ error: 'ai-categorization extension not loaded' },
{ status: 503 }
)
}
try {
const { seeded, errors } = await aiExt.services.seedAllTemplateEmbeddings()
const schemaVersion = await aiExt.services.getSchemaVersion()
return NextResponse.json({
success: errors.length === 0,
seeded,
errors,
schema_version: schemaVersion,
})
} catch (error) {
return NextResponse.json(
{ error: `Seeding failed: ${error instanceof Error ? error.message : 'Unknown error'}` },
{ status: 500 }
)
}
}
-141
View File
@@ -1,141 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/extensions/ai-consent', () => ({
AI_EXTENSIONS: ['receipt-ocr', 'ai-categorization', 'ai-chat'],
hasAiConsent: vi.fn(),
grantAiConsent: vi.fn(),
revokeAiConsent: vi.fn(),
isAiExtension: vi.fn((id: string) =>
['receipt-ocr', 'ai-categorization', 'ai-chat'].includes(id)
),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
import { GET, POST, DELETE } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockHasAiConsent = vi.mocked(hasAiConsent)
const mockGrantAiConsent = vi.mocked(grantAiConsent)
const mockRevokeAiConsent = vi.mocked(revokeAiConsent)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const { status } = await parseJsonResponse(await GET())
expect(status).toBe(401)
})
it('returns consent status for all AI extensions', async () => {
mockAuth('user-1')
mockHasAiConsent
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
const { status, body } = await parseJsonResponse<{ data: Record<string, boolean> }>(
await GET()
)
expect(status).toBe(200)
expect(body.data).toEqual({
'receipt-ocr': true,
'ai-categorization': false,
'ai-chat': true,
})
})
})
describe('POST /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'receipt-ocr' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(401)
})
it('grants consent for valid AI extension', async () => {
mockAuth('user-1')
mockGrantAiConsent.mockResolvedValue(undefined)
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'receipt-ocr' },
})
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
await POST(req)
)
expect(status).toBe(200)
expect(body.data.consented).toBe(true)
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'receipt-ocr')
})
it('returns 400 for non-AI extension', async () => {
mockAuth('user-1')
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'enable-banking' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(400)
})
})
describe('DELETE /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/ai-consent', {
method: 'DELETE',
body: { extension_id: 'ai-chat' },
})
const { status } = await parseJsonResponse(await DELETE(req))
expect(status).toBe(401)
})
it('revokes consent for valid AI extension', async () => {
mockAuth('user-1')
mockRevokeAiConsent.mockResolvedValue(undefined)
const req = createMockRequest('/api/ai-consent', {
method: 'DELETE',
body: { extension_id: 'ai-chat' },
})
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
await DELETE(req)
)
expect(status).toBe(200)
expect(body.data.consented).toBe(false)
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'ai-chat')
})
})
-76
View File
@@ -1,76 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
AI_EXTENSIONS,
hasAiConsent,
grantAiConsent,
revokeAiConsent,
isAiExtension,
} from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() {
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 statuses: Record<string, boolean> = {}
for (const ext of AI_EXTENSIONS) {
statuses[ext] = await hasAiConsent(supabase, companyId, ext)
}
return NextResponse.json({ data: statuses })
}
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 companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { extension_id } = body
if (!extension_id || !isAiExtension(extension_id)) {
return NextResponse.json(
{ error: 'Invalid or non-AI extension_id' },
{ status: 400 }
)
}
await grantAiConsent(supabase, user.id, companyId, extension_id)
return NextResponse.json({ data: { consented: true } })
}
export async function DELETE(request: Request) {
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 body = await request.json()
const { extension_id } = body
if (!extension_id || !isAiExtension(extension_id)) {
return NextResponse.json(
{ error: 'Invalid or non-AI extension_id' },
{ status: 400 }
)
}
await revokeAiConsent(supabase, user.id, companyId, extension_id)
return NextResponse.json({ data: { consented: false } })
}
+36 -1
View File
@@ -48,7 +48,42 @@ export async function POST(request: Request) {
return NextResponse.json({ error: durationError }, { status: 400 })
}
// Check for overlapping periods
// Enforce continuity: new period must chain from the latest existing period (BFL 3:1)
const { data: latest } = await supabase
.from('fiscal_periods')
.select('period_end, is_closed')
.eq('company_id', companyId)
.order('period_end', { ascending: false })
.limit(1)
.maybeSingle()
if (latest) {
const prev = new Date(latest.period_end + 'T00:00:00')
prev.setDate(prev.getDate() + 1)
const expectedStart = prev.toISOString().split('T')[0]
if (body.period_start !== expectedStart) {
return NextResponse.json(
{ error: `Period must start on ${expectedStart} (day after latest period ends)` },
{ status: 400 }
)
}
}
// Enforce: max one unclosed period (no skipping ahead)
const { count: openCount } = await supabase
.from('fiscal_periods')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_closed', false)
if (openCount && openCount > 0) {
return NextResponse.json(
{ error: 'Cannot create a new period while an unclosed period exists' },
{ status: 409 }
)
}
// Defense-in-depth: check for overlapping periods
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id, name')
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock supabase
const mockFrom = vi.fn()
const mockRpc = vi.fn()
const mockAuth = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue({
from: (...args: unknown[]) => mockFrom(...args),
rpc: (...args: unknown[]) => mockRpc(...args),
auth: { getUser: () => mockAuth() },
}),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET, POST } from '../route'
function mockChain(result: { data?: unknown; error?: unknown; count?: number | null }) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'neq', 'in', 'insert', 'update', 'upsert', 'order', 'limit', 'single', 'maybeSingle']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.single = vi.fn().mockResolvedValue(result)
chain.maybeSingle = vi.fn().mockResolvedValue(result)
// Make the chain thenable so await resolves to result
chain.then = (resolve: (v: unknown) => void) => resolve(result)
return chain
}
function makeRequest(url: string, options?: RequestInit) {
return new Request(url, options)
}
async function parseJson(response: Response) {
return response.json()
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/bookkeeping/voucher-gaps', () => {
it('returns 401 when not authenticated', async () => {
mockAuth.mockResolvedValue({ data: { user: null } })
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=fp-1')
const response = await GET(request)
const body = await parseJson(response)
expect(response.status).toBe(401)
expect(body.error).toBe('Unauthorized')
})
it('returns 400 when fiscal_period_id is missing', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps')
const response = await GET(request)
expect(response.status).toBe(400)
})
it('returns empty result when no voucher sequences exist', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockReturnValue(mockChain({ data: [], error: null }))
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
const response = await GET(request)
const body = await parseJson(response)
expect(response.status).toBe(200)
expect(body.data.gaps).toEqual([])
expect(body.data.totalGaps).toBe(0)
expect(body.data.unexplainedGaps).toBe(0)
})
it('returns gaps with explanations', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
let fromCallCount = 0
mockFrom.mockImplementation(() => {
fromCallCount++
if (fromCallCount === 1) {
// voucher_sequences query
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
}
if (fromCallCount === 2) {
// gap_explanations query
return mockChain({
data: [{ id: 'exp-1', voucher_series: 'A', gap_start: 3, gap_end: 3, explanation: 'Transient error', user_id: 'user-1', created_at: '2026-01-01' }],
error: null,
})
}
return mockChain({ data: null, error: null })
})
mockRpc.mockResolvedValue({
data: [{ gap_start: 3, gap_end: 3 }],
error: null,
})
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
const response = await GET(request)
const body = await parseJson(response)
expect(response.status).toBe(200)
expect(body.data.totalGaps).toBe(1)
expect(body.data.unexplainedGaps).toBe(0)
expect(body.data.gaps[0].explanation).toBeTruthy()
expect(body.data.gaps[0].explanation.explanation).toBe('Transient error')
})
it('returns unexplained gaps', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
let fromCallCount = 0
mockFrom.mockImplementation(() => {
fromCallCount++
if (fromCallCount === 1) {
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
}
if (fromCallCount === 2) {
return mockChain({ data: [], error: null })
}
return mockChain({ data: null, error: null })
})
mockRpc.mockResolvedValue({
data: [{ gap_start: 5, gap_end: 7 }],
error: null,
})
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
const response = await GET(request)
const body = await parseJson(response)
expect(response.status).toBe(200)
expect(body.data.totalGaps).toBe(1)
expect(body.data.unexplainedGaps).toBe(1)
expect(body.data.gaps[0].explanation).toBeNull()
})
})
describe('POST /api/bookkeeping/voucher-gaps', () => {
it('returns 401 when not authenticated', async () => {
mockAuth.mockResolvedValue({ data: { user: null } })
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
method: 'POST',
body: JSON.stringify({
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
gap_start: 5,
gap_end: 7,
explanation: 'Transient DB error during commit',
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(401)
})
it('returns 400 when explanation is empty', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
method: 'POST',
body: JSON.stringify({
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
gap_start: 5,
gap_end: 7,
explanation: '',
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('saves gap explanation successfully', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const savedRow = {
id: 'exp-1',
company_id: 'company-1',
user_id: 'user-1',
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
voucher_series: 'A',
gap_start: 5,
gap_end: 7,
explanation: 'Failed commit during bank sync',
created_at: '2026-04-01',
updated_at: '2026-04-01',
}
mockFrom.mockReturnValue(mockChain({ data: savedRow, error: null }))
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
method: 'POST',
body: JSON.stringify({
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
gap_start: 5,
gap_end: 7,
explanation: 'Failed commit during bank sync',
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
const body = await parseJson(response)
expect(response.status).toBe(200)
expect(body.data.explanation).toBe('Failed commit during bank sync')
})
it('returns 403 when user lacks permission', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockReturnValue(mockChain({ data: null, error: { code: '42501', message: 'permission denied' } }))
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
method: 'POST',
body: JSON.stringify({
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
gap_start: 5,
gap_end: 7,
explanation: 'Test explanation',
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
const body = await parseJson(response)
expect(response.status).toBe(403)
expect(body.error).toContain('owners and admins')
})
})
+149
View File
@@ -0,0 +1,149 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
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 validation = validateQuery(request, VoucherGapQuerySchema)
if (!validation.success) return validation.response
const { fiscal_period_id, voucher_series } = validation.data
// Get all series used in this period (or filter to specific series)
let seriesQuery = supabase
.from('voucher_sequences')
.select('voucher_series')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscal_period_id)
if (voucher_series) {
seriesQuery = seriesQuery.eq('voucher_series', voucher_series)
}
const { data: seriesRows } = await seriesQuery
if (!seriesRows || seriesRows.length === 0) {
return NextResponse.json({
data: { gaps: [], totalGaps: 0, unexplainedGaps: 0 },
})
}
// Detect gaps per series
const allGaps: Array<{
series: string
gap_start: number
gap_end: number
explanation: { id: string; explanation: string; user_id: string; created_at: string } | null
}> = []
for (const row of seriesRows) {
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
p_company_id: companyId,
p_fiscal_period_id: fiscal_period_id,
p_series: row.voucher_series,
})
if (!gapsError && gaps && gaps.length > 0) {
for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) {
allGaps.push({
series: row.voucher_series,
gap_start: gap.gap_start,
gap_end: gap.gap_end,
explanation: null,
})
}
}
}
// Fetch existing explanations and match them
if (allGaps.length > 0) {
const { data: explanations } = await supabase
.from('voucher_gap_explanations')
.select('id, voucher_series, gap_start, gap_end, explanation, user_id, created_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscal_period_id)
if (explanations) {
const explanationMap = new Map(
explanations.map((e) => [`${e.voucher_series}:${e.gap_start}:${e.gap_end}`, e])
)
for (const gap of allGaps) {
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
const match = explanationMap.get(key)
if (match) {
gap.explanation = {
id: match.id,
explanation: match.explanation,
user_id: match.user_id,
created_at: match.created_at,
}
}
}
}
}
const unexplained = allGaps.filter((g) => !g.explanation).length
return NextResponse.json({
data: {
gaps: allGaps,
totalGaps: allGaps.length,
unexplainedGaps: unexplained,
},
})
}
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 companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, SaveGapExplanationSchema)
if (!validation.success) return validation.response
const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data
// Upsert explanation (RLS enforces owner/admin role)
const { data, error } = await supabase
.from('voucher_gap_explanations')
.upsert(
{
company_id: companyId,
user_id: user.id,
fiscal_period_id,
voucher_series,
gap_start,
gap_end,
explanation,
},
{ onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' }
)
.select()
.single()
if (error) {
if (error.code === '42501') {
return NextResponse.json(
{ error: 'Only company owners and admins can document gap explanations' },
{ status: 403 }
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
@@ -291,7 +291,7 @@ async function sendConsentExpiryNotification(
const emailData = {
bankName: connection.bank_name as string,
daysUntilExpiry: daysLeft,
renewalUrl: `${baseUrl}/settings?tab=banking`,
renewalUrl: `${baseUrl}/settings/banking`,
companyName: companySettings?.company_name || 'gnubok',
isExpired,
}
+1 -13
View File
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
import type { ApiRouteDefinition } from '@/lib/extensions/types'
@@ -43,7 +42,7 @@ function matchPath(
* Catch-all route for extension-declared API routes.
*
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
* Example: /api/extensions/ext/receipt-ocr/abc123/confirm → POST /:id/confirm
* Example: /api/extensions/ext/mcp-server/mcp → POST /mcp
*
* - Looks up the extension in the registry
* - Checks the extension toggle (disabled → 403)
@@ -120,17 +119,6 @@ async function handleRequest(
const companyId = await requireCompanyId(supabase, user.id)
// AI consent check
if (isAiExtension(extensionId)) {
const consented = await hasAiConsent(supabase, companyId, extensionId)
if (!consented) {
return NextResponse.json(
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
{ status: 403 }
)
}
}
// If path params were extracted, create a new Request with them as search params
let handlerRequest = request
if (Object.keys(extractedParams).length > 0) {
@@ -1,158 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
// Mock server-only
vi.mock('server-only', () => ({}))
// Hoisted mocks to avoid reference-before-initialization
const { mockVerify, mockServiceClientFn } = vi.hoisted(() => {
return {
mockVerify: vi.fn(),
mockServiceClientFn: vi.fn(),
}
})
// Mock svix
vi.mock('svix', () => ({
Webhook: class MockWebhook {
verify = mockVerify
},
}))
// Mock Supabase SSR
vi.mock('@supabase/ssr', () => ({
createServerClient: (...args: unknown[]) => mockServiceClientFn(...args),
}))
// Mock email handler
vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({
parseInboundPayload: vi.fn(),
extractAttachments: vi.fn(),
resolveUserFromEmail: vi.fn(),
}))
// Mock unified document analyzer
vi.mock('@/lib/ai/document-analyzer', () => ({
analyzeDocument: vi.fn(),
}))
// Mock supplier matcher
vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({
matchSupplier: vi.fn(),
}))
// Mock receipt pipeline
vi.mock('@/extensions/general/receipt-ocr/lib/receipt-pipeline', () => ({
processReceiptFromDocument: vi.fn(),
}))
import { POST } from '../route'
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
const mockParseInboundPayload = vi.mocked(parseInboundPayload)
const mockExtractAttachments = vi.mocked(extractAttachments)
const mockResolveUserFromEmail = vi.mocked(resolveUserFromEmail)
describe('Invoice Inbox Webhook Route', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_WEBHOOK_SECRET = 'test-secret'
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key'
})
function makeWebhookRequest(body: unknown = {}) {
const bodyStr = JSON.stringify(body)
return new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'svix-id': 'msg_test123',
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
'svix-signature': 'v1,test-signature',
},
body: bodyStr,
})
}
it('returns 400 when webhook headers are missing', async () => {
const request = new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 401 when signature verification fails', async () => {
mockVerify.mockImplementation(() => {
throw new Error('Invalid signature')
})
const response = await POST(makeWebhookRequest({ from: 'test@test.com', to: 'inbox@co.com' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 400 when payload is invalid', async () => {
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue(null)
const response = await POST(makeWebhookRequest({}))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 404 when user not found for email', async () => {
const { supabase } = createQueuedMockSupabase()
mockServiceClientFn.mockReturnValue(supabase)
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue({
from: 'supplier@test.com',
to: 'unknown@inbox.com',
subject: 'Invoice',
html: null,
text: null,
attachments: [],
created_at: '2024-06-15T10:00:00Z',
})
mockResolveUserFromEmail.mockResolvedValue(null)
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'unknown@inbox.com' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('returns success with 0 processed when no attachments', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
mockServiceClientFn.mockReturnValue(supabase)
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue({
from: 'supplier@test.com',
to: 'inbox@myco.com',
subject: 'No attachments',
html: null,
text: null,
attachments: [],
created_at: '2024-06-15T10:00:00Z',
})
mockExtractAttachments.mockReturnValue([])
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
// Insert inbox item with error status
enqueueMany([
{ data: { id: 'item-1' }, error: null },
])
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'inbox@myco.com' }))
const { status, body } = await parseJsonResponse<{ data: { processed: number } }>(response)
expect(status).toBe(200)
expect(body.data.processed).toBe(0)
})
})
@@ -1,290 +0,0 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import { Webhook } from 'svix'
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
import { analyzeDocument } from '@/lib/ai/document-analyzer'
import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline'
import crypto from 'crypto'
function createServiceClient() {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {
getAll() { return [] },
setAll() { },
},
}
)
}
/**
* Build raw email payload for BFL 7 kap. 2§ archiving.
* Includes full email headers and body — excludes binary attachment content.
*/
function buildRawEmailPayload(body: Record<string, unknown>, payload: { from: string; to: string; subject: string; created_at: string }): Record<string, unknown> {
return {
from: payload.from,
to: payload.to,
subject: payload.subject,
created_at: payload.created_at,
text: body.text ?? null,
html: body.html ?? null,
headers: body.headers ?? null,
message_id: body.message_id ?? null,
in_reply_to: body.in_reply_to ?? null,
references: body.references ?? null,
archived_at: new Date().toISOString(),
}
}
export async function POST(request: Request) {
// Verify webhook signature
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
if (!webhookSecret) {
console.error('[document-inbox] RESEND_WEBHOOK_SECRET not configured')
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
}
const svixId = request.headers.get('svix-id')
const svixTimestamp = request.headers.get('svix-timestamp')
const svixSignature = request.headers.get('svix-signature')
if (!svixId || !svixTimestamp || !svixSignature) {
return NextResponse.json({ error: 'Missing webhook headers' }, { status: 400 })
}
const rawBody = await request.text()
try {
const wh = new Webhook(webhookSecret)
wh.verify(rawBody, {
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': svixSignature,
})
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const body = JSON.parse(rawBody)
const payload = parseInboundPayload(body)
if (!payload) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
}
const supabase = createServiceClient()
// Resolve user from recipient email
const resolved = await resolveUserFromEmail(payload.to, supabase)
if (!resolved) {
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const { userId, companyId } = resolved
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
const rawEmailPayload = buildRawEmailPayload(body, payload)
// Extract file attachments
const attachments = extractAttachments(payload)
if (attachments.length === 0) {
await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'error',
source: 'email',
email_from: payload.from,
email_subject: payload.subject,
email_received_at: payload.created_at,
error_message: 'No supported attachments found',
raw_email_payload: rawEmailPayload,
})
return NextResponse.json({ data: { processed: 0, message: 'No attachments' } })
}
const processed: string[] = []
for (const attachment of attachments) {
try {
const buffer = Buffer.from(attachment.content, 'base64')
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
// Upload to storage
const storagePath = `documents/${userId}/inbox/${Date.now()}-${attachment.filename}`
const { error: uploadError } = await supabase.storage
.from('documents')
.upload(storagePath, buffer, { contentType: attachment.content_type })
if (uploadError) {
console.error('[document-inbox] Upload failed:', uploadError)
continue
}
// Create document attachment
const { data: document, error: docError } = await supabase
.from('document_attachments')
.insert({
company_id: companyId,
user_id: userId,
storage_path: storagePath,
file_name: attachment.filename,
file_size_bytes: buffer.length,
mime_type: attachment.content_type,
sha256_hash: hash,
upload_source: 'email',
})
.select()
.single()
if (docError || !document) continue
// Unified classify + extract in a single Claude call
let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice'
let unifiedResult: Awaited<ReturnType<typeof analyzeDocument>> | null = null
try {
unifiedResult = await analyzeDocument(attachment.content, attachment.content_type)
documentType = unifiedResult.classification.type
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${unifiedResult.classification.confidence})`)
} catch (classifyErr) {
console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr)
}
// Create inbox item with document type and raw email payload
const { data: inboxItem, error: itemError } = await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'processing',
source: 'email',
email_from: payload.from,
email_subject: payload.subject,
email_received_at: payload.created_at,
document_id: document.id,
document_type: documentType,
raw_email_payload: rawEmailPayload,
})
.select()
.single()
if (itemError || !inboxItem) continue
// Route based on document type
try {
switch (documentType) {
case 'supplier_invoice': {
// Use pre-extracted invoice data from unified call
const extraction = unifiedResult?.invoice
if (!extraction) {
throw new Error('No invoice extraction available')
}
const isReverseCharge = unifiedResult?.classification.isReverseCharge ?? false
// Store reverse charge flag in extracted data
const extractedData = {
...(extraction as unknown as Record<string, unknown>),
isReverseCharge,
}
// Supplier matching
let matchedSupplierId: string | null = null
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('company_id', companyId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= 0.7) {
matchedSupplierId = match.supplierId
}
}
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
extracted_data: extractedData,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
})
.eq('id', inboxItem.id)
break
}
case 'receipt': {
// Use pre-extracted receipt data from unified call
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, {
documentId: document.id,
source: 'email',
emailFrom: payload.from,
storageUrl: urlData.publicUrl,
preExtracted: unifiedResult?.receipt ?? undefined,
})
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
linked_receipt_id: result.receipt.id,
confidence: result.receipt.extraction_confidence,
})
.eq('id', inboxItem.id)
break
}
case 'government_letter': {
// Store with status ready for manual review
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
extracted_data: {
sender: payload.from,
subject: payload.subject,
body: typeof body.text === 'string' ? body.text : null,
},
})
.eq('id', inboxItem.id)
break
}
case 'unknown':
default: {
// Store with status ready for manual handling
await supabase
.from('invoice_inbox_items')
.update({ status: 'ready' })
.eq('id', inboxItem.id)
break
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Processing failed'
await supabase
.from('invoice_inbox_items')
.update({ status: 'error', error_message: message })
.eq('id', inboxItem.id)
}
processed.push(inboxItem.id)
} catch (err) {
console.error('[document-inbox] Processing attachment failed:', err)
}
}
return NextResponse.json({ data: { processed: processed.length, ids: processed } })
}
+21 -4
View File
@@ -9,7 +9,7 @@ import {
calculateFileHash,
} from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
import { generateImportPreview, checkDuplicateImport, checkDuplicatePeriodImport } from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { SIEAccountMappingRecord } from '@/lib/import/types'
@@ -55,12 +55,12 @@ export async function POST(request: Request) {
// Decode to string
const content = decodeBuffer(arrayBuffer, encoding)
// Check for duplicate import
const duplicate = await checkDuplicateImport(supabase, user.id, content)
// Check for duplicate import (by file hash)
const duplicate = await checkDuplicateImport(supabase, companyId, content)
if (duplicate) {
return NextResponse.json({
error: 'duplicate',
message: `This file has already been imported on ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`,
message: `Denna fil har redan importerats ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`,
importId: duplicate.id,
}, { status: 409 })
}
@@ -68,6 +68,23 @@ export async function POST(request: Request) {
// Parse the SIE file
const parsed = parseSIEFile(content)
// Check for existing import covering the same fiscal period
if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
const periodDuplicate = await checkDuplicatePeriodImport(
supabase,
companyId,
parsed.stats.fiscalYearStart,
parsed.stats.fiscalYearEnd
)
if (periodDuplicate) {
return NextResponse.json({
error: 'duplicate_period',
message: `En SIE-import för perioden ${parsed.stats.fiscalYearStart} – ${parsed.stats.fiscalYearEnd} finns redan (importerad ${periodDuplicate.imported_at ? new Date(periodDuplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'})`,
importId: periodDuplicate.id,
}, { status: 409 })
}
}
// Validate the parsed data
const validation = validateSIEFile(parsed)
+36
View File
@@ -0,0 +1,36 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET: Validate IB/UB continuity for a fiscal period.
* Query param: period_id (required)
*/
export async function GET(request: Request) {
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 { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
try {
const result = await validateBalanceContinuity(supabase, companyId, periodId)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to validate continuity' },
{ status: 400 }
)
}
}
+96
View File
@@ -0,0 +1,96 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
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 companyId = await requireCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const storagePath = `logos/${companyId}/logo.${ext}`
// Upload (upsert to replace existing)
const { error: uploadError } = await supabase.storage
.from('documents')
.upload(storagePath, buffer, {
contentType: file.type,
upsert: true,
})
if (uploadError) {
return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
}
// Get public URL
const { data: urlData } = supabase.storage
.from('documents')
.getPublicUrl(storagePath)
// Update company settings
const { error: updateError } = await supabase
.from('company_settings')
.update({ logo_url: urlData.publicUrl })
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 })
}
return NextResponse.json({ data: { logo_url: urlData.publicUrl } })
}
export async function DELETE() {
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)
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
// Get current logo path
const { data: settings } = await supabase
.from('company_settings')
.select('logo_url')
.eq('company_id', companyId)
.single()
if (settings?.logo_url) {
// Extract storage path from URL
const url = new URL(settings.logo_url)
const pathMatch = url.pathname.match(/\/object\/public\/documents\/(.+)/)
if (pathMatch) {
await supabase.storage.from('documents').remove([pathMatch[1]])
}
}
// Clear logo_url
await supabase
.from('company_settings')
.update({ logo_url: null })
.eq('company_id', companyId)
return NextResponse.json({ data: { logo_url: null } })
}
@@ -14,21 +14,13 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
buildMappingResultFromCounterpartyTemplate: vi.fn(),
formatCounterpartyName: vi.fn((name: string) => name),
}))
// Mock extension registry
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: {
get: vi.fn().mockReturnValue({
id: 'ai-categorization',
name: 'AI',
version: '1.0.0',
services: {
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
},
}),
},
// Mock booking templates
const mockFindMatchingTemplates = vi.fn().mockReturnValue([])
vi.mock('@/lib/bookkeeping/booking-templates', () => ({
findMatchingTemplates: (...args: unknown[]) => mockFindMatchingTemplates(...args),
}))
// Mock Supabase
@@ -121,7 +113,7 @@ describe('POST /api/transactions/[id]/describe', () => {
amount: -450,
})
mockFindSimilarTemplates.mockResolvedValueOnce([
mockFindMatchingTemplates.mockReturnValueOnce([
{
template: {
id: 'restaurant_dining',
@@ -131,6 +123,12 @@ describe('POST /api/transactions/[id]/describe', () => {
debit_account: '6071',
credit_account: '1930',
description_sv: 'Representation - restaurang',
vat_rate: 0.12,
vat_treatment: 'reduced_12',
deductibility: 'conditional',
deductibility_note_sv: null,
special_rules_sv: null,
risk_level: 'MEDIUM',
},
confidence: 0.82,
},
@@ -162,23 +160,16 @@ describe('POST /api/transactions/[id]/describe', () => {
expect(status).toBe(200)
expect(body.data.templates).toHaveLength(1)
expect(body.data.needs_more_detail).toBe(false)
expect(body.data.ai_suggestion).toBeNull()
expect(body.data.user_description).toBe('business lunch with client')
expect(body.data.batch_candidate_count).toBe(3)
expect(body.data.merchant_name).toBe('Restaurant XYZ')
// Verify findSimilarTemplates was called with the user description
expect(mockFindSimilarTemplates).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tx-1' }),
'enskild_firma',
10,
'business lunch with client'
)
})
it('sets needs_more_detail when confidence is low', async () => {
const tx = makeTransaction({ id: 'tx-2', merchant_name: null })
mockFindSimilarTemplates.mockResolvedValueOnce([
mockFindMatchingTemplates.mockReturnValueOnce([
{
template: {
id: 'misc',
@@ -188,6 +179,12 @@ describe('POST /api/transactions/[id]/describe', () => {
debit_account: '6991',
credit_account: '1930',
description_sv: 'Okategoriserad utgift',
vat_rate: 0,
vat_treatment: null,
deductibility: 'full',
deductibility_note_sv: null,
special_rules_sv: null,
risk_level: 'LOW',
},
confidence: 0.4,
},
+7 -80
View File
@@ -3,73 +3,13 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { DescribeTransactionSchema } from '@/lib/api/schemas'
import { extensionRegistry } from '@/lib/extensions/registry'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { findMatchingTemplates } from '@/lib/bookkeeping/booking-templates'
import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType, VatTreatment } from '@/types'
import type { Extension } from '@/lib/extensions/types'
interface DescriptionAnalysisInput {
description: string
transactionAmount: number
transactionDate: string
transactionDescription: string
merchantName: string | null
currency: string
entityType: EntityType
}
interface DescriptionAnalysisResult {
debitAccount: string
creditAccount: string
vatTreatment: VatTreatment | null
category: string
confidence: number
reasoning: string
warnings: string[]
templateId: string | null
}
import type { Transaction, EntityType } from '@/types'
ensureInitialized()
async function getTemplateMatches(
aiExt: Extension | undefined,
transaction: Transaction,
entityType: EntityType,
description: string
): Promise<TemplateMatch[]> {
if (aiExt?.services?.findSimilarTemplates) {
return aiExt.services.findSimilarTemplates(transaction, entityType, 10, description)
}
return findMatchingTemplates(transaction, entityType)
}
async function getAiAnalysis(
aiExt: Extension | undefined,
transaction: Transaction,
entityType: EntityType,
description: string
): Promise<DescriptionAnalysisResult | null> {
if (!aiExt?.services?.analyzeDescription) return null
try {
const input: DescriptionAnalysisInput = {
description,
transactionAmount: transaction.amount,
transactionDate: transaction.date,
transactionDescription: transaction.description,
merchantName: transaction.merchant_name,
currency: transaction.currency,
entityType,
}
return await aiExt.services.analyzeDescription(input)
} catch (error) {
console.error('[describe] AI analysis failed, continuing with templates only:', error)
return null
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -110,13 +50,10 @@ export async function POST(
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const aiExt = extensionRegistry.get('ai-categorization')
// Run template matching, counterparty lookup, and AI analysis in parallel
const [templates, counterpartyMatch, aiSuggestion] = await Promise.all([
getTemplateMatches(aiExt, transaction as Transaction, entityType, description),
// Run template matching and counterparty lookup in parallel
const [templates, counterpartyMatch] = await Promise.all([
findMatchingTemplates(transaction as Transaction, entityType),
findCounterpartyTemplate(supabase, user.id, transaction as Transaction),
getAiAnalysis(aiExt, transaction as Transaction, entityType, description),
])
// Build counterparty suggestion if matched
@@ -147,8 +84,7 @@ export async function POST(
}
}
// AI or counterparty match rescues weak templates
const needsMoreDetail = (aiSuggestion || counterpartySuggestion)
const needsMoreDetail = counterpartySuggestion
? false
: templates.length === 0 || templates[0].confidence < 0.55
@@ -185,16 +121,7 @@ export async function POST(
risk_level: m.template.risk_level,
})),
counterparty_match: counterpartySuggestion,
ai_suggestion: aiSuggestion ? {
debit_account: aiSuggestion.debitAccount,
credit_account: aiSuggestion.creditAccount,
vat_treatment: aiSuggestion.vatTreatment,
category: aiSuggestion.category,
confidence: aiSuggestion.confidence,
reasoning: aiSuggestion.reasoning,
warnings: aiSuggestion.warnings,
template_id: aiSuggestion.templateId,
} : null,
ai_suggestion: null,
needs_more_detail: needsMoreDetail,
user_description: description,
batch_candidate_count: batchCandidateCount,
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { getSuggestedCategories, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType } from '@/types'
@@ -65,30 +65,6 @@ export async function POST(request: Request) {
}
}
// Fetch pre-computed AI suggestions for these transactions
const aiKeys = ids.map((id: string) => `suggestion:${id}`)
const { data: aiRecords } = await supabase
.from('extension_data')
.select('key, value')
.eq('company_id', companyId)
.eq('extension_id', 'ai-categorization')
.in('key', aiKeys)
type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string }
const aiSuggestionsMap: Record<string, AiSuggestion[]> = {}
if (aiRecords) {
for (const record of aiRecords) {
const txId = record.key.replace('suggestion:', '')
const value = record.value
// Handle both single object (old) and array (new) storage formats
if (Array.isArray(value)) {
aiSuggestionsMap[txId] = value as AiSuggestion[]
} else {
aiSuggestionsMap[txId] = [value as AiSuggestion]
}
}
}
// Fetch entity type for template matching
const { data: settings } = await supabase
.from('company_settings')
@@ -105,19 +81,11 @@ export async function POST(request: Request) {
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
for (const tx of transactions) {
let result = getSuggestedCategories(
suggestions[tx.id] = getSuggestedCategories(
tx as Transaction,
mappingRules || [],
categoryHistory
)
// Merge pre-computed AI suggestions if available
const aiSuggestions = aiSuggestionsMap[tx.id]
if (aiSuggestions && aiSuggestions.length > 0) {
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
}
suggestions[tx.id] = result
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
}