diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 233c93d9..03fc448f 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -165,7 +165,7 @@ function BankFileImportWizard() { setBankStep('confirm') }, [rawFileContent]) - const handleExecuteImport = useCallback(async (options: { skip_duplicates: boolean; auto_categorize: boolean }) => { + const handleExecuteImport = useCallback(async (options: { skip_duplicates: boolean; auto_categorize: boolean; settlement_account?: string }) => { if (!parseResult) return setBankIsLoading(true) diff --git a/app/api/bookkeeping/accounts/route.ts b/app/api/bookkeeping/accounts/route.ts index 23420b56..0f3ca704 100644 --- a/app/api/bookkeeping/accounts/route.ts +++ b/app/api/bookkeeping/accounts/route.ts @@ -76,6 +76,8 @@ export async function POST(request: Request) { plan_type: body.plan_type || 'k1', is_system_account: false, description: body.description || null, + default_vat_code: body.default_vat_code || null, + sru_code: body.sru_code || null, sort_order: parseInt(body.account_number), }) .select() diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts new file mode 100644 index 00000000..2bbd3949 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock dependencies +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { createClient } from '@/lib/supabase/server' +import { POST } from '../route' + +function createMockRequest(body: unknown): Request { + return new Request('http://localhost/api/bookkeeping/fiscal-periods', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +type Period = { id: string; period_start: string; period_end: string; is_closed: boolean; name?: string } + +/** + * Build a mock supabase that tracks sequential from('fiscal_periods') calls + * and returns the correct data based on the select() arguments. + */ +function buildMockSupabase(options: { + user?: { id: string } | null + allPeriods?: Period[] + openCount?: number + overlapping?: Array<{ id: string; name: string }> + insertResult?: { data: unknown; error: unknown } +}) { + const { + user = { id: 'user-1' }, + allPeriods = [], + openCount = 0, + overlapping = [], + insertResult = { data: { id: 'new-period', name: 'FY 2025' }, error: null }, + } = options + + // Track from() calls to fiscal_periods + let fpCallIndex = 0 + + const supabase = { + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user } }), + }, + from: vi.fn().mockImplementation(() => { + fpCallIndex++ + const callNum = fpCallIndex + + // Build a chainable object that resolves differently based on the call chain + const chainable: Record = {} + + // For the allPeriods query (call 1): .select('id, period_start, ...').eq(...).order(...) + // For openCount query (call 2): .select('id', { count: ... }).eq(...).eq(...) + // For overlap query (call 3): .select('id, name').eq(...).lte(...).gte(...).limit(...) + // For insert (call 4): .insert(...).select().single() + // For update (call 5): .update(...).eq(...).eq(...) + + chainable.select = vi.fn().mockImplementation((_sel: string, opts?: { count?: string }) => { + if (opts?.count === 'exact') { + // openCount query + return { + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ count: openCount }), + }), + } + } + + if (callNum === 1) { + // allPeriods query + return { + eq: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ data: allPeriods, error: null }), + }), + } + } + + // overlap query or any other select + return { + eq: vi.fn().mockReturnValue({ + lte: vi.fn().mockReturnValue({ + gte: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ data: overlapping, error: null }), + }), + }), + order: vi.fn().mockResolvedValue({ data: allPeriods, error: null }), + }), + } + }) + + chainable.insert = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue(insertResult), + }), + }) + + chainable.update = vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ error: null }), + }), + }) + + return chainable + }), + } + + ;(createClient as ReturnType).mockResolvedValue(supabase) + return supabase +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('POST /api/bookkeeping/fiscal-periods', () => { + it('returns 401 when not authenticated', async () => { + buildMockSupabase({ user: null }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(401) + }) + + it('creates first period successfully', async () => { + buildMockSupabase({ allPeriods: [] }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toBeDefined() + }) + + it('rejects overlapping periods', async () => { + buildMockSupabase({ + allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true }], + overlapping: [{ id: 'p1', name: 'FY 2024' }], + }) + // Forward chain from 2024, start = 2025-01-01 + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error).toMatch(/Overlaps/) + }) + + it('rejects forward period with wrong start date', async () => { + buildMockSupabase({ + allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true }], + }) + const req = createMockRequest({ name: 'FY 2026', period_start: '2026-02-01', period_end: '2026-12-31' }) + const res = await POST(req) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/must start on 2026-01-01/) + }) + + it('rejects forward period when unclosed period exists', async () => { + buildMockSupabase({ + allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }], + openCount: 1, + }) + const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' }) + const res = await POST(req) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error).toMatch(/unclosed period/) + }) + + it('allows backward period creation', async () => { + buildMockSupabase({ + allPeriods: [{ id: 'p1', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }], + overlapping: [], + }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(200) + }) + + it('rejects backward period with wrong end date', async () => { + buildMockSupabase({ + allPeriods: [{ id: 'p1', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }], + }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-11-30' }) + const res = await POST(req) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/must end on 2025-12-31/) + }) + + it('backward chaining skips unclosed period constraint', async () => { + // There's an unclosed period (2026), but backward creation should still work + buildMockSupabase({ + allPeriods: [{ id: 'p1', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }], + openCount: 1, + overlapping: [], + }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(200) + }) + + it('rejects period that is neither forward nor backward', async () => { + buildMockSupabase({ + allPeriods: [ + { id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true }, + { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }, + ], + }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/must chain before the earliest or after the latest/) + }) + + it('rejects invalid period duration (> 18 months)', async () => { + buildMockSupabase({ allPeriods: [] }) + const req = createMockRequest({ name: 'Long period', period_start: '2025-01-01', period_end: '2026-12-31' }) + const res = await POST(req) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/18 months/) + }) + + it('rejects invalid body', async () => { + buildMockSupabase({}) + const req = createMockRequest({ name: '', period_start: 'bad', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(400) + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index f36de63e..69425205 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -46,17 +46,14 @@ export async function POST(request: Request) { if (!validation.success) return validation.response const body = validation.data - // Enforce continuity: new period must chain from the latest existing period (BFL 3:1) - const { data: latest } = await supabase + // Fetch all existing periods to determine direction + const { data: allPeriods } = await supabase .from('fiscal_periods') - .select('period_end, is_closed') + .select('id, period_start, period_end, is_closed') .eq('company_id', companyId) - .order('period_end', { ascending: false }) - .limit(1) - .maybeSingle() + .order('period_start', { ascending: true }) - // First period for this company may start on any day (BFL 3 kap.) - const isFirstPeriod = !latest + const isFirstPeriod = !allPeriods || allPeriods.length === 0 // Validate period duration (max 18 months per BFL 3 kap.) const durationError = validatePeriodDuration(body.period_start, body.period_end, { isFirstPeriod }) @@ -64,30 +61,59 @@ export async function POST(request: Request) { return NextResponse.json({ error: durationError }, { status: 400 }) } - 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) { + if (allPeriods && allPeriods.length > 0) { + const earliest = allPeriods[0] + const latest = allPeriods[allPeriods.length - 1] + + const isBackward = body.period_end < earliest.period_start + const isForward = body.period_start > latest.period_end + + if (!isBackward && !isForward) { + // Neither backward nor forward — must overlap or be in the middle return NextResponse.json( - { error: `Period must start on ${expectedStart} (day after latest period ends)` }, + { error: 'New period must chain before the earliest or after the latest existing period' }, { 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 (isBackward) { + // Backward chaining: new period_end must be day before earliest period_start + const expectedEnd = new Date(earliest.period_start + 'T12:00:00Z') + expectedEnd.setUTCDate(expectedEnd.getUTCDate() - 1) + const expectedEndStr = expectedEnd.toISOString().split('T')[0] + if (body.period_end !== expectedEndStr) { + return NextResponse.json( + { error: `Period must end on ${expectedEndStr} (day before earliest period starts)` }, + { status: 400 } + ) + } + // Skip "no unclosed period" constraint for backward chaining (backfill needs the period open) + } else { + // Forward chaining: keep existing constraints (contiguity + no unclosed periods) + const prev = new Date(latest.period_end + 'T12:00:00Z') + prev.setUTCDate(prev.getUTCDate() + 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 } + ) + } - if (openCount && openCount > 0) { - return NextResponse.json( - { error: 'Cannot create a new period while an unclosed period exists' }, - { status: 409 } - ) + // Enforce: max one unclosed period (no skipping ahead) — forward only + 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 @@ -122,5 +148,17 @@ export async function POST(request: Request) { return NextResponse.json({ error: error.message }, { status: 500 }) } + // For backward chaining: update the old earliest period's previous_period_id + if (allPeriods && allPeriods.length > 0) { + const earliest = allPeriods[0] + if (body.period_end < earliest.period_start) { + await supabase + .from('fiscal_periods') + .update({ previous_period_id: data.id }) + .eq('id', earliest.id) + .eq('company_id', companyId) + } + } + return NextResponse.json({ data }) } diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts index 10380517..9bdf49f5 100644 --- a/app/api/import/bank-file/execute/route.ts +++ b/app/api/import/bank-file/execute/route.ts @@ -18,6 +18,7 @@ interface ExecuteRequest { file_hash: string skip_duplicates: boolean auto_categorize: boolean + settlement_account?: string } /** @@ -41,7 +42,7 @@ export async function POST(request: Request) { const companyId = await requireCompanyId(supabase, user.id) const body: ExecuteRequest = await request.json() - const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body + const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true, settlement_account } = body if (!transactions || transactions.length === 0) { return NextResponse.json({ error: 'No transactions to import' }, { status: 400 }) @@ -84,7 +85,7 @@ export async function POST(request: Request) { })) // Run ingestion pipeline - const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions) + const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, settlement_account ? { settlementAccount: settlement_account } : undefined) // Update import record with results await supabase diff --git a/components/bookkeeping/CreatePeriodDialog.tsx b/components/bookkeeping/CreatePeriodDialog.tsx new file mode 100644 index 00000000..2a2d7adc --- /dev/null +++ b/components/bookkeeping/CreatePeriodDialog.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useState, useMemo } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { Loader2 } from 'lucide-react' +import type { FiscalPeriod } from '@/types' + +interface Props { + open: boolean + onOpenChange: (open: boolean) => void + entryDate: string + periods: FiscalPeriod[] + onCreated: () => void +} + +function computeSuggestedPeriod(entryDate: string, periods: FiscalPeriod[]) { + if (periods.length === 0) { + // No periods at all — suggest a calendar year period around the entry date + const year = entryDate.split('-')[0] + return { + name: `FY ${year}`, + period_start: `${year}-01-01`, + period_end: `${year}-12-31`, + } + } + + const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start)) + const earliest = sorted[0] + const latest = sorted[sorted.length - 1] + + if (entryDate < earliest.period_start) { + // Backward: end = day before earliest start, start = 12 months back, 1st of month + const end = new Date(earliest.period_start + 'T00:00:00') + end.setDate(end.getDate() - 1) + + const start = new Date(end) + start.setMonth(start.getMonth() - 11) + start.setDate(1) + + const startStr = start.toISOString().split('T')[0] + const endStr = end.toISOString().split('T')[0] + const startYear = start.getFullYear() + const endYear = end.getFullYear() + const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}` + + return { name, period_start: startStr, period_end: endStr } + } + + // Forward: start = day after latest end, end = 12 months later (last day of month) + const start = new Date(latest.period_end + 'T00:00:00') + start.setDate(start.getDate() + 1) + + const end = new Date(start) + end.setMonth(end.getMonth() + 12) + end.setDate(0) // Last day of previous month + + const startStr = start.toISOString().split('T')[0] + const endStr = end.toISOString().split('T')[0] + const startYear = start.getFullYear() + const endYear = end.getFullYear() + const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}` + + return { name, period_start: startStr, period_end: endStr } +} + +export default function CreatePeriodDialog({ open, onOpenChange, entryDate, periods, onCreated }: Props) { + const { toast } = useToast() + const suggested = useMemo(() => computeSuggestedPeriod(entryDate, periods), [entryDate, periods]) + + const [name, setName] = useState(suggested.name) + const [periodStart, setPeriodStart] = useState(suggested.period_start) + const [periodEnd, setPeriodEnd] = useState(suggested.period_end) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Reset form when suggested values change (dialog reopened with new date) + const [lastSuggested, setLastSuggested] = useState(suggested) + if (suggested.name !== lastSuggested.name || suggested.period_start !== lastSuggested.period_start) { + setName(suggested.name) + setPeriodStart(suggested.period_start) + setPeriodEnd(suggested.period_end) + setLastSuggested(suggested) + } + + const handleCreate = async () => { + setIsSubmitting(true) + try { + const res = await fetch('/api/bookkeeping/fiscal-periods', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, period_start: periodStart, period_end: periodEnd }), + }) + + const result = await res.json() + + if (!res.ok) { + toast({ + title: 'Kunde inte skapa räkenskapsår', + description: result.error || 'Ett oväntat fel uppstod.', + variant: 'destructive', + }) + return + } + + toast({ title: 'Räkenskapsår skapat', description: `${name} har skapats.` }) + onOpenChange(false) + onCreated() + } catch { + toast({ + title: 'Kunde inte skapa räkenskapsår', + description: 'Ett nätverksfel uppstod. Försök igen.', + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + Skapa räkenskapsår + + Det finns inget räkenskapsår som täcker datumet {entryDate}. Skapa ett nytt nedan. + + + +
+
+ + setName(e.target.value)} className="mt-1" /> +
+
+
+ + setPeriodStart(e.target.value)} className="mt-1" /> +
+
+ + setPeriodEnd(e.target.value)} className="mt-1" /> +
+
+
+ + + + + +
+
+ ) +} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index a0ec0227..84ab967d 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -7,12 +7,13 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' -import { Plus, Trash2, AlertTriangle, Loader2, Lock } from 'lucide-react' +import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' @@ -82,6 +83,8 @@ export default function JournalEntryForm({ const [exchangeRate, setExchangeRate] = useState('') const [isFetchingRate, setIsFetchingRate] = useState(false) const [foreignAmount, setForeignAmount] = useState('') + const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null) + const [showCreatePeriod, setShowCreatePeriod] = useState(false) const isForeign = entryCurrency !== 'SEK' @@ -95,9 +98,19 @@ export default function JournalEntryForm({ async function fetchPeriods() { const res = await fetch('/api/bookkeeping/fiscal-periods') const { data } = await res.json() - setPeriods(data || []) - if (data && data.length > 0) { - setSelectedPeriod(data[0].id) + const fetched: FiscalPeriod[] = data || [] + setPeriods(fetched) + + // Auto-select period matching the current entry date + const match = fetched.find( + (p) => entryDate >= p.period_start && entryDate <= p.period_end + ) + if (match) { + setSelectedPeriod(match.id) + setPeriodMismatch(null) + } else if (fetched.length > 0) { + setSelectedPeriod(fetched[0].id) + setPeriodMismatch('no_period') } } @@ -118,6 +131,20 @@ export default function JournalEntryForm({ } }, []) + // Auto-select period when entry date changes + useEffect(() => { + if (periods.length === 0) return + const match = periods.find( + (p) => entryDate >= p.period_start && entryDate <= p.period_end + ) + if (match) { + setSelectedPeriod(match.id) + setPeriodMismatch(null) + } else { + setPeriodMismatch('no_period') + } + }, [entryDate, periods]) + // Fetch exchange rate from Riksbanken when currency changes const fetchRate = useCallback(async (currency: Currency) => { if (currency === 'SEK') return @@ -205,7 +232,7 @@ export default function JournalEntryForm({ : 0 const handleReview = () => { - if (!selectedPeriod || !description || !isBalanced) return + if (!selectedPeriod || !description || !isBalanced || periodMismatch) return const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded') if (!embedded && !hasDocuments) { setShowNoDocWarning(true) @@ -375,6 +402,26 @@ export default function JournalEntryForm({ )} + {/* Period mismatch warning */} + {periodMismatch === 'no_period' && ( +
+ +
+

Inget räkenskapsår matchar datumet {entryDate}

+

Skapa ett räkenskapsår som täcker detta datum för att kunna bokföra.

+
+ +
+ )} + {/* Currency section */}
@@ -645,16 +692,17 @@ export default function JournalEntryForm({
- {(!description || !selectedPeriod || isUploading) && ( + {(!description || !selectedPeriod || isUploading || periodMismatch) && (
{!description &&

Ange en beskrivning

} {!selectedPeriod &&

Välj en räkenskapsperiod

} + {periodMismatch === 'no_period' &&

Skapa ett räkenskapsår som matchar datumet

} {isUploading &&

Vänta tills filerna laddats upp

}
)} @@ -706,6 +754,14 @@ export default function JournalEntryForm({
+ +
) diff --git a/components/import/BankFileConfirmStep.tsx b/components/import/BankFileConfirmStep.tsx index b9bfdee3..7c3a7956 100644 --- a/components/import/BankFileConfirmStep.tsx +++ b/components/import/BankFileConfirmStep.tsx @@ -1,8 +1,11 @@ 'use client' +import { useState, useEffect } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { ArrowLeft, Loader2, @@ -11,14 +14,21 @@ import { Link2, Calendar, Lock, + Landmark, } from 'lucide-react' import { formatCurrency } from '@/lib/utils' import { useCanWrite } from '@/lib/hooks/use-can-write' +import { createClient } from '@/lib/supabase/client' import type { BankFileParseResult } from '@/lib/import/bank-file/types' +interface BankAccount { + account_number: string + account_name: string +} + interface BankFileConfirmStepProps { parseResult: BankFileParseResult - onExecute: (options: { skip_duplicates: boolean; auto_categorize: boolean }) => void + onExecute: (options: { skip_duplicates: boolean; auto_categorize: boolean; settlement_account?: string }) => void onBack: () => void isLoading: boolean } @@ -33,6 +43,30 @@ export default function BankFileConfirmStep({ const { transactions, stats, date_from, date_to } = parseResult const refsCount = transactions.filter((t) => t.reference).length + const [bankAccounts, setBankAccounts] = useState([]) + const [selectedAccount, setSelectedAccount] = useState('1930') + + useEffect(() => { + async function fetchBankAccounts() { + const supabase = createClient() + const { data } = await supabase + .from('chart_of_accounts') + .select('account_number, account_name') + .eq('is_active', true) + .gte('account_number', '1900') + .lte('account_number', '1999') + .order('account_number') + + if (data && data.length > 0) { + setBankAccounts(data) + // Default to 1930 if available, otherwise first account + const has1930 = data.some(a => a.account_number === '1930') + if (!has1930) setSelectedAccount(data[0].account_number) + } + } + fetchBankAccounts() + }, []) + if (isLoading) { return (
@@ -102,6 +136,33 @@ export default function BankFileConfirmStep({
+ {/* Bank account selector */} + {bankAccounts.length > 1 && ( +
+ + +

+ Välj vilket bankkonto transaktionerna ska bokföras mot. +

+
+ )} + {/* Additional info */} {refsCount > 0 && (
@@ -125,6 +186,7 @@ export default function BankFileConfirmStep({ onClick={() => onExecute({ skip_duplicates: true, auto_categorize: false, + settlement_account: selectedAccount !== '1930' ? selectedAccount : undefined, })} disabled={isLoading || !canWrite} title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index d2166465..e970fdce 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -102,7 +102,7 @@ export async function syncAccountTransactions( try { const fileName = `psd2-response_${connectionId}_${account.uid}_${new Date().toISOString().replace(/[:.]/g, '-')}_p${i + 1}.json` const buffer = new TextEncoder().encode(rawPages[i]).buffer as ArrayBuffer - await uploadDocument(supabase, companyId, userId, + await uploadDocument(supabase, userId, companyId, { name: fileName, buffer, type: 'application/json' }, { upload_source: 'api' } ) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 5138c561..5e55c935 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -494,7 +494,9 @@ export const CreateAccountSchema = z.object({ account_type: AccountTypeSchema, normal_balance: NormalBalanceSchema, plan_type: z.enum(['k1', 'full_bas']).optional(), - description: z.string().optional(), + description: z.string().nullable().optional(), + default_vat_code: z.string().nullable().optional(), + sru_code: z.string().nullable().optional(), }) export const UpdateAccountSchema = z.object({ diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts index afb34936..e50f750e 100644 --- a/lib/bookkeeping/__tests__/engine.test.ts +++ b/lib/bookkeeping/__tests__/engine.test.ts @@ -105,6 +105,20 @@ describe('createDraftEntry — cancelled status on line-insert failure', () => { const supabase = { from: vi.fn().mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }, + error: null, + }), + }), + }), + }), + } + } if (table === 'journal_entries') { return { insert: vi.fn().mockReturnValue({ @@ -158,6 +172,177 @@ describe('createDraftEntry — cancelled status on line-insert failure', () => { }) }) +describe('createDraftEntry — date/period cross-validation', () => { + function buildSupabase(periodData: { name: string; period_start: string; period_end: string } | null) { + return { + from: vi.fn().mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: periodData, + error: periodData ? null : { message: 'Not found' }, + }), + }), + }), + }), + } + } + if (table === 'chart_of_accounts') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockResolvedValue({ + data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }], + error: null, + }), + }), + }), + } + } + if (table === 'journal_entries') { + return { + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { id: 'entry-1', status: 'draft' }, + error: null, + }), + }), + }), + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { id: 'entry-1', status: 'draft', lines: [] }, + error: null, + }), + }), + }), + } + } + if (table === 'journal_entry_lines') { + return { + insert: vi.fn().mockResolvedValue({ error: null }), + } + } + return createMockChain() + }), + } + } + + const validLines = [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + ] + + it('rejects entry date before period start', async () => { + const supabase = buildSupabase({ + name: 'FY 2025', + period_start: '2025-01-01', + period_end: '2025-12-31', + }) + + await expect( + createDraftEntry(supabase as never, 'company-1', 'user-1', { + fiscal_period_id: 'period-1', + entry_date: '2024-12-15', + description: 'Test', + source_type: 'manual', + lines: validLines, + }) + ).rejects.toThrow('Entry date 2024-12-15 is outside fiscal period "FY 2025"') + }) + + it('rejects entry date after period end', async () => { + const supabase = buildSupabase({ + name: 'FY 2025', + period_start: '2025-01-01', + period_end: '2025-12-31', + }) + + await expect( + createDraftEntry(supabase as never, 'company-1', 'user-1', { + fiscal_period_id: 'period-1', + entry_date: '2026-01-15', + description: 'Test', + source_type: 'manual', + lines: validLines, + }) + ).rejects.toThrow('Entry date 2026-01-15 is outside fiscal period "FY 2025"') + }) + + it('accepts entry date within period', async () => { + const supabase = buildSupabase({ + name: 'FY 2025', + period_start: '2025-01-01', + period_end: '2025-12-31', + }) + + const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', { + fiscal_period_id: 'period-1', + entry_date: '2025-06-15', + description: 'Test', + source_type: 'manual', + lines: validLines, + }) + + expect(result).toBeDefined() + expect(result.id).toBe('entry-1') + }) + + it('accepts entry date on period start boundary', async () => { + const supabase = buildSupabase({ + name: 'FY 2025', + period_start: '2025-01-01', + period_end: '2025-12-31', + }) + + const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', { + fiscal_period_id: 'period-1', + entry_date: '2025-01-01', + description: 'Test', + source_type: 'manual', + lines: validLines, + }) + + expect(result).toBeDefined() + }) + + it('accepts entry date on period end boundary', async () => { + const supabase = buildSupabase({ + name: 'FY 2025', + period_start: '2025-01-01', + period_end: '2025-12-31', + }) + + const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', { + fiscal_period_id: 'period-1', + entry_date: '2025-12-31', + description: 'Test', + source_type: 'manual', + lines: validLines, + }) + + expect(result).toBeDefined() + }) + + it('throws when fiscal period not found', async () => { + const supabase = buildSupabase(null) + + await expect( + createDraftEntry(supabase as never, 'company-1', 'user-1', { + fiscal_period_id: 'nonexistent', + entry_date: '2025-06-15', + description: 'Test', + source_type: 'manual', + lines: validLines, + }) + ).rejects.toThrow('Fiscal period not found') + }) +}) + describe('JournalEntryStatus type includes cancelled', () => { it('cancelled is a valid JournalEntryStatus value', () => { const status: JournalEntryStatus = 'cancelled' diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 99688488..2dbdf01e 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -153,6 +153,24 @@ export async function createDraftEntry( ) } + // Validate that entry_date falls within the selected fiscal period + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('name, period_start, period_end') + .eq('id', input.fiscal_period_id) + .eq('company_id', companyId) + .single() + + if (periodError || !period) { + throw new Error('Fiscal period not found') + } + + if (input.entry_date < period.period_start || input.entry_date > period.period_end) { + throw new Error( + `Entry date ${input.entry_date} is outside fiscal period "${period.name}" (${period.period_start} - ${period.period_end})` + ) + } + // Resolve account IDs const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines) diff --git a/lib/bookkeeping/mapping-engine.ts b/lib/bookkeeping/mapping-engine.ts index fc06f612..3d4860a2 100644 --- a/lib/bookkeeping/mapping-engine.ts +++ b/lib/bookkeeping/mapping-engine.ts @@ -50,8 +50,11 @@ export async function evaluateMappingRules( supabase: SupabaseClient, companyId: string, transaction: Transaction, - entityType?: EntityType + entityType?: EntityType, + settlementAccount?: string ): Promise { + const bankAccount = settlementAccount || '1930' + // Fetch all active rules (user-specific + system defaults), ordered by priority const { data: rules, error } = await supabase .from('mapping_rules') @@ -63,29 +66,29 @@ export async function evaluateMappingRules( if (error || !rules || rules.length === 0) { // Try counterparty templates before static template fallback const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType) - if (counterpartyResult) return counterpartyResult + if (counterpartyResult) return applySettlementAccount(counterpartyResult, bankAccount) const templateResult = evaluateTemplateRules(transaction, entityType) - if (templateResult) return templateResult - return getDefaultResult(transaction) + if (templateResult) return applySettlementAccount(templateResult, bankAccount) + return getDefaultResult(transaction, bankAccount) } // Evaluate each rule in priority order for (const rule of rules as MappingRule[]) { if (matchesRule(rule, transaction)) { - return buildResult(rule, transaction, entityType) + return applySettlementAccount(buildResult(rule, transaction, entityType), bankAccount) } } // Try counterparty templates before static template fallback const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType) - if (counterpartyResult) return counterpartyResult + if (counterpartyResult) return applySettlementAccount(counterpartyResult, bankAccount) // Try template-based matching before default fallback const templateResult = evaluateTemplateRules(transaction, entityType) - if (templateResult) return templateResult + if (templateResult) return applySettlementAccount(templateResult, bankAccount) - return getDefaultResult(transaction) + return getDefaultResult(transaction, bankAccount) } /** @@ -261,13 +264,13 @@ function buildResult(rule: MappingRule, transaction: Transaction, entityType?: E /** * Default result when no rule matches (uncategorized) */ -function getDefaultResult(transaction: Transaction): MappingResult { +function getDefaultResult(transaction: Transaction, bankAccount = '1930'): MappingResult { const isExpense = transaction.amount < 0 return { rule: null, - debit_account: isExpense ? '6991' : '1930', - credit_account: isExpense ? '1930' : '3900', + debit_account: isExpense ? '6991' : bankAccount, + credit_account: isExpense ? bankAccount : '3900', risk_level: 'MEDIUM', confidence: 0.1, requires_review: true, @@ -277,6 +280,20 @@ function getDefaultResult(transaction: Transaction): MappingResult { } } +/** + * Replace any default 1930 references in a mapping result with the actual settlement account. + * This allows mapping rules and templates that don't explicitly set a bank account + * to work correctly with secondary bank accounts (e.g. 1931). + */ +function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult { + if (bankAccount === '1930') return result + return { + ...result, + debit_account: result.debit_account === '1930' ? bankAccount : result.debit_account, + credit_account: result.credit_account === '1930' ? bankAccount : result.credit_account, + } +} + /** * Save a user-level mapping rule learned from categorization. * diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts index b3fbc66c..b9a4e87d 100644 --- a/lib/bookkeeping/transaction-entries.ts +++ b/lib/bookkeeping/transaction-entries.ts @@ -110,7 +110,7 @@ export async function createTransactionJournalEntry( debit_amount: 0, credit_amount: absAmount, line_description: transaction.description, - ...(settlementAccount === '1930' ? currencyMeta : {}), + ...(isForeign ? currencyMeta : {}), }) } else { // Debit bank for full amount @@ -119,7 +119,7 @@ export async function createTransactionJournalEntry( debit_amount: absAmount, credit_amount: 0, line_description: transaction.description, - ...(settlementAccount === '1930' ? currencyMeta : {}), + ...(isForeign ? currencyMeta : {}), }) // All non-settlement lines for (const line of mappingResult.vat_lines) { @@ -176,7 +176,7 @@ export async function createTransactionJournalEntry( debit_amount: 0, credit_amount: absAmount, line_description: transaction.description, - ...(creditAccount === '1930' ? currencyMeta : {}), + ...(isForeign ? currencyMeta : {}), }) } else { // Income (legacy single debit/credit path) @@ -196,7 +196,7 @@ export async function createTransactionJournalEntry( debit_amount: absAmount, credit_amount: 0, line_description: transaction.description, - ...(debitAccount === '1930' ? currencyMeta : {}), + ...(isForeign ? currencyMeta : {}), }) // Credit revenue for net amount lines.push({ @@ -222,7 +222,7 @@ export async function createTransactionJournalEntry( debit_amount: absAmount, credit_amount: 0, line_description: transaction.description, - ...(debitAccount === '1930' ? currencyMeta : {}), + ...(isForeign ? currencyMeta : {}), }, { account_number: creditAccount, @@ -253,7 +253,8 @@ export function buildDomesticExpenseLines( amount: number, expenseAccount: string, description: string, - vatRate: number = 0.25 + vatRate: number = 0.25, + bankAccount: string = '1930' ): CreateJournalEntryLineInput[] { const absAmount = Math.abs(amount) const lines: CreateJournalEntryLineInput[] = [] @@ -276,7 +277,7 @@ export function buildDomesticExpenseLines( line_description: `Ingående moms ${vatRate * 100}%`, }, { - account_number: '1930', // Företagskonto + account_number: bankAccount, debit_amount: 0, credit_amount: absAmount, line_description: description, @@ -291,7 +292,7 @@ export function buildDomesticExpenseLines( line_description: description, }, { - account_number: '1930', + account_number: bankAccount, debit_amount: 0, credit_amount: absAmount, line_description: description, diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts index d7a860ac..06319501 100644 --- a/lib/core/bookkeeping/period-service.ts +++ b/lib/core/bookkeeping/period-service.ts @@ -206,6 +206,95 @@ export async function createNextPeriod( return newPeriod as FiscalPeriod } +/** + * Create a previous fiscal period before the given one. + * Computes a 12-month period ending the day before the given period starts. + * Updates previous_period_id chain so the given period points to the new one. + */ +export async function createPreviousPeriod( + supabase: SupabaseClient, + companyId: string, + userId: string, + currentPeriodId: string +): Promise { + + const { data: current, error: fetchError } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', currentPeriodId) + .eq('company_id', companyId) + .single() + + if (fetchError || !current) { + throw new Error('Current fiscal period not found') + } + + // Compute previous period end (day before current start) + const prevEnd = new Date(current.period_start + 'T12:00:00Z') + prevEnd.setUTCDate(prevEnd.getUTCDate() - 1) + + // Compute previous period start (1st of month, 12 months before prevEnd) + const prevStart = new Date(prevEnd) + prevStart.setUTCMonth(prevStart.getUTCMonth() - 11) + prevStart.setUTCDate(1) + + const prevStartStr = prevStart.toISOString().split('T')[0] + const prevEndStr = prevEnd.toISOString().split('T')[0] + + // Validate period duration + const durationError = validatePeriodDuration(prevStartStr, prevEndStr, { isFirstPeriod: false }) + if (durationError) { + throw new Error(durationError) + } + + // Check for overlapping periods + const { data: overlapping } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .lte('period_start', prevEndStr) + .gte('period_end', prevStartStr) + .limit(1) + + if (overlapping && overlapping.length > 0) { + throw new Error('Previous fiscal period already exists or overlaps with an existing period') + } + + // Generate name + const startYear = prevStart.getFullYear() + const endYear = prevEnd.getFullYear() + const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}` + + const { data: newPeriod, error: insertError } = await supabase + .from('fiscal_periods') + .insert({ + company_id: companyId, + user_id: userId, + name, + period_start: prevStartStr, + period_end: prevEndStr, + }) + .select() + .single() + + if (insertError || !newPeriod) { + throw new Error(`Failed to create previous period: ${insertError?.message}`) + } + + // Update the current period to point to the new one + const { error: updateError } = await supabase + .from('fiscal_periods') + .update({ previous_period_id: newPeriod.id }) + .eq('id', currentPeriodId) + .eq('company_id', companyId) + + if (updateError) { + throw new Error(`Failed to update period chain: ${updateError.message}`) + } + + return newPeriod as FiscalPeriod +} + /** * Get status summary for a fiscal period. */ diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index a3212526..dbc1188a 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -80,6 +80,10 @@ const ERROR_PATTERN_MAP: [RegExp, string | null][] = [ /Cannot attach documents to entries in a locked/i, 'Kan inte bifoga dokument till verifikationer i en låst period.', ], + [ + /Entry date .+ is outside fiscal period/i, + 'Datumet ligger utanför det valda räkenskapsåret.', + ], ] /** diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index 40bea081..67dd2d6a 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -412,7 +412,7 @@ describe('manualLink', () => { const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1') expect(result.success).toBe(false) - expect(result.error).toBe('Journal entry has no line on account 1930') + expect(result.error).toBe('Verifikationen saknar rad på bankkonto (19xx)') }) it('succeeds when all validations pass', async () => { diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index e5949e88..c92d9dfb 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -206,13 +206,14 @@ export async function runReconciliation( // ============================================================ /** - * Compare bank transaction totals vs GL 1930 balance. + * Compare bank transaction totals vs GL bank account balance. */ export async function getReconciliationStatus( supabase: SupabaseClient, companyId: string, dateFrom?: string, - dateTo?: string + dateTo?: string, + bankAccount = '1930' ): Promise { // Get all transactions in range let txQuery = supabase @@ -226,11 +227,11 @@ export async function getReconciliationStatus( const { data: transactions } = await txQuery - // Get GL 1930 lines (all, not just unlinked) + // Get GL bank account lines (all, not just unlinked) let glQuery = supabase .from('journal_entry_lines') .select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)') - .eq('account_number', '1930') + .eq('account_number', bankAccount) .eq('journal_entries.company_id', companyId) .eq('journal_entries.status', 'posted') @@ -280,7 +281,7 @@ export async function getReconciliationStatus( /** * Manually link a transaction to an existing journal entry. - * Validates that the journal entry has a 1930 line and amounts are directionally compatible. + * Validates that the journal entry has a bank account line and amounts are directionally compatible. */ export async function manualLink( supabase: SupabaseClient, @@ -321,15 +322,16 @@ export async function manualLink( return { success: false, error: 'Journal entry is not posted' } } - // Check for 1930 line + // Check for a bank account line (19xx class accounts) const { data: lines } = await supabase .from('journal_entry_lines') - .select('debit_amount, credit_amount') + .select('debit_amount, credit_amount, account_number') .eq('journal_entry_id', journalEntryId) - .eq('account_number', '1930') + .gte('account_number', '1900') + .lte('account_number', '1999') if (!lines || lines.length === 0) { - return { success: false, error: 'Journal entry has no line on account 1930' } + return { success: false, error: 'Verifikationen saknar rad på bankkonto (19xx)' } } // Check that no other transaction is already linked to this entry @@ -434,19 +436,32 @@ export async function unlinkReconciliation( // Helpers // ============================================================ -/** Fetch unlinked 1930 GL lines via the RPC function */ +/** Fetch unlinked bank GL lines via the RPC function */ export async function fetchUnlinkedGLLines( supabase: SupabaseClient, companyId: string, dateFrom?: string, - dateTo?: string + dateTo?: string, + bankAccount = '1930' ): Promise { - const { data, error } = await supabase.rpc('get_unlinked_1930_lines', { + const { data, error } = await supabase.rpc('get_unlinked_bank_lines', { p_company_id: companyId, p_date_from: dateFrom || null, p_date_to: dateTo || null, + p_account_number: bankAccount, }) + // Fall back to legacy RPC if the new one doesn't exist yet + if (error && bankAccount === '1930') { + const { data: fallbackData, error: fallbackError } = await supabase.rpc('get_unlinked_1930_lines', { + p_company_id: companyId, + p_date_from: dateFrom || null, + p_date_to: dateTo || null, + }) + if (fallbackError || !fallbackData) return [] + return fallbackData as UnlinkedGLLine[] + } + if (error || !data) return [] return data as UnlinkedGLLine[] } diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index b8ff6251..4276ddfc 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -122,7 +122,7 @@ export async function ingestTransactions( // Pre-fetch unlinked GL lines for reconciliation (non-critical) let glLinePool: UnlinkedGLLine[] = [] try { - glLinePool = await fetchUnlinkedGLLines(supabase, companyId) + glLinePool = await fetchUnlinkedGLLines(supabase, companyId, undefined, undefined, options?.settlementAccount) } catch { // Non-critical — reconciliation will be skipped } @@ -365,7 +365,9 @@ export async function ingestTransactions( const mappingResult = await evaluateMappingRules( supabase, companyId, - newTransaction as Transaction + newTransaction as Transaction, + undefined, + options?.settlementAccount ) if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) { diff --git a/supabase/migrations/20260413100000_get_unlinked_bank_lines.sql b/supabase/migrations/20260413100000_get_unlinked_bank_lines.sql new file mode 100644 index 00000000..d36c6d79 --- /dev/null +++ b/supabase/migrations/20260413100000_get_unlinked_bank_lines.sql @@ -0,0 +1,52 @@ +-- Generalized version of get_unlinked_1930_lines that accepts any bank account number. +-- This allows reconciliation against secondary bank accounts (e.g. 1931, 1932). + +CREATE FUNCTION public.get_unlinked_bank_lines( + p_company_id UUID, + p_date_from DATE DEFAULT NULL, + p_date_to DATE DEFAULT NULL, + p_account_number TEXT DEFAULT '1930' +) +RETURNS TABLE ( + line_id UUID, + journal_entry_id UUID, + debit_amount NUMERIC, + credit_amount NUMERIC, + line_description TEXT, + entry_date DATE, + voucher_number INT, + voucher_series TEXT, + entry_description TEXT, + source_type TEXT +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + jel.id AS line_id, + je.id AS journal_entry_id, + jel.debit_amount, + jel.credit_amount, + jel.line_description, + je.entry_date, + je.voucher_number, + je.voucher_series, + je.description AS entry_description, + je.source_type + FROM public.journal_entry_lines jel + JOIN public.journal_entries je ON je.id = jel.journal_entry_id + WHERE jel.account_number = p_account_number + AND je.company_id = p_company_id + AND je.status = 'posted' + AND (p_date_from IS NULL OR je.entry_date >= p_date_from) + AND (p_date_to IS NULL OR je.entry_date <= p_date_to) + AND NOT EXISTS ( + SELECT 1 + FROM public.transactions t + WHERE t.journal_entry_id = je.id + AND t.company_id = p_company_id + ) + ORDER BY je.entry_date, je.voucher_number; +$$; diff --git a/types/index.ts b/types/index.ts index d08a3c2a..d6c82ec6 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2192,6 +2192,9 @@ export interface IngestOptions { * Used when SIE-imported entries overlap the sync date range * to prevent double-booking. */ skipAutoCategorization?: boolean + /** Override the default settlement account (1930) for bank transactions. + * Used when importing to a secondary bank account (e.g., 1931). */ + settlementAccount?: string } /** Result of the transaction ingestion pipeline */