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

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

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

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

Fixes #967

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

* fix(invoices): harden deferred booking after review

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-16 18:15:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 14f7478abb
commit 1443235cec
19 changed files with 1091 additions and 9 deletions
+70
View File
@@ -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: