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:
co-authored by
Claude Opus 4.6
parent
e89f2c402d
commit
d0b3f21bde
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user