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:
co-authored by
Claude Fable 5
parent
14f7478abb
commit
1443235cec
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user