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:
@@ -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]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1274,6 +1274,7 @@ export default function NewInvoicePage() {
|
||||
notes={pendingData?.notes}
|
||||
numberPreview={numberPreview}
|
||||
oreRounding={oreRounding}
|
||||
vatRegistered={vatRegistered}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "{search}"
|
||||
</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¤cy=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||
fetch(`/api/transactions?reconciled=true¤cy=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 på <AccountNumber number="1930" /> i perioden</span>
|
||||
<span>Bokfört på <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) på <AccountNumber number="1930" />:{' '}
|
||||
Ingående balans (IB) på <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 på <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 på <AccountNumber number="1930" /> ({glLines.length})
|
||||
Omatchade verifikationer på <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 på <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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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 ───────────────
|
||||
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Mon–Fri 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
|
||||
* Mon–Fri 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 15–31) / 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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user