+ >
+ )}
{invoice.journal_entry_id && (
<>
diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx
index 88f73160..cf38d5bc 100644
--- a/app/(dashboard)/supplier-invoices/[id]/page.tsx
+++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx
@@ -23,6 +23,7 @@ import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton'
+import { useCompanySettings } from '@/components/settings/useSettings'
import { formatAmount, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
@@ -77,6 +78,7 @@ const statusVariants: Record 0) {
+ // Booked, but a follow-up is needed (e.g. periodiseringar failed).
+ toast({ title: t('booked_title'), description: t('booked_with_warnings_description'), variant: 'destructive' })
+ fetchInvoice()
+ } else {
+ toast({ title: t('booked_title'), description: t('booked_description') })
+ fetchInvoice()
+ }
+ setIsProcessing(false)
+ }
+
async function handleMarkPaid(force: boolean = false) {
setIsProcessing(true)
// When the user has edited the booking rows in this session, forward
@@ -825,6 +845,23 @@ export default function SupplierInvoiceDetailPage() {
{invoice.registration_journal_entry_id.substring(0, 8)}...
+ ) : companySettings?.accounting_method === 'accrual' &&
+ !invoice.is_credit_note &&
+ ['registered', 'approved', 'overdue'].includes(invoice.status) ? (
+ // #967: registered-without-booking (deferred booking). Ekonomi
+ // books the registration verifikat from here.
+
+ {t('not_booked_yet')}
+
+
) : (
{t('no_registration_voucher')}
)}
diff --git a/app/api/invoices/[id]/book/__tests__/route.test.ts b/app/api/invoices/[id]/book/__tests__/route.test.ts
new file mode 100644
index 00000000..936bacba
--- /dev/null
+++ b/app/api/invoices/[id]/book/__tests__/route.test.ts
@@ -0,0 +1,191 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextResponse } from 'next/server'
+import {
+ createMockRequest,
+ parseJsonResponse,
+ createMockRouteParams,
+ createQueuedMockSupabase,
+ makeInvoice,
+} 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/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 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),
+}))
+
+import { POST } from '../route'
+
+const mockUser = { id: 'user-1', email: 'test@test.se' }
+
+function bookRequest() {
+ return POST(
+ createMockRequest('/api/invoices/inv-1/book', { method: 'POST' }),
+ createMockRouteParams({ id: 'inv-1' }),
+ )
+}
+
+function makeUnbookedInvoice(overrides: Record = {}) {
+ return {
+ ...makeInvoice({ id: 'inv-1', status: 'sent' }),
+ journal_entry_id: null,
+ credited_invoice_id: null,
+ document_type: 'invoice',
+ customer: { name: 'Kunden AB' },
+ items: [],
+ ...overrides,
+ }
+}
+
+describe('POST /api/invoices/[id]/book', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
+ mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
+ })
+
+ 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 bookRequest())
+ expect(status).toBe(401)
+ })
+
+ it('returns 404 when the invoice does not exist', async () => {
+ enqueue({ data: null, error: { message: 'Not found' } })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(404)
+ expect(body.error.code).toBe('INVOICE_NOT_FOUND')
+ })
+
+ it('rejects an already booked invoice', async () => {
+ enqueue({ data: makeUnbookedInvoice({ journal_entry_id: 'je-existing' }), error: null })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('INVOICE_BOOK_ALREADY_BOOKED')
+ expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
+ })
+
+ it('rejects credit notes', async () => {
+ enqueue({ data: makeUnbookedInvoice({ credited_invoice_id: 'inv-0' }), error: null })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('INVOICE_BOOK_NOT_BOOKABLE')
+ })
+
+ it('rejects paid invoices (payment flow already booked them)', async () => {
+ enqueue({ data: makeUnbookedInvoice({ status: 'paid' }), error: null })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('INVOICE_BOOK_INVALID_STATUS')
+ })
+
+ it('fails closed when company settings cannot be read', async () => {
+ enqueue({ data: makeUnbookedInvoice(), error: null })
+ enqueue({ data: null, error: { message: 'boom' } })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(500)
+ expect(body.error.code).toBe('INVOICE_BOOK_FAILED')
+ expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
+ })
+
+ it('rejects booking under the cash method', async () => {
+ enqueue({ data: makeUnbookedInvoice(), error: null })
+ enqueue({ data: { accounting_method: 'cash', entity_type: 'aktiebolag' }, error: null })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('INVOICE_BOOK_CASH_METHOD')
+ expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when no fiscal period covers the invoice date', async () => {
+ enqueue({ data: makeUnbookedInvoice(), error: null })
+ enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null })
+ mockCreateInvoiceJournalEntry.mockResolvedValue(null)
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('INVOICE_BOOK_NO_FISCAL_PERIOD')
+ })
+
+ it('cancels the entry and returns 409 when another request booked first', async () => {
+ enqueue({ data: makeUnbookedInvoice(), error: null })
+ enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null })
+ mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
+ // CAS-guarded link matches no row: someone else already claimed it.
+ enqueue({ data: null, error: { message: 'no rows' } })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('INVOICE_BOOK_CONFLICT')
+ expect(mockCancelOrphan).toHaveBeenCalledWith(
+ mockSupabase,
+ 'company-1',
+ 'user-1',
+ 'je-1',
+ expect.any(String),
+ )
+ })
+
+ it('books the revenue entry and links it', async () => {
+ const invoice = makeUnbookedInvoice()
+ enqueue({ data: invoice, error: null })
+ enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null })
+ mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
+ enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null })
+
+ const { status, body } = await parseJsonResponse<{
+ data: { journal_entry_id: string }
+ journal_entry_id: string
+ }>(await bookRequest())
+
+ expect(status).toBe(200)
+ expect(body.journal_entry_id).toBe('je-1')
+ expect(body.data.journal_entry_id).toBe('je-1')
+ expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
+ mockSupabase,
+ 'company-1',
+ 'user-1',
+ expect.objectContaining({ id: 'inv-1' }),
+ 'aktiebolag',
+ 'Kunden AB',
+ )
+ expect(mockCreateSchedules).toHaveBeenCalled()
+ })
+})
diff --git a/app/api/invoices/[id]/book/route.ts b/app/api/invoices/[id]/book/route.ts
new file mode 100644
index 00000000..c35ec913
--- /dev/null
+++ b/app/api/invoices/[id]/book/route.ts
@@ -0,0 +1,164 @@
+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 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']
+
+/**
+ * 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.
+ */
+export const POST = withRouteContext(
+ 'invoice.book',
+ async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { user, supabase, companyId, log, requestId } = ctx
+
+ const { data: invoice } = await supabase
+ .from('invoices')
+ .select('*, customer:customers(name), items:invoice_items(*)')
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .single()
+
+ if (!invoice) {
+ return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
+ }
+ if (invoice.journal_entry_id) {
+ return errorResponseFromCode('INVOICE_BOOK_ALREADY_BOOKED', log, { requestId })
+ }
+ const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
+ if (!isRealInvoice || invoice.credited_invoice_id) {
+ return errorResponseFromCode('INVOICE_BOOK_NOT_BOOKABLE', log, { requestId })
+ }
+ if (!BOOKABLE_STATUSES.includes(invoice.status)) {
+ return errorResponseFromCode('INVOICE_BOOK_INVALID_STATUS', log, {
+ requestId,
+ details: { currentStatus: invoice.status },
+ })
+ }
+
+ // The revenue-at-issue entry is a faktureringsmetoden concept; under
+ // kontantmetoden the sale is booked in full when it is paid.
+ const { data: settings, error: settingsError } = await supabase
+ .from('company_settings')
+ .select('accounting_method, entity_type')
+ .eq('company_id', companyId)
+ .single()
+ // Fail closed: booking with guessed settings could apply the wrong
+ // method's or entity type's rules, so a failed/missing settings read aborts.
+ if (settingsError || !settings) {
+ log.error('failed to load company settings for deferred booking', settingsError ?? undefined, { invoiceId: id })
+ return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId })
+ }
+ 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'
+
+ 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 })
+ }
+
+ // 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, {
+ 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 })
+ }
+
+ // 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.
+ const warnings: Array<{ code: string; message: string }> = []
+ 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.',
+ })
+ }
+
+ return NextResponse.json({
+ data: linked,
+ journal_entry_id: journalEntry.id,
+ ...(warnings.length > 0 ? { warnings } : {}),
+ })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts
index de4ade89..00707f01 100644
--- a/app/api/invoices/[id]/mark-sent/route.ts
+++ b/app/api/invoices/[id]/mark-sent/route.ts
@@ -1,6 +1,7 @@
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'
@@ -177,7 +178,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
},
)
}
- } else if (isRealInvoice && accountingMethod === 'accrual') {
+ } 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 {
const journalEntry = await createInvoiceJournalEntry(
supabase,
@@ -246,7 +250,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
- if (isRealInvoice && accountingMethod === 'accrual' && !isCreditNote && !journalEntryId) {
+ // 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
.from('invoices')
diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts
index 2a6b4c33..61cc5437 100644
--- a/app/api/invoices/[id]/send/route.ts
+++ b/app/api/invoices/[id]/send/route.ts
@@ -11,6 +11,7 @@ import {
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
+import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
@@ -355,10 +356,12 @@ export const POST = withRouteContext(
}
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
- const accountingMethod = ((company as Record).accounting_method || 'accrual') as AccountingMethod
let createdJournalEntryId: string | undefined = creditJournalEntryId ?? undefined
- if (statusFlipped && !isCreditNote && isRealInvoice && accountingMethod === 'accrual') {
+ // #967: deferred companies send WITHOUT booking; ekonomi books later via
+ // POST /api/invoices/[id]/book. The invoice then legitimately sits at
+ // journal_entry_id = NULL until then, like under kontantmetoden.
+ if (statusFlipped && !isCreditNote && isRealInvoice && booksInvoicesOnIssue(company as CompanySettings)) {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
diff --git a/app/api/supplier-invoices/[id]/book/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/book/__tests__/route.test.ts
new file mode 100644
index 00000000..113831c3
--- /dev/null
+++ b/app/api/supplier-invoices/[id]/book/__tests__/route.test.ts
@@ -0,0 +1,202 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextResponse } from 'next/server'
+import {
+ createMockRequest,
+ parseJsonResponse,
+ createMockRouteParams,
+ createQueuedMockSupabase,
+ makeSupplierInvoice,
+} 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/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 mockCreateRegistrationEntry = vi.fn()
+vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
+ createSupplierInvoiceRegistrationEntry: (...args: unknown[]) => mockCreateRegistrationEntry(...args),
+}))
+
+const mockCreateSchedules = vi.fn()
+vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
+ createSchedulesForSupplierInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
+}))
+
+const mockCancelOrphan = vi.fn()
+vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
+ cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args),
+}))
+
+import { POST } from '../route'
+
+const mockUser = { id: 'user-1', email: 'test@test.se' }
+
+function bookRequest() {
+ return POST(
+ createMockRequest('/api/supplier-invoices/si-1/book', { method: 'POST' }),
+ createMockRouteParams({ id: 'si-1' }),
+ )
+}
+
+function makeUnbookedInvoice(overrides: Record = {}) {
+ return {
+ ...makeSupplierInvoice({ id: 'si-1' }),
+ registration_journal_entry_id: null,
+ is_credit_note: false,
+ items: [],
+ supplier: { id: 'supplier-1', name: 'Leverantören AB', supplier_type: 'company' },
+ ...overrides,
+ }
+}
+
+describe('POST /api/supplier-invoices/[id]/book', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
+ mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
+ })
+
+ 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 bookRequest())
+ expect(status).toBe(401)
+ })
+
+ it('returns 404 when the invoice does not exist', async () => {
+ enqueue({ data: null, error: { message: 'Not found' } })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(404)
+ expect(body.error.code).toBe('SI_NOT_FOUND')
+ })
+
+ it('rejects an already booked invoice', async () => {
+ enqueue({ data: makeUnbookedInvoice({ registration_journal_entry_id: 'je-existing' }), error: null })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('SI_BOOK_ALREADY_BOOKED')
+ expect(mockCreateRegistrationEntry).not.toHaveBeenCalled()
+ })
+
+ it('rejects paid invoices (payment flow already booked them)', async () => {
+ enqueue({ data: makeUnbookedInvoice({ status: 'paid' }), error: null })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('SI_BOOK_INVALID_STATUS')
+ })
+
+ it('fails closed when company settings cannot be read', async () => {
+ enqueue({ data: makeUnbookedInvoice(), error: null })
+ enqueue({ data: null, error: { message: 'boom' } })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(500)
+ expect(body.error.code).toBe('SI_BOOK_FAILED')
+ expect(mockCreateRegistrationEntry).not.toHaveBeenCalled()
+ })
+
+ it('rejects booking under the cash method', async () => {
+ enqueue({ data: makeUnbookedInvoice(), error: null })
+ enqueue({ data: { accounting_method: 'cash' }, error: null })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('SI_BOOK_CASH_METHOD')
+ expect(mockCreateRegistrationEntry).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when no fiscal period covers the invoice date', async () => {
+ enqueue({ data: makeUnbookedInvoice(), error: null })
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+ mockCreateRegistrationEntry.mockResolvedValue(null)
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('SI_BOOK_NO_FISCAL_PERIOD')
+ })
+
+ it('cancels the entry and returns 409 when another request booked first', async () => {
+ enqueue({ data: makeUnbookedInvoice(), error: null })
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+ mockCreateRegistrationEntry.mockResolvedValue({ id: 'je-1' })
+ // CAS-guarded link matches no row: someone else already claimed it.
+ enqueue({ data: null, error: { message: 'no rows' } })
+
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest())
+ expect(status).toBe(409)
+ expect(body.error.code).toBe('SI_BOOK_CONFLICT')
+ expect(mockCancelOrphan).toHaveBeenCalledWith(
+ mockSupabase,
+ 'company-1',
+ 'user-1',
+ 'je-1',
+ expect.any(String),
+ )
+ })
+
+ it('books the registration entry and links it', async () => {
+ const invoice = makeUnbookedInvoice()
+ enqueue({ data: invoice, error: null })
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+ mockCreateRegistrationEntry.mockResolvedValue({ id: 'je-1' })
+ enqueue({ data: { ...invoice, registration_journal_entry_id: 'je-1' }, error: null })
+
+ const { status, body } = await parseJsonResponse<{
+ data: { registration_journal_entry_id: string }
+ journal_entry_id: string
+ }>(await bookRequest())
+
+ expect(status).toBe(200)
+ expect(body.journal_entry_id).toBe('je-1')
+ expect(body.data.registration_journal_entry_id).toBe('je-1')
+ expect(mockCreateRegistrationEntry).toHaveBeenCalled()
+ // No accrual items on the fixture, so no schedule creation.
+ expect(mockCreateSchedules).not.toHaveBeenCalled()
+ })
+
+ it('creates accrual schedules and surfaces failures as warnings', async () => {
+ const invoice = makeUnbookedInvoice({
+ items: [
+ {
+ id: 'item-1',
+ description: 'Hyra Q3',
+ accrual_period_start: '2026-07-01',
+ accrual_period_end: '2026-09-30',
+ },
+ ],
+ })
+ enqueue({ data: invoice, error: null })
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+ mockCreateRegistrationEntry.mockResolvedValue({ id: 'je-1' })
+ enqueue({ data: { ...invoice, registration_journal_entry_id: 'je-1' }, error: null })
+ mockCreateSchedules.mockResolvedValue({ created: 0, failed: 1 })
+
+ const { status, body } = await parseJsonResponse<{
+ warnings?: Array<{ code: string }>
+ }>(await bookRequest())
+
+ expect(status).toBe(200)
+ expect(mockCreateSchedules).toHaveBeenCalled()
+ expect(body.warnings?.[0]?.code).toBe('ACCRUAL_SCHEDULE_FAILED')
+ })
+})
diff --git a/app/api/supplier-invoices/[id]/book/route.ts b/app/api/supplier-invoices/[id]/book/route.ts
new file mode 100644
index 00000000..c33f4b08
--- /dev/null
+++ b/app/api/supplier-invoices/[id]/book/route.ts
@@ -0,0 +1,167 @@
+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 { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
+import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
+import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
+import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
+
+// Statuses where the registration entry can still be created afterwards.
+// Paid/partially paid invoices are excluded: their payment flow has already
+// booked the full cash-style entry (mark-paid routes on the missing
+// registration link), so booking registration now would double-post.
+const BOOKABLE_STATUSES = ['registered', 'approved', 'overdue']
+
+/**
+ * POST /api/supplier-invoices/[id]/book
+ *
+ * The explicit "Bokför" step for companies with defer_invoice_booking (#967):
+ * one person registers the invoice without bookkeeping, ekonomi books the
+ * registration entry here once the kontering is verified.
+ */
+export const POST = withRouteContext(
+ 'supplier_invoice.book',
+ async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params
+ const { user, supabase, companyId, log, requestId } = ctx
+
+ const { data: invoice } = await supabase
+ .from('supplier_invoices')
+ .select('*, items:supplier_invoice_items(*), supplier:suppliers(id, name, supplier_type)')
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .single()
+
+ if (!invoice) {
+ return errorResponseFromCode('SI_NOT_FOUND', log, { requestId })
+ }
+ if (invoice.registration_journal_entry_id) {
+ return errorResponseFromCode('SI_BOOK_ALREADY_BOOKED', log, { requestId })
+ }
+ if (invoice.is_credit_note) {
+ return errorResponseFromCode('SI_BOOK_NOT_BOOKABLE', log, { requestId })
+ }
+ if (!BOOKABLE_STATUSES.includes(invoice.status)) {
+ return errorResponseFromCode('SI_BOOK_INVALID_STATUS', log, {
+ requestId,
+ details: { currentStatus: invoice.status },
+ })
+ }
+
+ // The registration entry is a faktureringsmetoden concept; under
+ // kontantmetoden the invoice is booked in full when it is paid.
+ const { data: settings, error: settingsError } = await supabase
+ .from('company_settings')
+ .select('accounting_method')
+ .eq('company_id', companyId)
+ .single()
+ // Fail closed: booking with guessed settings could apply the wrong
+ // method's rules, so a failed/missing settings read aborts.
+ if (settingsError || !settings) {
+ log.error('failed to load company settings for deferred booking', settingsError ?? undefined, { invoiceId: id })
+ return errorResponseFromCode('SI_BOOK_FAILED', log, { requestId })
+ }
+ if ((settings.accounting_method || 'accrual') !== 'accrual') {
+ return errorResponseFromCode('SI_BOOK_CASH_METHOD', log, { requestId })
+ }
+
+ const items = (invoice.items ?? []) as SupplierInvoiceItem[]
+
+ let journalEntry
+ try {
+ journalEntry = await createSupplierInvoiceRegistrationEntry(
+ supabase,
+ companyId!,
+ user.id,
+ invoice as SupplierInvoice,
+ items,
+ invoice.supplier?.supplier_type || 'company',
+ invoice.supplier?.name,
+ )
+ } catch (err) {
+ if (isBookkeepingError(err)) {
+ return errorResponse(err, log, { requestId })
+ }
+ log.error('deferred registration booking failed', err as Error, { invoiceId: id })
+ return errorResponseFromCode('SI_BOOK_FAILED', log, { requestId })
+ }
+
+ // 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('SI_BOOK_NO_FISCAL_PERIOD', log, {
+ requestId,
+ details: { invoiceDate: invoice.invoice_date },
+ })
+ }
+
+ // CAS-guarded link: only claim the invoice if it is still unbooked AND
+ // still in a bookable status. A concurrent book/mark-paid/credit that got
+ // there first would otherwise leave this entry double-posting 2440 +
+ // ingående moms (mark-paid moves to paid without touching the
+ // registration link), so cancel it.
+ const { data: linked, error: linkError } = await supabase
+ .from('supplier_invoices')
+ .update({ registration_journal_entry_id: journalEntry.id })
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .is('registration_journal_entry_id', null)
+ .in('status', BOOKABLE_STATUSES)
+ .select()
+ .single()
+
+ if (linkError || !linked) {
+ await cancelOrphanedPaymentEntry(
+ supabase,
+ companyId!,
+ user.id,
+ journalEntry.id,
+ 'Bokföring av leverantörsfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.',
+ )
+ return errorResponseFromCode('SI_BOOK_CONFLICT', log, { requestId })
+ }
+
+ // Periodiseringar ride on the registration 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.
+ const warnings: Array<{ code: string; message: string }> = []
+ const hasAccrualItems = items.some((item) => item.accrual_period_start && item.accrual_period_end)
+ if (hasAccrualItems) {
+ try {
+ const scheduleResult = await createSchedulesForSupplierInvoice(
+ supabase,
+ companyId!,
+ user.id,
+ invoice as SupplierInvoice,
+ items,
+ journalEntry.id,
+ )
+ if (scheduleResult.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 NextResponse.json({
+ data: linked,
+ journal_entry_id: journalEntry.id,
+ ...(warnings.length > 0 ? { warnings } : {}),
+ })
+ },
+ { requireWrite: true },
+)
diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts
index 79c95d0d..ff6d66d8 100644
--- a/app/api/supplier-invoices/__tests__/route.test.ts
+++ b/app/api/supplier-invoices/__tests__/route.test.ts
@@ -227,6 +227,51 @@ describe('POST /api/supplier-invoices', () => {
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
})
+ it('registers WITHOUT booking when defer_invoice_booking is on (#967)', async () => {
+ const supplier = makeSupplier({ id: VALID_UUID })
+ const createdInvoice = makeSupplierInvoice({ id: 'si-deferred' })
+
+ // Fetch supplier
+ enqueue({ data: supplier, error: null })
+ // RPC get_next_arrival_number
+ enqueue({ data: 5 })
+ // Insert invoice
+ enqueue({ data: createdInvoice, error: null })
+ // Insert items
+ enqueue({ data: null, error: null })
+ // Fetch company settings: accrual + deferred booking
+ enqueue({ data: { accounting_method: 'accrual', defer_invoice_booking: true }, error: null })
+
+ const request = createMockRequest('/api/supplier-invoices', {
+ method: 'POST',
+ body: {
+ supplier_id: VALID_UUID,
+ supplier_invoice_number: 'LF-002',
+ invoice_date: '2024-06-01',
+ due_date: '2024-07-01',
+ items: [
+ {
+ description: 'Material',
+ quantity: 10,
+ unit_price: 800,
+ account_number: '4010',
+ vat_rate: 0.25,
+ },
+ ],
+ },
+ })
+ const response = await POST(request)
+ const { status, body } = await parseJsonResponse<{
+ data: { registration_journal_entry_id: string | null }
+ }>(response)
+
+ expect(status).toBe(200)
+ expect(body.data).toBeTruthy()
+ // No registration verifikat: booking is a separate explicit step.
+ expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
+ expect(body.data.registration_journal_entry_id ?? null).toBeNull()
+ })
+
it('stores an uploaded document and links it to the registration entry', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-with-document', document_id: DOCUMENT_UUID })
diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts
index 3ec706e0..e332d6cc 100644
--- a/app/api/supplier-invoices/route.ts
+++ b/app/api/supplier-invoices/route.ts
@@ -7,6 +7,7 @@ import {
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
@@ -391,11 +392,14 @@ export const POST = withRouteContext(
// invoked for these (status='paid' from the start).
const { data: settings } = await supabase
.from('company_settings')
- .select('accounting_method')
+ .select('accounting_method, defer_invoice_booking')
.eq('company_id', companyId)
.single()
- const accountingMethod = settings?.accounting_method || 'accrual'
+ // #967: deferred companies register WITHOUT booking; ekonomi books later
+ // via POST /api/supplier-invoices/[id]/book. The invoice then legitimately
+ // sits at registration_journal_entry_id = NULL, like the cash method.
+ const booksOnRegistration = booksInvoicesOnIssue(settings)
let registrationJournalEntryId: string | null = null
let paymentJournalEntryId: string | null = null
@@ -460,7 +464,7 @@ export const POST = withRouteContext(
},
})
}
- } else if (accountingMethod === 'accrual') {
+ } else if (booksOnRegistration) {
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
supabase,
diff --git a/components/settings/sections/BookkeepingSettingsContent.tsx b/components/settings/sections/BookkeepingSettingsContent.tsx
index fbfc555f..4d20eb90 100644
--- a/components/settings/sections/BookkeepingSettingsContent.tsx
+++ b/components/settings/sections/BookkeepingSettingsContent.tsx
@@ -42,12 +42,18 @@ export function BookkeepingSettingsContent() {
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
const defaultVoucherSeries = (formData.get('default_voucher_series') as string) || 'A'
+ // Deferred booking is an accrual-only concept (#967): normalize to false
+ // under kontantmetoden so switching back to accrual can never re-activate
+ // a stale flag the user set in a mode where it had no effect.
+ const deferInvoiceBooking =
+ accountingMethod === 'accrual' && formData.get('defer_invoice_booking') === 'true'
const updates: Record = {
bookkeeping_locked_through: lockedThrough,
auto_lock_period_days: autoLockValue === 'none' ? null : parseInt(autoLockValue),
accounting_method: accountingMethod,
default_voucher_series: defaultVoucherSeries,
+ defer_invoice_booking: deferInvoiceBooking,
}
// Write-through: the booking engine resolves the series from the
@@ -110,6 +116,23 @@ export function BookkeepingSettingsContent() {
{t('method_help')}
+ {/* #967: register/send without booking; ekonomi books in a separate
+ explicit step. Only meaningful under faktureringsmetoden. */}
+
+
+
+
+ {t('defer_booking_help')}
+
+
{/* Default voucher series */}
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 69357d18..6330e823 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -1466,6 +1466,8 @@ export const UpdateSettingsSchema = z.object({
iban: z.string().regex(/^SE\d{22}$/, 'Ogiltigt IBAN (SE följt av 22 siffror)').nullable().optional().or(z.literal('')),
bic: z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT (8 eller 11 tecken)').nullable().optional().or(z.literal('')),
accounting_method: AccountingMethodSchema.optional(),
+ // #967: register/send invoices without booking; booking is a separate step.
+ defer_invoice_booking: z.boolean().optional(),
invoice_prefix: z.string().nullable().optional(),
next_invoice_number: z.number().int().positive().optional(),
next_arrival_number: z.number().int().positive().optional(),
diff --git a/lib/bookkeeping/__tests__/booking-mode.test.ts b/lib/bookkeeping/__tests__/booking-mode.test.ts
new file mode 100644
index 00000000..0323d633
--- /dev/null
+++ b/lib/bookkeeping/__tests__/booking-mode.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest'
+import { booksInvoicesOnIssue } from '../booking-mode'
+
+describe('booksInvoicesOnIssue (#967)', () => {
+ it('books at issue for accrual companies by default', () => {
+ expect(booksInvoicesOnIssue({ accounting_method: 'accrual' })).toBe(true)
+ expect(booksInvoicesOnIssue({ accounting_method: 'accrual', defer_invoice_booking: false })).toBe(true)
+ })
+
+ it('defers when defer_invoice_booking is on', () => {
+ expect(booksInvoicesOnIssue({ accounting_method: 'accrual', defer_invoice_booking: true })).toBe(false)
+ })
+
+ it('never books at issue under the cash method, regardless of the flag', () => {
+ expect(booksInvoicesOnIssue({ accounting_method: 'cash' })).toBe(false)
+ expect(booksInvoicesOnIssue({ accounting_method: 'cash', defer_invoice_booking: true })).toBe(false)
+ })
+
+ it('treats missing settings as the historical accrual default', () => {
+ expect(booksInvoicesOnIssue(null)).toBe(true)
+ expect(booksInvoicesOnIssue(undefined)).toBe(true)
+ expect(booksInvoicesOnIssue({})).toBe(true)
+ })
+})
diff --git a/lib/bookkeeping/booking-mode.ts b/lib/bookkeeping/booking-mode.ts
new file mode 100644
index 00000000..b6754ae1
--- /dev/null
+++ b/lib/bookkeeping/booking-mode.ts
@@ -0,0 +1,24 @@
+/**
+ * #967 "Registrera men bokför inte": whether issuing an invoice (registering
+ * a supplier invoice, sending a customer invoice) books it inline.
+ *
+ * Inline booking happens only under faktureringsmetoden (accrual) with
+ * defer_invoice_booking off. Kontantmetoden companies never book at issue
+ * (they book at payment), and deferred companies book via the explicit
+ * "Bokför" routes (POST /api/supplier-invoices/[id]/book,
+ * POST /api/invoices/[id]/book) instead.
+ *
+ * The payment flows need no gate of their own: both mark-paid paths already
+ * route on whether a live journal-entry link exists, so an invoice that is
+ * still unbooked when paid gets the full cash-style entry at payment.
+ */
+export function booksInvoicesOnIssue(
+ settings:
+ | { accounting_method?: string | null; defer_invoice_booking?: boolean | null }
+ | null
+ | undefined
+): boolean {
+ // No settings row: match the historical default (accrual, book at issue).
+ if (!settings) return true
+ return (settings.accounting_method || 'accrual') === 'accrual' && !settings.defer_invoice_booking
+}
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index 5c6df1f5..fa215e0d 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -806,6 +806,41 @@ const INVOICE: Record = {
message_sv: 'Verifikatet skapades, men kopplingen till fakturan måste återställas. Kontakta support.',
message_en: 'The voucher was created, but its invoice link must be repaired. Contact support.',
},
+ INVOICE_BOOK_ALREADY_BOOKED: {
+ httpStatus: 400,
+ message_sv: 'Fakturan är redan bokförd.',
+ message_en: 'The invoice is already booked.',
+ },
+ INVOICE_BOOK_INVALID_STATUS: {
+ httpStatus: 400,
+ message_sv: 'Endast skickade eller förfallna fakturor kan bokföras i efterhand.',
+ message_en: 'Only sent or overdue invoices can be booked afterwards.',
+ },
+ INVOICE_BOOK_NOT_BOOKABLE: {
+ httpStatus: 400,
+ message_sv: 'Kreditfakturor och andra dokumenttyper bokförs inte via detta steg.',
+ message_en: 'Credit notes and other document types are not booked through this step.',
+ },
+ INVOICE_BOOK_CASH_METHOD: {
+ httpStatus: 400,
+ 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.',
+ },
+ INVOICE_BOOK_NO_FISCAL_PERIOD: {
+ httpStatus: 400,
+ message_sv: 'Inget öppet räkenskapsår täcker fakturadatumet. Skapa räkenskapsåret först.',
+ message_en: 'No open fiscal period covers the invoice date. Create the fiscal year first.',
+ },
+ INVOICE_BOOK_CONFLICT: {
+ httpStatus: 409,
+ message_sv: 'Fakturan bokfördes samtidigt av en annan begäran. Ladda om sidan.',
+ message_en: 'The invoice was booked concurrently by another request. Reload the page.',
+ },
+ INVOICE_BOOK_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Fakturan kunde inte bokföras.',
+ message_en: 'Failed to book the invoice.',
+ },
INVOICE_SEND_EMAIL_NOT_CONFIGURED: {
httpStatus: 503,
message_sv:
@@ -984,6 +1019,41 @@ const SUPPLIER_INVOICE: Record = {
message_sv: 'Kunde inte godkänna leverantörsfakturan.',
message_en: 'Failed to update supplier invoice status to approved.',
},
+ SI_BOOK_ALREADY_BOOKED: {
+ httpStatus: 400,
+ message_sv: 'Leverantörsfakturan är redan bokförd.',
+ message_en: 'The supplier invoice is already booked.',
+ },
+ SI_BOOK_INVALID_STATUS: {
+ httpStatus: 400,
+ message_sv: 'Endast registrerade, godkända eller förfallna fakturor kan bokföras i efterhand.',
+ message_en: 'Only registered, approved or overdue invoices can be booked afterwards.',
+ },
+ SI_BOOK_NOT_BOOKABLE: {
+ httpStatus: 400,
+ message_sv: 'Kreditfakturor bokförs inte via detta steg.',
+ message_en: 'Credit notes are not booked through this step.',
+ },
+ SI_BOOK_CASH_METHOD: {
+ httpStatus: 400,
+ 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.',
+ },
+ SI_BOOK_NO_FISCAL_PERIOD: {
+ httpStatus: 400,
+ message_sv: 'Inget öppet räkenskapsår täcker fakturadatumet. Skapa räkenskapsåret först.',
+ message_en: 'No open fiscal period covers the invoice date. Create the fiscal year first.',
+ },
+ SI_BOOK_CONFLICT: {
+ httpStatus: 409,
+ message_sv: 'Leverantörsfakturan bokfördes samtidigt av en annan begäran. Ladda om sidan.',
+ message_en: 'The supplier invoice was booked concurrently by another request. Reload the page.',
+ },
+ SI_BOOK_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Leverantörsfakturan kunde inte bokföras.',
+ message_en: 'Failed to book the supplier invoice.',
+ },
PO_THREE_WAY_MATCH_FAILED: {
httpStatus: 422,
message_sv:
diff --git a/messages/en.json b/messages/en.json
index ac99753e..b8338da2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1357,6 +1357,10 @@
"method_accrual": "Faktureringsmetoden",
"method_cash": "Kontantmetoden",
"method_help": "The cash method may be used if annual net sales are normally at most 3 MSEK (BFL 5 kap. 2 §). Outstanding receivables and payables must be posted at year-end.",
+ "defer_booking_label": "Invoice bookkeeping",
+ "defer_booking_off": "Book immediately (default)",
+ "defer_booking_on": "Register first, book in a separate step",
+ "defer_booking_help": "Applies to the accrual method. With separate booking, one person can register supplier invoices and send customer invoices without posting them; finance books them afterwards with the Book button on the invoice.",
"series_heading": "Default voucher series",
"series_label": "Series",
"series_help": "Which series is preselected for manual bookkeeping. Can be changed per voucher.",
@@ -2767,6 +2771,13 @@
"your_reference_label": "Your reference",
"our_reference_label": "Our reference",
"bookkeeping_label": "Bookkeeping",
+ "not_booked_yet": "Not booked yet",
+ "book_action": "Book",
+ "booked_title": "Invoice booked",
+ "booked_description": "The verifikat has been created.",
+ "booked_with_warnings_description": "The invoice was booked, but one or more accrual schedules could not be created. Check under Bookkeeping, Periodiseringar.",
+ "book_failed_title": "Booking failed",
+ "book_failed_fallback": "The invoice could not be booked. Try again.",
"view_voucher": "View verifikat",
"correction_loading": "Loading…",
"correction_prompt": "Something wrong? Create a correction verifikat",
@@ -3336,6 +3347,12 @@
"vouchers_title": "Verifikat (sambandskrav)",
"registration_voucher": "Registration verifikat",
"no_registration_voucher": "No registration verifikat (cash method)",
+ "not_booked_yet": "Not booked yet",
+ "book_action": "Book",
+ "book_failed_title": "Booking failed",
+ "booked_title": "Invoice booked",
+ "booked_description": "The registration verifikat has been created.",
+ "booked_with_warnings_description": "The invoice was booked, but one or more accrual schedules could not be created. Check under Bookkeeping, Periodiseringar.",
"payment_voucher": "Payment verifikat",
"document_title": "Invoice document",
"document_attached": "The supplier invoice is attached and archived.",
diff --git a/messages/sv.json b/messages/sv.json
index 06f0d579..63b003fb 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -1357,6 +1357,10 @@
"method_accrual": "Faktureringsmetoden",
"method_cash": "Kontantmetoden",
"method_help": "Kontantmetoden får användas om årlig nettoomsättning normalt är högst 3 MSEK (BFL 5 kap. 2 §). Obetalda fordringar och skulder ska bokföras vid räkenskapsårets utgång.",
+ "defer_booking_label": "Bokföring av fakturor",
+ "defer_booking_off": "Bokför direkt (standard)",
+ "defer_booking_on": "Registrera först, bokför i ett separat steg",
+ "defer_booking_help": "Gäller faktureringsmetoden. Med separat bokföring kan en person registrera leverantörsfakturor och skicka kundfakturor utan att de bokförs; ekonomi bokför dem i efterhand med knappen Bokför på fakturan.",
"series_heading": "Standardserie för verifikationer",
"series_label": "Serie",
"series_help": "Vilken serie som förväljs vid manuell bokföring. Kan ändras per verifikation.",
@@ -2767,6 +2771,13 @@
"your_reference_label": "Er referens",
"our_reference_label": "Vår referens",
"bookkeeping_label": "Bokföring",
+ "not_booked_yet": "Ej bokförd ännu",
+ "book_action": "Bokför",
+ "booked_title": "Fakturan bokförd",
+ "booked_description": "Verifikationen har skapats.",
+ "booked_with_warnings_description": "Fakturan bokfördes, men en eller flera periodiseringar kunde inte skapas. Kontrollera under Bokföring, Periodiseringar.",
+ "book_failed_title": "Kunde inte bokföra",
+ "book_failed_fallback": "Fakturan kunde inte bokföras. Försök igen.",
"view_voucher": "Visa verifikation",
"correction_loading": "Hämtar…",
"correction_prompt": "Något fel? Skapa ändringsverifikation",
@@ -3336,6 +3347,12 @@
"vouchers_title": "Verifikationer (sambandskrav)",
"registration_voucher": "Registreringsverifikation",
"no_registration_voucher": "Ingen registreringsverifikation (kontantmetoden)",
+ "not_booked_yet": "Ej bokförd ännu",
+ "book_action": "Bokför",
+ "book_failed_title": "Kunde inte bokföra",
+ "booked_title": "Fakturan bokförd",
+ "booked_description": "Registreringsverifikationen har skapats.",
+ "booked_with_warnings_description": "Fakturan bokfördes, men en eller flera periodiseringar kunde inte skapas. Kontrollera under Bokföring, Periodiseringar.",
"payment_voucher": "Betalningsverifikation",
"document_title": "Fakturaunderlag",
"document_attached": "Leverantörens faktura är bifogad och arkiverad.",
diff --git a/supabase/migrations/20260716150000_company_settings_defer_invoice_booking.sql b/supabase/migrations/20260716150000_company_settings_defer_invoice_booking.sql
new file mode 100644
index 00000000..feeba56e
--- /dev/null
+++ b/supabase/migrations/20260716150000_company_settings_defer_invoice_booking.sql
@@ -0,0 +1,20 @@
+-- Issue #967 "Registrera men bokför inte": let companies split registering
+-- invoices from booking them. Many companies have one person who creates the
+-- customer invoice / registers the supplier invoice while ekonomi books it
+-- with the correct kontering afterwards.
+--
+-- When defer_invoice_booking is true AND the company uses faktureringsmetoden
+-- (accrual), registering a supplier invoice or sending a customer invoice no
+-- longer creates the journal entry inline; a separate explicit "Bokför"
+-- action (POST /api/supplier-invoices/[id]/book, /api/invoices/[id]/book)
+-- posts it later. Kontantmetoden companies already defer booking to payment,
+-- so the flag is a no-op for them. Default false keeps every existing
+-- company on the book-immediately behavior.
+
+ALTER TABLE public.company_settings
+ ADD COLUMN IF NOT EXISTS defer_invoice_booking boolean NOT NULL DEFAULT false;
+
+COMMENT ON COLUMN public.company_settings.defer_invoice_booking IS
+ 'When true (faktureringsmetoden only): registering supplier invoices / sending customer invoices does not book them; booking is a separate explicit step (#967).';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/types/index.ts b/types/index.ts
index 46d8da9b..511d95df 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -254,6 +254,9 @@ export interface CompanySettings {
// Accounting method
accounting_method: AccountingMethod
+ // #967: when true (accrual only), registering supplier invoices / sending
+ // customer invoices does NOT book them; booking is a separate explicit step.
+ defer_invoice_booking?: boolean
// Invoice settings
invoice_prefix: string | null