From 1443235cec5e5f7f0fdbc35749554b73e6084723 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:15:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(invoices):=20registrera=20utan=20att=20bok?= =?UTF-8?q?f=C3=B6ra=20+=20explicit=20Bokf=C3=B6r-steg=20(#1040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/invoices/[id]/page.tsx | 67 +++++- .../supplier-invoices/[id]/page.tsx | 37 ++++ .../[id]/book/__tests__/route.test.ts | 191 +++++++++++++++++ app/api/invoices/[id]/book/route.ts | 164 ++++++++++++++ app/api/invoices/[id]/mark-sent/route.ts | 10 +- app/api/invoices/[id]/send/route.ts | 7 +- .../[id]/book/__tests__/route.test.ts | 202 ++++++++++++++++++ app/api/supplier-invoices/[id]/book/route.ts | 167 +++++++++++++++ .../supplier-invoices/__tests__/route.test.ts | 45 ++++ app/api/supplier-invoices/route.ts | 10 +- .../sections/BookkeepingSettingsContent.tsx | 23 ++ lib/api/schemas.ts | 2 + .../__tests__/booking-mode.test.ts | 24 +++ lib/bookkeeping/booking-mode.ts | 24 +++ lib/errors/structured-errors.ts | 70 ++++++ messages/en.json | 17 ++ messages/sv.json | 17 ++ ...company_settings_defer_invoice_booking.sql | 20 ++ types/index.ts | 3 + 19 files changed, 1091 insertions(+), 9 deletions(-) create mode 100644 app/api/invoices/[id]/book/__tests__/route.test.ts create mode 100644 app/api/invoices/[id]/book/route.ts create mode 100644 app/api/supplier-invoices/[id]/book/__tests__/route.test.ts create mode 100644 app/api/supplier-invoices/[id]/book/route.ts create mode 100644 lib/bookkeeping/__tests__/booking-mode.test.ts create mode 100644 lib/bookkeeping/booking-mode.ts create mode 100644 supabase/migrations/20260716150000_company_settings_defer_invoice_booking.sql diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 398d2a84..9f36f7c2 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -125,6 +125,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [oreRounding, setOreRounding] = useState(true) const [vatRegistered, setVatRegistered] = useState(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 )} + {canBookAfterwards && ( + <> + +
+ {t('bookkeeping_label')} +
+ {t('not_booked_yet')} + {canWrite && ( + + )} +
+
+ + )} {invoice.journal_entry_id && ( <> diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 88f73160..cf38d5bc 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -23,6 +23,7 @@ import { AccountNumber } from '@/components/ui/account-number' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton' +import { useCompanySettings } from '@/components/settings/useSettings' import { formatAmount, formatCurrency } from '@/lib/utils' import { getDisplayTotal } from '@/lib/invoices/rounding' import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types' @@ -77,6 +78,7 @@ const statusVariants: Record 0) { + // Booked, but a follow-up is needed (e.g. periodiseringar failed). + toast({ title: t('booked_title'), description: t('booked_with_warnings_description'), variant: 'destructive' }) + fetchInvoice() + } else { + toast({ title: t('booked_title'), description: t('booked_description') }) + fetchInvoice() + } + setIsProcessing(false) + } + async function handleMarkPaid(force: boolean = false) { setIsProcessing(true) // When the user has edited the booking rows in this session, forward @@ -825,6 +845,23 @@ export default function SupplierInvoiceDetailPage() { {invoice.registration_journal_entry_id.substring(0, 8)}... + ) : companySettings?.accounting_method === 'accrual' && + !invoice.is_credit_note && + ['registered', 'approved', 'overdue'].includes(invoice.status) ? ( + // #967: registered-without-booking (deferred booking). Ekonomi + // books the registration verifikat from here. +
+ {t('not_booked_yet')} + +
) : (

{t('no_registration_voucher')}

)} diff --git a/app/api/invoices/[id]/book/__tests__/route.test.ts b/app/api/invoices/[id]/book/__tests__/route.test.ts new file mode 100644 index 00000000..936bacba --- /dev/null +++ b/app/api/invoices/[id]/book/__tests__/route.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeInvoice, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +const mockCreateInvoiceJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoiceJournalEntry: (...args: unknown[]) => mockCreateInvoiceJournalEntry(...args), +})) + +const mockCreateSchedules = vi.fn() +vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({ + createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args), +})) + +const mockCancelOrphan = vi.fn() +vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({ + cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args), +})) + +import { POST } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function bookRequest() { + return POST( + createMockRequest('/api/invoices/inv-1/book', { method: 'POST' }), + createMockRouteParams({ id: 'inv-1' }), + ) +} + +function makeUnbookedInvoice(overrides: Record = {}) { + return { + ...makeInvoice({ id: 'inv-1', status: 'sent' }), + journal_entry_id: null, + credited_invoice_id: null, + document_type: 'invoice', + customer: { name: 'Kunden AB' }, + items: [], + ...overrides, + } +} + +describe('POST /api/invoices/[id]/book', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const { status } = await parseJsonResponse(await bookRequest()) + expect(status).toBe(401) + }) + + it('returns 404 when the invoice does not exist', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(404) + expect(body.error.code).toBe('INVOICE_NOT_FOUND') + }) + + it('rejects an already booked invoice', async () => { + enqueue({ data: makeUnbookedInvoice({ journal_entry_id: 'je-existing' }), error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_BOOK_ALREADY_BOOKED') + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('rejects credit notes', async () => { + enqueue({ data: makeUnbookedInvoice({ credited_invoice_id: 'inv-0' }), error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_BOOK_NOT_BOOKABLE') + }) + + it('rejects paid invoices (payment flow already booked them)', async () => { + enqueue({ data: makeUnbookedInvoice({ status: 'paid' }), error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_BOOK_INVALID_STATUS') + }) + + it('fails closed when company settings cannot be read', async () => { + enqueue({ data: makeUnbookedInvoice(), error: null }) + enqueue({ data: null, error: { message: 'boom' } }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(500) + expect(body.error.code).toBe('INVOICE_BOOK_FAILED') + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('rejects booking under the cash method', async () => { + enqueue({ data: makeUnbookedInvoice(), error: null }) + enqueue({ data: { accounting_method: 'cash', entity_type: 'aktiebolag' }, error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_BOOK_CASH_METHOD') + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 400 when no fiscal period covers the invoice date', async () => { + enqueue({ data: makeUnbookedInvoice(), error: null }) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) + mockCreateInvoiceJournalEntry.mockResolvedValue(null) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_BOOK_NO_FISCAL_PERIOD') + }) + + it('cancels the entry and returns 409 when another request booked first', async () => { + enqueue({ data: makeUnbookedInvoice(), error: null }) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) + mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) + // CAS-guarded link matches no row: someone else already claimed it. + enqueue({ data: null, error: { message: 'no rows' } }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_BOOK_CONFLICT') + expect(mockCancelOrphan).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'user-1', + 'je-1', + expect.any(String), + ) + }) + + it('books the revenue entry and links it', async () => { + const invoice = makeUnbookedInvoice() + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) + mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null }) + + const { status, body } = await parseJsonResponse<{ + data: { journal_entry_id: string } + journal_entry_id: string + }>(await bookRequest()) + + expect(status).toBe(200) + expect(body.journal_entry_id).toBe('je-1') + expect(body.data.journal_entry_id).toBe('je-1') + expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + 'aktiebolag', + 'Kunden AB', + ) + expect(mockCreateSchedules).toHaveBeenCalled() + }) +}) diff --git a/app/api/invoices/[id]/book/route.ts b/app/api/invoices/[id]/book/route.ts new file mode 100644 index 00000000..c35ec913 --- /dev/null +++ b/app/api/invoices/[id]/book/route.ts @@ -0,0 +1,164 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices' +import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' +import type { CompanySettings, EntityType, Invoice, InvoiceItem } from '@/types' + +// Statuses where the revenue entry can still be created afterwards. Paid +// invoices are excluded: their payment flow has already booked the full +// cash-style entry (mark-paid routes on the missing journal-entry link), so +// booking the sale now would double-post revenue. +const BOOKABLE_STATUSES = ['sent', 'overdue'] + +/** + * POST /api/invoices/[id]/book + * + * The explicit "Bokför" step for companies with defer_invoice_booking (#967): + * one person creates and sends the invoice without bookkeeping, ekonomi books + * the revenue entry here once the kontering is verified. + */ +export const POST = withRouteContext( + 'invoice.book', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + + const { data: invoice } = await supabase + .from('invoices') + .select('*, customer:customers(name), items:invoice_items(*)') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!invoice) { + return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId }) + } + if (invoice.journal_entry_id) { + return errorResponseFromCode('INVOICE_BOOK_ALREADY_BOOKED', log, { requestId }) + } + const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' + if (!isRealInvoice || invoice.credited_invoice_id) { + return errorResponseFromCode('INVOICE_BOOK_NOT_BOOKABLE', log, { requestId }) + } + if (!BOOKABLE_STATUSES.includes(invoice.status)) { + return errorResponseFromCode('INVOICE_BOOK_INVALID_STATUS', log, { + requestId, + details: { currentStatus: invoice.status }, + }) + } + + // The revenue-at-issue entry is a faktureringsmetoden concept; under + // kontantmetoden the sale is booked in full when it is paid. + const { data: settings, error: settingsError } = await supabase + .from('company_settings') + .select('accounting_method, entity_type') + .eq('company_id', companyId) + .single() + // Fail closed: booking with guessed settings could apply the wrong + // method's or entity type's rules, so a failed/missing settings read aborts. + if (settingsError || !settings) { + log.error('failed to load company settings for deferred booking', settingsError ?? undefined, { invoiceId: id }) + return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId }) + } + if ((settings.accounting_method || 'accrual') !== 'accrual') { + return errorResponseFromCode('INVOICE_BOOK_CASH_METHOD', log, { requestId }) + } + const entityType = ((settings as Partial).entity_type as EntityType) || 'enskild_firma' + + let journalEntry + try { + journalEntry = await createInvoiceJournalEntry( + supabase, + companyId!, + user.id, + invoice as Invoice, + entityType, + invoice.customer?.name, + ) + } catch (err) { + if (isBookkeepingError(err)) { + return errorResponse(err, log, { requestId }) + } + log.error('deferred invoice booking failed', err as Error, { invoiceId: id }) + return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId }) + } + + // Returns null ONLY when no fiscal period covers invoice_date (other + // failures throw). Nothing was posted, so a plain error is safe. + if (!journalEntry) { + return errorResponseFromCode('INVOICE_BOOK_NO_FISCAL_PERIOD', log, { + requestId, + details: { invoiceDate: invoice.invoice_date }, + }) + } + + // CAS-guarded link: only claim the invoice if it is still unbooked, still + // in a bookable status, and still uncredited. A concurrent + // book/mark-paid/credit that got there first would otherwise leave this + // entry double-posting revenue, so cancel it. + const { data: linked, error: linkError } = await supabase + .from('invoices') + .update({ journal_entry_id: journalEntry.id }) + .eq('id', id) + .eq('company_id', companyId) + .is('journal_entry_id', null) + .in('status', BOOKABLE_STATUSES) + .is('credited_invoice_id', null) + .select() + .single() + + if (linkError || !linked) { + await cancelOrphanedPaymentEntry( + supabase, + companyId!, + user.id, + journalEntry.id, + 'Bokföring av kundfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.', + ) + return errorResponseFromCode('INVOICE_BOOK_CONFLICT', log, { requestId }) + } + + // Periodiseringar ride on the revenue entry, so they can only be created + // now. Non-blocking: the entry is committed (immutable); a schedule + // failure is surfaced as a warning and retried from the periodiseringar + // page. + const warnings: Array<{ code: string; message: string }> = [] + try { + const accrual = await createSchedulesForCustomerInvoice( + supabase, + companyId!, + user.id, + invoice as Invoice, + (invoice.items as InvoiceItem[] | null) ?? [], + journalEntry.id, + entityType, + ) + if (accrual.failed > 0) { + warnings.push({ + code: 'ACCRUAL_SCHEDULE_FAILED', + message: + 'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' + + 'skapas. Kontrollera under Bokföring → Periodiseringar.', + }) + } + } catch (err) { + log.error('accrual schedule creation failed on deferred booking', err as Error, { invoiceId: id }) + warnings.push({ + code: 'ACCRUAL_SCHEDULE_FAILED', + message: + 'Fakturan bokfördes, men periodiseringarna kunde inte skapas. ' + + 'Kontrollera under Bokföring → Periodiseringar.', + }) + } + + return NextResponse.json({ + data: linked, + journal_entry_id: journalEntry.id, + ...(warnings.length > 0 ? { warnings } : {}), + }) + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index de4ade89..00707f01 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode' import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices' import { eventBus } from '@/lib/events' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' @@ -177,7 +178,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( }, ) } - } else if (isRealInvoice && accountingMethod === 'accrual') { + } else if (isRealInvoice && booksInvoicesOnIssue(settings as CompanySettings)) { + // #967: deferred companies fall past this branch (mark-sent WITHOUT + // booking); ekonomi books later via POST /api/invoices/[id]/book, like + // under kontantmetoden. try { const journalEntry = await createInvoiceJournalEntry( supabase, @@ -246,7 +250,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( } } - if (isRealInvoice && accountingMethod === 'accrual' && !isCreditNote && !journalEntryId) { + // Fail-closed only when inline booking was supposed to happen: deferred + // (#967) and cash-method invoices are legitimately unbooked at this point. + if (isRealInvoice && booksInvoicesOnIssue(settings as CompanySettings) && !isCreditNote && !journalEntryId) { if (statusFlipped) { const { error: rollbackError } = await supabase .from('invoices') diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 2a6b4c33..61cc5437 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -11,6 +11,7 @@ import { generateInvoiceEmailSubject, } from '@/lib/email/invoice-templates' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode' import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices' import { uploadDocument } from '@/lib/core/documents/document-service' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' @@ -355,10 +356,12 @@ export const POST = withRouteContext( } const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' - const accountingMethod = ((company as Record).accounting_method || 'accrual') as AccountingMethod let createdJournalEntryId: string | undefined = creditJournalEntryId ?? undefined - if (statusFlipped && !isCreditNote && isRealInvoice && accountingMethod === 'accrual') { + // #967: deferred companies send WITHOUT booking; ekonomi books later via + // POST /api/invoices/[id]/book. The invoice then legitimately sits at + // journal_entry_id = NULL until then, like under kontantmetoden. + if (statusFlipped && !isCreditNote && isRealInvoice && booksInvoicesOnIssue(company as CompanySettings)) { try { const journalEntry = await createInvoiceJournalEntry( supabase, diff --git a/app/api/supplier-invoices/[id]/book/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/book/__tests__/route.test.ts new file mode 100644 index 00000000..113831c3 --- /dev/null +++ b/app/api/supplier-invoices/[id]/book/__tests__/route.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeSupplierInvoice, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +const mockCreateRegistrationEntry = vi.fn() +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoiceRegistrationEntry: (...args: unknown[]) => mockCreateRegistrationEntry(...args), +})) + +const mockCreateSchedules = vi.fn() +vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({ + createSchedulesForSupplierInvoice: (...args: unknown[]) => mockCreateSchedules(...args), +})) + +const mockCancelOrphan = vi.fn() +vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({ + cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args), +})) + +import { POST } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function bookRequest() { + return POST( + createMockRequest('/api/supplier-invoices/si-1/book', { method: 'POST' }), + createMockRouteParams({ id: 'si-1' }), + ) +} + +function makeUnbookedInvoice(overrides: Record = {}) { + return { + ...makeSupplierInvoice({ id: 'si-1' }), + registration_journal_entry_id: null, + is_credit_note: false, + items: [], + supplier: { id: 'supplier-1', name: 'Leverantören AB', supplier_type: 'company' }, + ...overrides, + } +} + +describe('POST /api/supplier-invoices/[id]/book', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const { status } = await parseJsonResponse(await bookRequest()) + expect(status).toBe(401) + }) + + it('returns 404 when the invoice does not exist', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(404) + expect(body.error.code).toBe('SI_NOT_FOUND') + }) + + it('rejects an already booked invoice', async () => { + enqueue({ data: makeUnbookedInvoice({ registration_journal_entry_id: 'je-existing' }), error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('SI_BOOK_ALREADY_BOOKED') + expect(mockCreateRegistrationEntry).not.toHaveBeenCalled() + }) + + it('rejects paid invoices (payment flow already booked them)', async () => { + enqueue({ data: makeUnbookedInvoice({ status: 'paid' }), error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('SI_BOOK_INVALID_STATUS') + }) + + it('fails closed when company settings cannot be read', async () => { + enqueue({ data: makeUnbookedInvoice(), error: null }) + enqueue({ data: null, error: { message: 'boom' } }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(500) + expect(body.error.code).toBe('SI_BOOK_FAILED') + expect(mockCreateRegistrationEntry).not.toHaveBeenCalled() + }) + + it('rejects booking under the cash method', async () => { + enqueue({ data: makeUnbookedInvoice(), error: null }) + enqueue({ data: { accounting_method: 'cash' }, error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('SI_BOOK_CASH_METHOD') + expect(mockCreateRegistrationEntry).not.toHaveBeenCalled() + }) + + it('returns 400 when no fiscal period covers the invoice date', async () => { + enqueue({ data: makeUnbookedInvoice(), error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateRegistrationEntry.mockResolvedValue(null) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(400) + expect(body.error.code).toBe('SI_BOOK_NO_FISCAL_PERIOD') + }) + + it('cancels the entry and returns 409 when another request booked first', async () => { + enqueue({ data: makeUnbookedInvoice(), error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateRegistrationEntry.mockResolvedValue({ id: 'je-1' }) + // CAS-guarded link matches no row: someone else already claimed it. + enqueue({ data: null, error: { message: 'no rows' } }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await bookRequest()) + expect(status).toBe(409) + expect(body.error.code).toBe('SI_BOOK_CONFLICT') + expect(mockCancelOrphan).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'user-1', + 'je-1', + expect.any(String), + ) + }) + + it('books the registration entry and links it', async () => { + const invoice = makeUnbookedInvoice() + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateRegistrationEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: { ...invoice, registration_journal_entry_id: 'je-1' }, error: null }) + + const { status, body } = await parseJsonResponse<{ + data: { registration_journal_entry_id: string } + journal_entry_id: string + }>(await bookRequest()) + + expect(status).toBe(200) + expect(body.journal_entry_id).toBe('je-1') + expect(body.data.registration_journal_entry_id).toBe('je-1') + expect(mockCreateRegistrationEntry).toHaveBeenCalled() + // No accrual items on the fixture, so no schedule creation. + expect(mockCreateSchedules).not.toHaveBeenCalled() + }) + + it('creates accrual schedules and surfaces failures as warnings', async () => { + const invoice = makeUnbookedInvoice({ + items: [ + { + id: 'item-1', + description: 'Hyra Q3', + accrual_period_start: '2026-07-01', + accrual_period_end: '2026-09-30', + }, + ], + }) + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateRegistrationEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: { ...invoice, registration_journal_entry_id: 'je-1' }, error: null }) + mockCreateSchedules.mockResolvedValue({ created: 0, failed: 1 }) + + const { status, body } = await parseJsonResponse<{ + warnings?: Array<{ code: string }> + }>(await bookRequest()) + + expect(status).toBe(200) + expect(mockCreateSchedules).toHaveBeenCalled() + expect(body.warnings?.[0]?.code).toBe('ACCRUAL_SCHEDULE_FAILED') + }) +}) diff --git a/app/api/supplier-invoices/[id]/book/route.ts b/app/api/supplier-invoices/[id]/book/route.ts new file mode 100644 index 00000000..c33f4b08 --- /dev/null +++ b/app/api/supplier-invoices/[id]/book/route.ts @@ -0,0 +1,167 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices' +import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' +import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' + +// Statuses where the registration entry can still be created afterwards. +// Paid/partially paid invoices are excluded: their payment flow has already +// booked the full cash-style entry (mark-paid routes on the missing +// registration link), so booking registration now would double-post. +const BOOKABLE_STATUSES = ['registered', 'approved', 'overdue'] + +/** + * POST /api/supplier-invoices/[id]/book + * + * The explicit "Bokför" step for companies with defer_invoice_booking (#967): + * one person registers the invoice without bookkeeping, ekonomi books the + * registration entry here once the kontering is verified. + */ +export const POST = withRouteContext( + 'supplier_invoice.book', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx + + const { data: invoice } = await supabase + .from('supplier_invoices') + .select('*, items:supplier_invoice_items(*), supplier:suppliers(id, name, supplier_type)') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!invoice) { + return errorResponseFromCode('SI_NOT_FOUND', log, { requestId }) + } + if (invoice.registration_journal_entry_id) { + return errorResponseFromCode('SI_BOOK_ALREADY_BOOKED', log, { requestId }) + } + if (invoice.is_credit_note) { + return errorResponseFromCode('SI_BOOK_NOT_BOOKABLE', log, { requestId }) + } + if (!BOOKABLE_STATUSES.includes(invoice.status)) { + return errorResponseFromCode('SI_BOOK_INVALID_STATUS', log, { + requestId, + details: { currentStatus: invoice.status }, + }) + } + + // The registration entry is a faktureringsmetoden concept; under + // kontantmetoden the invoice is booked in full when it is paid. + const { data: settings, error: settingsError } = await supabase + .from('company_settings') + .select('accounting_method') + .eq('company_id', companyId) + .single() + // Fail closed: booking with guessed settings could apply the wrong + // method's rules, so a failed/missing settings read aborts. + if (settingsError || !settings) { + log.error('failed to load company settings for deferred booking', settingsError ?? undefined, { invoiceId: id }) + return errorResponseFromCode('SI_BOOK_FAILED', log, { requestId }) + } + if ((settings.accounting_method || 'accrual') !== 'accrual') { + return errorResponseFromCode('SI_BOOK_CASH_METHOD', log, { requestId }) + } + + const items = (invoice.items ?? []) as SupplierInvoiceItem[] + + let journalEntry + try { + journalEntry = await createSupplierInvoiceRegistrationEntry( + supabase, + companyId!, + user.id, + invoice as SupplierInvoice, + items, + invoice.supplier?.supplier_type || 'company', + invoice.supplier?.name, + ) + } catch (err) { + if (isBookkeepingError(err)) { + return errorResponse(err, log, { requestId }) + } + log.error('deferred registration booking failed', err as Error, { invoiceId: id }) + return errorResponseFromCode('SI_BOOK_FAILED', log, { requestId }) + } + + // Returns null ONLY when no fiscal period covers invoice_date (other + // failures throw). Nothing was posted, so a plain error is safe. + if (!journalEntry) { + return errorResponseFromCode('SI_BOOK_NO_FISCAL_PERIOD', log, { + requestId, + details: { invoiceDate: invoice.invoice_date }, + }) + } + + // CAS-guarded link: only claim the invoice if it is still unbooked AND + // still in a bookable status. A concurrent book/mark-paid/credit that got + // there first would otherwise leave this entry double-posting 2440 + + // ingående moms (mark-paid moves to paid without touching the + // registration link), so cancel it. + const { data: linked, error: linkError } = await supabase + .from('supplier_invoices') + .update({ registration_journal_entry_id: journalEntry.id }) + .eq('id', id) + .eq('company_id', companyId) + .is('registration_journal_entry_id', null) + .in('status', BOOKABLE_STATUSES) + .select() + .single() + + if (linkError || !linked) { + await cancelOrphanedPaymentEntry( + supabase, + companyId!, + user.id, + journalEntry.id, + 'Bokföring av leverantörsfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.', + ) + return errorResponseFromCode('SI_BOOK_CONFLICT', log, { requestId }) + } + + // Periodiseringar ride on the registration entry, so they can only be + // created now. Non-blocking: the entry is committed (immutable); a + // schedule failure is surfaced as a warning and retried from the + // periodiseringar page. + const warnings: Array<{ code: string; message: string }> = [] + const hasAccrualItems = items.some((item) => item.accrual_period_start && item.accrual_period_end) + if (hasAccrualItems) { + try { + const scheduleResult = await createSchedulesForSupplierInvoice( + supabase, + companyId!, + user.id, + invoice as SupplierInvoice, + items, + journalEntry.id, + ) + if (scheduleResult.failed > 0) { + warnings.push({ + code: 'ACCRUAL_SCHEDULE_FAILED', + message: + 'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' + + 'skapas. Kontrollera under Bokföring → Periodiseringar.', + }) + } + } catch (err) { + log.error('accrual schedule creation failed on deferred booking', err as Error, { invoiceId: id }) + warnings.push({ + code: 'ACCRUAL_SCHEDULE_FAILED', + message: + 'Fakturan bokfördes, men periodiseringarna kunde inte skapas. ' + + 'Kontrollera under Bokföring → Periodiseringar.', + }) + } + } + + return NextResponse.json({ + data: linked, + journal_entry_id: journalEntry.id, + ...(warnings.length > 0 ? { warnings } : {}), + }) + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index 79c95d0d..ff6d66d8 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -227,6 +227,51 @@ describe('POST /api/supplier-invoices', () => { expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled() }) + it('registers WITHOUT booking when defer_invoice_booking is on (#967)', async () => { + const supplier = makeSupplier({ id: VALID_UUID }) + const createdInvoice = makeSupplierInvoice({ id: 'si-deferred' }) + + // Fetch supplier + enqueue({ data: supplier, error: null }) + // RPC get_next_arrival_number + enqueue({ data: 5 }) + // Insert invoice + enqueue({ data: createdInvoice, error: null }) + // Insert items + enqueue({ data: null, error: null }) + // Fetch company settings: accrual + deferred booking + enqueue({ data: { accounting_method: 'accrual', defer_invoice_booking: true }, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-002', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [ + { + description: 'Material', + quantity: 10, + unit_price: 800, + account_number: '4010', + vat_rate: 0.25, + }, + ], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { registration_journal_entry_id: string | null } + }>(response) + + expect(status).toBe(200) + expect(body.data).toBeTruthy() + // No registration verifikat: booking is a separate explicit step. + expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + expect(body.data.registration_journal_entry_id ?? null).toBeNull() + }) + it('stores an uploaded document and links it to the registration entry', async () => { const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-with-document', document_id: DOCUMENT_UUID }) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 3ec706e0..e332d6cc 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -7,6 +7,7 @@ import { import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices' import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions' import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' @@ -391,11 +392,14 @@ export const POST = withRouteContext( // invoked for these (status='paid' from the start). const { data: settings } = await supabase .from('company_settings') - .select('accounting_method') + .select('accounting_method, defer_invoice_booking') .eq('company_id', companyId) .single() - const accountingMethod = settings?.accounting_method || 'accrual' + // #967: deferred companies register WITHOUT booking; ekonomi books later + // via POST /api/supplier-invoices/[id]/book. The invoice then legitimately + // sits at registration_journal_entry_id = NULL, like the cash method. + const booksOnRegistration = booksInvoicesOnIssue(settings) let registrationJournalEntryId: string | null = null let paymentJournalEntryId: string | null = null @@ -460,7 +464,7 @@ export const POST = withRouteContext( }, }) } - } else if (accountingMethod === 'accrual') { + } else if (booksOnRegistration) { try { const journalEntry = await createSupplierInvoiceRegistrationEntry( supabase, diff --git a/components/settings/sections/BookkeepingSettingsContent.tsx b/components/settings/sections/BookkeepingSettingsContent.tsx index fbfc555f..4d20eb90 100644 --- a/components/settings/sections/BookkeepingSettingsContent.tsx +++ b/components/settings/sections/BookkeepingSettingsContent.tsx @@ -42,12 +42,18 @@ export function BookkeepingSettingsContent() { const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null const accountingMethod = (formData.get('accounting_method') as string) || 'accrual' const defaultVoucherSeries = (formData.get('default_voucher_series') as string) || 'A' + // Deferred booking is an accrual-only concept (#967): normalize to false + // under kontantmetoden so switching back to accrual can never re-activate + // a stale flag the user set in a mode where it had no effect. + const deferInvoiceBooking = + accountingMethod === 'accrual' && formData.get('defer_invoice_booking') === 'true' const updates: Record = { bookkeeping_locked_through: lockedThrough, auto_lock_period_days: autoLockValue === 'none' ? null : parseInt(autoLockValue), accounting_method: accountingMethod, default_voucher_series: defaultVoucherSeries, + defer_invoice_booking: deferInvoiceBooking, } // Write-through: the booking engine resolves the series from the @@ -110,6 +116,23 @@ export function BookkeepingSettingsContent() { {t('method_help')}

+ {/* #967: register/send without booking; ekonomi books in a separate + explicit step. Only meaningful under faktureringsmetoden. */} +
+ + +

+ {t('defer_booking_help')} +

+
{/* Default voucher series */} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 69357d18..6330e823 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1466,6 +1466,8 @@ export const UpdateSettingsSchema = z.object({ iban: z.string().regex(/^SE\d{22}$/, 'Ogiltigt IBAN (SE följt av 22 siffror)').nullable().optional().or(z.literal('')), bic: z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT (8 eller 11 tecken)').nullable().optional().or(z.literal('')), accounting_method: AccountingMethodSchema.optional(), + // #967: register/send invoices without booking; booking is a separate step. + defer_invoice_booking: z.boolean().optional(), invoice_prefix: z.string().nullable().optional(), next_invoice_number: z.number().int().positive().optional(), next_arrival_number: z.number().int().positive().optional(), diff --git a/lib/bookkeeping/__tests__/booking-mode.test.ts b/lib/bookkeeping/__tests__/booking-mode.test.ts new file mode 100644 index 00000000..0323d633 --- /dev/null +++ b/lib/bookkeeping/__tests__/booking-mode.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { booksInvoicesOnIssue } from '../booking-mode' + +describe('booksInvoicesOnIssue (#967)', () => { + it('books at issue for accrual companies by default', () => { + expect(booksInvoicesOnIssue({ accounting_method: 'accrual' })).toBe(true) + expect(booksInvoicesOnIssue({ accounting_method: 'accrual', defer_invoice_booking: false })).toBe(true) + }) + + it('defers when defer_invoice_booking is on', () => { + expect(booksInvoicesOnIssue({ accounting_method: 'accrual', defer_invoice_booking: true })).toBe(false) + }) + + it('never books at issue under the cash method, regardless of the flag', () => { + expect(booksInvoicesOnIssue({ accounting_method: 'cash' })).toBe(false) + expect(booksInvoicesOnIssue({ accounting_method: 'cash', defer_invoice_booking: true })).toBe(false) + }) + + it('treats missing settings as the historical accrual default', () => { + expect(booksInvoicesOnIssue(null)).toBe(true) + expect(booksInvoicesOnIssue(undefined)).toBe(true) + expect(booksInvoicesOnIssue({})).toBe(true) + }) +}) diff --git a/lib/bookkeeping/booking-mode.ts b/lib/bookkeeping/booking-mode.ts new file mode 100644 index 00000000..b6754ae1 --- /dev/null +++ b/lib/bookkeeping/booking-mode.ts @@ -0,0 +1,24 @@ +/** + * #967 "Registrera men bokför inte": whether issuing an invoice (registering + * a supplier invoice, sending a customer invoice) books it inline. + * + * Inline booking happens only under faktureringsmetoden (accrual) with + * defer_invoice_booking off. Kontantmetoden companies never book at issue + * (they book at payment), and deferred companies book via the explicit + * "Bokför" routes (POST /api/supplier-invoices/[id]/book, + * POST /api/invoices/[id]/book) instead. + * + * The payment flows need no gate of their own: both mark-paid paths already + * route on whether a live journal-entry link exists, so an invoice that is + * still unbooked when paid gets the full cash-style entry at payment. + */ +export function booksInvoicesOnIssue( + settings: + | { accounting_method?: string | null; defer_invoice_booking?: boolean | null } + | null + | undefined +): boolean { + // No settings row: match the historical default (accrual, book at issue). + if (!settings) return true + return (settings.accounting_method || 'accrual') === 'accrual' && !settings.defer_invoice_booking +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 5c6df1f5..fa215e0d 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -806,6 +806,41 @@ const INVOICE: Record = { message_sv: 'Verifikatet skapades, men kopplingen till fakturan måste återställas. Kontakta support.', message_en: 'The voucher was created, but its invoice link must be repaired. Contact support.', }, + INVOICE_BOOK_ALREADY_BOOKED: { + httpStatus: 400, + message_sv: 'Fakturan är redan bokförd.', + message_en: 'The invoice is already booked.', + }, + INVOICE_BOOK_INVALID_STATUS: { + httpStatus: 400, + message_sv: 'Endast skickade eller förfallna fakturor kan bokföras i efterhand.', + message_en: 'Only sent or overdue invoices can be booked afterwards.', + }, + INVOICE_BOOK_NOT_BOOKABLE: { + httpStatus: 400, + message_sv: 'Kreditfakturor och andra dokumenttyper bokförs inte via detta steg.', + message_en: 'Credit notes and other document types are not booked through this step.', + }, + INVOICE_BOOK_CASH_METHOD: { + httpStatus: 400, + message_sv: 'Vid kontantmetoden bokförs fakturan när den betalas.', + message_en: 'Under the cash method the invoice is booked when it is paid.', + }, + INVOICE_BOOK_NO_FISCAL_PERIOD: { + httpStatus: 400, + message_sv: 'Inget öppet räkenskapsår täcker fakturadatumet. Skapa räkenskapsåret först.', + message_en: 'No open fiscal period covers the invoice date. Create the fiscal year first.', + }, + INVOICE_BOOK_CONFLICT: { + httpStatus: 409, + message_sv: 'Fakturan bokfördes samtidigt av en annan begäran. Ladda om sidan.', + message_en: 'The invoice was booked concurrently by another request. Reload the page.', + }, + INVOICE_BOOK_FAILED: { + httpStatus: 500, + message_sv: 'Fakturan kunde inte bokföras.', + message_en: 'Failed to book the invoice.', + }, INVOICE_SEND_EMAIL_NOT_CONFIGURED: { httpStatus: 503, message_sv: @@ -984,6 +1019,41 @@ const SUPPLIER_INVOICE: Record = { message_sv: 'Kunde inte godkänna leverantörsfakturan.', message_en: 'Failed to update supplier invoice status to approved.', }, + SI_BOOK_ALREADY_BOOKED: { + httpStatus: 400, + message_sv: 'Leverantörsfakturan är redan bokförd.', + message_en: 'The supplier invoice is already booked.', + }, + SI_BOOK_INVALID_STATUS: { + httpStatus: 400, + message_sv: 'Endast registrerade, godkända eller förfallna fakturor kan bokföras i efterhand.', + message_en: 'Only registered, approved or overdue invoices can be booked afterwards.', + }, + SI_BOOK_NOT_BOOKABLE: { + httpStatus: 400, + message_sv: 'Kreditfakturor bokförs inte via detta steg.', + message_en: 'Credit notes are not booked through this step.', + }, + SI_BOOK_CASH_METHOD: { + httpStatus: 400, + message_sv: 'Vid kontantmetoden bokförs fakturan när den betalas.', + message_en: 'Under the cash method the invoice is booked when it is paid.', + }, + SI_BOOK_NO_FISCAL_PERIOD: { + httpStatus: 400, + message_sv: 'Inget öppet räkenskapsår täcker fakturadatumet. Skapa räkenskapsåret först.', + message_en: 'No open fiscal period covers the invoice date. Create the fiscal year first.', + }, + SI_BOOK_CONFLICT: { + httpStatus: 409, + message_sv: 'Leverantörsfakturan bokfördes samtidigt av en annan begäran. Ladda om sidan.', + message_en: 'The supplier invoice was booked concurrently by another request. Reload the page.', + }, + SI_BOOK_FAILED: { + httpStatus: 500, + message_sv: 'Leverantörsfakturan kunde inte bokföras.', + message_en: 'Failed to book the supplier invoice.', + }, PO_THREE_WAY_MATCH_FAILED: { httpStatus: 422, message_sv: diff --git a/messages/en.json b/messages/en.json index ac99753e..b8338da2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1357,6 +1357,10 @@ "method_accrual": "Faktureringsmetoden", "method_cash": "Kontantmetoden", "method_help": "The cash method may be used if annual net sales are normally at most 3 MSEK (BFL 5 kap. 2 §). Outstanding receivables and payables must be posted at year-end.", + "defer_booking_label": "Invoice bookkeeping", + "defer_booking_off": "Book immediately (default)", + "defer_booking_on": "Register first, book in a separate step", + "defer_booking_help": "Applies to the accrual method. With separate booking, one person can register supplier invoices and send customer invoices without posting them; finance books them afterwards with the Book button on the invoice.", "series_heading": "Default voucher series", "series_label": "Series", "series_help": "Which series is preselected for manual bookkeeping. Can be changed per voucher.", @@ -2767,6 +2771,13 @@ "your_reference_label": "Your reference", "our_reference_label": "Our reference", "bookkeeping_label": "Bookkeeping", + "not_booked_yet": "Not booked yet", + "book_action": "Book", + "booked_title": "Invoice booked", + "booked_description": "The verifikat has been created.", + "booked_with_warnings_description": "The invoice was booked, but one or more accrual schedules could not be created. Check under Bookkeeping, Periodiseringar.", + "book_failed_title": "Booking failed", + "book_failed_fallback": "The invoice could not be booked. Try again.", "view_voucher": "View verifikat", "correction_loading": "Loading…", "correction_prompt": "Something wrong? Create a correction verifikat", @@ -3336,6 +3347,12 @@ "vouchers_title": "Verifikat (sambandskrav)", "registration_voucher": "Registration verifikat", "no_registration_voucher": "No registration verifikat (cash method)", + "not_booked_yet": "Not booked yet", + "book_action": "Book", + "book_failed_title": "Booking failed", + "booked_title": "Invoice booked", + "booked_description": "The registration verifikat has been created.", + "booked_with_warnings_description": "The invoice was booked, but one or more accrual schedules could not be created. Check under Bookkeeping, Periodiseringar.", "payment_voucher": "Payment verifikat", "document_title": "Invoice document", "document_attached": "The supplier invoice is attached and archived.", diff --git a/messages/sv.json b/messages/sv.json index 06f0d579..63b003fb 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1357,6 +1357,10 @@ "method_accrual": "Faktureringsmetoden", "method_cash": "Kontantmetoden", "method_help": "Kontantmetoden får användas om årlig nettoomsättning normalt är högst 3 MSEK (BFL 5 kap. 2 §). Obetalda fordringar och skulder ska bokföras vid räkenskapsårets utgång.", + "defer_booking_label": "Bokföring av fakturor", + "defer_booking_off": "Bokför direkt (standard)", + "defer_booking_on": "Registrera först, bokför i ett separat steg", + "defer_booking_help": "Gäller faktureringsmetoden. Med separat bokföring kan en person registrera leverantörsfakturor och skicka kundfakturor utan att de bokförs; ekonomi bokför dem i efterhand med knappen Bokför på fakturan.", "series_heading": "Standardserie för verifikationer", "series_label": "Serie", "series_help": "Vilken serie som förväljs vid manuell bokföring. Kan ändras per verifikation.", @@ -2767,6 +2771,13 @@ "your_reference_label": "Er referens", "our_reference_label": "Vår referens", "bookkeeping_label": "Bokföring", + "not_booked_yet": "Ej bokförd ännu", + "book_action": "Bokför", + "booked_title": "Fakturan bokförd", + "booked_description": "Verifikationen har skapats.", + "booked_with_warnings_description": "Fakturan bokfördes, men en eller flera periodiseringar kunde inte skapas. Kontrollera under Bokföring, Periodiseringar.", + "book_failed_title": "Kunde inte bokföra", + "book_failed_fallback": "Fakturan kunde inte bokföras. Försök igen.", "view_voucher": "Visa verifikation", "correction_loading": "Hämtar…", "correction_prompt": "Något fel? Skapa ändringsverifikation", @@ -3336,6 +3347,12 @@ "vouchers_title": "Verifikationer (sambandskrav)", "registration_voucher": "Registreringsverifikation", "no_registration_voucher": "Ingen registreringsverifikation (kontantmetoden)", + "not_booked_yet": "Ej bokförd ännu", + "book_action": "Bokför", + "book_failed_title": "Kunde inte bokföra", + "booked_title": "Fakturan bokförd", + "booked_description": "Registreringsverifikationen har skapats.", + "booked_with_warnings_description": "Fakturan bokfördes, men en eller flera periodiseringar kunde inte skapas. Kontrollera under Bokföring, Periodiseringar.", "payment_voucher": "Betalningsverifikation", "document_title": "Fakturaunderlag", "document_attached": "Leverantörens faktura är bifogad och arkiverad.", diff --git a/supabase/migrations/20260716150000_company_settings_defer_invoice_booking.sql b/supabase/migrations/20260716150000_company_settings_defer_invoice_booking.sql new file mode 100644 index 00000000..feeba56e --- /dev/null +++ b/supabase/migrations/20260716150000_company_settings_defer_invoice_booking.sql @@ -0,0 +1,20 @@ +-- Issue #967 "Registrera men bokför inte": let companies split registering +-- invoices from booking them. Many companies have one person who creates the +-- customer invoice / registers the supplier invoice while ekonomi books it +-- with the correct kontering afterwards. +-- +-- When defer_invoice_booking is true AND the company uses faktureringsmetoden +-- (accrual), registering a supplier invoice or sending a customer invoice no +-- longer creates the journal entry inline; a separate explicit "Bokför" +-- action (POST /api/supplier-invoices/[id]/book, /api/invoices/[id]/book) +-- posts it later. Kontantmetoden companies already defer booking to payment, +-- so the flag is a no-op for them. Default false keeps every existing +-- company on the book-immediately behavior. + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS defer_invoice_booking boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN public.company_settings.defer_invoice_booking IS + 'When true (faktureringsmetoden only): registering supplier invoices / sending customer invoices does not book them; booking is a separate explicit step (#967).'; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 46d8da9b..511d95df 100644 --- a/types/index.ts +++ b/types/index.ts @@ -254,6 +254,9 @@ export interface CompanySettings { // Accounting method accounting_method: AccountingMethod + // #967: when true (accrual only), registering supplier invoices / sending + // customer invoices does NOT book them; booking is a separate explicit step. + defer_invoice_booking?: boolean // Invoice settings invoice_prefix: string | null