feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA (#1533)
* feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA Customer feedback: MCP-created invoices land in Granskning and then sit as unnumbered drafts that each need individual issuance, and the list filter gives no signal about where the work is. - New POST /api/invoices/bulk-book: drafts get an F-number + mark-sent semantics (no email) and book inline when the company books at issue; sent/overdue unbooked invoices get the deferred /book semantics. Sequential loop keeps voucher numbers ordered; per-item Swedish errors. - Extracted the shared cores into lib/invoices/issue-and-book-invoice.ts and lib/invoices/book-invoice-deferred.ts, now used by the per-id mark-sent and book routes AND the bulk loop, so they cannot drift. Per-id route behavior unchanged (existing route tests untouched, green). - Invoice list: multi-select with hover-reveal checkboxes (supplier-invoices shape), bulkbar with mode-aware action label, ConfirmationDialog with a draft/sent breakdown, one aggregate toast. Kontantmetoden hides selection entirely. - ContextPicker: count annotations on every status view via the one shared predicate (counts always match rows), active view written back to the URL (?status=) for shareable views. No seg/chip row: founder-locked pattern. - Granskning: after a bulk approve that committed create_invoice ops, the summary toast links to /invoices?status=draft to finish with bulk Bokfor. Verified: npm run lint clean, npm test 13845 passed, npm run check:guards passed. New tests: bulk-book route (11), issueAndBookInvoice (7), bookInvoiceDeferred (7). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): bulk-book review findings, deferred drafts, dupes, URL params - Deferred-booking companies (accrual + defer_invoice_booking): a draft in bulk-book no longer gets silently ISSUED (F-number consumed, marked sent, invoice.sent emitted) while reporting status 'booked' with a null journal_entry_id. The draft branch now requires booksInvoicesOnIssue(); otherwise the item fails per-row with the new INVOICE_BOOK_DEFERRED_DRAFT code (Swedish + English) before the invoice is touched. - Duplicate ids in one request no longer double-book: the second iteration read the stale pre-loop snapshot, passed the already-booked check, and minted a voucher the CAS claim then cancelled (cancelled verifikat + gap explanation per duplicate). Ids are deduped before the loop. - Bulkbar: the select-all link is hidden when the current view has no selectable rows; "Markera alla (0)" only wiped the existing selection. - Invoice dialog open/close handlers (new invoice, self-billed, ROT/RUT payout) rewrite only their own query keys instead of hardcoding '/invoices', so the ?status= view write-back survives them. - /pending: the "Bokfor utkasten" toast CTA is suppressed for kontantmetod and deferred-booking companies where the invoice list offers no draft bulk Bokfor (dead end); the neutral hint sentence stays. Tests: deferred-draft rejection (asserts issueAndBookInvoice never called, sent invoice in the same batch still books) and duplicate-id dedupe (exactly one booking call); both fail without the route fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
dea5e31756
commit
bffa57a565
@@ -9,18 +9,21 @@ import { createClient } from '@/lib/supabase/client'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { RowStatus, type RowStatusDescriptor } from '@/components/ui/row-status'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
|
||||
import { DataListEmpty } from '@/components/ui/data-list'
|
||||
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { SplitButton, type SplitButtonOption } from '@/components/ui/split-button'
|
||||
import { useUiState } from '@/lib/hooks/use-ui-state'
|
||||
import { resolveInitialMode } from '@/lib/ui-state/client'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { invoiceDisplayNumber } from '@/lib/invoices/display'
|
||||
@@ -83,8 +86,39 @@ const CREATE_MODES = ['faktura', 'aterkommande', 'sjalvfaktura'] as const
|
||||
// Main views (concept seg) and the low-frequency views behind "Fler …".
|
||||
const SEG_TABS = ['all', 'unpaid', 'overdue', 'draft'] as const
|
||||
const MORE_TABS = ['paid', 'proforma', 'delivery_note', 'credit', 'cancelled'] as const
|
||||
const ALL_TABS = [...SEG_TABS, ...MORE_TABS]
|
||||
type ListTab = (typeof SEG_TABS)[number] | (typeof MORE_TABS)[number]
|
||||
|
||||
// The one status predicate: both the visible rows and the per-view counts in
|
||||
// the ContextPicker go through it, so the annotation always matches what the
|
||||
// view will show.
|
||||
function matchesListTab(invoice: Invoice, tab: ListTab): boolean {
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
return (
|
||||
(tab === 'all' && invoice.status !== 'cancelled') ||
|
||||
(tab === 'unpaid' &&
|
||||
['sent', 'overdue'].includes(invoice.status) &&
|
||||
!isCreditNote &&
|
||||
docType === 'invoice') ||
|
||||
(tab === 'overdue' &&
|
||||
invoice.status === 'overdue' &&
|
||||
!isCreditNote &&
|
||||
docType === 'invoice') ||
|
||||
(tab === 'draft' &&
|
||||
invoice.status === 'draft' &&
|
||||
docType === 'invoice' &&
|
||||
!isCreditNote) ||
|
||||
(tab === 'paid' && invoice.status === 'paid') ||
|
||||
(tab === 'credit' && isCreditNote) ||
|
||||
(tab === 'proforma' && docType === 'proforma' && invoice.status !== 'cancelled') ||
|
||||
(tab === 'delivery_note' &&
|
||||
docType === 'delivery_note' &&
|
||||
invoice.status !== 'cancelled') ||
|
||||
(tab === 'cancelled' && invoice.status === 'cancelled')
|
||||
)
|
||||
}
|
||||
|
||||
const TAB_LABEL_KEYS: Record<ListTab, string> = {
|
||||
all: 'tab_all',
|
||||
unpaid: 'tab_unpaid',
|
||||
@@ -160,6 +194,12 @@ export default function InvoicesPage() {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||
const [rotRutEnabled, setRotRutEnabled] = useState<boolean>(false)
|
||||
// Booking mode drives which rows are bulk-bookable (kontantmetoden: none).
|
||||
const [accountingMethod, setAccountingMethod] = useState<string>('accrual')
|
||||
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState<boolean>(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBulkBookConfirm, setShowBulkBookConfirm] = useState(false)
|
||||
const [isBulkBooking, setIsBulkBooking] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sort, setSort] = useState<InvoiceListSort | null>(null)
|
||||
@@ -168,7 +208,7 @@ export default function InvoicesPage() {
|
||||
const param = searchParams.get('status') ?? searchParams.get('tab')
|
||||
const alias: Record<string, ListTab> = { drafts: 'draft' }
|
||||
const candidate = param ? (alias[param] ?? (param as ListTab)) : null
|
||||
return candidate && [...SEG_TABS, ...MORE_TABS].includes(candidate as never)
|
||||
return candidate && ALL_TABS.includes(candidate as never)
|
||||
? (candidate as ListTab)
|
||||
: 'all'
|
||||
})
|
||||
@@ -192,11 +232,38 @@ export default function InvoicesPage() {
|
||||
const showNewInvoice = searchParams.has('new') || copyFromId !== null
|
||||
const openSelfBilled = searchParams.has('self')
|
||||
const showRotRutPayout = searchParams.has('rot-rut')
|
||||
const closeNewInvoice = () => router.replace('/invoices', { scroll: false })
|
||||
const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false })
|
||||
const openNewSelfBilled = () => router.push('/invoices?new=1&self=1', { scroll: false })
|
||||
const closeRotRutPayout = () => router.replace('/invoices', { scroll: false })
|
||||
const openRotRutPayout = () => router.push('/invoices?rot-rut=1', { scroll: false })
|
||||
// Open/close handlers rewrite only their own keys: a hardcoded '/invoices'
|
||||
// would destroy the ?status= view write-back (and any other params) every
|
||||
// time a dialog opens or closes.
|
||||
const invoicesUrl = (mutate: (params: URLSearchParams) => void) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
mutate(params)
|
||||
const qs = params.toString()
|
||||
return qs ? `/invoices?${qs}` : '/invoices'
|
||||
}
|
||||
const closeNewInvoice = () =>
|
||||
router.replace(
|
||||
invoicesUrl((p) => {
|
||||
p.delete('new')
|
||||
p.delete('self')
|
||||
p.delete('copy')
|
||||
}),
|
||||
{ scroll: false },
|
||||
)
|
||||
const openNewInvoice = () =>
|
||||
router.push(invoicesUrl((p) => p.set('new', '1')), { scroll: false })
|
||||
const openNewSelfBilled = () =>
|
||||
router.push(
|
||||
invoicesUrl((p) => {
|
||||
p.set('new', '1')
|
||||
p.set('self', '1')
|
||||
}),
|
||||
{ scroll: false },
|
||||
)
|
||||
const closeRotRutPayout = () =>
|
||||
router.replace(invoicesUrl((p) => p.delete('rot-rut')), { scroll: false })
|
||||
const openRotRutPayout = () =>
|
||||
router.push(invoicesUrl((p) => p.set('rot-rut', '1')), { scroll: false })
|
||||
|
||||
// Begäran om utbetalning (Lag 2009:194 8 §) only concerns companies selling
|
||||
// ROT/RUT-eligible work to consumers, so the action stays out of the header
|
||||
@@ -225,7 +292,7 @@ export default function InvoicesPage() {
|
||||
),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('ore_rounding, rot_rut_enabled')
|
||||
.select('ore_rounding, rot_rut_enabled, accounting_method, defer_invoice_booking')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle(),
|
||||
])
|
||||
@@ -249,6 +316,16 @@ export default function InvoicesPage() {
|
||||
? (settingsResult.value.data?.rot_rut_enabled ?? false)
|
||||
: false,
|
||||
)
|
||||
setAccountingMethod(
|
||||
settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.data?.accounting_method ?? 'accrual')
|
||||
: 'accrual',
|
||||
)
|
||||
setDeferInvoiceBooking(
|
||||
settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.data?.defer_invoice_booking ?? false)
|
||||
: false,
|
||||
)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -258,7 +335,10 @@ export default function InvoicesPage() {
|
||||
}, [])
|
||||
|
||||
const normalizedSearch = searchTerm.trim().toLocaleLowerCase('sv-SE')
|
||||
const filteredInvoices = useMemo(
|
||||
// Search + fiscal-year scope, before the status view: the per-view counts
|
||||
// in the ContextPicker are computed on this same base so they always equal
|
||||
// the rows the view would show.
|
||||
const scopedInvoices = useMemo(
|
||||
() =>
|
||||
invoices.filter((invoice) => {
|
||||
const matchesSearch =
|
||||
@@ -275,36 +355,13 @@ export default function InvoicesPage() {
|
||||
(invoice.invoice_date >= fyPeriod.period_start &&
|
||||
invoice.invoice_date <= fyPeriod.period_end)
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType =
|
||||
(invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const matchesTab =
|
||||
(activeTab === 'all' && invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'unpaid' &&
|
||||
['sent', 'overdue'].includes(invoice.status) &&
|
||||
!isCreditNote &&
|
||||
docType === 'invoice') ||
|
||||
(activeTab === 'overdue' &&
|
||||
invoice.status === 'overdue' &&
|
||||
!isCreditNote &&
|
||||
docType === 'invoice') ||
|
||||
(activeTab === 'draft' &&
|
||||
invoice.status === 'draft' &&
|
||||
docType === 'invoice' &&
|
||||
!isCreditNote) ||
|
||||
(activeTab === 'paid' && invoice.status === 'paid') ||
|
||||
(activeTab === 'credit' && isCreditNote) ||
|
||||
(activeTab === 'proforma' &&
|
||||
docType === 'proforma' &&
|
||||
invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'delivery_note' &&
|
||||
docType === 'delivery_note' &&
|
||||
invoice.status !== 'cancelled') ||
|
||||
(activeTab === 'cancelled' && invoice.status === 'cancelled')
|
||||
|
||||
return matchesSearch && matchesFy && matchesTab
|
||||
return matchesSearch && matchesFy
|
||||
}),
|
||||
[activeTab, fyPeriod, invoices, normalizedSearch],
|
||||
[fyPeriod, invoices, normalizedSearch],
|
||||
)
|
||||
const filteredInvoices = useMemo(
|
||||
() => scopedInvoices.filter((invoice) => matchesListTab(invoice, activeTab)),
|
||||
[activeTab, scopedInvoices],
|
||||
)
|
||||
const sortedInvoices = useMemo(
|
||||
() => (sort ? sortInvoiceList(filteredInvoices, sort, oreRounding) : filteredInvoices),
|
||||
@@ -312,9 +369,15 @@ export default function InvoicesPage() {
|
||||
)
|
||||
const visibleInvoices = sortedInvoices.slice(0, visibleCount)
|
||||
|
||||
const overdueCount = invoices.filter(
|
||||
(i) => i.status === 'overdue' && !i.credited_invoice_id,
|
||||
).length
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts = Object.fromEntries(ALL_TABS.map((tab) => [tab, 0])) as Record<ListTab, number>
|
||||
for (const invoice of scopedInvoices) {
|
||||
for (const tab of ALL_TABS) {
|
||||
if (matchesListTab(invoice, tab)) counts[tab] += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}, [scopedInvoices])
|
||||
|
||||
const resetPaging = () => setVisibleCount(INITIAL_VISIBLE_ROWS)
|
||||
|
||||
@@ -327,6 +390,108 @@ export default function InvoicesPage() {
|
||||
resetPaging()
|
||||
}
|
||||
|
||||
// Write the active view back to the URL (?status=) so views are shareable
|
||||
// and survive back-navigation; the mount parser above already reads it.
|
||||
// replace, not push: filter flips shouldn't stack history entries.
|
||||
const updateTab = (tab: ListTab) => {
|
||||
setActiveTab(tab)
|
||||
resetPaging()
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('tab')
|
||||
if (tab === 'all') params.delete('status')
|
||||
else params.set('status', tab)
|
||||
const qs = params.toString()
|
||||
router.replace(qs ? `/invoices?${qs}` : '/invoices', { scroll: false })
|
||||
}
|
||||
|
||||
// Bulk Bokför eligibility. Kontantmetoden books at payment, so no row is
|
||||
// selectable (the checkbox column is hidden entirely). Accrual companies
|
||||
// that book at issue select drafts ("Bokför och markera som skickade");
|
||||
// deferred companies (#967) select sent/overdue invoices that lack a
|
||||
// verifikat (worklist-canonical predicate: journal_entry_id IS NULL).
|
||||
const bulkMode: 'issue' | 'deferred' | null =
|
||||
accountingMethod !== 'accrual' ? null : deferInvoiceBooking ? 'deferred' : 'issue'
|
||||
const showSelection = canWrite && bulkMode !== null
|
||||
|
||||
const isBulkSelectable = (invoice: Invoice): boolean => {
|
||||
if (!bulkMode) return false
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
if (docType !== 'invoice' || invoice.credited_invoice_id) return false
|
||||
if (bulkMode === 'issue') return invoice.status === 'draft'
|
||||
return ['sent', 'overdue'].includes(invoice.status) && !invoice.journal_entry_id
|
||||
}
|
||||
|
||||
const selectableInvoices = filteredInvoices.filter(isBulkSelectable)
|
||||
const allSelectableSelected =
|
||||
selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id))
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectedInvoices = invoices.filter((inv) => selectedIds.has(inv.id))
|
||||
const selectedDraftCount = selectedInvoices.filter((inv) => inv.status === 'draft').length
|
||||
const selectedSentCount = selectedInvoices.length - selectedDraftCount
|
||||
|
||||
async function handleBulkBook() {
|
||||
if (selectedIds.size === 0) return
|
||||
setIsBulkBooking(true)
|
||||
try {
|
||||
const res = await fetch('/api/invoices/bulk-book', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status }))
|
||||
|
||||
const summary = json.data?.summary as { booked: number; failed: number } | undefined
|
||||
const results = (json.data?.results ?? []) as Array<{ status: string; error?: string }>
|
||||
|
||||
// One aggregate toast (TOAST_LIMIT is 1), never per-row.
|
||||
if (summary && summary.failed > 0) {
|
||||
const firstErrors = results
|
||||
.filter((r) => r.status === 'failed' && r.error)
|
||||
.slice(0, 2)
|
||||
.map((r) => r.error as string)
|
||||
toast({
|
||||
title: t('bulk_book_partial_title'),
|
||||
description: [
|
||||
t('bulk_book_partial_description', {
|
||||
booked: summary.booked,
|
||||
failed: summary.failed,
|
||||
}),
|
||||
...firstErrors,
|
||||
].join(' '),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t('bulk_book_success_title'),
|
||||
description: t('bulk_book_success_description', {
|
||||
count: summary?.booked ?? selectedIds.size,
|
||||
}),
|
||||
})
|
||||
}
|
||||
setShowBulkBookConfirm(false)
|
||||
setSelectedIds(new Set())
|
||||
fetchInvoices()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('bulk_book_failed_title'),
|
||||
description: getErrorMessage(err),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsBulkBooking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createOptions: SplitButtonOption[] = [
|
||||
{
|
||||
key: 'faktura',
|
||||
@@ -430,21 +595,17 @@ export default function InvoicesPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ContextPicker
|
||||
value={activeTab}
|
||||
onChange={(id) => {
|
||||
setActiveTab(id as ListTab)
|
||||
resetPaging()
|
||||
}}
|
||||
onChange={(id) => updateTab(id as ListTab)}
|
||||
ariaLabel={t('status_picker_aria')}
|
||||
triggerLabel={
|
||||
activeTab === 'overdue' && overdueCount > 0
|
||||
? `${t(TAB_LABEL_KEYS[activeTab])} · ${overdueCount}`
|
||||
tabCounts[activeTab] > 0
|
||||
? `${t(TAB_LABEL_KEYS[activeTab])} · ${tabCounts[activeTab]}`
|
||||
: t(TAB_LABEL_KEYS[activeTab])
|
||||
}
|
||||
items={[...SEG_TABS, ...MORE_TABS].map((tab) => ({
|
||||
items={ALL_TABS.map((tab) => ({
|
||||
id: tab,
|
||||
label: t(TAB_LABEL_KEYS[tab]),
|
||||
annotation:
|
||||
tab === 'overdue' && overdueCount > 0 ? String(overdueCount) : undefined,
|
||||
annotation: tabCounts[tab] > 0 ? String(tabCounts[tab]) : undefined,
|
||||
}))}
|
||||
/>
|
||||
<div className="relative min-w-[190px] max-w-xs flex-1">
|
||||
@@ -472,6 +633,39 @@ export default function InvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulkbar: appears once anything is selected (supplier-invoices shape). */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-border px-1 py-2 text-[12.5px] animate-fade-in">
|
||||
<span className="whitespace-nowrap">
|
||||
<strong className="font-semibold tabular-nums">{selectedIds.size}</strong>{' '}
|
||||
{t('bulkbar_selected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setShowBulkBookConfirm(true)}>
|
||||
{bulkMode === 'issue'
|
||||
? t('bulk_book_and_send_action', { count: selectedIds.size })
|
||||
: t('bulk_book_action', { count: selectedIds.size })}
|
||||
</Button>
|
||||
{/* Hidden when the current view has no selectable rows: a
|
||||
"Markera alla (0)" link would only wipe the selection. */}
|
||||
{selectableInvoices.length > 0 && !allSelectableSelected && (
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))}
|
||||
>
|
||||
{t('bulk_select_all', { count: selectableInvoices.length })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
{t('bulk_clear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
@@ -503,6 +697,9 @@ export default function InvoicesPage() {
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{showSelection && (
|
||||
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
|
||||
)}
|
||||
<SortableHeader
|
||||
label={t('th_nr')}
|
||||
sortLabel={t('sort_by', { column: t('th_nr') })}
|
||||
@@ -571,9 +768,33 @@ export default function InvoicesPage() {
|
||||
return (
|
||||
<tr
|
||||
key={invoice.id}
|
||||
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
|
||||
className={cn(
|
||||
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
|
||||
selectedIds.has(invoice.id) && 'bg-secondary/40',
|
||||
)}
|
||||
onClick={() => router.push(`/invoices/${invoice.id}`)}
|
||||
>
|
||||
{/* Hover-revealed selection checkbox (supplier-invoices shape). */}
|
||||
{showSelection && (
|
||||
<td
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isBulkSelectable(invoice) && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(invoice.id)}
|
||||
onCheckedChange={() => toggleSelect(invoice.id)}
|
||||
aria-label={t('bulk_select_row')}
|
||||
className={cn(
|
||||
'transition-opacity duration-150',
|
||||
selectedIds.has(invoice.id) || selectedIds.size > 0
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')}>
|
||||
<Link
|
||||
href={`/invoices/${invoice.id}`}
|
||||
@@ -656,6 +877,29 @@ export default function InvoicesPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm-before-posting (convention 10): bulk Bokför writes immutable
|
||||
verifikat, so describe the outcome first. */}
|
||||
<ConfirmationDialog
|
||||
open={showBulkBookConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!isBulkBooking) setShowBulkBookConfirm(open)
|
||||
}}
|
||||
onConfirm={handleBulkBook}
|
||||
isSubmitting={isBulkBooking}
|
||||
title={t('bulk_book_confirm_title', { count: selectedIds.size })}
|
||||
warningText={t('bulk_book_confirm_warning')}
|
||||
confirmLabel={t('bulk_book_confirm_label')}
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
{selectedDraftCount > 0 && (
|
||||
<p>{t('bulk_book_breakdown_drafts', { count: selectedDraftCount })}</p>
|
||||
)}
|
||||
{selectedSentCount > 0 && (
|
||||
<p>{t('bulk_book_breakdown_sent', { count: selectedSentCount })}</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, Fragment, createContext, useContext } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,9 +28,12 @@ import {
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Bot,
|
||||
@@ -819,6 +823,7 @@ type ViewTab = 'pending' | 'history'
|
||||
|
||||
export default function PendingOperationsPage() {
|
||||
const t = useTranslations('pending')
|
||||
const router = useRouter()
|
||||
const accountNames = useAccountNamesSource()
|
||||
const [operations, setOperations] = useState<PendingOperation[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -844,6 +849,30 @@ export default function PendingOperationsPage() {
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [isRejecting, setIsRejecting] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const company = useCompanyOptional()?.company ?? null
|
||||
// Whether the "Bokför utkasten" toast CTA leads anywhere: bulk Bokför on
|
||||
// /invoices only selects drafts when the company books at issue. Under
|
||||
// kontantmetoden or deferred booking (#967) the CTA would be a dead end,
|
||||
// so it stays suppressed (false until settings load: suppressing is the
|
||||
// safe direction, the neutral hint sentence still shows).
|
||||
const [invoiceDraftsCtaUseful, setInvoiceDraftsCtaUseful] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company) return
|
||||
let cancelled = false
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, defer_invoice_booking')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setInvoiceDraftsCtaUseful(booksInvoicesOnIssue(data))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company])
|
||||
|
||||
// Read ?conversation= once on mount so deep-links from the agent context
|
||||
// strip filter the list automatically.
|
||||
@@ -979,6 +1008,28 @@ export default function PendingOperationsPage() {
|
||||
| { committed: number; failed: number; skipped: number; rejected: number }
|
||||
| undefined
|
||||
|
||||
// Committed create_invoice ops land as unnumbered DRAFTS: point the
|
||||
// user at the draft view where bulk Bokför finishes the job.
|
||||
const results = (json.data?.results ?? []) as Array<{ id: string; status: string }>
|
||||
const committedIds = new Set(
|
||||
results.filter((r) => r.status === 'committed').map((r) => r.id),
|
||||
)
|
||||
const committedInvoiceDrafts = operations.some(
|
||||
(op) => committedIds.has(op.id) && op.operation_type === 'create_invoice',
|
||||
)
|
||||
const draftsCta = committedInvoiceDrafts && invoiceDraftsCtaUseful
|
||||
? {
|
||||
action: (
|
||||
<ToastAction
|
||||
altText={t('bulk_invoice_drafts_cta')}
|
||||
onClick={() => router.push('/invoices?status=draft')}
|
||||
>
|
||||
{t('bulk_invoice_drafts_cta')}
|
||||
</ToastAction>
|
||||
),
|
||||
}
|
||||
: {}
|
||||
|
||||
if (summary) {
|
||||
const parts: string[] = []
|
||||
if (summary.committed > 0) parts.push(`${summary.committed} godkända`)
|
||||
@@ -988,11 +1039,14 @@ export default function PendingOperationsPage() {
|
||||
|
||||
toast({
|
||||
title: summary.failed > 0 ? 'Klart med fel' : 'Godkänt',
|
||||
description: parts.join(', '),
|
||||
description: committedInvoiceDrafts
|
||||
? `${parts.join(', ')}. ${t('bulk_invoice_drafts_hint')}`
|
||||
: parts.join(', '),
|
||||
variant: summary.failed > 0 ? 'destructive' : 'default',
|
||||
...draftsCta,
|
||||
})
|
||||
} else {
|
||||
toast({ title: 'Godkänt' })
|
||||
toast({ title: 'Godkänt', ...draftsCta })
|
||||
}
|
||||
|
||||
setShowBulkDialog(false)
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import type { CompanySettings, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
// Statuses where the revenue entry can still be created afterwards. Paid
|
||||
// invoices are excluded: their payment flow has already booked the full
|
||||
// cash-style entry (mark-paid routes on the missing journal-entry link), so
|
||||
// booking the sale now would double-post revenue.
|
||||
const BOOKABLE_STATUSES = ['sent', 'overdue']
|
||||
import {
|
||||
bookInvoiceDeferred,
|
||||
INVOICE_BOOKABLE_STATUSES,
|
||||
} from '@/lib/invoices/book-invoice-deferred'
|
||||
import type { CompanySettings, EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/book
|
||||
*
|
||||
* The explicit "Bokför" step for companies with defer_invoice_booking (#967):
|
||||
* one person creates and sends the invoice without bookkeeping, ekonomi books
|
||||
* the revenue entry here once the kontering is verified.
|
||||
* the revenue entry here once the kontering is verified. The core lives in
|
||||
* lib/invoices/book-invoice-deferred.ts, shared with the bulk Bokför route.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'invoice.book',
|
||||
@@ -44,7 +38,7 @@ export const POST = withRouteContext(
|
||||
if (!isRealInvoice || invoice.credited_invoice_id) {
|
||||
return errorResponseFromCode('INVOICE_BOOK_NOT_BOOKABLE', log, { requestId })
|
||||
}
|
||||
if (!BOOKABLE_STATUSES.includes(invoice.status)) {
|
||||
if (!INVOICE_BOOKABLE_STATUSES.includes(invoice.status)) {
|
||||
return errorResponseFromCode('INVOICE_BOOK_INVALID_STATUS', log, {
|
||||
requestId,
|
||||
details: { currentStatus: invoice.status },
|
||||
@@ -69,132 +63,29 @@ export const POST = withRouteContext(
|
||||
}
|
||||
const entityType = ((settings as Partial<CompanySettings>).entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
let journalEntry
|
||||
try {
|
||||
journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
entityType,
|
||||
invoice.customer?.name,
|
||||
)
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
log.error('deferred invoice booking failed', err as Error, { invoiceId: id })
|
||||
return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId })
|
||||
}
|
||||
const result = await bookInvoiceDeferred({
|
||||
supabase,
|
||||
companyId: companyId!,
|
||||
userId: user.id,
|
||||
invoice,
|
||||
entityType,
|
||||
log,
|
||||
})
|
||||
|
||||
// Returns null ONLY when no fiscal period covers invoice_date (other
|
||||
// failures throw). Nothing was posted, so a plain error is safe.
|
||||
if (!journalEntry) {
|
||||
return errorResponseFromCode('INVOICE_BOOK_NO_FISCAL_PERIOD', log, {
|
||||
if (!result.ok) {
|
||||
if (result.kind === 'domain') {
|
||||
return errorResponse(result.error, log, { requestId })
|
||||
}
|
||||
return errorResponseFromCode(result.errorCode, log, {
|
||||
requestId,
|
||||
details: { invoiceDate: invoice.invoice_date },
|
||||
})
|
||||
}
|
||||
|
||||
// CAS-guarded link: only claim the invoice if it is still unbooked, still
|
||||
// in a bookable status, and still uncredited. A concurrent
|
||||
// book/mark-paid/credit that got there first would otherwise leave this
|
||||
// entry double-posting revenue, so cancel it.
|
||||
const { data: linked, error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.in('status', BOOKABLE_STATUSES)
|
||||
.is('credited_invoice_id', null)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (linkError || !linked) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
journalEntry.id,
|
||||
'Bokföring av kundfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.',
|
||||
)
|
||||
return errorResponseFromCode('INVOICE_BOOK_CONFLICT', log, { requestId })
|
||||
}
|
||||
|
||||
const warnings: Array<{ code: string; message: string }> = []
|
||||
|
||||
// The send flow archived the exact delivered PDF before this deferred
|
||||
// journal entry existed. Attach the newest successful delivery snapshot now.
|
||||
const { data: deliveryDocumentId, error: deliveryDocumentError } = await supabase.rpc(
|
||||
'latest_sent_invoice_delivery_document',
|
||||
{ p_company_id: companyId, p_invoice_id: id },
|
||||
)
|
||||
|
||||
if (deliveryDocumentError) {
|
||||
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
|
||||
invoiceId: id,
|
||||
})
|
||||
warnings.push({
|
||||
code: 'PDF_LINK_FAILED',
|
||||
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
|
||||
})
|
||||
} else if (typeof deliveryDocumentId === 'string') {
|
||||
try {
|
||||
await linkToJournalEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
deliveryDocumentId,
|
||||
journalEntry.id,
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('failed to link delivered invoice PDF on deferred booking', err as Error, {
|
||||
invoiceId: id,
|
||||
documentId: deliveryDocumentId,
|
||||
})
|
||||
warnings.push({
|
||||
code: 'PDF_LINK_FAILED',
|
||||
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Periodiseringar ride on the revenue entry, so they can only be created
|
||||
// now. Non-blocking: the entry is committed (immutable); a schedule
|
||||
// failure is surfaced as a warning and retried from the periodiseringar
|
||||
// page.
|
||||
try {
|
||||
const accrual = await createSchedulesForCustomerInvoice(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
(invoice.items as InvoiceItem[] | null) ?? [],
|
||||
journalEntry.id,
|
||||
entityType,
|
||||
)
|
||||
if (accrual.failed > 0) {
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_SCHEDULE_FAILED',
|
||||
message:
|
||||
'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' +
|
||||
'skapas. Kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('accrual schedule creation failed on deferred booking', err as Error, { invoiceId: id })
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_SCHEDULE_FAILED',
|
||||
message:
|
||||
'Fakturan bokfördes, men periodiseringarna kunde inte skapas. ' +
|
||||
'Kontrollera under Bokföring → Periodiseringar.',
|
||||
...(result.details ? { details: result.details } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: linked,
|
||||
journal_entry_id: journalEntry.id,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
data: result.invoice,
|
||||
journal_entry_id: result.journalEntryId,
|
||||
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
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 {
|
||||
archiveIssuedInvoicePdf,
|
||||
issueAndBookInvoice,
|
||||
type IssuePartialFailure,
|
||||
} from '@/lib/invoices/issue-and-book-invoice'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
|
||||
import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
|
||||
import {
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { hasRequiredInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type {
|
||||
AccountingMethod,
|
||||
CompanySettings,
|
||||
CreditNote,
|
||||
Customer,
|
||||
EntityType,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
} from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -41,6 +32,9 @@ ensureInitialized()
|
||||
* Manually marks a draft invoice as sent (for invoices delivered outside the system).
|
||||
* Under faktureringsmetoden (accrual): creates the journal entry (Debit 1510, Credit 30xx/26xx).
|
||||
* Under kontantmetoden (cash): no journal entry; booking happens at payment.
|
||||
*
|
||||
* The non-credit-note core lives in lib/invoices/issue-and-book-invoice.ts,
|
||||
* shared with POST /api/invoices/bulk-book; credit notes stay inline here.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.mark_sent',
|
||||
@@ -112,8 +106,40 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
|
||||
}
|
||||
|
||||
if (!isCreditNote) {
|
||||
const result = await issueAndBookInvoice({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
invoice: invoice as Invoice,
|
||||
settings: settings as CompanySettings,
|
||||
log,
|
||||
customLines,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(result.errorCode, log, {
|
||||
requestId,
|
||||
...(result.details ? { details: result.details } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
status: 'sent',
|
||||
journal_entry_id: result.journalEntryId,
|
||||
...(result.partialFailures.length > 0
|
||||
? { partial: true, partial_failures: result.partialFailures }
|
||||
: {}),
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'private, no-store' } },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Credit-note path ────────────────────────────────────────────────
|
||||
|
||||
const invoiceCurrency = (invoice as Invoice).currency
|
||||
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
|
||||
if (!hasRequiredInvoicePaymentAccount(settings as CompanySettings, invoice as Invoice)) {
|
||||
return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
|
||||
requestId,
|
||||
@@ -131,33 +157,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
|
||||
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()
|
||||
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
|
||||
if (!original) {
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const journalEntryRequired = originalInvoice
|
||||
? creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
|
||||
: false
|
||||
const isRecovery = isCreditNote && invoice.status === 'sent'
|
||||
const originalInvoice = original as CreditNoteOriginalInvoice
|
||||
const originalInvoiceNumber = original.invoice_number ?? undefined
|
||||
|
||||
const journalEntryRequired = creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
|
||||
const isRecovery = invoice.status === 'sent'
|
||||
|
||||
if (
|
||||
isRecovery &&
|
||||
originalInvoice?.status === 'credited' &&
|
||||
originalInvoice.status === 'credited' &&
|
||||
(!journalEntryRequired || !!invoice.journal_entry_id)
|
||||
) {
|
||||
return errorResponseFromCode('INVOICE_CREDIT_ALREADY_ISSUED', log, { requestId })
|
||||
@@ -185,235 +205,71 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
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
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
|
||||
// Custom lines only apply where mark-sent books inline; elsewhere they are
|
||||
// deliberately ignored (documented in MarkInvoiceSentSchema). Log it so the
|
||||
// mismatch is visible in audit review instead of vanishing silently.
|
||||
if (
|
||||
customLines &&
|
||||
(isCreditNote || !isRealInvoice || !booksInvoicesOnIssue(settings as CompanySettings))
|
||||
) {
|
||||
// Custom lines never apply to credit notes; log the mismatch so it stays
|
||||
// visible in audit review instead of vanishing silently (documented in
|
||||
// MarkInvoiceSentSchema).
|
||||
if (customLines) {
|
||||
log.warn('mark-sent: custom lines ignored (not on accrual book-at-issue path)', {
|
||||
invoiceId: id,
|
||||
lineCount: customLines.length,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
const partialFailures: IssuePartialFailure[] = []
|
||||
|
||||
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 && booksInvoicesOnIssue(settings as CompanySettings)) {
|
||||
// #967: deferred companies fall past this branch (mark-sent WITHOUT
|
||||
// booking); ekonomi books later via POST /api/invoices/[id]/book, like
|
||||
// under kontantmetoden.
|
||||
try {
|
||||
if (customLines) {
|
||||
// Audit trail: distinguish user-edited bookings from generated ones.
|
||||
log.info('mark-sent: booking user-edited custom lines', {
|
||||
invoiceId: id,
|
||||
userId: user.id,
|
||||
lineCount: customLines.length,
|
||||
})
|
||||
}
|
||||
const journalEntry = customLines
|
||||
? await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
entityType,
|
||||
invoice.customer?.name,
|
||||
{ customLines },
|
||||
)
|
||||
: await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
entityType,
|
||||
invoice.customer?.name,
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
const issueResult = await issueCreditNote({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
creditNote: invoice as CreditNote,
|
||||
originalInvoice,
|
||||
entityType,
|
||||
accountingMethod,
|
||||
log,
|
||||
})
|
||||
const journalEntryId = issueResult.journalEntryId
|
||||
partialFailures.push(...issueResult.failures)
|
||||
|
||||
// Periodiserade lines: create schedules + catch-up dissolutions now
|
||||
// that the revenue entry exists. Failures are logged, never fatal:
|
||||
// the verifikat is committed. Skipped when the user edited the lines:
|
||||
// the generated 29xx deferral may no longer exist in what was booked,
|
||||
// and a schedule would then dissolve an interim balance that was
|
||||
// never credited. User-edited lines book exactly as reviewed.
|
||||
if (!customLines) {
|
||||
const accrual = await createSchedulesForCustomerInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
(invoice.items as InvoiceItem[] | null) ?? [],
|
||||
journalEntry.id,
|
||||
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
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
if (linkError) {
|
||||
// Don't fail mark-sent: the verifikat committed; only the link
|
||||
// failed. But log it through the structured logger so it reaches log
|
||||
// aggregation/alerting: this write silently no-ops when the
|
||||
// journal_entry_id column is missing (it was absent in prod until the
|
||||
// 20260613100000 migration), which leaves mark-paid unable to detect
|
||||
// an already-booked sale.
|
||||
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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-closed only when inline booking was supposed to happen: deferred
|
||||
// (#967) and cash-method invoices are legitimately unbooked at this point.
|
||||
if (isRealInvoice && booksInvoicesOnIssue(settings as CompanySettings) && !isCreditNote && !journalEntryId) {
|
||||
if (statusFlipped) {
|
||||
const { error: rollbackError } = await supabase
|
||||
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)
|
||||
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'] },
|
||||
})
|
||||
return errorResponseFromCode(
|
||||
issueResult.repairRequired
|
||||
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
|
||||
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
|
||||
log,
|
||||
{
|
||||
requestId,
|
||||
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Render and archive the PDF as underlag so it remains retrievable even if
|
||||
// the invoice row is later cancelled. Mirrors the send route.
|
||||
// the invoice row is later cancelled. Credit notes are real invoices
|
||||
// (document_type 'invoice'), so this mirrors the non-credit path.
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
const items = (invoice.items as InvoiceItem[] | null ?? []).slice().sort(
|
||||
(a, b) => a.sort_order - b.sort_order
|
||||
)
|
||||
|
||||
// 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".
|
||||
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||||
settings as CompanySettings,
|
||||
renderableInvoice.currency,
|
||||
{ paymentAccountRequired },
|
||||
)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: renderableInvoice,
|
||||
customer: invoice.customer as Customer,
|
||||
items,
|
||||
company: renderCompany,
|
||||
originalInvoiceNumber,
|
||||
branding,
|
||||
swishQrDataUrl,
|
||||
})
|
||||
)
|
||||
|
||||
const filename = invoicePdfFilename({
|
||||
companyName: settings.company_name,
|
||||
customerName: (invoice.customer as Customer).name,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
invoiceId: invoice.id,
|
||||
invoiceDate: invoice.invoice_date,
|
||||
documentType: invoice.document_type,
|
||||
isCreditNote: !!invoice.credited_invoice_id,
|
||||
})
|
||||
|
||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||
await uploadDocument(supabase, user.id, companyId, {
|
||||
name: filename,
|
||||
buffer: pdfArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
}, {
|
||||
upload_source: 'system',
|
||||
journal_entry_id: journalEntryId ?? undefined,
|
||||
})
|
||||
} 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.',
|
||||
})
|
||||
}
|
||||
const pdfFailure = await archiveIssuedInvoicePdf({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
invoice: invoice as Invoice,
|
||||
settings: settings as CompanySettings,
|
||||
journalEntryId,
|
||||
originalInvoiceNumber,
|
||||
log,
|
||||
})
|
||||
if (pdfFailure) partialFailures.push(pdfFailure)
|
||||
}
|
||||
|
||||
if (statusFlipped) {
|
||||
@@ -433,13 +289,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCreditNote) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId: user.id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCompanySettings,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
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 }),
|
||||
}))
|
||||
|
||||
const mockIssueAndBook = vi.fn()
|
||||
vi.mock('@/lib/invoices/issue-and-book-invoice', () => ({
|
||||
issueAndBookInvoice: (...args: unknown[]) => mockIssueAndBook(...args),
|
||||
}))
|
||||
|
||||
const mockBookDeferred = vi.fn()
|
||||
vi.mock('@/lib/invoices/book-invoice-deferred', () => ({
|
||||
bookInvoiceDeferred: (...args: unknown[]) => mockBookDeferred(...args),
|
||||
INVOICE_BOOKABLE_STATUSES: ['sent', 'overdue'],
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const UUID_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const UUID_2 = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
const accrualSettings = makeCompanySettings({
|
||||
accounting_method: 'accrual',
|
||||
entity_type: 'aktiebolag',
|
||||
})
|
||||
|
||||
function bulkRequest(ids: unknown) {
|
||||
return POST(createMockRequest('/api/invoices/bulk-book', { method: 'POST', body: { ids } }))
|
||||
}
|
||||
|
||||
function makeDraft(id: string) {
|
||||
return {
|
||||
...makeInvoice({ id, status: 'draft' }),
|
||||
document_type: 'invoice',
|
||||
credited_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
customer: { name: 'Kunden AB' },
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('POST /api/invoices/bulk-book', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(await bulkRequest([UUID_1]))
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for an invalid body', async () => {
|
||||
const { status } = await parseJsonResponse(await bulkRequest([]))
|
||||
expect(status).toBe(400)
|
||||
expect(mockIssueAndBook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for non-uuid ids', async () => {
|
||||
const { status } = await parseJsonResponse(await bulkRequest(['not-a-uuid']))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects the whole batch under kontantmetoden', async () => {
|
||||
enqueue({ data: { ...accrualSettings, accounting_method: 'cash' }, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await bulkRequest([UUID_1]),
|
||||
)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_BOOK_CASH_METHOD')
|
||||
expect(mockIssueAndBook).not.toHaveBeenCalled()
|
||||
expect(mockBookDeferred).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports unknown ids as per-item INVOICE_NOT_FOUND failures', async () => {
|
||||
enqueue({ data: accrualSettings, error: null })
|
||||
enqueue({ data: [], error: null }) // invoices fetch finds nothing
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
results: Array<{ id: string; status: string; error_code?: string; error?: string }>
|
||||
summary: { total: number; booked: number; failed: number }
|
||||
}
|
||||
}>(await bulkRequest([UUID_1]))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results).toEqual([
|
||||
expect.objectContaining({
|
||||
id: UUID_1,
|
||||
status: 'failed',
|
||||
error_code: 'INVOICE_NOT_FOUND',
|
||||
error: expect.stringContaining('hittas'),
|
||||
}),
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 1, booked: 0, failed: 1 })
|
||||
})
|
||||
|
||||
it('issues and books drafts via issueAndBookInvoice', async () => {
|
||||
enqueue({ data: accrualSettings, error: null })
|
||||
enqueue({ data: [makeDraft(UUID_1)], error: null })
|
||||
mockIssueAndBook.mockResolvedValue({ ok: true, journalEntryId: 'je-1', partialFailures: [] })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
results: Array<{ id: string; status: string; journal_entry_id?: string | null }>
|
||||
summary: { total: number; booked: number; failed: number }
|
||||
}
|
||||
}>(await bulkRequest([UUID_1]))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockIssueAndBook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
supabase: mockSupabase,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
invoice: expect.objectContaining({ id: UUID_1 }),
|
||||
}),
|
||||
)
|
||||
// No email path and no custom lines from bulk.
|
||||
expect(mockIssueAndBook.mock.calls[0][0]).not.toHaveProperty('customLines')
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: UUID_1, status: 'booked', journal_entry_id: 'je-1' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 1, booked: 1, failed: 0 })
|
||||
})
|
||||
|
||||
it('rejects drafts per item under deferred booking without issuing them', async () => {
|
||||
// accrual + defer_invoice_booking: issuing the draft would consume an
|
||||
// F-number and mark it sent WITHOUT booking anything. The sent invoice in
|
||||
// the same batch must still book: deferred is exactly the /book case.
|
||||
enqueue({ data: { ...accrualSettings, defer_invoice_booking: true }, error: null })
|
||||
enqueue({
|
||||
data: [makeDraft(UUID_1), { ...makeDraft(UUID_2), status: 'sent' }],
|
||||
error: null,
|
||||
})
|
||||
mockBookDeferred.mockResolvedValue({
|
||||
ok: true,
|
||||
invoice: { id: UUID_2 },
|
||||
journalEntryId: 'je-3',
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
results: Array<{ id: string; status: string; error_code?: string; error?: string }>
|
||||
summary: { total: number; booked: number; failed: number }
|
||||
}
|
||||
}>(await bulkRequest([UUID_1, UUID_2]))
|
||||
|
||||
expect(status).toBe(200)
|
||||
// The draft is never touched: no issuance side effects at all.
|
||||
expect(mockIssueAndBook).not.toHaveBeenCalled()
|
||||
expect(body.data.results).toEqual([
|
||||
expect.objectContaining({
|
||||
id: UUID_1,
|
||||
status: 'failed',
|
||||
error_code: 'INVOICE_BOOK_DEFERRED_DRAFT',
|
||||
error: expect.stringContaining('separat steg'),
|
||||
}),
|
||||
{ id: UUID_2, status: 'booked', journal_entry_id: 'je-3' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 2, booked: 1, failed: 1 })
|
||||
})
|
||||
|
||||
it('processes duplicated ids exactly once (no second voucher)', async () => {
|
||||
enqueue({ data: accrualSettings, error: null })
|
||||
enqueue({ data: [makeDraft(UUID_1)], error: null })
|
||||
mockIssueAndBook.mockResolvedValue({ ok: true, journalEntryId: 'je-1', partialFailures: [] })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
results: Array<{ id: string; status: string; journal_entry_id?: string | null }>
|
||||
summary: { total: number; booked: number; failed: number }
|
||||
}
|
||||
}>(await bulkRequest([UUID_1, UUID_1]))
|
||||
|
||||
expect(status).toBe(200)
|
||||
// Without dedupe the second iteration reads the stale pre-loop snapshot,
|
||||
// passes the already-booked check, and mints a voucher the CAS claim then
|
||||
// cancels: one cancelled verifikat + gap explanation per duplicate.
|
||||
expect(mockIssueAndBook).toHaveBeenCalledTimes(1)
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: UUID_1, status: 'booked', journal_entry_id: 'je-1' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 1, booked: 1, failed: 0 })
|
||||
})
|
||||
|
||||
it('books sent unbooked invoices via bookInvoiceDeferred', async () => {
|
||||
enqueue({ data: accrualSettings, error: null })
|
||||
enqueue({
|
||||
data: [{ ...makeDraft(UUID_1), status: 'sent' }],
|
||||
error: null,
|
||||
})
|
||||
mockBookDeferred.mockResolvedValue({
|
||||
ok: true,
|
||||
invoice: { id: UUID_1 },
|
||||
journalEntryId: 'je-2',
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { results: Array<{ id: string; status: string; journal_entry_id?: string | null }> }
|
||||
}>(await bulkRequest([UUID_1]))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockBookDeferred).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
invoice: expect.objectContaining({ id: UUID_1 }),
|
||||
entityType: 'aktiebolag',
|
||||
}),
|
||||
)
|
||||
expect(mockIssueAndBook).not.toHaveBeenCalled()
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: UUID_1, status: 'booked', journal_entry_id: 'je-2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips already-booked sent invoices without touching the engine', async () => {
|
||||
enqueue({ data: accrualSettings, error: null })
|
||||
enqueue({
|
||||
data: [{ ...makeDraft(UUID_1), status: 'sent', journal_entry_id: 'je-old' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { results: Array<{ status: string; error_code?: string }> }
|
||||
}>(await bulkRequest([UUID_1]))
|
||||
|
||||
expect(body.data.results[0]).toEqual(
|
||||
expect.objectContaining({ status: 'failed', error_code: 'INVOICE_BOOK_ALREADY_BOOKED' }),
|
||||
)
|
||||
expect(mockBookDeferred).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects credit notes and non-invoice document types per item', async () => {
|
||||
enqueue({ data: accrualSettings, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
{ ...makeDraft(UUID_1), credited_invoice_id: 'inv-0' },
|
||||
{ ...makeDraft(UUID_2), document_type: 'proforma' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { results: Array<{ id: string; status: string; error_code?: string }> }
|
||||
}>(await bulkRequest([UUID_1, UUID_2]))
|
||||
|
||||
expect(body.data.results).toEqual([
|
||||
expect.objectContaining({ id: UUID_1, status: 'failed', error_code: 'INVOICE_BOOK_NOT_BOOKABLE' }),
|
||||
expect.objectContaining({ id: UUID_2, status: 'failed', error_code: 'INVOICE_BOOK_NOT_BOOKABLE' }),
|
||||
])
|
||||
expect(mockIssueAndBook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects paid invoices per item with INVOICE_BOOK_INVALID_STATUS', async () => {
|
||||
enqueue({ data: accrualSettings, error: null })
|
||||
enqueue({ data: [{ ...makeDraft(UUID_1), status: 'paid' }], error: null })
|
||||
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { results: Array<{ status: string; error_code?: string }> }
|
||||
}>(await bulkRequest([UUID_1]))
|
||||
|
||||
expect(body.data.results[0]).toEqual(
|
||||
expect.objectContaining({ status: 'failed', error_code: 'INVOICE_BOOK_INVALID_STATUS' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('continues past failures and reports a mixed summary', async () => {
|
||||
enqueue({ data: accrualSettings, error: null })
|
||||
enqueue({ data: [makeDraft(UUID_1), makeDraft(UUID_2)], error: null })
|
||||
mockIssueAndBook
|
||||
.mockResolvedValueOnce({ ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' })
|
||||
.mockResolvedValueOnce({ ok: true, journalEntryId: 'je-9', partialFailures: [] })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
results: Array<{ id: string; status: string; error_code?: string; error?: string }>
|
||||
summary: { total: number; booked: number; failed: number }
|
||||
}
|
||||
}>(await bulkRequest([UUID_1, UUID_2]))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockIssueAndBook).toHaveBeenCalledTimes(2)
|
||||
expect(body.data.results).toEqual([
|
||||
expect.objectContaining({
|
||||
id: UUID_1,
|
||||
status: 'failed',
|
||||
error_code: 'INVOICE_MARK_SENT_RACE',
|
||||
// Per-item errors are Swedish user strings, never raw codes.
|
||||
error: expect.any(String),
|
||||
}),
|
||||
{ id: UUID_2, status: 'booked', journal_entry_id: 'je-9' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 2, booked: 1, failed: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { InvoicesBulkBookSchema } from '@/lib/api/schemas'
|
||||
import { issueAndBookInvoice, type IssuableInvoice } from '@/lib/invoices/issue-and-book-invoice'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
import {
|
||||
bookInvoiceDeferred,
|
||||
INVOICE_BOOKABLE_STATUSES,
|
||||
} from '@/lib/invoices/book-invoice-deferred'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import type { CompanySettings, EntityType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface BulkBookItemResult {
|
||||
id: string
|
||||
status: 'booked' | 'failed'
|
||||
journal_entry_id?: string | null
|
||||
error_code?: string
|
||||
/** Swedish user-facing message; raw errors stay in logs. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
function failItem(id: string, errorCode: string): BulkBookItemResult {
|
||||
return {
|
||||
id,
|
||||
status: 'failed',
|
||||
error_code: errorCode,
|
||||
error: getErrorEntry(errorCode)?.message_sv ?? 'Något gick fel. Försök igen.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/invoices/bulk-book
|
||||
*
|
||||
* One "Bokför" click for many customer invoices (MCP-created invoices land as
|
||||
* drafts that used to need individual issuance). Per invoice:
|
||||
* - draft → issueAndBookInvoice(): F-number + mark sent (NO
|
||||
* email) + revenue verifikat. Exactly the mark-sent
|
||||
* semantics; only when the company books at issue,
|
||||
* deferred-booking companies get a per-item error
|
||||
* instead of a silent issuance.
|
||||
* - sent/overdue,
|
||||
* unbooked → bookInvoiceDeferred(): the /book semantics
|
||||
* (CAS-guarded claim).
|
||||
* - anything else → per-item error.
|
||||
*
|
||||
* The loop is sequential on purpose: commit_journal_entry assigns voucher
|
||||
* numbers atomically per call, so a serial loop keeps them gap-free and in
|
||||
* order. Failures never abort the batch; each item reports its own outcome.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'invoice.bulk_book',
|
||||
async (request, { user, supabase, companyId, log, requestId }) => {
|
||||
const validated = await validateBody(request, InvoicesBulkBookSchema)
|
||||
if (!validated.success) return validated.response
|
||||
const { ids } = validated.data
|
||||
|
||||
// One settings read for the whole batch: issuance needs the full row
|
||||
// (payment accounts + PDF branding), booking needs method + entity type.
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (settingsError || !settings) {
|
||||
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
|
||||
}
|
||||
// Under kontantmetoden nothing books before payment, so the whole request
|
||||
// is a no-op: reject it instead of issuing drafts nobody asked to send.
|
||||
if ((settings.accounting_method || 'accrual') !== 'accrual') {
|
||||
return errorResponseFromCode('INVOICE_BOOK_CASH_METHOD', log, { requestId })
|
||||
}
|
||||
const entityType =
|
||||
((settings as Partial<CompanySettings>).entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const { data: invoices, error: fetchError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.in('id', ids)
|
||||
.eq('company_id', companyId)
|
||||
if (fetchError) {
|
||||
log.error('failed to fetch invoices for bulk book', fetchError)
|
||||
return errorResponseFromCode('INTERNAL_ERROR', log, { requestId })
|
||||
}
|
||||
|
||||
const invoicesById = new Map(
|
||||
((invoices ?? []) as IssuableInvoice[]).map((invoice) => [invoice.id, invoice]),
|
||||
)
|
||||
const results: BulkBookItemResult[] = []
|
||||
|
||||
// Dedupe: the loop's already-booked checks read the pre-loop snapshot, so
|
||||
// a repeated id would pass them twice and commit a second voucher that the
|
||||
// CAS claim then cancels (a cancelled verifikat + gap explanation per
|
||||
// duplicate). Each unique id is processed exactly once.
|
||||
const uniqueIds = [...new Set(ids)]
|
||||
|
||||
for (const id of uniqueIds) {
|
||||
const invoice = invoicesById.get(id)
|
||||
if (!invoice) {
|
||||
results.push(failItem(id, 'INVOICE_NOT_FOUND'))
|
||||
continue
|
||||
}
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
if (!isRealInvoice || invoice.credited_invoice_id) {
|
||||
results.push(failItem(id, 'INVOICE_BOOK_NOT_BOOKABLE'))
|
||||
continue
|
||||
}
|
||||
|
||||
if (invoice.status === 'draft') {
|
||||
// Deferred-booking companies (#967) issue via mark-sent and book via
|
||||
// the explicit /book step. issueAndBookInvoice would consume an
|
||||
// F-number and flip the draft to sent WITHOUT booking anything, an
|
||||
// irreversible side effect nobody asked for, so the item fails
|
||||
// before the invoice is touched.
|
||||
if (!booksInvoicesOnIssue(settings)) {
|
||||
results.push(failItem(id, 'INVOICE_BOOK_DEFERRED_DRAFT'))
|
||||
continue
|
||||
}
|
||||
const result = await issueAndBookInvoice({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
invoice,
|
||||
settings: settings as CompanySettings,
|
||||
log,
|
||||
})
|
||||
if (!result.ok) {
|
||||
results.push(failItem(id, result.errorCode))
|
||||
} else {
|
||||
results.push({ id, status: 'booked', journal_entry_id: result.journalEntryId })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (INVOICE_BOOKABLE_STATUSES.includes(invoice.status)) {
|
||||
// Worklist-canonical unbooked predicate: journal_entry_id IS NULL.
|
||||
if (invoice.journal_entry_id) {
|
||||
results.push(failItem(id, 'INVOICE_BOOK_ALREADY_BOOKED'))
|
||||
continue
|
||||
}
|
||||
const result = await bookInvoiceDeferred({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
invoice,
|
||||
entityType,
|
||||
log,
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.kind === 'domain') {
|
||||
const code = (result.error as { code?: string } | null)?.code
|
||||
results.push({
|
||||
id,
|
||||
status: 'failed',
|
||||
...(code ? { error_code: code } : {}),
|
||||
error: getErrorMessage(result.error),
|
||||
})
|
||||
} else {
|
||||
results.push(failItem(id, result.errorCode))
|
||||
}
|
||||
} else {
|
||||
results.push({ id, status: 'booked', journal_entry_id: result.journalEntryId })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
results.push(failItem(id, 'INVOICE_BOOK_INVALID_STATUS'))
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: results.length,
|
||||
booked: results.filter((r) => r.status === 'booked').length,
|
||||
failed: results.filter((r) => r.status === 'failed').length,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { results, summary } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -802,6 +802,13 @@ export const MarkInvoiceSentSchema = z.object({
|
||||
})).min(2).optional(),
|
||||
})
|
||||
|
||||
// Bulk Bokför: drafts are issued (F-number + mark-sent semantics, no email)
|
||||
// and booked when the company books at issue; sent/overdue unbooked invoices
|
||||
// get the deferred /book semantics. 200 caps one request at two list pages.
|
||||
export const InvoicesBulkBookSchema = z.object({
|
||||
ids: z.array(z.string().uuid()).min(1).max(200),
|
||||
})
|
||||
|
||||
export const SendInvoiceSchema = MarkInvoiceSentSchema.extend({
|
||||
additional_cc: invoiceEmailAddressList.optional(),
|
||||
additional_bcc: invoiceEmailAddressList.optional(),
|
||||
|
||||
@@ -919,6 +919,16 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Vid kontantmetoden bokförs fakturan när den betalas.',
|
||||
message_en: 'Under the cash method the invoice is booked when it is paid.',
|
||||
},
|
||||
// Bulk Bokför on a DRAFT when the company defers invoice booking (#967):
|
||||
// issuing the draft would consume an F-number and mark it sent without
|
||||
// booking anything, so the item is rejected before any side effect.
|
||||
INVOICE_BOOK_DEFERRED_DRAFT: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Företaget bokför fakturor i ett separat steg. Skicka eller markera utkastet som skickat först, bokför sedan.',
|
||||
message_en:
|
||||
'This company books invoices in a separate step. Send or mark the draft as sent first, then book it.',
|
||||
},
|
||||
INVOICE_BOOK_NO_FISCAL_PERIOD: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Inget öppet räkenskapsår täcker fakturadatumet. Skapa räkenskapsåret först.',
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
|
||||
import { FiscalPeriodNotFoundError } from '@/lib/bookkeeping/errors'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const mockCreateInvoiceJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoiceJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockCreateSchedules = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
|
||||
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
|
||||
}))
|
||||
|
||||
const mockCancelOrphan = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
|
||||
cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args),
|
||||
}))
|
||||
|
||||
const mockLinkToJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { bookInvoiceDeferred, INVOICE_BOOKABLE_STATUSES } from '../book-invoice-deferred'
|
||||
|
||||
const log: Logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: () => log,
|
||||
}
|
||||
|
||||
function makeSentInvoice(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...makeInvoice({ id: 'inv-1', status: 'sent' }),
|
||||
journal_entry_id: null,
|
||||
credited_invoice_id: null,
|
||||
document_type: 'invoice' as const,
|
||||
customer: { name: 'Kunden AB' },
|
||||
items: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function book(invoice = makeSentInvoice()) {
|
||||
return bookInvoiceDeferred({
|
||||
supabase: mockSupabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
invoice: invoice as never,
|
||||
entityType: 'aktiebolag',
|
||||
log,
|
||||
})
|
||||
}
|
||||
|
||||
describe('bookInvoiceDeferred', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
|
||||
mockLinkToJournalEntry.mockResolvedValue({ id: 'document-1' })
|
||||
})
|
||||
|
||||
it('exports the bookable statuses the routes guard on', () => {
|
||||
expect(INVOICE_BOOKABLE_STATUSES).toEqual(['sent', 'overdue'])
|
||||
})
|
||||
|
||||
it('books the revenue entry, claims the invoice and links the delivered PDF', async () => {
|
||||
const invoice = makeSentInvoice()
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null }) // CAS link
|
||||
enqueue({ data: 'document-1', error: null }) // delivery-document rpc
|
||||
|
||||
const result = await book(invoice)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
invoice: expect.objectContaining({ journal_entry_id: 'je-1' }),
|
||||
journalEntryId: 'je-1',
|
||||
warnings: [],
|
||||
})
|
||||
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'aktiebolag',
|
||||
'Kunden AB',
|
||||
)
|
||||
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
'document-1',
|
||||
'je-1',
|
||||
)
|
||||
expect(mockCreateSchedules).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns INVOICE_BOOK_NO_FISCAL_PERIOD when no period covers the date', async () => {
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue(null)
|
||||
|
||||
const result = await book()
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
errorCode: 'INVOICE_BOOK_NO_FISCAL_PERIOD',
|
||||
details: { invoiceDate: '2024-06-15' },
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels the orphaned entry and reports a conflict when the CAS claim loses', async () => {
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: null, error: { message: 'no rows' } }) // CAS link fails
|
||||
|
||||
const result = await book()
|
||||
|
||||
expect(result).toEqual({ ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_CONFLICT' })
|
||||
expect(mockCancelOrphan).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-1',
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('passes typed bookkeeping errors through as domain failures', async () => {
|
||||
const domainError = new FiscalPeriodNotFoundError()
|
||||
mockCreateInvoiceJournalEntry.mockRejectedValue(domainError)
|
||||
|
||||
const result = await book()
|
||||
|
||||
expect(result).toEqual({ ok: false, kind: 'domain', error: domainError })
|
||||
expect(mockCancelOrphan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps unknown engine failures to INVOICE_BOOK_FAILED', async () => {
|
||||
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('network down'))
|
||||
|
||||
const result = await book()
|
||||
|
||||
expect(result).toEqual({ ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_FAILED' })
|
||||
})
|
||||
|
||||
it('reports accrual-schedule failures as warnings, not errors', async () => {
|
||||
const invoice = makeSentInvoice()
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null })
|
||||
enqueue({ data: null, error: null }) // no delivered PDF found
|
||||
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 1 })
|
||||
|
||||
const result = await book(invoice)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.warnings).toEqual([
|
||||
expect.objectContaining({ code: 'ACCRUAL_SCHEDULE_FAILED' }),
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
makeCompanySettings,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
const mockEnsureInvoiceNumber = vi.fn()
|
||||
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
|
||||
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureInvoiceNumber(...args),
|
||||
}))
|
||||
|
||||
const mockRenderToBuffer = vi.fn()
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
||||
Document: vi.fn(),
|
||||
Page: vi.fn(),
|
||||
Text: vi.fn(),
|
||||
View: vi.fn(),
|
||||
StyleSheet: { create: (s: unknown) => s },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/pdf-template', () => ({
|
||||
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
||||
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
|
||||
SHOW_SWISH_ON_INVOICE: false,
|
||||
}))
|
||||
|
||||
const mockCreateInvoiceJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoiceJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockCreateSchedules = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
|
||||
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
|
||||
}))
|
||||
|
||||
const mockUploadDocument = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
||||
}))
|
||||
|
||||
const mockRecordManualInvoiceDelivery = vi.fn()
|
||||
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
|
||||
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
|
||||
}))
|
||||
|
||||
import { issueAndBookInvoice } from '../issue-and-book-invoice'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
const log: Logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: () => log,
|
||||
}
|
||||
|
||||
const customer = makeCustomer({ id: 'cust-1' })
|
||||
const settings = makeCompanySettings({
|
||||
accounting_method: 'accrual',
|
||||
entity_type: 'enskild_firma',
|
||||
bankgiro: '123-4567',
|
||||
}) as CompanySettings
|
||||
|
||||
function makeDraft(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...makeInvoice({ id: 'inv-1', invoice_number: 'F-2026010', status: 'draft' }),
|
||||
document_type: 'invoice' as const,
|
||||
credited_invoice_id: null,
|
||||
customer,
|
||||
items: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function issue(invoice = makeDraft(), theSettings = settings) {
|
||||
return issueAndBookInvoice({
|
||||
supabase: mockSupabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
invoice: invoice as never,
|
||||
settings: theSettings,
|
||||
log,
|
||||
})
|
||||
}
|
||||
|
||||
describe('issueAndBookInvoice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
||||
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
||||
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
|
||||
mockRecordManualInvoiceDelivery.mockResolvedValue({ id: 'delivery-1' })
|
||||
mockEnsureInvoiceNumber.mockResolvedValue('F-2026010')
|
||||
})
|
||||
|
||||
it('rejects when the payment account is missing, before number allocation', async () => {
|
||||
const bare = {
|
||||
...settings,
|
||||
invoice_payment_accounts: {},
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
swish: null,
|
||||
iban: null,
|
||||
} as CompanySettings
|
||||
|
||||
const result = await issue(makeDraft({ invoice_number: null }), bare)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errorCode: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
|
||||
details: { currency: 'SEK' },
|
||||
})
|
||||
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails with INVOICE_CREATE_NUMBER_ASSIGN_FAILED when numbering fails', async () => {
|
||||
mockEnsureInvoiceNumber.mockRejectedValue(new Error('sequence exhausted'))
|
||||
|
||||
const result = await issue()
|
||||
|
||||
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED' })
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns INVOICE_MARK_SENT_RACE when another request flipped the draft first', async () => {
|
||||
enqueue({ data: [], error: null }) // CAS update matches no row
|
||||
|
||||
const result = await issue()
|
||||
|
||||
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' })
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the draft and fails closed when the journal entry cannot be created', async () => {
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
|
||||
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
enqueue({ data: null, error: null }) // rollback update
|
||||
|
||||
const result = await issue()
|
||||
|
||||
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_MARK_SENT_BOOK_FAILED' })
|
||||
// Two invoice updates: the CAS flip and the rollback to draft.
|
||||
expect(findCalls('invoices', 'update')).toEqual([
|
||||
[{ status: 'sent' }],
|
||||
[{ status: 'draft' }],
|
||||
])
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books, links, archives the PDF, records delivery and emits invoice.sent', async () => {
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' })
|
||||
enqueue({ data: null, error: null }) // journal_entry_id link
|
||||
|
||||
const emitted: string[] = []
|
||||
eventBus.on('invoice.sent', async () => {
|
||||
emitted.push('invoice.sent')
|
||||
})
|
||||
|
||||
const result = await issue()
|
||||
|
||||
expect(result).toEqual({ ok: true, journalEntryId: 'je-7', partialFailures: [] })
|
||||
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'enskild_firma',
|
||||
customer.name,
|
||||
)
|
||||
expect(mockCreateSchedules).toHaveBeenCalled()
|
||||
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
|
||||
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
|
||||
supabase: mockSupabase,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
invoiceId: 'inv-1',
|
||||
})
|
||||
expect(emitted).toEqual(['invoice.sent'])
|
||||
})
|
||||
|
||||
it('marks sent WITHOUT booking under deferred booking (#967)', async () => {
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
|
||||
|
||||
const result = await issue(makeDraft(), {
|
||||
...settings,
|
||||
defer_invoice_booking: true,
|
||||
} as CompanySettings)
|
||||
|
||||
expect(result).toEqual({ ok: true, journalEntryId: null, partialFailures: [] })
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
// Still a real issuance: underlag archived and delivery recorded.
|
||||
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
|
||||
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces PDF-archive failures as partial failures, not errors', async () => {
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' })
|
||||
enqueue({ data: null, error: null }) // journal_entry_id link
|
||||
mockUploadDocument.mockRejectedValue(new Error('Storage offline'))
|
||||
|
||||
const result = await issue()
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.journalEntryId).toBe('je-8')
|
||||
expect(result.partialFailures).toEqual([
|
||||
expect.objectContaining({ step: 'pdf_archive' }),
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { Customer, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
// Statuses where the revenue entry can still be created afterwards. Paid
|
||||
// invoices are excluded: their payment flow has already booked the full
|
||||
// cash-style entry (mark-paid routes on the missing journal-entry link), so
|
||||
// booking the sale now would double-post revenue.
|
||||
export const INVOICE_BOOKABLE_STATUSES = ['sent', 'overdue']
|
||||
|
||||
export interface BookDeferredWarning {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BookInvoiceDeferredResult =
|
||||
| {
|
||||
ok: true
|
||||
/** The invoice row as returned by the CAS-guarded link update. */
|
||||
invoice: Invoice
|
||||
journalEntryId: string
|
||||
warnings: BookDeferredWarning[]
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
/** Typed bookkeeping error: map with errorResponse()/getErrorMessage(). */
|
||||
kind: 'domain'
|
||||
error: unknown
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
kind: 'code'
|
||||
errorCode: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* The deferred "Bokför" core (#967): create the revenue verifikat for an
|
||||
* already-sent invoice and claim it with a CAS-guarded link. Shared by
|
||||
* POST /api/invoices/[id]/book and POST /api/invoices/bulk-book so the two
|
||||
* can never drift apart. The caller is responsible for the eligibility
|
||||
* guards (real invoice, bookable status, unbooked, accrual method).
|
||||
*/
|
||||
export async function bookInvoiceDeferred(opts: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
userId: string
|
||||
invoice: Invoice & {
|
||||
customer?: (Customer & { name?: string }) | { name?: string } | null
|
||||
items?: InvoiceItem[] | null
|
||||
}
|
||||
entityType: EntityType
|
||||
log: Logger
|
||||
}): Promise<BookInvoiceDeferredResult> {
|
||||
const { supabase, companyId, userId, invoice, entityType, log } = opts
|
||||
const id = invoice.id
|
||||
|
||||
let journalEntry
|
||||
try {
|
||||
journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice as Invoice,
|
||||
entityType,
|
||||
invoice.customer?.name,
|
||||
)
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) {
|
||||
return { ok: false, kind: 'domain', error: err }
|
||||
}
|
||||
log.error('deferred invoice booking failed', err as Error, { invoiceId: id })
|
||||
return { ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_FAILED' }
|
||||
}
|
||||
|
||||
// Returns null ONLY when no fiscal period covers invoice_date (other
|
||||
// failures throw). Nothing was posted, so a plain error is safe.
|
||||
if (!journalEntry) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
errorCode: 'INVOICE_BOOK_NO_FISCAL_PERIOD',
|
||||
details: { invoiceDate: invoice.invoice_date },
|
||||
}
|
||||
}
|
||||
|
||||
// CAS-guarded link: only claim the invoice if it is still unbooked, still
|
||||
// in a bookable status, and still uncredited. A concurrent
|
||||
// book/mark-paid/credit that got there first would otherwise leave this
|
||||
// entry double-posting revenue, so cancel it.
|
||||
const { data: linked, error: linkError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.in('status', INVOICE_BOOKABLE_STATUSES)
|
||||
.is('credited_invoice_id', null)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (linkError || !linked) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
journalEntry.id,
|
||||
'Bokföring av kundfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.',
|
||||
)
|
||||
return { ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_CONFLICT' }
|
||||
}
|
||||
|
||||
const warnings: BookDeferredWarning[] = []
|
||||
|
||||
// The send flow archived the exact delivered PDF before this deferred
|
||||
// journal entry existed. Attach the newest successful delivery snapshot now.
|
||||
const { data: deliveryDocumentId, error: deliveryDocumentError } = await supabase.rpc(
|
||||
'latest_sent_invoice_delivery_document',
|
||||
{ p_company_id: companyId, p_invoice_id: id },
|
||||
)
|
||||
|
||||
if (deliveryDocumentError) {
|
||||
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
|
||||
invoiceId: id,
|
||||
})
|
||||
warnings.push({
|
||||
code: 'PDF_LINK_FAILED',
|
||||
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
|
||||
})
|
||||
} else if (typeof deliveryDocumentId === 'string') {
|
||||
try {
|
||||
await linkToJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
deliveryDocumentId,
|
||||
journalEntry.id,
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('failed to link delivered invoice PDF on deferred booking', err as Error, {
|
||||
invoiceId: id,
|
||||
documentId: deliveryDocumentId,
|
||||
})
|
||||
warnings.push({
|
||||
code: 'PDF_LINK_FAILED',
|
||||
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Periodiseringar ride on the revenue entry, so they can only be created
|
||||
// now. Non-blocking: the entry is committed (immutable); a schedule
|
||||
// failure is surfaced as a warning and retried from the periodiseringar
|
||||
// page.
|
||||
try {
|
||||
const accrual = await createSchedulesForCustomerInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice as Invoice,
|
||||
(invoice.items as InvoiceItem[] | null) ?? [],
|
||||
journalEntry.id,
|
||||
entityType,
|
||||
)
|
||||
if (accrual.failed > 0) {
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_SCHEDULE_FAILED',
|
||||
message:
|
||||
'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' +
|
||||
'skapas. Kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('accrual schedule creation failed on deferred booking', err as Error, { invoiceId: id })
|
||||
warnings.push({
|
||||
code: 'ACCRUAL_SCHEDULE_FAILED',
|
||||
message:
|
||||
'Fakturan bokfördes, men periodiseringarna kunde inte skapas. ' +
|
||||
'Kontrollera under Bokföring → Periodiseringar.',
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, invoice: linked as Invoice, journalEntryId: journalEntry.id, warnings }
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import type { CustomIssuanceLine } from '@/lib/invoices/issuance-custom-lines'
|
||||
import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
|
||||
import {
|
||||
hasRequiredInvoicePaymentAccount,
|
||||
invoiceRequiresPaymentAccount,
|
||||
} from '@/lib/invoices/payment-accounts'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type {
|
||||
CompanySettings,
|
||||
Customer,
|
||||
EntityType,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
} from '@/types'
|
||||
|
||||
export interface IssuePartialFailure {
|
||||
step: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** Joined invoice row as the issuance flows fetch it. */
|
||||
export type IssuableInvoice = Invoice & {
|
||||
customer?: (Customer & { name?: string }) | null
|
||||
items?: InvoiceItem[] | null
|
||||
}
|
||||
|
||||
export type IssueAndBookResult =
|
||||
| {
|
||||
ok: true
|
||||
journalEntryId: string | null
|
||||
partialFailures: IssuePartialFailure[]
|
||||
}
|
||||
| { ok: false; errorCode: string; details?: Record<string, unknown> }
|
||||
|
||||
export interface IssueAndBookOptions {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
userId: string
|
||||
/**
|
||||
* The draft invoice (with customer + items joined). Never a credit note:
|
||||
* credit-note issuance lives in issue-credit-note.ts and stays on the
|
||||
* mark-sent route.
|
||||
*/
|
||||
invoice: IssuableInvoice
|
||||
settings: CompanySettings
|
||||
log: Logger
|
||||
/**
|
||||
* User-edited journal lines from the mark-sent body. Bulk paths pass none:
|
||||
* generated lines book as-is.
|
||||
*/
|
||||
customLines?: CustomIssuanceLine[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive the issued invoice's PDF as underlag so it remains retrievable even
|
||||
* if the invoice row is later cancelled. Shared between the mark-sent route
|
||||
* (real invoices and credit notes) and the bulk Bokför flow. Returns a partial
|
||||
* failure instead of throwing: the issuance itself already committed.
|
||||
*/
|
||||
export async function archiveIssuedInvoicePdf(args: {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
userId: string
|
||||
invoice: IssuableInvoice
|
||||
settings: CompanySettings
|
||||
journalEntryId: string | null
|
||||
originalInvoiceNumber?: string
|
||||
log: Logger
|
||||
}): Promise<IssuePartialFailure | null> {
|
||||
const { supabase, companyId, userId, invoice, settings, journalEntryId, log } = args
|
||||
try {
|
||||
const items = ((invoice.items as InvoiceItem[] | null) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
// The DB status flip already happened, 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".
|
||||
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
||||
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||||
settings,
|
||||
renderableInvoice.currency,
|
||||
{ paymentAccountRequired },
|
||||
)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: renderableInvoice,
|
||||
customer: invoice.customer as Customer,
|
||||
items,
|
||||
company: renderCompany,
|
||||
originalInvoiceNumber: args.originalInvoiceNumber,
|
||||
branding,
|
||||
swishQrDataUrl,
|
||||
}),
|
||||
)
|
||||
|
||||
const filename = invoicePdfFilename({
|
||||
companyName: settings.company_name,
|
||||
customerName: (invoice.customer as Customer).name,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
invoiceId: invoice.id,
|
||||
invoiceDate: invoice.invoice_date,
|
||||
documentType: invoice.document_type,
|
||||
isCreditNote: !!invoice.credited_invoice_id,
|
||||
})
|
||||
|
||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||
await uploadDocument(
|
||||
supabase,
|
||||
userId,
|
||||
companyId,
|
||||
{
|
||||
name: filename,
|
||||
buffer: pdfArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
},
|
||||
{
|
||||
upload_source: 'system',
|
||||
journal_entry_id: journalEntryId ?? undefined,
|
||||
},
|
||||
)
|
||||
return null
|
||||
} catch (err) {
|
||||
log.error('failed to archive invoice PDF on mark-sent', err as Error)
|
||||
return {
|
||||
step: 'pdf_archive',
|
||||
reason: 'Fakturans PDF kunde inte arkiveras.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a draft invoice without sending an email: assign the F-number, flip
|
||||
* the status to 'sent', and (under faktureringsmetoden with inline booking)
|
||||
* create and link the revenue verifikat. Exactly the mark-sent semantics for
|
||||
* a non-credit-note invoice; used by both POST /api/invoices/[id]/mark-sent
|
||||
* and POST /api/invoices/bulk-book so the two can never drift apart.
|
||||
*
|
||||
* Under kontantmetoden or deferred booking (#967) the invoice is marked sent
|
||||
* without a journal entry, matching mark-sent.
|
||||
*/
|
||||
export async function issueAndBookInvoice(
|
||||
opts: IssueAndBookOptions,
|
||||
): Promise<IssueAndBookResult> {
|
||||
const { supabase, companyId, userId, invoice, settings, log } = opts
|
||||
const customLines = opts.customLines ?? null
|
||||
const id = invoice.id
|
||||
|
||||
if (!hasRequiredInvoicePaymentAccount(settings, invoice as Invoice)) {
|
||||
return {
|
||||
ok: false,
|
||||
errorCode: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
|
||||
details: { currency: (invoice as Invoice).currency },
|
||||
}
|
||||
}
|
||||
|
||||
// Assign the number only after all payment-instruction guards pass.
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||||
} catch (err) {
|
||||
log.error('failed to assign invoice number on mark-sent', err as Error)
|
||||
return { ok: false, errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED' }
|
||||
}
|
||||
|
||||
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Compare-and-set prevents two concurrent requests from posting two journal
|
||||
// entries for the same draft.
|
||||
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 { ok: false, errorCode: 'INVOICE_MARK_SENT_STATUS_FAILED' }
|
||||
}
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
return { ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' }
|
||||
}
|
||||
|
||||
// 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
|
||||
const partialFailures: IssuePartialFailure[] = []
|
||||
|
||||
// Custom lines only apply where issuance books inline; elsewhere they are
|
||||
// deliberately ignored (documented in MarkInvoiceSentSchema). Log it so the
|
||||
// mismatch is visible in audit review instead of vanishing silently.
|
||||
if (customLines && (!isRealInvoice || !booksInvoicesOnIssue(settings))) {
|
||||
log.warn('mark-sent: custom lines ignored (not on accrual book-at-issue path)', {
|
||||
invoiceId: id,
|
||||
lineCount: customLines.length,
|
||||
})
|
||||
}
|
||||
|
||||
if (isRealInvoice && booksInvoicesOnIssue(settings)) {
|
||||
// #967: deferred companies fall past this branch (mark sent WITHOUT
|
||||
// booking); ekonomi books later via POST /api/invoices/[id]/book, like
|
||||
// under kontantmetoden.
|
||||
try {
|
||||
if (customLines) {
|
||||
// Audit trail: distinguish user-edited bookings from generated ones.
|
||||
log.info('mark-sent: booking user-edited custom lines', {
|
||||
invoiceId: id,
|
||||
userId,
|
||||
lineCount: customLines.length,
|
||||
})
|
||||
}
|
||||
const journalEntry = customLines
|
||||
? await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice as Invoice,
|
||||
entityType,
|
||||
invoice.customer?.name,
|
||||
{ customLines },
|
||||
)
|
||||
: await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice as Invoice,
|
||||
entityType,
|
||||
invoice.customer?.name,
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
|
||||
// Periodiserade lines: create schedules + catch-up dissolutions now
|
||||
// that the revenue entry exists. Failures are logged, never fatal:
|
||||
// the verifikat is committed. Skipped when the user edited the lines:
|
||||
// the generated 29xx deferral may no longer exist in what was booked,
|
||||
// and a schedule would then dissolve an interim balance that was
|
||||
// never credited. User-edited lines book exactly as reviewed.
|
||||
if (!customLines) {
|
||||
const accrual = await createSchedulesForCustomerInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice as Invoice,
|
||||
(invoice.items as InvoiceItem[] | null) ?? [],
|
||||
journalEntry.id,
|
||||
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
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
if (linkError) {
|
||||
// Don't fail the issuance: the verifikat committed; only the link
|
||||
// failed. But log it through the structured logger so it reaches log
|
||||
// aggregation/alerting: this write silently no-ops when the
|
||||
// journal_entry_id column is missing (it was absent in prod until the
|
||||
// 20260613100000 migration), which leaves mark-paid unable to detect
|
||||
// an already-booked sale.
|
||||
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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-closed only when inline booking was supposed to happen: deferred
|
||||
// (#967) and cash-method invoices are legitimately unbooked at this point.
|
||||
if (isRealInvoice && booksInvoicesOnIssue(settings) && !journalEntryId) {
|
||||
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 { ok: false, errorCode: 'INVOICE_MARK_SENT_BOOK_FAILED' }
|
||||
}
|
||||
|
||||
if (partialFailures.some((failure) => failure.step === 'journal_link')) {
|
||||
return {
|
||||
ok: false,
|
||||
errorCode: 'INVOICE_MARK_SENT_REPAIR_REQUIRED',
|
||||
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) {
|
||||
const pdfFailure = await archiveIssuedInvoicePdf({
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoice,
|
||||
settings,
|
||||
journalEntryId,
|
||||
log,
|
||||
})
|
||||
if (pdfFailure) partialFailures.push(pdfFailure)
|
||||
}
|
||||
|
||||
try {
|
||||
await recordManualInvoiceDelivery({
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
invoiceId: id,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to record manual invoice delivery', err as Error)
|
||||
partialFailures.push({
|
||||
step: 'delivery_history',
|
||||
reason: 'Utskicket kunde inte sparas i fakturans historik.',
|
||||
})
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId },
|
||||
})
|
||||
|
||||
return { ok: true, journalEntryId, partialFailures }
|
||||
}
|
||||
@@ -638,6 +638,8 @@
|
||||
"type_categorize_transaction": "Categorization",
|
||||
"type_create_customer": "New customer",
|
||||
"type_create_invoice": "New invoice",
|
||||
"bulk_invoice_drafts_hint": "The invoice drafts are created but not yet booked.",
|
||||
"bulk_invoice_drafts_cta": "Book the drafts",
|
||||
"type_create_transaction": "New transaction",
|
||||
"type_create_voucher": "New journal entry",
|
||||
"type_correct_entry": "Correction",
|
||||
@@ -5946,6 +5948,22 @@
|
||||
"create_recurring_desc": "Created automatically, e.g. every month",
|
||||
"create_self": "Self-billing invoice",
|
||||
"create_self_desc": "Register a self-billing invoice from your customer",
|
||||
"bulkbar_selected": "{count, plural, one {invoice selected} other {invoices selected}}",
|
||||
"bulk_select_all": "Select all ({count})",
|
||||
"bulk_clear": "Clear",
|
||||
"bulk_select_row": "Select invoice",
|
||||
"bulk_book_action": "Book ({count})",
|
||||
"bulk_book_and_send_action": "Book and mark as sent ({count})",
|
||||
"bulk_book_confirm_title": "{count, plural, one {# invoice will be booked} other {# invoices will be booked}}",
|
||||
"bulk_book_confirm_warning": "Vouchers are created and cannot be edited afterwards, only corrected or reversed.",
|
||||
"bulk_book_confirm_label": "Book",
|
||||
"bulk_book_breakdown_drafts": "{count, plural, one {# draft gets an invoice number and is booked as sent} other {# drafts get invoice numbers and are booked as sent}}",
|
||||
"bulk_book_breakdown_sent": "{count, plural, one {# sent invoice will be booked} other {# sent invoices will be booked}}",
|
||||
"bulk_book_success_title": "Booked",
|
||||
"bulk_book_success_description": "{count, plural, one {# invoice was booked} other {# invoices were booked}}",
|
||||
"bulk_book_partial_title": "Partially done",
|
||||
"bulk_book_partial_description": "{booked} booked, {failed} failed.",
|
||||
"bulk_book_failed_title": "Could not book",
|
||||
"status_overdue_days": "Overdue {days} d",
|
||||
"status_paid_date": "Paid {date}",
|
||||
"status_picker_aria": "Filter by status"
|
||||
|
||||
@@ -638,6 +638,8 @@
|
||||
"type_categorize_transaction": "Kategorisering",
|
||||
"type_create_customer": "Ny kund",
|
||||
"type_create_invoice": "Ny faktura",
|
||||
"bulk_invoice_drafts_hint": "Fakturautkasten är skapade men inte bokförda.",
|
||||
"bulk_invoice_drafts_cta": "Bokför utkasten",
|
||||
"type_create_transaction": "Ny transaktion",
|
||||
"type_create_voucher": "Ny verifikation",
|
||||
"type_correct_entry": "Rättelse",
|
||||
@@ -5946,6 +5948,22 @@
|
||||
"create_recurring_desc": "Skapas automatiskt, till exempel varje månad",
|
||||
"create_self": "Självfaktura",
|
||||
"create_self_desc": "Registrera en självfaktura från din kund",
|
||||
"bulkbar_selected": "{count, plural, one {faktura vald} other {fakturor valda}}",
|
||||
"bulk_select_all": "Markera alla ({count})",
|
||||
"bulk_clear": "Rensa",
|
||||
"bulk_select_row": "Välj faktura",
|
||||
"bulk_book_action": "Bokför ({count})",
|
||||
"bulk_book_and_send_action": "Bokför och markera som skickade ({count})",
|
||||
"bulk_book_confirm_title": "{count, plural, one {# faktura bokförs} other {# fakturor bokförs}}",
|
||||
"bulk_book_confirm_warning": "Verifikat skapas och kan inte ändras efteråt, bara rättas eller stornas.",
|
||||
"bulk_book_confirm_label": "Bokför",
|
||||
"bulk_book_breakdown_drafts": "{count, plural, one {# utkast får fakturanummer och bokförs som skickat} other {# utkast får fakturanummer och bokförs som skickade}}",
|
||||
"bulk_book_breakdown_sent": "{count, plural, one {# skickad faktura bokförs} other {# skickade fakturor bokförs}}",
|
||||
"bulk_book_success_title": "Bokfört",
|
||||
"bulk_book_success_description": "{count, plural, one {# faktura bokfördes} other {# fakturor bokfördes}}",
|
||||
"bulk_book_partial_title": "Delvis klart",
|
||||
"bulk_book_partial_description": "{booked} bokförda, {failed} misslyckades.",
|
||||
"bulk_book_failed_title": "Kunde inte bokföra",
|
||||
"status_overdue_days": "Förfallen {days} dgr",
|
||||
"status_paid_date": "Betald {date}",
|
||||
"status_picker_aria": "Filtrera på status"
|
||||
|
||||
Reference in New Issue
Block a user