fix(bookkeeping): book a negative line-pattern rounding diff on 3740 opposite the business side (#1898) (#1994)
buildMultiLineMappingResult booked Math.abs(roundingDiff) on the business side regardless of sign, so a learned line_pattern whose ratios over-allocate (three 0.3334 ratios on 100.00 kr = 100.02, diff -0.02) produced an entry off by 2x|diff|. commit_journal_entry rejected it, so the user saw a failed confirm and, since #1894, an unbalanced prefill. The 3740 leg now lands on the business side for a positive diff (under-allocation, unchanged) and on the opposite side for a negative diff (over-allocation), flipped after the mirror. computeProposalLines gets the identical rule in the same change to keep the byte-parity contract, and a 5000-amount sweep test pins engine and proposal together. Also reachable with normalized ratios: 50/50 on 100.03 kr rounds to 50.02 + 50.02. Closes #1898 Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
04406edb0f
commit
175bb8bd92
@@ -1110,6 +1110,116 @@ describe('dimensions propagation (PR7)', () => {
|
||||
|
||||
// ── learning-loop repair (issue #865) ────────────────────────
|
||||
|
||||
describe('line-pattern rounding diff on 3740 (issue #1898)', () => {
|
||||
const expenseTriple = (ratio: number): LinePatternEntry[] => [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio },
|
||||
{ account: '6991', type: 'business', side: 'debit', ratio },
|
||||
]
|
||||
|
||||
function entrySums(
|
||||
tx: Parameters<typeof buildTransactionEntryLines>[0],
|
||||
result: Parameters<typeof buildTransactionEntryLines>[1],
|
||||
) {
|
||||
const lines = buildTransactionEntryLines(tx, result)
|
||||
return {
|
||||
debits: roundOre(lines.reduce((s, l) => s + l.debit_amount, 0)),
|
||||
credits: roundOre(lines.reduce((s, l) => s + l.credit_amount, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
it('books an over-allocating pattern rounding diff on 3740 opposite the business side', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: expenseTriple(0.3334),
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: -100 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
// 3 x 33.34 = 100.02 over-allocates by 0.02: 3740 must offset on the credit side
|
||||
for (const account of ['6110', '6212', '6991']) {
|
||||
expect(result.vat_lines.find((l) => l.account_number === account)?.debit_amount).toBe(33.34)
|
||||
}
|
||||
const rounding = result.vat_lines.find((l) => l.account_number === '3740')
|
||||
expect(rounding?.credit_amount).toBe(0.02)
|
||||
expect(rounding?.debit_amount).toBe(0)
|
||||
const { debits, credits } = entrySums(tx, result)
|
||||
expect(debits).toBe(100.02)
|
||||
expect(credits).toBe(100.02)
|
||||
})
|
||||
|
||||
it('balances a normalized 50/50 pattern on an odd-ore amount', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
],
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: -100.03 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
// 50.015 rounds to 50.02 twice: ratios that sum to exactly 1 still over-allocate
|
||||
expect(result.vat_lines.find((l) => l.account_number === '6110')?.debit_amount).toBe(50.02)
|
||||
expect(result.vat_lines.find((l) => l.account_number === '6212')?.debit_amount).toBe(50.02)
|
||||
const rounding = result.vat_lines.find((l) => l.account_number === '3740')
|
||||
expect(rounding?.credit_amount).toBe(0.01)
|
||||
expect(rounding?.debit_amount).toBe(0)
|
||||
const { debits, credits } = entrySums(tx, result)
|
||||
expect(debits).toBe(credits)
|
||||
})
|
||||
|
||||
it('keeps an under-allocating diff on the business side', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: expenseTriple(0.333),
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: -100 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
// 3 x 33.30 = 99.90 under-allocates by 0.10: unchanged business-side placement
|
||||
const rounding = result.vat_lines.find((l) => l.account_number === '3740')
|
||||
expect(rounding?.debit_amount).toBe(0.1)
|
||||
expect(rounding?.credit_amount).toBe(0)
|
||||
const { debits, credits } = entrySums(tx, result)
|
||||
expect(debits).toBe(100)
|
||||
expect(credits).toBe(100)
|
||||
})
|
||||
|
||||
it('mirrors the over-allocation rounding leg on sign mismatch', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: expenseTriple(0.3334),
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: 100 }) // refund of a three-way expense
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
expect(result.direction_mismatch).toBe(true)
|
||||
for (const account of ['6110', '6212', '6991']) {
|
||||
expect(result.vat_lines.find((l) => l.account_number === account)?.credit_amount).toBe(33.34)
|
||||
}
|
||||
// Mirrored business side is credit, so the over-allocation offset lands on debit
|
||||
const rounding = result.vat_lines.find((l) => l.account_number === '3740')
|
||||
expect(rounding?.debit_amount).toBe(0.02)
|
||||
expect(rounding?.credit_amount).toBe(0)
|
||||
const { debits, credits } = entrySums(tx, result)
|
||||
expect(debits).toBe(100.02)
|
||||
expect(credits).toBe(100.02)
|
||||
})
|
||||
})
|
||||
|
||||
describe('learning-loop repair (issue #865)', () => {
|
||||
/** Queue-based mock that records insert payloads per table (see PR7 block). */
|
||||
function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeProposalLines, proposalLinesToFormLines, resolveTemplateAccountsForEntity } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines'
|
||||
import { buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { makeCategorizationTemplate, makeTransaction } from '@/tests/helpers'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { LinePatternEntry } from '@/types'
|
||||
|
||||
@@ -387,6 +389,75 @@ describe('computeProposalLines', () => {
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('books an over-allocating diff on 3740 opposite the business side (#1898)', () => {
|
||||
const over: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
{ account: '6991', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: -100, linePattern: over })
|
||||
// 3 x 33.34 = 100.02 over-allocates by 0.02: 3740 offsets on the credit side
|
||||
expect(lines).toContainEqual({ side: 'kredit', account: '3740', amount: 0.02 })
|
||||
expect(sumSide(lines, 'debet')).toBe(100.02)
|
||||
expect(sumSide(lines, 'kredit')).toBe(100.02)
|
||||
})
|
||||
|
||||
it('balances a normalized 50/50 pattern on an odd-ore amount (#1898)', () => {
|
||||
const half: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: -100.03, linePattern: half })
|
||||
// 50.015 rounds to 50.02 twice: ratios that sum to exactly 1 still over-allocate
|
||||
expect(lines).toContainEqual({ side: 'debet', account: '6110', amount: 50.02 })
|
||||
expect(lines).toContainEqual({ side: 'kredit', account: '3740', amount: 0.01 })
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('mirrors the over-allocation rounding leg on a refund (#1898)', () => {
|
||||
const over: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
{ account: '6991', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: 100, linePattern: over })
|
||||
// Mirrored business side is kredit, so the over-allocation offset lands on debet
|
||||
expect(lines).toContainEqual({ side: 'kredit', account: '6110', amount: 33.34 })
|
||||
expect(lines).toContainEqual({ side: 'debet', account: '3740', amount: 0.02 })
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('keeps the 3740 leg in byte parity with the engine across 0.01..50.00 kr (#1898)', () => {
|
||||
const half: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
]
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: half,
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
for (let ore = 1; ore <= 5000; ore++) {
|
||||
const amount = -(ore / 100)
|
||||
const tx = makeTransaction({ amount })
|
||||
const engine = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
const proposal = computeProposalLines({ amount, linePattern: half })
|
||||
const engineRounding = engine.vat_lines.find(l => l.account_number === '3740')
|
||||
const proposalRounding = proposal.find(l => l.account === '3740')
|
||||
if (engineRounding) {
|
||||
expect(proposalRounding, `amount ${amount}`).toEqual({
|
||||
side: engineRounding.debit_amount > 0 ? 'debet' : 'kredit',
|
||||
account: '3740',
|
||||
amount: engineRounding.debit_amount || engineRounding.credit_amount,
|
||||
})
|
||||
} else {
|
||||
expect(proposalRounding, `amount ${amount}`).toBeUndefined()
|
||||
}
|
||||
expect(sumSide(proposal, 'debet'), `amount ${amount}`).toBe(sumSide(proposal, 'kredit'))
|
||||
}
|
||||
})
|
||||
|
||||
it('handles income patterns with the settlement on the debit side', () => {
|
||||
const incomePattern: LinePatternEntry[] = [
|
||||
{ account: '2611', type: 'vat', side: 'credit', vat_rate: 0.25 },
|
||||
|
||||
@@ -624,7 +624,8 @@ function buildLegacyMismatchResult(
|
||||
* Build a MappingResult from a multi-line counterparty template pattern.
|
||||
*
|
||||
* VAT is computed from rate (exact), business/tax from ratio against non-VAT subtotal.
|
||||
* Rounding difference goes to 3740 (Öresutjämning).
|
||||
* Rounding difference goes to 3740 (Öresutjämning): on the business side when
|
||||
* the ratios under-allocate, on the opposite side when they over-allocate (#1898).
|
||||
* Settlement line always equals the exact transaction amount.
|
||||
*/
|
||||
function buildMultiLineMappingResult(
|
||||
@@ -688,12 +689,18 @@ function buildMultiLineMappingResult(
|
||||
const totalAllocated = Math.round((totalVat + nonVatAllocated) * 100) / 100
|
||||
const roundingDiff = Math.round((absAmount - totalAllocated) * 100) / 100
|
||||
if (roundingDiff !== 0) {
|
||||
// Determine the side for the rounding line (same side as business lines)
|
||||
// A positive diff means the ratios under-allocated: 3740 fills the gap on
|
||||
// the business side. A negative diff means they over-allocated (three
|
||||
// 0.3334 ratios on 100.00 kr give 3 x 33.34 = 100.02): 3740 offsets on
|
||||
// the OPPOSITE side so the non-settlement lines net to absAmount (#1898).
|
||||
// businessSide is already mirror-applied via side(), so flip after it.
|
||||
const businessSide = side(pattern.find(e => e.type === 'business')?.side ?? 'credit')
|
||||
const roundingSide: 'debit' | 'credit' =
|
||||
roundingDiff > 0 ? businessSide : (businessSide === 'debit' ? 'credit' : 'debit')
|
||||
allLines.push({
|
||||
account_number: '3740',
|
||||
debit_amount: businessSide === 'debit' ? Math.abs(roundingDiff) : 0,
|
||||
credit_amount: businessSide === 'credit' ? Math.abs(roundingDiff) : 0,
|
||||
debit_amount: roundingSide === 'debit' ? Math.abs(roundingDiff) : 0,
|
||||
credit_amount: roundingSide === 'credit' ? Math.abs(roundingDiff) : 0,
|
||||
description: 'Öresutjämning',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
* gross = 14 mod 28 ore and the entry goes off by 1 ore), why the fiktiv-moms
|
||||
* pair uses the engine's plain rounding (roundOre's EPSILON nudge diverges at
|
||||
* exact-half floats like 8.62 * 0.25), and why sign-mismatched counterparty
|
||||
* matches are mirrored exactly as the server mirrors them.
|
||||
* matches are mirrored exactly as the server mirrors them. The 3740 rounding
|
||||
* leg follows the engine's sign rule too: business side when the ratios
|
||||
* under-allocate, opposite side when they over-allocate (#1898).
|
||||
*
|
||||
* The resulting booking still goes through JournalEntryForm's normal manual
|
||||
* validation and the bookkeeping engine: nothing here writes to the ledger.
|
||||
@@ -251,8 +253,15 @@ export function computeProposalLines(input: ProposalLinesInput): ProposalLine[]
|
||||
const totalAllocated = engineRound(totalVat + allocated)
|
||||
const diff = engineRound(absAmount - totalAllocated)
|
||||
if (diff !== 0) {
|
||||
// ENGINE PARITY (#1898, buildMultiLineMappingResult step 4): a positive
|
||||
// diff (under-allocation) lands on the mirrored business side, a
|
||||
// negative diff (over-allocation) on the opposite side. The flip is
|
||||
// applied AFTER the mirror, exactly as the engine does it.
|
||||
const businessSide = linePattern.find(e => e.type === 'business')?.side ?? 'credit'
|
||||
result.push({ side: side(businessSide), account: '3740', amount: Math.abs(diff) })
|
||||
const effectiveBusinessSide = side(businessSide)
|
||||
const roundingSide: 'debet' | 'kredit' =
|
||||
diff > 0 ? effectiveBusinessSide : (effectiveBusinessSide === 'debet' ? 'kredit' : 'debet')
|
||||
result.push({ side: roundingSide, account: '3740', amount: Math.abs(diff) })
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user