diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 5a38d746..11d298d2 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -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] diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 81f8cde4..d2e07e09 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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 = { function BankFileImportWizard() { const { toast } = useToast() + const tTx = useTranslations('transactions') + const { company } = useCompany() const [bankStep, setBankStep] = useState('upload') const [bankIsLoading, setBankIsLoading] = useState(false) @@ -114,6 +117,28 @@ function BankFileImportWizard() { // Import result const [ingestResult, setIngestResult] = useState(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([]) + 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 (
+ {/* Status chip for at-a-glance "auto-sync is healthy / stale / needs attention" */} + + + {/* Overlap warning — active PSD2 means file import will likely create + duplicates of transactions the nightly sync already covers. */} + {activePsd2Banks.length > 0 && ( +
+ +
+

+ {tTx('import_psd2_active_warning_title', { bankName: activePsd2Banks.join(', ') })} +

+

+ {tTx('import_psd2_active_warning_body')} +

+
+
+ )} + {/* Progress */} diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index adb365a2..5df5b4c6 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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(true) + const [vatRegistered, setVatRegistered] = useState(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

{customer.name}

- {customer.org_number && ( + {customer.customer_type !== 'individual' && customer.org_number && (

{t('org_number_label', { value: customer.org_number })}

)} - {customer.vat_number && ( + {customer.customer_type !== 'individual' && customer.vat_number && (

{t('vat_number_label', { value: customer.vat_number })}

)}
@@ -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 (
{t('vat_label')} diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index c189b26f..60057019 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -1274,6 +1274,7 @@ export default function NewInvoicePage() { notes={pendingData?.notes} numberPreview={numberPreview} oreRounding={oreRounding} + vatRegistered={vatRegistered} /> )} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 0c027382..c17dd844 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -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. diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index 3aa252bd..527a4204 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -35,6 +35,7 @@ const LINE_ITEM_TYPE_LABELS: Record = { 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)', diff --git a/app/(dashboard)/settings/banking/page.tsx b/app/(dashboard)/settings/banking/page.tsx index 01152ea0..54b69561 100644 --- a/app/(dashboard)/settings/banking/page.tsx +++ b/app/(dashboard)/settings/banking/page.tsx @@ -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 ? ( - + <> + + + ) : ( diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 7ca8ceea..13f5a450 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -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([]) 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(null) + const [markPaidPreviewFailed, setMarkPaidPreviewFailed] = useState(false) + const [isEditingLines, setIsEditingLines] = useState(false) + const [editLines, setEditLines] = useState([]) const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() const statusLabels = useMemo>(() => ({ @@ -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) => + 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 })}

+
+ + +

+ T.ex. 1930 bankkonto, 1940 övrigt bankkonto, 2018 egna uttag (EF), 2893 ägarlån (AB). +

+
+ + {/* 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) && ( +
+
+

Bokföring

+ {markPaidPreview && ( +
+ {isEditingLines && ( + + )} + +
+ )} +
+ + {markPaidPreviewFailed && !markPaidPreview && ( +

+ Kunde inte förhandsgranska bokföringen. Fortsätt eller avbryt. +

+ )} + + {markPaidPreview && !isEditingLines && ( +
+
Konto
+
+
Debet
+
Kredit
+ {markPaidPreview.lines.map((line, i) => ( +
+
{line.account_number}
+
{line.description}
+
+ {line.debit_amount > 0 ? formatCurrency(line.debit_amount, invoice.currency) : ''} +
+
+ {line.credit_amount > 0 ? formatCurrency(line.credit_amount, invoice.currency) : ''} +
+
+ ))} +
+ )} + + {markPaidPreview && isEditingLines && ( +
+ {editLines.map((line, i) => ( +
+ updateEditLine(i, { account_number: acc })} + /> + updateEditLine(i, { description: e.target.value })} + placeholder="Beskrivning" + /> +
+ + +
+ updateEditLine(i, { amount: e.target.value })} + className="text-right tabular-nums" + placeholder="0" + /> + +
+ ))} + +
+ +
+ Debet {formatCurrency(editValidation.totalDebit, invoice.currency)} + {' / '} + Kredit {formatCurrency(editValidation.totalCredit, invoice.currency)} +
+
+ + {!editValidation.isBalanced && ( +

+ Debet och kredit måste vara lika och större än noll. Differens:{' '} + {formatCurrency(Math.abs(editValidation.diff), invoice.currency)} +

+ )} + {editValidation.accountInvalid && ( +

Kontonummer måste vara 4 siffror.

+ )} +
+ )} +
+ )} +
-
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index b0d06499..af8afff2 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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))} /> - +
+ + +
+ {/* Search + view dropdown */}
diff --git a/app/api/bookkeeping/fix-cash-mismatch/route.ts b/app/api/bookkeeping/fix-cash-mismatch/route.ts new file mode 100644 index 00000000..c322654b --- /dev/null +++ b/app/api/bookkeeping/fix-cash-mismatch/route.ts @@ -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 { + // 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 }, +) diff --git a/app/api/bookkeeping/journal-entries/[id]/route.ts b/app/api/bookkeeping/journal-entries/[id]/route.ts index a716c1de..99b2658e 100644 --- a/app/api/bookkeeping/journal-entries/[id]/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/route.ts @@ -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: { diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index b81e318e..3e8574d0 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -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)) { diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts index 6d7a1cfc..f9b06eb5 100644 --- a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts +++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts @@ -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 }) } /** diff --git a/app/api/supplier-invoices/[id]/mark-paid/preview/route.ts b/app/api/supplier-invoices/[id]/mark-paid/preview/route.ts new file mode 100644 index 00000000..458e6035 --- /dev/null +++ b/app/api/supplier-invoices/[id]/mark-paid/preview/route.ts @@ -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, + }) + }, +) diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index e66798a8..e6441e94 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -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, diff --git a/app/api/transactions/[id]/ignore/route.ts b/app/api/transactions/[id]/ignore/route.ts new file mode 100644 index 00000000..d7187bb3 --- /dev/null +++ b/app/api/transactions/[id]/ignore/route.ts @@ -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 }) +} diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index 3d6d61b6..efc77cd4 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -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 diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts new file mode 100644 index 00000000..84c343c1 --- /dev/null +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -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() + 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, + }) + }, +) diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 8a531f63..37e2c77a 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -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 diff --git a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts new file mode 100644 index 00000000..e5de4b75 --- /dev/null +++ b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts @@ -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, + }) + }, +) diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index e0cf9b05..5c2fbc9c 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -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[], diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index f939b387..911ff207 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -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) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index 52249b3f..870bc37d 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -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!, diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts index 9c25be4a..efc3b489 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route.ts @@ -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!, diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index b1191588..b39ccb33 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index 3a729586..04bc3046 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -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!, diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index b34dc90e..a0ae2296 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -196,7 +196,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc {isOpen && flatList.length > 0 && (
{groupedAccounts.map((group) => (
@@ -221,7 +221,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc onMouseEnter={() => setHighlightedIndex(flatIndex)} > {account.account_number} - {account.account_name} + {account.account_name} ) })} @@ -232,7 +232,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc {/* Empty state */} {isOpen && search.trim() && flatList.length === 0 && ( -
+

Hittade inget konto som matchar.

diff --git a/components/bookkeeping/AttachmentPreviewSheet.tsx b/components/bookkeeping/AttachmentPreviewSheet.tsx index 521f15cb..454aa5c9 100644 --- a/components/bookkeeping/AttachmentPreviewSheet.tsx +++ b/components/bookkeeping/AttachmentPreviewSheet.tsx @@ -50,12 +50,22 @@ interface AttachmentPreviewSheetProps { type IntegrityState = 'valid' | 'invalid' | 'error' const integrityCache = new Map() -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({
{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 (
- {isImageType(doc.mime_type) ? ( + {isImageType(doc.mime_type, doc.file_name) ? ( ) : ( @@ -307,7 +319,7 @@ export default function AttachmentPreviewSheet({
- {isPdfType(doc.mime_type) && integrity[doc.id] === 'invalid' && ( + {isPdfType(doc.mime_type, doc.file_name) && integrity[doc.id] === 'invalid' && (
@@ -332,7 +344,7 @@ export default function AttachmentPreviewSheet({
)} - {isPdfType(doc.mime_type) && integrity[doc.id] !== 'invalid' && ( + {isPdfType(doc.mime_type, doc.file_name) && integrity[doc.id] !== 'invalid' && ( // + type="application/pdf" invokes Chrome's PDF // plugin directly.