Fix/m sprint fixes (#613)

* fix(dashboard): exclude ignored and already-triaged transactions from stale count

The "Gamla transaktioner" widget counted transactions that had been ignored
or already marked as is_business=true but not yet booked, so users saw a
nag for a row they had already dealt with — and the /transactions inbox
correctly hid it. Align the count with the inbox criterion (is_business
IS NULL, is_ignored = false) so the widget clears when the row leaves
the inbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): read entity_type from settings response wrapper

The transactions page read entityRes.entity_type directly, but
/api/settings returns { data: { entity_type, ... } }. The expression
was always undefined, so setEntityType never fired and entityType
stayed at its initial 'enskild_firma'. The template picker's
entity_type filter then dropped every aktiebolag-tagged user template
for AB customers — only entity_type='all' templates made it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stale templates
bank sync
journal entry from transaction

* fixed pr comments

* fixed pr comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-30 01:28:41 +02:00
committed by GitHub
parent fc7a46c3f2
commit ea1bf01f1e
76 changed files with 5474 additions and 532 deletions
+5 -4
View File
@@ -178,10 +178,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const foreignTotal = hasForeignCurrency ? Math.abs(Number(foreignLines[0].amount_in_currency) || 0) : 0
const foreignExchangeRate = hasForeignCurrency ? (Number(foreignLines[0].exchange_rate) || null) : null
const canCorrect =
entry.status === 'posted' &&
entry.source_type !== 'storno' &&
entry.source_type !== 'correction'
// A correction is itself a regular posted verifikation and can be corrected
// again (BFL 5 kap. 5 § — the chain just grows). Storno entries are pure
// reversals and cannot be corrected directly; the user walks to the latest
// correction (or the original) and corrects that one.
const canCorrect = entry.status === 'posted' && entry.source_type !== 'storno'
// Include current entry in the chain for the visualization
const fullChain = [entry, ...chain]
+45 -1
View File
@@ -9,7 +9,7 @@ import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download } from 'lucide-react'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download, AlertTriangle } from 'lucide-react'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
@@ -73,6 +73,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
import dynamic from 'next/dynamic'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import CloudBackupCard from '@/extensions/general/cloud-backup/components/CloudBackupCard'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
const MigrationWizard = dynamic(
() => import('@/components/extensions/general/ArcimMigrationWorkspace'),
@@ -98,6 +99,8 @@ const BANK_STEP_LABELS: Record<BankFileStep, string> = {
function BankFileImportWizard() {
const { toast } = useToast()
const tTx = useTranslations('transactions')
const { company } = useCompany()
const [bankStep, setBankStep] = useState<BankFileStep>('upload')
const [bankIsLoading, setBankIsLoading] = useState(false)
@@ -114,6 +117,28 @@ function BankFileImportWizard() {
// Import result
const [ingestResult, setIngestResult] = useState<IngestResult | null>(null)
// Active PSD2 connections — drives an overlap warning so users don't
// accidentally upload a CSV covering periods we already sync nightly.
const [activePsd2Banks, setActivePsd2Banks] = useState<string[]>([])
useEffect(() => {
if (!company?.id) return
let cancelled = false
const supabase = createClient()
supabase
.from('bank_connections')
.select('bank_name')
.eq('company_id', company.id)
.eq('status', 'active')
.then(({ data }) => {
if (cancelled) return
const names = Array.from(new Set((data ?? []).map((r) => r.bank_name).filter(Boolean)))
setActivePsd2Banks(names)
})
return () => {
cancelled = true
}
}, [company?.id])
const steps = parseResult?.format === 'generic_csv' ? BANK_STEPS_WITH_MAPPING : BANK_STEPS
const currentStepIndex = steps.indexOf(bankStep)
const progress = ((currentStepIndex + 1) / steps.length) * 100
@@ -239,6 +264,25 @@ function BankFileImportWizard() {
return (
<div className="space-y-6">
{/* Status chip for at-a-glance "auto-sync is healthy / stale / needs attention" */}
<BankSyncStatusChip />
{/* Overlap warning — active PSD2 means file import will likely create
duplicates of transactions the nightly sync already covers. */}
{activePsd2Banks.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
<div className="flex-1 text-sm">
<p className="font-medium">
{tTx('import_psd2_active_warning_title', { bankName: activePsd2Banks.join(', ') })}
</p>
<p className="mt-1 text-muted-foreground">
{tTx('import_psd2_active_warning_body')}
</p>
</div>
</div>
)}
{/* Progress */}
<Card>
<CardContent className="pt-6">
+14 -4
View File
@@ -88,6 +88,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
const reminderLevelLabel = (level: 1 | 2 | 3): string => t(`reminder_level_${level}`)
@@ -126,14 +127,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setInvoice(data as InvoiceWithRelations)
// Fetch the öresavrundning setting so the detail view matches the PDF.
// Fetch the öresavrundning + VAT-registration settings so the detail view
// matches the PDF (pdf-template.tsx:792 hides org_number / personnummer
// for private customers, and :876 suppresses the moms row when the seller
// is not VAT-registered and the invoice carries no VAT).
if (data.company_id) {
const { data: settings } = await supabase
.from('company_settings')
.select('ore_rounding')
.select('ore_rounding, vat_registered')
.eq('company_id', data.company_id)
.maybeSingle()
setOreRounding(settings?.ore_rounding ?? true)
if (typeof settings?.vat_registered === 'boolean') {
setVatRegistered(settings.vat_registered)
}
}
// Fetch reminders for this invoice
@@ -481,10 +488,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardContent>
<div className="space-y-2">
<p className="font-medium text-lg">{customer.name}</p>
{customer.org_number && (
{customer.customer_type !== 'individual' && customer.org_number && (
<p className="text-muted-foreground">{t('org_number_label', { value: customer.org_number })}</p>
)}
{customer.vat_number && (
{customer.customer_type !== 'individual' && customer.vat_number && (
<p className="text-muted-foreground">{t('vat_number_label', { value: customer.vat_number })}</p>
)}
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground">
@@ -577,6 +584,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.sort(([a], [b]) => b - a)
if (entries.length === 0) {
if (vatRegistered === false && invoice.vat_amount === 0) {
return null
}
return (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label')}</span>
+1
View File
@@ -1274,6 +1274,7 @@ export default function NewInvoicePage() {
notes={pendingData?.notes}
numberPreview={numberPreview}
oreRounding={oreRounding}
vatRegistered={vatRegistered}
/>
</ConfirmationDialog>
)}
+2 -2
View File
@@ -108,8 +108,8 @@ export default async function DashboardPage() {
supabase.from('document_attachments').select('journal_entry_id').eq('company_id', companyId).eq('is_current_version', true).not('journal_entry_id', 'is', null),
supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('is_business', null),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).eq('is_ignored', false).is('is_business', null).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('is_ignored', false).is('is_business', null),
// Skatteverket tokens are user-scoped (one BankID identity per user) but
// carry the active company_id; either filter would work — we use user_id
// because that's what the token-store reads/writes against.
@@ -35,6 +35,7 @@ const LINE_ITEM_TYPE_LABELS: Record<SalaryLineItemType, string> = {
sick_day15_plus: 'Sjuklön (dag 15+, Försäkringskassan)',
vab: 'VAB (vård av sjukt barn)',
parental_leave: 'Föräldraledighet',
unpaid_leave: 'Tjänstledighet utan lön',
vacation: 'Semester',
semesterersattning: 'Semesterersättning',
traktamente_taxfree: 'Traktamente (skattefritt)',
+5 -1
View File
@@ -10,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
const BankingPanel = getSettingsPanel('enable-banking')
@@ -144,7 +145,10 @@ export default function BankingSettingsPage() {
)}
{hasBankingExtension && BankingPanel ? (
<BankingPanel />
<>
<BankSyncStatusChip />
<BankingPanel />
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
+361 -5
View File
@@ -13,15 +13,47 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate } from '@/lib/utils'
import { formatDate, cn } from '@/lib/utils'
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { formatCurrency } from '@/lib/utils'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
interface EditableLine {
account_number: string
side: 'debit' | 'credit'
amount: string
description: string
}
function parseAmount(s: string): number {
const n = Number(s.replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
interface PreviewLine {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
interface MarkPaidPreview {
entry_type: 'clearing' | 'cash'
lines: PreviewLine[]
invoice_already_booked: boolean
accounting_method: 'accrual' | 'cash'
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -50,6 +82,8 @@ export default function SupplierInvoiceDetailPage() {
const [payTab, setPayTab] = useState<'new' | 'existing'>('new')
const [payAmount, setPayAmount] = useState('')
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
const [paymentAccount, setPaymentAccount] = useState('1930')
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [duplicateCandidates, setDuplicateCandidates] = useState<
Array<{
@@ -60,6 +94,10 @@ export default function SupplierInvoiceDetailPage() {
merchant_name: string | null
}> | null
>(null)
const [markPaidPreview, setMarkPaidPreview] = useState<MarkPaidPreview | null>(null)
const [markPaidPreviewFailed, setMarkPaidPreviewFailed] = useState(false)
const [isEditingLines, setIsEditingLines] = useState(false)
const [editLines, setEditLines] = useState<EditableLine[]>([])
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
const statusLabels = useMemo<Record<string, string>>(() => ({
@@ -91,6 +129,141 @@ export default function SupplierInvoiceDetailPage() {
fetchInvoice()
}, [params.id])
// When the dialog closes, drop any in-progress edits so reopening starts
// from the server's default booking again.
useEffect(() => {
if (!isPayDialogOpen) {
setIsEditingLines(false)
setEditLines([])
}
}, [isPayDialogOpen])
// Mirror the preview into the editable working copy. Only resets when not
// currently editing — otherwise typing in the inputs would clobber on
// every keystroke since the preview refetches on input change.
useEffect(() => {
if (!isEditingLines && markPaidPreview) {
setEditLines(
markPaidPreview.lines.map((l) => {
const isDebit = l.debit_amount > 0
return {
account_number: l.account_number,
side: isDebit ? 'debit' : 'credit',
amount: String(isDebit ? l.debit_amount : l.credit_amount),
description: l.description,
}
}),
)
}
}, [markPaidPreview, isEditingLines])
const editValidation = useMemo(() => {
if (!isEditingLines) return { isBalanced: true, isValid: true, diff: 0, totalDebit: 0, totalCredit: 0, accountInvalid: false }
const totalDebit = round2(editLines.filter((l) => l.side === 'debit').reduce((s, l) => s + parseAmount(l.amount), 0))
const totalCredit = round2(editLines.filter((l) => l.side === 'credit').reduce((s, l) => s + parseAmount(l.amount), 0))
const isBalanced = totalDebit === totalCredit && totalDebit > 0
const accountInvalid = editLines.some((l) => !/^\d{4}$/.test(l.account_number.trim()))
return {
isBalanced,
accountInvalid,
isValid: isBalanced && !accountInvalid,
diff: round2(totalDebit - totalCredit),
totalDebit,
totalCredit,
}
}, [isEditingLines, editLines])
const updateEditLine = (i: number, patch: Partial<EditableLine>) =>
setEditLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const removeEditLine = (i: number) =>
setEditLines((prev) => prev.filter((_, idx) => idx !== i))
const addEditLine = () =>
setEditLines((prev) => [...prev, { account_number: '', side: 'debit', amount: '', description: '' }])
const resetEditLines = () => {
if (!markPaidPreview) return
setEditLines(
markPaidPreview.lines.map((l) => {
const isDebit = l.debit_amount > 0
return {
account_number: l.account_number,
side: isDebit ? 'debit' : 'credit',
amount: String(isDebit ? l.debit_amount : l.credit_amount),
description: l.description,
}
}),
)
}
// Load a preview of the JE that mark-paid would post. Refetches when the
// user changes amount or payment account so the displayed Debet/Kredit
// lines always reflect the current dialog inputs.
useEffect(() => {
if (!isPayDialogOpen || !invoice) {
setMarkPaidPreview(null)
setMarkPaidPreviewFailed(false)
return
}
const amountNum = Number(payAmount)
if (!Number.isFinite(amountNum) || amountNum <= 0) {
setMarkPaidPreview(null)
return
}
let cancelled = false
const ctrl = new AbortController()
;(async () => {
setMarkPaidPreviewFailed(false)
try {
const qs = new URLSearchParams({
amount: String(amountNum),
payment_account: paymentAccount,
})
const res = await fetch(
`/api/supplier-invoices/${invoice.id}/mark-paid/preview?${qs.toString()}`,
{ signal: ctrl.signal },
)
if (!res.ok) {
if (!cancelled) setMarkPaidPreviewFailed(true)
return
}
const data = (await res.json()) as MarkPaidPreview
if (!cancelled) setMarkPaidPreview(data)
} catch (err) {
if ((err as Error)?.name === 'AbortError') return
if (!cancelled) setMarkPaidPreviewFailed(true)
}
})()
return () => {
cancelled = true
ctrl.abort()
}
}, [isPayDialogOpen, invoice, payAmount, paymentAccount])
// Load chart of accounts and remember the last picked payment account so the
// dialog defaults to the user's previous choice instead of re-defaulting to
// 1930 every time.
useEffect(() => {
let cancelled = false
;(async () => {
const [accountsRes, settingsRes] = await Promise.all([
fetch('/api/bookkeeping/accounts'),
fetch('/api/settings'),
])
if (cancelled) return
if (accountsRes.ok) {
const { data } = await accountsRes.json()
if (Array.isArray(data)) setAccounts(data as BASAccount[])
}
if (settingsRes.ok) {
const { data } = await settingsRes.json()
const last = (data as { last_supplier_payment_account?: string | null } | null)?.last_supplier_payment_account
if (last) setPaymentAccount(last)
}
})()
return () => {
cancelled = true
}
}, [])
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
@@ -106,10 +279,33 @@ export default function SupplierInvoiceDetailPage() {
async function handleMarkPaid(force: boolean = false) {
setIsProcessing(true)
// When the user has edited the booking rows in this session, forward
// them so the server validates balance and posts via createJournalEntry
// directly. Otherwise the server picks the default routing (clearing
// or cash) based on the SI's booking state.
const linesPayload =
isEditingLines && editValidation.isValid
? editLines.map((l) => {
const amount = round2(parseAmount(l.amount))
return {
account_number: l.account_number.trim(),
debit_amount: l.side === 'debit' ? amount : 0,
credit_amount: l.side === 'credit' ? amount : 0,
line_description: l.description?.trim() || undefined,
}
})
: undefined
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate, ...(force ? { force: true } : {}) }),
body: JSON.stringify({
amount: parseFloat(payAmount),
payment_date: paymentDate,
payment_account: paymentAccount,
...(force ? { force: true } : {}),
...(linesPayload ? { lines: linesPayload } : {}),
}),
})
const result = await res.json()
if (!res.ok) {
@@ -631,11 +827,171 @@ export default function SupplierInvoiceDetailPage() {
{t('remaining_to_pay', { amount: formatAmount(invoice.remaining_amount), currency: invoice.currency })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="payment-account">Betalkonto</Label>
<AccountCombobox
value={paymentAccount}
accounts={accounts}
onChange={setPaymentAccount}
/>
<p className="text-xs text-muted-foreground">
T.ex. 1930 bankkonto, 1940 övrigt bankkonto, 2018 egna uttag (EF), 2893 ägarlån (AB).
</p>
</div>
{/* Bokföringspreview — visar exakt vad som kommer postas.
Redigerbar via "Redigera"-knappen så användaren kan välja
andra konton eller flytta belopp mellan debet/kredit. */}
{(markPaidPreview || markPaidPreviewFailed) && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Bokföring</p>
{markPaidPreview && (
<div className="flex gap-2">
{isEditingLines && (
<Button variant="ghost" size="sm" onClick={resetEditLines} disabled={isProcessing}>
Återställ
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsEditingLines((v) => !v)}
disabled={isProcessing}
>
{isEditingLines ? 'Klart' : (
<>
<Pencil className="h-3 w-3 mr-1" />
Redigera
</>
)}
</Button>
</div>
)}
</div>
{markPaidPreviewFailed && !markPaidPreview && (
<p className="text-sm text-muted-foreground">
Kunde inte förhandsgranska bokföringen. Fortsätt eller avbryt.
</p>
)}
{markPaidPreview && !isEditingLines && (
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 gap-y-1 text-sm tabular-nums">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Konto</div>
<div />
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">Debet</div>
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">Kredit</div>
{markPaidPreview.lines.map((line, i) => (
<div key={i} className="contents">
<div className="font-medium">{line.account_number}</div>
<div className="text-muted-foreground truncate">{line.description}</div>
<div className="text-right">
{line.debit_amount > 0 ? formatCurrency(line.debit_amount, invoice.currency) : ''}
</div>
<div className="text-right">
{line.credit_amount > 0 ? formatCurrency(line.credit_amount, invoice.currency) : ''}
</div>
</div>
))}
</div>
)}
{markPaidPreview && isEditingLines && (
<div className="space-y-2">
{editLines.map((line, i) => (
<div
key={i}
className="grid grid-cols-[minmax(180px,1.6fr)_minmax(0,1fr)_140px_110px_28px] gap-2 items-center"
>
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(acc) => updateEditLine(i, { account_number: acc })}
/>
<Input
value={line.description}
onChange={(e) => updateEditLine(i, { description: e.target.value })}
placeholder="Beskrivning"
/>
<div className="inline-flex rounded-md border bg-background overflow-hidden h-9">
<button
type="button"
onClick={() => updateEditLine(i, { side: 'debit' })}
className={cn(
'flex-1 px-2 text-xs font-medium transition-colors',
line.side === 'debit' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-secondary/60',
)}
aria-pressed={line.side === 'debit'}
>
Debet
</button>
<button
type="button"
onClick={() => updateEditLine(i, { side: 'credit' })}
className={cn(
'flex-1 px-2 text-xs font-medium border-l transition-colors',
line.side === 'credit' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-secondary/60',
)}
aria-pressed={line.side === 'credit'}
>
Kredit
</button>
</div>
<Input
inputMode="decimal"
value={line.amount}
onChange={(e) => updateEditLine(i, { amount: e.target.value })}
className="text-right tabular-nums"
placeholder="0"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeEditLine(i)}
disabled={editLines.length <= 2}
aria-label="Ta bort rad"
className="h-8 w-8"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center justify-between pt-1">
<Button variant="ghost" size="sm" onClick={addEditLine}>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
<div className="text-xs tabular-nums text-muted-foreground">
Debet {formatCurrency(editValidation.totalDebit, invoice.currency)}
{' / '}
Kredit {formatCurrency(editValidation.totalCredit, invoice.currency)}
</div>
</div>
{!editValidation.isBalanced && (
<p className="text-xs text-destructive">
Debet och kredit måste vara lika och större än noll. Differens:{' '}
{formatCurrency(Math.abs(editValidation.diff), invoice.currency)}
</p>
)}
{editValidation.accountInvalid && (
<p className="text-xs text-destructive">Kontonummer måste vara 4 siffror.</p>
)}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
{t('cancel')}
</Button>
<Button onClick={() => handleMarkPaid(false)} disabled={isProcessing}>
<Button
onClick={() => handleMarkPaid(false)}
disabled={isProcessing || (isEditingLines && !editValidation.isValid)}
>
{isProcessing ? t('processing') : t('register_payment')}
</Button>
</div>
+42 -119
View File
@@ -25,6 +25,8 @@ import TransactionForm from '@/components/transactions/TransactionForm'
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
import BankSyncSinceLastVisit from '@/components/transactions/BankSyncSinceLastVisit'
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
@@ -434,8 +436,8 @@ export default function TransactionsPage() {
if (cancelled) return
if (entityRes?.entity_type) {
setEntityType(entityRes.entity_type)
if (entityRes?.data?.entity_type) {
setEntityType(entityRes.data.entity_type)
}
}
@@ -855,7 +857,16 @@ export default function TransactionsPage() {
}
}
async function handleConfirmInvoiceMatch(opts?: { force?: boolean; expected_journal_entry_id?: string }) {
async function handleConfirmInvoiceMatch(opts?: {
force?: boolean
expected_journal_entry_id?: string
lines?: Array<{
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}>
}) {
if (!selectedTransaction) return
const isSupplier = !!selectedTransaction.potential_supplier_invoice
const isCustomer = !!selectedTransaction.potential_invoice
@@ -880,6 +891,12 @@ export default function TransactionsPage() {
body.expected_journal_entry_id = opts.expected_journal_entry_id
}
}
// User-edited journal entry rows from the match dialog. Forwarded
// verbatim; the server validates balance and posts via
// createJournalEntry directly. Default routing applies when omitted.
if (opts?.lines && opts.lines.length >= 2) {
body.lines = opts.lines
}
const response = await fetch(url, {
method: 'POST',
@@ -1054,127 +1071,29 @@ export default function TransactionsPage() {
}
}
async function handleSelectInvoiceFromPicker(invoice: Invoice & { customer?: Customer }) {
function handleSelectInvoiceFromPicker(invoice: Invoice & { customer?: Customer }) {
if (!invoicePickerTransaction) return
// Don't POST directly from the picker. Route through the confirm dialog
// so the user sees the JE preview (Debet 1930 / Kredit 1510, or the cash
// variant) before the booking is created. Same UX as the auto-suggested
// path. Closes the picker and opens the match dialog with the picked
// invoice attached as potential_invoice.
const tx = invoicePickerTransaction
setIsMatchingFromPicker(true)
try {
const response = await fetch(`/api/transactions/${tx.id}/match-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: invoice.id }),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fakturamatchning misslyckades',
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
setIsMatchingFromPicker(false)
return
}
toast({
title: 'Faktura matchad',
description: `Faktura ${invoice.invoice_number ?? ''} markerad som betald`,
})
setInvoicePickerOpen(false)
setInvoicePickerTransaction(null)
setExitingIds((prev) => new Set(prev).add(tx.id))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === tx.id
? {
...t,
invoice_id: invoice.id,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
category: (result.category ?? 'income_services') as TransactionCategory,
journal_entry_id: result.journal_entry_id,
}
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(tx.id)
return next
})
setIsMatchingFromPicker(false)
}, 350)
} catch {
toast({
title: 'Matchning misslyckades',
description: t('match_failed_with_invoice'),
variant: 'destructive',
})
setIsMatchingFromPicker(false)
}
setInvoicePickerOpen(false)
setInvoicePickerTransaction(null)
setSelectedTransaction({ ...tx, potential_invoice: invoice })
setMatchDialogOpen(true)
}
async function handleSelectSupplierInvoiceFromPicker(invoice: SupplierInvoice & { supplier?: Supplier }) {
function handleSelectSupplierInvoiceFromPicker(invoice: SupplierInvoice & { supplier?: Supplier }) {
if (!supplierInvoicePickerTransaction) return
// Route through the confirm dialog so the supplier-side JE preview
// (Debet 2440 / Kredit 1930, or kontant-variant) is shown before commit.
const tx = supplierInvoicePickerTransaction
setIsMatchingSupplierFromPicker(true)
try {
const response = await fetch(`/api/transactions/${tx.id}/match-supplier-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_invoice_id: invoice.id }),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Matchning misslyckades',
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
setIsMatchingSupplierFromPicker(false)
return
}
toast({
title: 'Leverantörsfaktura matchad',
description: `Faktura ${invoice.supplier_invoice_number ?? ''} markerad som betald`,
})
setSupplierInvoicePickerOpen(false)
setSupplierInvoicePickerTransaction(null)
setExitingIds((prev) => new Set(prev).add(tx.id))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === tx.id
? {
...t,
supplier_invoice_id: invoice.id,
is_business: true,
journal_entry_id: result.journal_entry_id ?? t.journal_entry_id,
}
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(tx.id)
return next
})
setIsMatchingSupplierFromPicker(false)
}, 350)
} catch {
toast({
title: 'Matchning misslyckades',
description: 'Transaktionen kunde inte matchas med leverantörsfakturan. Försök igen.',
variant: 'destructive',
})
setIsMatchingSupplierFromPicker(false)
}
setSupplierInvoicePickerOpen(false)
setSupplierInvoicePickerTransaction(null)
setSelectedTransaction({ ...tx, potential_supplier_invoice: invoice })
setMatchDialogOpen(true)
}
function openInvoiceMatchPicker(transaction: TransactionWithInvoice) {
@@ -1666,7 +1585,11 @@ export default function TransactionsPage() {
onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))}
/>
<BankSyncStatusChip />
<div className="flex flex-wrap items-center gap-2">
<BankSyncStatusChip />
<BankSyncNowButton />
</div>
<BankSyncSinceLastVisit />
{/* Search + view dropdown */}
<div className="flex items-center gap-2">
@@ -0,0 +1,252 @@
/**
* GET /api/bookkeeping/fix-cash-mismatch → list affected payments
* POST /api/bookkeeping/fix-cash-mismatch → remediate one payment (or all)
*
* Targeted fix for the cash/clearing routing bug. The old matcher chose its
* journal entry shape from the company's current accounting_method instead
* of from invoice.journal_entry_id, so customers who sent invoices under
* accrual (Dr 1510 / Cr 30xx + 26xx on send) and then matched a bank
* receipt after the company had flipped to kontantmetoden ended up with:
* - 1510 Kundfordran NEVER credited (orphan receivable on the books)
* - 30xx Försäljning AND 26xx Utgående moms double-counted
* - momsdeklaration would over-report output VAT
*
* Detection: any invoice_payments row whose payment journal entry has
* source_type='invoice_cash_payment' while the underlying invoice carries
* its own (still-active) accrual JE.
*
* Remediation per affected payment:
* 1. reverseEntry(payment_je) — storno cancels Dr 1930 / Cr 30xx / Cr 26xx
* 2. createInvoicePaymentJournalEntry — posts the correct Dr 1930 / Cr 1510
* 3. Re-link invoice_payments + transactions to the new JE
*
* Net effect on the books: 30xx and 26xx are restored to their correct
* (single-count) amounts, 1510 is cleared, 1930 nets to a single debit,
* invoice keeps status='paid', transaction keeps invoice_id linkage.
*/
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types'
ensureInitialized()
type AffectedPayment = {
payment_id: string
payment_journal_entry_id: string
invoice_id: string
invoice_number: string | null
counterparty_name: string | null
amount: number
payment_date: string
transaction_id: string | null
invoice_journal_entry_id: string
}
async function findAffected(
supabase: import('@supabase/supabase-js').SupabaseClient,
companyId: string,
): Promise<AffectedPayment[]> {
// 1. Find payment JEs that took the (now-wrong) cash path.
const { data: cashPaymentEntries, error: jeErr } = await supabase
.from('journal_entries')
.select('id, source_id, status')
.eq('company_id', companyId)
.eq('source_type', 'invoice_cash_payment')
.eq('status', 'posted')
if (jeErr) throw jeErr
if (!cashPaymentEntries || cashPaymentEntries.length === 0) return []
// 2. For each, the source_id is the invoice; affected iff that invoice
// ALSO has its own journal_entry_id (i.e. 1510 was booked on send).
const invoiceIds = Array.from(new Set(cashPaymentEntries.map((e) => e.source_id).filter(Boolean)))
if (invoiceIds.length === 0) return []
const { data: invoices, error: invErr } = await supabase
.from('invoices')
.select('id, invoice_number, journal_entry_id, customer:customers(name)')
.eq('company_id', companyId)
.in('id', invoiceIds)
.not('journal_entry_id', 'is', null)
if (invErr) throw invErr
const invoiceMap = new Map(
(invoices ?? []).map((i) => [
i.id as string,
{
invoice_number: (i.invoice_number as string | null) ?? null,
invoice_journal_entry_id: i.journal_entry_id as string,
counterparty_name: ((i.customer as { name?: string | null } | null)?.name) ?? null,
},
]),
)
// 3. Pull the invoice_payments rows so we can show + later re-link.
const affectedJeIds = cashPaymentEntries
.filter((e) => invoiceMap.has(e.source_id as string))
.map((e) => e.id as string)
if (affectedJeIds.length === 0) return []
const { data: payments, error: payErr } = await supabase
.from('invoice_payments')
.select('id, invoice_id, journal_entry_id, amount, payment_date, transaction_id')
.eq('company_id', companyId)
.in('journal_entry_id', affectedJeIds)
if (payErr) throw payErr
return (payments ?? []).map((p) => {
const inv = invoiceMap.get(p.invoice_id as string)!
return {
payment_id: p.id as string,
payment_journal_entry_id: p.journal_entry_id as string,
invoice_id: p.invoice_id as string,
invoice_number: inv.invoice_number,
counterparty_name: inv.counterparty_name,
amount: p.amount as number,
payment_date: p.payment_date as string,
transaction_id: (p.transaction_id as string | null) ?? null,
invoice_journal_entry_id: inv.invoice_journal_entry_id,
}
})
}
export const GET = withRouteContext(
'bookkeeping.fix_cash_mismatch.list',
async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
try {
const affected = await findAffected(supabase, companyId!)
return NextResponse.json({ affected })
} catch (err) {
log.error('failed to detect cash-mismatch payments', err as Error)
return errorResponse(err, log, { requestId })
}
},
)
const PostSchema = z.object({
// Either a single payment to fix, or omit to fix all currently detected.
payment_id: z.string().uuid().optional(),
})
export const POST = withRouteContext(
'bookkeeping.fix_cash_mismatch.apply',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
let body: unknown
try {
body = await request.json()
} catch {
body = {}
}
const parsed = PostSchema.safeParse(body)
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, { requestId })
}
const { payment_id } = parsed.data
let targets: AffectedPayment[]
try {
const all = await findAffected(supabase, companyId!)
targets = payment_id ? all.filter((p) => p.payment_id === payment_id) : all
} catch (err) {
log.error('failed to detect targets', err as Error)
return errorResponse(err, log, { requestId })
}
if (targets.length === 0) {
return NextResponse.json({ fixed: 0, results: [] })
}
const results: Array<{
payment_id: string
ok: boolean
old_journal_entry_id: string
storno_journal_entry_id?: string
new_journal_entry_id?: string
error?: string
}> = []
for (const t of targets) {
try {
// Storno the wrong cash entry. This reverses Dr 1930 / Cr 30xx / Cr
// 26xx by posting the mirror, restoring revenue + VAT to their pre-
// match (correctly-counted-once) state.
const storno = await reverseEntry(supabase, companyId!, user.id, t.payment_journal_entry_id)
// Re-fetch the invoice so we have currency / exchange rate metadata
// for the clearing entry. Customer name is best-effort.
const { data: inv, error: invErr } = await supabase
.from('invoices')
.select('*, customer:customers(name)')
.eq('id', t.invoice_id)
.eq('company_id', companyId)
.single()
if (invErr || !inv) throw invErr ?? new Error('invoice missing')
const clearing = await createInvoicePaymentJournalEntry(
supabase,
companyId!,
user.id,
inv as Invoice,
t.payment_date,
undefined,
(inv.customer as { name?: string } | null)?.name ?? t.counterparty_name ?? undefined,
t.amount,
)
if (!clearing) throw new Error('clearing entry creation returned null')
// Re-link the invoice_payments row to the new (correct) JE.
const { error: relinkPayErr } = await supabase
.from('invoice_payments')
.update({ journal_entry_id: clearing.id })
.eq('id', t.payment_id)
.eq('company_id', companyId)
if (relinkPayErr) throw relinkPayErr
// Re-link the transaction too, so /transactions reflects the correct
// voucher when the user clicks through.
if (t.transaction_id) {
const { error: relinkTxErr } = await supabase
.from('transactions')
.update({ journal_entry_id: clearing.id })
.eq('id', t.transaction_id)
.eq('company_id', companyId)
if (relinkTxErr) {
log.warn('failed to relink transaction; voucher chain still correct via payment row', {
transactionId: t.transaction_id,
error: relinkTxErr.message,
})
}
}
results.push({
payment_id: t.payment_id,
ok: true,
old_journal_entry_id: t.payment_journal_entry_id,
storno_journal_entry_id: storno.id,
new_journal_entry_id: clearing.id,
})
} catch (err) {
log.error('remediation failed for payment', err as Error, { paymentId: t.payment_id })
results.push({
payment_id: t.payment_id,
ok: false,
old_journal_entry_id: t.payment_journal_entry_id,
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
return NextResponse.json({
fixed: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
})
},
{ requireWrite: true },
)
@@ -6,6 +6,7 @@ import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { createLogger } from '@/lib/logger'
import { syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
const logger = createLogger('journal-entries')
@@ -56,6 +57,18 @@ export async function DELETE(
const companyId = await requireCompanyId(supabase, user.id)
// Read source_type/source_id BEFORE deleting so we can revert the linked
// invoice/supplier_invoice status afterwards. The GL row gets cancelled by
// delete_last_voucher but the invoice's paid status lives outside the GL
// and would otherwise stay stuck on "paid" after the user deletes the
// payment voucher.
const { data: entryBefore } = await supabase
.from('journal_entries')
.select('id, source_type, source_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
const { data, error } = await supabase.rpc('delete_last_voucher', {
p_company_id: companyId,
p_entry_id: id,
@@ -69,6 +82,14 @@ export async function DELETE(
)
}
if (entryBefore) {
try {
await syncInvoiceStatusFromPaymentEntry(supabase, companyId, entryBefore)
} catch (syncError) {
logger.warn('payment status sync failed after delete', { entryId: id, error: syncError })
}
}
await eventBus.emit({
type: 'journal_entry.deleted',
payload: {
+17 -8
View File
@@ -133,6 +133,15 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Drive the JE shape from the invoice's actual booking state, not from
// the current accounting_method setting. If the invoice was booked at
// send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510 —
// otherwise the receivable orphans and 30xx + VAT double-count. Only
// when there is no prior JE (pure kontantmetoden) do we recognise
// revenue + VAT here.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
@@ -155,7 +164,7 @@ export const POST = withRouteContext(
details: { paymentDate },
})
}
const sourceType = accountingMethod === 'accrual' ? 'invoice_paid' : 'invoice_cash_payment'
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
@@ -168,18 +177,18 @@ export const POST = withRouteContext(
}
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, input)
journalEntryId = journalEntry?.id ?? null
} else if (accountingMethod === 'accrual') {
const journalEntry = await createInvoicePaymentJournalEntry(
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
exchangeRateDifference, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
} else {
} else if (useCashEntry) {
const journalEntry = await createInvoiceCashEntry(
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
entityType, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
} else {
const journalEntry = await createInvoicePaymentJournalEntry(
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
exchangeRateDifference, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
if (isBookkeepingError(err)) {
@@ -5,6 +5,7 @@ import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { validateBody } from '@/lib/api/validate'
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -35,7 +36,20 @@ export async function GET(
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
}
return NextResponse.json({ data })
// Strip the encrypted personnummer ciphertext before sending to the browser
// — replace it with the YYYYMMDD-XXXX masked form so the page can render
// identity without exposing the suffix or the raw cipher blob.
const masked = {
...data,
employee: data.employee
? {
...data.employee,
personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)),
}
: data.employee,
}
return NextResponse.json({ data: masked })
}
/**
@@ -0,0 +1,141 @@
/**
* GET /api/supplier-invoices/[id]/mark-paid/preview?amount=...&payment_account=...
*
* Read-only preview of the journal entry mark-paid would post. Mirrors the
* POST handler's routing: if the SI has a registration JE, payment clears
* 2440. Otherwise (kontantmetoden + never booked), expense + input VAT
* book here.
*/
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
type PreviewLine = {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
const QuerySchema = z.object({
amount: z.coerce.number().positive(),
payment_account: z.string().min(1).optional(),
})
export const GET = withRouteContext(
'supplier_invoice.mark_paid_preview',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const parsed = QuerySchema.safeParse({
amount: url.searchParams.get('amount'),
payment_account: url.searchParams.get('payment_account') ?? undefined,
})
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, { requestId })
}
const { amount, payment_account } = parsed.data
const { data: invoice, error: invErr } = await supabase
.from('supplier_invoices')
.select('*, items:supplier_invoice_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) {
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, last_supplier_payment_account')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const creditAccount =
payment_account ||
(settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account ||
'1930'
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
const lines: PreviewLine[] = []
let entryType: 'clearing' | 'cash' = 'clearing'
if (useCashEntry) {
entryType = 'cash'
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
const items = si.items ?? []
let totalAmountSek = 0
let totalVatSek = 0
if (items.length > 0) {
for (const it of items) {
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
lines.push({
account_number: expenseAcct,
debit_amount: Math.round((lineTotal - vat) * 100) / 100,
credit_amount: 0,
description: it.description ?? 'Kostnad',
})
totalAmountSek += lineTotal
totalVatSek += vat
}
} else {
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
lines.push({
account_number: '4000',
debit_amount: Math.round(subSek * 100) / 100,
credit_amount: 0,
description: 'Kostnad',
})
totalAmountSek = subSek + vatSek
totalVatSek = vatSek
}
if (totalVatSek > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(totalVatSek * 100) / 100,
credit_amount: 0,
description: 'Ingående moms',
})
}
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round(totalAmountSek * 100) / 100,
description: 'Utbetalning',
})
} else {
const rounded = Math.round(amount * 100) / 100
lines.push({
account_number: '2440',
debit_amount: rounded,
credit_amount: 0,
description: 'Kvittning leverantörsskuld',
})
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: rounded,
description: 'Utbetalning',
})
}
return NextResponse.json({
entry_type: entryType,
lines,
invoice_already_booked: siAlreadyBooked,
accounting_method: accountingMethod,
})
},
)
@@ -5,6 +5,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
@@ -124,16 +125,54 @@ export const POST = withRouteContext(
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.select('accounting_method, last_supplier_payment_account')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const paymentAccount = body.payment_account || undefined
// Route on the supplier invoice's actual booking state, not the current
// accounting_method. A supplier invoice that was booked at receipt under
// accrual (Dr expense + 2641 / Cr 2440) must clear 2440 here even if the
// company has since switched to kontantmetoden — otherwise the supplier
// debt orphans on 2440 and expense + input VAT double-count.
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
if (body.lines) {
const totalDebit = body.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = body.lines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', opLog, {
requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, paymentDate)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', opLog, {
requestId,
details: { paymentDate },
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
const desc = invoice.supplier?.name
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
const je = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: body.lines,
})
if (je) journalEntryId = je.id
} else if (useCashEntry) {
const journalEntry = await createSupplierInvoiceCashEntry(
supabase, companyId!, user.id,
invoice as SupplierInvoice,
@@ -141,6 +180,7 @@ export const POST = withRouteContext(
paymentDate,
invoice.supplier?.supplier_type || 'swedish_business',
invoice.supplier?.name,
paymentAccount,
)
if (journalEntry) journalEntryId = journalEntry.id
} else {
@@ -150,6 +190,7 @@ export const POST = withRouteContext(
paymentAmount, paymentDate,
body.exchange_rate_difference,
invoice.supplier?.name,
paymentAccount,
)
if (journalEntry) journalEntryId = journalEntry.id
}
@@ -247,6 +288,19 @@ export const POST = withRouteContext(
opLog.warn('supplier_invoice.paid event emission failed', err as Error)
}
// Remember the chosen payment account so the next dialog can default to it.
// Only update when the caller actually picked one — the MCP / agent path
// sends no payment_account and shouldn't churn this setting.
if (paymentAccount && paymentAccount !== settings?.last_supplier_payment_account) {
const { error: settingsError } = await supabase
.from('company_settings')
.update({ last_supplier_payment_account: paymentAccount })
.eq('company_id', companyId)
if (settingsError) {
opLog.warn('failed to persist last_supplier_payment_account', settingsError)
}
}
return NextResponse.json({
success: true,
status: newStatus,
+105
View File
@@ -0,0 +1,105 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* POST /api/transactions/[id]/ignore
*
* Mark a bank transaction as ignored so it stops surfacing in the bank
* reconciliation view (and other "to book" funnels) without creating a
* verifikation. Use case: tiny ränteintäkter, rounding noise, opening-balance
* artefacts — anything the user wants off the unmatched list but doesn't want
* to fabricate a journal entry for.
*
* Refuses when the transaction is already booked; once a verifikation exists,
* the proper way to revisit it is /uncategorize (storno).
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaktionen är redan bokförd — använd Avmatcha eller backa verifikationen för att ändra status.' },
{ status: 409 }
)
}
if (transaction.is_ignored) {
return NextResponse.json({ success: true, already_ignored: true })
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: true })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
/**
* DELETE /api/transactions/[id]/ignore
*
* Reverse a previous ignore. The row comes back into the unmatched list with
* no further side effects — we never created a verifikation, so there's
* nothing to storno.
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: false })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
@@ -393,6 +393,56 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(body.remaining_amount).toBe(7500)
})
it('cash method ignores cash entry when invoice was already booked (accrual→cash migration)', async () => {
// Regression: customer sent invoices under accrual (1510 was debited on
// send), then switched to kontantmetoden before the bank receipt arrived.
// Old logic posted createInvoiceCashEntry — orphaning 1510 and double-
// counting revenue + VAT. Fix: route on invoice.journal_entry_id, not on
// the current accounting_method setting.
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
const invoice = {
...makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
paid_amount: 0,
}),
// journal_entry_id lives on the DB column but not the TS Invoice type;
// attach via spread so the test row mirrors a real accrual-booked
// invoice the matcher will read.
journal_entry_id: 'je-send-on-accrual',
}
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: [], error: null }) // hard-duplicate check
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' })
// The PDF re-attach block runs because invoice.journal_entry_id is set;
// returning null skips the attach without aborting the match.
enqueue({ data: null, error: null }) // document_attachments lookup
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: null, error: null }) // logMatchEvent
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ invoice_status: string }>(response)
expect(status).toBe(200)
expect(body.invoice_status).toBe('paid')
// Must clear 1510, not re-recognise revenue + VAT
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
})
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when tx amount exceeds invoice remaining', async () => {
// Tx is +12 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
// would push paid_amount past invoice.total; the new guard rejects so
@@ -0,0 +1,186 @@
/**
* GET /api/transactions/[id]/match-invoice/preview?invoice_id=...
*
* Returns the journal entry lines that match-invoice would create for this
* (transaction, invoice) pair. Read-only does not stage or write anything.
*
* The shape mirrors the routing decision in the POST handler: if the invoice
* was already booked (invoice.journal_entry_id is set, i.e. 1510 is on the
* books), we preview the clearing entry (Dr 1930 / Cr 1510). Only when the
* invoice was never booked AND the company is on kontantmetoden AND the
* receipt fully pays the invoice do we preview the cash entry (Dr 1930 /
* Cr 30xx / Cr 26xx).
*
* The UI uses this to show the user the exact lines before they confirm
* the lack of any preview was part of the reported bug.
*/
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
import type { EntityType, Invoice, InvoiceItem } from '@/types'
import { z } from 'zod'
type PreviewLine = {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
const QuerySchema = z.object({
invoice_id: z.string().uuid(),
})
export const GET = withRouteContext(
'transaction.match_invoice_preview',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const parsed = QuerySchema.safeParse({ invoice_id: url.searchParams.get('invoice_id') })
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'invoice_id', message: 'invoice_id must be a UUID' },
})
}
const { invoice_id } = parsed.data
const { data: transaction, error: txErr } = await supabase
.from('transactions')
.select('id, date, amount, currency')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txErr || !transaction) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
}
const { data: invoice, error: invErr } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', invoice_id)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) {
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const paidAmount = transaction.amount
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
const newRemaining = Math.max(
0,
Math.round((currentRemaining - paidAmount) * 100) / 100,
)
const isFullyPaid = newRemaining <= 0
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
const lines: PreviewLine[] = []
let entryType: 'clearing' | 'cash' = 'clearing'
if (useCashEntry) {
entryType = 'cash'
// Mirror createInvoiceCashEntry: per-rate revenue + VAT credits, 1930 debit.
const inv = invoice as Invoice & { items?: InvoiceItem[] }
const items = inv.items ?? []
const isForeign = inv.currency !== 'SEK'
// Per-item rate aggregation (matches generatePerRateLines semantics).
// InvoiceItem.line_total is the gross-net-line; the subtotal contribution
// is line_total minus that line's vat_amount.
const byRate = new Map<number, { subtotal: number; vat: number }>()
if (items.length > 0) {
for (const it of items) {
const rate = it.vat_rate ?? 25
const itemVat = resolveSekAmount(it.vat_amount, null, inv.currency, inv.exchange_rate)
const itemTotal = resolveSekAmount(it.line_total, null, inv.currency, inv.exchange_rate)
const sub = Math.round((itemTotal - itemVat) * 100) / 100
const bucket = byRate.get(rate) ?? { subtotal: 0, vat: 0 }
bucket.subtotal += sub
bucket.vat += itemVat
byRate.set(rate, bucket)
}
} else {
// Fallback to invoice-level totals
const sub = resolveSekAmount(inv.subtotal, inv.subtotal_sek, inv.currency, inv.exchange_rate)
const vat = resolveSekAmount(inv.vat_amount, inv.vat_amount_sek, inv.currency, inv.exchange_rate)
byRate.set(inv.vat_rate ?? 25, { subtotal: sub, vat })
}
const creditLines: PreviewLine[] = []
for (const [rate, totals] of byRate) {
const vatTreatment = totals.vat > 0
? (rate === 25 ? 'standard_25' : rate === 12 ? 'reduced_12' : rate === 6 ? 'reduced_6' : inv.vat_treatment)
: inv.vat_treatment
const revenueAcct = getRevenueAccount(vatTreatment, entityType)
creditLines.push({
account_number: revenueAcct,
debit_amount: 0,
credit_amount: Math.round(totals.subtotal * 100) / 100,
description: `Försäljning ${rate}%`,
})
if (totals.vat > 0) {
creditLines.push({
account_number: getOutputVatAccount(vatTreatment),
debit_amount: 0,
credit_amount: Math.round(totals.vat * 100) / 100,
description: `Utgående moms ${rate}%`,
})
}
}
const totalCredits = creditLines.reduce((s, l) => s + l.credit_amount, 0)
const cashDebit = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(inv.total, inv.total_sek, inv.currency, inv.exchange_rate)
lines.push({
account_number: '1930',
debit_amount: Math.round(cashDebit * 100) / 100,
credit_amount: 0,
description: 'Inbetalning från bank',
})
lines.push(...creditLines)
} else {
// Clearing entry: Dr 1930 / Cr 1510 at the paid amount in SEK.
const inv = invoice as Invoice
const bookedSek = resolveSekAmount(paidAmount, null, inv.currency, inv.exchange_rate)
const amount = Math.round(bookedSek * 100) / 100
lines.push({
account_number: '1930',
debit_amount: amount,
credit_amount: 0,
description: 'Inbetalning från bank',
})
lines.push({
account_number: '1510',
debit_amount: 0,
credit_amount: amount,
description: 'Kvittning kundfordran',
})
}
return NextResponse.json({
entry_type: entryType,
lines,
invoice_already_booked: invoiceAlreadyBooked,
accounting_method: accountingMethod,
is_fully_paid: isFullyPaid,
})
},
)
@@ -3,7 +3,7 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -41,7 +41,7 @@ export const POST = withRouteContext(
operation: 'transaction.match_invoice',
})
if (!validation.success) return validation.response
const { invoice_id, force, expected_journal_entry_id } = validation.data
const { invoice_id, force, expected_journal_entry_id, lines: customLines } = validation.data
const txLog = log.child({ transactionId, invoiceId: invoice_id })
@@ -251,21 +251,66 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Drive the JE shape from the INVOICE'S booking state, not from the
// company's current accounting_method setting. If the invoice was already
// booked at send (Dr 1510 / Cr 30xx + VAT) we MUST clear 1510 here —
// otherwise the receivable stays orphaned and 30xx + VAT get double-
// counted. This happens when a company sent invoices under accrual,
// then flipped to kontantmetoden before payment arrived.
// Only when the invoice carries no prior JE (pure kontantmetoden, no
// receivable on the books) do we recognise revenue + VAT here.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
if (accountingMethod === 'cash' && isFullyPaid) {
if (customLines) {
// User-edited rows from the match dialog. Validate balance, then
// post via createJournalEntry directly. source_type still derives
// from the routing decision so downstream payment-sync (which keys
// off invoice_paid / invoice_cash_payment) keeps working.
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId,
details: { paymentDate: transaction.date },
})
}
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
const desc = invoice.customer?.name
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
: `Inbetalning kundfaktura ${invoice.invoice_number}`
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
journalEntryId = journalEntry?.id ?? null
} else if (useCashEntry) {
const journalEntry = await createInvoiceCashEntry(
supabase, companyId, user.id, invoice as Invoice, transaction.date,
entityType, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
} else {
// Accrual or cash partial: clearing entry against 1510. The cash-method
// partial path is intentional — under kontantmetoden 1510 has no prior
// balance, so this leaves a credit on 1510 that gets resolved when the
// final payment lands and createInvoiceCashEntry runs.
// Clearing entry against 1510. Covers accrual, cash-with-prior-JE
// (mid-stream switch), and cash partial. The cash partial path is
// intentional — under kontantmetoden 1510 has no prior balance, so
// partials leave a credit on 1510 that gets resolved on final
// payment when createInvoiceCashEntry would normally run.
const journalEntry = await createInvoicePaymentJournalEntry(
supabase, companyId, user.id, invoice as Invoice, transaction.date,
undefined, invoice.customer?.name, paidAmount,
@@ -356,7 +401,11 @@ export const POST = withRouteContext(
return errorResponseFromCode('MATCH_INVOICE_ALREADY_PAID', txLog, { requestId })
}
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
// The "intäkt bokförs vid slutbetalning" note only applies to genuine
// kontantmetoden partials — invoices that were never booked. When the
// invoice was booked under accrual, the clearing entry already handles
// the partial cleanly and the note would be misleading.
const paymentNotes = (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid)
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
: null
@@ -0,0 +1,167 @@
/**
* GET /api/transactions/[id]/match-supplier-invoice/preview?supplier_invoice_id=...
*
* Read-only preview of the journal entry lines that match-supplier-invoice
* would create. Mirrors the routing decision in the POST handler: if the
* supplier invoice already has a registration JE (2440 posted at receipt),
* payment clears 2440. Only true kontantmetoden SIs (no registration JE)
* book expense + input VAT here.
*/
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
type PreviewLine = {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
const QuerySchema = z.object({
supplier_invoice_id: z.string().uuid(),
})
export const GET = withRouteContext(
'transaction.match_supplier_invoice_preview',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const parsed = QuerySchema.safeParse({
supplier_invoice_id: url.searchParams.get('supplier_invoice_id'),
})
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'supplier_invoice_id', message: 'supplier_invoice_id must be a UUID' },
})
}
const { supplier_invoice_id } = parsed.data
const { data: transaction, error: txErr } = await supabase
.from('transactions')
.select('id, date, amount, currency')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txErr || !transaction) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
}
const { data: invoice, error: invErr } = await supabase
.from('supplier_invoices')
.select('*, items:supplier_invoice_items(*)')
.eq('id', supplier_invoice_id)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) {
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, last_supplier_payment_account')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const paymentAccount =
(settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account || '1930'
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
const lines: PreviewLine[] = []
let entryType: 'clearing' | 'cash' = 'clearing'
if (useCashEntry) {
entryType = 'cash'
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
const items = si.items ?? []
// Mirror createSupplierInvoiceCashEntry: per-item expense debit + VAT
// debit + bank credit. We only need a faithful preview, not exact
// account-mapping fidelity — show one aggregate expense line per item
// (or a single fallback line if items are missing).
let totalAmountSek = 0
let totalVatSek = 0
if (items.length > 0) {
for (const it of items) {
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
lines.push({
account_number: expenseAcct,
debit_amount: Math.round((lineTotal - vat) * 100) / 100,
credit_amount: 0,
description: it.description ?? 'Kostnad',
})
totalAmountSek += lineTotal
totalVatSek += vat
}
} else {
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
lines.push({
account_number: '4000',
debit_amount: Math.round(subSek * 100) / 100,
credit_amount: 0,
description: 'Kostnad',
})
totalAmountSek = subSek + vatSek
totalVatSek = vatSek
}
if (totalVatSek > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(totalVatSek * 100) / 100,
credit_amount: 0,
description: 'Ingående moms',
})
}
lines.push({
account_number: paymentAccount,
debit_amount: 0,
credit_amount: Math.round(totalAmountSek * 100) / 100,
description: 'Utbetalning från bank',
})
} else {
// Clearing: Dr 2440 / Cr 1930 (or chosen payment account).
const si = invoice as SupplierInvoice
const amountSek = resolveSekAmount(
Math.abs(transaction.amount),
null,
transaction.currency,
null,
)
const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate)
const amount = Math.round(Math.min(amountSek, total) * 100) / 100
lines.push({
account_number: '2440',
debit_amount: amount,
credit_amount: 0,
description: 'Kvittning leverantörsskuld',
})
lines.push({
account_number: paymentAccount,
debit_amount: 0,
credit_amount: amount,
description: 'Utbetalning från bank',
})
}
return NextResponse.json({
entry_type: entryType,
lines,
invoice_already_booked: siAlreadyBooked,
accounting_method: accountingMethod,
})
},
)
@@ -3,6 +3,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -32,7 +33,7 @@ export const POST = withRouteContext(
operation: 'transaction.match_supplier_invoice',
})
if (!validation.success) return validation.response
const { supplier_invoice_id } = validation.data
const { supplier_invoice_id, lines: customLines } = validation.data
const txLog = log.child({ transactionId, supplierInvoiceId: supplier_invoice_id })
@@ -163,13 +164,22 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
// Route on the supplier invoice's actual booking state — if 2440 was
// posted at receipt (accrual), the match must clear 2440 regardless of
// the company's current setting. Only true kontantmetoden invoices
// (no registration JE) book expense + input VAT here.
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
// Cash method (kontantmetoden) collapses registration + payment into a
// single entry that credits 1930 at sum(expenses_SEK). It has no
// exchange_rate_difference path — if the actual bank SEK differs from
// the invoice's booked SEK, the 1930 credit won't match the bank
// transaction and we'd silently leave a reconciliation gap. Block the
// combination and ask the user to switch to accrual or do a manual JE.
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
// Only applies to true cash-method invoices — accrual-booked invoices
// never hit the cash branch.
if (useCashEntry && exchangeRateDifference !== 0) {
return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId,
details: {
@@ -184,7 +194,36 @@ export const POST = withRouteContext(
let journalEntryError: string | null = null
try {
if (accountingMethod === 'cash') {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId,
details: { paymentDate: transaction.date },
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
const desc = invoice.supplier?.name
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
if (journalEntry) journalEntryId = journalEntry.id
} else if (useCashEntry) {
const journalEntry = await createSupplierInvoiceCashEntry(
supabase, companyId, user.id, invoice as SupplierInvoice,
(invoice.items || []) as SupplierInvoiceItem[],
+29 -2
View File
@@ -20,20 +20,47 @@ export async function GET(request: Request) {
const currency = searchParams.get('currency') || undefined
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
// When set, return only ignored rows — used by the reconciliation view to
// surface a "Visa ignorerade" undo list. The default (no param) behaviour
// continues to exclude ignored rows from unmatched results.
const onlyIgnored = searchParams.get('only_ignored') === 'true'
// account_number is accepted for API symmetry with the reconciliation status
// endpoint; transactions don't carry a cash_account FK today (PSD2 account
// identity is embedded in external_id), so we use it to derive a default
// currency when the caller didn't supply one. Anything more precise needs
// the cash_account_id backfill tracked as Tier 4.
const accountNumberParam = searchParams.get('account_number') || undefined
let derivedCurrency = currency
if (!derivedCurrency && accountNumberParam) {
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('currency')
.eq('company_id', companyId)
.eq('ledger_account', accountNumberParam)
.maybeSingle()
if (cashAccount?.currency) derivedCurrency = cashAccount.currency as string
}
let query = supabase
.from('transactions')
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method')
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method, is_ignored')
.eq('company_id', companyId)
// unmatched and reconciled are mutually exclusive — unmatched wins if both set
if (unmatched) {
query = query.is('journal_entry_id', null)
// Hide rows the user has explicitly suppressed from the reconciliation
// view. Other callers (e.g. BookDirectlyDialog) also benefit — once
// ignored, the row stops surfacing in the "to book" funnel everywhere.
if (!onlyIgnored) query = query.eq('is_ignored', false)
} else if (reconciled) {
query = query.not('journal_entry_id', 'is', null)
}
if (currency) query = query.eq('currency', currency)
if (onlyIgnored) query = query.eq('is_ignored', true)
if (derivedCurrency) query = query.eq('currency', derivedCurrency)
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
@@ -240,6 +240,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const entityType = ((settings as { entity_type?: string } | null)?.entity_type ??
'enskild_firma') as EntityType
// The JE shape is driven by the invoice's actual booking state, not the
// company's current accounting_method. An invoice that was booked at send
// under accrual (Dr 1510) must be cleared at payment regardless of where
// the setting sits today — otherwise the receivable orphans and 30xx +
// VAT double-count. Only true kontantmetoden invoices (never booked)
// recognise revenue + VAT here.
const invoiceAlreadyBooked = !!(typed as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
// Compute the would-be payment amount. Default path (no customLines):
// use remaining_amount, not total — protects against over-crediting AR
// when a concurrent partial payment slips through the pre-flight check
@@ -363,7 +372,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
input,
)
journalEntryId = entry?.id ?? null
} else if (accountingMethod === 'cash') {
} else if (useCashEntry) {
const entry = await createInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
@@ -26,7 +26,7 @@ import {
createSupplierInvoiceCashEntry,
createSupplierInvoicePaymentEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
@@ -117,6 +117,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
let bodyPaymentDate: string | undefined
let exchangeRateDifference: number | undefined
let bodyNotes: string | undefined
let customLines:
| Array<{ account_number: string; debit_amount: number; credit_amount: number; line_description?: string }>
| undefined
if (rawBody) {
const parsed = MarkSupplierInvoicePaidSchema.safeParse(rawBody)
if (!parsed.success) {
@@ -134,6 +137,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
bodyPaymentDate = parsed.data.payment_date
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyNotes = parsed.data.notes
customLines = parsed.data.lines
}
const today = new Date().toISOString().split('T')[0]
@@ -276,14 +280,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
.maybeSingle()
const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
// FX-required validation. Under accrual the registration JE used the
// invoice's exchange rate to compute subtotal_sek; the payment JE has to
// book any rate delta to 3960 / 7960 (BAS) or AP will carry a stranded
// 2440 balance after the bank line clears. The pitfall docs warn about
// this — enforce it.
// Route on the supplier invoice's actual booking state — if 2440 was
// posted at receipt, payment must clear 2440 regardless of the current
// accounting_method.
const siAlreadyBooked = !!(typed as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
// FX-required validation. Whenever the registration JE used the invoice's
// exchange rate to compute subtotal_sek (i.e. the SI was booked under
// accrual or migrated from accrual), the payment JE has to book any rate
// delta to 3960 / 7960 or AP will carry a stranded 2440 balance after the
// bank line clears. Gated on the booking state, not the current setting.
if (
typed.currency !== 'SEK' &&
accountingMethod === 'accrual' &&
!useCashEntry &&
exchangeRateDifference === undefined
) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
@@ -328,7 +338,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Strict-mode: book the JE FIRST. Failure aborts before any SI mutation.
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', ctx.log, {
requestId: ctx.requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, paymentDate)
if (!fiscalPeriodId) {
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', ctx.log, {
requestId: ctx.requestId,
details: { payment_date: paymentDate },
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
const desc = supplierRow?.name
? `Utbetalning leverantörsfaktura ${typed.supplier_invoice_number}, ${supplierRow.name}`
: `Utbetalning leverantörsfaktura ${typed.supplier_invoice_number}`
const entry = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: sourceType,
source_id: typed.id,
lines: customLines,
})
journalEntryId = entry?.id ?? null
} else if (useCashEntry) {
const entry = await createSupplierInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
@@ -26,7 +26,7 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -123,7 +123,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
},
})
}
const { invoice_id, force, expected_journal_entry_id } = parsed.data
const { invoice_id, force, expected_journal_entry_id, lines: customLines } = parsed.data
const txLog = ctx.log.child({ transactionId: txId, invoiceId: invoice_id })
const { data: transaction, error: fetchTxErr } = await ctx.supabase
@@ -309,15 +309,23 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const entityType: EntityType =
(settings?.entity_type as EntityType) || 'enskild_firma'
// Reject cash-method partial payments. Under kontantmetoden, utgående
// moms must be reported in the period of actual receipt (ML 13 kap 8 §);
// the partial-payment branch below uses createInvoicePaymentJournalEntry
// (the accrual-style 1510/1930 clearing entry), which doesn't model the
// per-installment moms event. Rather than silently over-report moms,
// refuse the operation and document the constraint. Full payments
// (isFullyPaid=true) flow through createInvoiceCashEntry which IS the
// correct kontantmetod path.
if (accountingMethod === 'cash' && !isFullyPaid) {
// The JE shape is driven by the INVOICE'S booking state, not the
// company's current setting. If the invoice already has a JE (Dr 1510
// posted at send), the match must clear 1510 — otherwise the receivable
// stays orphaned and 30xx + 26xx get double-counted. The current
// accounting_method only governs the cash-method fast path for
// invoices that were never booked.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
// Reject cash-method partial payments ONLY for pure kontantmetoden
// invoices (no prior JE). Under kontantmetoden utgående moms must be
// reported in the period of actual receipt (ML 13 kap 8 §); the
// partial-payment branch uses the accrual-style clearing entry which
// doesn't model the per-installment moms event. When the invoice was
// already booked under accrual, the clearing entry IS the correct
// partial path regardless of the company's current setting.
if (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', txLog, {
requestId: ctx.requestId,
details: {
@@ -340,7 +348,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// strictly worse than a clean failure to retry.
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash' && isFullyPaid) {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId: ctx.requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, transaction.date)
if (!fiscalPeriodId) {
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId: ctx.requestId,
details: { payment_date: transaction.date },
})
}
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
const desc = invoice.customer?.name
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
: `Inbetalning kundfaktura ${invoice.invoice_number}`
const je = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
journalEntryId = je?.id ?? null
} else if (useCashEntry) {
const je = await createInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
@@ -436,8 +473,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// The "intäkt bokförs vid slutbetalning" note only applies to genuine
// kontantmetoden partials — never-booked invoices. When the invoice was
// booked under accrual, the clearing entry handles the partial cleanly
// and the note would be misleading.
const paymentNotes =
accountingMethod === 'cash' && !isFullyPaid
!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
: null
@@ -15,7 +15,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -103,7 +103,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
},
})
}
const { supplier_invoice_id } = parsed.data
const { supplier_invoice_id, lines: customLines } = parsed.data
const txLog = ctx.log.child({ transactionId: txId, supplierInvoiceId: supplier_invoice_id })
const { data: transaction, error: fetchTxErr } = await ctx.supabase
@@ -209,7 +209,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
// Route on the supplier invoice's actual booking state. An invoice
// booked at receipt (registration_journal_entry_id set) must clear
// 2440 regardless of the company's current setting.
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
if (useCashEntry && exchangeRateDifference !== 0) {
return v1ErrorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
requestId: ctx.requestId,
details: {
@@ -224,7 +230,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// payment JE can't be created. See the parallel comment in match-invoice.
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId: ctx.requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, transaction.date)
if (!fiscalPeriodId) {
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId: ctx.requestId,
details: { payment_date: transaction.date },
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
const desc = invoice.supplier?.name
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
const je = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
if (je) journalEntryId = je.id
} else if (useCashEntry) {
const je = await createSupplierInvoiceCashEntry(
ctx.supabase,
ctx.companyId!,
+3 -3
View File
@@ -196,7 +196,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc
{isOpen && flatList.length > 0 && (
<div
ref={listRef}
className="absolute z-50 top-full left-0 mt-1 w-64 max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
className="absolute z-50 top-full left-0 mt-1 min-w-[20rem] w-[max(100%,28rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
>
{groupedAccounts.map((group) => (
<div key={group.className}>
@@ -221,7 +221,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className="font-mono shrink-0">{account.account_number}</span>
<span className="truncate">{account.account_name}</span>
<span className="break-words">{account.account_name}</span>
</button>
)
})}
@@ -232,7 +232,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc
{/* Empty state */}
{isOpen && search.trim() && flatList.length === 0 && (
<div className="absolute z-50 top-full left-0 mt-1 w-64 rounded-md border border-input bg-card shadow-md p-3">
<div className="absolute z-50 top-full left-0 mt-1 min-w-[20rem] w-[max(100%,28rem)] rounded-md border border-input bg-card shadow-md p-3">
<p className="text-sm text-muted-foreground">
Hittade inget konto som matchar.
</p>
@@ -50,12 +50,22 @@ interface AttachmentPreviewSheetProps {
type IntegrityState = 'valid' | 'invalid' | 'error'
const integrityCache = new Map<string, IntegrityState>()
function isImageType(type: string | null): boolean {
return type?.startsWith('image/') ?? false
function isImageType(type: string | null, fileName?: string): boolean {
if (type?.startsWith('image/')) return true
// Legacy uploads and browsers that fail to sniff sometimes leave mime_type
// null or set it to application/octet-stream — fall back to filename.
if (type === null || type === 'application/octet-stream') {
return /\.(jpe?g|png|gif|webp|svg)$/i.test(fileName ?? '')
}
return false
}
function isPdfType(type: string | null): boolean {
return type === 'application/pdf'
function isPdfType(type: string | null, fileName?: string): boolean {
if (type === 'application/pdf') return true
if (type === null || type === 'application/octet-stream') {
return fileName?.toLowerCase().endsWith('.pdf') ?? false
}
return false
}
function formatFileSize(bytes: number): string {
@@ -247,13 +257,15 @@ export default function AttachmentPreviewSheet({
<div className="space-y-6">
{documents.map((doc) => {
const inlineSrc = `/api/documents/${doc.id}/inline`
const previewable = isImageType(doc.mime_type) || isPdfType(doc.mime_type)
const previewable =
isImageType(doc.mime_type, doc.file_name) ||
isPdfType(doc.mime_type, doc.file_name)
const isReplacing = replacingDocId === doc.id
return (
<div key={doc.id} className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
{isImageType(doc.mime_type) ? (
{isImageType(doc.mime_type, doc.file_name) ? (
<ImageIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
@@ -307,7 +319,7 @@ export default function AttachmentPreviewSheet({
</div>
</div>
{isPdfType(doc.mime_type) && integrity[doc.id] === 'invalid' && (
{isPdfType(doc.mime_type, doc.file_name) && integrity[doc.id] === 'invalid' && (
<div className="flex h-[70vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-6 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-warning/15">
<AlertTriangle className="h-5 w-5 text-warning-foreground" />
@@ -332,7 +344,7 @@ export default function AttachmentPreviewSheet({
</div>
)}
{isPdfType(doc.mime_type) && integrity[doc.id] !== 'invalid' && (
{isPdfType(doc.mime_type, doc.file_name) && integrity[doc.id] !== 'invalid' && (
// <object> + type="application/pdf" invokes Chrome's PDF
// plugin directly. <iframe> went through Chrome's frame
// pipeline first and intermittently surfaced
@@ -378,7 +390,7 @@ export default function AttachmentPreviewSheet({
</>
)}
{isImageType(doc.mime_type) && (
{isImageType(doc.mime_type, doc.file_name) && (
<div className="overflow-hidden rounded-lg border border-border bg-muted/30">
<img
src={inlineSrc}
@@ -12,8 +12,8 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import CorrectionPreview from '@/components/bookkeeping/CorrectionPreview'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Plus, Trash2 } from 'lucide-react'
@@ -163,63 +163,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
</p>
</div>
{/* Original entry (read-only) */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{/* Original entry metadata — lines live inside CorrectionPreview below */}
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<span className="font-mono">{formatVoucher(entry)}</span>
<span className="tabular-nums">{formatDate(entry.entry_date)}</span>
<Badge variant="outline" className="text-xs">Original</Badge>
</div>
<p className="text-sm">{entry.description}</p>
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-1.5 w-48">Konto</th>
<th className="py-1.5">Beskrivning</th>
<th className="py-1.5 w-28 text-right">Debet</th>
<th className="py-1.5 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{originalLines.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-1.5"><AccountNumber number={line.account_number} showName /></td>
<td className="py-1.5 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-1.5 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<td className="py-1.5 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-1.5">
{originalLines.map((line) => (
<div key={line.id} className="flex items-center justify-between py-1.5 border-b last:border-0 text-sm">
<div className="min-w-0">
<AccountNumber number={line.account_number} showName />
</div>
<span className="font-mono text-xs shrink-0 ml-2">
{Number(line.debit_amount) > 0
? `D ${Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}`
: `K ${Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}`}
</span>
</div>
))}
</div>
</div>
{/* Divider */}
<div className="border-t my-2" />
{/* Live diff: original | storno | correction | förändring */}
<CorrectionPreview originalLines={originalLines} correctedLines={lines} />
{/* Corrected lines (editable) */}
<div className="space-y-2">
@@ -0,0 +1,114 @@
'use client'
import { AccountNumber } from '@/components/ui/account-number'
import {
buildCorrectionRows,
formatSignedAmount,
type CorrectionLineInput,
} from '@/components/bookkeeping/correction-preview-rows'
import type { JournalEntryLine } from '@/types'
interface Props {
originalLines: JournalEntryLine[]
correctedLines: CorrectionLineInput[]
}
function signClass(n: number): string {
if (n > 0) return 'text-success'
if (n < 0) return 'text-destructive'
return 'text-muted-foreground'
}
export default function CorrectionPreview({ originalLines, correctedLines }: Props) {
const rows = buildCorrectionRows(originalLines, correctedLines)
const hasAnyCorrection = correctedLines.some((l) => {
if (l.account_number.length !== 4) return false
const d = typeof l.debit_amount === 'string' ? parseFloat(l.debit_amount) : l.debit_amount
const c = typeof l.credit_amount === 'string' ? parseFloat(l.credit_amount) : l.credit_amount
return (Number.isFinite(d) && d > 0) || (Number.isFinite(c) && c > 0)
})
if (rows.length === 0) return null
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Effekt per konto</p>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
Debet Kredit
</p>
</div>
<div className="hidden sm:block rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground bg-muted/30">
<tr>
<th className="px-3 py-2 text-left w-56">Konto</th>
<th className="px-3 py-2 text-right">Original</th>
<th className="px-3 py-2 text-right">Storno</th>
<th className="px-3 py-2 text-right">Rättelse</th>
<th className="px-3 py-2 text-right border-l">Förändring</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.account_number} className="border-t">
<td className="px-3 py-1.5">
<AccountNumber number={row.account_number} showName size="sm" />
</td>
<td className={`px-3 py-1.5 text-right tabular-nums ${signClass(row.original)}`}>
{formatSignedAmount(row.original)}
</td>
<td className={`px-3 py-1.5 text-right tabular-nums ${signClass(row.storno)}`}>
{formatSignedAmount(row.storno)}
</td>
<td className={`px-3 py-1.5 text-right tabular-nums ${signClass(row.correction)}`}>
{hasAnyCorrection ? formatSignedAmount(row.correction) : ''}
</td>
<td
className={`px-3 py-1.5 text-right tabular-nums border-l font-medium ${signClass(row.delta)}`}
>
{hasAnyCorrection ? formatSignedAmount(row.delta) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{rows.map((row) => (
<div key={row.account_number} className="rounded-lg border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<AccountNumber number={row.account_number} showName size="sm" />
<span className={`text-sm font-medium tabular-nums ${signClass(row.delta)}`}>
{hasAnyCorrection ? formatSignedAmount(row.delta) : ''}
</span>
</div>
<dl className="grid grid-cols-3 gap-2 text-xs">
<div>
<dt className="text-muted-foreground">Original</dt>
<dd className={`tabular-nums ${signClass(row.original)}`}>{formatSignedAmount(row.original)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Storno</dt>
<dd className={`tabular-nums ${signClass(row.storno)}`}>{formatSignedAmount(row.storno)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Rättelse</dt>
<dd className={`tabular-nums ${signClass(row.correction)}`}>
{hasAnyCorrection ? formatSignedAmount(row.correction) : ''}
</dd>
</div>
</dl>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Förändring = storno + rättelse. Det är det netto som tillkommer ovanpå originalet när du
bokför.
</p>
</div>
)
}
@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest'
import { makeJournalEntryLine } from '@/tests/helpers'
import {
buildCorrectionRows,
formatSignedAmount,
} from '@/components/bookkeeping/correction-preview-rows'
describe('buildCorrectionRows', () => {
it('returns no rows when both inputs are empty', () => {
expect(buildCorrectionRows([], [])).toEqual([])
})
it('amount change on same accounts — storno cancels original, correction adds the new value', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
]
const corrected = [
{ account_number: '5410', debit_amount: 1200, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1200 },
]
const rows = buildCorrectionRows(original, corrected)
expect(rows).toEqual([
{ account_number: '1930', original: -1000, storno: 1000, correction: -1200, delta: -200 },
{ account_number: '5410', original: 1000, storno: -1000, correction: 1200, delta: 200 },
])
})
it('account swap — old account zeros out, new account picks up the value', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
]
const corrected = [
{ account_number: '5420', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
const rows = buildCorrectionRows(original, corrected)
// 5410 ends at delta=-1000 (drained back), 5420 ends at delta=+1000 (new),
// 1930 nets to zero — correction matches storno exactly.
expect(rows.find((r) => r.account_number === '5410')).toEqual({
account_number: '5410',
original: 1000,
storno: -1000,
correction: 0,
delta: -1000,
})
expect(rows.find((r) => r.account_number === '5420')).toEqual({
account_number: '5420',
original: 0,
storno: 0,
correction: 1000,
delta: 1000,
})
expect(rows.find((r) => r.account_number === '1930')).toEqual({
account_number: '1930',
original: -1000,
storno: 1000,
correction: -1000,
delta: 0,
})
})
it('identical correction has zero delta on every row', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
]
const corrected = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
const rows = buildCorrectionRows(original, corrected)
expect(rows.every((r) => r.delta === 0)).toBe(true)
})
it('accepts string amounts from form inputs', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
]
const corrected = [
{ account_number: '5410', debit_amount: '1500.50', credit_amount: '' },
]
const rows = buildCorrectionRows(original, corrected)
expect(rows[0]).toEqual({
account_number: '5410',
original: 1000,
storno: -1000,
correction: 1500.5,
delta: 500.5,
})
})
it('skips corrected lines with partial account numbers (mid-edit)', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
]
const corrected = [
{ account_number: '54', debit_amount: 1200, credit_amount: 0 },
{ account_number: '193', debit_amount: 0, credit_amount: 1200 },
]
const rows = buildCorrectionRows(original, corrected)
// Only original 5410 shows up; the partial entries are ignored.
expect(rows).toHaveLength(1)
expect(rows[0].account_number).toBe('5410')
expect(rows[0].correction).toBe(0)
})
it('rounds to öre to avoid 0.1+0.2 drift', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 0.1, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '5410', debit_amount: 0.2, credit_amount: 0 }),
]
const rows = buildCorrectionRows(original, [])
expect(rows[0].original).toBe(0.3)
expect(rows[0].storno).toBe(-0.3)
})
})
describe('formatSignedAmount', () => {
it('formats positive amounts with leading +', () => {
expect(formatSignedAmount(1200)).toBe('+1\u00a0200,00')
})
it('formats negative amounts with unicode minus', () => {
expect(formatSignedAmount(-1000)).toBe('1\u00a0000,00')
})
it('renders zero as en-dash', () => {
expect(formatSignedAmount(0)).toBe('')
})
it('always renders two decimals', () => {
expect(formatSignedAmount(5.5)).toBe('+5,50')
})
})
@@ -0,0 +1,87 @@
import type { JournalEntryLine } from '@/types'
export interface CorrectionLineInput {
account_number: string
debit_amount: string | number
credit_amount: string | number
}
export interface AccountRow {
account_number: string
original: number
storno: number
correction: number
delta: number
}
function toNumber(v: string | number | null | undefined): number {
if (v == null) return 0
const n = typeof v === 'string' ? parseFloat(v) : v
return Number.isFinite(n) ? n : 0
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/**
* Build per-account diff rows: original net, storno (= original), proposed
* correction net, and förändring (= storno + correction = correction original).
*
* Net per row is debit credit. Accounts appearing only on one side still get
* a row, so the user sees account swaps clearly (old account drains to zero,
* new account picks up the value).
*
* Corrected lines with account_number.length !== 4 are skipped those are
* incomplete user input mid-edit, not real proposals.
*/
export function buildCorrectionRows(
original: JournalEntryLine[],
corrected: CorrectionLineInput[]
): AccountRow[] {
const map = new Map<string, AccountRow>()
const ensure = (acc: string): AccountRow => {
let row = map.get(acc)
if (!row) {
row = { account_number: acc, original: 0, storno: 0, correction: 0, delta: 0 }
map.set(acc, row)
}
return row
}
for (const line of original) {
if (!line.account_number) continue
const net = toNumber(line.debit_amount) - toNumber(line.credit_amount)
const row = ensure(line.account_number)
row.original += net
row.storno -= net
}
for (const line of corrected) {
if (!line.account_number || line.account_number.length !== 4) continue
const net = toNumber(line.debit_amount) - toNumber(line.credit_amount)
const row = ensure(line.account_number)
row.correction += net
}
for (const row of map.values()) {
row.original = round2(row.original)
row.storno = round2(row.storno)
row.correction = round2(row.correction)
row.delta = round2(row.storno + row.correction)
}
return Array.from(map.values()).sort((a, b) =>
a.account_number.localeCompare(b.account_number)
)
}
export function formatSignedAmount(n: number): string {
if (n === 0) return ''
const abs = Math.abs(n).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
return n > 0 ? `+${abs}` : `${abs}`
}
+5 -1
View File
@@ -32,6 +32,9 @@ interface InvoiceReviewContentProps {
numberPreview?: string | null
/** Mirrors `company_settings.ore_rounding`. Defaults to true to match `getDisplayTotal`. */
oreRounding?: boolean
/** Mirrors `company_settings.vat_registered`. When false and the invoice carries
* no VAT, the moms row is suppressed to match the PDF (pdf-template.tsx:876). */
vatRegistered?: boolean
}
export function InvoiceReviewContent({
@@ -48,6 +51,7 @@ export function InvoiceReviewContent({
notes,
numberPreview,
oreRounding,
vatRegistered,
}: InvoiceReviewContentProps) {
const t = useTranslations('invoice_review')
const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true })
@@ -161,7 +165,7 @@ export function InvoiceReviewContent({
<span>{formatCurrency(vat, currency)}</span>
</div>
))}
{Array.from(vatByRate.values()).every((vat) => vat === 0) && (
{Array.from(vatByRate.values()).every((vat) => vat === 0) && !(vatRegistered === false && vatAmount === 0) && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{formatCurrency(0, currency)}</span>
+517 -65
View File
@@ -1,15 +1,29 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal, Search, X } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import type { CashAccount } from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -63,6 +77,7 @@ interface UnmatchedTransaction {
amount: number
reference: string | null
currency: string
is_ignored?: boolean
}
interface MatchedTransaction {
@@ -88,6 +103,153 @@ interface DryRunMatch {
confidence: number
}
// ============================================================
// Searchable verifikation picker
// ============================================================
/**
* Inline combobox for choosing a journal entry to match a bank transaction
* against. The native <select> couldn't be searched, and the unmatched-GL list
* routinely runs to hundreds of rows (historical SIE imports), so the old UX
* forced users to scroll a giant unsorted dropdown. This picker filters by
* voucher number, date, amount or description as the user types, and renders
* the selected verifikation as a removable chip.
*/
interface MatchPickerProps {
glLines: UnlinkedGLLine[]
value: string
onChange: (journalEntryId: string) => void
disabled?: boolean
placeholder?: string
}
function MatchVerifikationPicker({
glLines,
value,
onChange,
disabled,
placeholder = 'Sök ver.nr, datum, belopp eller beskrivning…',
}: MatchPickerProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onDocMouseDown(e: MouseEvent) {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDocMouseDown)
return () => document.removeEventListener('mousedown', onDocMouseDown)
}, [open])
const selected = glLines.find((l) => l.journal_entry_id === value) || null
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q.length === 0
? glLines
: glLines.filter((line) => {
const amt = (line.debit_amount > 0 ? line.debit_amount : line.credit_amount).toString()
return (
formatVoucher(line).toLowerCase().includes(q) ||
line.entry_date.toLowerCase().includes(q) ||
amt.includes(q) ||
(line.entry_description || '').toLowerCase().includes(q) ||
(line.line_description || '').toLowerCase().includes(q)
)
})
return base.slice(0, 25)
}, [search, glLines])
if (selected) {
const amount = selected.debit_amount > 0 ? selected.debit_amount : -selected.credit_amount
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-sm">
<span className="font-mono text-xs shrink-0">{formatVoucher(selected)}</span>
<span className="text-muted-foreground shrink-0 tabular-nums">{formatDate(selected.entry_date)}</span>
<span className="font-mono tabular-nums shrink-0">{formatCurrency(amount)}</span>
<span className="truncate text-muted-foreground">{selected.entry_description}</span>
<Button
type="button"
size="icon"
variant="ghost"
className="ml-auto h-6 w-6 shrink-0"
onClick={() => onChange('')}
disabled={disabled}
aria-label="Avmarkera verifikation"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div ref={containerRef} className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
className="pl-9"
/>
</div>
{open && (
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover shadow-[var(--shadow-md)]">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
Inga verifikationer matchar &quot;{search}&quot;
</div>
) : (
<div className="max-h-72 overflow-y-auto">
{filtered.map((line) => {
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<button
key={line.line_id}
type="button"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-secondary/60 focus:bg-secondary/60 focus:outline-none"
onMouseDown={(e) => {
// mousedown beats blur — without this the popover closes
// before the click registers when the user has tabbed
// through and uses keyboard.
e.preventDefault()
}}
onClick={() => {
onChange(line.journal_entry_id)
setSearch('')
setOpen(false)
}}
>
<span className="font-mono text-xs shrink-0 w-12">{formatVoucher(line)}</span>
<span className="text-muted-foreground shrink-0 tabular-nums w-24">{formatDate(line.entry_date)}</span>
<span className="font-mono tabular-nums shrink-0 w-24 text-right">{formatCurrency(amount)}</span>
<span className="truncate text-muted-foreground flex-1">
{line.line_description || line.entry_description}
</span>
</button>
)
})}
{glLines.length > filtered.length && (
<div className="px-3 py-2 text-[11px] text-muted-foreground border-t border-border bg-secondary/30">
Visar {filtered.length} av {glLines.length} sök för att filtrera fler.
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
// ============================================================
// Component
// ============================================================
@@ -103,16 +265,47 @@ export function BankReconciliationView() {
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [accountNumber, setAccountNumber] = useState('1930')
const [cashAccounts, setCashAccounts] = useState<CashAccount[]>([])
const [dryRunResults, setDryRunResults] = useState<DryRunMatch[] | null>(null)
const [runLoading, setRunLoading] = useState(false)
const [applyLoading, setApplyLoading] = useState(false)
const [linkLoading, setLinkLoading] = useState<string | null>(null)
const [unlinkLoading, setUnlinkLoading] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [showMatched, setShowMatched] = useState(false)
// Default expanded so users discover the undo path. The card itself only
// renders when ignoredTx.length > 0 — collapsing it by default hid the
// recovery affordance from anyone who didn't already know it was there.
const [showIgnored, setShowIgnored] = useState(true)
const [ignoredTx, setIgnoredTx] = useState<UnmatchedTransaction[]>([])
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
const { toast } = useToast()
// Derive the currency for the selected ledger account from cash_accounts.
// Without this the lists below would hardcode SEK and silently return zero
// rows for users on 1932 EUR (or any other non-SEK cash account).
const accountCurrency =
cashAccounts.find((a) => a.ledger_account === accountNumber)?.currency ?? 'SEK'
useEffect(() => {
let cancelled = false
fetch('/api/cash-accounts')
.then((r) => r.json())
.then((j) => {
if (!cancelled && Array.isArray(j.data)) setCashAccounts(j.data as CashAccount[])
})
.catch(() => {
// Non-critical — falls back to 'SEK' currency, matches old behaviour.
})
return () => {
cancelled = true
}
}, [])
const fetchAll = useCallback(async () => {
setLoading(true)
setError(null)
@@ -123,11 +316,19 @@ export function BankReconciliationView() {
params.set('account_number', accountNumber)
const qs = `?${params}`
const txParams = new URLSearchParams()
txParams.set('currency', accountCurrency)
txParams.set('account_number', accountNumber)
if (dateFrom) txParams.set('date_from', dateFrom)
if (dateTo) txParams.set('date_to', dateTo)
const unmatchedQs = `?unmatched=true&${txParams}`
const reconciledQs = `?reconciled=true&${txParams}`
const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([
fetch(`/api/reconciliation/bank/status${qs}`),
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`),
fetch(`/api/transactions?unmatched=true&currency=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
fetch(`/api/transactions?reconciled=true&currency=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
fetch(`/api/transactions${unmatchedQs}`),
fetch(`/api/transactions${reconciledQs}`),
])
const [statusData, glData, unmatchedData, matchedData] = await Promise.all([
@@ -141,13 +342,27 @@ export function BankReconciliationView() {
setGlLines(glData.data || [])
setUnmatchedTx(unmatchedData.data || [])
setMatchedTx(matchedData.data || [])
// Refresh the ignored list whenever the main lists refresh.
// Deliberately NOT filtered by account or currency — if a user ignored
// a row on 1932 EUR and then switched to 1930 SEK, the recovery card
// would disappear and the row would feel "stuck". Company-wide scope
// keeps the Återställ path reachable from any account selection. The
// date filter is also dropped so old ignores stay visible.
try {
const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`)
const ignoredData = await ignoredRes.json()
setIgnoredTx(ignoredData.data || [])
} catch {
setIgnoredTx([])
}
} catch (e) {
console.error('[reconciliation] fetchAll failed', e)
setError('Kunde inte hämta avstämningsdata')
} finally {
setLoading(false)
}
}, [dateFrom, dateTo, accountNumber])
}, [dateFrom, dateTo, accountNumber, accountCurrency])
useEffect(() => {
fetchAll()
@@ -253,6 +468,106 @@ export function BankReconciliationView() {
}
}
/**
* Inline shortcut for the most common "stuck on the unmatched list" cause:
* a small ränteintäkt that has no upstream voucher to match against. Calls
* the standard categorize endpoint with the existing bank_interest_income
* template so the resulting verifikation is identical to the /transactions
* flow no parallel booking path.
*/
const handleBookInterestIncome = async (transactionId: string) => {
setActionLoading(transactionId)
try {
const res = await fetch(`/api/transactions/${transactionId}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_business: true,
template_id: 'bank_interest_income',
confirm_no_match: true,
}),
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error?.message || result.error || 'Kunde inte bokföra ränteintäkten')
return
}
if (result.journal_entry_error) {
setError(result.journal_entry_error)
return
}
await fetchAll()
} catch {
setError('Kunde inte bokföra ränteintäkten')
} finally {
setActionLoading(null)
}
}
const handleIgnore = async (tx: UnmatchedTransaction) => {
// Even though Ignorera is fully reversible, it's still a state change the
// user could miss after a misclick — the row vanishes from the unmatched
// list immediately. Confirmation before the write + an explicit Ångra
// toast on success gives two recovery affordances. The persistent
// "Ignorerade transaktioner" card is the third.
const ok = await confirm({
title: 'Ignorera transaktionen?',
description: `${tx.description}${formatCurrency(tx.amount)} (${formatDate(tx.date)}) försvinner från avstämningen utan att bokföras. Du kan återställa den från "Ignorerade transaktioner" nedan när som helst.`,
confirmLabel: 'Ignorera',
cancelLabel: 'Avbryt',
variant: 'warning',
})
if (!ok) return
setActionLoading(tx.id)
try {
const res = await fetch(`/api/transactions/${tx.id}/ignore`, {
method: 'POST',
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error || 'Kunde inte ignorera transaktionen')
return
}
await fetchAll()
toast({
title: 'Transaktionen ignorerad',
description: `${tx.description}${formatCurrency(tx.amount)}`,
action: (
<ToastAction
altText="Ångra ignorera"
onClick={() => handleUnignore(tx.id)}
>
Ångra
</ToastAction>
),
})
} catch {
setError('Kunde inte ignorera transaktionen')
} finally {
setActionLoading(null)
}
}
const handleUnignore = async (transactionId: string) => {
setActionLoading(transactionId)
try {
const res = await fetch(`/api/transactions/${transactionId}/ignore`, {
method: 'DELETE',
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error || 'Kunde inte återställa transaktionen')
return
}
await fetchAll()
} catch {
setError('Kunde inte återställa transaktionen')
} finally {
setActionLoading(null)
}
}
if (loading) {
return (
<Card>
@@ -293,7 +608,7 @@ export function BankReconciliationView() {
<Card className="border-2">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Avstämning mot <AccountNumber number="1930" /></CardTitle>
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
{status.is_reconciled ? (
<Badge className="bg-success/10 text-success">Avstämd</Badge>
) : (
@@ -301,7 +616,7 @@ export function BankReconciliationView() {
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">
Endast konto <AccountNumber number="1930" /> ingår i denna avstämning. Övriga bankkonton (t.ex. Plusgiro <AccountNumber number="1920" />, kreditkort <AccountNumber number="1940" /> eller valutakonton) måste avstämmas separat.
Avstämningen körs mot <AccountNumber number={accountNumber} /> ({accountCurrency}). Övriga bankkonton (t.ex. Plusgiro <AccountNumber number="1920" />, kreditkort <AccountNumber number="1940" /> eller valutakonton) stäms av separat välj kontot i listan nedan.
</p>
</CardHeader>
<CardContent>
@@ -311,7 +626,7 @@ export function BankReconciliationView() {
<span className="font-mono">{formatCurrency(status.bank_transaction_total)}</span>
</div>
<div className="flex justify-between">
<span>Bokfört <AccountNumber number="1930" /> i perioden</span>
<span>Bokfört <AccountNumber number={accountNumber} /> i perioden</span>
<span className="font-mono">
{formatCurrency(status.gl_1930_period_movement)}
</span>
@@ -324,7 +639,7 @@ export function BankReconciliationView() {
</div>
{status.gl_1930_opening_balance !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Ingående balans (IB) <AccountNumber number="1930" />:{' '}
Ingående balans (IB) <AccountNumber number={accountNumber} />:{' '}
<span className="font-mono">{formatCurrency(status.gl_1930_opening_balance)}</span>
{' '} räknas inte i avstämningen.
</p>
@@ -439,69 +754,141 @@ export function BankReconciliationView() {
{/* Unmatched Transactions */}
{unmatchedTx.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
<section className="space-y-3">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Omatchade transaktioner ({unmatchedTx.length})
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-24">Referens</th>
<th className="py-2 w-64">Föreslå verifikation</th>
<th className="py-2 w-24"></th>
</tr>
</thead>
<tbody>
{unmatchedTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[200px]">{tx.description}</td>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
<td className="py-2">
<select
value={selectedMatch[tx.id] || ''}
onChange={(e) =>
setSelectedMatch((prev) => ({ ...prev, [tx.id]: e.target.value }))
}
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs"
</h2>
{glLines.length > 0 && (
<p className="text-xs text-muted-foreground">
{glLines.length} verifikation{glLines.length === 1 ? '' : 'er'} att matcha mot
</p>
)}
</div>
<div className="space-y-3">
{unmatchedTx.map((tx) => {
// Piggy-bank shortcut hardcodes the 1930↔8310 ränteintäkt template,
// so only offer it on a SEK account using 1930. On EUR (1932) or
// other settlement accounts the booking would post the EUR amount
// to the SEK cash account — silently wrong, hide it.
const canBookInterest = tx.amount > 0 && accountNumber === '1930'
const isPositive = tx.amount > 0
return (
<div
key={tx.id}
className="rounded-lg border border-border bg-card p-4 space-y-4"
>
{/* Header row: meta + description + amount + menu */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground tabular-nums">
<span>{formatDate(tx.date)}</span>
<span aria-hidden>·</span>
<Badge variant="outline" className="text-[10px] uppercase tracking-wider">
{tx.currency}
</Badge>
{tx.reference && (
<>
<span aria-hidden>·</span>
<span>Ref: {tx.reference}</span>
</>
)}
</div>
<div className="mt-1.5 text-sm font-medium truncate">{tx.description}</div>
</div>
<div className="flex items-start gap-2 shrink-0">
<div
className={`font-display text-xl tabular-nums ${
isPositive ? 'text-success' : ''
}`}
>
<option value="">Välj verifikation...</option>
{glLines.map((line) => {
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<option key={line.line_id} value={line.journal_entry_id}>
{formatVoucher(line)} | {formatDate(line.entry_date)} | {formatCurrency(lineAmount)} | {line.entry_description}
</option>
)
})}
</select>
</td>
<td className="py-2">
{isPositive ? '+' : ''}
{formatCurrency(tx.amount)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
aria-label="Fler åtgärder"
disabled={actionLoading === tx.id}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{canBookInterest && (
<DropdownMenuItem
onClick={() => handleBookInterestIncome(tx.id)}
disabled={actionLoading === tx.id}
>
<PiggyBank className="h-4 w-4" />
<div className="flex flex-col">
<span>Bokför som ränteintäkt</span>
<span className="text-xs text-muted-foreground">
1930 mot 8310, ingen moms
</span>
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleIgnore(tx)}
disabled={actionLoading === tx.id}
>
<EyeOff className="h-4 w-4" />
<div className="flex flex-col">
<span>Ignorera transaktion</span>
<span className="text-xs text-muted-foreground">
Dölj utan att bokföra. Går att återställa.
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Match action row */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-[11px] uppercase tracking-wider text-muted-foreground">
Matcha mot verifikation
</Label>
{glLines.length === 0 && (
<span className="text-[11px] text-muted-foreground">
Inga omatchade verifikationer <AccountNumber number={accountNumber} />
</span>
)}
</div>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<MatchVerifikationPicker
glLines={glLines}
value={selectedMatch[tx.id] || ''}
onChange={(v) =>
setSelectedMatch((prev) => ({ ...prev, [tx.id]: v }))
}
disabled={linkLoading === tx.id || glLines.length === 0}
/>
</div>
<Button
size="sm"
variant="outline"
disabled={!selectedMatch[tx.id] || linkLoading === tx.id}
onClick={() => handleManualLink(tx.id)}
className="shrink-0 h-10"
>
<Link2 className="h-3 w-3 mr-1" />
{linkLoading === tx.id ? '...' : 'Matcha'}
<Link2 className="h-3.5 w-3.5 mr-1.5" />
{linkLoading === tx.id ? 'Matchar…' : 'Matcha'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
</div>
</div>
)
})}
</div>
</section>
)}
{/* Unmatched GL Lines */}
@@ -509,7 +896,7 @@ export function BankReconciliationView() {
<Card>
<CardHeader>
<CardTitle className="text-lg">
Omatchade verifikationer <AccountNumber number="1930" /> ({glLines.length})
Omatchade verifikationer <AccountNumber number={accountNumber} /> ({glLines.length})
</CardTitle>
</CardHeader>
<CardContent>
@@ -548,6 +935,69 @@ export function BankReconciliationView() {
</Card>
)}
{/* Ignored transactions (undo) */}
{ignoredTx.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer"
onClick={() => setShowIgnored(!showIgnored)}
>
<div className="flex items-center gap-2">
{showIgnored ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-lg">
Ignorerade transaktioner ({ignoredTx.length})
</CardTitle>
</div>
</CardHeader>
{showIgnored && (
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
Rader du valt att dölja från avstämningen. De påverkar inte saldot <AccountNumber number={accountNumber} /> de är bara gömda från listan.
</p>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-20">Valuta</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-28"></th>
</tr>
</thead>
<tbody>
{ignoredTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0 text-muted-foreground">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className="py-2 text-xs">
<Badge variant="outline" className="text-xs">{tx.currency}</Badge>
</td>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2">
<Button
size="sm"
variant="ghost"
disabled={actionLoading === tx.id}
onClick={() => handleUnignore(tx.id)}
>
{actionLoading === tx.id ? '...' : 'Återställ'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)}
{/* Recently Matched */}
{matchedTx.length > 0 && (
<Card>
@@ -616,13 +1066,15 @@ export function BankReconciliationView() {
)}
{/* Empty state */}
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && !loading && (
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga transaktioner eller verifikationer att stämma av.
</CardContent>
</Card>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
}
+11 -8
View File
@@ -22,6 +22,7 @@ import {
Heart,
HeartPulse,
Loader2,
MinusCircle,
Trash2,
X,
type LucideIcon,
@@ -48,6 +49,7 @@ type AbsenceType =
| 'pregnancy'
| 'care_relative'
| 'study'
| 'unpaid_leave'
| 'other_leave'
interface AbsenceDay {
@@ -73,16 +75,17 @@ interface AbsenceTypeMeta {
}
const TYPE_META: Record<AbsenceType, AbsenceTypeMeta> = {
sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' },
vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' },
parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' },
pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' },
care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' },
study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' },
other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' },
sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' },
vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' },
parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' },
pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' },
care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' },
study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' },
unpaid_leave: { label: 'Tjänstledig utan lön', shortLabel: 'Tjänstl.', icon: MinusCircle, pillClass: 'bg-slate-100 text-slate-800' },
other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' },
}
const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'other_leave']
const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave']
// ─── Component ─────────────────────────────────────────────────────
+23 -4
View File
@@ -63,6 +63,12 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
if (customerError) throw customerError
if (cancelled) return
// Non-momsregistrerade säljare ska inte få en exempel-rad med 25 %
// VAT — förhandsvisningen är hårdkodad sample-data, inte ett val
// användaren gjort, så vi följer settings.vat_registered direkt här
// (till skillnad från /invoices/new som låter användaren välja och
// bara varnar vid submit).
const previewVatRate = settings.vat_registered === false ? 0 : 25
const response = await fetch('/api/invoices/preview-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -77,7 +83,7 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
quantity: 1,
unit: 'st',
unit_price: 1000,
vat_rate: 25,
vat_rate: previewVatRate,
},
],
}),
@@ -147,11 +153,24 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
)}
{!isLoading && !error && blobUrl && (
<iframe
src={blobUrl}
// <object> + type="application/pdf" invokes Chrome's PDF plugin
// directly. <iframe> went through Chrome's frame pipeline first
// and intermittently surfaced "Det här innehållet har blockerats"
// even with a permissive CSP. See AttachmentPreviewSheet.tsx for
// the same workaround on journal entry attachments.
<object
data={blobUrl}
type="application/pdf"
title={t('iframe_title')}
className="w-full h-[70vh] rounded-lg border border-border"
/>
>
<p className="p-4 text-sm text-muted-foreground">
{t('error')}:{' '}
<a href={blobUrl} target="_blank" rel="noreferrer" className="underline">
{t('iframe_title')}
</a>
</p>
</object>
)}
</DialogContent>
</Dialog>
@@ -15,7 +15,7 @@ import { Loader2 } from 'lucide-react'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { CompanySettings, JournalEntrySourceType } from '@/types'
const SERIES_OPTIONS = 'ABCDEFG'.split('')
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
// Subset of source_types presented to the user. The DB column accepts every
// JournalEntrySourceType, but several values (storno, correction, etc.) are
@@ -0,0 +1,138 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Loader2, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
interface ActiveConnection {
id: string
bank_name: string
}
/**
* On-demand "Sync now" button beside BankSyncStatusChip. Reuses the
* per-connection sync endpoint that BankingSettingsPanel already calls;
* if the user has multiple active connections, a dropdown lets them
* pick which one to sync.
*/
export default function BankSyncNowButton() {
const t = useTranslations('transactions')
const { toast } = useToast()
const router = useRouter()
const { company } = useCompany()
const [connections, setConnections] = useState<ActiveConnection[] | null>(null)
const [syncingId, setSyncingId] = useState<string | null>(null)
useEffect(() => {
if (!company?.id) return
let cancelled = false
const supabase = createClient()
supabase
.from('bank_connections')
.select('id, bank_name')
.eq('company_id', company.id)
.eq('status', 'active')
.then(({ data }) => {
if (!cancelled) setConnections(data ?? [])
})
return () => {
cancelled = true
}
}, [company?.id])
if (!connections || connections.length === 0) return null
async function syncConnection(connectionId: string) {
setSyncingId(connectionId)
try {
const res = await fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Sync failed')
}
toast({
title: t('bank_sync_button_now'),
description: data.imported === 1
? t('bank_sync_new_since_last_visit_one')
: t('bank_sync_new_since_last_visit_many', { count: data.imported ?? 0 }),
})
router.refresh()
} catch (error) {
toast({
title: t('bank_sync_button_now'),
description: error instanceof Error ? error.message : 'Sync failed',
variant: 'destructive',
})
} finally {
setSyncingId(null)
}
}
const isSyncing = syncingId !== null
const label = isSyncing ? t('bank_sync_button_syncing') : t('bank_sync_button_now')
if (connections.length === 1) {
return (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={isSyncing}
onClick={() => syncConnection(connections[0].id)}
>
{isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{connections.map((conn) => (
<DropdownMenuItem
key={conn.id}
disabled={isSyncing}
onSelect={() => syncConnection(conn.id)}
>
{conn.bank_name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,92 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Sparkles, X } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
/**
* One-time pill telling the user how many new PSD2-synced transactions
* have arrived since they last visited /transactions. Helps make the
* nightly cron visible without polling or pushing notifications.
*
* State lives in localStorage keyed per company. On mount:
* - If no previous visit recorded: store now, render nothing.
* - Else: count rows added since lastVisit. If > 0, show the pill.
* - On dismiss: write `now` to localStorage and hide.
*/
export default function BankSyncSinceLastVisit() {
const t = useTranslations('transactions')
const { company } = useCompany()
const [count, setCount] = useState<number | null>(null)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
if (!company?.id) return
if (typeof window === 'undefined') return
const storageKey = `gnubok.lastTransactionsVisit.${company.id}`
const lastVisitRaw = window.localStorage.getItem(storageKey)
const now = new Date().toISOString()
if (!lastVisitRaw) {
window.localStorage.setItem(storageKey, now)
return
}
let cancelled = false
const supabase = createClient()
supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', company.id)
.eq('import_source', 'enable_banking')
.gt('created_at', lastVisitRaw)
.then(({ count: rowCount }) => {
if (cancelled) return
if (rowCount && rowCount > 0) {
setCount(rowCount)
} else {
// Nothing new — refresh the timestamp so we don't keep checking
// the same window forever.
window.localStorage.setItem(storageKey, now)
}
})
return () => {
cancelled = true
}
}, [company?.id])
if (dismissed || !count || count <= 0) return null
function handleDismiss() {
if (typeof window === 'undefined') return
if (company?.id) {
window.localStorage.setItem(
`gnubok.lastTransactionsVisit.${company.id}`,
new Date().toISOString(),
)
}
setDismissed(true)
}
return (
<div className="inline-flex items-center gap-2 rounded-md border border-success/30 bg-success/5 px-2.5 py-1 text-xs text-success">
<Sparkles className="h-3.5 w-3.5" />
<span>
{count === 1
? t('bank_sync_new_since_last_visit_one')
: t('bank_sync_new_since_last_visit_many', { count })}
</span>
<button
type="button"
onClick={handleDismiss}
aria-label={t('bank_sync_new_since_last_visit_dismiss')}
className="ml-1 rounded-sm p-0.5 opacity-70 transition-opacity hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
)
}
+80 -24
View File
@@ -6,6 +6,12 @@ import { useTranslations } from 'next-intl'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/info-tooltip'
interface ConnectionRow {
id: string
@@ -13,6 +19,37 @@ interface ConnectionRow {
last_synced_at: string | null
}
const STALE_THRESHOLD_MS = 36 * 60 * 60 * 1000
type ChipState =
| { kind: 'none' }
| { kind: 'attention'; count: number }
| { kind: 'stale'; mostRecent: string }
| { kind: 'healthy'; mostRecent: string | null }
export function getChipState(rows: ConnectionRow[], now: number = Date.now()): ChipState {
if (rows.length === 0) return { kind: 'none' }
const needsAttention = rows.filter(
(r) => r.status === 'expired' || r.status === 'error',
)
if (needsAttention.length > 0) {
return { kind: 'attention', count: needsAttention.length }
}
const mostRecent = rows
.map((r) => r.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop()
if (mostRecent && now - new Date(mostRecent).getTime() > STALE_THRESHOLD_MS) {
return { kind: 'stale', mostRecent }
}
return { kind: 'healthy', mostRecent: mostRecent ?? null }
}
function useAgeFormatter() {
const t = useTranslations('transactions')
return (iso: string): string => {
@@ -49,13 +86,13 @@ export default function BankSyncStatusChip() {
}
}, [company?.id])
if (!rows || rows.length === 0) return null
if (!rows) return null
const needsAttention = rows.filter(
(r) => r.status === 'expired' || r.status === 'error',
)
const state = getChipState(rows)
if (needsAttention.length > 0) {
if (state.kind === 'none') return null
if (state.kind === 'attention') {
return (
<Link
href="/settings/banking"
@@ -63,32 +100,51 @@ export default function BankSyncStatusChip() {
>
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{needsAttention.length === 1
{state.count === 1
? t('bank_sync_attention_one')
: t('bank_sync_attention_many', { count: needsAttention.length })}
: t('bank_sync_attention_many', { count: state.count })}
</span>
</Link>
)
}
const mostRecent = rows
.map((r) => r.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop()
if (state.kind === 'stale') {
return (
<Link
href="/settings/banking"
className="inline-flex items-center gap-1.5 rounded-md border border-warning/40 bg-warning/5 px-2.5 py-1 text-xs text-warning transition-colors hover:bg-warning/10"
>
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{t('bank_sync_stale_warning')}
<span className="ml-1 tabular-nums opacity-70">({formatAge(state.mostRecent)})</span>
</span>
</Link>
)
}
// healthy
return (
<div className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5" />
<span>
{t('bank_sync_auto_nightly')}
{mostRecent && (
<>
{t('bank_sync_last_separator')}
<span className="tabular-nums">{formatAge(mostRecent)}</span>
</>
)}
</span>
</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5" />
<span>
{t('bank_sync_auto_nightly')}
{state.mostRecent && (
<>
{t('bank_sync_last_separator')}
<span className="tabular-nums">{formatAge(state.mostRecent)}</span>
</>
)}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[320px]">
<div className="text-sm leading-relaxed">{t('bank_sync_latency_hint')}</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+354 -25
View File
@@ -1,12 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { CheckCircle2, AlertTriangle } from 'lucide-react'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { CheckCircle2, AlertTriangle, Trash2, Plus, Pencil } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
import type { BASAccount } from '@/types'
interface DuplicateCandidate {
journal_entry_id: string
@@ -18,15 +21,72 @@ interface DuplicateCandidate {
reason: 'exact_amount_same_date' | 'exact_amount_within_window'
}
interface PreviewLine {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
interface MatchPreview {
entry_type: 'clearing' | 'cash'
lines: PreviewLine[]
invoice_already_booked: boolean
accounting_method: 'accrual' | 'cash'
is_fully_paid: boolean
}
// String-typed working copy of a line. The amount is a single value plus a
// side (debit / credit) — modeling a verifikationsrad as one positive number
// with a direction matches how Swedish accountants think and tightens the
// failure modes (you can't accidentally fill both sides). Conversion back
// to the server's { debit_amount, credit_amount } shape happens at submit.
interface EditableLine {
account_number: string
side: 'debit' | 'credit'
amount: string
description: string
}
export interface ConfirmOpts {
force?: boolean
expected_journal_entry_id?: string
lines?: Array<{
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}>
}
interface InvoiceMatchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
isConfirming: boolean
onConfirm: (opts?: { force?: boolean; expected_journal_entry_id?: string }) => void
onConfirm: (opts?: ConfirmOpts) => void
onLinkToExisting?: (journalEntryId: string) => void
}
function previewToEditable(line: PreviewLine): EditableLine {
const isDebit = line.debit_amount > 0
return {
account_number: line.account_number,
side: isDebit ? 'debit' : 'credit',
amount: String(isDebit ? line.debit_amount : line.credit_amount),
description: line.description,
}
}
function parseAmount(s: string): number {
const n = Number(s.replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
export default function InvoiceMatchDialog({
open,
onOpenChange,
@@ -40,13 +100,80 @@ export default function InvoiceMatchDialog({
const isCustomerInvoice = !!transaction?.potential_invoice
const transactionId = transaction?.id ?? null
// Customer-side only: pre-flight check for a manual verifikation that
// already books this receipt. Supplier-side duplicate-payment surfacing
// is handled by the mark-paid guard on the supplier-invoice side; here
// we only need the customer flow for the reported issue.
const [candidate, setCandidate] = useState<DuplicateCandidate | null>(null)
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false)
const invoiceId = transaction?.potential_invoice?.id ?? null
const supplierInvoiceId = transaction?.potential_supplier_invoice?.id ?? null
const [preview, setPreview] = useState<MatchPreview | null>(null)
const [previewFailed, setPreviewFailed] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editLines, setEditLines] = useState<EditableLine[]>([])
// BAS accounts power the AccountCombobox suggestions in edit mode. Loaded
// once on dialog open; same endpoint that PaymentBookingDialog uses.
const [accounts, setAccounts] = useState<BASAccount[]>([])
useEffect(() => {
if (!open) return
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/bookkeeping/accounts')
if (!res.ok) return
const data = await res.json()
if (!cancelled) setAccounts((data?.data as BASAccount[]) ?? [])
} catch {
// Non-fatal: combobox just shows no suggestions, user can still
// type the number manually.
}
})()
return () => {
cancelled = true
}
}, [open])
useEffect(() => {
if (!open || !transactionId) {
setPreview(null)
setPreviewFailed(false)
setIsEditing(false)
setEditLines([])
return
}
let cancelled = false
const previewUrl = isCustomerInvoice && invoiceId
? `/api/transactions/${transactionId}/match-invoice/preview?invoice_id=${invoiceId}`
: isSupplierInvoice && supplierInvoiceId
? `/api/transactions/${transactionId}/match-supplier-invoice/preview?supplier_invoice_id=${supplierInvoiceId}`
: null
if (!previewUrl) {
setPreview(null)
setPreviewFailed(false)
return
}
async function loadPreview() {
setPreviewFailed(false)
try {
const res = await fetch(previewUrl!)
if (!res.ok) {
if (!cancelled) setPreviewFailed(true)
return
}
const data = (await res.json()) as MatchPreview
if (!cancelled) {
setPreview(data)
setEditLines(data.lines.map(previewToEditable))
}
} catch {
if (!cancelled) setPreviewFailed(true)
}
}
loadPreview()
return () => {
cancelled = true
}
}, [open, transactionId, isCustomerInvoice, isSupplierInvoice, invoiceId, supplierInvoiceId])
useEffect(() => {
if (!open || !transactionId || !isCustomerInvoice || !onLinkToExisting) {
setCandidate(null)
@@ -72,10 +199,59 @@ export default function InvoiceMatchDialog({
}
}, [open, transactionId, isCustomerInvoice, onLinkToExisting])
// The invoice candidate the dialog is about, normalized to a single shape.
// Supplier invoices show the negative-amount paid-out match; customer
// invoices show the positive-amount paid-in match. Each side carries its
// own follow-up action language.
// Live balance + validity. The dialog disables Confirm while edit mode is
// active and the entry is invalid; an out-of-balance entry can't be sent.
const editValidation = useMemo(() => {
if (!isEditing) return { isBalanced: true, isValid: true, diff: 0, totalDebit: 0, totalCredit: 0, accountInvalid: false }
const totalDebit = round2(
editLines.filter((l) => l.side === 'debit').reduce((s, l) => s + parseAmount(l.amount), 0),
)
const totalCredit = round2(
editLines.filter((l) => l.side === 'credit').reduce((s, l) => s + parseAmount(l.amount), 0),
)
const isBalanced = totalDebit === totalCredit && totalDebit > 0
const accountInvalid = editLines.some((l) => !/^\d{4}$/.test(l.account_number.trim()))
return {
isBalanced,
accountInvalid,
isValid: isBalanced && !accountInvalid,
diff: round2(totalDebit - totalCredit),
totalDebit,
totalCredit,
}
}, [isEditing, editLines])
const handleConfirm = (opts?: { force?: boolean; expected_journal_entry_id?: string }) => {
const linesPayload = isEditing && preview && editValidation.isValid
? editLines.map((l) => {
const amount = round2(parseAmount(l.amount))
return {
account_number: l.account_number.trim(),
debit_amount: l.side === 'debit' ? amount : 0,
credit_amount: l.side === 'credit' ? amount : 0,
line_description: l.description?.trim() || undefined,
}
})
: undefined
onConfirm({ ...(opts ?? {}), ...(linesPayload ? { lines: linesPayload } : {}) })
}
const resetEdits = () => {
if (preview) setEditLines(preview.lines.map(previewToEditable))
}
const addEditLine = () => {
setEditLines((prev) => [...prev, { account_number: '', side: 'debit', amount: '', description: '' }])
}
const removeEditLine = (i: number) => {
setEditLines((prev) => prev.filter((_, idx) => idx !== i))
}
const updateEditLine = (i: number, patch: Partial<EditableLine>) => {
setEditLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
}
const matchTitle = isSupplierInvoice ? t('title_supplier') : t('title_customer')
const matchDescription = isSupplierInvoice
? t('description_supplier')
@@ -83,7 +259,7 @@ export default function InvoiceMatchDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{matchTitle}</DialogTitle>
<DialogDescription>{matchDescription}</DialogDescription>
@@ -111,13 +287,6 @@ export default function InvoiceMatchDialog({
})}
</p>
{candidate.description && (
// Truncate to a short head before render. The
// description is free-text and may carry a customer
// name or note that's not strictly required to
// identify the verifikation (voucher_label + amount +
// date already do that). Cap length to keep the
// dialog tight and limit incidental PII surfacing
// in the rendered DOM. GDPR Art.5(1)(c).
<p className="text-xs text-muted-foreground truncate">
{candidate.description.length > 80
? `${candidate.description.slice(0, 80).trimEnd()}`
@@ -141,11 +310,8 @@ export default function InvoiceMatchDialog({
variant="outline"
size="sm"
onClick={() =>
onConfirm({
handleConfirm({
force: true,
// Echo the candidate the user reviewed back to
// the server so the bypass is bound to this
// specific duplicate. See match-invoice route.
expected_journal_entry_id: candidate.journal_entry_id,
})
}
@@ -256,6 +422,166 @@ export default function InvoiceMatchDialog({
)
})()}
{/* Bookkeeping preview editable. Read-only by default; user
clicks "Redigera" to switch the rows to inputs. */}
{(preview || previewFailed) && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{t('booking_title')}</p>
{preview && (
<div className="flex gap-2">
{isEditing && (
<Button variant="ghost" size="sm" onClick={resetEdits} disabled={isConfirming}>
{t('booking_reset')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing((v) => !v)}
disabled={isConfirming}
>
{isEditing ? t('booking_done_editing') : (
<>
<Pencil className="h-3 w-3 mr-1" />
{t('booking_edit')}
</>
)}
</Button>
</div>
)}
</div>
{previewFailed && !preview && (
<p className="text-sm text-muted-foreground">{t('booking_unavailable')}</p>
)}
{preview && !isEditing && (
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 gap-y-1 text-sm tabular-nums">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('booking_account')}
</div>
<div />
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">
{t('booking_debit')}
</div>
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">
{t('booking_credit')}
</div>
{preview.lines.map((line, i) => (
<div key={i} className="contents">
<div className="font-medium">{line.account_number}</div>
<div className="text-muted-foreground truncate">{line.description}</div>
<div className="text-right">
{line.debit_amount > 0
? formatCurrency(line.debit_amount, transaction.currency)
: ''}
</div>
<div className="text-right">
{line.credit_amount > 0
? formatCurrency(line.credit_amount, transaction.currency)
: ''}
</div>
</div>
))}
</div>
)}
{preview && isEditing && (
<div className="space-y-2">
{editLines.map((line, i) => (
<div
key={i}
className="grid grid-cols-[minmax(180px,1.6fr)_minmax(0,1fr)_140px_110px_28px] gap-2 items-center"
>
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(acc) => updateEditLine(i, { account_number: acc })}
/>
<Input
value={line.description}
onChange={(e) => updateEditLine(i, { description: e.target.value })}
placeholder={t('booking_description_placeholder')}
/>
{/* Side toggle segmented control. Clicking either
button picks that side; the amount stays the
same. */}
<div className="inline-flex rounded-md border bg-background overflow-hidden h-9">
<button
type="button"
onClick={() => updateEditLine(i, { side: 'debit' })}
className={cn(
'flex-1 px-2 text-xs font-medium transition-colors',
line.side === 'debit'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60',
)}
aria-pressed={line.side === 'debit'}
>
{t('booking_debit')}
</button>
<button
type="button"
onClick={() => updateEditLine(i, { side: 'credit' })}
className={cn(
'flex-1 px-2 text-xs font-medium border-l transition-colors',
line.side === 'credit'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60',
)}
aria-pressed={line.side === 'credit'}
>
{t('booking_credit')}
</button>
</div>
<Input
inputMode="decimal"
value={line.amount}
onChange={(e) => updateEditLine(i, { amount: e.target.value })}
className="text-right tabular-nums"
placeholder="0"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeEditLine(i)}
disabled={editLines.length <= 2}
aria-label={t('booking_remove_line')}
className="h-8 w-8"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center justify-between pt-1">
<Button variant="ghost" size="sm" onClick={addEditLine}>
<Plus className="h-3 w-3 mr-1" />
{t('booking_add_line')}
</Button>
<div className="text-xs tabular-nums text-muted-foreground">
{t('booking_debit')} {formatCurrency(editValidation.totalDebit, transaction.currency)}
{' / '}
{t('booking_credit')} {formatCurrency(editValidation.totalCredit, transaction.currency)}
</div>
</div>
{!editValidation.isBalanced && (
<p className="text-xs text-destructive">
{t('booking_unbalanced', {
diff: formatCurrency(Math.abs(editValidation.diff), transaction.currency),
})}
</p>
)}
{editValidation.accountInvalid && (
<p className="text-xs text-destructive">{t('booking_account_invalid')}</p>
)}
</div>
)}
</div>
)}
{/* What will happen */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">{t('on_confirm_title')}</p>
@@ -272,7 +598,10 @@ export default function InvoiceMatchDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
{t('cancel')}
</Button>
<Button onClick={() => onConfirm()} disabled={isConfirming || isCheckingDuplicate}>
<Button
onClick={() => handleConfirm()}
disabled={isConfirming || isCheckingDuplicate || (isEditing && !editValidation.isValid)}
>
{isConfirming ? t('confirming') : t('confirm_match')}
</Button>
</DialogFooter>
@@ -45,6 +45,11 @@ const UNBALANCED_SIE = [
'}',
].join('\n')
const COVER_VALID_SIE = [
{ sourceAccount: '6110', sourceName: 'Kontorsmaterial', targetAccount: '6110', targetName: 'Kontorsmaterial', confidence: 1, matchType: 'exact', isOverride: false },
{ sourceAccount: '1930', sourceName: 'Företagskonto', targetAccount: '1930', targetName: 'Företagskonto', confidence: 1, matchType: 'exact', isOverride: false },
]
beforeEach(() => {
vi.clearAllMocks()
})
@@ -55,7 +60,7 @@ describe('gnubok_import_sie — stage-time validation', () => {
enqueue({ data: { id: 'op-sie' }, error: null }) // pending_operations insert
const result = (await importSie.execute(
{ file_content: VALID_SIE, filename: 'bok.se', mappings: [] },
{ file_content: VALID_SIE, filename: 'bok.se', mappings: COVER_VALID_SIE },
'company-1',
'user-1',
supabase as never,
@@ -70,6 +75,8 @@ describe('gnubok_import_sie — stage-time validation', () => {
expect(result.preview.account_count).toBe(3)
expect(result.preview.fiscal_year).toMatchObject({ start: '2024-01-01', end: '2024-12-31' })
expect(result.preview.opening_balance).toMatchObject({ total: 0, is_balanced: true })
expect(result.preview.accounts_mapped).toMatchObject({ covered: 2, total: 2 })
expect(result.preview.would_skip_all_vouchers).toBe(false)
})
it('rejects an unbalanced file at stage time (no blind staging)', async () => {
@@ -77,7 +84,7 @@ describe('gnubok_import_sie — stage-time validation', () => {
await expect(
importSie.execute(
{ file_content: UNBALANCED_SIE, filename: 'trasig.se', mappings: [] },
{ file_content: UNBALANCED_SIE, filename: 'trasig.se', mappings: COVER_VALID_SIE },
'company-1',
'user-1',
supabase as never,
@@ -98,4 +105,75 @@ describe('gnubok_import_sie — stage-time validation', () => {
),
).rejects.toThrow(/file_content/)
})
// Lookma AB regression (support case 2026-05-28): staging with mappings=[]
// committed a 0-entry 'completed' sie_imports row that then blocked retry.
// The fix refuses to stage when the mappings can't cover the file.
it('rejects when mappings is empty and the file has vouchers', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
importSie.execute(
{ file_content: VALID_SIE, filename: 'lookma.se', mappings: [] },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/täcker inga konton|skulle hoppas över/i)
})
it('rejects when mappings don\'t overlap the file\'s accounts', async () => {
const { supabase } = createQueuedMockSupabase()
const wrongMappings = [
{ sourceAccount: '9999', sourceName: 'Fantasi', targetAccount: '9999', targetName: 'Fantasi', confidence: 1, matchType: 'exact', isOverride: false },
]
await expect(
importSie.execute(
{ file_content: VALID_SIE, filename: 'wrong.se', mappings: wrongMappings },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/täcker inga konton/i)
})
it('rejects when targetAccount is null on every mapping (Lookma shape)', async () => {
const { supabase } = createQueuedMockSupabase()
// The original Lookma agent sent #KONTO rows formatted as mapping objects
// but with no targetAccount resolved. Coverage check ignores those.
const halfBakedMappings = [
{ sourceAccount: '6110', sourceName: 'Kontorsmaterial', targetAccount: null, targetName: '', confidence: 0, matchType: 'manual', isOverride: false },
{ sourceAccount: '1930', sourceName: 'Företagskonto', targetAccount: null, targetName: '', confidence: 0, matchType: 'manual', isOverride: false },
]
await expect(
importSie.execute(
{ file_content: VALID_SIE, filename: 'half.se', mappings: halfBakedMappings },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/täcker inga konton/i)
})
it('stages when partial overlap exists (1 of 2 accounts mapped)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-sie-partial' }, error: null })
const partial = [
{ sourceAccount: '6110', sourceName: 'Kontorsmaterial', targetAccount: '6110', targetName: 'Kontorsmaterial', confidence: 1, matchType: 'exact', isOverride: false },
]
const result = (await importSie.execute(
{ file_content: VALID_SIE, filename: 'bok.se', mappings: partial },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.accounts_mapped).toMatchObject({ covered: 1, total: 2 })
expect(result.preview.would_skip_all_vouchers).toBe(false)
})
})
@@ -0,0 +1,154 @@
/**
* Stage-time pre-flight for gnubok_undo_sie_import.
*
* The tool mirrors undoSIEImport's gates so the approver sees an honest
* preview: row must exist, must be in 'completed' status, and if linked
* to a fiscal period that period must be open + unlocked.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
const undoTool = tools.find((t) => t.name === 'gnubok_undo_sie_import')!
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_undo_sie_import — stage-time validation', () => {
it('stages an undo when the import is completed and the period is open', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'imp-1',
filename: 'lookmaab-201907-202006.se',
fiscal_year_start: '2019-07-01',
fiscal_year_end: '2020-06-30',
transactions_count: 109,
opening_balance_entry_id: null,
status: 'completed',
fiscal_period_id: 'fp-1',
imported_at: '2026-05-28T10:00:00Z',
},
error: null,
}) // sie_imports lookup
enqueue({
data: { name: 'Räkenskapsår 2019/2020', is_closed: false, locked_at: null },
error: null,
}) // fiscal_periods lookup
enqueue({ data: { id: 'op-undo-1' }, error: null }) // pending_operations insert
const result = (await undoTool.execute(
{ import_id: 'imp-1', reason: 'Importen skapade 0 verifikat' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)) as { staged: boolean; operation_id?: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-undo-1')
expect(result.preview.import).toMatchObject({
id: 'imp-1',
filename: 'lookmaab-201907-202006.se',
transactions_count: 109,
has_opening_balance_entry: false,
fiscal_period_name: 'Räkenskapsår 2019/2020',
})
})
it('rejects when the import row is not found', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // sie_imports lookup misses
await expect(
undoTool.execute(
{ import_id: 'imp-missing' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/hittades inte/i)
})
it('rejects when the import is not in completed status', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'imp-2',
filename: 'half.se',
fiscal_year_start: '2024-01-01',
fiscal_year_end: '2024-12-31',
transactions_count: 0,
opening_balance_entry_id: null,
status: 'pending',
fiscal_period_id: null,
imported_at: null,
},
error: null,
})
await expect(
undoTool.execute(
{ import_id: 'imp-2' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/slutförda importer kan ångras/i)
})
it('rejects when the linked fiscal period is locked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'imp-3',
filename: 'locked.se',
fiscal_year_start: '2024-01-01',
fiscal_year_end: '2024-12-31',
transactions_count: 1,
opening_balance_entry_id: 'ob-1',
status: 'completed',
fiscal_period_id: 'fp-locked',
imported_at: '2026-05-01T00:00:00Z',
},
error: null,
})
enqueue({
data: { name: 'Räkenskapsår 2024', is_closed: false, locked_at: '2026-04-30T00:00:00Z' },
error: null,
})
await expect(
undoTool.execute(
{ import_id: 'imp-3' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/låst eller stängt/i)
})
it('rejects missing import_id before any DB hit', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
undoTool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' }),
).rejects.toThrow(/import_id/i)
})
it('rejects reason longer than 500 characters', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
undoTool.execute(
{ import_id: 'imp-1', reason: 'x'.repeat(501) },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
),
).rejects.toThrow(/500/)
})
})
+132
View File
@@ -6847,6 +6847,38 @@ export const tools: McpTool[] = [
const ibCurrent = parsed.openingBalances.filter((b) => b.yearIndex === 0)
const ibTotal = Math.round(ibCurrent.reduce((s, b) => s + b.amount, 0) * 100) / 100
// Mapping-coverage check. The executor's per-voucher loop silently
// skips any line whose account is not in `mappings`, so an empty or
// non-overlapping mapping set produces a committed import with
// journal_entries_created=0 that then claims the (company_id,
// file_hash) slot in the partial unique index and blocks retry.
// Refuse to stage when the mapping wouldn't cover a single account
// present in the file.
const importOB = Boolean(args.import_opening_balances)
const sourceAccountsInFile = new Set<string>()
for (const v of parsed.vouchers) for (const l of v.lines) sourceAccountsInFile.add(l.account)
if (importOB) for (const b of ibCurrent) sourceAccountsInFile.add(b.account)
const mappedSources = new Set(
(mappings as Array<{ sourceAccount?: unknown; targetAccount?: unknown }>)
.filter((m) => typeof m?.targetAccount === 'string' && m.targetAccount.length > 0 && typeof m?.sourceAccount === 'string')
.map((m) => m.sourceAccount as string),
)
const coveredAccounts = [...sourceAccountsInFile].filter((a) => mappedSources.has(a))
const accountsMapped = { covered: coveredAccounts.length, total: sourceAccountsInFile.size }
const wouldSkipAllVouchers = sourceAccountsInFile.size > 0 && coveredAccounts.length === 0
if (wouldSkipAllVouchers) {
const sample = [...sourceAccountsInFile].slice(0, 8).join(', ')
throw new Error(
`Kontomappningarna täcker inga konton i SIE-filen — alla ` +
`${parsed.stats.totalVouchers} verifikationer skulle hoppas över ` +
`och importen skulle skapa 0 verifikat. Filen innehåller ` +
`${sourceAccountsInFile.size} unika källkonton (t.ex. ${sample}). ` +
`Bifoga "mappings" där sourceAccount matchar #KONTO-numren i filen ` +
`och targetAccount är ett giltigt BAS-konto.`,
)
}
return stagePendingOperation(supabase, companyId, userId, 'import_sie',
`SIE-import: ${filename}`,
{
@@ -6862,6 +6894,8 @@ export const tools: McpTool[] = [
filename,
file_size_bytes: fileContent.length,
mappings_count: mappings.length,
accounts_mapped: accountsMapped,
would_skip_all_vouchers: wouldSkipAllVouchers,
company_name: parsed.header.companyName,
org_number: parsed.header.orgNumber,
fiscal_year: { start: parsed.stats.fiscalYearStart, end: parsed.stats.fiscalYearEnd },
@@ -6884,6 +6918,104 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_undo_sie_import',
description: 'Stage undo of a completed SIE import: hard-deletes its entries (transactions + opening balance), detaches docs, resets voucher_sequences, marks the row \'undone\' so the file can be re-imported. Use after a botched import. Period must be open. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
import_id: { type: 'string', description: 'UUID of the sie_imports row to undo. Must be status=\'completed\'.' },
reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason — shown in pending_operations review.' },
},
required: ['import_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const importId = args.import_id as string
const reason = typeof args.reason === 'string' ? args.reason : undefined
if (!importId) throw new Error('import_id is required')
if (reason !== undefined && reason.length > 500) {
throw new Error('reason must be 500 characters or fewer')
}
// Pre-flight mirrors undoSIEImport: confirm row exists, belongs to
// this company, is in 'completed' status, and (if linked) the fiscal
// period is open + unlocked. Surfacing rejection at stage-time keeps
// the agent honest about what the approver is being asked to confirm.
type ImportRow = {
id: string
filename: string
fiscal_year_start: string | null
fiscal_year_end: string | null
transactions_count: number | null
opening_balance_entry_id: string | null
status: string
fiscal_period_id: string | null
imported_at: string | null
}
const { data, error: lookupErr } = await supabase
.from('sie_imports')
.select('id, filename, fiscal_year_start, fiscal_year_end, transactions_count, opening_balance_entry_id, status, fiscal_period_id, imported_at')
.eq('id', importId)
.eq('company_id', companyId)
.maybeSingle()
const importRow = data as ImportRow | null
if (lookupErr) {
throw new Error(`Kunde inte slå upp SIE-import ${importId}: ${lookupErr.message}`)
}
if (!importRow) {
throw new Error(`SIE-import hittades inte: ${importId}`)
}
if (importRow.status !== 'completed') {
throw new Error(`Bara slutförda importer kan ångras (nuvarande status: ${importRow.status}).`)
}
let fiscalPeriodName: string | null = null
if (importRow.fiscal_period_id) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('name, is_closed, locked_at')
.eq('id', importRow.fiscal_period_id)
.eq('company_id', companyId)
.maybeSingle()
if (period?.is_closed || period?.locked_at) {
throw new Error(
`Räkenskapsåret "${period.name ?? 'okänt'}" är låst eller stängt. ` +
`Öppna perioden innan du ångrar importen.`,
)
}
fiscalPeriodName = (period as { name?: string } | null)?.name ?? null
}
return stagePendingOperation(supabase, companyId, userId, 'undo_sie_import',
`Ångra SIE-import: ${importRow.filename}`,
{ import_id: importId },
{
import: {
id: importRow.id,
filename: importRow.filename,
fiscal_year: { start: importRow.fiscal_year_start, end: importRow.fiscal_year_end },
fiscal_period_name: fiscalPeriodName,
transactions_count: importRow.transactions_count ?? 0,
has_opening_balance_entry: Boolean(importRow.opening_balance_entry_id),
imported_at: importRow.imported_at,
},
reason: reason ?? null,
will: 'hard-delete the import\'s journal entries (transactions + opening balance), detach user-attached documents, reset voucher_sequences, and mark the sie_imports row as \'undone\' so the file can be re-imported',
},
actor,
{
description: 'After commit, re-stage the SIE import with corrected mappings via gnubok_import_sie.',
tool: 'gnubok_import_sie',
},
)
},
},
// ── Phase 4: arbitrary-line bookkeeping primitives ───────────────
{
+34
View File
@@ -402,6 +402,19 @@ export const MarkSupplierInvoicePaidSchema = z.object({
exchange_rate_difference: z.number().optional(),
notes: z.string().optional(),
force: z.boolean().optional(),
// Which BAS account to credit for the payment. Defaults to 1930 to preserve
// the historical behaviour for MCP / agent callers that don't supply it.
payment_account: accountNumber.optional(),
// Optional user-edited journal entry rows. When present they override the
// default 2440-clearing / cash booking. Server validates balance and posts
// via createJournalEntry directly. source_type still derives from the
// routing decision so downstream payment-sync keeps working.
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).optional(),
})
export const UpdateSupplierInvoiceSchema = z.object({
@@ -492,6 +505,17 @@ export const MatchInvoiceSchema = z
// specific, user-seen duplicate so an automation can't sweep through
// force=true to bypass the guard without ever consulting the candidate.
expected_journal_entry_id: uuid.optional(),
// Optional user-edited journal entry lines. When present they override
// the default clearing/cash booking — the route validates balance and
// posts via createJournalEntry directly. Source_type is still set from
// the routing decision (invoice_paid vs invoice_cash_payment) so
// downstream payment-sync continues to work.
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).optional(),
})
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
message: 'expected_journal_entry_id is required when force=true',
@@ -639,6 +663,15 @@ export const CreateTransactionFromDocumentSchema = z.object({
export const MatchSupplierInvoiceSchema = z.object({
supplier_invoice_id: uuid,
// Same purpose as MatchInvoiceSchema.lines — user-edited rows override
// the default 2440-clearing / cash booking. Route validates balance and
// posts via createJournalEntry; source_type still derives from routing.
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).optional(),
})
@@ -1314,6 +1347,7 @@ export const AbsenceTypeSchema = z.enum([
'pregnancy',
'care_relative',
'study',
'unpaid_leave',
'other_leave',
])
@@ -29,6 +29,7 @@ function makeTx(overrides: Partial<Transaction> = {}): Transaction {
receipt_id: null,
document_id: null,
reconciliation_method: null,
is_ignored: false,
import_source: 'enable_banking',
reference: null,
counterparty_iban: 'SE9550000000054910000003',
@@ -0,0 +1,159 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { isPaymentSourceType, syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { JournalEntry } from '@/types'
describe('isPaymentSourceType', () => {
it.each([
'invoice_paid',
'invoice_cash_payment',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
])('recognises %s as payment', (sourceType) => {
expect(isPaymentSourceType(sourceType)).toBe(true)
})
it.each(['manual', 'invoice_created', 'supplier_invoice_registered', '', null, undefined])(
'rejects %s',
(sourceType) => {
expect(isPaymentSourceType(sourceType)).toBe(false)
}
)
})
describe('syncInvoiceStatusFromPaymentEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function entry(overrides: Partial<JournalEntry> = {}): Pick<JournalEntry, 'id' | 'source_type' | 'source_id'> {
return {
id: 'entry-1',
source_type: 'supplier_invoice_paid',
source_id: 'supplier-invoice-1',
...overrides,
} as Pick<JournalEntry, 'id' | 'source_type' | 'source_id'>
}
it('is a no-op when source_type is not a payment', async () => {
const { supabase } = createQueuedMockSupabase()
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'manual' as JournalEntry['source_type'] })
)
expect(supabase.from).not.toHaveBeenCalled()
})
it('is a no-op when source_id is missing', async () => {
const { supabase } = createQueuedMockSupabase()
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_id: null })
)
expect(supabase.from).not.toHaveBeenCalled()
})
it('reverts a fully-paid supplier invoice back to approved', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
// Fully paid before deletion: paid_amount === total_amount
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
{ data: null }, // UPDATE result
])
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual([
'supplier_invoice_payments',
'supplier_invoices',
'supplier_invoices',
])
})
it('reverts a partially-paid supplier invoice to partially_paid when paid_amount remains', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 500 } }, // payment being reversed
// Started with 1000 paid (multiple payments), reversing 500
{ data: { paid_amount: 1000, total_amount: 1500, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
// Test passes if the queries fire in the expected order without error
expect((supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(3)
})
it('routes customer invoice entries through the invoices table', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
{ data: { paid_amount: 1000, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'invoice_paid', source_id: 'invoice-1' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls).toEqual(['invoice_payments', 'invoices', 'invoices'])
})
it('handles invoice_cash_payment the same way as invoice_paid', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 500 } },
{ data: { paid_amount: 500, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls[0]).toBe('invoice_payments')
expect(fromCalls[1]).toBe('invoices')
})
it('handles supplier_invoice_cash_payment the same way as supplier_invoice_paid', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { amount: 1000 } },
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
{ data: null },
])
await syncInvoiceStatusFromPaymentEntry(
supabase as never,
'co-1',
entry({ source_type: 'supplier_invoice_cash_payment' })
)
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(fromCalls[0]).toBe('supplier_invoice_payments')
expect(fromCalls[1]).toBe('supplier_invoices')
})
it('does not error when no payment row exists for the supplier entry', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null }, // no payment row
{ data: { paid_amount: 1000, total_amount: 1000, due_date: '2099-12-31' } },
])
await expect(
syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
).resolves.toBeUndefined()
})
})
@@ -877,6 +877,43 @@ describe('createSupplierInvoicePaymentEntry', () => {
expect(input.description).toBe('Utbetalning leverantörsfaktura LF-200, Leverantör AB (ankomst 10)')
})
it('credits the provided paymentAccount instead of 1930', async () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01',
undefined, undefined, '1940'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '1940')[0].credit_amount).toBe(10000)
})
it('falls back to 1930 when paymentAccount is undefined', async () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(10000)
})
it('uses paymentAccount on the FX-difference branch too', async () => {
const invoice = makeSupplierInvoice({ total: 11500, currency: 'EUR' })
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 11500, '2024-07-15',
500, undefined, '2018'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '2018')[0].credit_amount).toBe(11000)
})
it('uses paymentDate not invoice_date as entry_date', async () => {
const invoice = makeSupplierInvoice({ invoice_date: '2024-06-01' })
@@ -936,6 +973,23 @@ describe('createSupplierInvoiceCashEntry', () => {
assertBalanced(input)
})
it('credits the provided paymentAccount instead of 1930', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000, vat_amount: 2000, total: 10000,
})
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business',
undefined, '2018'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
expect(findByAccount(input.lines, '2018')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
it('domestic zero VAT', async () => {
const invoice = makeSupplierInvoice({
subtotal: 5000,
+7 -86
View File
@@ -12,6 +12,7 @@ import {
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -589,92 +590,12 @@ export async function reverseEntry(
throw new EntryAlreadyReversedError()
}
// If this was a payment entry, sync the linked invoice/supplier-invoice status
const paymentSourceTypes = [
'invoice_paid', 'invoice_cash_payment',
'supplier_invoice_paid', 'supplier_invoice_cash_payment',
]
if (paymentSourceTypes.includes(original.source_type) && original.source_id) {
// The GL reversal is already handled above (line-by-line mirror of the original
// verifikation per BFL 5 kap 5§). Here we sync the business-level invoice state.
// Payment amounts come from the payments table, not from GL line inspection —
// this works identically for kontantmetod and faktureringsmetod.
const entryId = original.id
if (original.source_type.startsWith('supplier_invoice')) {
const { data: payment } = await supabase
.from('supplier_invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: supplierInvoice } = await supabase
.from('supplier_invoices')
.select('paid_amount, total_amount, due_date')
.eq('id', original.source_id)
.eq('company_id', companyId)
.single()
if (supplierInvoice && payment) {
const newPaidAmount = Math.round((supplierInvoice.paid_amount - payment.amount) * 100) / 100
const newRemaining = Math.round((supplierInvoice.total_amount - Math.max(0, newPaidAmount)) * 100) / 100
let newStatus: string
if (newPaidAmount > 0) {
newStatus = 'partially_paid'
} else if (supplierInvoice.due_date && new Date(supplierInvoice.due_date) < new Date()) {
newStatus = 'overdue'
} else {
newStatus = 'approved'
}
await supabase
.from('supplier_invoices')
.update({
status: newStatus,
paid_amount: Math.max(0, newPaidAmount),
remaining_amount: newRemaining,
paid_at: null,
payment_journal_entry_id: null,
})
.eq('id', original.source_id)
.eq('company_id', companyId)
}
} else {
const { data: payment } = await supabase
.from('invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, due_date')
.eq('id', original.source_id)
.eq('company_id', companyId)
.single()
if (customerInvoice) {
const paymentAmount = payment?.amount ?? customerInvoice.paid_amount
const newPaidAmount = Math.round((customerInvoice.paid_amount - paymentAmount) * 100) / 100
const revertStatus = newPaidAmount > 0
? 'partially_paid'
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
? 'overdue'
: 'sent'
await supabase
.from('invoices')
.update({
status: revertStatus,
paid_at: null,
paid_amount: Math.max(0, newPaidAmount),
})
.eq('id', original.source_id)
.eq('company_id', companyId)
.in('status', ['paid', 'partially_paid'])
}
}
// If this was a payment entry, sync the linked invoice/supplier-invoice status.
// Helper is shared with the DELETE journal entry route so both code paths leave
// the invoice in a consistent state (BFL 5 kap 5§ requires GL reversal; this
// covers the business-level state that lives outside the GL).
if (isPaymentSourceType(original.source_type)) {
await syncInvoiceStatusFromPaymentEntry(supabase, companyId, original as JournalEntry)
}
// Fetch complete reversal entry with lines
+107
View File
@@ -0,0 +1,107 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { JournalEntry } from '@/types'
export const PAYMENT_SOURCE_TYPES = [
'invoice_paid',
'invoice_cash_payment',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
] as const
export function isPaymentSourceType(sourceType: string | null | undefined): boolean {
if (!sourceType) return false
return (PAYMENT_SOURCE_TYPES as readonly string[]).includes(sourceType)
}
/**
* Revert the business-level paid status on the invoice or supplier invoice
* that a payment journal entry was attached to. Used by both reverseEntry()
* (storno) and the DELETE journal entry route both paths leave the GL in a
* consistent state but the invoice's status/paid_amount/paid_at would otherwise
* stay stuck on "paid".
*
* Safe to call with any entry returns early if source_type is not a payment.
*/
export async function syncInvoiceStatusFromPaymentEntry(
supabase: SupabaseClient,
companyId: string,
entry: Pick<JournalEntry, 'id' | 'source_type' | 'source_id'>
): Promise<void> {
if (!isPaymentSourceType(entry.source_type) || !entry.source_id) return
const entryId = entry.id
if (entry.source_type.startsWith('supplier_invoice')) {
const { data: payment } = await supabase
.from('supplier_invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: supplierInvoice } = await supabase
.from('supplier_invoices')
.select('paid_amount, total_amount, due_date')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
if (supplierInvoice && payment) {
const newPaidAmount = Math.round((supplierInvoice.paid_amount - payment.amount) * 100) / 100
const newRemaining = Math.round((supplierInvoice.total_amount - Math.max(0, newPaidAmount)) * 100) / 100
let newStatus: string
if (newPaidAmount > 0) {
newStatus = 'partially_paid'
} else if (supplierInvoice.due_date && new Date(supplierInvoice.due_date) < new Date()) {
newStatus = 'overdue'
} else {
newStatus = 'approved'
}
await supabase
.from('supplier_invoices')
.update({
status: newStatus,
paid_amount: Math.max(0, newPaidAmount),
remaining_amount: newRemaining,
paid_at: null,
payment_journal_entry_id: null,
})
.eq('id', entry.source_id)
.eq('company_id', companyId)
}
} else {
const { data: payment } = await supabase
.from('invoice_payments')
.select('amount')
.eq('journal_entry_id', entryId)
.single()
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, due_date')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
if (customerInvoice) {
const paymentAmount = payment?.amount ?? customerInvoice.paid_amount
const newPaidAmount = Math.round((customerInvoice.paid_amount - paymentAmount) * 100) / 100
const revertStatus = newPaidAmount > 0
? 'partially_paid'
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
? 'overdue'
: 'sent'
await supabase
.from('invoices')
.update({
status: revertStatus,
paid_at: null,
paid_amount: Math.max(0, newPaidAmount),
})
.eq('id', entry.source_id)
.eq('company_id', companyId)
.in('status', ['paid', 'partially_paid'])
}
}
}
+10 -6
View File
@@ -202,8 +202,10 @@ export async function createSupplierInvoicePaymentEntry(
paymentAmount: number,
paymentDate: string,
exchangeRateDifference?: number,
supplierName?: string
supplierName?: string,
paymentAccount?: string
): Promise<JournalEntry | null> {
const creditAccount = paymentAccount || '1930'
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
@@ -228,7 +230,7 @@ export async function createSupplierInvoicePaymentEntry(
// Credit: Bank at actual SEK paid
lines.push({
account_number: '1930',
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round(actualSekPaid * 100) / 100,
line_description: desc,
@@ -262,7 +264,7 @@ export async function createSupplierInvoicePaymentEntry(
})
lines.push({
account_number: '1930',
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round(paymentAmount * 100) / 100,
line_description: desc,
@@ -297,8 +299,10 @@ export async function createSupplierInvoiceCashEntry(
items: SupplierInvoiceItem[],
paymentDate: string,
supplierType: string,
supplierName?: string
supplierName?: string,
paymentAccount?: string
): Promise<JournalEntry | null> {
const creditAccount = paymentAccount || '1930'
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
@@ -370,12 +374,12 @@ export async function createSupplierInvoiceCashEntry(
}
}
// Credit: Företagskonto — balance guarantee: ensures sum(debits) === sum(credits)
// Credit: payment account — balance guarantee: ensures sum(debits) === sum(credits)
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: '1930',
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
/**
* Chained-correction invariant: a posted entry of source_type='correction'
* can itself be reversed and corrected the chain just grows. The UI used
* to block this; the storno-service never did. This test asserts the DB
* layer accepts the full two-level chain (CHECK constraint, FK, immutability
* trigger), so any future migration that accidentally tightens one of those
* will fail loudly here instead of silently breaking BFL 5 kap. 5 § flows.
*
* The flow this mirrors:
* 1. Original posted (manual)
* 2. correctEntry storno-1 + correction-1, original reversed
* 3. correctEntry on correction-1 storno-2 + correction-2,
* correction-1 reversed
*
* We drive the SQL directly because correctEntry uses the Supabase JS client
* which is out of scope for the pg-real harness (see year-end-invariants.pg
* for the same rationale).
*/
describe('chained correction (pg-real)', () => {
it('accepts a correction whose original is itself a correction', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const pool = getPool()
async function insertDraft(opts: {
sourceType: string
reversesId?: string | null
correctionOfId?: string | null
}): Promise<string> {
const id = randomUUID()
await pool.query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status, reverses_id, correction_of_id)
VALUES ($1, $2, $3, $4, 0, 'A', '2026-06-15', $5, $6, 'draft', $7, $8)`,
[
id,
userId,
companyId,
fiscalPeriodId,
`Entry ${opts.sourceType}`,
opts.sourceType,
opts.reversesId ?? null,
opts.correctionOfId ?? null,
],
)
return id
}
async function insertLines(entryId: string, debitAcc: string, creditAcc: string, amount: number) {
await pool.query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
[entryId, debitAcc, amount, creditAcc],
)
}
async function commit(entryId: string): Promise<number> {
const { rows } = await pool.query<{ voucher_number: number }>(
`SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
[companyId, entryId],
)
return rows[0]!.voucher_number
}
async function markReversed(entryId: string, reversedById: string) {
await pool.query(
`UPDATE public.journal_entries
SET status = 'reversed', reversed_by_id = $2
WHERE id = $1 AND status = 'posted'`,
[entryId, reversedById],
)
}
// === Step 1: original posted ===
const originalId = await insertDraft({ sourceType: 'manual' })
await insertLines(originalId, '5410', '1930', 1000)
await commit(originalId)
// === Step 2: first storno + first correction ===
const storno1Id = await insertDraft({ sourceType: 'storno', reversesId: originalId })
await insertLines(storno1Id, '1930', '5410', 1000) // swapped legs
await commit(storno1Id)
await markReversed(originalId, storno1Id)
const correction1Id = await insertDraft({
sourceType: 'correction',
correctionOfId: originalId,
})
await insertLines(correction1Id, '5420', '1930', 1200)
await commit(correction1Id)
// Sanity: original is reversed, correction1 is posted.
const { rows: midRows } = await pool.query<{ id: string; status: string; source_type: string }>(
`SELECT id, status, source_type FROM public.journal_entries
WHERE id = ANY($1::uuid[])`,
[[originalId, correction1Id]],
)
const midState = Object.fromEntries(midRows.map((r) => [r.id, r]))
expect(midState[originalId]?.status).toBe('reversed')
expect(midState[correction1Id]?.status).toBe('posted')
expect(midState[correction1Id]?.source_type).toBe('correction')
// === Step 3: storno + correction OF the first correction ===
// This is the new path. The DB must accept reverses_id and correction_of_id
// pointing at a source_type='correction' entry, and accept a second
// entry of source_type='correction' in the same period.
const storno2Id = await insertDraft({ sourceType: 'storno', reversesId: correction1Id })
await insertLines(storno2Id, '1930', '5420', 1200)
await commit(storno2Id)
await markReversed(correction1Id, storno2Id)
const correction2Id = await insertDraft({
sourceType: 'correction',
correctionOfId: correction1Id,
})
await insertLines(correction2Id, '5430', '1930', 1500)
const correction2Voucher = await commit(correction2Id)
expect(correction2Voucher).toBeGreaterThan(0)
// === Final assertions: full chain is intact ===
const { rows: finalRows } = await pool.query<{
id: string
status: string
source_type: string
reverses_id: string | null
correction_of_id: string | null
reversed_by_id: string | null
}>(
`SELECT id, status, source_type, reverses_id, correction_of_id, reversed_by_id
FROM public.journal_entries
WHERE company_id = $1
ORDER BY voucher_number`,
[companyId],
)
expect(finalRows).toHaveLength(5)
const state = Object.fromEntries(finalRows.map((r) => [r.id, r]))
expect(state[originalId]).toMatchObject({
status: 'reversed',
source_type: 'manual',
reversed_by_id: storno1Id,
})
expect(state[storno1Id]).toMatchObject({
status: 'posted',
source_type: 'storno',
reverses_id: originalId,
})
expect(state[correction1Id]).toMatchObject({
status: 'reversed',
source_type: 'correction',
correction_of_id: originalId,
reversed_by_id: storno2Id,
})
expect(state[storno2Id]).toMatchObject({
status: 'posted',
source_type: 'storno',
reverses_id: correction1Id,
})
expect(state[correction2Id]).toMatchObject({
status: 'posted',
source_type: 'correction',
correction_of_id: correction1Id,
})
})
})
@@ -276,6 +276,55 @@ describe('correctEntry', () => {
expect(result.corrected).toBeDefined()
})
it('accepts a source_type=correction entry as the original (chained correction, BFL 5 kap. 5 §)', async () => {
// The user just corrected entry A → got correction C. They now want to
// correct C. Service must not care about source_type of the original —
// status='posted' is the only constraint.
const correctionAsOriginal = makeJournalEntry({
id: 'correction-1',
status: 'posted',
source_type: 'correction',
correction_of_id: 'orig-A',
description: 'Rättelse: Test purchase',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
lines: [
makeJournalEntryLine({ account_number: '5420', debit_amount: 1200, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1200 }),
],
})
const secondReversal = makeJournalEntry({ id: 'reversal-2', reverses_id: 'correction-1' })
const secondCorrection = makeJournalEntry({
id: 'correction-2',
correction_of_id: 'correction-1',
source_type: 'correction',
})
results = [
{ data: correctionAsOriginal, error: null }, // 0: fetch original (the prior correction)
{ data: secondReversal, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
{ data: secondCorrection, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: insert corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [{ id: 'correction-1' }], error: null }, // 8: CAS update
{ data: { ...secondReversal, lines: [] }, error: null }, // 9: fetch final reversal
{ data: { ...secondCorrection, lines: [] }, error: null }, // 10: fetch final corrected
]
const supabase = makeClient()
const result = await correctEntry(supabase as never, 'company-1', 'user-1', 'correction-1', [
{ account_number: '5430', debit_amount: 1500, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1500 },
])
expect(result.reversal.reverses_id).toBe('correction-1')
expect(result.corrected.correction_of_id).toBe('correction-1')
expect(result.corrected.source_type).toBe('correction')
})
it('emits journal_entry.corrected event', async () => {
setupResults()
@@ -0,0 +1,234 @@
/**
* Regression suite for the Lookma AB support case (2026-05-28).
*
* The bug: gnubok_import_sie + executeSIEImport accepted mappings that
* couldn't cover a single account in the file. The per-voucher loop then
* silently skipped every verifikation, finalizeImportRecord marked the
* sie_imports row 'completed' with transactions_count=0, and the partial
* unique index on (company_id, file_hash) held the slot blocking retry.
*
* The fix layers three guards:
* 1. Stage-time refusal in gnubok_import_sie (covered in
* extensions/general/mcp-server/__tests__/import-sie-stage.test.ts).
* 2. Defense-in-depth refusal in executeSIEImport (this file).
* 3. Finalizer downgrade of any 0-entry success to 'failed' (this file).
*/
import { describe, it, expect } from 'vitest'
import { executeSIEImport, finalizeImportRecord } from '../sie-import'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ParsedSIEFile, AccountMapping, ImportResult } from '../types'
import type { SupabaseClient } from '@supabase/supabase-js'
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
return {
header: {
sieType: 4,
flagga: 0,
program: 'TestProg',
programVersion: '1.0',
generatedDate: '2024-01-01',
format: 'PC8',
companyName: 'Lookma Mock AB',
orgNumber: '5567201701',
address: null,
fiscalYears: [{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }],
currency: 'SEK',
kontoPlanType: null,
},
accounts: [
{ number: '1930', name: 'Företagskonto' },
{ number: '6110', name: 'Kontorsmaterial' },
],
openingBalances: [{ yearIndex: 0, account: '1930', amount: 50000 }],
closingBalances: [],
resultBalances: [],
vouchers: [
{
series: 'A',
number: 1,
date: new Date(2024, 0, 15),
description: 'Inköp',
lines: [
{ account: '6110', amount: 1000 },
{ account: '1930', amount: -1000 },
],
},
],
issues: [],
stats: {
totalAccounts: 2,
totalVouchers: 1,
totalTransactionLines: 2,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
...overrides,
}
}
function makeMapping(source: string, target: string | null): AccountMapping {
return {
sourceAccount: source,
sourceName: `Account ${source}`,
targetAccount: target as string,
targetName: target ? `Target ${target}` : '',
confidence: target ? 1 : 0,
matchType: target ? 'exact' : 'manual',
isOverride: false,
}
}
describe('executeSIEImport — defense-in-depth coverage check', () => {
it('refuses to insert a sie_imports row when mappings is empty', async () => {
const { supabase } = createQueuedMockSupabase()
const parsed = makeParsedFile()
const result = await executeSIEImport(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
parsed,
[],
{
filename: 'lookma.se',
fileContent: '#dummy',
createFiscalPeriod: false,
importOpeningBalances: false,
importTransactions: true,
},
)
expect(result.success).toBe(false)
expect(result.importId).toBeNull()
expect(result.errors.join(' ')).toMatch(/täcker inga konton/i)
})
it('refuses when mappings exist but cover none of the file\'s accounts', async () => {
const { supabase } = createQueuedMockSupabase()
const parsed = makeParsedFile()
const result = await executeSIEImport(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
parsed,
[makeMapping('9999', '9999')],
{
filename: 'wrong.se',
fileContent: '#dummy',
createFiscalPeriod: false,
importOpeningBalances: false,
importTransactions: true,
},
)
expect(result.success).toBe(false)
expect(result.importId).toBeNull()
expect(result.errors.join(' ')).toMatch(/täcker inga konton/i)
})
it('still rejects mappings with targetAccount=null (existing guard)', async () => {
const { supabase } = createQueuedMockSupabase()
const parsed = makeParsedFile()
const result = await executeSIEImport(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
parsed,
[makeMapping('6110', null), makeMapping('1930', null)],
{
filename: 'half.se',
fileContent: '#dummy',
createFiscalPeriod: false,
importOpeningBalances: false,
importTransactions: true,
},
)
expect(result.success).toBe(false)
expect(result.errors.join(' ')).toMatch(/not mapped/i)
})
})
describe('finalizeImportRecord — 0-entry downgrade', () => {
it('flips a 0-entry success to status=failed and records the reason', async () => {
const { supabase } = createQueuedMockSupabase()
const result: ImportResult = {
success: true,
importId: 'imp-1',
fiscalPeriodId: 'fp-1',
openingBalanceEntryId: null,
journalEntriesCreated: 0,
journalEntryIds: [],
errors: [],
warnings: ['100 verifikationer hoppades över med ej mappade konton'],
replacedPriorImport: null,
}
await finalizeImportRecord(
supabase as unknown as SupabaseClient,
'imp-1',
'company-1',
result,
'#dummy',
)
expect(result.success).toBe(false)
expect(result.errors.join(' ')).toMatch(/0 verifikationer/i)
})
it('leaves a successful run with entries alone', async () => {
const { supabase } = createQueuedMockSupabase()
const result: ImportResult = {
success: true,
importId: 'imp-2',
fiscalPeriodId: 'fp-2',
openingBalanceEntryId: null,
journalEntriesCreated: 42,
journalEntryIds: Array(42).fill('je'),
errors: [],
warnings: [],
replacedPriorImport: null,
}
await finalizeImportRecord(
supabase as unknown as SupabaseClient,
'imp-2',
'company-1',
result,
'#dummy',
)
expect(result.success).toBe(true)
expect(result.errors).toEqual([])
})
it('leaves a 0-voucher run alone when an OB entry was created', async () => {
const { supabase } = createQueuedMockSupabase()
const result: ImportResult = {
success: true,
importId: 'imp-3',
fiscalPeriodId: 'fp-3',
openingBalanceEntryId: 'ob-1',
journalEntriesCreated: 1,
journalEntryIds: ['ob-1'],
errors: [],
warnings: [],
replacedPriorImport: null,
}
await finalizeImportRecord(
supabase as unknown as SupabaseClient,
'imp-3',
'company-1',
result,
'#dummy',
)
expect(result.success).toBe(true)
})
})
@@ -0,0 +1,111 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
// Migration 20260529120000_sie_imports_undone_release_slot.sql extends
// the partial unique index sie_imports_company_id_file_hash_active_idx
// to also exclude 'undone'. Without this, undo_sie_import marks a row
// 'undone' but the slot stays held — the caller cannot re-import the
// same file.
async function insertSIEImport(params: {
companyId: string
userId: string
fileHash: string
status: 'pending' | 'mapped' | 'completed' | 'failed' | 'replaced' | 'undone'
fiscalPeriodId?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.sie_imports
(id, user_id, company_id, filename, file_hash, sie_type,
fiscal_year_start, fiscal_year_end, accounts_count, transactions_count,
status, fiscal_period_id, imported_at)
VALUES ($1, $2, $3, 'undone-test.se', $4, 4,
'2026-01-01', '2026-12-31', 0, 0,
$5, $6, $7)`,
[
id,
params.userId,
params.companyId,
params.fileHash,
params.status,
params.fiscalPeriodId ?? null,
params.status === 'completed' ? new Date().toISOString() : null,
],
)
return id
}
describe('sie_imports partial unique index: undone status releases the slot', () => {
it('still blocks a duplicate active row (regression guard)', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const hash = `hash-${randomUUID()}`
await insertSIEImport({ companyId, userId, fileHash: hash, status: 'completed', fiscalPeriodId })
await expect(
insertSIEImport({ companyId, userId, fileHash: hash, status: 'pending', fiscalPeriodId }),
).rejects.toThrow(/sie_imports_company_id_file_hash_active_idx/)
})
it('allows a new pending row once the prior is undone', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const hash = `hash-${randomUUID()}`
const priorId = await insertSIEImport({
companyId,
userId,
fileHash: hash,
status: 'completed',
fiscalPeriodId,
})
// Mimic undo_sie_import's terminal write (we don't run the RPC here —
// the RPC also detaches docs + deletes JEs which need richer setup).
await getPool().query(
`UPDATE public.sie_imports SET status = 'undone', replaced_at = now() WHERE id = $1`,
[priorId],
)
const newId = await insertSIEImport({
companyId,
userId,
fileHash: hash,
status: 'pending',
fiscalPeriodId,
})
expect(newId).toBeTruthy()
})
it('allows a re-import after a 0-entry vacuous import is backfilled to failed', async () => {
// Mirrors the Lookma AB recovery path: 0-entry 'completed' rows are
// backfilled to 'failed' by the same migration; the partial index
// already excludes 'failed', so a fresh re-import succeeds.
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const hash = `hash-${randomUUID()}`
const stuckId = await insertSIEImport({
companyId,
userId,
fileHash: hash,
status: 'completed',
fiscalPeriodId,
})
await getPool().query(
`UPDATE public.sie_imports SET status = 'failed' WHERE id = $1`,
[stuckId],
)
const newId = await insertSIEImport({
companyId,
userId,
fileHash: hash,
status: 'pending',
fiscalPeriodId,
})
expect(newId).toBeTruthy()
})
})
+52 -1
View File
@@ -1641,7 +1641,7 @@ async function createPendingImportRecord(
/**
* Phase 2: Finalize the import record with results and archive the SIE file.
*/
async function finalizeImportRecord(
export async function finalizeImportRecord(
supabase: SupabaseClient,
importId: string,
companyId: string,
@@ -1649,6 +1649,28 @@ async function finalizeImportRecord(
fileContent: string,
documentation?: MigrationDocumentation
): Promise<void> {
// Safety net: if the import ran without errors but didn't actually create
// any journal entries (no OB entry, no vouchers), refuse to mark it as
// 'completed'. A 'completed' row with transactions_count=0 would claim
// the (company_id, file_hash) slot in the partial unique index and the
// overlapping-period check would block any retry. Flipping to 'failed'
// (which the partial index already excludes) keeps the slot free so the
// caller can re-import the same file once the mapping is fixed.
const noEntriesCreated =
result.success &&
result.journalEntriesCreated === 0 &&
!result.openingBalanceEntryId
if (noEntriesCreated) {
result.success = false
if (result.errors.length === 0) {
result.errors.push(
'Importen skapade 0 verifikationer — markerar som misslyckad så filen ' +
'kan importeras om utan replace/undo. Granska varningarna för att se ' +
'vilka konton som behöver mappas.',
)
}
}
const status = result.success ? 'completed' : 'failed'
await supabase
@@ -1799,6 +1821,35 @@ export async function executeSIEImport(
return result
}
// Defense in depth: refuse to enter executeSIEImport when the mapping
// doesn't cover a single account present in the file. Without this guard
// a stale MCP client (or the HTTP execute route) could still drive
// importVouchers to silently skip every voucher and write a 0-entry
// 'completed' sie_imports row that holds the unique-index slot. Mirrors
// the stage-time check in gnubok_import_sie.
const sourceAccountsInFile = new Set<string>()
for (const v of parsed.vouchers) for (const l of v.lines) sourceAccountsInFile.add(l.account)
if (options.importOpeningBalances) {
for (const b of parsed.openingBalances.filter((b) => b.yearIndex === 0)) {
sourceAccountsInFile.add(b.account)
}
}
const mappedSources = new Set(
mappings.filter((m) => m.targetAccount).map((m) => m.sourceAccount),
)
const hasOverlap = [...sourceAccountsInFile].some((a) => mappedSources.has(a))
if (sourceAccountsInFile.size > 0 && !hasOverlap) {
const sample = [...sourceAccountsInFile].slice(0, 8).join(', ')
result.errors.push(
`Kontomappningarna täcker inga konton i SIE-filen. ` +
`Filen innehåller ${sourceAccountsInFile.size} unika källkonton ` +
`(t.ex. ${sample}), men inget av dem finns i mappings.sourceAccount. ` +
`Importen avbryts innan en sie_imports-rad skapas så att du kan ` +
`försöka igen med korrekta mappningar.`,
)
return result
}
// Replace mode: if a prior completed import overlaps the new SIE's fiscal
// year, mark it 'replaced' (and cancel its imported entries) before we
// try to insert. Done before checkDuplicateImport / checkDuplicatePeriodImport
+45 -7
View File
@@ -42,7 +42,7 @@ import {
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport } from '@/lib/import/sie-import'
import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
import { AccountsNotInChartError, isBookkeepingError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
import { getEmailService } from '@/lib/email/service'
@@ -661,15 +661,21 @@ async function commitMarkInvoicePaid(
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
// Route on invoice state, not the company's current accounting_method —
// an invoice booked at send under accrual must clear 1510 here even if
// the company has since switched to kontantmetoden.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
if (isRealInvoice) {
if (accountingMethod === 'accrual') {
const je = await createInvoicePaymentJournalEntry(
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
if (useCashEntry) {
const je = await createInvoiceCashEntry(
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoiceCashEntry(
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
const je = await createInvoicePaymentJournalEntry(
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
)
journalEntryId = je?.id ?? null
}
@@ -910,9 +916,14 @@ async function commitMatchTransactionInvoice(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Route on invoice state, not the company's current setting. Mirror of
// the match-invoice route fix — see that handler for the full rationale.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash' && isFullyPaid) {
if (useCashEntry) {
const je = await createInvoiceCashEntry(
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
)
@@ -2155,6 +2166,30 @@ async function commitImportSie(
}
}
async function commitUndoSieImport(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
const importId = params.import_id as string
if (!importId) {
return { error: 'import_id is required', status: 400 }
}
const result = await undoSIEImport(supabase, companyId, importId)
if (!result.success) {
return { error: result.error ?? 'SIE undo failed', status: 400 }
}
return {
data: {
import_id: importId,
deleted_entries: result.deletedEntries,
},
}
}
// ── Phase 4: arbitrary-line bookkeeping primitives ───────────────
/**
@@ -2700,6 +2735,9 @@ export async function commitPendingOperation(
case 'import_sie':
result = await commitImportSie(supabase, userId, companyId, pendingOp.params)
break
case 'undo_sie_import':
result = await commitUndoSieImport(supabase, companyId, pendingOp.params)
break
case 'create_voucher':
result = await commitCreateVoucher(supabase, userId, companyId, pendingOp.params, opts)
break
+3
View File
@@ -61,6 +61,9 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// but not the irreversible tier that year-end close / period lock occupy.
post_annual_depreciation: 'medium',
import_sie: 'high',
// Hard-deletes the import's journal entries + resets voucher sequences.
// Same destructive reach as replace_sie_import; never auto-commit.
undo_sie_import: 'high',
explain_voucher_gap: 'medium',
uncategorize_transaction: 'medium',
approve_supplier_invoice: 'high',
+7 -3
View File
@@ -180,6 +180,7 @@ export async function runReconciliation(
.select('*')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_ignored', false)
.eq('currency', currency)
if (dateFrom) query = query.gte('date', dateFrom)
@@ -262,10 +263,13 @@ export async function getReconciliationStatus(
bankAccount = '1930',
currency: string = 'SEK',
): Promise<ReconciliationStatus> {
// Get all transactions in range
// Get all transactions in range. Ignored rows are pulled too so the totals
// card still reflects what the bank actually moved, but they're excluded
// from the "unmatched" count below — the user has explicitly said they
// don't want them surfacing as something to reconcile.
let txQuery = supabase
.from('transactions')
.select('amount, journal_entry_id, reconciliation_method')
.select('amount, journal_entry_id, reconciliation_method, is_ignored')
.eq('company_id', companyId)
.eq('currency', currency)
@@ -328,7 +332,7 @@ export async function getReconciliationStatus(
).length
const unmatchedTransactionCount = (transactions || []).filter(
(tx) => tx.journal_entry_id === null
(tx) => tx.journal_entry_id === null && tx.is_ignored !== true
).length
// Unlinked GL lines count (RPC excludes source_type='opening_balance' since
+175 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { calculateSalary, calculateKarensavdrag, calculateSjuklon, calculateAvgifterRate, calculateVacationAccrual } from '../calculation-engine'
import {
calculateSalary,
calculateKarensavdrag,
calculateSjuklon,
calculateAvgifterRate,
calculateVacationAccrual,
prorateBaseSalaryForPeriod,
} from '../calculation-engine'
import type { PayrollConfig } from '../payroll-config'
import type { TaxTableRate } from '../tax-tables'
@@ -384,6 +391,173 @@ describe('calculateSalary', () => {
})
})
// ============================================================
// Partial-month employment proration
// ============================================================
//
// May 2026 calendar (Mon-Fri only):
// May 1 (Fri), May 4-8 (5), May 11-15 (5), May 18-22 (5), May 25-29 (5)
// → 21 workdays total in May.
// An employee hired May 15 (Fri) works May 15, 18-22, 25-29 → 11 workdays.
// 11 / 21 ≈ 0.5238 → 40 000 SEK × 0.5238 ≈ 20 952,38 SEK.
describe('partial-month employment proration', () => {
it('prorates base salary for an employee hired mid-month', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
// 40 000 × 11 / 21 = 20 952,38 (rounded via engine's r())
expect(result.grossSalary).toBeCloseTo(20952.38, 2)
})
it('prorates base salary for an employee terminated mid-month', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2020-01-01',
employmentEnd: '2026-05-15',
}),
config2026,
emptyTaxRates,
)
// May 1, 4-8, 11-15 = 11 workdays → 40 000 × 11 / 21
expect(result.grossSalary).toBeCloseTo(20952.38, 2)
})
it('does not prorate when the employee covers the full period', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2024-01-01',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000)
})
it('returns 0 gross when employment does not overlap the period', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-06-01',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(0)
})
it('combines employment proration with employment_degree', () => {
const result = calculateSalary(
makeBasicInput({
employmentDegree: 50,
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
// 40 000 × 50% × 11 / 21 = 20 000 × 11 / 21 ≈ 10 476,19
expect(result.grossSalary).toBeCloseTo(10476.19, 2)
})
it('skips proration when period bounds are missing (backward compat)', () => {
const result = calculateSalary(
makeBasicInput({
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000)
})
it('subtracts unpaid_leave once on top of proration (no double-counting)', () => {
// 40 000 × 11/21 (mid-month hire) 2 × 40 000/21 (two unpaid days)
// = 20 952,38 3 809,52
// = 17 142,86
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
lineItems: [
{
itemType: 'unpaid_leave',
amount: -3809.52, // = 2 × Math.round((40000/21) * 100) / 100
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: false,
},
],
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBeCloseTo(17142.86, 2)
})
})
describe('prorateBaseSalaryForPeriod', () => {
it('returns 1 when all dates are missing', () => {
expect(prorateBaseSalaryForPeriod(undefined, undefined, undefined, undefined)).toBe(1)
})
it('returns 1 when employment fully covers the period', () => {
expect(
prorateBaseSalaryForPeriod('2020-01-01', null, '2026-05-01', '2026-05-31'),
).toBe(1)
})
it('returns 11/21 for May 2026 mid-month hire on the 15th', () => {
const ratio = prorateBaseSalaryForPeriod(
'2026-05-15',
null,
'2026-05-01',
'2026-05-31',
)
expect(ratio).toBeCloseTo(11 / 21, 6)
})
it('returns 0 when employment ends before the period starts', () => {
expect(
prorateBaseSalaryForPeriod('2020-01-01', '2026-04-30', '2026-05-01', '2026-05-31'),
).toBe(0)
})
it('returns 0 when employment starts after the period ends', () => {
expect(
prorateBaseSalaryForPeriod('2026-06-01', null, '2026-05-01', '2026-05-31'),
).toBe(0)
})
})
// ============================================================
// Hardening — realistic API flow & cross-rule invariants
// ============================================================
@@ -237,11 +237,34 @@ describe('deriveAbsenceLineItems — parental', () => {
})
})
describe('deriveAbsenceLineItems — unpaid_leave', () => {
it('emits unpaid_leave line item with a per-day daily-rate deduction', () => {
const result = deriveAbsenceLineItems(
baseInput({
monthlySalary: 42000, // dailyRate = 42 000 / 21 = 2 000
periodDays: days([
['2026-04-10', 'unpaid_leave'],
['2026-04-13', 'unpaid_leave'],
]),
}),
)
const unpaid = result.lineItems.find(li => li.item_type === 'unpaid_leave')
expect(unpaid).toBeDefined()
expect(unpaid!.quantity).toBe(2)
expect(unpaid!.amount).toBe(-4000)
// false — engine's Step 3 absence sum already subtracts unpaid_leave;
// setting the flag would double-count in Step 4 totalGrossDeductions.
expect(unpaid!.is_gross_deduction).toBe(false)
expect(unpaid!.is_vacation_basis).toBe(false)
expect(result.aggregated.unpaidLeaveDays).toBe(2)
})
})
describe('deriveAbsenceLineItems — empty', () => {
it('returns empty result for no absence', () => {
const result = deriveAbsenceLineItems(baseInput())
expect(result.lineItems).toEqual([])
expect(result.aggregated).toEqual({ sickDays: 0, vabDays: 0, parentalDays: 0 })
expect(result.aggregated).toEqual({ sickDays: 0, vabDays: 0, parentalDays: 0, unpaidLeaveDays: 0 })
expect(result.flagFkReporting).toBe(false)
expect(result.flagLakarintyg).toBe(false)
})
+1
View File
@@ -37,6 +37,7 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
sick_day15_plus: '7281',
vab: '7210',
parental_leave: '7210',
unpaid_leave: '7210',
vacation: '7285',
semesterersattning: '7285',
// Travel
+123 -8
View File
@@ -42,6 +42,17 @@ export interface SalaryCalculationInput {
/** Line items */
lineItems: CalculationLineItem[]
/**
* Pay period bounds (YYYY-MM-DD). Together with employmentStart/employmentEnd
* they drive partial-month proration: an employee hired mid-period or
* terminated mid-period receives only the workday-fraction of base salary.
* When omitted, proration is skipped (ratio = 1).
*/
periodStart?: string
periodEnd?: string
employmentStart?: string
employmentEnd?: string | null
}
export interface CalculationLineItem {
@@ -134,6 +145,80 @@ function fmtKr(amount: number): string {
return `${Math.round(amount).toLocaleString('sv-SE')} kr`
}
// ============================================================
// Partial-month proration
// ============================================================
const DAY_MS = 24 * 60 * 60 * 1000
function parseIsoDateUtc(s: string): Date {
return new Date(`${s}T00:00:00Z`)
}
function maxDate(a: string, b: string): string {
return a >= b ? a : b
}
function minDate(a: string, b: string): string {
return a <= b ? a : b
}
/**
* Count MonFri days inclusive between start and end (YYYY-MM-DD). Returns 0
* when start > end. Swedish bank holidays are NOT excluded the engine uses
* the same 21-workday convention used elsewhere (monthlySalary / 21), so a
* variable workday count that excluded holidays would diverge from the
* baseline daily rate convention.
*/
function countWorkdaysInclusive(start: string, end: string): number {
if (start > end) return 0
const startMs = parseIsoDateUtc(start).getTime()
const endMs = parseIsoDateUtc(end).getTime()
const totalDays = Math.round((endMs - startMs) / DAY_MS) + 1
let workdays = 0
for (let i = 0; i < totalDays; i++) {
const d = new Date(startMs + i * DAY_MS)
const dow = d.getUTCDay() // 0 = Sun, 6 = Sat
if (dow >= 1 && dow <= 5) workdays += 1
}
return workdays
}
/**
* Fraction of the pay period the employee was actually employed, measured in
* MonFri workdays. Returns 1 when the employee was employed for the full
* period (or when employment dates / period bounds are missing). Returns 0
* when the employee was not employed at all during the period.
*
* This is the standard Swedish payroll convention for partial-month proration:
* an employee hired 2026-05-15 gets workdays-in-(May 1531) / workdays-in-May.
* Hourly employees are not prorated here they are paid for actually-worked
* hours, so the calling code passes salaryType='monthly' to gate this.
*/
export function prorateBaseSalaryForPeriod(
employmentStart: string | undefined,
employmentEnd: string | null | undefined,
periodStart: string | undefined,
periodEnd: string | undefined,
): number {
if (!periodStart || !periodEnd) return 1
if (!employmentStart) return 1
const effectiveStart = maxDate(employmentStart, periodStart)
const effectiveEnd = employmentEnd ? minDate(employmentEnd, periodEnd) : periodEnd
if (effectiveStart > effectiveEnd) return 0
// Fast path: employment fully covers the period.
if (employmentStart <= periodStart && (!employmentEnd || employmentEnd >= periodEnd)) {
return 1
}
const overlap = countWorkdaysInclusive(effectiveStart, effectiveEnd)
const total = countWorkdaysInclusive(periodStart, periodEnd)
if (total === 0) return 1
const ratio = overlap / total
if (ratio < 0) return 0
if (ratio > 1) return 1
return ratio
}
// ============================================================
// Main calculation
// ============================================================
@@ -162,13 +247,43 @@ export function calculateSalary(
// ─── Step 1: Base salary ───
let baseSalary: number
if (input.salaryType === 'monthly') {
baseSalary = r(input.monthlySalary * (input.employmentDegree / 100))
steps.push({
label: 'Grundlön',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
const degreeAdjusted = r(input.monthlySalary * (input.employmentDegree / 100))
const prorationRatio = prorateBaseSalaryForPeriod(
input.employmentStart,
input.employmentEnd,
input.periodStart,
input.periodEnd,
)
if (prorationRatio < 1 && input.periodStart && input.periodEnd) {
baseSalary = r(degreeAdjusted * prorationRatio)
const overlapStart = input.employmentStart && input.employmentStart > input.periodStart
? input.employmentStart
: input.periodStart
const overlapEnd = input.employmentEnd && input.employmentEnd < input.periodEnd
? input.employmentEnd
: input.periodEnd
steps.push({
label: 'Grundlön (proportionerad anställningsperiod)',
formula: 'månadslön × (sysselsättningsgrad / 100) × (arbetsdagar i anställning / arbetsdagar i period)',
input: {
monthly_salary: input.monthlySalary,
employment_degree: input.employmentDegree,
degree_adjusted: degreeAdjusted,
overlap_start: overlapStart,
overlap_end: overlapEnd,
proration_ratio: Math.round(prorationRatio * 10000) / 10000,
},
output: baseSalary,
})
} else {
baseSalary = degreeAdjusted
steps.push({
label: 'Grundlön',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
}
} else {
const hours = input.hoursWorked || 0
const rate = input.hourlyRate || 0
@@ -205,7 +320,7 @@ export function calculateSalary(
// ─── Step 3: Subtract absence deductions ───
const absenceItems = input.lineItems.filter(
li => ['sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'vacation'].includes(li.itemType)
li => ['sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'unpaid_leave', 'vacation'].includes(li.itemType)
)
const totalAbsence = r(absenceItems.reduce((sum, li) => sum + li.amount, 0))
if (totalAbsence !== 0) {
+16 -2
View File
@@ -49,7 +49,21 @@ export async function createSalaryRunWithEmployees(
.eq('company_id', companyId)
.eq('is_active', true)
for (const emp of employees || []) {
// Pay period bounds (inclusive) — used to skip employees whose employment
// does not overlap the run. employment_start is NOT NULL on employees;
// employment_end is nullable for ongoing employments.
const periodStart = `${params.periodYear}-${String(params.periodMonth).padStart(2, '0')}-01`
const periodEnd = new Date(Date.UTC(params.periodYear, params.periodMonth, 0))
.toISOString()
.slice(0, 10)
const eligibleEmployees = (employees || []).filter((emp) => {
if (emp.employment_start && emp.employment_start > periodEnd) return false
if (emp.employment_end && emp.employment_end < periodStart) return false
return true
})
for (const emp of eligibleEmployees) {
const baseAmount =
emp.salary_type === 'monthly'
? Math.round((emp.monthly_salary || 0) * (emp.employment_degree / 100) * 100) / 100
@@ -91,7 +105,7 @@ export async function createSalaryRunWithEmployees(
}
}
return { run: run as Record<string, unknown>, employeeCount: (employees || []).length }
return { run: run as Record<string, unknown>, employeeCount: eligibleEmployees.length }
} catch (err) {
// Compensating delete — never leave a half-populated run. Cascade removes
// any salary_run_employees / salary_line_items already inserted.
+29 -1
View File
@@ -35,6 +35,7 @@ export type AbsenceType =
| 'pregnancy'
| 'care_relative'
| 'study'
| 'unpaid_leave'
| 'other_leave'
export interface AbsenceDay {
@@ -44,7 +45,7 @@ export interface AbsenceDay {
}
export interface DerivedLineItem {
item_type: 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave'
item_type: 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave' | 'unpaid_leave'
description: string
quantity: number
amount: number
@@ -58,6 +59,7 @@ export interface AggregatedCounts {
sickDays: number
vabDays: number
parentalDays: number
unpaidLeaveDays: number
}
export interface DeriveResult {
@@ -173,6 +175,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
.map(d => d.absence_date)
const vabDays = periodDays.filter(d => d.absence_type === 'vab')
const parentalDays = periodDays.filter(d => d.absence_type === 'parental')
const unpaidLeaveDays = periodDays.filter(d => d.absence_type === 'unpaid_leave')
let flagFkReporting = false
let flagLakarintyg = false
@@ -334,12 +337,37 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
})
}
// ── Unpaid leave (tjänstledighet utan lön) ─────────────────────────────
// Each day reduces gross pay by one daily rate (monthlySalary / 21 — same
// convention used elsewhere in the engine). Not semestergrundande per SemL
// 17 § (only paid leave types accrue vacation).
//
// is_gross_deduction is deliberately false: the engine's Step 3 absence
// sum already subtracts items whose item_type is 'unpaid_leave', so setting
// the flag would double-count the amount in Step 4's gross_deduction sum.
const unpaidLeaveCount = unpaidLeaveDays.length
if (unpaidLeaveCount > 0) {
const dailyRate = r(monthlySalary / 21)
const deduction = r(dailyRate * unpaidLeaveCount)
lineItems.push({
item_type: 'unpaid_leave',
description: `Tjänstledighet utan lön (${unpaidLeaveCount} dagar)`,
quantity: unpaidLeaveCount,
amount: -deduction,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: false,
is_gross_deduction: false,
})
}
return {
lineItems,
aggregated: {
sickDays: periodSickDates.length,
vabDays: vabCount,
parentalDays: parentalCount,
unpaidLeaveDays: unpaidLeaveCount,
},
flagFkReporting,
flagLakarintyg,
+5
View File
@@ -43,6 +43,7 @@ const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
'sick_day15_plus',
'vab',
'parental_leave',
'unpaid_leave',
]
/**
@@ -598,6 +599,10 @@ export async function runSalaryCalculation(
vaxaStodStart: emp.vaxa_stod_start,
vaxaStodEnd: emp.vaxa_stod_end,
lineItems,
periodStart,
periodEnd,
employmentStart: emp.employment_start,
employmentEnd: emp.employment_end,
},
config,
taxRates.map((r) => ({
+84 -7
View File
@@ -177,6 +177,83 @@ describe('ingestTransactions', () => {
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 2b. CSV row dedupes against uncategorized enable_banking row when
// date+amount+description prefix match (Lunar CSV vs Lunar PSD2 case).
// -----------------------------------------------------------------------
it('dedupes CSV row against unbooked enable_banking row with matching description', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'ICA Maxi Solna',
external_id: 'lunar_csvhash123',
import_source: 'csv_lunar',
})
// Booked transaction map query — none
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query — one PSD2 row with matching content
enqueue({
data: [{ date: '2024-06-15', amount: -250.0, description: 'ICA Maxi Solna' }],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — external_id differs, so no match
enqueue({ data: [], error: null })
// No insert expected — row should be deduplicated at content layer
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 2c. No false positive: same date+amount but different description does
// NOT trigger content dedup — guards against the historical concern
// about unrelated transfers colliding on (date, amount) alone.
// -----------------------------------------------------------------------
it('does not dedupe when date+amount match but description differs', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
date: '2024-06-15',
amount: -250.0,
description: 'Coop Stockholm',
external_id: 'lunar_csvhash456',
import_source: 'csv_lunar',
})
const inserted = makeTransaction({
id: 'tx-no-collision',
external_id: raw.external_id,
amount: -250.0,
})
// Booked transaction map query — none
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query — a PSD2 row with same date/amount but DIFFERENT description
enqueue({
data: [{ date: '2024-06-15', amount: -250.0, description: 'ICA Maxi Solna' }],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — no match
enqueue({ data: [], error: null })
// Insert succeeds — the new row is not a duplicate
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
expect(result.transaction_ids).toEqual(['tx-no-collision'])
})
// -----------------------------------------------------------------------
// 3. Counts errors when insert fails
// -----------------------------------------------------------------------
@@ -657,7 +734,7 @@ describe('ingestTransactions', () => {
// -----------------------------------------------------------------------
// Content-based dedup: cross-source duplicate detection
// -----------------------------------------------------------------------
it('skips transactions that match already-booked ones by date+amount', async () => {
it('skips transactions that match already-booked ones by date+amount+description', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
external_id: 'psd2_conn123_tx456',
@@ -665,9 +742,9 @@ describe('ingestTransactions', () => {
amount: -250,
})
// Booked transaction map returns a booked tx with same date+amount
// Booked transaction map returns a booked tx with same date+amount+description
enqueue({
data: [{ date: '2024-06-15', amount: -250 }],
data: [{ date: '2024-06-15', amount: -250, description: raw.description }],
error: null,
})
// Unbooked bank-synced transaction map query
@@ -694,7 +771,7 @@ describe('ingestTransactions', () => {
// Booked transaction map: same date but different amount
enqueue({
data: [{ date: '2024-06-15', amount: -250 }],
data: [{ date: '2024-06-15', amount: -250, description: raw.description }],
error: null,
})
// Unbooked bank-synced transaction map query
@@ -724,12 +801,12 @@ describe('ingestTransactions', () => {
const inserted = makeTransaction({ id: 'tx-new', amount: -100 })
// Booked map: 2 existing booked transactions with same date+amount
// Booked map: 2 existing booked transactions with same date+amount+description
// So 2 of the 3 incoming should be skipped, 1 should be imported
enqueue({
data: [
{ date: '2024-06-15', amount: -100 },
{ date: '2024-06-15', amount: -100 },
{ date: '2024-06-15', amount: -100, description: raw1.description },
{ date: '2024-06-15', amount: -100, description: raw1.description },
],
error: null,
})
+44 -27
View File
@@ -16,15 +16,27 @@ interface ExistingTransactionMaps {
/** Booked transactions (any source) — consumed by any incoming raw transaction. */
booked: Map<string, number>
/**
* Unbooked enable_banking transactions only consumed when the incoming raw
* transaction is also from enable_banking. This catches reconnect duplicates
* (external_id changed but the same tx already exists from a prior sync)
* without producing false positives for unrelated CSV imports that happen to
* share a date/amount with a pending bank-synced row.
* Unbooked enable_banking transactions consumed by any incoming raw
* transaction regardless of source. Catches two cases: PSD2 reconnect
* duplicates (external_id regenerated, same tx already pending) AND
* CSV imports overlapping an active PSD2 sync (same Lunar/etc tx arriving
* twice, once via PSD2 and once via file upload).
*/
unbookedEnableBanking: Map<string, number>
}
/**
* Stable content-dedup key. Includes a normalized description prefix so the
* two-tuple (date, amount) doesn't false-positive across unrelated transfers
* that happen to share a date and amount. Lunar's CSV "Text" column and
* PSD2's `description || counterparty_name` (see enable-banking/lib/sync.ts)
* agree well enough in practice for the same underlying transaction.
*/
function contentDedupKey(date: string, amount: number | string, description: string | null | undefined): string {
const descPrefix = (description || '').toLowerCase().trim().slice(0, 24)
return `${date}|${amount}|${descPrefix}`
}
async function buildExistingTransactionMaps(
supabase: SupabaseClient,
companyId: string,
@@ -41,7 +53,7 @@ async function buildExistingTransactionMaps(
try {
const { data: bookedRows } = await supabase
.from('transactions')
.select('date, amount')
.select('date, amount, description')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
@@ -49,7 +61,7 @@ async function buildExistingTransactionMaps(
if (bookedRows) {
for (const tx of bookedRows) {
const key = `${tx.date}|${tx.amount}`
const key = contentDedupKey(tx.date, tx.amount, tx.description)
booked.set(key, (booked.get(key) || 0) + 1)
}
}
@@ -60,7 +72,7 @@ async function buildExistingTransactionMaps(
try {
const { data: unbookedBank } = await supabase
.from('transactions')
.select('date, amount')
.select('date, amount, description')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('import_source', 'enable_banking')
@@ -69,7 +81,7 @@ async function buildExistingTransactionMaps(
if (unbookedBank) {
for (const tx of unbookedBank) {
const key = `${tx.date}|${tx.amount}`
const key = contentDedupKey(tx.date, tx.amount, tx.description)
unbookedEnableBanking.set(key, (unbookedEnableBanking.get(key) || 0) + 1)
}
}
@@ -85,8 +97,12 @@ async function buildExistingTransactionMaps(
*
* Handles:
* 1. Deduplication via external_id
* 1b. Content-based dedup via date+amount against already-booked transactions
* (catches cross-source duplicates, e.g. CSV import then PSD2 sync)
* 1b. Content-based dedup (date+amount+description prefix) against already-booked
* transactions catches cross-source duplicates, e.g. PSD2 row gets booked
* before the user later re-imports the same period via CSV.
* 1c. Content-based dedup against unbooked enable_banking rows catches PSD2
* reconnect duplicates AND CSV imports overlapping an active PSD2 sync (the
* description-prefix component makes this safe to apply across sources).
* 2. Insert into transactions table
* 3. OCR/reference-based invoice matching (highest confidence)
* 4. Amount+customer fallback invoice matching
@@ -112,10 +128,11 @@ export async function ingestTransactions(
transaction_ids: [],
}
// Pre-fetch existing transactions for content-based dedup (date+amount).
// Booked rows (any source) catch cross-source duplicates; unbooked
// enable_banking rows catch reconnect duplicates but are only consumed
// by incoming enable_banking rows to avoid blocking unrelated CSV imports.
// Pre-fetch existing transactions for content-based dedup
// (date+amount+description prefix). Booked rows catch cross-source
// duplicates after they've been booked; unbooked enable_banking rows
// catch the more common case where a PSD2 row is still pending in the
// inbox when the user re-imports the same period via CSV.
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, rawTransactions)
// When rawInsertOnly is set (viewer imports), skip pre-fetching supplier
@@ -206,8 +223,8 @@ export async function ingestTransactions(
}
// 1b. Content-based dedup: skip if an already-booked transaction
// exists with the same date and amount (cross-source duplicate).
const contentKey = `${raw.date}|${raw.amount}`
// exists with the same date, amount, and description prefix.
const contentKey = contentDedupKey(raw.date, raw.amount, raw.description)
const bookedCount = existingMaps.booked.get(contentKey) || 0
if (bookedCount > 0) {
existingMaps.booked.set(contentKey, bookedCount - 1)
@@ -215,16 +232,16 @@ export async function ingestTransactions(
continue
}
// 1c. Reconnect dedup: only enable_banking rows consume slots from the
// unbooked-enable_banking map, so a CSV row with the same date/amount as
// a pending bank-synced row is not incorrectly dropped as a duplicate.
if (raw.import_source === 'enable_banking') {
const unbookedEbCount = existingMaps.unbookedEnableBanking.get(contentKey) || 0
if (unbookedEbCount > 0) {
existingMaps.unbookedEnableBanking.set(contentKey, unbookedEbCount - 1)
result.duplicates++
continue
}
// 1c. Overlap dedup: skip if an unbooked enable_banking row already
// exists with the same (date, amount, description prefix). Applies to
// any incoming source — PSD2 reconnects, CSV imports over an active
// PSD2 sync, etc. Description prefix prevents unrelated transfers from
// colliding on (date, amount) alone.
const unbookedEbCount = existingMaps.unbookedEnableBanking.get(contentKey) || 0
if (unbookedEbCount > 0) {
existingMaps.unbookedEnableBanking.set(contentKey, unbookedEbCount - 1)
result.duplicates++
continue
}
// 2. Insert new transaction (with SEK conversion for foreign currencies)
+24 -1
View File
@@ -1800,6 +1800,20 @@
"on_confirm_mark_paid_supplier": "The supplier invoice is marked as paid",
"on_confirm_mark_paid_customer": "The invoice is marked as paid",
"on_confirm_voucher": "A journal entry is created automatically",
"booking_title": "Bookkeeping",
"booking_loading": "Calculating booking...",
"booking_unavailable": "Could not preview the booking. Continue or cancel.",
"booking_debit": "Debit",
"booking_credit": "Credit",
"booking_account": "Account",
"booking_edit": "Edit",
"booking_reset": "Reset",
"booking_done_editing": "Done",
"booking_add_line": "Add line",
"booking_remove_line": "Remove line",
"booking_description_placeholder": "Description",
"booking_unbalanced": "Debit and credit must match and be greater than zero. Difference: {diff}",
"booking_account_invalid": "Account number must be 4 digits.",
"cancel": "Cancel",
"confirming": "Confirming...",
"confirm_match": "Confirm match"
@@ -3265,7 +3279,16 @@
"bank_sync_age_just_now": "just now",
"bank_sync_age_minutes": "{count} min ago",
"bank_sync_age_hours": "{count} h ago",
"bank_sync_age_days": "{count} d ago"
"bank_sync_age_days": "{count} d ago",
"bank_sync_stale_warning": "Last sync was over 36 hours ago — check the connection",
"bank_sync_latency_hint": "Banks report transactions with up to 48 hours of delay. Today's transactions often only appear the next morning.",
"bank_sync_button_now": "Sync now",
"bank_sync_button_syncing": "Syncing…",
"bank_sync_new_since_last_visit_one": "1 new bank transaction since your last visit",
"bank_sync_new_since_last_visit_many": "{count} new bank transactions since your last visit",
"bank_sync_new_since_last_visit_dismiss": "Dismiss",
"import_psd2_active_warning_title": "PSD2 is active for {bankName}",
"import_psd2_active_warning_body": "Transactions sync automatically each night. File imports are only needed for older history or when PSD2 isn't working — otherwise duplicates may occur."
},
"bookkeeping": {
"title": "Bookkeeping",
+24 -1
View File
@@ -1800,6 +1800,20 @@
"on_confirm_mark_paid_supplier": "Leverantörsfakturan markeras som betald",
"on_confirm_mark_paid_customer": "Fakturan markeras som betald",
"on_confirm_voucher": "Bokföringsverifikation skapas automatiskt",
"booking_title": "Bokföring",
"booking_loading": "Beräknar bokföring...",
"booking_unavailable": "Kunde inte förhandsgranska bokföringen. Fortsätt eller avbryt.",
"booking_debit": "Debet",
"booking_credit": "Kredit",
"booking_account": "Konto",
"booking_edit": "Redigera",
"booking_reset": "Återställ",
"booking_done_editing": "Klart",
"booking_add_line": "Lägg till rad",
"booking_remove_line": "Ta bort rad",
"booking_description_placeholder": "Beskrivning",
"booking_unbalanced": "Debet och kredit måste vara lika och större än noll. Differens: {diff}",
"booking_account_invalid": "Kontonummer måste vara 4 siffror.",
"cancel": "Avbryt",
"confirming": "Bekräftar...",
"confirm_match": "Bekräfta matchning"
@@ -3265,7 +3279,16 @@
"bank_sync_age_just_now": "just nu",
"bank_sync_age_minutes": "{count} min sedan",
"bank_sync_age_hours": "{count} tim sedan",
"bank_sync_age_days": "{count} d sedan"
"bank_sync_age_days": "{count} d sedan",
"bank_sync_stale_warning": "Senaste synk var över 36 timmar sedan — kontrollera anslutningen",
"bank_sync_latency_hint": "Banker rapporterar transaktioner med upp till 48 timmars fördröjning. Dagens transaktioner syns ofta först nästa morgon.",
"bank_sync_button_now": "Synka nu",
"bank_sync_button_syncing": "Synkar…",
"bank_sync_new_since_last_visit_one": "1 ny banktransaktion sen ditt senaste besök",
"bank_sync_new_since_last_visit_many": "{count} nya banktransaktioner sen ditt senaste besök",
"bank_sync_new_since_last_visit_dismiss": "Stäng",
"import_psd2_active_warning_title": "PSD2 är aktivt för {bankName}",
"import_psd2_active_warning_body": "Transaktioner synkas automatiskt varje natt. Filimport behövs bara för äldre historik eller om PSD2 inte fungerar — annars kan dubbletter uppstå."
},
"bookkeeping": {
"title": "Bokföring",
@@ -0,0 +1,38 @@
-- Extend the active-import partial unique index to also release the
-- (company_id, file_hash) slot when a row is marked 'undone'.
--
-- Background: 20260528120100_undo_sie_import.sql introduced the 'undone'
-- status (set by undo_sie_import RPC) but the partial unique index from
-- 20260517150000_sie_imports_active_partial_unique.sql still only
-- excluded 'replaced' and 'failed'. Net effect: a clean undo left the
-- file_hash slot held, so the caller could not re-import the same file
-- afterwards without going through replace_sie_import. Add 'undone' to
-- the predicate so undo + retry works.
--
-- Backfill: prior to this migration, the executor would mark an import
-- 'completed' even when journal_entries_created=0 (see Lookma AB support
-- case 2026-05-28). Those rows hold the slot and block any retry. The
-- companion code change in finalizeImportRecord prevents new occurrences;
-- this backfill heals existing data by flipping every 'completed' row
-- that produced literally zero entries to 'failed' (which the partial
-- index already excludes). Safe by construction — no journal entries
-- were ever created for these rows, so nothing downstream depends on
-- their 'completed' status.
DROP INDEX IF EXISTS public.sie_imports_company_id_file_hash_active_idx;
CREATE UNIQUE INDEX sie_imports_company_id_file_hash_active_idx
ON public.sie_imports (company_id, file_hash)
WHERE status <> ALL (ARRAY['replaced'::text, 'failed'::text, 'undone'::text]);
UPDATE public.sie_imports
SET status = 'failed',
error_message = COALESCE(error_message || '; ', '')
|| 'Backfill 2026-05-29: importen markerades som '
|| '''completed'' men skapade 0 verifikationer. '
|| 'Slot frigjord så filen kan importeras om med korrekta mappningar.'
WHERE status = 'completed'
AND transactions_count = 0
AND opening_balance_entry_id IS NULL;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,12 @@
-- Remember the last BAS account a user picked when paying a supplier invoice,
-- so the next mark-paid dialog can default to it instead of forcing the user
-- to re-pick 1930 / 1940 / 2018 / 2893 each time.
--
-- Free-text TEXT column — the existing chart_of_accounts CHECK constraint
-- (4 ASCII digits per BAS standard) is enforced upstream by the Zod schema
-- (accountNumber primitive in lib/api/schemas.ts).
ALTER TABLE company_settings
ADD COLUMN last_supplier_payment_account TEXT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,37 @@
-- Migration: transactions.is_ignored
--
-- Adds an "ignore" flag for bank transactions the user has chosen to suppress
-- from the bank reconciliation view (Rapporter → Bankavstämning) without
-- booking them. Use case: small ränteintäkter, opening-balance adjustments,
-- rounding noise — the user wants the row off the unmatched list but doesn't
-- want to fabricate a verifikation.
--
-- The flag is intentionally orthogonal to `is_business`:
-- - is_business=null → not yet triaged
-- - is_business=true → bokförd som affärstransaktion (has journal_entry_id)
-- - is_business=false → privat uttag (has journal_entry_id, 2013 in EF)
-- - is_ignored=true → "hide from reconciliation, never going to book it"
--
-- An ignored transaction MUST NOT have a journal_entry_id. The check
-- enforces that — once booked, the row has a verifikation and "ignored" is
-- meaningless. Unignoring is just `is_ignored=false`; safe because we never
-- created an entry to reverse.
ALTER TABLE public.transactions
ADD COLUMN IF NOT EXISTS is_ignored BOOLEAN NOT NULL DEFAULT false;
-- An ignored transaction has no journal entry. Without this constraint a
-- categorize → ignore race could leave the row both booked AND hidden from
-- the reconciliation list, which is exactly the silent-divergence pattern
-- bank reconciliation exists to prevent.
ALTER TABLE public.transactions
ADD CONSTRAINT transactions_is_ignored_no_journal_entry
CHECK (is_ignored = false OR journal_entry_id IS NULL);
-- Partial index — most rows will be is_ignored=false, only the small slice
-- of intentionally-skipped transactions need to be looked up by this flag.
CREATE INDEX IF NOT EXISTS idx_transactions_is_ignored
ON public.transactions (company_id, is_ignored)
WHERE is_ignored = true;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,53 @@
-- =============================================================================
-- Salary absence: add 'unpaid_leave' (tjänstledighet utan lön)
-- =============================================================================
--
-- The salary engine treats unpaid_leave as a per-day gross deduction (one
-- daily rate per day) and excludes it from semestergrundande tid (SemL 17 §
-- only paid leave types accrue vacation). It complements the existing partial-
-- month employment_start/employment_end proration: that handles new hires and
-- terminations; unpaid_leave handles sabbaticals/leave mid-employment.
ALTER TABLE public.salary_absence_days
DROP CONSTRAINT salary_absence_days_absence_type_check;
ALTER TABLE public.salary_absence_days
ADD CONSTRAINT salary_absence_days_absence_type_check
CHECK (absence_type IN (
'sick', -- sjukfrånvaro
'vab', -- vård av barn (tillfällig föräldrapenning)
'parental', -- föräldraledighet (föräldrapenning)
'pregnancy', -- graviditetspenning
'care_relative', -- närståendepenning
'study', -- studieledig
'unpaid_leave', -- tjänstledighet utan lön
'other_leave'
));
-- The derived absence line items (sick_karens, vab, parental_leave, …) are
-- inserted into salary_line_items by the calculator. unpaid_leave follows the
-- same pattern, so the line-item CHECK must accept it too.
ALTER TABLE public.salary_line_items
DROP CONSTRAINT salary_line_items_item_type_check;
ALTER TABLE public.salary_line_items
ADD CONSTRAINT salary_line_items_item_type_check
CHECK (item_type IN (
'monthly_salary', 'hourly_salary',
'overtime', 'overtime_50', 'overtime_100',
'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
'bonus', 'commission',
'gross_deduction_pension', 'gross_deduction_other',
'benefit_car', 'benefit_housing', 'benefit_meals',
'benefit_wellness', 'benefit_bike', 'benefit_other',
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
'vab', 'parental_leave', 'unpaid_leave',
'vacation', 'semesterersattning',
'traktamente_taxfree', 'traktamente_taxable',
'mileage_taxfree', 'mileage_taxable',
'net_deduction_advance', 'net_deduction_union',
'net_deduction_benefit_payment', 'net_deduction_other',
'correction', 'other'
));
NOTIFY pgrst, 'reload schema';
+2
View File
@@ -190,6 +190,7 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
mcc_code: null,
merchant_name: 'ICA Maxi',
reconciliation_method: null,
is_ignored: false,
receipt_id: null,
document_id: null,
import_source: null,
@@ -560,6 +561,7 @@ export function makeCompanySettings(
storno: 'A',
correction: 'A',
},
last_supplier_payment_account: null,
ore_rounding: true,
invoice_show_ocr: true,
invoice_show_bankgiro: true,
+13 -1
View File
@@ -256,6 +256,10 @@ export interface CompanySettings {
*/
default_voucher_series_per_source_type: Partial<Record<JournalEntrySourceType, string>>
// Most recently picked BAS account for supplier invoice payments — used to
// default the mark-paid dialog so repeat payments don't force re-picking.
last_supplier_payment_account: string | null
// Invoice PDF settings
ore_rounding: boolean
invoice_show_ocr: boolean
@@ -440,6 +444,11 @@ export interface Transaction {
// Reconciliation
reconciliation_method: ReconciliationMethod | null
// User has chosen to suppress this transaction from the bank reconciliation
// view without booking it. See migration
// 20260529140000_transactions_is_ignored.sql for the rationale.
is_ignored: boolean
// Import tracking
import_source: string | null
reference: string | null // OCR number, Bankgiro reference
@@ -1520,6 +1529,9 @@ export type PendingOperationType =
| 'run_currency_revaluation'
// Stream 1 Phase 1: SIE import (export is read-only)
| 'import_sie'
// SIE undo: hard-deletes the import's journal entries and releases the
// (company_id, file_hash) slot. Recovery for botched imports.
| 'undo_sie_import'
// Stream 1 Phase 1: voucher gap explanations
| 'explain_voucher_gap'
// Stream 1 Phase 1: transaction reversal
@@ -2869,7 +2881,7 @@ export type SalaryLineItemType =
| 'gross_deduction_pension' | 'gross_deduction_other'
| 'benefit_car' | 'benefit_housing' | 'benefit_meals' | 'benefit_wellness' | 'benefit_bike' | 'benefit_other'
| 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus'
| 'vab' | 'parental_leave' | 'vacation' | 'semesterersattning'
| 'vab' | 'parental_leave' | 'unpaid_leave' | 'vacation' | 'semesterersattning'
| 'traktamente_taxfree' | 'traktamente_taxable'
| 'mileage_taxfree' | 'mileage_taxable'
| 'net_deduction_advance' | 'net_deduction_union' | 'net_deduction_benefit_payment'