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:
co-authored by
Claude Fable 5.1
parent
c091a7f28e
commit
2d49a81508
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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%`,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user