feat(invoices): registrera utan att bokföra + explicit Bokför-steg (#1040)

* feat(invoices): registrera utan att bokföra + explicit Bokför-steg

Companies where one person registers supplier invoices / sends customer
invoices while ekonomi does the actual bookkeeping had no way to split
the two: under faktureringsmetoden every registration/send booked the
journal entry inline.

- New company setting defer_invoice_booking (default off, accrual only):
  registering a supplier invoice or sending/marking-sent a customer
  invoice creates NO journal entry.
- New explicit booking routes POST /api/supplier-invoices/[id]/book and
  POST /api/invoices/[id]/book: create the registration/revenue entry
  afterwards, CAS-guarded against concurrent booking (a lost race
  cancels the just-posted voucher with a gap explanation), including
  periodisering schedules.
- Detail pages show "Ej bokförd ännu" + a Bokför button for unbooked
  accrual invoices; the settings toggle lives under Bokföringsmetod.
- mark-paid needs no changes: both payment flows already route on the
  journal-entry link, so an invoice still unbooked when paid gets the
  full cash-style entry.
- The mark-sent fail-closed rollback now keys on the same gate so
  deferred sends are not rolled back as booking failures.

Fixes #967

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): harden deferred booking after review

CodeRabbit round on #1040:
- CAS link guards also require a still-bookable status (and uncredited,
  customer side) so a concurrent mark-paid/credit cannot end up with a
  double-posting registration/revenue entry.
- Settings reads fail closed instead of defaulting to accrual rules.
- Detail pages surface the ACCRUAL_SCHEDULE_FAILED warning instead of
  showing plain success, and the customer page no longer stringifies
  structured errors into "[object Object]".
- The settings form normalizes defer_invoice_booking to false under
  kontantmetoden so a stale flag cannot re-activate on method switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-16 18:15:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 14f7478abb
commit 1443235cec
19 changed files with 1091 additions and 9 deletions
@@ -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<string, unknown> = {}) {
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()
})
})
+164
View File
@@ -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<CompanySettings>).entity_type as EntityType) || 'enskild_firma'
let journalEntry
try {
journalEntry = await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name,
)
} catch (err) {
if (isBookkeepingError(err)) {
return errorResponse(err, log, { requestId })
}
log.error('deferred invoice booking failed', err as Error, { invoiceId: id })
return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId })
}
// 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 },
)
+8 -2
View File
@@ -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')
+5 -2
View File
@@ -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<string, unknown>).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,
@@ -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<string, unknown> = {}) {
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')
})
})
@@ -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 },
)
@@ -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 })
+7 -3
View File
@@ -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,