From ade4ad5971ccda95d8dd79fde6f8271e36c58396 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:13:02 +0200 Subject: [PATCH] Fiscal period and multi bank (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add fiscal period backward chaining and entry date validation Support creating fiscal periods before the earliest existing period (backward chaining) for backfill scenarios, alongside the existing forward chaining. The engine now validates that entry dates fall within the selected fiscal period, with a Swedish error message. The journal entry form auto-selects the matching period and shows a warning with a CreatePeriodDialog when no period covers the entry date. * feat: support multi-bank-account for imports and reconciliation Plumb a configurable settlement account through the entire bank import pipeline — mapping engine, transaction entries, ingest, and reconciliation — so secondary bank accounts (e.g. 1931, 1932) work correctly instead of hardcoding 1930. Adds a get_unlinked_bank_lines RPC that generalizes the existing get_unlinked_1930_lines with a fallback for backwards compatibility. The bank file import UI now shows a bank account selector when multiple 19xx accounts exist. Also adds default_vat_code/sru_code to account creation and fixes uploadDocument argument order in enable-banking sync. --- app/(dashboard)/import/page.tsx | 2 +- app/api/bookkeeping/accounts/route.ts | 2 + .../fiscal-periods/__tests__/route.test.ts | 237 ++++++++++++++++++ app/api/bookkeeping/fiscal-periods/route.ts | 90 +++++-- app/api/import/bank-file/execute/route.ts | 5 +- components/bookkeeping/CreatePeriodDialog.tsx | 168 +++++++++++++ components/bookkeeping/JournalEntryForm.tsx | 70 +++++- components/import/BankFileConfirmStep.tsx | 64 ++++- extensions/general/enable-banking/lib/sync.ts | 2 +- lib/api/schemas.ts | 4 +- lib/bookkeeping/__tests__/engine.test.ts | 185 ++++++++++++++ lib/bookkeeping/engine.ts | 18 ++ lib/bookkeeping/mapping-engine.ts | 39 ++- lib/bookkeeping/transaction-entries.ts | 17 +- lib/core/bookkeeping/period-service.ts | 89 +++++++ lib/errors/get-error-message.ts | 4 + .../__tests__/bank-reconciliation.test.ts | 2 +- lib/reconciliation/bank-reconciliation.ts | 39 ++- lib/transactions/ingest.ts | 6 +- ...20260413100000_get_unlinked_bank_lines.sql | 52 ++++ types/index.ts | 3 + 21 files changed, 1025 insertions(+), 73 deletions(-) create mode 100644 app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts create mode 100644 components/bookkeeping/CreatePeriodDialog.tsx create mode 100644 supabase/migrations/20260413100000_get_unlinked_bank_lines.sql 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 */