From bffa57a565f0235b9414f94a7e90cda9c7b5d3e7 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 12 Aug 2026 21:02:27 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- app/(dashboard)/invoices/page.tsx | 346 +++++++++++++--- app/(dashboard)/pending/page.tsx | 58 ++- app/api/invoices/[id]/book/route.ts | 159 ++------ app/api/invoices/[id]/mark-sent/route.ts | 355 +++++------------ .../bulk-book/__tests__/route.test.ts | 332 ++++++++++++++++ app/api/invoices/bulk-book/route.ts | 185 +++++++++ lib/api/schemas.ts | 7 + lib/errors/structured-errors.ts | 10 + .../__tests__/book-invoice-deferred.test.ts | 167 ++++++++ .../__tests__/issue-and-book-invoice.test.ts | 227 +++++++++++ lib/invoices/book-invoice-deferred.ts | 188 +++++++++ lib/invoices/issue-and-book-invoice.ts | 368 ++++++++++++++++++ messages/en.json | 18 + messages/sv.json | 18 + 14 files changed, 1998 insertions(+), 440 deletions(-) create mode 100644 app/api/invoices/bulk-book/__tests__/route.test.ts create mode 100644 app/api/invoices/bulk-book/route.ts create mode 100644 lib/invoices/__tests__/book-invoice-deferred.test.ts create mode 100644 lib/invoices/__tests__/issue-and-book-invoice.test.ts create mode 100644 lib/invoices/book-invoice-deferred.ts create mode 100644 lib/invoices/issue-and-book-invoice.ts diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index de3f1f22..f5701b1e 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -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 = { all: 'tab_all', unpaid: 'tab_unpaid', @@ -160,6 +194,12 @@ export default function InvoicesPage() { const [invoices, setInvoices] = useState([]) const [oreRounding, setOreRounding] = useState(true) const [rotRutEnabled, setRotRutEnabled] = useState(false) + // Booking mode drives which rows are bulk-bookable (kontantmetoden: none). + const [accountingMethod, setAccountingMethod] = useState('accrual') + const [deferInvoiceBooking, setDeferInvoiceBooking] = useState(false) + const [selectedIds, setSelectedIds] = useState>(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(null) @@ -168,7 +208,7 @@ export default function InvoicesPage() { const param = searchParams.get('status') ?? searchParams.get('tab') const alias: Record = { 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 + 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() {
{ - 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, }))} />
@@ -472,6 +633,39 @@ export default function InvoicesPage() {
+ {/* Bulkbar: appears once anything is selected (supplier-invoices shape). */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size}{' '} + {t('bulkbar_selected', { count: selectedIds.size })} + + + {/* Hidden when the current view has no selectable rows: a + "Markera alla (0)" link would only wipe the selection. */} + {selectableInvoices.length > 0 && !allSelectableSelected && ( + + )} + +
+ )} + {isLoading ? (
{[1, 2, 3].map((i) => ( @@ -503,6 +697,9 @@ export default function InvoicesPage() { + {showSelection && ( + + )} router.push(`/invoices/${invoice.id}`)} > + {/* Hover-revealed selection checkbox (supplier-invoices shape). */} + {showSelection && ( + + )}
e.stopPropagation()} + > + {isBulkSelectable(invoice) && ( + 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', + )} + /> + )} + )} + + {/* Confirm-before-posting (convention 10): bulk Bokför writes immutable + verifikat, so describe the outcome first. */} + { + 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')} + > +
+ {selectedDraftCount > 0 && ( +

{t('bulk_book_breakdown_drafts', { count: selectedDraftCount })}

+ )} + {selectedSentCount > 0 && ( +

{t('bulk_book_breakdown_sent', { count: selectedSentCount })}

+ )} +
+
) } diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 2696bf03..c04b315e 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -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([]) 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: ( + router.push('/invoices?status=draft')} + > + {t('bulk_invoice_drafts_cta')} + + ), + } + : {} + 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) diff --git a/app/api/invoices/[id]/book/route.ts b/app/api/invoices/[id]/book/route.ts index e8bece2d..6b9f0460 100644 --- a/app/api/invoices/[id]/book/route.ts +++ b/app/api/invoices/[id]/book/route.ts @@ -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).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 }, diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 8b7b81af..18b674ab 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -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, diff --git a/app/api/invoices/bulk-book/__tests__/route.test.ts b/app/api/invoices/bulk-book/__tests__/route.test.ts new file mode 100644 index 00000000..ce1fb67d --- /dev/null +++ b/app/api/invoices/bulk-book/__tests__/route.test.ts @@ -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 }) + }) +}) diff --git a/app/api/invoices/bulk-book/route.ts b/app/api/invoices/bulk-book/route.ts new file mode 100644 index 00000000..9cee66c5 --- /dev/null +++ b/app/api/invoices/bulk-book/route.ts @@ -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).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 }, +) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 18eed651..3fc9b805 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 4b3cfb52..88caa713 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -919,6 +919,16 @@ const INVOICE: Record = { 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.', diff --git a/lib/invoices/__tests__/book-invoice-deferred.test.ts b/lib/invoices/__tests__/book-invoice-deferred.test.ts new file mode 100644 index 00000000..a8fe1ce5 --- /dev/null +++ b/lib/invoices/__tests__/book-invoice-deferred.test.ts @@ -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 = {}) { + 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' }), + ]) + } + }) +}) diff --git a/lib/invoices/__tests__/issue-and-book-invoice.test.ts b/lib/invoices/__tests__/issue-and-book-invoice.test.ts new file mode 100644 index 00000000..69aee7a7 --- /dev/null +++ b/lib/invoices/__tests__/issue-and-book-invoice.test.ts @@ -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 = {}) { + 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' }), + ]) + } + }) +}) diff --git a/lib/invoices/book-invoice-deferred.ts b/lib/invoices/book-invoice-deferred.ts new file mode 100644 index 00000000..b7c301a2 --- /dev/null +++ b/lib/invoices/book-invoice-deferred.ts @@ -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 + } + +/** + * 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 { + 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 } +} diff --git a/lib/invoices/issue-and-book-invoice.ts b/lib/invoices/issue-and-book-invoice.ts new file mode 100644 index 00000000..bfce057b --- /dev/null +++ b/lib/invoices/issue-and-book-invoice.ts @@ -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 } + +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 { + 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 { + 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 } +} diff --git a/messages/en.json b/messages/en.json index 78c2604f..e99613d0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" diff --git a/messages/sv.json b/messages/sv.json index 39ca6dfc..eb27e63c 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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"