Fix/m sprint fixes (#613)
* fix(dashboard): exclude ignored and already-triaged transactions from stale count The "Gamla transaktioner" widget counted transactions that had been ignored or already marked as is_business=true but not yet booked, so users saw a nag for a row they had already dealt with — and the /transactions inbox correctly hid it. Align the count with the inbox criterion (is_business IS NULL, is_ignored = false) so the widget clears when the row leaves the inbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): read entity_type from settings response wrapper The transactions page read entityRes.entity_type directly, but /api/settings returns { data: { entity_type, ... } }. The expression was always undefined, so setEntityType never fired and entityType stayed at its initial 'enskild_firma'. The template picker's entity_type filter then dropped every aktiebolag-tagged user template for AB customers — only entity_type='all' templates made it through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * stale templates bank sync journal entry from transaction * fixed pr comments * fixed pr comment --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fc7a46c3f2
commit
ea1bf01f1e
@@ -178,10 +178,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
const foreignTotal = hasForeignCurrency ? Math.abs(Number(foreignLines[0].amount_in_currency) || 0) : 0
|
||||
const foreignExchangeRate = hasForeignCurrency ? (Number(foreignLines[0].exchange_rate) || null) : null
|
||||
|
||||
const canCorrect =
|
||||
entry.status === 'posted' &&
|
||||
entry.source_type !== 'storno' &&
|
||||
entry.source_type !== 'correction'
|
||||
// A correction is itself a regular posted verifikation and can be corrected
|
||||
// again (BFL 5 kap. 5 § — the chain just grows). Storno entries are pure
|
||||
// reversals and cannot be corrected directly; the user walks to the latest
|
||||
// correction (or the original) and corrects that one.
|
||||
const canCorrect = entry.status === 'posted' && entry.source_type !== 'storno'
|
||||
|
||||
// Include current entry in the chain for the visualization
|
||||
const fullChain = [entry, ...chain]
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download } from 'lucide-react'
|
||||
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download, AlertTriangle } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
@@ -73,6 +73,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
|
||||
import dynamic from 'next/dynamic'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import CloudBackupCard from '@/extensions/general/cloud-backup/components/CloudBackupCard'
|
||||
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
||||
|
||||
const MigrationWizard = dynamic(
|
||||
() => import('@/components/extensions/general/ArcimMigrationWorkspace'),
|
||||
@@ -98,6 +99,8 @@ const BANK_STEP_LABELS: Record<BankFileStep, string> = {
|
||||
|
||||
function BankFileImportWizard() {
|
||||
const { toast } = useToast()
|
||||
const tTx = useTranslations('transactions')
|
||||
const { company } = useCompany()
|
||||
|
||||
const [bankStep, setBankStep] = useState<BankFileStep>('upload')
|
||||
const [bankIsLoading, setBankIsLoading] = useState(false)
|
||||
@@ -114,6 +117,28 @@ function BankFileImportWizard() {
|
||||
// Import result
|
||||
const [ingestResult, setIngestResult] = useState<IngestResult | null>(null)
|
||||
|
||||
// Active PSD2 connections — drives an overlap warning so users don't
|
||||
// accidentally upload a CSV covering periods we already sync nightly.
|
||||
const [activePsd2Banks, setActivePsd2Banks] = useState<string[]>([])
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
let cancelled = false
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('bank_connections')
|
||||
.select('bank_name')
|
||||
.eq('company_id', company.id)
|
||||
.eq('status', 'active')
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return
|
||||
const names = Array.from(new Set((data ?? []).map((r) => r.bank_name).filter(Boolean)))
|
||||
setActivePsd2Banks(names)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company?.id])
|
||||
|
||||
const steps = parseResult?.format === 'generic_csv' ? BANK_STEPS_WITH_MAPPING : BANK_STEPS
|
||||
const currentStepIndex = steps.indexOf(bankStep)
|
||||
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
||||
@@ -239,6 +264,25 @@ function BankFileImportWizard() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status chip for at-a-glance "auto-sync is healthy / stale / needs attention" */}
|
||||
<BankSyncStatusChip />
|
||||
|
||||
{/* Overlap warning — active PSD2 means file import will likely create
|
||||
duplicates of transactions the nightly sync already covers. */}
|
||||
{activePsd2Banks.length > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">
|
||||
{tTx('import_psd2_active_warning_title', { bankName: activePsd2Banks.join(', ') })}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{tTx('import_psd2_active_warning_body')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
||||
@@ -88,6 +88,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
|
||||
|
||||
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
|
||||
const reminderLevelLabel = (level: 1 | 2 | 3): string => t(`reminder_level_${level}`)
|
||||
@@ -126,14 +127,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
|
||||
setInvoice(data as InvoiceWithRelations)
|
||||
|
||||
// Fetch the öresavrundning setting so the detail view matches the PDF.
|
||||
// Fetch the öresavrundning + VAT-registration settings so the detail view
|
||||
// matches the PDF (pdf-template.tsx:792 hides org_number / personnummer
|
||||
// for private customers, and :876 suppresses the moms row when the seller
|
||||
// is not VAT-registered and the invoice carries no VAT).
|
||||
if (data.company_id) {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('ore_rounding')
|
||||
.select('ore_rounding, vat_registered')
|
||||
.eq('company_id', data.company_id)
|
||||
.maybeSingle()
|
||||
setOreRounding(settings?.ore_rounding ?? true)
|
||||
if (typeof settings?.vat_registered === 'boolean') {
|
||||
setVatRegistered(settings.vat_registered)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch reminders for this invoice
|
||||
@@ -481,10 +488,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-lg">{customer.name}</p>
|
||||
{customer.org_number && (
|
||||
{customer.customer_type !== 'individual' && customer.org_number && (
|
||||
<p className="text-muted-foreground">{t('org_number_label', { value: customer.org_number })}</p>
|
||||
)}
|
||||
{customer.vat_number && (
|
||||
{customer.customer_type !== 'individual' && customer.vat_number && (
|
||||
<p className="text-muted-foreground">{t('vat_number_label', { value: customer.vat_number })}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground">
|
||||
@@ -577,6 +584,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
.sort(([a], [b]) => b - a)
|
||||
|
||||
if (entries.length === 0) {
|
||||
if (vatRegistered === false && invoice.vat_amount === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('vat_label')}</span>
|
||||
|
||||
@@ -1274,6 +1274,7 @@ export default function NewInvoicePage() {
|
||||
notes={pendingData?.notes}
|
||||
numberPreview={numberPreview}
|
||||
oreRounding={oreRounding}
|
||||
vatRegistered={vatRegistered}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
@@ -108,8 +108,8 @@ export default async function DashboardPage() {
|
||||
supabase.from('document_attachments').select('journal_entry_id').eq('company_id', companyId).eq('is_current_version', true).not('journal_entry_id', 'is', null),
|
||||
supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30),
|
||||
supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'),
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('is_business', null),
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).eq('is_ignored', false).is('is_business', null).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]),
|
||||
supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('is_ignored', false).is('is_business', null),
|
||||
// Skatteverket tokens are user-scoped (one BankID identity per user) but
|
||||
// carry the active company_id; either filter would work — we use user_id
|
||||
// because that's what the token-store reads/writes against.
|
||||
|
||||
@@ -35,6 +35,7 @@ const LINE_ITEM_TYPE_LABELS: Record<SalaryLineItemType, string> = {
|
||||
sick_day15_plus: 'Sjuklön (dag 15+, Försäkringskassan)',
|
||||
vab: 'VAB (vård av sjukt barn)',
|
||||
parental_leave: 'Föräldraledighet',
|
||||
unpaid_leave: 'Tjänstledighet utan lön',
|
||||
vacation: 'Semester',
|
||||
semesterersattning: 'Semesterersättning',
|
||||
traktamente_taxfree: 'Traktamente (skattefritt)',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
|
||||
@@ -144,7 +145,10 @@ export default function BankingSettingsPage() {
|
||||
)}
|
||||
|
||||
{hasBankingExtension && BankingPanel ? (
|
||||
<BankingPanel />
|
||||
<>
|
||||
<BankSyncStatusChip />
|
||||
<BankingPanel />
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
|
||||
@@ -13,15 +13,47 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info } from 'lucide-react'
|
||||
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { formatDate, cn } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
|
||||
|
||||
interface EditableLine {
|
||||
account_number: string
|
||||
side: 'debit' | 'credit'
|
||||
amount: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function parseAmount(s: string): number {
|
||||
const n = Number(s.replace(',', '.'))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
interface PreviewLine {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
description: string
|
||||
}
|
||||
|
||||
interface MarkPaidPreview {
|
||||
entry_type: 'clearing' | 'cash'
|
||||
lines: PreviewLine[]
|
||||
invoice_already_booked: boolean
|
||||
accounting_method: 'accrual' | 'cash'
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -50,6 +82,8 @@ export default function SupplierInvoiceDetailPage() {
|
||||
const [payTab, setPayTab] = useState<'new' | 'existing'>('new')
|
||||
const [payAmount, setPayAmount] = useState('')
|
||||
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||
const [paymentAccount, setPaymentAccount] = useState('1930')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [duplicateCandidates, setDuplicateCandidates] = useState<
|
||||
Array<{
|
||||
@@ -60,6 +94,10 @@ export default function SupplierInvoiceDetailPage() {
|
||||
merchant_name: string | null
|
||||
}> | null
|
||||
>(null)
|
||||
const [markPaidPreview, setMarkPaidPreview] = useState<MarkPaidPreview | null>(null)
|
||||
const [markPaidPreviewFailed, setMarkPaidPreviewFailed] = useState(false)
|
||||
const [isEditingLines, setIsEditingLines] = useState(false)
|
||||
const [editLines, setEditLines] = useState<EditableLine[]>([])
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
|
||||
|
||||
const statusLabels = useMemo<Record<string, string>>(() => ({
|
||||
@@ -91,6 +129,141 @@ export default function SupplierInvoiceDetailPage() {
|
||||
fetchInvoice()
|
||||
}, [params.id])
|
||||
|
||||
// When the dialog closes, drop any in-progress edits so reopening starts
|
||||
// from the server's default booking again.
|
||||
useEffect(() => {
|
||||
if (!isPayDialogOpen) {
|
||||
setIsEditingLines(false)
|
||||
setEditLines([])
|
||||
}
|
||||
}, [isPayDialogOpen])
|
||||
|
||||
// Mirror the preview into the editable working copy. Only resets when not
|
||||
// currently editing — otherwise typing in the inputs would clobber on
|
||||
// every keystroke since the preview refetches on input change.
|
||||
useEffect(() => {
|
||||
if (!isEditingLines && markPaidPreview) {
|
||||
setEditLines(
|
||||
markPaidPreview.lines.map((l) => {
|
||||
const isDebit = l.debit_amount > 0
|
||||
return {
|
||||
account_number: l.account_number,
|
||||
side: isDebit ? 'debit' : 'credit',
|
||||
amount: String(isDebit ? l.debit_amount : l.credit_amount),
|
||||
description: l.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, [markPaidPreview, isEditingLines])
|
||||
|
||||
const editValidation = useMemo(() => {
|
||||
if (!isEditingLines) return { isBalanced: true, isValid: true, diff: 0, totalDebit: 0, totalCredit: 0, accountInvalid: false }
|
||||
const totalDebit = round2(editLines.filter((l) => l.side === 'debit').reduce((s, l) => s + parseAmount(l.amount), 0))
|
||||
const totalCredit = round2(editLines.filter((l) => l.side === 'credit').reduce((s, l) => s + parseAmount(l.amount), 0))
|
||||
const isBalanced = totalDebit === totalCredit && totalDebit > 0
|
||||
const accountInvalid = editLines.some((l) => !/^\d{4}$/.test(l.account_number.trim()))
|
||||
return {
|
||||
isBalanced,
|
||||
accountInvalid,
|
||||
isValid: isBalanced && !accountInvalid,
|
||||
diff: round2(totalDebit - totalCredit),
|
||||
totalDebit,
|
||||
totalCredit,
|
||||
}
|
||||
}, [isEditingLines, editLines])
|
||||
|
||||
const updateEditLine = (i: number, patch: Partial<EditableLine>) =>
|
||||
setEditLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const removeEditLine = (i: number) =>
|
||||
setEditLines((prev) => prev.filter((_, idx) => idx !== i))
|
||||
const addEditLine = () =>
|
||||
setEditLines((prev) => [...prev, { account_number: '', side: 'debit', amount: '', description: '' }])
|
||||
const resetEditLines = () => {
|
||||
if (!markPaidPreview) return
|
||||
setEditLines(
|
||||
markPaidPreview.lines.map((l) => {
|
||||
const isDebit = l.debit_amount > 0
|
||||
return {
|
||||
account_number: l.account_number,
|
||||
side: isDebit ? 'debit' : 'credit',
|
||||
amount: String(isDebit ? l.debit_amount : l.credit_amount),
|
||||
description: l.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Load a preview of the JE that mark-paid would post. Refetches when the
|
||||
// user changes amount or payment account so the displayed Debet/Kredit
|
||||
// lines always reflect the current dialog inputs.
|
||||
useEffect(() => {
|
||||
if (!isPayDialogOpen || !invoice) {
|
||||
setMarkPaidPreview(null)
|
||||
setMarkPaidPreviewFailed(false)
|
||||
return
|
||||
}
|
||||
const amountNum = Number(payAmount)
|
||||
if (!Number.isFinite(amountNum) || amountNum <= 0) {
|
||||
setMarkPaidPreview(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const ctrl = new AbortController()
|
||||
;(async () => {
|
||||
setMarkPaidPreviewFailed(false)
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
amount: String(amountNum),
|
||||
payment_account: paymentAccount,
|
||||
})
|
||||
const res = await fetch(
|
||||
`/api/supplier-invoices/${invoice.id}/mark-paid/preview?${qs.toString()}`,
|
||||
{ signal: ctrl.signal },
|
||||
)
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setMarkPaidPreviewFailed(true)
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as MarkPaidPreview
|
||||
if (!cancelled) setMarkPaidPreview(data)
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === 'AbortError') return
|
||||
if (!cancelled) setMarkPaidPreviewFailed(true)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
ctrl.abort()
|
||||
}
|
||||
}, [isPayDialogOpen, invoice, payAmount, paymentAccount])
|
||||
|
||||
// Load chart of accounts and remember the last picked payment account so the
|
||||
// dialog defaults to the user's previous choice instead of re-defaulting to
|
||||
// 1930 every time.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const [accountsRes, settingsRes] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts'),
|
||||
fetch('/api/settings'),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (accountsRes.ok) {
|
||||
const { data } = await accountsRes.json()
|
||||
if (Array.isArray(data)) setAccounts(data as BASAccount[])
|
||||
}
|
||||
if (settingsRes.ok) {
|
||||
const { data } = await settingsRes.json()
|
||||
const last = (data as { last_supplier_payment_account?: string | null } | null)?.last_supplier_payment_account
|
||||
if (last) setPaymentAccount(last)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleApprove() {
|
||||
setIsProcessing(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
|
||||
@@ -106,10 +279,33 @@ export default function SupplierInvoiceDetailPage() {
|
||||
|
||||
async function handleMarkPaid(force: boolean = false) {
|
||||
setIsProcessing(true)
|
||||
// When the user has edited the booking rows in this session, forward
|
||||
// them so the server validates balance and posts via createJournalEntry
|
||||
// directly. Otherwise the server picks the default routing (clearing
|
||||
// or cash) based on the SI's booking state.
|
||||
const linesPayload =
|
||||
isEditingLines && editValidation.isValid
|
||||
? editLines.map((l) => {
|
||||
const amount = round2(parseAmount(l.amount))
|
||||
return {
|
||||
account_number: l.account_number.trim(),
|
||||
debit_amount: l.side === 'debit' ? amount : 0,
|
||||
credit_amount: l.side === 'credit' ? amount : 0,
|
||||
line_description: l.description?.trim() || undefined,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate, ...(force ? { force: true } : {}) }),
|
||||
body: JSON.stringify({
|
||||
amount: parseFloat(payAmount),
|
||||
payment_date: paymentDate,
|
||||
payment_account: paymentAccount,
|
||||
...(force ? { force: true } : {}),
|
||||
...(linesPayload ? { lines: linesPayload } : {}),
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
@@ -631,11 +827,171 @@ export default function SupplierInvoiceDetailPage() {
|
||||
{t('remaining_to_pay', { amount: formatAmount(invoice.remaining_amount), currency: invoice.currency })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-account">Betalkonto</Label>
|
||||
<AccountCombobox
|
||||
value={paymentAccount}
|
||||
accounts={accounts}
|
||||
onChange={setPaymentAccount}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
T.ex. 1930 bankkonto, 1940 övrigt bankkonto, 2018 egna uttag (EF), 2893 ägarlån (AB).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bokföringspreview — visar exakt vad som kommer postas.
|
||||
Redigerbar via "Redigera"-knappen så användaren kan välja
|
||||
andra konton eller flytta belopp mellan debet/kredit. */}
|
||||
{(markPaidPreview || markPaidPreviewFailed) && (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Bokföring</p>
|
||||
{markPaidPreview && (
|
||||
<div className="flex gap-2">
|
||||
{isEditingLines && (
|
||||
<Button variant="ghost" size="sm" onClick={resetEditLines} disabled={isProcessing}>
|
||||
Återställ
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingLines((v) => !v)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isEditingLines ? 'Klart' : (
|
||||
<>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
Redigera
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{markPaidPreviewFailed && !markPaidPreview && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kunde inte förhandsgranska bokföringen. Fortsätt eller avbryt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{markPaidPreview && !isEditingLines && (
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 gap-y-1 text-sm tabular-nums">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Konto</div>
|
||||
<div />
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">Debet</div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">Kredit</div>
|
||||
{markPaidPreview.lines.map((line, i) => (
|
||||
<div key={i} className="contents">
|
||||
<div className="font-medium">{line.account_number}</div>
|
||||
<div className="text-muted-foreground truncate">{line.description}</div>
|
||||
<div className="text-right">
|
||||
{line.debit_amount > 0 ? formatCurrency(line.debit_amount, invoice.currency) : ''}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{line.credit_amount > 0 ? formatCurrency(line.credit_amount, invoice.currency) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{markPaidPreview && isEditingLines && (
|
||||
<div className="space-y-2">
|
||||
{editLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-[minmax(180px,1.6fr)_minmax(0,1fr)_140px_110px_28px] gap-2 items-center"
|
||||
>
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(acc) => updateEditLine(i, { account_number: acc })}
|
||||
/>
|
||||
<Input
|
||||
value={line.description}
|
||||
onChange={(e) => updateEditLine(i, { description: e.target.value })}
|
||||
placeholder="Beskrivning"
|
||||
/>
|
||||
<div className="inline-flex rounded-md border bg-background overflow-hidden h-9">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateEditLine(i, { side: 'debit' })}
|
||||
className={cn(
|
||||
'flex-1 px-2 text-xs font-medium transition-colors',
|
||||
line.side === 'debit' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-secondary/60',
|
||||
)}
|
||||
aria-pressed={line.side === 'debit'}
|
||||
>
|
||||
Debet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateEditLine(i, { side: 'credit' })}
|
||||
className={cn(
|
||||
'flex-1 px-2 text-xs font-medium border-l transition-colors',
|
||||
line.side === 'credit' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-secondary/60',
|
||||
)}
|
||||
aria-pressed={line.side === 'credit'}
|
||||
>
|
||||
Kredit
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={line.amount}
|
||||
onChange={(e) => updateEditLine(i, { amount: e.target.value })}
|
||||
className="text-right tabular-nums"
|
||||
placeholder="0"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeEditLine(i)}
|
||||
disabled={editLines.length <= 2}
|
||||
aria-label="Ta bort rad"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={addEditLine}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
<div className="text-xs tabular-nums text-muted-foreground">
|
||||
Debet {formatCurrency(editValidation.totalDebit, invoice.currency)}
|
||||
{' / '}
|
||||
Kredit {formatCurrency(editValidation.totalCredit, invoice.currency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!editValidation.isBalanced && (
|
||||
<p className="text-xs text-destructive">
|
||||
Debet och kredit måste vara lika och större än noll. Differens:{' '}
|
||||
{formatCurrency(Math.abs(editValidation.diff), invoice.currency)}
|
||||
</p>
|
||||
)}
|
||||
{editValidation.accountInvalid && (
|
||||
<p className="text-xs text-destructive">Kontonummer måste vara 4 siffror.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => handleMarkPaid(false)} disabled={isProcessing}>
|
||||
<Button
|
||||
onClick={() => handleMarkPaid(false)}
|
||||
disabled={isProcessing || (isEditingLines && !editValidation.isValid)}
|
||||
>
|
||||
{isProcessing ? t('processing') : t('register_payment')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ import TransactionForm from '@/components/transactions/TransactionForm'
|
||||
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
|
||||
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
|
||||
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
||||
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
|
||||
import BankSyncSinceLastVisit from '@/components/transactions/BankSyncSinceLastVisit'
|
||||
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
|
||||
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
|
||||
import InboxZeroState from '@/components/transactions/InboxZeroState'
|
||||
@@ -434,8 +436,8 @@ export default function TransactionsPage() {
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (entityRes?.entity_type) {
|
||||
setEntityType(entityRes.entity_type)
|
||||
if (entityRes?.data?.entity_type) {
|
||||
setEntityType(entityRes.data.entity_type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,7 +857,16 @@ export default function TransactionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmInvoiceMatch(opts?: { force?: boolean; expected_journal_entry_id?: string }) {
|
||||
async function handleConfirmInvoiceMatch(opts?: {
|
||||
force?: boolean
|
||||
expected_journal_entry_id?: string
|
||||
lines?: Array<{
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description?: string
|
||||
}>
|
||||
}) {
|
||||
if (!selectedTransaction) return
|
||||
const isSupplier = !!selectedTransaction.potential_supplier_invoice
|
||||
const isCustomer = !!selectedTransaction.potential_invoice
|
||||
@@ -880,6 +891,12 @@ export default function TransactionsPage() {
|
||||
body.expected_journal_entry_id = opts.expected_journal_entry_id
|
||||
}
|
||||
}
|
||||
// User-edited journal entry rows from the match dialog. Forwarded
|
||||
// verbatim; the server validates balance and posts via
|
||||
// createJournalEntry directly. Default routing applies when omitted.
|
||||
if (opts?.lines && opts.lines.length >= 2) {
|
||||
body.lines = opts.lines
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -1054,127 +1071,29 @@ export default function TransactionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectInvoiceFromPicker(invoice: Invoice & { customer?: Customer }) {
|
||||
function handleSelectInvoiceFromPicker(invoice: Invoice & { customer?: Customer }) {
|
||||
if (!invoicePickerTransaction) return
|
||||
// Don't POST directly from the picker. Route through the confirm dialog
|
||||
// so the user sees the JE preview (Debet 1930 / Kredit 1510, or the cash
|
||||
// variant) before the booking is created. Same UX as the auto-suggested
|
||||
// path. Closes the picker and opens the match dialog with the picked
|
||||
// invoice attached as potential_invoice.
|
||||
const tx = invoicePickerTransaction
|
||||
setIsMatchingFromPicker(true)
|
||||
try {
|
||||
const response = await fetch(`/api/transactions/${tx.id}/match-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invoice_id: invoice.id }),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Fakturamatchning misslyckades',
|
||||
description: getErrorMessage(result, { context: 'transaction' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsMatchingFromPicker(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Faktura matchad',
|
||||
description: `Faktura ${invoice.invoice_number ?? ''} markerad som betald`,
|
||||
})
|
||||
|
||||
setInvoicePickerOpen(false)
|
||||
setInvoicePickerTransaction(null)
|
||||
setExitingIds((prev) => new Set(prev).add(tx.id))
|
||||
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
|
||||
setTimeout(() => {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === tx.id
|
||||
? {
|
||||
...t,
|
||||
invoice_id: invoice.id,
|
||||
potential_invoice_id: null,
|
||||
potential_invoice: undefined,
|
||||
is_business: true,
|
||||
category: (result.category ?? 'income_services') as TransactionCategory,
|
||||
journal_entry_id: result.journal_entry_id,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(tx.id)
|
||||
return next
|
||||
})
|
||||
setIsMatchingFromPicker(false)
|
||||
}, 350)
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Matchning misslyckades',
|
||||
description: t('match_failed_with_invoice'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsMatchingFromPicker(false)
|
||||
}
|
||||
setInvoicePickerOpen(false)
|
||||
setInvoicePickerTransaction(null)
|
||||
setSelectedTransaction({ ...tx, potential_invoice: invoice })
|
||||
setMatchDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleSelectSupplierInvoiceFromPicker(invoice: SupplierInvoice & { supplier?: Supplier }) {
|
||||
function handleSelectSupplierInvoiceFromPicker(invoice: SupplierInvoice & { supplier?: Supplier }) {
|
||||
if (!supplierInvoicePickerTransaction) return
|
||||
// Route through the confirm dialog so the supplier-side JE preview
|
||||
// (Debet 2440 / Kredit 1930, or kontant-variant) is shown before commit.
|
||||
const tx = supplierInvoicePickerTransaction
|
||||
setIsMatchingSupplierFromPicker(true)
|
||||
try {
|
||||
const response = await fetch(`/api/transactions/${tx.id}/match-supplier-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_invoice_id: invoice.id }),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Matchning misslyckades',
|
||||
description: getErrorMessage(result, { context: 'transaction' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsMatchingSupplierFromPicker(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Leverantörsfaktura matchad',
|
||||
description: `Faktura ${invoice.supplier_invoice_number ?? ''} markerad som betald`,
|
||||
})
|
||||
|
||||
setSupplierInvoicePickerOpen(false)
|
||||
setSupplierInvoicePickerTransaction(null)
|
||||
setExitingIds((prev) => new Set(prev).add(tx.id))
|
||||
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
|
||||
setTimeout(() => {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === tx.id
|
||||
? {
|
||||
...t,
|
||||
supplier_invoice_id: invoice.id,
|
||||
is_business: true,
|
||||
journal_entry_id: result.journal_entry_id ?? t.journal_entry_id,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(tx.id)
|
||||
return next
|
||||
})
|
||||
setIsMatchingSupplierFromPicker(false)
|
||||
}, 350)
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Matchning misslyckades',
|
||||
description: 'Transaktionen kunde inte matchas med leverantörsfakturan. Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsMatchingSupplierFromPicker(false)
|
||||
}
|
||||
setSupplierInvoicePickerOpen(false)
|
||||
setSupplierInvoicePickerTransaction(null)
|
||||
setSelectedTransaction({ ...tx, potential_supplier_invoice: invoice })
|
||||
setMatchDialogOpen(true)
|
||||
}
|
||||
|
||||
function openInvoiceMatchPicker(transaction: TransactionWithInvoice) {
|
||||
@@ -1666,7 +1585,11 @@ export default function TransactionsPage() {
|
||||
onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))}
|
||||
/>
|
||||
|
||||
<BankSyncStatusChip />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BankSyncStatusChip />
|
||||
<BankSyncNowButton />
|
||||
</div>
|
||||
<BankSyncSinceLastVisit />
|
||||
|
||||
{/* Search + view dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* GET /api/bookkeeping/fix-cash-mismatch → list affected payments
|
||||
* POST /api/bookkeeping/fix-cash-mismatch → remediate one payment (or all)
|
||||
*
|
||||
* Targeted fix for the cash/clearing routing bug. The old matcher chose its
|
||||
* journal entry shape from the company's current accounting_method instead
|
||||
* of from invoice.journal_entry_id, so customers who sent invoices under
|
||||
* accrual (Dr 1510 / Cr 30xx + 26xx on send) and then matched a bank
|
||||
* receipt after the company had flipped to kontantmetoden ended up with:
|
||||
* - 1510 Kundfordran NEVER credited (orphan receivable on the books)
|
||||
* - 30xx Försäljning AND 26xx Utgående moms double-counted
|
||||
* - momsdeklaration would over-report output VAT
|
||||
*
|
||||
* Detection: any invoice_payments row whose payment journal entry has
|
||||
* source_type='invoice_cash_payment' while the underlying invoice carries
|
||||
* its own (still-active) accrual JE.
|
||||
*
|
||||
* Remediation per affected payment:
|
||||
* 1. reverseEntry(payment_je) — storno cancels Dr 1930 / Cr 30xx / Cr 26xx
|
||||
* 2. createInvoicePaymentJournalEntry — posts the correct Dr 1930 / Cr 1510
|
||||
* 3. Re-link invoice_payments + transactions to the new JE
|
||||
*
|
||||
* Net effect on the books: 30xx and 26xx are restored to their correct
|
||||
* (single-count) amounts, 1510 is cleared, 1930 nets to a single debit,
|
||||
* invoice keeps status='paid', transaction keeps invoice_id linkage.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
type AffectedPayment = {
|
||||
payment_id: string
|
||||
payment_journal_entry_id: string
|
||||
invoice_id: string
|
||||
invoice_number: string | null
|
||||
counterparty_name: string | null
|
||||
amount: number
|
||||
payment_date: string
|
||||
transaction_id: string | null
|
||||
invoice_journal_entry_id: string
|
||||
}
|
||||
|
||||
async function findAffected(
|
||||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<AffectedPayment[]> {
|
||||
// 1. Find payment JEs that took the (now-wrong) cash path.
|
||||
const { data: cashPaymentEntries, error: jeErr } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, source_id, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'invoice_cash_payment')
|
||||
.eq('status', 'posted')
|
||||
if (jeErr) throw jeErr
|
||||
if (!cashPaymentEntries || cashPaymentEntries.length === 0) return []
|
||||
|
||||
// 2. For each, the source_id is the invoice; affected iff that invoice
|
||||
// ALSO has its own journal_entry_id (i.e. 1510 was booked on send).
|
||||
const invoiceIds = Array.from(new Set(cashPaymentEntries.map((e) => e.source_id).filter(Boolean)))
|
||||
if (invoiceIds.length === 0) return []
|
||||
|
||||
const { data: invoices, error: invErr } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, journal_entry_id, customer:customers(name)')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', invoiceIds)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
if (invErr) throw invErr
|
||||
const invoiceMap = new Map(
|
||||
(invoices ?? []).map((i) => [
|
||||
i.id as string,
|
||||
{
|
||||
invoice_number: (i.invoice_number as string | null) ?? null,
|
||||
invoice_journal_entry_id: i.journal_entry_id as string,
|
||||
counterparty_name: ((i.customer as { name?: string | null } | null)?.name) ?? null,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
// 3. Pull the invoice_payments rows so we can show + later re-link.
|
||||
const affectedJeIds = cashPaymentEntries
|
||||
.filter((e) => invoiceMap.has(e.source_id as string))
|
||||
.map((e) => e.id as string)
|
||||
if (affectedJeIds.length === 0) return []
|
||||
|
||||
const { data: payments, error: payErr } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('id, invoice_id, journal_entry_id, amount, payment_date, transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', affectedJeIds)
|
||||
if (payErr) throw payErr
|
||||
|
||||
return (payments ?? []).map((p) => {
|
||||
const inv = invoiceMap.get(p.invoice_id as string)!
|
||||
return {
|
||||
payment_id: p.id as string,
|
||||
payment_journal_entry_id: p.journal_entry_id as string,
|
||||
invoice_id: p.invoice_id as string,
|
||||
invoice_number: inv.invoice_number,
|
||||
counterparty_name: inv.counterparty_name,
|
||||
amount: p.amount as number,
|
||||
payment_date: p.payment_date as string,
|
||||
transaction_id: (p.transaction_id as string | null) ?? null,
|
||||
invoice_journal_entry_id: inv.invoice_journal_entry_id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'bookkeeping.fix_cash_mismatch.list',
|
||||
async (_request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
try {
|
||||
const affected = await findAffected(supabase, companyId!)
|
||||
return NextResponse.json({ affected })
|
||||
} catch (err) {
|
||||
log.error('failed to detect cash-mismatch payments', err as Error)
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const PostSchema = z.object({
|
||||
// Either a single payment to fix, or omit to fix all currently detected.
|
||||
payment_id: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'bookkeeping.fix_cash_mismatch.apply',
|
||||
async (request, ctx) => {
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
body = {}
|
||||
}
|
||||
const parsed = PostSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return errorResponseFromCode('VALIDATION_ERROR', log, { requestId })
|
||||
}
|
||||
const { payment_id } = parsed.data
|
||||
|
||||
let targets: AffectedPayment[]
|
||||
try {
|
||||
const all = await findAffected(supabase, companyId!)
|
||||
targets = payment_id ? all.filter((p) => p.payment_id === payment_id) : all
|
||||
} catch (err) {
|
||||
log.error('failed to detect targets', err as Error)
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
return NextResponse.json({ fixed: 0, results: [] })
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
payment_id: string
|
||||
ok: boolean
|
||||
old_journal_entry_id: string
|
||||
storno_journal_entry_id?: string
|
||||
new_journal_entry_id?: string
|
||||
error?: string
|
||||
}> = []
|
||||
|
||||
for (const t of targets) {
|
||||
try {
|
||||
// Storno the wrong cash entry. This reverses Dr 1930 / Cr 30xx / Cr
|
||||
// 26xx by posting the mirror, restoring revenue + VAT to their pre-
|
||||
// match (correctly-counted-once) state.
|
||||
const storno = await reverseEntry(supabase, companyId!, user.id, t.payment_journal_entry_id)
|
||||
|
||||
// Re-fetch the invoice so we have currency / exchange rate metadata
|
||||
// for the clearing entry. Customer name is best-effort.
|
||||
const { data: inv, error: invErr } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(name)')
|
||||
.eq('id', t.invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (invErr || !inv) throw invErr ?? new Error('invoice missing')
|
||||
|
||||
const clearing = await createInvoicePaymentJournalEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
inv as Invoice,
|
||||
t.payment_date,
|
||||
undefined,
|
||||
(inv.customer as { name?: string } | null)?.name ?? t.counterparty_name ?? undefined,
|
||||
t.amount,
|
||||
)
|
||||
if (!clearing) throw new Error('clearing entry creation returned null')
|
||||
|
||||
// Re-link the invoice_payments row to the new (correct) JE.
|
||||
const { error: relinkPayErr } = await supabase
|
||||
.from('invoice_payments')
|
||||
.update({ journal_entry_id: clearing.id })
|
||||
.eq('id', t.payment_id)
|
||||
.eq('company_id', companyId)
|
||||
if (relinkPayErr) throw relinkPayErr
|
||||
|
||||
// Re-link the transaction too, so /transactions reflects the correct
|
||||
// voucher when the user clicks through.
|
||||
if (t.transaction_id) {
|
||||
const { error: relinkTxErr } = await supabase
|
||||
.from('transactions')
|
||||
.update({ journal_entry_id: clearing.id })
|
||||
.eq('id', t.transaction_id)
|
||||
.eq('company_id', companyId)
|
||||
if (relinkTxErr) {
|
||||
log.warn('failed to relink transaction; voucher chain still correct via payment row', {
|
||||
transactionId: t.transaction_id,
|
||||
error: relinkTxErr.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
payment_id: t.payment_id,
|
||||
ok: true,
|
||||
old_journal_entry_id: t.payment_journal_entry_id,
|
||||
storno_journal_entry_id: storno.id,
|
||||
new_journal_entry_id: clearing.id,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('remediation failed for payment', err as Error, { paymentId: t.payment_id })
|
||||
results.push({
|
||||
payment_id: t.payment_id,
|
||||
ok: false,
|
||||
old_journal_entry_id: t.payment_journal_entry_id,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
fixed: results.filter((r) => r.ok).length,
|
||||
failed: results.filter((r) => !r.ok).length,
|
||||
results,
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
|
||||
|
||||
const logger = createLogger('journal-entries')
|
||||
|
||||
@@ -56,6 +57,18 @@ export async function DELETE(
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// Read source_type/source_id BEFORE deleting so we can revert the linked
|
||||
// invoice/supplier_invoice status afterwards. The GL row gets cancelled by
|
||||
// delete_last_voucher but the invoice's paid status lives outside the GL
|
||||
// and would otherwise stay stuck on "paid" after the user deletes the
|
||||
// payment voucher.
|
||||
const { data: entryBefore } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, source_type, source_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const { data, error } = await supabase.rpc('delete_last_voucher', {
|
||||
p_company_id: companyId,
|
||||
p_entry_id: id,
|
||||
@@ -69,6 +82,14 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
if (entryBefore) {
|
||||
try {
|
||||
await syncInvoiceStatusFromPaymentEntry(supabase, companyId, entryBefore)
|
||||
} catch (syncError) {
|
||||
logger.warn('payment status sync failed after delete', { entryId: id, error: syncError })
|
||||
}
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.deleted',
|
||||
payload: {
|
||||
|
||||
@@ -133,6 +133,15 @@ export const POST = withRouteContext(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Drive the JE shape from the invoice's actual booking state, not from
|
||||
// the current accounting_method setting. If the invoice was booked at
|
||||
// send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510 —
|
||||
// otherwise the receivable orphans and 30xx + VAT double-count. Only
|
||||
// when there is no prior JE (pure kontantmetoden) do we recognise
|
||||
// revenue + VAT here.
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
@@ -155,7 +164,7 @@ export const POST = withRouteContext(
|
||||
details: { paymentDate },
|
||||
})
|
||||
}
|
||||
const sourceType = accountingMethod === 'accrual' ? 'invoice_paid' : 'invoice_cash_payment'
|
||||
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
@@ -168,18 +177,18 @@ export const POST = withRouteContext(
|
||||
}
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, input)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else if (accountingMethod === 'accrual') {
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
|
||||
exchangeRateDifference, invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
} else if (useCashEntry) {
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
|
||||
entityType, invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
|
||||
exchangeRateDifference, invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
|
||||
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -35,7 +36,20 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
// Strip the encrypted personnummer ciphertext before sending to the browser
|
||||
// — replace it with the YYYYMMDD-XXXX masked form so the page can render
|
||||
// identity without exposing the suffix or the raw cipher blob.
|
||||
const masked = {
|
||||
...data,
|
||||
employee: data.employee
|
||||
? {
|
||||
...data.employee,
|
||||
personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)),
|
||||
}
|
||||
: data.employee,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: masked })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* GET /api/supplier-invoices/[id]/mark-paid/preview?amount=...&payment_account=...
|
||||
*
|
||||
* Read-only preview of the journal entry mark-paid would post. Mirrors the
|
||||
* POST handler's routing: if the SI has a registration JE, payment clears
|
||||
* 2440. Otherwise (kontantmetoden + never booked), expense + input VAT
|
||||
* book here.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
type PreviewLine = {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const QuerySchema = z.object({
|
||||
amount: z.coerce.number().positive(),
|
||||
payment_account: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'supplier_invoice.mark_paid_preview',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const url = new URL(request.url)
|
||||
const parsed = QuerySchema.safeParse({
|
||||
amount: url.searchParams.get('amount'),
|
||||
payment_account: url.searchParams.get('payment_account') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return errorResponseFromCode('VALIDATION_ERROR', log, { requestId })
|
||||
}
|
||||
const { amount, payment_account } = parsed.data
|
||||
|
||||
const { data: invoice, error: invErr } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, items:supplier_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (invErr || !invoice) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, last_supplier_payment_account')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const creditAccount =
|
||||
payment_account ||
|
||||
(settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account ||
|
||||
'1930'
|
||||
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
const lines: PreviewLine[] = []
|
||||
let entryType: 'clearing' | 'cash' = 'clearing'
|
||||
|
||||
if (useCashEntry) {
|
||||
entryType = 'cash'
|
||||
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
|
||||
const items = si.items ?? []
|
||||
let totalAmountSek = 0
|
||||
let totalVatSek = 0
|
||||
if (items.length > 0) {
|
||||
for (const it of items) {
|
||||
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
|
||||
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
|
||||
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
|
||||
lines.push({
|
||||
account_number: expenseAcct,
|
||||
debit_amount: Math.round((lineTotal - vat) * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: it.description ?? 'Kostnad',
|
||||
})
|
||||
totalAmountSek += lineTotal
|
||||
totalVatSek += vat
|
||||
}
|
||||
} else {
|
||||
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
|
||||
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
|
||||
lines.push({
|
||||
account_number: '4000',
|
||||
debit_amount: Math.round(subSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: 'Kostnad',
|
||||
})
|
||||
totalAmountSek = subSek + vatSek
|
||||
totalVatSek = vatSek
|
||||
}
|
||||
if (totalVatSek > 0) {
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: Math.round(totalVatSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: 'Ingående moms',
|
||||
})
|
||||
}
|
||||
lines.push({
|
||||
account_number: creditAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalAmountSek * 100) / 100,
|
||||
description: 'Utbetalning',
|
||||
})
|
||||
} else {
|
||||
const rounded = Math.round(amount * 100) / 100
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: rounded,
|
||||
credit_amount: 0,
|
||||
description: 'Kvittning leverantörsskuld',
|
||||
})
|
||||
lines.push({
|
||||
account_number: creditAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: rounded,
|
||||
description: 'Utbetalning',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
entry_type: entryType,
|
||||
lines,
|
||||
invoice_already_booked: siAlreadyBooked,
|
||||
accounting_method: accountingMethod,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
@@ -124,16 +125,54 @@ export const POST = withRouteContext(
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.select('accounting_method, last_supplier_payment_account')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const paymentAccount = body.payment_account || undefined
|
||||
|
||||
// Route on the supplier invoice's actual booking state, not the current
|
||||
// accounting_method. A supplier invoice that was booked at receipt under
|
||||
// accrual (Dr expense + 2641 / Cr 2440) must clear 2440 here even if the
|
||||
// company has since switched to kontantmetoden — otherwise the supplier
|
||||
// debt orphans on 2440 and expense + input VAT double-count.
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
if (body.lines) {
|
||||
const totalDebit = body.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = body.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', opLog, {
|
||||
requestId,
|
||||
details: { totalDebit, totalCredit },
|
||||
})
|
||||
}
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', opLog, {
|
||||
requestId,
|
||||
details: { paymentDate },
|
||||
})
|
||||
}
|
||||
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
|
||||
const desc = invoice.supplier?.name
|
||||
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
|
||||
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
|
||||
const je = await createJournalEntry(supabase, companyId!, user.id, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: desc,
|
||||
source_type: sourceType,
|
||||
source_id: invoice.id,
|
||||
lines: body.lines,
|
||||
})
|
||||
if (je) journalEntryId = je.id
|
||||
} else if (useCashEntry) {
|
||||
const journalEntry = await createSupplierInvoiceCashEntry(
|
||||
supabase, companyId!, user.id,
|
||||
invoice as SupplierInvoice,
|
||||
@@ -141,6 +180,7 @@ export const POST = withRouteContext(
|
||||
paymentDate,
|
||||
invoice.supplier?.supplier_type || 'swedish_business',
|
||||
invoice.supplier?.name,
|
||||
paymentAccount,
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else {
|
||||
@@ -150,6 +190,7 @@ export const POST = withRouteContext(
|
||||
paymentAmount, paymentDate,
|
||||
body.exchange_rate_difference,
|
||||
invoice.supplier?.name,
|
||||
paymentAccount,
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
}
|
||||
@@ -247,6 +288,19 @@ export const POST = withRouteContext(
|
||||
opLog.warn('supplier_invoice.paid event emission failed', err as Error)
|
||||
}
|
||||
|
||||
// Remember the chosen payment account so the next dialog can default to it.
|
||||
// Only update when the caller actually picked one — the MCP / agent path
|
||||
// sends no payment_account and shouldn't churn this setting.
|
||||
if (paymentAccount && paymentAccount !== settings?.last_supplier_payment_account) {
|
||||
const { error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.update({ last_supplier_payment_account: paymentAccount })
|
||||
.eq('company_id', companyId)
|
||||
if (settingsError) {
|
||||
opLog.warn('failed to persist last_supplier_payment_account', settingsError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: newStatus,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/ignore
|
||||
*
|
||||
* Mark a bank transaction as ignored so it stops surfacing in the bank
|
||||
* reconciliation view (and other "to book" funnels) without creating a
|
||||
* verifikation. Use case: tiny ränteintäkter, rounding noise, opening-balance
|
||||
* artefacts — anything the user wants off the unmatched list but doesn't want
|
||||
* to fabricate a journal entry for.
|
||||
*
|
||||
* Refuses when the transaction is already booked; once a verifikation exists,
|
||||
* the proper way to revisit it is /uncategorize (storno).
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id, is_ignored')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (transaction.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaktionen är redan bokförd — använd Avmatcha eller backa verifikationen för att ändra status.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
if (transaction.is_ignored) {
|
||||
return NextResponse.json({ success: true, already_ignored: true })
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ is_ignored: true })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/transactions/[id]/ignore
|
||||
*
|
||||
* Reverse a previous ignore. The row comes back into the unmatched list with
|
||||
* no further side effects — we never created a verifikation, so there's
|
||||
* nothing to storno.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ is_ignored: false })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -393,6 +393,56 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect(body.remaining_amount).toBe(7500)
|
||||
})
|
||||
|
||||
it('cash method ignores cash entry when invoice was already booked (accrual→cash migration)', async () => {
|
||||
// Regression: customer sent invoices under accrual (1510 was debited on
|
||||
// send), then switched to kontantmetoden before the bank receipt arrived.
|
||||
// Old logic posted createInvoiceCashEntry — orphaning 1510 and double-
|
||||
// counting revenue + VAT. Fix: route on invoice.journal_entry_id, not on
|
||||
// the current accounting_method setting.
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = {
|
||||
...makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
paid_amount: 0,
|
||||
}),
|
||||
// journal_entry_id lives on the DB column but not the TS Invoice type;
|
||||
// attach via spread so the test row mirrors a real accrual-booked
|
||||
// invoice the matcher will read.
|
||||
journal_entry_id: 'je-send-on-accrual',
|
||||
}
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' })
|
||||
|
||||
// The PDF re-attach block runs because invoice.journal_entry_id is set;
|
||||
// returning null skips the attach without aborting the match.
|
||||
enqueue({ data: null, error: null }) // document_attachments lookup
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
enqueue({ data: null, error: null }) // insert invoice_payments
|
||||
enqueue({ data: null, error: null }) // update transaction
|
||||
enqueue({ data: null, error: null }) // logMatchEvent
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ invoice_status: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.invoice_status).toBe('paid')
|
||||
// Must clear 1510, not re-recognise revenue + VAT
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
|
||||
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when tx amount exceeds invoice remaining', async () => {
|
||||
// Tx is +12 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
|
||||
// would push paid_amount past invoice.total; the new guard rejects so
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* GET /api/transactions/[id]/match-invoice/preview?invoice_id=...
|
||||
*
|
||||
* Returns the journal entry lines that match-invoice would create for this
|
||||
* (transaction, invoice) pair. Read-only — does not stage or write anything.
|
||||
*
|
||||
* The shape mirrors the routing decision in the POST handler: if the invoice
|
||||
* was already booked (invoice.journal_entry_id is set, i.e. 1510 is on the
|
||||
* books), we preview the clearing entry (Dr 1930 / Cr 1510). Only when the
|
||||
* invoice was never booked AND the company is on kontantmetoden AND the
|
||||
* receipt fully pays the invoice do we preview the cash entry (Dr 1930 /
|
||||
* Cr 30xx / Cr 26xx).
|
||||
*
|
||||
* The UI uses this to show the user the exact lines before they confirm —
|
||||
* the lack of any preview was part of the reported bug.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
import { z } from 'zod'
|
||||
|
||||
type PreviewLine = {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const QuerySchema = z.object({
|
||||
invoice_id: z.string().uuid(),
|
||||
})
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'transaction.match_invoice_preview',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id: transactionId } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const url = new URL(request.url)
|
||||
const parsed = QuerySchema.safeParse({ invoice_id: url.searchParams.get('invoice_id') })
|
||||
if (!parsed.success) {
|
||||
return errorResponseFromCode('VALIDATION_ERROR', log, {
|
||||
requestId,
|
||||
details: { field: 'invoice_id', message: 'invoice_id must be a UUID' },
|
||||
})
|
||||
}
|
||||
const { invoice_id } = parsed.data
|
||||
|
||||
const { data: transaction, error: txErr } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, currency')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (txErr || !transaction) {
|
||||
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: invoice, error: invErr } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
.eq('id', invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (invErr || !invoice) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const paidAmount = transaction.amount
|
||||
const currentRemaining =
|
||||
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
|
||||
const newRemaining = Math.max(
|
||||
0,
|
||||
Math.round((currentRemaining - paidAmount) * 100) / 100,
|
||||
)
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
|
||||
|
||||
const lines: PreviewLine[] = []
|
||||
let entryType: 'clearing' | 'cash' = 'clearing'
|
||||
|
||||
if (useCashEntry) {
|
||||
entryType = 'cash'
|
||||
// Mirror createInvoiceCashEntry: per-rate revenue + VAT credits, 1930 debit.
|
||||
const inv = invoice as Invoice & { items?: InvoiceItem[] }
|
||||
const items = inv.items ?? []
|
||||
const isForeign = inv.currency !== 'SEK'
|
||||
|
||||
// Per-item rate aggregation (matches generatePerRateLines semantics).
|
||||
// InvoiceItem.line_total is the gross-net-line; the subtotal contribution
|
||||
// is line_total minus that line's vat_amount.
|
||||
const byRate = new Map<number, { subtotal: number; vat: number }>()
|
||||
if (items.length > 0) {
|
||||
for (const it of items) {
|
||||
const rate = it.vat_rate ?? 25
|
||||
const itemVat = resolveSekAmount(it.vat_amount, null, inv.currency, inv.exchange_rate)
|
||||
const itemTotal = resolveSekAmount(it.line_total, null, inv.currency, inv.exchange_rate)
|
||||
const sub = Math.round((itemTotal - itemVat) * 100) / 100
|
||||
const bucket = byRate.get(rate) ?? { subtotal: 0, vat: 0 }
|
||||
bucket.subtotal += sub
|
||||
bucket.vat += itemVat
|
||||
byRate.set(rate, bucket)
|
||||
}
|
||||
} else {
|
||||
// Fallback to invoice-level totals
|
||||
const sub = resolveSekAmount(inv.subtotal, inv.subtotal_sek, inv.currency, inv.exchange_rate)
|
||||
const vat = resolveSekAmount(inv.vat_amount, inv.vat_amount_sek, inv.currency, inv.exchange_rate)
|
||||
byRate.set(inv.vat_rate ?? 25, { subtotal: sub, vat })
|
||||
}
|
||||
|
||||
const creditLines: PreviewLine[] = []
|
||||
for (const [rate, totals] of byRate) {
|
||||
const vatTreatment = totals.vat > 0
|
||||
? (rate === 25 ? 'standard_25' : rate === 12 ? 'reduced_12' : rate === 6 ? 'reduced_6' : inv.vat_treatment)
|
||||
: inv.vat_treatment
|
||||
const revenueAcct = getRevenueAccount(vatTreatment, entityType)
|
||||
creditLines.push({
|
||||
account_number: revenueAcct,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totals.subtotal * 100) / 100,
|
||||
description: `Försäljning ${rate}%`,
|
||||
})
|
||||
if (totals.vat > 0) {
|
||||
creditLines.push({
|
||||
account_number: getOutputVatAccount(vatTreatment),
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totals.vat * 100) / 100,
|
||||
description: `Utgående moms ${rate}%`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totalCredits = creditLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
const cashDebit = isForeign
|
||||
? Math.round(totalCredits * 100) / 100
|
||||
: resolveSekAmount(inv.total, inv.total_sek, inv.currency, inv.exchange_rate)
|
||||
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: Math.round(cashDebit * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: 'Inbetalning från bank',
|
||||
})
|
||||
lines.push(...creditLines)
|
||||
} else {
|
||||
// Clearing entry: Dr 1930 / Cr 1510 at the paid amount in SEK.
|
||||
const inv = invoice as Invoice
|
||||
const bookedSek = resolveSekAmount(paidAmount, null, inv.currency, inv.exchange_rate)
|
||||
const amount = Math.round(bookedSek * 100) / 100
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
description: 'Inbetalning från bank',
|
||||
})
|
||||
lines.push({
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
description: 'Kvittning kundfordran',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
entry_type: entryType,
|
||||
lines,
|
||||
invoice_already_booked: invoiceAlreadyBooked,
|
||||
accounting_method: accountingMethod,
|
||||
is_fully_paid: isFullyPaid,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
@@ -41,7 +41,7 @@ export const POST = withRouteContext(
|
||||
operation: 'transaction.match_invoice',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { invoice_id, force, expected_journal_entry_id } = validation.data
|
||||
const { invoice_id, force, expected_journal_entry_id, lines: customLines } = validation.data
|
||||
|
||||
const txLog = log.child({ transactionId, invoiceId: invoice_id })
|
||||
|
||||
@@ -251,21 +251,66 @@ export const POST = withRouteContext(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Drive the JE shape from the INVOICE'S booking state, not from the
|
||||
// company's current accounting_method setting. If the invoice was already
|
||||
// booked at send (Dr 1510 / Cr 30xx + VAT) we MUST clear 1510 here —
|
||||
// otherwise the receivable stays orphaned and 30xx + VAT get double-
|
||||
// counted. This happens when a company sent invoices under accrual,
|
||||
// then flipped to kontantmetoden before payment arrived.
|
||||
// Only when the invoice carries no prior JE (pure kontantmetoden, no
|
||||
// receivable on the books) do we recognise revenue + VAT here.
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'cash' && isFullyPaid) {
|
||||
if (customLines) {
|
||||
// User-edited rows from the match dialog. Validate balance, then
|
||||
// post via createJournalEntry directly. source_type still derives
|
||||
// from the routing decision so downstream payment-sync (which keys
|
||||
// off invoice_paid / invoice_cash_payment) keeps working.
|
||||
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
|
||||
requestId,
|
||||
details: { totalDebit, totalCredit },
|
||||
})
|
||||
}
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
|
||||
if (!fiscalPeriodId) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
|
||||
requestId,
|
||||
details: { paymentDate: transaction.date },
|
||||
})
|
||||
}
|
||||
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
|
||||
const desc = invoice.customer?.name
|
||||
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
|
||||
: `Inbetalning kundfaktura ${invoice.invoice_number}`
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: desc,
|
||||
source_type: sourceType,
|
||||
source_id: invoice.id,
|
||||
lines: customLines,
|
||||
})
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else if (useCashEntry) {
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
supabase, companyId, user.id, invoice as Invoice, transaction.date,
|
||||
entityType, invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Accrual or cash partial: clearing entry against 1510. The cash-method
|
||||
// partial path is intentional — under kontantmetoden 1510 has no prior
|
||||
// balance, so this leaves a credit on 1510 that gets resolved when the
|
||||
// final payment lands and createInvoiceCashEntry runs.
|
||||
// Clearing entry against 1510. Covers accrual, cash-with-prior-JE
|
||||
// (mid-stream switch), and cash partial. The cash partial path is
|
||||
// intentional — under kontantmetoden 1510 has no prior balance, so
|
||||
// partials leave a credit on 1510 that gets resolved on final
|
||||
// payment when createInvoiceCashEntry would normally run.
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
supabase, companyId, user.id, invoice as Invoice, transaction.date,
|
||||
undefined, invoice.customer?.name, paidAmount,
|
||||
@@ -356,7 +401,11 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('MATCH_INVOICE_ALREADY_PAID', txLog, { requestId })
|
||||
}
|
||||
|
||||
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
|
||||
// The "intäkt bokförs vid slutbetalning" note only applies to genuine
|
||||
// kontantmetoden partials — invoices that were never booked. When the
|
||||
// invoice was booked under accrual, the clearing entry already handles
|
||||
// the partial cleanly and the note would be misleading.
|
||||
const paymentNotes = (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid)
|
||||
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
|
||||
: null
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* GET /api/transactions/[id]/match-supplier-invoice/preview?supplier_invoice_id=...
|
||||
*
|
||||
* Read-only preview of the journal entry lines that match-supplier-invoice
|
||||
* would create. Mirrors the routing decision in the POST handler: if the
|
||||
* supplier invoice already has a registration JE (2440 posted at receipt),
|
||||
* payment clears 2440. Only true kontantmetoden SIs (no registration JE)
|
||||
* book expense + input VAT here.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
type PreviewLine = {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const QuerySchema = z.object({
|
||||
supplier_invoice_id: z.string().uuid(),
|
||||
})
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'transaction.match_supplier_invoice_preview',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id: transactionId } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const url = new URL(request.url)
|
||||
const parsed = QuerySchema.safeParse({
|
||||
supplier_invoice_id: url.searchParams.get('supplier_invoice_id'),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return errorResponseFromCode('VALIDATION_ERROR', log, {
|
||||
requestId,
|
||||
details: { field: 'supplier_invoice_id', message: 'supplier_invoice_id must be a UUID' },
|
||||
})
|
||||
}
|
||||
const { supplier_invoice_id } = parsed.data
|
||||
|
||||
const { data: transaction, error: txErr } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, currency')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (txErr || !transaction) {
|
||||
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: invoice, error: invErr } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, items:supplier_invoice_items(*)')
|
||||
.eq('id', supplier_invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (invErr || !invoice) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, last_supplier_payment_account')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const paymentAccount =
|
||||
(settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account || '1930'
|
||||
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
const lines: PreviewLine[] = []
|
||||
let entryType: 'clearing' | 'cash' = 'clearing'
|
||||
|
||||
if (useCashEntry) {
|
||||
entryType = 'cash'
|
||||
const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] }
|
||||
const items = si.items ?? []
|
||||
|
||||
// Mirror createSupplierInvoiceCashEntry: per-item expense debit + VAT
|
||||
// debit + bank credit. We only need a faithful preview, not exact
|
||||
// account-mapping fidelity — show one aggregate expense line per item
|
||||
// (or a single fallback line if items are missing).
|
||||
let totalAmountSek = 0
|
||||
let totalVatSek = 0
|
||||
if (items.length > 0) {
|
||||
for (const it of items) {
|
||||
const lineTotal = resolveSekAmount(it.line_total, null, si.currency, si.exchange_rate)
|
||||
const vat = resolveSekAmount(it.vat_amount, null, si.currency, si.exchange_rate)
|
||||
const expenseAcct = (it as { expense_account?: string | null }).expense_account ?? '4000'
|
||||
lines.push({
|
||||
account_number: expenseAcct,
|
||||
debit_amount: Math.round((lineTotal - vat) * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: it.description ?? 'Kostnad',
|
||||
})
|
||||
totalAmountSek += lineTotal
|
||||
totalVatSek += vat
|
||||
}
|
||||
} else {
|
||||
const subSek = resolveSekAmount(si.subtotal, si.subtotal_sek, si.currency, si.exchange_rate)
|
||||
const vatSek = resolveSekAmount(si.vat_amount, si.vat_amount_sek, si.currency, si.exchange_rate)
|
||||
lines.push({
|
||||
account_number: '4000',
|
||||
debit_amount: Math.round(subSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: 'Kostnad',
|
||||
})
|
||||
totalAmountSek = subSek + vatSek
|
||||
totalVatSek = vatSek
|
||||
}
|
||||
|
||||
if (totalVatSek > 0) {
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: Math.round(totalVatSek * 100) / 100,
|
||||
credit_amount: 0,
|
||||
description: 'Ingående moms',
|
||||
})
|
||||
}
|
||||
|
||||
lines.push({
|
||||
account_number: paymentAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalAmountSek * 100) / 100,
|
||||
description: 'Utbetalning från bank',
|
||||
})
|
||||
} else {
|
||||
// Clearing: Dr 2440 / Cr 1930 (or chosen payment account).
|
||||
const si = invoice as SupplierInvoice
|
||||
const amountSek = resolveSekAmount(
|
||||
Math.abs(transaction.amount),
|
||||
null,
|
||||
transaction.currency,
|
||||
null,
|
||||
)
|
||||
const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate)
|
||||
const amount = Math.round(Math.min(amountSek, total) * 100) / 100
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
description: 'Kvittning leverantörsskuld',
|
||||
})
|
||||
lines.push({
|
||||
account_number: paymentAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
description: 'Utbetalning från bank',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
entry_type: entryType,
|
||||
lines,
|
||||
invoice_already_booked: siAlreadyBooked,
|
||||
accounting_method: accountingMethod,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
@@ -32,7 +33,7 @@ export const POST = withRouteContext(
|
||||
operation: 'transaction.match_supplier_invoice',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { supplier_invoice_id } = validation.data
|
||||
const { supplier_invoice_id, lines: customLines } = validation.data
|
||||
|
||||
const txLog = log.child({ transactionId, supplierInvoiceId: supplier_invoice_id })
|
||||
|
||||
@@ -163,13 +164,22 @@ export const POST = withRouteContext(
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Route on the supplier invoice's actual booking state — if 2440 was
|
||||
// posted at receipt (accrual), the match must clear 2440 regardless of
|
||||
// the company's current setting. Only true kontantmetoden invoices
|
||||
// (no registration JE) book expense + input VAT here.
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// Cash method (kontantmetoden) collapses registration + payment into a
|
||||
// single entry that credits 1930 at sum(expenses_SEK). It has no
|
||||
// exchange_rate_difference path — if the actual bank SEK differs from
|
||||
// the invoice's booked SEK, the 1930 credit won't match the bank
|
||||
// transaction and we'd silently leave a reconciliation gap. Block the
|
||||
// combination and ask the user to switch to accrual or do a manual JE.
|
||||
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
|
||||
// Only applies to true cash-method invoices — accrual-booked invoices
|
||||
// never hit the cash branch.
|
||||
if (useCashEntry && exchangeRateDifference !== 0) {
|
||||
return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
@@ -184,7 +194,36 @@ export const POST = withRouteContext(
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
if (customLines) {
|
||||
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
|
||||
requestId,
|
||||
details: { totalDebit, totalCredit },
|
||||
})
|
||||
}
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
|
||||
if (!fiscalPeriodId) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
|
||||
requestId,
|
||||
details: { paymentDate: transaction.date },
|
||||
})
|
||||
}
|
||||
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
|
||||
const desc = invoice.supplier?.name
|
||||
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
|
||||
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: desc,
|
||||
source_type: sourceType,
|
||||
source_id: invoice.id,
|
||||
lines: customLines,
|
||||
})
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else if (useCashEntry) {
|
||||
const journalEntry = await createSupplierInvoiceCashEntry(
|
||||
supabase, companyId, user.id, invoice as SupplierInvoice,
|
||||
(invoice.items || []) as SupplierInvoiceItem[],
|
||||
|
||||
@@ -20,20 +20,47 @@ export async function GET(request: Request) {
|
||||
const currency = searchParams.get('currency') || undefined
|
||||
const dateFrom = searchParams.get('date_from') || undefined
|
||||
const dateTo = searchParams.get('date_to') || undefined
|
||||
// When set, return only ignored rows — used by the reconciliation view to
|
||||
// surface a "Visa ignorerade" undo list. The default (no param) behaviour
|
||||
// continues to exclude ignored rows from unmatched results.
|
||||
const onlyIgnored = searchParams.get('only_ignored') === 'true'
|
||||
// account_number is accepted for API symmetry with the reconciliation status
|
||||
// endpoint; transactions don't carry a cash_account FK today (PSD2 account
|
||||
// identity is embedded in external_id), so we use it to derive a default
|
||||
// currency when the caller didn't supply one. Anything more precise needs
|
||||
// the cash_account_id backfill tracked as Tier 4.
|
||||
const accountNumberParam = searchParams.get('account_number') || undefined
|
||||
|
||||
let derivedCurrency = currency
|
||||
if (!derivedCurrency && accountNumberParam) {
|
||||
const { data: cashAccount } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('currency')
|
||||
.eq('company_id', companyId)
|
||||
.eq('ledger_account', accountNumberParam)
|
||||
.maybeSingle()
|
||||
if (cashAccount?.currency) derivedCurrency = cashAccount.currency as string
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method')
|
||||
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method, is_ignored')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
// unmatched and reconciled are mutually exclusive — unmatched wins if both set
|
||||
if (unmatched) {
|
||||
query = query.is('journal_entry_id', null)
|
||||
// Hide rows the user has explicitly suppressed from the reconciliation
|
||||
// view. Other callers (e.g. BookDirectlyDialog) also benefit — once
|
||||
// ignored, the row stops surfacing in the "to book" funnel everywhere.
|
||||
if (!onlyIgnored) query = query.eq('is_ignored', false)
|
||||
} else if (reconciled) {
|
||||
query = query.not('journal_entry_id', 'is', null)
|
||||
}
|
||||
|
||||
if (currency) query = query.eq('currency', currency)
|
||||
if (onlyIgnored) query = query.eq('is_ignored', true)
|
||||
|
||||
if (derivedCurrency) query = query.eq('currency', derivedCurrency)
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
|
||||
|
||||
@@ -240,6 +240,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const entityType = ((settings as { entity_type?: string } | null)?.entity_type ??
|
||||
'enskild_firma') as EntityType
|
||||
|
||||
// The JE shape is driven by the invoice's actual booking state, not the
|
||||
// company's current accounting_method. An invoice that was booked at send
|
||||
// under accrual (Dr 1510) must be cleared at payment regardless of where
|
||||
// the setting sits today — otherwise the receivable orphans and 30xx +
|
||||
// VAT double-count. Only true kontantmetoden invoices (never booked)
|
||||
// recognise revenue + VAT here.
|
||||
const invoiceAlreadyBooked = !!(typed as { journal_entry_id?: string | null }).journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// Compute the would-be payment amount. Default path (no customLines):
|
||||
// use remaining_amount, not total — protects against over-crediting AR
|
||||
// when a concurrent partial payment slips through the pre-flight check
|
||||
@@ -363,7 +372,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
input,
|
||||
)
|
||||
journalEntryId = entry?.id ?? null
|
||||
} else if (accountingMethod === 'cash') {
|
||||
} else if (useCashEntry) {
|
||||
const entry = await createInvoiceCashEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
createSupplierInvoiceCashEntry,
|
||||
createSupplierInvoicePaymentEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
@@ -117,6 +117,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
let bodyPaymentDate: string | undefined
|
||||
let exchangeRateDifference: number | undefined
|
||||
let bodyNotes: string | undefined
|
||||
let customLines:
|
||||
| Array<{ account_number: string; debit_amount: number; credit_amount: number; line_description?: string }>
|
||||
| undefined
|
||||
if (rawBody) {
|
||||
const parsed = MarkSupplierInvoicePaidSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
@@ -134,6 +137,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
bodyPaymentDate = parsed.data.payment_date
|
||||
exchangeRateDifference = parsed.data.exchange_rate_difference
|
||||
bodyNotes = parsed.data.notes
|
||||
customLines = parsed.data.lines
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
@@ -276,14 +280,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.maybeSingle()
|
||||
const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual'
|
||||
|
||||
// FX-required validation. Under accrual the registration JE used the
|
||||
// invoice's exchange rate to compute subtotal_sek; the payment JE has to
|
||||
// book any rate delta to 3960 / 7960 (BAS) or AP will carry a stranded
|
||||
// 2440 balance after the bank line clears. The pitfall docs warn about
|
||||
// this — enforce it.
|
||||
// Route on the supplier invoice's actual booking state — if 2440 was
|
||||
// posted at receipt, payment must clear 2440 regardless of the current
|
||||
// accounting_method.
|
||||
const siAlreadyBooked = !!(typed as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// FX-required validation. Whenever the registration JE used the invoice's
|
||||
// exchange rate to compute subtotal_sek (i.e. the SI was booked under
|
||||
// accrual or migrated from accrual), the payment JE has to book any rate
|
||||
// delta to 3960 / 7960 or AP will carry a stranded 2440 balance after the
|
||||
// bank line clears. Gated on the booking state, not the current setting.
|
||||
if (
|
||||
typed.currency !== 'SEK' &&
|
||||
accountingMethod === 'accrual' &&
|
||||
!useCashEntry &&
|
||||
exchangeRateDifference === undefined
|
||||
) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
@@ -328,7 +338,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// Strict-mode: book the JE FIRST. Failure aborts before any SI mutation.
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
if (customLines) {
|
||||
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { totalDebit, totalCredit },
|
||||
})
|
||||
}
|
||||
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { payment_date: paymentDate },
|
||||
})
|
||||
}
|
||||
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
|
||||
const desc = supplierRow?.name
|
||||
? `Utbetalning leverantörsfaktura ${typed.supplier_invoice_number}, ${supplierRow.name}`
|
||||
: `Utbetalning leverantörsfaktura ${typed.supplier_invoice_number}`
|
||||
const entry = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: desc,
|
||||
source_type: sourceType,
|
||||
source_id: typed.id,
|
||||
lines: customLines,
|
||||
})
|
||||
journalEntryId = entry?.id ?? null
|
||||
} else if (useCashEntry) {
|
||||
const entry = await createSupplierInvoiceCashEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
@@ -123,7 +123,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
},
|
||||
})
|
||||
}
|
||||
const { invoice_id, force, expected_journal_entry_id } = parsed.data
|
||||
const { invoice_id, force, expected_journal_entry_id, lines: customLines } = parsed.data
|
||||
const txLog = ctx.log.child({ transactionId: txId, invoiceId: invoice_id })
|
||||
|
||||
const { data: transaction, error: fetchTxErr } = await ctx.supabase
|
||||
@@ -309,15 +309,23 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const entityType: EntityType =
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Reject cash-method partial payments. Under kontantmetoden, utgående
|
||||
// moms must be reported in the period of actual receipt (ML 13 kap 8 §);
|
||||
// the partial-payment branch below uses createInvoicePaymentJournalEntry
|
||||
// (the accrual-style 1510/1930 clearing entry), which doesn't model the
|
||||
// per-installment moms event. Rather than silently over-report moms,
|
||||
// refuse the operation and document the constraint. Full payments
|
||||
// (isFullyPaid=true) flow through createInvoiceCashEntry which IS the
|
||||
// correct kontantmetod path.
|
||||
if (accountingMethod === 'cash' && !isFullyPaid) {
|
||||
// The JE shape is driven by the INVOICE'S booking state, not the
|
||||
// company's current setting. If the invoice already has a JE (Dr 1510
|
||||
// posted at send), the match must clear 1510 — otherwise the receivable
|
||||
// stays orphaned and 30xx + 26xx get double-counted. The current
|
||||
// accounting_method only governs the cash-method fast path for
|
||||
// invoices that were never booked.
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
|
||||
|
||||
// Reject cash-method partial payments ONLY for pure kontantmetoden
|
||||
// invoices (no prior JE). Under kontantmetoden utgående moms must be
|
||||
// reported in the period of actual receipt (ML 13 kap 8 §); the
|
||||
// partial-payment branch uses the accrual-style clearing entry which
|
||||
// doesn't model the per-installment moms event. When the invoice was
|
||||
// already booked under accrual, the clearing entry IS the correct
|
||||
// partial path regardless of the company's current setting.
|
||||
if (!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
@@ -340,7 +348,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// strictly worse than a clean failure to retry.
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
if (accountingMethod === 'cash' && isFullyPaid) {
|
||||
if (customLines) {
|
||||
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { totalDebit, totalCredit },
|
||||
})
|
||||
}
|
||||
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, transaction.date)
|
||||
if (!fiscalPeriodId) {
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { payment_date: transaction.date },
|
||||
})
|
||||
}
|
||||
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
|
||||
const desc = invoice.customer?.name
|
||||
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
|
||||
: `Inbetalning kundfaktura ${invoice.invoice_number}`
|
||||
const je = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: desc,
|
||||
source_type: sourceType,
|
||||
source_id: invoice.id,
|
||||
lines: customLines,
|
||||
})
|
||||
journalEntryId = je?.id ?? null
|
||||
} else if (useCashEntry) {
|
||||
const je = await createInvoiceCashEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
@@ -436,8 +473,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// The "intäkt bokförs vid slutbetalning" note only applies to genuine
|
||||
// kontantmetoden partials — never-booked invoices. When the invoice was
|
||||
// booked under accrual, the clearing entry handles the partial cleanly
|
||||
// and the note would be misleading.
|
||||
const paymentNotes =
|
||||
accountingMethod === 'cash' && !isFullyPaid
|
||||
!invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid
|
||||
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
|
||||
: null
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
@@ -103,7 +103,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
},
|
||||
})
|
||||
}
|
||||
const { supplier_invoice_id } = parsed.data
|
||||
const { supplier_invoice_id, lines: customLines } = parsed.data
|
||||
const txLog = ctx.log.child({ transactionId: txId, supplierInvoiceId: supplier_invoice_id })
|
||||
|
||||
const { data: transaction, error: fetchTxErr } = await ctx.supabase
|
||||
@@ -209,7 +209,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.single()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
|
||||
// Route on the supplier invoice's actual booking state. An invoice
|
||||
// booked at receipt (registration_journal_entry_id set) must clear
|
||||
// 2440 regardless of the company's current setting.
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
if (useCashEntry && exchangeRateDifference !== 0) {
|
||||
return v1ErrorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
@@ -224,7 +230,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// payment JE can't be created. See the parallel comment in match-invoice.
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
if (customLines) {
|
||||
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { totalDebit, totalCredit },
|
||||
})
|
||||
}
|
||||
const fiscalPeriodId = await findFiscalPeriod(ctx.supabase, ctx.companyId!, transaction.date)
|
||||
if (!fiscalPeriodId) {
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { payment_date: transaction.date },
|
||||
})
|
||||
}
|
||||
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
|
||||
const desc = invoice.supplier?.name
|
||||
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
|
||||
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
|
||||
const je = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: desc,
|
||||
source_type: sourceType,
|
||||
source_id: invoice.id,
|
||||
lines: customLines,
|
||||
})
|
||||
if (je) journalEntryId = je.id
|
||||
} else if (useCashEntry) {
|
||||
const je = await createSupplierInvoiceCashEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
|
||||
Reference in New Issue
Block a user