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:
@@ -125,6 +125,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
// #967: register/send without booking; ekonomi books in a separate step.
|
||||
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState(false)
|
||||
const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45])
|
||||
|
||||
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
|
||||
@@ -220,7 +222,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
data.company_id
|
||||
? supabase
|
||||
.from('company_settings')
|
||||
.select('ore_rounding, vat_registered, accounting_method, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
|
||||
.select('ore_rounding, vat_registered, accounting_method, defer_invoice_booking, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3')
|
||||
.eq('company_id', data.company_id)
|
||||
.maybeSingle()
|
||||
: Promise.resolve(null),
|
||||
@@ -256,6 +258,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
setVatRegistered(settings.vat_registered)
|
||||
}
|
||||
setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
|
||||
setDeferInvoiceBooking(!!settings?.defer_invoice_booking)
|
||||
setReminderDays([
|
||||
settings?.reminder_days_level_1 ?? 15,
|
||||
settings?.reminder_days_level_2 ?? 30,
|
||||
@@ -273,6 +276,42 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// #967: deferred booking: create the revenue verifikat afterwards.
|
||||
async function handleBook() {
|
||||
if (!invoice) return
|
||||
setIsUpdating(true)
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/book`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
// data.error is a structured object for this route; only strings are
|
||||
// usable as a toast message.
|
||||
const message =
|
||||
typeof data.error === 'string'
|
||||
? data.error
|
||||
: typeof data.error?.message === 'string'
|
||||
? data.error.message
|
||||
: t('book_failed_fallback')
|
||||
throw new Error(message)
|
||||
}
|
||||
if (Array.isArray(data.warnings) && data.warnings.length > 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' })
|
||||
} else {
|
||||
toast({ title: t('booked_title'), description: t('booked_description') })
|
||||
}
|
||||
fetchInvoice()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('book_failed_title'),
|
||||
description: error instanceof Error ? error.message : t('fallback_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(status: InvoiceStatus) {
|
||||
if (!invoice) return
|
||||
|
||||
@@ -532,7 +571,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const booksOnIssue = isCreditNote
|
||||
? !!originalInvoice && creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
|
||||
: accountingMethod === 'accrual'
|
||||
: accountingMethod === 'accrual' && !deferInvoiceBooking
|
||||
// #967: sent under deferred booking; ekonomi books the revenue verifikat
|
||||
// from here afterwards.
|
||||
const canBookAfterwards =
|
||||
isRealInvoice &&
|
||||
!isCreditNote &&
|
||||
!invoice.journal_entry_id &&
|
||||
accountingMethod === 'accrual' &&
|
||||
['sent', 'overdue'].includes(invoice.status)
|
||||
const preferredSendMode = getCreditNoteSendMode({
|
||||
customerHasEmail,
|
||||
isSandbox,
|
||||
@@ -976,6 +1023,22 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{canBookAfterwards && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground text-sm">{t('bookkeeping_label')}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{t('not_booked_yet')}</span>
|
||||
{canWrite && (
|
||||
<Button size="sm" onClick={handleBook} disabled={isUpdating}>
|
||||
{t('book_action')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{invoice.journal_entry_id && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
@@ -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<string, 'default' | 'secondary' | 'success' | 'warn
|
||||
|
||||
export default function SupplierInvoiceDetailPage() {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -282,6 +284,24 @@ export default function SupplierInvoiceDetailPage() {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
// #967: deferred booking: create the registration verifikat afterwards.
|
||||
async function handleBook() {
|
||||
setIsProcessing(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/book`, { method: 'POST' })
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: t('book_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
|
||||
} else if (Array.isArray(result.warnings) && result.warnings.length > 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)}...
|
||||
</Link>
|
||||
</div>
|
||||
) : 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.
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{t('not_booked_yet')}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBook}
|
||||
disabled={isProcessing || !canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? <CheckCircle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('book_action')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t('no_registration_voucher')}</p>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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')}
|
||||
</p>
|
||||
</div>
|
||||
{/* #967: register/send without booking; ekonomi books in a separate
|
||||
explicit step. Only meaningful under faktureringsmetoden. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defer_invoice_booking">{t('defer_booking_label')}</Label>
|
||||
<select
|
||||
id="defer_invoice_booking"
|
||||
name="defer_invoice_booking"
|
||||
defaultValue={settings.defer_invoice_booking ? 'true' : 'false'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="false">{t('defer_booking_off')}</option>
|
||||
<option value="true">{t('defer_booking_on')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('defer_booking_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Default voucher series */}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -806,6 +806,41 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
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<string, StructuredErrorEntry> = {
|
||||
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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user