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