fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts (#2439)

* fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts

A supplier-invoice item with a negative line_total (an öresavrundning row on
3740, a rabatt row) was copied straight into debit_amount, producing a line
like "3740 debit -0.25". The entry balances arithmetically, so no trigger
fired, but the verifikat page renders only positive amounts: the row showed
empty and the visible debits (20 056,25) disagreed with the summa (20 056,00).
Prod holds 14 such lines: 12 supplier registrations in 3 companies, 1
customer invoice (3004 credit -0.50), 1 storno mirroring a bad original.

Why it occurred: the "one non-negative side per line" invariant lived
nowhere. Zod allows negative items (they are legitimate), the engine only
checked balance, and journal_entry_lines had no CHECK. Any producer that
aggregates user rows could repeat it.

What was removed or simplified: no new state. The privately-paid supplier
path already flipped negative buckets to credit; that rule is now one
helper (lib/bookkeeping/line-side.ts) shared by the supplier registration,
cash-method and privately-paid generators and by the customer-invoice
per-rate generator. The credit-note generator stops swapping sides and
takes |net|, since its inputs now arrive on the correct side.

Why this and not the proposed fix: patching only the supplier generator
leaves MCP, templates and future producers free to repeat the class, and
rejecting negative items at input would break real rabatt/avrundning rows.
So the sign is fixed at three levels: producers flip the side, the engine
refuses negative amounts before any write (JOURNAL_LINE_NEGATIVE_AMOUNT,
Swedish message), and a NOT VALID CHECK on journal_entry_lines rejects new
rows regardless of the writer. reverseEntry swaps on the net so a legacy
negative line stornos into a well-formed line before the data repair runs.

The 14 existing prod lines are repaired by a separate founder-approved SQL
(flip to the opposite column, net unchanged); VALIDATE CONSTRAINT follows
in a later migration once prod reports zero offending rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMJvTFitzKQYuv7ABUVFj9

* fix(bookkeeping): anchor foreign-currency 1510/1930 on the net of the revenue lines; flip salary buckets by side

Skeptic findings on ab119d6ed:

1. A non-SEK customer invoice with a negative row (rabatt, avrundning on a
   separate revenue account) now lands that row on the debit side, but the
   1510 (accrual) and 1930 (kontantmetod) anchors summed only credit_amount,
   so the entry was overstated by the row and threw "Verifikationen
   balanserar inte". Both anchors now use credit - debit. EUR test added for
   both paths.

2. Salary: arbetsgivaravgifter, semesteravsättning, pension and SLP buckets
   copied bucket.amount into debit_amount and the aggregated liability into
   credit_amount. A negative month (unpaid leave beyond gross) produced
   7510 D -628,40, which the engine now refuses. Buckets and liabilities go
   through debitNatural/creditNatural so a negative month books 7510 K /
   2731 D. Test added.

3. replaceOpeningBalanceEntry, the third engine write path, now runs the
   same non-negative guard as createDraftEntry and updateDraftEntry.

4. The credit-note comment claimed |net| is side-correct for every original;
   it is not for originals with a negative row (pre-existing, callers negate
   items with -Math.abs). Comment now states the actual behaviour and the
   known gap.

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

* fix(bookkeeping): map JOURNAL_LINE_NEGATIVE_AMOUNT to a structured 400; supplier anchors flip side when the invoice nets below zero

CodeRabbit on #2439:

- JournalLineNegativeAmountError was not registered in isBookkeepingError /
  bookkeepingErrorResponse, so the journal-entry routes would have returned
  a generic 500 instead of the structured 400 with code and details. Added,
  with a test.

- The three supplier balance anchors (2440 on registration, the payment
  account under kontantmetoden, the liability account for privately paid
  invoices) were fixed-credit lines. An invoice whose rows net below zero
  (a leverantörskreditfaktura keyed in as an invoice) produced a negative
  credit there, which the engine now refuses. The anchors go through
  creditNatural so such an invoice books 2440 D, as a supplier credit note
  would. Tests for all three paths.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-08 18:10:34 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent c091a7f28e
commit 2d49a81508
17 changed files with 628 additions and 50 deletions
@@ -236,3 +236,51 @@ describe('engine.pg: triggers & RPCs that mocks cannot catch', () => {
expect(period.rows[0]!.opening_balances_set).toBe(false)
})
})
describe('engine.pg: journal_entry_lines_amounts_non_negative', () => {
it('rejects a line with a negative debit_amount', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await expect(
getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '3740', -0.25, 0)`,
[entryId],
),
).rejects.toThrow(/journal_entry_lines_amounts_non_negative/)
})
it('rejects a line with a negative credit_amount', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await expect(
getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '3004', 0, -0.5)`,
[entryId],
),
).rejects.toThrow(/journal_entry_lines_amounts_non_negative/)
})
it('accepts the same amounts on the opposite side', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '3740', 0, 0.25),
($1, '6110', 0.25, 0)`,
[entryId],
)
const rows = await getPool().query<{ n: string }>(
`SELECT count(*)::text AS n FROM public.journal_entry_lines WHERE journal_entry_id = $1`,
[entryId],
)
expect(rows.rows[0]!.n).toBe('2')
})
})
+143 -1
View File
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine'
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry, assertLinesNonNegative } from '../engine'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotReverseStornoError,
JournalLineNegativeAmountError,
getUnusedVoucherAllocation,
} from '../errors'
import type { CreateJournalEntryLineInput, JournalEntryStatus } from '@/types'
@@ -99,6 +100,147 @@ describe('validateBalance', () => {
})
})
describe('assertLinesNonNegative', () => {
it('accepts one non-negative side per line', () => {
expect(() =>
assertLinesNonNegative([
{ account_number: '6110', debit_amount: 16000, credit_amount: 0 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
{ account_number: '2440', debit_amount: 0, credit_amount: 15999.75 },
])
).not.toThrow()
})
it('rejects a negative debit even though the entry balances arithmetically', () => {
const lines: CreateJournalEntryLineInput[] = [
{ account_number: '6110', debit_amount: 16000, credit_amount: 0 },
{ account_number: '3740', debit_amount: -0.25, credit_amount: 0 },
{ account_number: '2440', debit_amount: 0, credit_amount: 15999.75 },
]
expect(validateBalance(lines).valid).toBe(true)
expect(() => assertLinesNonNegative(lines)).toThrow(JournalLineNegativeAmountError)
try {
assertLinesNonNegative(lines)
} catch (err) {
const e = err as JournalLineNegativeAmountError
expect(e.code).toBe('JOURNAL_LINE_NEGATIVE_AMOUNT')
expect(e.accountNumber).toBe('3740')
expect(e.debitAmount).toBe(-0.25)
}
})
it('rejects a negative credit', () => {
expect(() =>
assertLinesNonNegative([
{ account_number: '1510', debit_amount: 999.5, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
{ account_number: '3740', debit_amount: 0, credit_amount: -0.5 },
])
).toThrow(JournalLineNegativeAmountError)
})
})
describe('createDraftEntry: refuses negative-side lines before any write', () => {
it('throws JournalLineNegativeAmountError and never touches the database', async () => {
const from = vi.fn()
await expect(
createDraftEntry({ from } as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2024-01-01',
description: 'Leverantörsfaktura 374',
source_type: 'supplier_invoice_registered',
lines: [
{ account_number: '6110', debit_amount: 16000, credit_amount: 0 },
{ account_number: '3740', debit_amount: -0.25, credit_amount: 0 },
{ account_number: '2440', debit_amount: 0, credit_amount: 15999.75 },
],
})
).rejects.toThrow(JournalLineNegativeAmountError)
expect(from).not.toHaveBeenCalled()
})
})
describe('reverseEntry: legacy negative-side lines reverse on their net', () => {
it('a debit -0.25 original becomes debit 0.25 on the storno, never credit -0.25', async () => {
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 42,
entry_date: '2026-06-06',
description: 'Leverantörsfaktura 374',
source_type: 'supplier_invoice_registered',
source_id: null,
lines: [
{ account_number: '6110', debit_amount: 16000, credit_amount: 0 },
{ account_number: '3740', debit_amount: -0.25, credit_amount: 0 },
{ account_number: '2440', debit_amount: 0, credit_amount: 15999.75 },
],
}
const reversal = { id: 'reversal-1', reverses_id: 'entry-1' }
let jeCall = 0
const jeResults = [
{ data: original, error: null },
{ data: reversal, error: null },
{ data: null, error: null },
{ data: [{ id: 'entry-1' }], error: null },
{ data: { ...reversal, lines: [] }, error: null },
]
function jeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'update', 'insert']) b[m] = vi.fn().mockReturnValue(b)
b.single = vi.fn().mockImplementation(async () => jeResults[jeCall++])
b.then = (resolve: (v: unknown) => void) => resolve(jeResults[jeCall++])
return b
}
let insertedLines: Array<{ account_number: string; debit_amount: number; credit_amount: number }> = []
const supabase = {
rpc: vi.fn().mockResolvedValue({ data: 43, error: null }),
from: vi.fn().mockImplementation((table: string) => {
if (table === 'journal_entries') return jeBuilder()
if (table === 'chart_of_accounts') {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) b[m] = vi.fn().mockReturnValue(b)
b.then = (resolve: (v: unknown) => void) =>
resolve({
data: [
{ id: 'acc-6110', account_number: '6110' },
{ id: 'acc-3740', account_number: '3740' },
{ id: 'acc-2440', account_number: '2440' },
],
error: null,
})
return b
}
if (table === 'journal_entry_lines') {
return {
insert: vi.fn().mockImplementation(async (rows: typeof insertedLines) => {
insertedLines = rows
return { error: null }
}),
}
}
return createMockChain()
}),
}
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
const byAccount = Object.fromEntries(insertedLines.map((l) => [l.account_number, l]))
expect(byAccount['6110']).toMatchObject({ debit_amount: 0, credit_amount: 16000 })
expect(byAccount['3740']).toMatchObject({ debit_amount: 0.25, credit_amount: 0 })
expect(byAccount['2440']).toMatchObject({ debit_amount: 15999.75, credit_amount: 0 })
for (const l of insertedLines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
})
})
describe('getSwedishLocalDate', () => {
it('returns a date string in YYYY-MM-DD format', () => {
const date = getSwedishLocalDate()
+15
View File
@@ -12,6 +12,7 @@ import {
InvalidMappingResultError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
JournalLineNegativeAmountError,
accountsNotInChartResponse,
bookkeepingErrorResponse,
isAccountsNotInChartError,
@@ -187,6 +188,20 @@ describe('isAccountsNotInChartError', () => {
})
})
describe('JournalLineNegativeAmountError', () => {
it('carries the offending line and maps to a structured 400', async () => {
const err = new JournalLineNegativeAmountError('3740', -0.25, 0)
expect(err.code).toBe('JOURNAL_LINE_NEGATIVE_AMOUNT')
expect(err.accountNumber).toBe('3740')
expect(isBookkeepingError(err)).toBe(true)
const res = bookkeepingErrorResponse(err)
expect(res?.status).toBe(400)
const body = await res!.json()
expect(body.error.code).toBe('JOURNAL_LINE_NEGATIVE_AMOUNT')
expect(body.error.details).toEqual({ accountNumber: '3740', debitAmount: -0.25, creditAmount: 0 })
})
})
describe('isBookkeepingError', () => {
it('returns true for all typed bookkeeping errors', () => {
expect(isBookkeepingError(new AccountsNotInChartError(['1930']))).toBe(true)
@@ -153,6 +153,85 @@ describe('getOutputVatAccount', () => {
})
})
describe('createInvoiceJournalEntry: negative item rows keep one non-negative side', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('books a negative 0 % row (avrundning on 3740) as a debit, not a negative credit', async () => {
const invoice = makeInvoice({
subtotal: 999.5,
vat_amount: 250,
total: 1249.5,
vat_treatment: 'standard_25',
items: [
makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 }),
makeItem({ id: 'item-2', description: 'Öresavrundning', unit_price: -0.5, line_total: -0.5, vat_rate: 0, vat_amount: 0, revenue_account: '3740' }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice, 'aktiebolag')
const input = mockedCreateEntry.mock.calls[0][3]
const rounding = input.lines.find((l) => l.account_number === '3740')
expect(rounding?.debit_amount).toBe(0.5)
expect(rounding?.credit_amount).toBe(0)
expect(input.lines.find((l) => l.account_number === '1510')?.debit_amount).toBe(1249.5)
for (const l of input.lines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
const totalDebit = input.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = input.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(roundOre(totalDebit)).toBe(roundOre(totalCredit))
})
})
describe('foreign-currency invoices with a negative row derive the debit from the NET of the credit lines', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// EUR 1000 @25 % plus a -10 rabatt row on 3740, rate 10: the receivable is
// the NET of the credit lines (12 400), not the sum of the positive ones.
const eurInvoice = () =>
makeInvoice({
currency: 'EUR',
exchange_rate: 10,
subtotal: 990,
vat_amount: 250,
total: 1240,
vat_treatment: 'standard_25',
items: [
makeItem({ line_total: 1000, vat_rate: 25, vat_amount: 250 }),
makeItem({ id: 'item-2', description: 'Rabatt', unit_price: -10, line_total: -10, vat_rate: 0, vat_amount: 0, revenue_account: '3740' }),
],
})
function expectNetBalanced(lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>, arAccount: string) {
const rabatt = lines.find((l) => l.account_number === '3740')
expect(rabatt).toMatchObject({ debit_amount: 100, credit_amount: 0 })
expect(lines.find((l) => l.account_number === arAccount)?.debit_amount).toBe(12400)
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = lines.reduce((s, l) => s + l.credit_amount, 0)
expect(roundOre(totalDebit)).toBe(roundOre(totalCredit))
for (const l of lines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
}
it('createInvoiceJournalEntry: 1510 = 12 400, entry balances', async () => {
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', eurInvoice(), 'aktiebolag')
expectNetBalanced(mockedCreateEntry.mock.calls[0][3].lines, '1510')
})
it('createInvoiceCashEntry: 1930 = 12 400, entry balances', async () => {
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', eurInvoice(), '2024-07-01', 'aktiebolag')
expectNetBalanced(mockedCreateEntry.mock.calls[0][3].lines, '1930')
})
})
describe('createInvoiceJournalEntry: per-line VAT', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { creditNatural, debitNatural } from '../line-side'
describe('line-side: signed amount to one-sided line amounts', () => {
it('debitNatural books a positive amount as debit', () => {
expect(debitNatural(16000)).toEqual({ debit_amount: 16000, credit_amount: 0 })
})
it('debitNatural flips a negative amount to the credit side (öresavrundning on 3740)', () => {
expect(debitNatural(-0.25)).toEqual({ debit_amount: 0, credit_amount: 0.25 })
})
it('creditNatural books a positive amount as credit', () => {
expect(creditNatural(1000)).toEqual({ debit_amount: 0, credit_amount: 1000 })
})
it('creditNatural flips a negative amount to the debit side', () => {
expect(creditNatural(-0.5)).toEqual({ debit_amount: 0.5, credit_amount: 0 })
})
it('rounds to öre on both sides and keeps zero as 0/0', () => {
expect(debitNatural(10.005)).toEqual({ debit_amount: 10.01, credit_amount: 0 })
expect(creditNatural(-10.004)).toEqual({ debit_amount: 10, credit_amount: 0 })
expect(debitNatural(0)).toEqual({ debit_amount: 0, credit_amount: 0 })
})
it('never returns a negative side', () => {
for (const n of [-1234.56, -0.01, 0, 0.01, 1234.56]) {
for (const fn of [debitNatural, creditNatural]) {
const r = fn(n)
expect(r.debit_amount).toBeGreaterThanOrEqual(0)
expect(r.credit_amount).toBeGreaterThanOrEqual(0)
const net = Math.round((r.debit_amount - r.credit_amount) * 100) / 100
const expected = Math.round(n * 100) / 100
expect(net + 0).toBe((fn === debitNatural ? expected : -expected) + 0)
}
}
})
})
@@ -1900,6 +1900,97 @@ describe('buildSupplierInvoicePrivatelyPaidLines', () => {
})
})
describe('negative item rows (öresavrundning, rabatt) keep one non-negative side', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
// Prod case: 16 000 + 45 at 25 % = 20 056,25; the supplier bills 20 056,00
// and the user adds a -0,25 avrundning row on 3740. The old generator
// booked 3740 as debit -0,25, which the verifikat page hid.
const roundingItems = [
makeItem({ id: 'i1', line_total: 16000, account_number: '6110', vat_rate: 0.25, vat_amount: 4000 }),
makeItem({ id: 'i2', line_total: 45, account_number: '3540', vat_rate: 0.25, vat_amount: 11.25 }),
makeItem({ id: 'i3', line_total: -0.25, account_number: '3740', vat_rate: 0, vat_amount: 0 }),
]
it('registration entry books the -0,25 row as credit 0,25 on 3740 and 2440 at the billed total', async () => {
const invoice = makeSupplierInvoice({ subtotal: 16044.75, vat_amount: 4011.25, total: 20056 })
await createSupplierInvoiceRegistrationEntry(null as never, 'company-1', 'user-1', invoice, roundingItems, 'swedish_business')
const input = mockedCreateEntry.mock.calls[0][3]
const rounding = findByAccount(input.lines, '3740')
expect(rounding).toHaveLength(1)
expect(rounding[0].debit_amount).toBe(0)
expect(rounding[0].credit_amount).toBe(0.25)
expect(findByAccount(input.lines, '2641')[0].debit_amount).toBe(4011.25)
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(20056)
for (const l of input.lines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
assertBalanced(input)
})
it('cash-method entry books the -0,25 row as a credit and 1930 at the billed total', async () => {
const invoice = makeSupplierInvoice({ subtotal: 16044.75, vat_amount: 4011.25, total: 20056 })
await createSupplierInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, roundingItems, '2026-06-10', 'swedish_business')
const input = mockedCreateEntry.mock.calls[0][3]
const rounding = findByAccount(input.lines, '3740')
expect(rounding[0].debit_amount).toBe(0)
expect(rounding[0].credit_amount).toBe(0.25)
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(20056)
for (const l of input.lines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
assertBalanced(input)
})
})
describe('a supplier invoice that nets below zero books its anchor on the debit side', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
const items = [
makeItem({ id: 'i1', line_total: 100, account_number: '6110', vat_rate: 0, vat_amount: 0 }),
makeItem({ id: 'i2', line_total: -400, account_number: '6110', vat_rate: 0, vat_amount: 0 }),
]
it('registration: 6110 K 300 / 2440 D 300', async () => {
const invoice = makeSupplierInvoice({ subtotal: -300, vat_amount: 0, total: -300 })
await createSupplierInvoiceRegistrationEntry(null as never, 'company-1', 'user-1', invoice, items, 'swedish_business')
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6110')[0]).toMatchObject({ debit_amount: 0, credit_amount: 300 })
expect(findByAccount(input.lines, '2440')[0]).toMatchObject({ debit_amount: 300, credit_amount: 0 })
assertBalanced(input)
})
it('cash method: 1930 D 300', async () => {
const invoice = makeSupplierInvoice({ subtotal: -300, vat_amount: 0, total: -300 })
await createSupplierInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, items, '2026-06-10', 'swedish_business')
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '1930')[0]).toMatchObject({ debit_amount: 300, credit_amount: 0 })
assertBalanced(input)
})
it('privately paid: liability D 300', () => {
const invoice = makeSupplierInvoice({ subtotal: -300, vat_amount: 0, total: -300 })
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', 'Utlägg')
const liability = lines.filter((l) => l.account_number === '2893')
expect(liability[0]).toMatchObject({ debit_amount: 300, credit_amount: 0 })
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round(totalDebit * 100)).toBe(Math.round(totalCredit * 100))
})
})
describe('largestExpenseAccount', () => {
it('picks the account of the largest line by magnitude, first line on a tie', () => {
const items = [
+28 -5
View File
@@ -13,6 +13,7 @@ import {
FiscalPeriodNotFoundError,
withUnusedVoucherAllocation,
JournalEntryNotBalancedError,
JournalLineNegativeAmountError,
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
import {
@@ -32,6 +33,7 @@ import {
isDimensionValidationExemptSource,
} from '@/lib/bookkeeping/dimension-rules'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { creditNatural } from '@/lib/bookkeeping/line-side'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import { syncRotRutReclaimAfterReversal } from '@/lib/invoices/rot-rut-reclaim-reversal'
@@ -71,6 +73,22 @@ export function validateBalance(lines: CreateJournalEntryLineInput[]): {
}
}
/**
* Refuse any line whose debit_amount or credit_amount is below zero. The
* balance check cannot catch this (a negative debit nets like a credit), so
* it is the engine's job to keep the one-non-negative-side invariant that the
* DB CHECK `journal_entry_lines_amounts_non_negative` mirrors.
*/
export function assertLinesNonNegative(lines: CreateJournalEntryLineInput[]): void {
for (const line of lines) {
const debit = line.debit_amount || 0
const credit = line.credit_amount || 0
if (debit < 0 || credit < 0) {
throw new JournalLineNegativeAmountError(line.account_number, debit, credit)
}
}
}
/**
* Get the next voucher number for a company/period/series
* Uses the concurrent-safe INSERT ON CONFLICT implementation in the database
@@ -248,7 +266,8 @@ export async function createDraftEntry(
userId: string,
input: CreateJournalEntryInput
): Promise<JournalEntry> {
// Validate balance
// Validate sides and balance
assertLinesNonNegative(input.lines)
const balance = validateBalance(input.lines)
if (!balance.valid) {
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
@@ -448,7 +467,8 @@ export async function updateDraftEntry(
throw new CannotEditNonDraftError(existing.status as string)
}
// Same balance gate as createDraftEntry.
// Same side and balance gates as createDraftEntry.
assertLinesNonNegative(input.lines)
const balance = validateBalance(input.lines)
if (!balance.valid) {
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
@@ -884,6 +904,7 @@ export async function replaceOpeningBalanceEntry(
)
}
assertLinesNonNegative(input.lines)
const balance = validateBalance(input.lines)
if (!balance.valid) {
throw new JournalEntryNotBalancedError(
@@ -1100,11 +1121,13 @@ export async function reverseEntry(
const lines = (original.lines as JournalEntryLine[]) || []
// Create reversed lines (swap debit and credit, preserve dimensions)
// Create reversed lines (swap debit and credit, preserve dimensions). The
// swap runs on the line's NET so a legacy negative-side line (debit -0.25,
// booked before the non-negative CHECK) reverses to a well-formed positive
// line on the opposite side instead of copying the bad sign into the storno.
const reversedLines: CreateJournalEntryLineInput[] = lines.map((line) => ({
account_number: line.account_number,
debit_amount: line.credit_amount,
credit_amount: line.debit_amount,
...creditNatural((line.debit_amount || 0) - (line.credit_amount || 0)),
line_description: `Reversal: ${line.line_description || ''}`,
currency: line.currency,
amount_in_currency: line.amount_in_currency
+40
View File
@@ -18,6 +18,7 @@ export { DimensionValidationError, MandatoryDimensionMissingError } from './dime
export const ACCOUNTS_NOT_IN_CHART = 'ACCOUNTS_NOT_IN_CHART' as const
export const JOURNAL_ENTRY_NOT_BALANCED = 'JOURNAL_ENTRY_NOT_BALANCED' as const
export const JOURNAL_LINE_NEGATIVE_AMOUNT = 'JOURNAL_LINE_NEGATIVE_AMOUNT' as const
export const FISCAL_PERIOD_NOT_FOUND = 'FISCAL_PERIOD_NOT_FOUND' as const
export const ENTRY_DATE_OUTSIDE_FISCAL_PERIOD = 'ENTRY_DATE_OUTSIDE_FISCAL_PERIOD' as const
export const JOURNAL_ENTRY_NOT_FOUND = 'JOURNAL_ENTRY_NOT_FOUND' as const
@@ -94,6 +95,27 @@ export class JournalEntryNotBalancedError extends Error {
}
}
/**
* A line arrived with a negative debit_amount or credit_amount. Such a line
* balances arithmetically (debit -0.25 nets like credit 0.25), so the balance
* trigger never fires, but every reader assumes one non-negative side per
* line: the verifikat page hides it, sums disagree with the visible rows.
* Producers must flip the side instead (lib/bookkeeping/line-side.ts).
*/
export class JournalLineNegativeAmountError extends Error {
readonly code = JOURNAL_LINE_NEGATIVE_AMOUNT
constructor(
public readonly accountNumber: string,
public readonly debitAmount: number,
public readonly creditAmount: number
) {
super(
`Journal line on ${accountNumber} has a negative amount (debit ${debitAmount}, credit ${creditAmount}); book it on the opposite side instead`
)
this.name = 'JournalLineNegativeAmountError'
}
}
export class FiscalPeriodNotFoundError extends Error {
readonly code = FISCAL_PERIOD_NOT_FOUND
constructor() {
@@ -374,6 +396,7 @@ export function isBookkeepingError(err: unknown): boolean {
return (
err instanceof AccountsNotInChartError ||
err instanceof JournalEntryNotBalancedError ||
err instanceof JournalLineNegativeAmountError ||
err instanceof FiscalPeriodNotFoundError ||
err instanceof EntryDateOutsideFiscalPeriodError ||
err instanceof JournalEntryNotFoundError ||
@@ -453,6 +476,23 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
if (err instanceof JournalLineNegativeAmountError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: {
accountNumber: err.accountNumber,
debitAmount: err.debitAmount,
creditAmount: err.creditAmount,
},
},
},
{ status: 400 }
)
}
if (err instanceof FiscalPeriodNotFoundError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },
+22 -10
View File
@@ -12,6 +12,7 @@ import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { creditNatural } from './line-side'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
@@ -263,10 +264,12 @@ function generatePerRateLines(
? Math.round((rateSubtotalSek - allocated) * 100) / 100
: Math.round(toSek(bucket.subtotal) * 100) / 100
allocated = Math.round((allocated + credit) * 100) / 100
// A bucket that nets below zero (rabatt row, negative correction row)
// books as a debit of the absolute value: every line carries exactly
// one non-negative side (creditNatural), never a negative credit.
lines.push({
account_number: bucket.account,
debit_amount: 0,
credit_amount: credit,
...creditNatural(credit),
line_description: `Försäljning faktura ${invoiceTagText}`,
dimensions: bucket.dimensions,
})
@@ -277,8 +280,7 @@ function generatePerRateLines(
const vatAccount = getOutputVatAccount(treatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: roundedVat,
...creditNatural(roundedVat),
line_description: `Utgående moms ${rate}% faktura ${invoiceTagText}`,
dimensions: options?.defaultDimensions,
})
@@ -483,9 +485,11 @@ export async function createInvoiceJournalEntry(
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate, defaultDimensions)
: { lines: [], totalSek: 0 }
// Debit: Kundfordringar, balance guarantee: debit = sum of all credit
// lines MINUS the ROT/RUT total which goes to 1513 instead.
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
// Debit: Kundfordringar, balance guarantee: debit = NET of all revenue/VAT
// lines (a negative rabatt/avrundning row sits on the debit side after
// creditNatural, so it must subtract) MINUS the ROT/RUT total which goes
// to 1513 instead.
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount - l.debit_amount, 0)
const debitAmount = isForeign
? Math.round(totalCredits * 100) / 100
: headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
@@ -738,11 +742,18 @@ export async function createCreditNoteJournalEntry(
// cancelled/stornoed by the credit flow.
{ deferAccruals: true, defaultDimensions }
)
// Every caller hands us items negated with -Math.abs (build-credit-note-
// item.ts), so generatePerRateLines lands every line on the debit side
// via creditNatural. Taking |net| as the debit reproduces the pre-existing
// output exactly (the old column swap would now flip them back). Known
// gap, unchanged by this commit: an original with a NEGATIVE row (rabatt,
// avrundning) is credited on the same side as the original instead of
// the opposite one, because -Math.abs erases the row's sign upstream.
for (const line of creditLines) {
debitLines.push({
...line,
debit_amount: Math.abs(line.credit_amount),
credit_amount: Math.abs(line.debit_amount),
debit_amount: Math.round(Math.abs(line.credit_amount - line.debit_amount) * 100) / 100,
credit_amount: 0,
line_description: `Kreditfaktura ${tag}${lineSuffix}`,
})
}
@@ -893,7 +904,8 @@ export async function createInvoiceCashEntry(
// Debit: Företagskonto, balance guarantee: debit = sum of credit lines
// minus the ROT/RUT total which goes to 1513 instead.
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
// NET of the revenue/VAT lines: a negative row sits on the debit side.
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount - l.debit_amount, 0)
const cashDebit = isForeign
? Math.round(totalCredits * 100) / 100
: headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
+42
View File
@@ -0,0 +1,42 @@
/**
* Signed amount → one-sided journal line amounts.
*
* A journal line carries exactly one side: `debit_amount` OR `credit_amount`,
* both non-negative (enforced by `journal_entry_lines_amounts_non_negative`
* and by the engine before any write). Producers that aggregate user rows
* (supplier-invoice items, invoice items) can legitimately net below zero: a
* rabatt row, an öresavrundning row on 3740, a negative correction row. That
* sign must flip the SIDE of the line, never the sign of the amount: a
* `debit_amount: -0.25` balances arithmetically, so no trigger fires, but
* every reader (verifikat page, SIE export, kontoutdrag) hides or misreads it
* (issue: "Kto 3740 visar noll, och Debit/Kredit summerar inte").
*/
export interface LineSides {
debit_amount: number
credit_amount: number
}
function roundOre(n: number): number {
return Math.round(n * 100) / 100
}
/**
* Natural-debit amount (expense, asset, receivable): positive books as debit,
* negative books as credit of the absolute value.
*/
export function debitNatural(amount: number): LineSides {
const rounded = roundOre(amount)
if (rounded < 0) return { debit_amount: 0, credit_amount: -rounded }
return { debit_amount: rounded, credit_amount: 0 }
}
/**
* Natural-credit amount (revenue, liability, output VAT): positive books as
* credit, negative books as debit of the absolute value.
*/
export function creditNatural(amount: number): LineSides {
const rounded = roundOre(amount)
if (rounded < 0) return { debit_amount: -rounded, credit_amount: 0 }
return { debit_amount: 0, credit_amount: rounded }
}
+14 -14
View File
@@ -17,6 +17,7 @@ import {
} from './dimension-resolver'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { creditNatural, debitNatural } from './line-side'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExpenseClaimLineInput } from '@/lib/expenses/expense-claims-service'
import type {
@@ -166,13 +167,14 @@ export async function createSupplierInvoiceRegistrationEntry(
defaultDimensions
)
// Debit: Expense accounts (in SEK)
// Debit: Expense accounts (in SEK). A bucket that nets below zero (rabatt
// row, öresavrundning on 3740) books as a credit of the absolute value:
// every line carries exactly one non-negative side (debitNatural).
const debitLines: CreateJournalEntryLineInput[] = []
for (const bucket of expenseBuckets) {
debitLines.push({
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
...debitNatural(bucket.amount),
line_description: desc,
dimensions: bucket.dimensions,
})
@@ -250,10 +252,11 @@ export async function createSupplierInvoiceRegistrationEntry(
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
// An invoice whose rows net below zero (a leverantörskreditfaktura keyed in
// as an invoice) books 2440 on the debit side instead of a negative credit.
lines.push({
account_number: '2440',
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
...creditNatural(totalDebits - totalCredits),
line_description: desc,
dimensions: defaultDimensions,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
@@ -444,12 +447,12 @@ export async function createSupplierInvoiceCashEntry(
defaultDimensions
)
// Debit: Expense accounts (in SEK)
// Debit: Expense accounts (in SEK). Negative buckets flip to the credit
// side (debitNatural), same rule as the registration entry.
for (const bucket of expenseBuckets) {
const line: CreateJournalEntryLineInput = {
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
...debitNatural(bucket.amount),
line_description: desc,
dimensions: bucket.dimensions,
}
@@ -539,8 +542,7 @@ export async function createSupplierInvoiceCashEntry(
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
...creditNatural(totalDebits - totalCredits),
line_description: desc,
dimensions: defaultDimensions,
})
@@ -604,8 +606,7 @@ export function buildSupplierInvoicePrivatelyPaidLines(
if (amount === 0) continue
lines.push({
account_number: bucket.account,
debit_amount: amount > 0 ? amount : 0,
credit_amount: amount < 0 ? -amount : 0,
...debitNatural(amount),
line_description: description,
dimensions: bucket.dimensions,
})
@@ -667,8 +668,7 @@ export function buildSupplierInvoicePrivatelyPaidLines(
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: liabilityAccount,
debit_amount: 0,
credit_amount: roundOre(totalDebits - totalCredits),
...creditNatural(totalDebits - totalCredits),
line_description: description,
dimensions: defaultDimensions,
})
+4
View File
@@ -481,6 +481,10 @@ export function getErrorMessage(
return 'Verifikationen balanserar inte. Kontrollera att debet och kredit är lika stora.'
}
if (structured.code === 'JOURNAL_LINE_NEGATIVE_AMOUNT') {
return 'En verifikationsrad har ett negativt belopp. Boka beloppet på motsatt sida i stället.'
}
if (structured.code === 'FISCAL_PERIOD_NOT_FOUND') {
return 'Räkenskapsperioden kunde inte hittas.'
}
+9
View File
@@ -212,6 +212,15 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
description: 'Recalculate the lines so totals are equal before retrying.',
},
},
JOURNAL_LINE_NEGATIVE_AMOUNT: {
httpStatus: 400,
message_sv: 'En verifikationsrad har ett negativt belopp. Boka beloppet på motsatt sida i stället.',
message_en: 'A journal line has a negative amount. Book it on the opposite side instead.',
remediation: {
description:
'Every line carries one non-negative side: move a negative debit to credit_amount (and vice versa) before retrying.',
},
},
FISCAL_PERIOD_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Räkenskapsperioden kunde inte hittas.',
@@ -656,6 +656,31 @@ describe('salary entries: dimensions propagation (PR8)', () => {
})
})
describe('salary entries: negative avgifter keep one non-negative side', () => {
it('books a negative avgifter month as 7510 K / 2731 D instead of a negative debit', async () => {
// Full-month unpaid leave plus a manual deduction pushes gross below zero;
// avgifter follow (31,42 % of -2 000 = -628,40). The old builder emitted
// 7510 D -628,40, which the engine now refuses; the side must flip.
const run = makeRun([
makeEmployee({ employee_id: 'a', gross_salary: -2000, avgifter_amount: -628.4, avgifter_basis: undefined }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
const expense = linesOn(avgifter, '7510')
expect(expense).toHaveLength(1)
expect(expense[0]).toMatchObject({ debit_amount: 0, credit_amount: 628.4 })
const liability = linesOn(avgifter, '2731')
expect(liability).toHaveLength(1)
expect(liability[0]).toMatchObject({ debit_amount: 628.4, credit_amount: 0 })
for (const l of avgifter.lines) {
expect(l.debit_amount).toBeGreaterThanOrEqual(0)
expect(l.credit_amount).toBeGreaterThanOrEqual(0)
}
assertBalanced(avgifter)
})
})
describe('salary entries: kostnadsersättning (#2331)', () => {
const claimLine = (amount: number, account: string | null = '2820') => ({
item_type: 'expense_reimbursement',
+11 -20
View File
@@ -7,6 +7,7 @@ import {
} from '@/lib/bookkeeping/dimension-resolver'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { creditNatural, debitNatural } from '@/lib/bookkeeping/line-side'
import { SALARY_ACCOUNTS, getLineItemAccount, isTaxFreeReimbursementType } from './account-mapping'
import {
computeDeclaredAvgifterWithOverrides,
@@ -499,8 +500,7 @@ async function createAvgifterEntry(
const lines: CreateJournalEntryLineInput[] = [
...buckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: bucket.amount,
credit_amount: 0,
...debitNatural(bucket.amount),
line_description: `${desc}: Arbetsgivaravgifter`,
dimensions: bucket.dimensions,
})),
@@ -511,8 +511,7 @@ async function createAvgifterEntry(
? [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: liabilityAvgifter,
...creditNatural(liabilityAvgifter),
line_description: `${desc}: Arbetsgivaravgifter`,
} satisfies CreateJournalEntryLineInput,
]
@@ -575,15 +574,13 @@ async function createVacationEntry(
lines.push(
...buckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
debit_amount: bucket.amount,
credit_amount: 0,
...debitNatural(bucket.amount),
line_description: `${desc}: Semesteravsättning`,
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
debit_amount: 0,
credit_amount: creditTotal,
...creditNatural(creditTotal),
line_description: `${desc}: Semesteravsättning`,
}
)
@@ -595,15 +592,13 @@ async function createVacationEntry(
lines.push(
...buckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
debit_amount: bucket.amount,
credit_amount: 0,
...debitNatural(bucket.amount),
line_description: `${desc}: Sociala avgifter på semester`,
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: creditTotal,
...creditNatural(creditTotal),
line_description: `${desc}: Sociala avgifter på semester`,
}
)
@@ -654,15 +649,13 @@ async function createPensionEntry(
const lines: CreateJournalEntryLineInput[] = [
...pensionBuckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.PENSION_EXPENSE,
debit_amount: bucket.amount,
credit_amount: 0,
...debitNatural(bucket.amount),
line_description: `${desc}: Pensionsförsäkringspremier`,
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.PENSION_LIABILITY,
debit_amount: 0,
credit_amount: pensionCredit,
...creditNatural(pensionCredit),
line_description: `${desc}: Pensionsförsäkringspremier`,
},
]
@@ -673,15 +666,13 @@ async function createPensionEntry(
lines.push(
...slpBuckets.map((bucket): CreateJournalEntryLineInput => ({
account_number: SALARY_ACCOUNTS.SLP_EXPENSE,
debit_amount: bucket.amount,
credit_amount: 0,
...debitNatural(bucket.amount),
line_description: `${desc}: Särskild löneskatt 24,26%`,
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.SLP_LIABILITY,
debit_amount: 0,
credit_amount: slpCredit,
...creditNatural(slpCredit),
line_description: `${desc}: Särskild löneskatt 24,26%`,
}
)