fix(rattelse): let a bank-side strike land on the linked bank amount (#1701)

The inline rättelse RPC refused any net change on a 19xx account when the
verifikat is linked to a bank transaction, including the one change that
makes a mis-booked bank side match the feed (1930 D / 1930 K against a
deposit, credit should have been 2970). Anchor the guard to the signed
linked bank amount instead of to the pre-state: a non-zero change on the
bank account is allowed iff the post-state net equals the linked amount
(once per transaction, split links by allocated_amount). Reskontra sides
stay strictly net-preserving. The refusal message now shows both amounts.

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:
Jakob Wennberg
2026-08-19 20:09:23 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 06e554f3cc
commit 926ca75fd3
3 changed files with 570 additions and 0 deletions
+127
View File
@@ -6,6 +6,7 @@ import {
insertAuthUser,
insertCompanyMember,
insertDraftJournalEntry,
insertCashAccount,
} from '@/tests/pg/fixtures'
// Migration 20260723210000_verifikat_inline_rattelse.sql: the founder-approved
@@ -548,6 +549,132 @@ describe('inline rättelse: lines (correct_entry_lines_inline)', () => {
expect(res2.rows[0].result.struck_count).toBe(1)
})
// 20260819092408_inline_rattelse_bank_anchor.sql: the bank-side guard is
// anchored to the linked bank amount, not to the pre-state. The Discord
// case (Sebastian, 2026-08-19): a +10 874,81 deposit booked as 1930 D /
// 1930 K (the credit should have been 2970), so the 1930 net was 0 and the
// only rättelse that makes the entry match the feed was refused.
it('allows a bank-side strike that makes the 19xx net equal the linked bank amount', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '1930')
await insertChartAccount(companyId, userId, '2970')
const entryId = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId, sourceType: 'bank_transaction', status: 'draft', voucherNumber: 21,
})
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
VALUES ($1, '1930', 10874.81, 0, 1)`,
[entryId],
)
const { rows: wrongRows } = await getPool().query<{ id: string }>(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order, line_description)
VALUES ($1, '1930', 0, 10874.81, 2, 'Förutbetalda intäkter') RETURNING id`,
[entryId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
await getPool().query(
`INSERT INTO public.transactions (user_id, company_id, date, description, amount, journal_entry_id, is_business)
VALUES ($1, $2, '2026-02-10', 'BOKADIREKT X', 10874.81, $3, true)`,
[userId, companyId, entryId],
)
// Moving the 1930 net somewhere that is NOT the bank amount is still
// refused, and the message now carries both amounts.
await expect(
callStrike(companyId, entryId, [wrongRows[0].id],
[
{ account_number: '1930', debit_amount: 0, credit_amount: 5000 },
{ account_number: '2970', debit_amount: 0, credit_amount: 5874.81 },
], userId),
).rejects.toThrow(/kopplad till en banktransaktion på 10874\.81 kr.*5874\.81 kr/)
// Striking the wrong 1930 K line and re-adding it on 2970 takes the 1930
// net from 0 to +10 874,81 = the deposit. Allowed.
const res = await callStrike(companyId, entryId, [wrongRows[0].id],
[{ account_number: '2970', debit_amount: 0, credit_amount: 10874.81, line_description: 'Förutbetalda intäkter' }], userId)
expect(res.rows[0].result.struck_count).toBe(1)
expect(res.rows[0].result.added_count).toBe(1)
const { rows: after } = await getPool().query<{ account_number: string; debit_amount: string; credit_amount: string }>(
`SELECT account_number, debit_amount::text, credit_amount::text FROM public.journal_entry_lines
WHERE journal_entry_id = $1 ORDER BY sort_order`,
[entryId],
)
expect(after.map((l) => [l.account_number, Number(l.debit_amount), Number(l.credit_amount)])).toEqual([
['1930', 10874.81, 0],
['2970', 0, 10874.81],
])
// Now that the bank side matches the feed, moving it again is refused:
// the anchor is the feed, not the pre-state.
const { rows: bankRows } = await getPool().query<{ id: string }>(
`SELECT id FROM public.journal_entry_lines WHERE journal_entry_id = $1 AND account_number = '1930'`,
[entryId],
)
await expect(
callStrike(companyId, entryId, [bankRows[0].id],
[
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
{ account_number: '2970', debit_amount: 874.81, credit_amount: 0 },
], userId),
).rejects.toThrow(/kopplad till en banktransaktion/)
})
it('anchors split-linked entries by allocated_amount on the transaction cash account, once per transaction', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '1940')
await insertChartAccount(companyId, userId, '3010')
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1940' })
const entryId = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId, sourceType: 'bank_transaction', status: 'draft', voucherNumber: 22,
})
// Two deposits (600 + 400) on the 1940 cash account, booked as
// 1940 D 1000 / 1940 K 1000: the contra line landed on the bank account.
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
VALUES ($1, '1940', 1000, 0, 1)`,
[entryId],
)
const { rows: wrongRows } = await getPool().query<{ id: string }>(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
VALUES ($1, '1940', 0, 1000, 2) RETURNING id`,
[entryId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
const { rows: txRows } = await getPool().query<{ id: string }>(
`INSERT INTO public.transactions (user_id, company_id, date, description, amount, cash_account_id, is_business, journal_entry_id)
VALUES ($1, $2, '2026-02-10', 'Swish 1', 600, $3, true, NULL),
($1, $2, '2026-02-10', 'Swish 2', 400, $3, true, $4)
RETURNING id`,
[userId, companyId, cashAccountId, entryId],
)
// Both transactions carry split links; the second ALSO has the direct FK
// (the 1:1 bulk-book shape). Double counting it would make the anchor
// 1 400 and wrongly refuse the fix below.
for (const tx of txRows) {
await getPool().query(
`INSERT INTO public.transaction_voucher_links
(user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
VALUES ($1, $2, $3, $4, (SELECT amount FROM public.transactions WHERE id = $3), 'bank_line')`,
[userId, companyId, tx.id, entryId],
)
}
// Partial move: 1940 would end at 400 ≠ 1 000. Refused with the amounts.
await expect(
callStrike(companyId, entryId, [wrongRows[0].id],
[
{ account_number: '1940', debit_amount: 0, credit_amount: 600 },
{ account_number: '3010', debit_amount: 0, credit_amount: 400 },
], userId),
).rejects.toThrow(/banktransaktion på 1000\.00 kr.*400\.00 kr/)
// Full move: 1940 net 0 -> 1 000 = 600 + 400. Allowed.
const res = await callStrike(companyId, entryId, [wrongRows[0].id],
[{ account_number: '3010', debit_amount: 0, credit_amount: 1000 }], userId)
expect(res.rows[0].result.struck_count).toBe(1)
})
it('keeps the journal_entry_rattelse_log immutable', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '5420')