Fiscal period and multi bank (#228)
* 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.
This commit is contained in:
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skapa räkenskapsår</DialogTitle>
|
||||
<DialogDescription>
|
||||
Det finns inget räkenskapsår som täcker datumet {entryDate}. Skapa ett nytt nedan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Namn</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Startdatum</Label>
|
||||
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Slutdatum</Label>
|
||||
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isSubmitting || !name || !periodStart || !periodEnd}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Skapa
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Period mismatch warning */}
|
||||
{periodMismatch === 'no_period' && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
|
||||
<div className="flex-1 text-sm text-warning-foreground">
|
||||
<p className="font-medium">Inget räkenskapsår matchar datumet {entryDate}</p>
|
||||
<p className="mt-0.5">Skapa ett räkenskapsår som täcker detta datum för att kunna bokföra.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreatePeriod(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<CalendarPlus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Skapa räkenskapsår
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Currency section */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-24">
|
||||
@@ -645,16 +692,17 @@ export default function JournalEntryForm({
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
onClick={handleReview}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading || !canWrite}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isUploading || !canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
>
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
|
||||
Granska & skapa
|
||||
</Button>
|
||||
{(!description || !selectedPeriod || isUploading) && (
|
||||
{(!description || !selectedPeriod || isUploading || periodMismatch) && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
|
||||
{!description && <p>Ange en beskrivning</p>}
|
||||
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
|
||||
{periodMismatch === 'no_period' && <p>Skapa ett räkenskapsår som matchar datumet</p>}
|
||||
{isUploading && <p>Vänta tills filerna laddats upp</p>}
|
||||
</div>
|
||||
)}
|
||||
@@ -706,6 +754,14 @@ export default function JournalEntryForm({
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
|
||||
<CreatePeriodDialog
|
||||
open={showCreatePeriod}
|
||||
onOpenChange={setShowCreatePeriod}
|
||||
entryDate={entryDate}
|
||||
periods={periods}
|
||||
onCreated={fetchPeriods}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -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<BankAccount[]>([])
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center py-24 space-y-6">
|
||||
@@ -102,6 +136,33 @@ export default function BankFileConfirmStep({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bank account selector */}
|
||||
{bankAccounts.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Landmark className="h-4 w-4 text-muted-foreground" />
|
||||
Bankkonto
|
||||
</Label>
|
||||
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bankAccounts.map((account) => (
|
||||
<SelectItem key={account.account_number} value={account.account_number}>
|
||||
<span className="font-mono">{account.account_number}</span>
|
||||
{' '}
|
||||
{account.account_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Välj vilket bankkonto transaktionerna ska bokföras mot.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional info */}
|
||||
{refsCount > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user