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
parent c091a7f28e
commit 2d49a81508
17 changed files with 628 additions and 50 deletions
+1
View File
@@ -1672,3 +1672,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
[2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place.
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
[2026-09-08] Negative journal-line amounts: fixed the sign at three levels (producers flip the SIDE via lib/bookkeeping/line-side.ts, the engine refuses negative amounts before any write, and a NOT VALID CHECK on journal_entry_lines) instead of only patching the supplier-invoice generator or hiding negative items in the form. Why: the invariant lived nowhere (no Zod rule, no engine check, no constraint), so MCP, templates and any future producer could repeat it; negative items themselves are valid input (rabatt, öresavrundning), so rejecting them at input would break real invoices. reverseEntry now swaps on the net so legacy negative lines storno cleanly before the data repair runs.
@@ -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%`,
}
)
@@ -0,0 +1,17 @@
-- Every journal line carries exactly one non-negative side.
--
-- A line with debit_amount = -0.25 balances arithmetically against a credit
-- (the balance trigger never fires), so producers that copied a negative item
-- total straight into debit_amount (supplier-invoice öresavrundning rows on
-- 3740, negative customer-invoice items) shipped 14 such lines to prod. Every
-- reader assumes one positive side per line: the verifikat page hid the row
-- and its sums disagreed with the visible lines.
--
-- NOT VALID: existing rows are repaired by a separate, founder-approved data
-- fix (flip the sign to the opposite column, net unchanged); new inserts and
-- updates are checked from now on. VALIDATE CONSTRAINT follows in a later
-- migration once prod reports zero offending rows.
ALTER TABLE public.journal_entry_lines
ADD CONSTRAINT journal_entry_lines_amounts_non_negative
CHECK (debit_amount >= 0 AND credit_amount >= 0)
NOT VALID;