* 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>
40 lines
1.6 KiB
TypeScript
40 lines
1.6 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
})
|
|
})
|