Fix/supp ag fb (#1023)

* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
This commit is contained in:
Mattsson
2026-07-15 15:53:15 +02:00
committed by GitHub
parent a558c75678
commit 072aedeaf9
116 changed files with 5708 additions and 669 deletions
+34 -27
View File
@@ -22,7 +22,7 @@ import {
Package,
Wrench,
Edit2,
Archive,
Trash2,
Loader2,
Lock,
} from 'lucide-react'
@@ -55,6 +55,7 @@ export default function ArticleDetailPage({
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
@@ -127,36 +128,38 @@ export default function ArticleDetailPage({
}
}
async function handleDeactivate() {
async function handleDelete() {
if (!article) return
const ok = await confirmAction({
title: t('deactivate_confirm_title', { name: article.name }),
description: t('deactivate_confirm_description'),
confirmLabel: t('deactivate_confirm_label'),
title: t('delete_confirm_title', { name: article.name }),
description: t('delete_confirm_description'),
confirmLabel: t('delete_confirm_label'),
variant: 'destructive',
})
if (!ok) return
setIsDeleting(true)
try {
const response = await fetch(`/api/articles/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Deactivate failed')
}
await throwOnStructuredError(response)
toast({
title: t('deactivated_title'),
title: t('deleted_title'),
description: article.name,
})
router.push('/articles')
} catch {
} catch (err) {
const body = (err as { body?: unknown }).body
toast({
title: t('deactivate_failed_title'),
description: t('retry'),
title: t('delete_failed_title'),
description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }),
variant: 'destructive',
})
} finally {
setIsDeleting(false)
}
}
@@ -175,7 +178,7 @@ export default function ArticleDetailPage({
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div>
<Link
href="/articles"
@@ -203,7 +206,7 @@ export default function ArticleDetailPage({
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
@@ -214,19 +217,23 @@ export default function ArticleDetailPage({
{canWrite ? <Edit2 className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
{t('edit')}
</Button>
{article.active && (
<Button
variant="outline"
size="sm"
onClick={handleDeactivate}
className="text-destructive hover:text-destructive"
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Archive className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
{t('deactivate')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleDelete}
className="min-h-10 text-destructive hover:text-destructive"
disabled={isDeleting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isDeleting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : canWrite ? (
<Trash2 className="h-4 w-4 mr-1" />
) : (
<Lock className="h-4 w-4 mr-1" />
)}
{t('delete')}
</Button>
</div>
</div>
-1
View File
@@ -102,7 +102,6 @@ function ArticlesPageInner() {
.from('articles')
.select('*')
.eq('company_id', company.id)
.eq('active', true)
.order('name', { ascending: true })
if (error) {
+13 -3
View File
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { maskCustomerPersonalNumber } from '@/lib/customers/mask-personal-number'
import CustomerForm from '@/components/customers/CustomerForm'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
@@ -275,7 +276,7 @@ export default function CustomerDetailPage({
</CardContent>
</Card>
{/* Business details */}
{/* Customer details */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('section_business')}</CardTitle>
@@ -287,12 +288,20 @@ export default function CustomerDetailPage({
{customer.customer_number}
</div>
)}
{customer.org_number && (
{customer.customer_type !== 'individual' && customer.org_number && (
<div className="text-sm">
<span className="text-muted-foreground">{t('label_org_number')} </span>
{customer.org_number}
</div>
)}
{customer.customer_type === 'individual' && (customer.personal_number || customer.org_number) && (
<div className="text-sm">
<span className="text-muted-foreground">{t('label_personal_number')} </span>
<span className="tabular-nums">
{maskCustomerPersonalNumber(customer.personal_number || customer.org_number)}
</span>
</div>
)}
{customer.vat_number && (
<div className="text-sm flex items-center gap-2">
<span className="text-muted-foreground">{t('label_vat')} </span>
@@ -306,7 +315,7 @@ export default function CustomerDetailPage({
<span className="text-muted-foreground">{t('label_payment_terms')} </span>
{t('payment_terms_value', { days: customer.default_payment_terms || 30 })}
</div>
{!customer.customer_number && !customer.org_number && !customer.vat_number && (
{!customer.customer_number && !customer.org_number && !customer.personal_number && !customer.vat_number && (
<p className="text-sm text-muted-foreground">{t('no_business_info')}</p>
)}
</CardContent>
@@ -409,6 +418,7 @@ export default function CustomerDetailPage({
country: customer.country || undefined,
org_number: customer.org_number || undefined,
vat_number: customer.vat_number || undefined,
personal_number: customer.personal_number || undefined,
default_payment_terms: customer.default_payment_terms || undefined,
notes: customer.notes || undefined,
}}
+35 -3
View File
@@ -15,6 +15,10 @@ import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { Loader2, ArrowLeft, AlertTriangle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import type { Invoice, InvoiceItem, Customer } from '@/types'
interface InvoiceWithRelations extends Invoice {
@@ -24,6 +28,8 @@ interface InvoiceWithRelations extends Invoice {
export default function CreateCreditNotePage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { isSandbox } = useCompany()
const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
@@ -35,6 +41,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
const [isSubmitting, setIsSubmitting] = useState(false)
const [reason, setReason] = useState('')
const [confirmText, setConfirmText] = useState('')
const [createdCreditNote, setCreatedCreditNote] = useState<InvoiceWithRelations | null>(null)
const [showSendPrompt, setShowSendPrompt] = useState(false)
useEffect(() => {
fetchInvoice()
@@ -114,14 +122,17 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
throw new Error(data.error || t('create_failed_fallback'))
}
const { data: creditNote } = await response.json()
const { data: creditNote } = await response.json() as { data: InvoiceWithRelations }
toast({
title: t('created_toast_title'),
description: t('created_toast_description', { number: creditNote.invoice_number }),
description: creditNote.invoice_number
? t('created_toast_description', { number: creditNote.invoice_number })
: undefined,
})
router.push(`/invoices/${creditNote.id}`)
setCreatedCreditNote(creditNote)
setShowSendPrompt(true)
} catch (error) {
toast({
title: t('create_failed_title'),
@@ -146,9 +157,30 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
}
const customer = invoice.customer
const sendMode = getCreditNoteSendMode({
customerHasEmail: !!createdCreditNote?.customer.email,
isSandbox,
canEmail,
})
function handleSendPromptOpenChange(open: boolean) {
setShowSendPrompt(open)
if (!open && createdCreditNote) {
router.push(`/invoices/${createdCreditNote.id}`)
}
}
return (
<div className="space-y-6 max-w-3xl mx-auto">
{createdCreditNote && (
<SendInvoiceDialog
open={showSendPrompt}
onOpenChange={handleSendPromptOpenChange}
invoice={createdCreditNote}
mode={sendMode}
onSuccess={() => undefined}
/>
)}
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
+114 -31
View File
@@ -15,6 +15,9 @@ import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import { canCopyInvoice } from '@/lib/invoices/copy-invoice'
import {
Loader2,
ArrowLeft,
@@ -33,8 +36,11 @@ import {
Lock,
CalendarClock,
Pencil,
Copy,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
@@ -77,6 +83,8 @@ interface InvoiceWithRelations extends Invoice {
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { isSandbox } = useCompany()
const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
@@ -116,6 +124,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [nextNumberPreview, setNextNumberPreview] = useState<string | null>(null)
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45])
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
const reminderLevelLabel = (level: 1 | 2 | 3): string => t(`reminder_level_${level}`)
@@ -210,21 +220,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
data.company_id
? supabase
.from('company_settings')
.select('ore_rounding, vat_registered')
.select('ore_rounding, vat_registered, accounting_method, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
.eq('company_id', data.company_id)
.maybeSingle()
: Promise.resolve(null),
data.status === 'credited'
!data.credited_invoice_id &&
['sent', 'paid', 'overdue', 'credited'].includes(data.status)
? supabase
.from('invoices')
.select('id, invoice_number')
.select('id, invoice_number, status')
.eq('credited_invoice_id', id)
.single()
.neq('status', 'cancelled')
.maybeSingle()
: Promise.resolve(null),
data.credited_invoice_id
? supabase
.from('invoices')
.select('id, invoice_number')
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', data.credited_invoice_id)
.single()
: Promise.resolve(null),
@@ -243,10 +255,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (typeof settings?.vat_registered === 'boolean') {
setVatRegistered(settings.vat_registered)
}
setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
setReminderDays([
settings?.reminder_days_level_1 ?? 15,
settings?.reminder_days_level_2 ?? 30,
settings?.reminder_days_level_3 ?? 45,
])
}
if (creditNoteRes?.data) {
setCreditNote(creditNoteRes.data as Invoice)
}
setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
if (originalRes?.data) {
setOriginalInvoice(originalRes.data as Invoice)
}
@@ -466,7 +482,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// Unnumbered drafts are hard deleted ("Ta bort"); numbered drafts are
// makulerade and keep their number in the series.
toast(
invoice.invoice_number
invoice.invoice_number && !invoice.credited_invoice_id
? {
title: t('cancelled_toast_title'),
description: t('cancelled_with_number', { number: invoice.invoice_number }),
@@ -477,7 +493,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
)
router.push('/invoices')
router.push(
invoice.credited_invoice_id
? `/invoices/${invoice.credited_invoice_id}`
: '/invoices',
)
} catch (error) {
toast({
title: t('cancel_failed_title'),
@@ -509,6 +529,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const isProforma = docType === 'proforma'
const isDeliveryNote = docType === 'delivery_note'
const isRealInvoice = docType === 'invoice'
const isCreditNote = !!invoice.credited_invoice_id
const booksOnIssue = isCreditNote
? !!originalInvoice && creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
: accountingMethod === 'accrual'
const preferredSendMode = getCreditNoteSendMode({
customerHasEmail,
isSandbox,
canEmail,
})
const creditNoteNeedsRepair =
isCreditNote &&
invoice.status === 'sent' &&
!!originalInvoice &&
(
originalInvoice.status !== 'credited' ||
(
creditNoteNeedsJournalEntry(accountingMethod, originalInvoice) &&
!invoice.journal_entry_id
)
)
// An unnumbered draft is one saved via "Spara som utkast" that hasn't been
// finalized: no F-number yet, so it can still be reviewed-and-created or
// hard-deleted. Once finalized it gets a number and behaves like any draft.
@@ -525,6 +565,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// in place (header + lines) via /invoices/{id}/edit. Sent/paid invoices are
// immutable (BFL); they are corrected with a credit note instead.
const isEditableDraft = isEditableInvoiceDraft(invoice)
const isCopyable = canCopyInvoice(invoice)
const hasAccruedItems = invoice.items.some(itemHasAccrual)
return (
<div className="space-y-8">
@@ -600,14 +641,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
)}
{invoice.status === 'draft' && !isDeliveryNote && invoice.invoice_number && (
customerHasEmail ? (
preferredSendMode === 'email' ? (
<Button
onClick={() => openSendDialog('email')}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Mail className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('send_via_email')}
{t(booksOnIssue ? 'send_via_email_and_book' : 'send_via_email')}
</Button>
) : (
<Button
@@ -617,10 +658,29 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('mark_sent_manually')}
{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
</Button>
)
)}
{isCopyable && canWrite && (
<Link href={`/invoices?copy=${invoice.id}`}>
<Button variant="outline">
<Copy className="mr-2 h-4 w-4" />
{t('copy_invoice')}
</Button>
</Link>
)}
{creditNoteNeedsRepair && (
<Button
variant="secondary"
onClick={() => openSendDialog('manual')}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <AlertTriangle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('complete_credit_bookkeeping')}
</Button>
)}
{isDeliveryNote && invoice.status === 'draft' && (
<Button
variant="secondary"
@@ -632,7 +692,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('mark_as_sent')}
</Button>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && !isCreditNote && (
<Button
onClick={() => setShowPaymentDialog(true)}
disabled={isUpdating || !canWrite}
@@ -1079,7 +1139,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</CardTitle>
{reminders.length === 0 && (
<CardDescription>
{t('reminders_description')}
{t('reminders_description', {
day1: reminderDays[0],
day2: reminderDays[1],
day3: reminderDays[2],
})}
</CardDescription>
)}
</CardHeader>
@@ -1135,19 +1199,26 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
{/* Credit note reference (if this invoice was credited) */}
{invoice.status === 'credited' && creditNote && (
<Card className="border-warning/50">
{creditNote && (
<Card className={creditNote.status === 'draft' ? undefined : 'border-warning/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<CardTitle className={cn(
'flex items-center gap-2',
creditNote.status !== 'draft' && 'text-warning',
)}>
<ReceiptText className="h-5 w-5" />
{t('credited_card_title')}
{creditNote.status === 'draft'
? t('credit_draft_card_title')
: t('credited_card_title')}
</CardTitle>
</CardHeader>
<CardContent>
<Link href={`/invoices/${creditNote.id}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="mr-2 h-4 w-4" />
{t('see_credit_note', { number: creditNote.invoice_number ?? '' })}
{creditNote.status === 'draft'
? t('open_credit_draft', { number: creditNote.invoice_number ?? '' })
: t('see_credit_note', { number: creditNote.invoice_number ?? '' })}
</Button>
</Link>
</CardContent>
@@ -1201,7 +1272,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
{/* Status actions */}
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && (!invoice.credited_invoice_id || invoice.status === 'draft') && (
<Card>
<CardHeader>
<CardTitle>{t('actions_card_title')}</CardTitle>
@@ -1239,7 +1310,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* When the customer has an email the header offers "Send via
email" as the primary; keep the manual-mark-sent path here
as the secondary alternative (it is not in the header). */}
{!isDeliveryNote && customerHasEmail && (
{!isDeliveryNote && preferredSendMode === 'email' && (
<>
<Button
variant="ghost"
@@ -1247,7 +1318,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
onClick={() => openSendDialog('manual')}
>
<Send className="mr-2 h-4 w-4" />
{t('mark_sent_manually')}
{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
</Button>
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
{t('send_manual_hint_with_email')}
@@ -1261,12 +1332,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('delete_draft')}
{t(isCreditNote ? 'remove_credit_draft' : 'delete_draft')}
</Button>
</>
)
)}
{((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice) && (
{((invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') && isRealInvoice && !creditNote) && (
<Link href={`/invoices/${invoice.id}/credit`} className="block">
<Button variant="outline" className="w-full">
<ReceiptText className="mr-2 h-4 w-4" />
@@ -1280,15 +1351,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>
{/* Remove/cancel confirmation. A numbered draft is makulerad (status flips
to 'cancelled', number retained for a gap-free series); an unnumbered
draft is hard deleted since it never entered the number series. */}
{/* Remove/cancel confirmation. An unissued credit-note draft and an
unnumbered invoice draft are hard deleted; other numbered drafts are
retained as cancelled to preserve their number series. */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{invoice.invoice_number ? t('delete_dialog_title') : t('remove_dialog_title')}</DialogTitle>
<DialogTitle>
{isCreditNote
? t('remove_credit_dialog_title')
: invoice.invoice_number
? t('delete_dialog_title')
: t('remove_dialog_title')}
</DialogTitle>
<DialogDescription>
{invoice.invoice_number ? (
{isCreditNote ? (
t('remove_credit_dialog_desc')
) : invoice.invoice_number ? (
<>
{t('delete_dialog_desc_with_number_1')}
<strong>{t('delete_dialog_status_makulerad')}</strong>
@@ -1310,7 +1389,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
<Button variant="destructive" onClick={deleteInvoice} disabled={isDeleting}>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{invoice.invoice_number ? t('delete_dialog_confirm') : t('remove_dialog_confirm')}
{isCreditNote
? t('remove_credit_dialog_confirm')
: invoice.invoice_number
? t('delete_dialog_confirm')
: t('remove_dialog_confirm')}
</Button>
</DialogFooter>
</DialogContent>
+3 -1
View File
@@ -87,7 +87,8 @@ export default function InvoicesPage() {
// /invoices/new redirect) opens the same dialog, and the browser back
// button closes it. No canWrite gate here: like the old /invoices/new page,
// the editor itself disables submission for viewers.
const showNewInvoice = searchParams.has('new')
const copyFromId = searchParams.get('copy')
const showNewInvoice = searchParams.has('new') || copyFromId !== null
const closeNewInvoice = () => router.replace('/invoices', { scroll: false })
const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false })
@@ -377,6 +378,7 @@ export default function InvoicesPage() {
<NewInvoiceDialog
open={showNewInvoice}
copyFromId={copyFromId}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
@@ -13,7 +13,7 @@ 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, Pencil, Plus, CalendarClock } from 'lucide-react'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock, Paperclip } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -22,6 +22,7 @@ import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton'
import { formatAmount, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
@@ -788,6 +789,26 @@ export default function SupplierInvoiceDetailPage() {
</Card>
)}
{invoice.document_id && (
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('document_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Paperclip className="h-4 w-4 shrink-0" />
<span>{t('document_attached')}</span>
</div>
<DocumentViewButton
documentId={invoice.document_id}
label={t('view_document')}
/>
</div>
</CardContent>
</Card>
)}
{/* Journal entries (sambandskrav) */}
<Card>
<CardHeader>
+5
View File
@@ -45,6 +45,7 @@ export default function PrivacyPolicyPage() {
<ul>
<li><strong>Kontouppgifter:</strong> E-postadress (för inloggning)</li>
<li><strong>Företagsuppgifter:</strong> Företagsnamn, organisationsnummer, adress, kontaktuppgifter</li>
<li><strong>Kundidentitet:</strong> Personnummer för privatkunder när det behövs för avtal eller fakturering</li>
<li><strong>Bokföringsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokföringsunderlag</li>
@@ -203,6 +204,10 @@ export default function PrivacyPolicyPage() {
<strong>Kontouppgifter:</strong> Så länge kontot är aktivt, plus 30 dagar efter
begäran om radering (för att hantera pågående bokföringsplikter).
</li>
<li>
<strong>Kundidentitet:</strong> Under kundrelationen, eller i sju år när uppgiften
ingår i räkenskapsinformation som måste bevaras.
</li>
<li>
<strong>Tekniska loggar:</strong> Maximalt 90 dagar.
</li>
+51 -14
View File
@@ -111,10 +111,9 @@ export const PATCH = withRouteContext(
{ requireWrite: true },
)
// DELETE soft-deactivates (active = false) rather than hard-deleting. Articles
// are master data referenced by historical invoice lines via a (frozen) copy;
// keeping the row preserves the register's audit trail and the article number.
// Re-activate by PATCHing { active: true }.
// Articles are master data, while invoice lines hold frozen copies of the
// accounting values. An article may therefore be deleted only while no invoice
// line references it. The preflight also covers draft invoices.
export const DELETE = withRouteContext(
'article.delete',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
@@ -122,28 +121,66 @@ export const DELETE = withRouteContext(
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ articleId: id })
const { data, error } = await supabase
const { error: articleError } = await supabase
.from('articles')
.update({ active: false })
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.select()
.single()
if (error) {
if (error.code === 'PGRST116') {
if (articleError) {
if (articleError.code === 'PGRST116') {
return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId })
}
opLog.error('article deactivate failed', error)
return errorResponseFromCode('ARTICLE_UPDATE_FAILED', opLog, {
opLog.error('article lookup before delete failed', articleError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: error.message },
details: { reason: articleError.message },
})
}
const { count: usageCount, error: usageError } = await supabase
.from('invoice_items')
.select('id', { count: 'exact', head: true })
.eq('article_id', id)
.eq('company_id', companyId)
if (usageError) {
opLog.error('article usage check failed', usageError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: usageError.message },
})
}
if ((usageCount ?? 0) > 0) {
return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId })
}
const { error: deleteError, count: deletedCount } = await supabase
.from('articles')
.delete({ count: 'exact' })
.eq('id', id)
.eq('company_id', companyId)
if (deleteError) {
if (deleteError.code === '23503') {
return errorResponseFromCode('ARTICLE_IN_USE', opLog, { requestId })
}
opLog.error('article delete failed', deleteError)
return errorResponseFromCode('ARTICLE_DELETE_FAILED', opLog, {
requestId,
details: { reason: deleteError.message },
})
}
if (deletedCount === 0) {
return errorResponseFromCode('ARTICLE_NOT_FOUND', opLog, { requestId })
}
await eventBus.emit({
type: 'article.updated',
payload: { article: data as Article, companyId: companyId!, userId: user.id },
type: 'article.deleted',
payload: { articleId: id, companyId, userId: user.id },
})
return NextResponse.json({ success: true })
+63 -5
View File
@@ -1,11 +1,13 @@
/**
* Tests for GET/PATCH/DELETE /api/articles/[id] (artikelregister).
*
* DELETE soft-deactivates (active = false) rather than hard-deleting, so the
* article and its number survive for history. PATCH is a sparse update.
* DELETE permanently removes only articles that have never been used on an
* invoice line. PATCH is a sparse update.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
@@ -97,13 +99,69 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
})
it('DELETE soft-deactivates and returns success', async () => {
enqueue({ data: { id: 'a1', active: false } })
it('DELETE returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await DELETE(createMockRequest('/api/articles/a1', { method: 'DELETE' }), createMockRouteParams({ id: 'a1' }))
const response = await DELETE(
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
createMockRouteParams({ id: 'a1' }),
)
expect(response.status).toBe(401)
})
it('DELETE returns 404 when the article is not found', async () => {
enqueue({ data: null, error: { code: 'PGRST116', message: 'not found' } })
const response = await DELETE(
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
createMockRouteParams({ id: 'a1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('ARTICLE_NOT_FOUND')
})
it('DELETE rejects an article used on an invoice line', async () => {
enqueue({ data: { id: 'a1' }, error: null })
enqueue({ data: null, error: null, count: 1 })
const response = await DELETE(
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
createMockRouteParams({ id: 'a1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('ARTICLE_IN_USE')
expect(supabase.from).toHaveBeenCalledTimes(2)
})
it('DELETE permanently removes an unused article', async () => {
enqueue({ data: { id: 'a1' }, error: null })
enqueue({ data: null, error: null, count: 0 })
enqueue({ data: null, error: null, count: 1 })
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await DELETE(
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
createMockRouteParams({ id: 'a1' }),
)
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(supabase.from).toHaveBeenNthCalledWith(1, 'articles')
expect(supabase.from).toHaveBeenNthCalledWith(2, 'invoice_items')
expect(supabase.from).toHaveBeenNthCalledWith(3, 'articles')
expect(emitSpy).toHaveBeenCalledWith({
type: 'article.deleted',
payload: { articleId: 'a1', companyId: 'company-1', userId: 'user-1' },
})
})
})
+31 -2
View File
@@ -4,6 +4,7 @@ import { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
export const GET = withRouteContext(
'customer.get',
@@ -37,7 +38,7 @@ export const GET = withRouteContext(
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
return NextResponse.json({ data: { ...maskCustomerRow(data), invoices: invoices || [] } })
},
)
@@ -55,6 +56,26 @@ export const PATCH = withRouteContext(
if (!result.success) return result.response
const body = result.data
const { data: existing, error: existingError } = await supabase
.from('customers')
.select('id, customer_type')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (existingError || !existing) {
if (existingError?.code === 'PGRST116') {
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
}
opLog.error('customer lookup before update failed', existingError)
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
}
const effectiveType = body.customer_type ?? existing.customer_type
if (body.personal_number && effectiveType !== 'individual') {
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId })
}
const updateData: Record<string, unknown> = {}
if (body.name !== undefined) updateData.name = body.name
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
@@ -69,6 +90,11 @@ export const PATCH = withRouteContext(
if (body.country !== undefined) updateData.country = body.country
if (body.org_number !== undefined) updateData.org_number = body.org_number
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
if (body.personal_number !== undefined) {
updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
} else if (body.customer_type !== undefined && effectiveType !== 'individual') {
updateData.personal_number = null
}
if (body.language !== undefined) updateData.language = body.language
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
if (body.notes !== undefined) updateData.notes = body.notes
@@ -82,6 +108,9 @@ export const PATCH = withRouteContext(
.single()
if (error) {
if (error.code === 'PGRST116') {
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
}
if (error.code === '23505') {
return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', opLog, {
requestId,
@@ -127,7 +156,7 @@ export const PATCH = withRouteContext(
}
}
return NextResponse.json({ data })
return NextResponse.json({ data: maskCustomerRow(data) })
},
{ requireWrite: true },
)
@@ -0,0 +1,228 @@
import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
const buildChain = (): unknown =>
new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void) => resolve(queryResult)
}
return (...args: unknown[]) => {
if (prop === 'insert') captured.insert.push(args[0])
if (prop === 'update') captured.update.push(args[0])
return buildChain()
}
},
},
)
const supabase = {
from: vi.fn(() => buildChain()),
rpc: vi.fn(() => buildChain()),
}
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
import { PATCH } from '../[id]/route'
type CustomerWrite = { personal_number?: string | null }
describe('personal_number on customer routes', () => {
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
captured.insert.length = 0
captured.update.length = 0
queryResult = { data: null, error: null }
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 before creating a customer when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Anna Andersson', customer_type: 'individual' },
}),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(401)
expect(captured.insert).toHaveLength(0)
})
it('returns 400 for an invalid personal number', async () => {
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: 'not-a-personal-number',
},
}),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(400)
expect(captured.insert).toHaveLength(0)
})
it('stores the personal number when creating a private customer', async () => {
queryResult = {
data: {
id: 'customer-1',
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: '19900101-1234',
},
error: null,
}
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: '19900101-1234',
},
}),
{ params: Promise.resolve({}) },
)
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
expect(status).toBe(200)
const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string
expect(encrypted).not.toBe('19900101-1234')
expect(decryptPersonnummer(encrypted)).toBe('19900101-1234')
expect(body.data.personal_number).toBe('********-1234')
})
it('updates the personal number for an existing private customer', async () => {
queryResult = {
data: {
id: 'customer-1',
customer_type: 'individual',
personal_number: '900101-1234',
},
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { personal_number: '900101-1234' },
}),
routeParams,
)
expect(response.status).toBe(200)
const encrypted = (captured.update[0] as CustomerWrite).personal_number as string
expect(encrypted).not.toBe('900101-1234')
expect(decryptPersonnummer(encrypted)).toBe('900101-1234')
})
it('clears the personal number when null is sent', async () => {
queryResult = {
data: { id: 'customer-1', customer_type: 'individual', personal_number: null },
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { personal_number: null },
}),
routeParams,
)
expect(response.status).toBe(200)
expect((captured.update[0] as CustomerWrite).personal_number).toBeNull()
})
it('does not change the personal number when the field is omitted', async () => {
queryResult = {
data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' },
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { name: 'Anna A' },
}),
routeParams,
)
expect(response.status).toBe(200)
expect(captured.update[0]).not.toHaveProperty('personal_number')
})
it('rejects a personal number for a corporate customer', async () => {
queryResult = {
data: { id: 'customer-1', customer_type: 'swedish_business' },
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { personal_number: '900101-1234' },
}),
routeParams,
)
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(response.status).toBe(400)
expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED')
expect(captured.update).toHaveLength(0)
})
it('returns 404 when the customer does not exist', async () => {
queryResult = {
data: null,
error: { code: 'PGRST116', message: 'No rows returned' },
}
const response = await PATCH(
createMockRequest('/api/customers/missing', {
method: 'PATCH',
body: { personal_number: '900101-1234' },
}),
{ params: Promise.resolve({ id: 'missing' }) },
)
expect(response.status).toBe(404)
})
})
+6 -3
View File
@@ -7,6 +7,7 @@ import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Customer } from '@/types'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
ensureInitialized()
@@ -26,7 +27,7 @@ export const GET = withRouteContext(
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data })
return NextResponse.json({ data: (data ?? []).map(maskCustomerRow) })
},
)
@@ -59,6 +60,7 @@ export const POST = withRouteContext(
country: body.country || 'Sweden',
org_number: body.org_number,
vat_number: body.vat_number,
personal_number: encryptCustomerPersonalNumber(body.personal_number),
language: body.language || 'sv',
default_payment_terms: body.default_payment_terms || 30,
notes: body.notes,
@@ -104,12 +106,13 @@ export const POST = withRouteContext(
}
}
const safeCustomer = maskCustomerRow(data)
await eventBus.emit({
type: 'customer.created',
payload: { customer: data as Customer, companyId: companyId!, userId: user.id },
payload: { customer: safeCustomer as Customer, companyId: companyId!, userId: user.id },
})
return NextResponse.json({ data })
return NextResponse.json({ data: safeCustomer })
},
{ requireWrite: true },
)
+52 -1
View File
@@ -18,13 +18,14 @@ vi.mock('@/lib/init', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE } from '../route'
import { DELETE, PATCH } from '../route'
describe('DELETE /api/invoices/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -175,3 +176,53 @@ describe('DELETE /api/invoices/[id]', () => {
expect(status).toBe(500)
})
})
describe('PATCH /api/invoices/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1', email: 'test@test.se' } },
})
})
it('rejects editing a credit-note draft', async () => {
enqueue({
data: {
id: 'credit-1',
status: 'draft',
invoice_number: 'KR-F-2026001',
journal_entry_id: null,
is_self_billed: false,
credited_invoice_id: '11111111-1111-4111-8111-111111111111',
},
error: null,
})
const response = await PATCH(
createMockRequest('/api/invoices/credit-1', {
method: 'PATCH',
body: {
customer_id: '22222222-2222-4222-8222-222222222222',
invoice_date: '2026-07-14',
due_date: '2026-07-14',
currency: 'SEK',
items: [
{
description: 'Kredit',
quantity: 1,
unit: 'st',
unit_price: 100,
},
],
},
}),
createMockRouteParams({ id: 'credit-1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT')
})
})
@@ -114,6 +114,42 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(status).toBe(400)
})
it('rejects a sent credit note before booking a payment', async () => {
const invoice = makeInvoice({
status: 'sent',
credited_invoice_id: 'original-invoice-1',
})
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('rejects an original invoice while an active credit-note draft exists', async () => {
const invoice = {
...makeInvoice({ status: 'sent', credited_invoice_id: null }),
credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
}
enqueue({ data: invoice, error: null })
const response = await POST(
createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' }),
)
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(response.status).toBe(400)
expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE')
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
})
it('marks sent invoice as paid with accrual method', async () => {
const customer = makeCustomer()
const invoice = makeInvoice({
+21 -1
View File
@@ -31,7 +31,7 @@ export const POST = withRouteContext(
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -40,6 +40,26 @@ export const POST = withRouteContext(
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
}
if (invoice.credited_invoice_id) {
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
requestId,
details: { reason: 'credit_note' },
})
}
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
status: string
creation_complete?: boolean
}> }).credit_notes ?? []).filter(
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
)
if (activeCreditNotes.length > 0) {
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
requestId,
details: { reason: 'active_credit_note' },
})
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
requestId,
@@ -52,6 +52,17 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
mockCreateInvoiceJournalEntry(...args),
}))
const mockIssueCreditNote = vi.fn()
vi.mock('@/lib/invoices/issue-credit-note', () => ({
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
creditNoteNeedsJournalEntry: (method: string, original: { status: string; journal_entry_id?: string | null; paid_at?: string | null; paid_amount?: number | null }) =>
method === 'accrual' ||
!!original.journal_entry_id ||
original.status === 'paid' ||
!!original.paid_at ||
Math.abs(original.paid_amount ?? 0) > 0,
}))
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
@@ -94,18 +105,66 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockIssueCreditNote.mockResolvedValue({
complete: true,
journalEntryId: 'credit-je-1',
journalEntryRequired: true,
failures: [],
})
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}),
})
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 404 when the invoice does not exist', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const request = createMockRequest('/api/invoices/missing/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'missing' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('returns 400 when the invoice is not a draft', async () => {
enqueue({ data: makeInvoice({ id: 'inv-1', status: 'sent' }), error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('archives the rendered PDF as underlag linked to the journal entry', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: null, error: null }) // status update
enqueue({ data: company, error: null }) // settings
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' })
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response)
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_id: string | null
partial?: boolean
partial_failures?: Array<{ step: string }>
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
@@ -128,37 +187,25 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
)
})
it('archives the PDF even when journal entry creation fails (non-blocking)', async () => {
it('restores the draft and fails closed when journal entry creation fails', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string | null }>(response)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBeNull()
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({ name: 'faktura-F-2026010.pdf' }),
expect.objectContaining({
upload_source: 'system',
journal_entry_id: undefined,
})
)
expect(status).toBe(500)
expect(body.error.code).toBe('INVOICE_MARK_SENT_BOOK_FAILED')
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('still returns 200 when PDF archival itself fails', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' })
enqueue({ data: null, error: null })
@@ -183,8 +230,8 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
})
enqueue({ data: proforma, error: null })
enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
enqueue({ data: [{ id: 'inv-2' }], error: null })
const request = createMockRequest('/api/invoices/inv-2/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-2' }))
@@ -196,10 +243,10 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect(mockRenderToBuffer).not.toHaveBeenCalled()
})
it('uses kreditfaktura filename when archiving a credit note', async () => {
it('issues the credit note and uses a credit-note filename when archiving it', async () => {
const creditNote = makeInvoice({
id: 'inv-3',
invoice_number: 'F-2026011',
invoice_number: 'KR-F-2026010',
status: 'draft',
credited_invoice_id: 'inv-1',
customer,
@@ -207,31 +254,132 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
})
enqueue({ data: creditNote, error: null })
enqueue({ data: null, error: null })
enqueue({ data: company, error: null })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-9' })
enqueue({ data: null, error: null })
// Lookup of the original invoice's number for the credit note PDF
enqueue({ data: { invoice_number: 'F-2026010' }, error: null })
const original = {
id: 'inv-1',
invoice_number: 'F-2026010',
status: 'sent',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
}
enqueue({ data: original, error: null })
enqueue({ data: [{ id: 'inv-3' }], error: null })
const request = createMockRequest('/api/invoices/inv-3/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-3' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockIssueCreditNote).toHaveBeenCalledWith(
expect.objectContaining({
creditNote: expect.objectContaining({ id: 'inv-3' }),
originalInvoice: original,
accountingMethod: 'accrual',
}),
)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({ name: 'kreditfaktura-F-2026011.pdf' }),
expect.objectContaining({ name: 'kreditfaktura-KR-F-2026010.pdf' }),
expect.anything()
)
})
it('fails closed and restores the draft when credit-note booking cannot start', async () => {
const creditNote = makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-2026010',
status: 'draft',
credited_invoice_id: 'inv-1',
customer,
items: invoice.items,
})
enqueue({ data: creditNote, error: null })
enqueue({ data: company, error: null })
enqueue({
data: {
id: 'inv-1',
invoice_number: 'F-2026010',
status: 'sent',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
},
error: null,
})
enqueue({ data: [{ id: 'credit-1' }], error: null })
mockIssueCreditNote.mockResolvedValue({
complete: false,
journalEntryId: null,
journalEntryRequired: true,
failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
})
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('repairs a sent credit note without running the draft status transition again', async () => {
const creditNote = makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-2026010',
status: 'sent',
credited_invoice_id: 'inv-1',
journal_entry_id: null,
customer,
items: invoice.items,
})
enqueue({ data: creditNote, error: null })
enqueue({ data: company, error: null })
enqueue({
data: {
id: 'inv-1',
invoice_number: 'F-2026010',
status: 'sent',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
},
error: null,
})
const request = createMockRequest('/api/invoices/credit-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockIssueCreditNote).toHaveBeenCalledOnce()
})
it('returns 409 when another request already marked the draft as sent', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
enqueue({ data: [], error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(409)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('renders the archived PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft')
enqueue({ data: null, error: null }) // status update
enqueue({ data: company, error: null }) // settings
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-99' })
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
+181 -40
View File
@@ -2,13 +2,28 @@ import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { eventBus } from '@/lib/events'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import {
creditNoteNeedsJournalEntry,
issueCreditNote,
type CreditNoteOriginalInvoice,
} from '@/lib/invoices/issue-credit-note'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { uploadDocument } from '@/lib/core/documents/document-service'
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type {
AccountingMethod,
CompanySettings,
CreditNote,
Customer,
EntityType,
Invoice,
InvoiceItem,
} from '@/types'
ensureInitialized()
@@ -21,7 +36,7 @@ ensureInitialized()
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.mark_sent',
async (request, { supabase, user, companyId, log }, { params }) => {
async (_request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
// Fetch invoice
@@ -33,14 +48,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
if (invoice.status !== 'draft') {
return NextResponse.json(
{ error: 'Endast utkast kan markeras som skickade' },
{ status: 400 }
)
const isCreditNote = !!invoice.credited_invoice_id
if (!isCreditNote && invoice.status !== 'draft') {
return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId })
}
if (isCreditNote && !['draft', 'sent'].includes(invoice.status)) {
return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId })
}
// Assign invoice number now if this draft doesn't have one yet
@@ -48,43 +65,126 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
log.error('failed to assign invoice number on mark-sent', err as Error)
return NextResponse.json(
{ error: 'Kunde inte tilldela fakturanummer. Försök igen.' },
{ status: 500 }
)
}
// Update status to sent
const { error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
}
// Fetch full company settings for PDF rendering and accounting method
const { data: settings } = await supabase
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
if (settingsError || !settings) {
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
}
const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
let originalInvoice: CreditNoteOriginalInvoice | undefined
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: original } = await supabase
.from('invoices')
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
if (!original) {
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId })
}
originalInvoice = original as CreditNoteOriginalInvoice
originalInvoiceNumber = original.invoice_number ?? undefined
}
const journalEntryRequired = originalInvoice
? creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
: false
const isRecovery = isCreditNote && invoice.status === 'sent'
if (
isRecovery &&
originalInvoice?.status === 'credited' &&
(!journalEntryRequired || !!invoice.journal_entry_id)
) {
return errorResponseFromCode('INVOICE_CREDIT_ALREADY_ISSUED', log, { requestId })
}
// Compare-and-set prevents two concurrent requests from posting two journal
// entries for the same draft.
let statusFlipped = false
if (!isRecovery) {
const { data: updatedRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (updateError) {
log.error('invoice mark-sent status update failed', updateError)
return errorResponseFromCode('INVOICE_MARK_SENT_STATUS_FAILED', log, { requestId })
}
if (!updatedRows || updatedRows.length === 0) {
return errorResponseFromCode('INVOICE_MARK_SENT_RACE', log, { requestId })
}
statusFlipped = true
}
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
if (isRealInvoice && accountingMethod === 'accrual') {
const partialFailures: Array<{ step: string; reason: string }> = []
if (isCreditNote && originalInvoice) {
const issueResult = await issueCreditNote({
supabase,
companyId,
userId: user.id,
creditNote: invoice as CreditNote,
originalInvoice,
entityType,
accountingMethod,
log,
})
journalEntryId = issueResult.journalEntryId
partialFailures.push(...issueResult.failures)
if (!issueResult.complete) {
// If no immutable entry was created, restoring the draft is safe and
// lets the user fix the period/account issue before trying again.
if (statusFlipped && issueResult.journalEntryRequired && !issueResult.journalEntryId) {
await supabase
.from('invoices')
.update({ status: 'draft' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'sent')
.is('journal_entry_id', null)
}
return errorResponseFromCode(
issueResult.repairRequired
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
log,
{
requestId,
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
},
)
}
} else if (isRealInvoice && accountingMethod === 'accrual') {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma',
entityType,
invoice.customer?.name
)
if (journalEntry) {
@@ -100,12 +200,16 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
(settings?.entity_type as EntityType) || 'enskild_firma',
entityType,
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
const { error: linkError } = await supabase
@@ -122,32 +226,55 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
log.error('mark-sent: journal_entry_id link to invoice failed', linkError, {
journalEntryId: journalEntry.id,
})
partialFailures.push({
step: 'journal_link',
reason: 'Verifikatet skapades men kunde inte kopplas till fakturan.',
})
}
} else {
partialFailures.push({
step: 'journal_entry',
reason: 'Ingen öppen bokföringsperiod hittades för fakturans datum.',
})
}
} catch (err) {
log.error('failed to create invoice journal entry on mark-sent', err as Error)
partialFailures.push({
step: 'journal_entry',
reason: 'Fakturans verifikat kunde inte skapas.',
})
}
}
if (isRealInvoice && accountingMethod === 'accrual' && !isCreditNote && !journalEntryId) {
if (statusFlipped) {
const { error: rollbackError } = await supabase
.from('invoices')
.update({ status: 'draft' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'sent')
.is('journal_entry_id', null)
if (rollbackError) log.error('failed to restore draft after mark-sent booking failure', rollbackError)
}
return errorResponseFromCode('INVOICE_MARK_SENT_BOOK_FAILED', log, { requestId })
}
if (partialFailures.some((failure) => failure.step === 'journal_link')) {
return errorResponseFromCode('INVOICE_MARK_SENT_REPAIR_REQUIRED', log, {
requestId,
details: { failure_steps: ['journal_link'] },
})
}
// Render and archive the PDF as underlag so it remains retrievable even if
// the invoice row is later cancelled. Mirrors the send route.
if (isRealInvoice && settings) {
if (isRealInvoice) {
try {
const items = (invoice.items as InvoiceItem[] | null ?? []).slice().sort(
(a, b) => a.sort_order - b.sort_order
)
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: originalInvoice } = await supabase
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
originalInvoiceNumber = originalInvoice?.invoice_number ?? undefined
}
// The DB status flip already happened above, but the in-memory `invoice`
// is stale and still reads 'draft': override here so the archived
// underlag isn't stamped "UTKAST: inte en giltig faktura".
@@ -183,13 +310,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
} catch (err) {
log.error('failed to archive invoice PDF on mark-sent', err as Error)
partialFailures.push({
step: 'pdf_archive',
reason: 'Fakturans PDF kunde inte arkiveras.',
})
}
}
if (!isCreditNote) {
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId: user.id },
})
}
return NextResponse.json({
success: true,
status: 'sent',
journal_entry_id: journalEntryId,
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
: {}),
})
},
{ requireWrite: true },
+20 -34
View File
@@ -1,11 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateInvoiceSchema } from '@/lib/api/schemas'
@@ -15,8 +11,6 @@ import type { InvoiceDocumentType } from '@/types'
ensureInitialized() // Module-level: wires the audit-log handler for invoice.draft_deleted.
const log = createLogger('api.invoices.cancel')
/**
* DELETE /api/invoices/[id]
*
@@ -32,37 +26,25 @@ const log = createLogger('api.invoices.cancel')
* Only drafts may be removed either way. Sent / paid invoices are immutable per
* BFL and must be reversed via a credit note instead.
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
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)
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.delete',
async (_request, { user, supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const opLog = log.child({ invoiceId: id })
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, invoice_number, user_id')
.select('id, status, invoice_number, user_id, credited_invoice_id, journal_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !invoice) {
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
return errorResponseFromCode('INVOICE_NOT_FOUND', opLog, { requestId })
}
if (invoice.status !== 'draft') {
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log)
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', opLog, { requestId })
}
// Unnumbered drafts (saved via "Spara som utkast", never finalized) are not
@@ -82,13 +64,14 @@ export async function DELETE(
.select('id')
if (removeError) {
return NextResponse.json({ error: removeError.message }, { status: 500 })
opLog.error('invoice draft delete failed', removeError)
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
}
if (!removed || removed.length === 0) {
// Finalized between fetch and delete: refuse rather than fall through to
// makulering of a now-issued invoice.
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
}
// The row is gone, so there's no journal trace of the removal. Emit an
@@ -100,7 +83,7 @@ export async function DELETE(
payload: { invoiceId: id, companyId, userId: user.id },
})
return NextResponse.json({ data: { deleted: true } })
return NextResponse.json({ data: { deleted: true } })
}
// Numbered draft: retain the row and its number, flip to 'cancelled'
@@ -118,15 +101,18 @@ export async function DELETE(
.select('id')
if (cancelError) {
return NextResponse.json({ error: cancelError.message }, { status: 500 })
opLog.error('invoice cancellation failed', cancelError)
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
}
if (!updated || updated.length === 0) {
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
}
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
}
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
},
{ requireWrite: true },
)
/**
* PATCH /api/invoices/[id]
@@ -161,7 +147,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
// received self-billing document) may be edited.
const { data: existing, error: fetchError } = await supabase
.from('invoices')
.select('id, status, invoice_number, journal_entry_id, is_self_billed')
.select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id')
.eq('id', id)
.eq('company_id', companyId!)
.single()
@@ -66,6 +66,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
mockCreateInvoiceJournalEntry(...args),
}))
const mockIssueCreditNote = vi.fn()
vi.mock('@/lib/invoices/issue-credit-note', () => ({
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
}))
// The sandbox guard issues a company_settings query at the top of the route;
// short-circuit it in tests since the queued mock-supabase is shaped for the
// route's existing fetch chain, not an extra pre-flight read.
@@ -113,6 +118,12 @@ describe('POST /api/invoices/[id]/send', () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockIsConfigured.mockReturnValue(true)
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockIssueCreditNote.mockResolvedValue({
complete: true,
journalEntryId: 'credit-je-1',
journalEntryRequired: true,
failures: [],
})
})
it('returns 401 when not authenticated', async () => {
@@ -314,6 +325,144 @@ describe('POST /api/invoices/[id]/send', () => {
)
})
it('issues and books a credit-note draft through the email send flow', async () => {
const creditNote = makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-2024001',
status: 'draft',
credited_invoice_id: 'inv-1',
customer,
items: (invoice.items ?? []).map((item) => ({
...item,
invoice_id: 'credit-1',
quantity: -Math.abs(item.quantity),
line_total: -Math.abs(item.line_total),
vat_amount: -Math.abs(item.vat_amount ?? 0),
})),
subtotal: -10000,
vat_amount: -2500,
total: -12500,
})
const original = {
id: 'inv-1',
invoice_number: 'F-2024001',
status: 'sent',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
}
enqueue({ data: creditNote, error: null })
enqueue({ data: company, error: null })
enqueue({ data: original, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'credit-message-1' })
enqueue({ data: [{ id: 'credit-1' }], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean; message: string }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.message).toContain('Kreditfakturan har skickats')
expect(mockIssueCreditNote).toHaveBeenCalledWith(
expect.objectContaining({
companyId: 'company-1',
creditNote: expect.objectContaining({ id: 'credit-1' }),
originalInvoice: original,
accountingMethod: 'accrual',
}),
)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({ filename: 'kreditfaktura-KR-F-2024001.pdf' }),
],
}),
)
expect(emitSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.sent' }),
)
})
it('does not email a credit note when its bookkeeping cannot be completed', async () => {
const creditNote = makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-2024001',
status: 'draft',
credited_invoice_id: 'inv-1',
customer,
items: invoice.items,
})
enqueue({ data: creditNote, error: null })
enqueue({ data: company, error: null })
enqueue({
data: {
id: 'inv-1',
invoice_number: 'F-2024001',
status: 'sent',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
},
error: null,
})
enqueue({ data: [{ id: 'credit-1' }], error: null })
mockIssueCreditNote.mockResolvedValue({
complete: false,
journalEntryId: null,
journalEntryRequired: true,
failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
})
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('retries delivery for an already-issued credit note after provider failure', async () => {
const creditNote = makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-2024001',
status: 'sent',
credited_invoice_id: 'inv-1',
customer,
items: invoice.items,
})
enqueue({ data: creditNote, error: null })
enqueue({ data: company, error: null })
enqueue({
data: {
id: 'inv-1',
invoice_number: 'F-2024001',
status: 'credited',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
},
error: null,
})
mockSendEmail.mockResolvedValue({ success: true, messageId: 'retry-message-1' })
const response = await POST(
createMockRequest('/api/invoices/credit-1/send', { method: 'POST' }),
createMockRouteParams({ id: 'credit-1' }),
)
expect(response.status).toBe(200)
expect(mockIssueCreditNote).toHaveBeenCalledTimes(1)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
})
it('skips journal entry for cash method', async () => {
const cashCompany = makeCompanySettings({ accounting_method: 'cash' })
enqueue({ data: invoice, error: null })
@@ -451,12 +600,10 @@ describe('POST /api/invoices/[id]/send', () => {
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
// Provider errors map to 502 PROVIDER_FAILED with the provider message in details.
// Provider errors map to a safe retryable response without leaking provider text.
expect(status).toBe(502)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PROVIDER_FAILED')
expect(
(body.error as unknown as { details?: { providerError?: string } }).details?.providerError,
).toContain('SMTP error')
expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
})
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
+116 -31
View File
@@ -14,13 +14,25 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import {
issueCreditNote,
type CreditNoteOriginalInvoice,
} from '@/lib/invoices/issue-credit-note'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
import type {
AccountingMethod,
CompanySettings,
CreditNote,
Customer,
EntityType,
Invoice,
InvoiceItem,
} from '@/types'
ensureInitialized()
@@ -59,6 +71,9 @@ export const POST = withRouteContext(
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
}
const isCreditNote = !!invoice.credited_invoice_id
const isCreditDeliveryRetry = isCreditNote && invoice.status === 'sent'
// A cancelled invoice keeps its F-series number for compliance with ML 17
// kap 24§ but is not a valid faktura: sending it would deliver a
// "MAKULERAD" PDF as if it were live. Checked before the generic draft
@@ -73,7 +88,7 @@ export const POST = withRouteContext(
// (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
// and orphaning the first entry. Mirrors the v1 route and the MCP commit
// executor, which both reject non-drafts.
if (invoice.status !== 'draft') {
if (invoice.status !== 'draft' && !isCreditDeliveryRetry) {
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
requestId,
details: { currentStatus: invoice.status },
@@ -100,18 +115,22 @@ export const POST = withRouteContext(
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
let originalInvoice: CreditNoteOriginalInvoice | undefined
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: originalInvoice } = await supabase
const { data: original } = await supabase
.from('invoices')
.select('invoice_number')
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
if (originalInvoice) {
originalInvoiceNumber = originalInvoice.invoice_number
if (!original) {
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', opLog, { requestId })
}
originalInvoice = original as CreditNoteOriginalInvoice
originalInvoiceNumber = original.invoice_number ?? undefined
}
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
@@ -149,13 +168,15 @@ export const POST = withRouteContext(
// that the number exists, so the email button and PDF QR carry it. A
// failure never blocks the send: the faktura is legally valid without a
// link, so it degrades to a PARTIAL warning instead.
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
opLog,
)
const { failure: paymentLinkFailure } = isCreditNote
? { failure: undefined }
: await applyPaymentLinkToInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
opLog,
)
// Final render with the assigned number: this is the buffer attached to
// the email and later archived as underlag. Override status to 'sent' on
@@ -182,12 +203,11 @@ export const POST = withRouteContext(
)
const emailData = {
invoice: invoice as Invoice,
invoice: renderableInvoice,
customer,
company: company as CompanySettings,
}
const isCreditNote = !!invoice.credited_invoice_id
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) {
@@ -201,6 +221,78 @@ export const POST = withRouteContext(
}
const ccAddress = company.email || user.email
const partialFailures: Array<{ step: string; reason: string }> = []
if (paymentLinkFailure) {
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
}
let statusFlipped = isCreditDeliveryRetry
let creditJournalEntryId: string | null = null
// Credit notes must be fully issued and booked before delivery. The CAS is
// the single-winner lock; the idempotent issue service can repair any
// immutable entry that committed before a later database step failed.
if (isCreditNote && originalInvoice) {
if (!isCreditDeliveryRetry) {
const { data: flipRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (updateError) {
opLog.error('credit note status update failed before issue', updateError)
return errorResponseFromCode('INVOICE_CREDIT_ISSUE_INCOMPLETE', opLog, {
requestId,
details: { failure_steps: ['status_update'] },
})
}
if (!flipRows || flipRows.length === 0) {
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
requestId,
details: { currentStatus: 'sent' },
})
}
statusFlipped = true
}
const issueResult = await issueCreditNote({
supabase,
companyId: companyId!,
userId: user.id,
creditNote: invoice as CreditNote,
originalInvoice,
entityType: ((company as CompanySettings).entity_type as EntityType) || 'enskild_firma',
accountingMethod: ((company as Record<string, unknown>).accounting_method || 'accrual') as AccountingMethod,
log: opLog,
})
creditJournalEntryId = issueResult.journalEntryId
if (!issueResult.complete) {
if (issueResult.journalEntryRequired && !issueResult.journalEntryId) {
await supabase
.from('invoices')
.update({ status: 'draft' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'sent')
.is('journal_entry_id', null)
}
return errorResponseFromCode(
issueResult.repairRequired
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
opLog,
{
requestId,
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
},
)
}
}
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress,
@@ -222,7 +314,7 @@ export const POST = withRouteContext(
opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown'))
return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, {
requestId,
details: { providerError: result.error },
details: { retryable: true },
})
}
@@ -230,12 +322,6 @@ export const POST = withRouteContext(
// follow-up steps degrade the response to PARTIAL: the user gets a
// success toast with a sub-warning, and the audit trail records exactly
// which sub-step broke.
const partialFailures: Array<{ step: string; reason: string }> = []
if (paymentLinkFailure) {
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
}
// Optimistic-locked flip (draft → sent). Two concurrent sends can both
// pass the draft guard above and both email the customer, but only the
// request that wins this compare-and-set runs the bookkeeping steps
@@ -245,8 +331,7 @@ export const POST = withRouteContext(
// A genuine update error also skips the follow-ups: the row is still
// 'draft', so a later retry re-runs the whole pipeline and ends with
// exactly one journal entry (at the cost of a duplicate email).
let statusFlipped = false
{
if (!isCreditNote) {
const { data: flipRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
@@ -270,10 +355,10 @@ export const POST = withRouteContext(
}
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
const accountingMethod = (company as Record<string, unknown>).accounting_method as string | undefined
let createdJournalEntryId: string | undefined
const accountingMethod = ((company as Record<string, unknown>).accounting_method || 'accrual') as AccountingMethod
let createdJournalEntryId: string | undefined = creditJournalEntryId ?? undefined
if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
if (statusFlipped && !isCreditNote && isRealInvoice && accountingMethod === 'accrual') {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
@@ -312,7 +397,7 @@ export const POST = withRouteContext(
opLog.error('failed to create invoice journal entry on send', err as Error)
partialFailures.push({
step: 'journal_entry',
reason: err instanceof Error ? err.message : 'unknown',
reason: 'Fakturans verifikat kunde inte skapas.',
})
}
}
@@ -332,7 +417,7 @@ export const POST = withRouteContext(
opLog.error('failed to store invoice PDF as underlag', err as Error)
partialFailures.push({
step: 'pdf_archive',
reason: err instanceof Error ? err.message : 'unknown',
reason: 'Fakturans PDF kunde inte arkiveras.',
})
}
}
@@ -340,7 +425,7 @@ export const POST = withRouteContext(
// Gated like the steps above: on a lost race the winning request emits
// it; on a flip error the row is still 'draft', so emitting would
// contradict DB state and the retry emits it instead.
if (statusFlipped) {
if (statusFlipped && !isCreditNote) {
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
@@ -356,7 +441,7 @@ export const POST = withRouteContext(
return NextResponse.json({
success: true,
message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`,
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`,
messageId: result.messageId,
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
+33 -20
View File
@@ -41,12 +41,6 @@ vi.mock('@/lib/currency/riksbanken', () => ({
convertToSEK: vi.fn(),
}))
const mockCreateCreditNoteJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createCreditNoteJournalEntry: (...args: unknown[]) =>
mockCreateCreditNoteJournalEntry(...args),
}))
import { GET, POST } from '../route'
describe('GET /api/invoices', () => {
@@ -419,7 +413,7 @@ describe('POST /api/invoices (create credit note)', () => {
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
it('creates credit note with negated amounts and emits event', async () => {
it('creates a credit note draft without booking or crediting the original', async () => {
const items = [
{
id: 'item-1',
@@ -449,25 +443,21 @@ describe('POST /api/invoices (create credit note)', () => {
subtotal: -10000,
vat_amount: -2500,
total: -12500,
status: 'sent',
status: 'draft',
})
// Fetch original invoice
enqueue({ data: original, error: null })
// No existing credit-note draft
enqueue({ data: null, error: null })
// Insert credit note
enqueue({ data: creditNote, error: null })
// Insert credit note items
enqueue({ data: null, error: null })
// Update original status to 'credited'
// Mark creation complete
enqueue({ data: null, error: null })
// Fetch complete credit note
enqueue({ data: { ...creditNote, items: [] }, error: null })
// Fetch company settings for entity type
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update credit note with journal_entry_id
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
@@ -476,13 +466,35 @@ describe('POST /api/invoices (create credit note)', () => {
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data).toBeTruthy()
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'credit_note.created' })
)
expect(body.data.status).toBe('draft')
expect(emitSpy).not.toHaveBeenCalled()
expect(mockSupabase.from).toHaveBeenCalledTimes(6)
})
it('returns an existing credit-note draft instead of creating a duplicate', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'sent' })
const existing = makeInvoice({
id: 'credit-existing',
invoice_number: 'KR-F-2024001',
status: 'draft',
credited_invoice_id: VALID_UUID,
})
enqueue({ data: original, error: null })
enqueue({ data: existing, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
expect(status).toBe(200)
expect(body.data.id).toBe('credit-existing')
expect(mockSupabase.from).toHaveBeenCalledTimes(2)
})
it('rolls back credit note when items insertion fails', async () => {
@@ -508,6 +520,7 @@ describe('POST /api/invoices (create credit note)', () => {
const creditNote = makeInvoice({ id: 'cn-1' })
enqueue({ data: original, error: null })
enqueue({ data: null, error: null })
enqueue({ data: creditNote, error: null })
// Items fail
enqueue({ data: null, error: { message: 'Items insert failed' } })
+107 -115
View File
@@ -3,11 +3,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
import type { Invoice, InvoiceDocumentType, InvoiceItem } from '@/types'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
import { buildCreditNoteItem } from '@/lib/invoices/build-credit-note-item'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Logger } from '@/lib/logger'
@@ -253,10 +252,6 @@ async function createCreditNote(
log: Logger,
requestId: string,
) {
// Non-blocking issues (e.g. partial accrual cancellation) surfaced to the
// caller alongside the created credit note.
const warnings: Array<{ code: string; message: string }> = []
const { data: originalInvoice, error: originalError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
@@ -286,6 +281,59 @@ async function createCreditNote(
})
}
// Returning the existing credit note makes the action idempotent. A
// cancelled, unissued draft is reopened so the deterministic KR number can
// be reused without colliding with the company-wide invoice-number key.
const { data: existingCreditNote, error: existingCreditNoteError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('credited_invoice_id', input.credited_invoice_id)
.eq('company_id', companyId)
.eq('creation_complete', true)
.maybeSingle()
if (existingCreditNoteError) {
log.error('failed to check for an existing credit note', existingCreditNoteError)
return errorResponse(existingCreditNoteError, log, { requestId })
}
if (existingCreditNote) {
if (existingCreditNote.status === 'cancelled' && !existingCreditNote.journal_entry_id) {
const today = new Date().toISOString().split('T')[0]
const { error: reopenError } = await supabase
.from('invoices')
.update({
status: 'draft',
invoice_date: today,
due_date: today,
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
updated_at: new Date().toISOString(),
})
.eq('id', existingCreditNote.id)
.eq('company_id', companyId)
.eq('status', 'cancelled')
if (reopenError) {
log.error('failed to reopen cancelled credit note draft', reopenError)
return errorResponse(reopenError, log, { requestId })
}
const { data: reopenedCreditNote, error: reopenedError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', existingCreditNote.id)
.eq('company_id', companyId)
.single()
if (reopenedError || !reopenedCreditNote) {
return errorResponse(reopenedError ?? new Error('Credit note draft not found'), log, {
requestId,
})
}
return NextResponse.json({ data: reopenedCreditNote })
}
return NextResponse.json({ data: existingCreditNote })
}
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
const { data: creditNote, error: creditNoteError } = await supabase
@@ -313,17 +361,33 @@ async function createCreditNote(
reverse_charge_text: originalInvoice.reverse_charge_text,
your_reference: originalInvoice.your_reference,
our_reference: originalInvoice.our_reference,
deduction_total: originalInvoice.deduction_total
? -Math.abs(originalInvoice.deduction_total)
: 0,
deduction_personnummer_encrypted: originalInvoice.deduction_personnummer_encrypted ?? null,
deduction_personnummer_last4: originalInvoice.deduction_personnummer_last4 ?? null,
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
credited_invoice_id: input.credited_invoice_id,
// Copy the original's dimension bag so the credit-note verifikat nets
// against the same dimension cells in reports (dimensions PR7).
default_dimensions: originalInvoice.default_dimensions ?? {},
status: 'sent',
status: 'draft',
creation_complete: false,
})
.select()
.single()
if (creditNoteError) {
if (creditNoteError.code === '23505') {
const { data: racedCreditNote } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('credited_invoice_id', input.credited_invoice_id)
.eq('company_id', companyId)
.eq('creation_complete', true)
.maybeSingle()
if (racedCreditNote) return NextResponse.json({ data: racedCreditNote })
}
log.error('credit note insert failed', creditNoteError)
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
@@ -331,40 +395,24 @@ async function createCreditNote(
})
}
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null; dimensions?: Record<string, string> }) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
description: item.description,
quantity: -Math.abs(item.quantity),
unit: item.unit,
unit_price: item.unit_price,
line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0,
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
// Carry the original's per-line revenue-account override so the reversal
// hits the SAME account it originally credited (e.g. 3041, not the
// VAT-derived 3001): otherwise the override account keeps a dangling
// balance. article_id is preserved for the usage history.
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
// Same reasoning for periodiserade lines: the credit-note verifikat must
// reverse against the 29xx interim account the original credited, not the
// revenue account. generatePerRateLines reads these fields to substitute.
// No schedule is ever created for a credit note (only send/mark-sent
// create schedules); the original's schedule is cancelled below.
accrual_period_start: item.accrual_period_start ?? null,
accrual_period_end: item.accrual_period_end ?? null,
accrual_balance_account: item.accrual_balance_account ?? null,
// Same reasoning as revenue_account: the reversal must carry the exact
// per-item bag the original booked with (dimensions PR7).
dimensions: item.dimensions ?? {},
}))
const creditNoteItems = (originalInvoice.items || []).map((item: InvoiceItem) =>
buildCreditNoteItem(creditNote.id, item)
)
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', creditNote.id)
const { error: cleanupError } = await supabase
.from('invoices')
.delete()
.eq('id', creditNote.id)
.eq('company_id', companyId)
.eq('creation_complete', false)
if (cleanupError) {
log.error('failed to clean up incomplete credit note', cleanupError, {
creditNoteId: creditNote.id,
})
}
log.error('credit note items insert failed; rolled back', itemsError, {
creditNoteId: creditNote.id,
})
@@ -374,91 +422,35 @@ async function createCreditNote(
})
}
await supabase
const { error: completionError } = await supabase
.from('invoices')
.update({ status: 'credited' })
.eq('id', input.credited_invoice_id)
.update({ creation_complete: true, updated_at: new Date().toISOString() })
.eq('id', creditNote.id)
.eq('company_id', companyId)
.eq('creation_complete', false)
const { data: completeCreditNote } = await supabase
if (completionError) {
log.error('failed to mark credit note creation complete', completionError, {
creditNoteId: creditNote.id,
})
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId })
}
const { data: completeCreditNote, error: completeCreditNoteError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', creditNote.id)
.single()
const { data: creditNoteSettings } = await supabase
.from('company_settings')
.select('entity_type, accounting_method')
.eq('company_id', companyId)
.eq('creation_complete', true)
.single()
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
// Cash method skips: there's no original invoice JE to reverse, recognition
// is deferred until refund.
if (completeCreditNote && accountingMethod === 'accrual') {
try {
const journalEntry = await createCreditNoteJournalEntry(
supabase,
companyId,
userId,
completeCreditNote as Invoice,
entityType,
completeCreditNote.customer?.name,
)
if (journalEntry) {
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', creditNote.id)
}
} catch (err) {
log.error('failed to create credit note journal entry', err as Error, {
creditNoteId: creditNote.id,
})
// Non-blocking: credit note still exists.
}
// Periodisering interplay: cancel remaining months and storno posted
// dissolutions so origin + dissolutions + stornos + credit net to zero on
// both 29xx and 3xxx. Best-effort: never blocks the credit itself, but
// partial reversals are surfaced as a response warning so the user knows
// the schedule stayed active.
try {
const cancelResult = await cancelSchedulesForSource(
supabase,
companyId,
userId,
{ invoiceId: input.credited_invoice_id },
{ reversalDate: creditNote.invoice_date },
)
if (cancelResult.failedReversals > 0) {
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
'kunde inte vändas. Periodiseringen är fortfarande aktiv: ' +
'kontrollera under Bokföring → Periodiseringar.',
})
}
} catch (err) {
log.warn('failed to cancel accrual schedules for credited invoice', err as Error)
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
'Kontrollera under Bokföring → Periodiseringar.',
})
}
await eventBus.emit({
type: 'credit_note.created',
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
})
if (completeCreditNoteError || !completeCreditNote) {
log.error('failed to read completed credit note', completeCreditNoteError)
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { requestId })
}
return NextResponse.json({
data: completeCreditNote,
...(warnings.length > 0 ? { warnings } : {}),
})
// A credit note is only issued when the user sends it or marks it as sent.
// Until then it is a non-editable draft: no journal entry is created and
// the original invoice remains in its current state.
return NextResponse.json({ data: completeCreditNote })
}
+82
View File
@@ -84,6 +84,88 @@ describe('PUT /api/settings', () => {
expect(body.data.company_name).toBe('New Name')
})
it('updates all three reminder thresholds', async () => {
enqueueMany([
{
data: {
entity_type: 'aktiebolag',
onboarding_complete: true,
reminder_days_level_1: 15,
reminder_days_level_2: 30,
reminder_days_level_3: 45,
},
},
{
data: {
id: 's1',
reminder_days_level_1: 7,
reminder_days_level_2: 21,
reminder_days_level_3: 35,
},
},
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: {
reminder_days_level_1: 7,
reminder_days_level_2: 21,
reminder_days_level_3: 35,
},
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
data: { reminder_days_level_1: number; reminder_days_level_2: number; reminder_days_level_3: number }
}>(response)
expect(status).toBe(200)
expect(body.data).toMatchObject({
reminder_days_level_1: 7,
reminder_days_level_2: 21,
reminder_days_level_3: 35,
})
})
it('returns 400 when reminder thresholds are not increasing', async () => {
enqueue({
data: {
reminder_days_level_1: 15,
reminder_days_level_2: 30,
reminder_days_level_3: 45,
},
})
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: {
reminder_days_level_1: 30,
reminder_days_level_2: 20,
reminder_days_level_3: 45,
},
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('returns 404 when the settings row does not exist', async () => {
enqueueMany([
{ data: { onboarding_complete: false } },
{ data: null, error: { code: 'PGRST116', message: 'No rows returned' } },
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { reminder_days_level_1: 10 },
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('blocks a vacation-year basis change while open balances exist', async () => {
enqueueMany([
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
+16 -1
View File
@@ -40,7 +40,7 @@ export const PUT = withRouteContext(
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis')
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
.eq('company_id', companyId)
.single()
@@ -48,6 +48,18 @@ export const PUT = withRouteContext(
if (!validation.success) return validation.response
const body = validation.data
const reminderDays = [
body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15,
body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30,
body.reminder_days_level_3 ?? oldSettings?.reminder_days_level_3 ?? 45,
]
if (!(reminderDays[0] < reminderDays[1] && reminderDays[1] < reminderDays[2])) {
return NextResponse.json(
{ error: 'Påminnelsedagarna måste ligga i stigande ordning.' },
{ status: 400 },
)
}
// Lock org_number after onboarding is complete (legal identifier: changing it
// would orphan vouchers, SIE history, and tax filings). company_name remains
// editable so users can update their display/brand name (e.g. särskilt företagsnamn).
@@ -121,6 +133,9 @@ export const PUT = withRouteContext(
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
@@ -39,6 +39,11 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
}))
const mockLinkToJournalEntry = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
}))
import { eventBus } from '@/lib/events'
import { GET, POST } from '../route'
@@ -128,6 +133,7 @@ describe('GET /api/supplier-invoices', () => {
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
const DOCUMENT_UUID = '550e8400-e29b-41d4-a716-446655440002'
describe('POST /api/supplier-invoices', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -221,6 +227,77 @@ describe('POST /api/supplier-invoices', () => {
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
})
it('stores an uploaded document and links it to the registration entry', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-with-document', document_id: DOCUMENT_UUID })
enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: supplier, error: null })
enqueue({ data: 6 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-document' })
enqueue({ data: null, error: null })
mockLinkToJournalEntry.mockResolvedValue({ id: DOCUMENT_UUID })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
document_id: DOCUMENT_UUID,
supplier_invoice_number: 'LF-DOCUMENT',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{ description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { document_id: string; registration_journal_entry_id: string }
}>(response)
expect(status).toBe(200)
expect(body.data.document_id).toBe(DOCUMENT_UUID)
expect(body.data.registration_journal_entry_id).toBe('je-document')
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
DOCUMENT_UUID,
'je-document',
)
})
it('rejects a document that is missing or outside the active company', async () => {
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
document_id: DOCUMENT_UUID,
supplier_invoice_number: 'LF-INVALID-DOCUMENT',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{ description: 'Service', quantity: 1, unit_price: 1000, account_number: '6200' },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
})
it('emits supplier_invoice.registered event', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
+60
View File
@@ -12,6 +12,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
ensureInitialized()
@@ -68,6 +69,42 @@ export const POST = withRouteContext(
const body = validation.data
const paidPrivately = body.paid_with_private_funds === true
if (body.document_id) {
const { data: document, error: documentError } = await supabase
.from('document_attachments')
.select('id, journal_entry_id')
.eq('id', body.document_id)
.eq('company_id', companyId)
.maybeSingle()
if (documentError || !document || document.journal_entry_id) {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'document_id is missing, belongs to another company, or is already linked' },
})
}
const { data: existingDocumentUse, error: existingDocumentUseError } = await supabase
.from('supplier_invoices')
.select('id')
.eq('company_id', companyId)
.eq('document_id', body.document_id)
.limit(1)
.maybeSingle()
if (existingDocumentUseError) {
log.error('supplier invoice document usage lookup failed', existingDocumentUseError)
return errorResponse(existingDocumentUseError, log, { requestId })
}
if (existingDocumentUse) {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'document_id is already used by a supplier invoice' },
})
}
}
if (paidPrivately && body.reverse_charge) {
// RC invoices come from registered businesses with formal invoices and
// go through normal AP. "Privately paid" only makes sense for
@@ -236,6 +273,7 @@ export const POST = withRouteContext(
user_id: user.id,
company_id: companyId,
supplier_id: body.supplier_id,
document_id: body.document_id || null,
arrival_number: arrivalNum,
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
@@ -502,6 +540,28 @@ export const POST = withRouteContext(
}
}
const primaryJournalEntryId = paymentJournalEntryId || registrationJournalEntryId
if (body.document_id && primaryJournalEntryId) {
try {
await linkToJournalEntry(
supabase,
companyId,
body.document_id,
primaryJournalEntryId,
)
} catch (err) {
log.warn('supplier invoice document could not be linked to journal entry', {
documentId: body.document_id,
journalEntryId: primaryJournalEntryId,
error: err instanceof Error ? err.message : String(err),
})
warnings.push({
code: 'DOCUMENT_LINK_FAILED',
message: 'Fakturan registrerades, men underlaget kunde inte kopplas till verifikationen.',
})
}
}
try {
await eventBus.emit({
type: 'supplier_invoice.registered',
@@ -309,6 +309,43 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => {
expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_OPEN')
})
it('returns 400 before linking when supplied invoice is a credit note', async () => {
enqueue({
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000 }),
error: null,
})
enqueue({
data: {
id: JE_UUID,
status: 'posted',
voucher_series: 'A',
voucher_number: 1,
entry_date: '2026-05-15',
},
error: null,
})
enqueue({
data: makeInvoice({
id: INV_UUID,
status: 'sent',
total: -1000,
remaining_amount: -1000,
credited_invoice_id: 'original-invoice-1',
}),
error: null,
})
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
method: 'POST',
body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
})
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('LINK_TX_INVOICE_CREDIT_NOTE')
})
it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses and rolls back the tx link', async () => {
enqueue({
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }),
@@ -196,6 +196,51 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE')
})
it('rejects matching an original invoice with an active credit-note draft', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
const invoice = {
...makeInvoice({ id: VALID_UUID, status: 'sent', credited_invoice_id: null }),
credit_notes: [{ id: 'credit-1', status: 'draft', creation_complete: true }],
}
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
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 { body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(response.status).toBe(400)
expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 before booking when matching against a credit note', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
const creditNote = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: -12500,
credited_invoice_id: 'original-invoice-1',
})
enqueue({ data: tx, error: null })
enqueue({ data: creditNote, error: null })
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<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
})
it('returns 400 when invoice is not in unpaid state', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' })
@@ -82,7 +82,7 @@ export const POST = withRouteContext(
const { data: invoice, error: fetchInvError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
.eq('id', invoice_id)
.eq('company_id', companyId)
.single()
@@ -104,6 +104,23 @@ export const POST = withRouteContext(
})
}
if (invoice.credited_invoice_id) {
return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, { requestId })
}
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
status: string
creation_complete?: boolean
}> }).credit_notes ?? []).filter(
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
)
if (activeCreditNotes.length > 0) {
return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
requestId,
details: { reason: 'active_credit_note' },
})
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, {
requestId,
@@ -571,6 +571,47 @@ describe('POST :id/match-invoice', () => {
expect((await res.json()).error.code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED')
})
it('rejects a credit note before creating a payment journal entry', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_ID,
amount: 12500,
date: '2026-05-12',
currency: 'SEK',
invoice_id: null,
},
error: null,
},
invoices: {
data: {
id: INV_ID,
status: 'sent',
document_type: 'invoice',
total: -12500,
credited_invoice_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
},
error: null,
},
}),
)
const res = await matchInvoicePOST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
{ invoice_id: INV_ID },
),
txParams(TX_ID),
)
expect(res.status).toBe(400)
expect((await res.json()).error.code).toBe('MATCH_INVOICE_CREDIT_NOTE')
expect(createInvPmtJE).not.toHaveBeenCalled()
expect(createInvCashJE).not.toHaveBeenCalled()
})
// The v1 route threads resolveSettlementAccount(transaction.cash_account_id)
// exactly like the dashboard route and the agent/MCP commit path; these
// regression tests were missing here (flagged in triage on #987) even
@@ -180,6 +180,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
details: { documentType: docType },
})
}
if (invoice.credited_invoice_id) {
return v1ErrorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
requestId: ctx.requestId,
})
}
if (
invoice.status !== 'sent' &&
invoice.status !== 'overdue' &&