feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)

One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
This commit is contained in:
Jakob Wennberg
2026-08-30 11:56:15 +02:00
committed by GitHub
parent 749f90fe62
commit d11d0a2e90
36 changed files with 2252 additions and 158 deletions
+22 -8
View File
@@ -2413,14 +2413,28 @@ export const PruneAccountsSchema = z
// Bank reconciliation schemas
// ============================================================
export const BankLinkSchema = z.object({
transaction_id: uuid,
journal_entry_id: uuid,
// Settlement account being reconciled. The voucher must have a line on this
// account and the transaction must belong to it. Defaults to '1930' in the
// route for back-compat.
account_number: accountNumber.optional(),
})
export const BankLinkSchema = z
.object({
transaction_id: uuid,
// One verifikat (1:1, or N:1 when other transactions already point at it).
journal_entry_id: uuid.optional(),
// Or several verifikat settled by this one transaction (1:N, #1553): the
// signed slice per verifikat in the transaction's sign convention. The
// slices must sum to the transaction amount; the engine enforces it.
allocations: z
.array(z.object({ journal_entry_id: uuid, amount: z.number() }))
.min(2)
.max(50)
.optional(),
// Settlement account being reconciled. The voucher must have a line on this
// account and the transaction must belong to it. Defaults to '1930' in the
// route for back-compat.
account_number: accountNumber.optional(),
})
.refine((v) => (v.journal_entry_id ? !v.allocations : Boolean(v.allocations)), {
message: 'Ange journal_entry_id eller allocations, inte båda.',
path: ['journal_entry_id'],
})
export const BankUnlinkSchema = z.object({
transaction_id: uuid,
+87 -10
View File
@@ -913,12 +913,19 @@ describe('reverseEntry: bank transaction unlink', () => {
type Filter = [op: string, column: string, value: unknown]
interface RecordedWrite { payload?: unknown; op?: string; filters: Filter[] }
type RemainingRow = { transaction_id: string; role?: string; allocated_amount?: number }
type TxRow = { id: string; amount: number; journal_entry_id: string | null }
/**
* Mock for reverseEntry. `voucherLinks` are the transaction ids the junction
* holds for entry-1; `remainingLinks` what it still holds for those ids after
* the delete (a row anchored to some other verifikat too).
* the delete (a row anchored to some other verifikat too), as ids (one
* bank_line row each, no amount) or as full rows. `txRows` is what the
* partial-split read returns for the rows that still have anchors.
*/
function setup(opts: { voucherLinks?: string[]; remainingLinks?: string[] } = {}) {
function setup(
opts: { voucherLinks?: string[]; remainingLinks?: Array<string | RemainingRow>; txRows?: TxRow[] } = {},
) {
let jeCall = 0
const jeResults = [
{ data: original, error: null }, // fetch original (.single)
@@ -985,12 +992,21 @@ describe('reverseEntry: bank transaction unlink', () => {
if (table === 'journal_entry_lines') {
return { insert: vi.fn().mockResolvedValue({ error: null }) }
}
if (table === 'transactions') return recorder(txWrites, () => ({ error: null }))
if (table === 'transactions') {
return recorder(txWrites, (current) =>
current.op === 'select' ? { data: opts.txRows ?? [], error: null } : { error: null },
)
}
if (table === 'transaction_voucher_links') {
return recorder(linkOps, (current) => {
if (current.op !== 'select') return { error: null }
const ids = linkSelectCall++ === 0 ? opts.voucherLinks ?? [] : opts.remainingLinks ?? []
return { data: ids.map((transaction_id) => ({ transaction_id })), error: null }
if (linkSelectCall++ === 0) {
return { data: (opts.voucherLinks ?? []).map((transaction_id) => ({ transaction_id })), error: null }
}
const rows = (opts.remainingLinks ?? []).map((r) =>
typeof r === 'string' ? { transaction_id: r, role: 'bank_line', allocated_amount: 0 } : r,
)
return { data: rows, error: null }
})
}
return createMockChain()
@@ -1062,7 +1078,7 @@ describe('reverseEntry: bank transaction unlink', () => {
},
{
op: 'select',
payload: 'transaction_id',
payload: 'transaction_id, role, allocated_amount',
filters: [
['eq', 'company_id', 'company-1'],
['in', 'transaction_id', ['tx-a', 'tx-b', 'tx-c']],
@@ -1070,11 +1086,21 @@ describe('reverseEntry: bank transaction unlink', () => {
},
])
expect(txWrites).toHaveLength(2)
expect(txWrites[1].payload).toEqual({ is_business: null, category: null, reconciliation_method: null })
// [0] unlink, [1] the partial-split read for the row still anchored
// (tx-c), [2] the release of the rows with no anchor left.
expect(txWrites).toHaveLength(3)
expect(txWrites[1]).toEqual({
op: 'select',
payload: 'id, amount, journal_entry_id',
filters: [
['eq', 'company_id', 'company-1'],
['in', 'id', ['tx-c']],
],
})
expect(txWrites[2].payload).toEqual({ is_business: null, category: null, reconciliation_method: null })
// Only rows with no anchor left, and never a row whose journal_entry_id
// still points at another verifikat (residual booking).
expect(txWrites[1].filters).toEqual([
expect(txWrites[2].filters).toEqual([
['eq', 'company_id', 'company-1'],
['in', 'id', ['tx-a', 'tx-b']],
['is', 'journal_entry_id', null],
@@ -1090,7 +1116,58 @@ describe('reverseEntry: bank transaction unlink', () => {
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select'])
expect(txWrites).toHaveLength(1)
// The unlink plus the partial-split read; no release.
expect(txWrites.map((w) => w.op)).toEqual(['update', 'select'])
})
it('releases a 1:N split whole when one of its verifikat is reversed (#1553): surviving slices dropped', async () => {
// tx-s (amount -800) was split over entry-1 (-500) and entry-2 (-300).
// Reversing entry-1 leaves a -300 bank_line slice that no longer explains
// the row: the slice goes, and the row returns to Att bokföra.
const { supabase, txWrites, linkOps } = setup({
voucherLinks: ['tx-s'],
remainingLinks: [{ transaction_id: 'tx-s', role: 'bank_line', allocated_amount: -300 }],
txRows: [{ id: 'tx-s', amount: -800, journal_entry_id: null }],
})
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select', 'delete'])
expect(linkOps[3].filters).toEqual([
['eq', 'company_id', 'company-1'],
['in', 'transaction_id', ['tx-s']],
])
const release = txWrites[txWrites.length - 1]
expect(release.op).toBe('update')
expect(release.payload).toEqual({ is_business: null, category: null, reconciliation_method: null })
expect(release.filters).toEqual([
['eq', 'company_id', 'company-1'],
['in', 'id', ['tx-s']],
['is', 'journal_entry_id', null],
])
})
it('keeps a row whose surviving slices still sum to its amount, or that carries a non-bank_line anchor', async () => {
// tx-full: a bulk-booked-style anchor on another verifikat covering the
// whole amount. tx-res: a residual booking's 'other' row (its main
// verifikat pointer is null here because the storno of THAT verifikat is
// what left it; the residual row is not a slice and is never judged).
const { supabase, txWrites, linkOps } = setup({
voucherLinks: ['tx-full', 'tx-res'],
remainingLinks: [
{ transaction_id: 'tx-full', role: 'bank_line', allocated_amount: -800 },
{ transaction_id: 'tx-res', role: 'other', allocated_amount: -10 },
],
txRows: [
{ id: 'tx-full', amount: -800, journal_entry_id: null },
{ id: 'tx-res', amount: -1010, journal_entry_id: null },
],
})
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select'])
expect(txWrites.map((w) => w.op)).toEqual(['update', 'select'])
})
})
+61 -2
View File
@@ -1295,7 +1295,7 @@ export async function reverseEntry(
} else {
const { data: remainingRows, error: remainingError } = await supabase
.from('transaction_voucher_links')
.select('transaction_id')
.select('transaction_id, role, allocated_amount')
.eq('company_id', companyId)
.in('transaction_id', junctionTxIds)
if (remainingError) {
@@ -1303,8 +1303,67 @@ export async function reverseEntry(
entryId,
})
} else {
const remainingByTx = new Map<string, Array<{ role: string; allocated_amount: number }>>()
for (const row of (remainingRows ?? []) as Array<{
transaction_id: string
role?: string | null
allocated_amount?: number | string | null
}>) {
const rows = remainingByTx.get(row.transaction_id) ?? []
rows.push({ role: row.role ?? 'bank_line', allocated_amount: Number(row.allocated_amount ?? 0) })
remainingByTx.set(row.transaction_id, rows)
}
// A row split over several verifikat (1:N, lib/reconciliation/
// bank-reconciliation.ts linkTransactionToVouchers) is anchored by
// 'bank_line' slices that sum to its amount. Reversing one of them
// leaves a partial anchor: the row would read as booked while the
// ledger no longer explains it, and the reconciliation difference
// would move by the reversed slice with nothing to act on. Such a
// row goes back to Att bokföra whole: the surviving slices are
// dropped (their vouchers surface as unmatched again) and the row is
// released like a bulk-booked one. Rows whose remaining anchors
// still sum to the amount (bulk-book, one row per transaction) or
// include a non-bank_line anchor (a residual booking) are untouched.
const partialSplitIds: string[] = []
const candidates = junctionTxIds.filter((id) => (remainingByTx.get(id) ?? []).length > 0)
if (candidates.length > 0) {
const { data: txRows, error: txReadError } = await supabase
.from('transactions')
.select('id, amount, journal_entry_id')
.eq('company_id', companyId)
.in('id', candidates)
if (txReadError) {
log.error('failed to read split transactions after storno', txReadError, { entryId })
} else {
for (const tx of (txRows ?? []) as Array<{
id: string
amount: number | string | null
journal_entry_id: string | null
}>) {
if (tx.journal_entry_id) continue
const rows = remainingByTx.get(tx.id) ?? []
if (!rows.every((r) => r.role === 'bank_line')) continue
const anchored = rows.reduce((sum, r) => sum + r.allocated_amount, 0)
if (Math.abs(Math.round((anchored - Number(tx.amount ?? 0)) * 100) / 100) > 0.005) {
partialSplitIds.push(tx.id)
}
}
}
}
if (partialSplitIds.length > 0) {
const { error: partialDeleteError } = await supabase
.from('transaction_voucher_links')
.delete()
.eq('company_id', companyId)
.in('transaction_id', partialSplitIds)
if (partialDeleteError) {
log.error('failed to drop the surviving slices of a split transaction after storno', partialDeleteError, {
entryId,
})
}
}
const stillAnchored = new Set(
((remainingRows ?? []) as Array<{ transaction_id: string }>).map((r) => r.transaction_id),
[...remainingByTx.keys()].filter((id) => !partialSplitIds.includes(id)),
)
const releaseIds = junctionTxIds.filter((id) => !stillAnchored.has(id))
if (releaseIds.length > 0) {
@@ -0,0 +1,110 @@
/**
* commitReconciliationMatch, driven through the public commitPendingOperation
* dispatcher. The linking lives in lib/reconciliation/actions.ts matchPairs
* (unit tested there); these tests cover the wiring for the bank 1:N pair of
* issue #1553: the staged pair (one row, several verifikat, allocations)
* reaches matchPairs intact and as ONE pair, never re-split into independent
* 1:1 links, and partial success surfaces in the committed payload.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
const matchMock = vi.fn()
vi.mock('@/lib/reconciliation/actions', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/actions')>('@/lib/reconciliation/actions')
return { ...actual, matchPairs: (...args: unknown[]) => matchMock(...args) }
})
import { commitPendingOperation } from '../commit'
const CASH = '11111111-1111-4111-8111-111111111111'
const KEY = `bank:${CASH}`
const T1 = '22222222-2222-4222-8222-222222222222'
const E1 = '44444444-4444-4444-8444-444444444444'
const E2 = '55555555-5555-4555-8555-555555555555'
const splitPair = {
external_ids: [T1],
journal_entry_ids: [E1, E2],
allocations: [
{ journal_entry_id: E1, amount: -500 },
{ journal_entry_id: E2, amount: -300 },
],
}
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'reconciliation_match',
status: 'pending',
title: 'test',
params: { account_key: KEY, pairs: [splitPair] },
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-08-29T00:00:00Z',
resolved_at: null,
updated_at: '2026-08-29T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: reconciliation_match with a bank 1:N pair (#1553)', () => {
it('passes the staged split pair, allocations included, to matchPairs as one pair', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's committed update
matchMock.mockResolvedValue({
dry_run: false,
considered: 1,
applied: [
{ external_id: T1, journal_entry_id: E1, allocated_amount: -500 },
{ external_id: T1, journal_entry_id: E2, allocated_amount: -300 },
],
skipped: [],
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(matchMock).toHaveBeenCalledTimes(1)
expect(matchMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', KEY, { pairs: [splitPair] }, { dryRun: false })
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ account_key: KEY, applied_count: 2, skipped_count: 0 })
expect((result.data as { applied: Array<{ allocated_amount: number }> }).applied.map((a) => a.allocated_amount)).toEqual([-500, -300])
})
it('reports a refused split (sum mismatch at commit time) as a 409 auto-reject carrying the skip code', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's failed update
matchMock.mockResolvedValue({
dry_run: false,
considered: 1,
applied: [],
skipped: [
{ pair: splitPair, code: 'PAIR_NOT_CLOSED', message: 'Fördelningen (-800) stämmer inte med transaktionens belopp (-900).' },
],
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
// 409 is a conflict with the ledger's current state, which the
// dispatcher records as rejected (like a 404), not as a failed attempt.
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.code).toBe('PAIR_NOT_CLOSED')
expect(result.error).toMatch(/PAIR_NOT_CLOSED/)
})
})
+10 -2
View File
@@ -6108,7 +6108,9 @@ async function commitBulkBookInboxItems(
* same service the page and the v1 API use. Every pair is re-validated at
* commit time (row still open, entry still posted and unlinked, amounts
* close); partial success is reported in data.applied / data.skipped rather
* than failing the whole operation, because the pairs are independent.
* than failing the whole operation, because the pairs are independent. A
* bank 1:N pair (one row, several verifikat, allocations) is staged as one
* pair and re-validated as one all-or-nothing split.
*/
async function commitReconciliationMatch(
supabase: SupabaseClient,
@@ -6117,7 +6119,13 @@ async function commitReconciliationMatch(
params: Record<string, unknown>
): Promise<ExecutorResult> {
const accountKey = params.account_key as string | undefined
const pairs = params.pairs as Array<{ external_ids: string[]; journal_entry_ids: string[] }> | undefined
const pairs = params.pairs as
| Array<{
external_ids: string[]
journal_entry_ids: string[]
allocations?: Array<{ journal_entry_id: string; amount: number }>
}>
| undefined
if (!accountKey || !Array.isArray(pairs) || pairs.length === 0) {
return { error: 'account_key and pairs are required', status: 400 }
}
+152 -1
View File
@@ -6,6 +6,8 @@ const linkGroupMock = vi.fn()
const unlinkMock = vi.fn()
const setIgnoredMock = vi.fn()
const manualLinkMock = vi.fn()
const linkToVouchersMock = vi.fn()
const junctionLinkedMock = vi.fn()
const unlinkReconciliationMock = vi.fn()
const skvStatusMock = vi.fn()
const emitMock = vi.fn()
@@ -22,6 +24,8 @@ vi.mock('@/lib/skatteverket/skattekonto-link', async (importOriginal) => {
})
vi.mock('../bank-reconciliation', () => ({
manualLink: (...args: unknown[]) => manualLinkMock(...args),
linkTransactionToVouchers: (...args: unknown[]) => linkToVouchersMock(...args),
fetchJunctionLinkedTxIds: (...args: unknown[]) => junctionLinkedMock(...args),
unlinkReconciliation: (...args: unknown[]) => unlinkReconciliationMock(...args),
}))
vi.mock('../skattekonto-reconciliation', () => ({
@@ -46,6 +50,7 @@ describe('matchPairs', () => {
linkMock.mockReset()
linkGroupMock.mockReset()
manualLinkMock.mockReset()
linkToVouchersMock.mockReset()
skvStatusMock.mockReset()
emitMock.mockResolvedValue(undefined)
})
@@ -72,7 +77,7 @@ describe('matchPairs', () => {
{ external_ids: [R1], journal_entry_ids: [E1] },
{ external_ids: [R2], journal_entry_ids: [E1] },
{ external_ids: [R1, R2], journal_entry_ids: [E2] },
// 1:M stays refused until the residual link table exists.
// 1:N is a bank-only shape (#1553): a skattekonto row never splits.
{ external_ids: [R1], journal_entry_ids: [E1, E2] },
],
})
@@ -84,6 +89,8 @@ describe('matchPairs', () => {
{ external_id: R2, journal_entry_id: E2, via: 'entry_total' },
])
expect(result?.skipped.map((s) => s.code)).toEqual(['ALREADY_LINKED', 'UNSUPPORTED_PAIR_SHAPE'])
expect(result?.skipped[1].message).toMatch(/skattekontot/)
expect(linkToVouchersMock).not.toHaveBeenCalled()
expect(linkGroupMock).toHaveBeenCalledWith(supabase, COMPANY, [R1, R2], E2)
expect(emitMock).toHaveBeenCalledTimes(3)
expect(emitMock.mock.calls[0][0]).toMatchObject({
@@ -154,6 +161,125 @@ describe('matchPairs', () => {
expect(result?.applied).toHaveLength(1)
})
it('splits ONE bank transaction over SEVERAL verifikat through linkTransactionToVouchers (1:N, #1553)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ledger_account: '1930' } }) // cash_accounts lookup, resolved once
linkToVouchersMock.mockResolvedValue({
success: true,
allocations: [
{ journal_entry_id: E1, amount: -500 },
{ journal_entry_id: E2, amount: -300 },
],
})
const result = await matchPairs(supabase as never, COMPANY, USER, `bank:${CASH}`, {
pairs: [{ external_ids: [R1], journal_entry_ids: [E1, E2] }],
})
// No allocations given: every slice is left undefined for the engine to
// default to the voucher's bank line and enforce the sum.
expect(linkToVouchersMock).toHaveBeenCalledWith(
supabase,
COMPANY,
R1,
[
{ journal_entry_id: E1, amount: undefined },
{ journal_entry_id: E2, amount: undefined },
],
USER,
'1930',
{ dryRun: false },
)
expect(manualLinkMock).not.toHaveBeenCalled()
expect(result?.applied).toEqual([
{ external_id: R1, journal_entry_id: E1, allocated_amount: -500 },
{ external_id: R1, journal_entry_id: E2, allocated_amount: -300 },
])
expect(result?.skipped).toEqual([])
expect(emitMock).toHaveBeenCalledTimes(2)
expect(emitMock.mock.calls[1][0]).toMatchObject({
type: 'reconciliation.matched',
payload: { accountKey: `bank:${CASH}`, externalId: R1, journalEntryId: E2 },
})
})
it('forwards explicit allocations, refuses a set that does not name exactly the pair\'s verifikat, and never writes on dry run', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ledger_account: '1931' } })
linkToVouchersMock.mockResolvedValue({
success: true,
allocations: [
{ journal_entry_id: E1, amount: -450 },
{ journal_entry_id: E2, amount: -350 },
],
})
const result = await matchPairs(
supabase as never,
COMPANY,
USER,
`bank:${CASH}`,
{
pairs: [
{
external_ids: [R1],
journal_entry_ids: [E1, E2],
allocations: [
{ journal_entry_id: E1, amount: -450 },
{ journal_entry_id: E2, amount: -350 },
],
},
// Names a verifikat outside the pair: refused before the engine.
{ external_ids: [R2], journal_entry_ids: [E1, E2], allocations: [{ journal_entry_id: E1, amount: -800 }] },
// N:M has no engine shape on either account kind.
{ external_ids: [R1, R2], journal_entry_ids: [E1, E2] },
],
},
{ dryRun: true },
)
expect(linkToVouchersMock).toHaveBeenCalledTimes(1)
expect(linkToVouchersMock).toHaveBeenCalledWith(
supabase,
COMPANY,
R1,
[
{ journal_entry_id: E1, amount: -450 },
{ journal_entry_id: E2, amount: -350 },
],
USER,
'1931',
{ dryRun: true },
)
expect(result?.applied).toHaveLength(2)
expect(result?.skipped.map((s) => s.code)).toEqual(['UNSUPPORTED_PAIR_SHAPE', 'UNSUPPORTED_PAIR_SHAPE'])
expect(result?.skipped[0].message).toMatch(/allocations/)
expect(emitMock).not.toHaveBeenCalled()
})
it('a refused split is one PAIR_NOT_CLOSED skip for the whole pair (all or nothing)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ledger_account: '1930' } })
linkToVouchersMock.mockResolvedValue({
success: false,
error: 'Fördelningen (-700) stämmer inte med transaktionens belopp (-800).',
})
const result = await matchPairs(supabase as never, COMPANY, USER, `bank:${CASH}`, {
pairs: [{ external_ids: [R1], journal_entry_ids: [E1, E2] }],
})
expect(result?.applied).toEqual([])
expect(result?.skipped).toEqual([
{
pair: { external_ids: [R1], journal_entry_ids: [E1, E2] },
code: 'PAIR_NOT_CLOSED',
message: 'Fördelningen (-700) stämmer inte med transaktionens belopp (-800).',
},
])
expect(emitMock).not.toHaveBeenCalled()
})
it('a failed bank link is a PAIR_NOT_CLOSED skip, not a throw', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ledger_account: '1930' } })
@@ -172,6 +298,8 @@ describe('unmatchLink / setItemIgnored', () => {
unlinkMock.mockReset()
unlinkReconciliationMock.mockReset()
setIgnoredMock.mockReset()
junctionLinkedMock.mockReset()
junctionLinkedMock.mockResolvedValue(new Set<string>())
emitMock.mockResolvedValue(undefined)
})
@@ -192,6 +320,29 @@ describe('unmatchLink / setItemIgnored', () => {
expect(result).toEqual({ external_id: R1, previous_journal_entry_id: E1 })
})
it('reports the first junction verifikat as previous_journal_entry_id when a split row (no pointer) is unmatched', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { journal_entry_id: null } })
unlinkReconciliationMock.mockResolvedValue({ success: true, previousJournalEntryIds: [E1, E2] })
const result = await unmatchLink(supabase as never, COMPANY, USER, `bank:${CASH}`, R1)
expect(result).toEqual({ external_id: R1, previous_journal_entry_id: E1 })
expect(emitMock.mock.calls[0][0]).toMatchObject({
type: 'reconciliation.unmatched',
payload: { externalId: R1, previousJournalEntryId: E1 },
})
})
it('refuses to ignore a bank transaction anchored only through transaction_voucher_links (1:N split, bulk-book)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: R1, journal_entry_id: null, is_ignored: false } })
junctionLinkedMock.mockResolvedValue(new Set([R1]))
await expect(setItemIgnored(supabase as never, COMPANY, `bank:${CASH}`, R1, true)).rejects.toMatchObject({
code: 'ALREADY_BOOKED',
})
expect(junctionLinkedMock).toHaveBeenCalledWith(supabase, COMPANY, [R1])
expect(findCalls('transactions', 'update')).toEqual([])
})
it('ignores a skattekonto row via the core helper', async () => {
const { supabase } = createQueuedMockSupabase()
setIgnoredMock.mockResolvedValue({ skattekonto_transaction_id: R1, is_ignored: true })
@@ -31,13 +31,14 @@ import {
tryReconcileTransaction,
runReconciliation,
manualLink,
linkTransactionToVouchers,
unlinkReconciliation,
getReconciliationStatus,
scopeTransactionsToAccount,
ledgerLineAmountIn,
} from '../bank-reconciliation'
import type { UnlinkedGLLine } from '../bank-reconciliation'
import { makeTransaction } from '@/tests/helpers'
import { createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
vi.mock('@/lib/supabase/server')
@@ -1009,6 +1010,38 @@ describe('manualLink', () => {
expect(result.success).toBe(true)
})
it('refuses a row anchored through a bank_line junction row (bulk-book, 1:N split) even with a NULL pointer', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = {
...makeTransaction({ id: 'tx-1', journal_entry_id: null }),
transaction_voucher_links: [{ journal_entry_id: 'je-split-a', role: 'bank_line' }],
}
enqueue({ data: tx })
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
expect(result.success).toBe(false)
expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.')
// Nothing past the transaction read: no verifikat lookup, no write.
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('does not treat a residual booking\'s "other" junction row as a link (the row stays re-linkable after a storno)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = {
...makeTransaction({ id: 'tx-1', journal_entry_id: null }),
transaction_voucher_links: [{ journal_entry_id: 'je-residual', role: 'other' }],
}
enqueue({ data: tx })
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
enqueue({ data: [{ id: 'tx-1' }] })
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
expect(result.success).toBe(true)
})
it('rejects when a concurrent linker won the race (0 rows updated)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
@@ -1361,6 +1394,382 @@ describe('manualLink', () => {
})
})
// ============================================================
// linkTransactionToVouchers: ONE bank row over SEVERAL verifikat (1:N, #1553)
// ============================================================
describe('linkTransactionToVouchers', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
const E1 = 'je-utlagg-1'
const E2 = 'je-utlagg-2'
const postedEntries = [
{ id: E1, status: 'posted', voucher_series: 'A', voucher_number: 11 },
{ id: E2, status: 'posted', voucher_series: 'A', voucher_number: 12 },
]
/** Two utlägg verifikat, each crediting 1930: -500 and -300 in bank terms. */
const bankLines = [
{ journal_entry_id: E1, debit_amount: 0, credit_amount: 500, currency: null, amount_in_currency: null },
{ journal_entry_id: E2, debit_amount: 0, credit_amount: 300, currency: null, amount_in_currency: null },
]
const freeTx = (over: Record<string, unknown> = {}) => ({
...makeTransaction({ id: 'tx-1', amount: -800, journal_entry_id: null, reconciliation_method: null }),
transaction_voucher_links: [],
...over,
})
/**
* Queue up to and including the ledger read, in the order the function
* consumes it: tx, invoice_payments, supplier_invoice_payments,
* journal_entries, journal_entry_lines. (cash_account_id is null on the
* fixture, so the cash-account cross-check is skipped.)
*/
function enqueueReads(
enqueue: (r: { data?: unknown; error?: unknown }) => void,
opts: { tx?: Record<string, unknown>; entries?: unknown[]; lines?: unknown[] } = {},
) {
enqueue({ data: opts.tx ?? freeTx() })
enqueue({ data: [] }) // invoice_payments
enqueue({ data: [] }) // supplier_invoice_payments
enqueue({ data: opts.entries ?? postedEntries })
enqueue({ data: opts.lines ?? bankLines })
}
it('links one row to two verifikat: pointer stays NULL, one signed bank_line slice per verifikat, one matched event each', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
const reconciled = vi.fn()
eventBus.on('transaction.reconciled', reconciled)
enqueueReads(enqueue)
enqueue({ data: [{ id: 'tx-1' }] }) // lock UPDATE
enqueue({ data: null }) // junction insert
enqueue({ data: null }) // payment_match_log
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[
{ journal_entry_id: E1, amount: -500 },
{ journal_entry_id: E2, amount: -300 },
],
'user-1',
'1930',
)
expect(result).toEqual({
success: true,
allocations: [
{ journal_entry_id: E1, amount: -500 },
{ journal_entry_id: E2, amount: -300 },
],
})
// The lock: pointer explicitly NULL, method manual, business, locked on
// the pointer being NULL (a concurrent linker makes it match zero rows).
const [lockPayload] = findCalls('transactions', 'update')[0]
expect(lockPayload).toEqual({
journal_entry_id: null,
reconciliation_method: 'manual',
is_business: true,
potential_journal_entry_id: null,
potential_match_method: null,
potential_match_confidence: null,
})
expect(findCalls('transactions', 'is')[0]).toEqual(['journal_entry_id', null])
// One insert carrying both slices.
const inserts = findCalls('transaction_voucher_links', 'insert')
expect(inserts).toHaveLength(1)
expect(inserts[0][0]).toEqual([
{ user_id: 'user-1', company_id: 'company-1', transaction_id: 'tx-1', journal_entry_id: E1, allocated_amount: -500, role: 'bank_line' },
{ user_id: 'user-1', company_id: 'company-1', transaction_id: 'tx-1', journal_entry_id: E2, allocated_amount: -300, role: 'bank_line' },
])
// Behandlingshistorik row with every slice.
const [logRow] = findCalls('payment_match_log', 'insert')[0] as [Record<string, unknown>]
expect(logRow).toMatchObject({ user_id: 'user-1', transaction_id: 'tx-1', action: 'matched', match_method: 'manual' })
expect((logRow.new_state as { journal_entry_ids: string[] }).journal_entry_ids).toEqual([E1, E2])
expect(reconciled).toHaveBeenCalledTimes(2)
// Handlers receive the payload itself (lib/events/bus.ts).
expect(reconciled.mock.calls.map((c) => c[0].journalEntryId)).toEqual([E1, E2])
})
it('defaults an omitted slice to the verifikat\'s net line on the account', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueReads(enqueue)
enqueue({ data: [{ id: 'tx-1' }] })
enqueue({ data: null })
enqueue({ data: null })
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
'user-1',
'1930',
)
expect(result.success).toBe(true)
expect(result.allocations).toEqual([
{ journal_entry_id: E1, amount: -500 },
{ journal_entry_id: E2, amount: -300 },
])
expect(findCalls('transaction_voucher_links', 'insert')).toHaveLength(1)
})
it('dry run validates and resolves the slices without writing', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueReads(enqueue)
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
'user-1',
'1930',
{ dryRun: true },
)
expect(result.success).toBe(true)
expect(result.allocations).toHaveLength(2)
expect(findCalls('transactions', 'update')).toEqual([])
expect(findCalls('transaction_voucher_links', 'insert')).toEqual([])
})
it('refuses when the slices do not sum to the transaction amount, writing nothing', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueReads(enqueue, { tx: freeTx({ amount: -900 }) })
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
'user-1',
'1930',
)
expect(result.success).toBe(false)
expect(result.error).toBe('Fördelningen (-800) stämmer inte med transaktionens belopp (-900).')
expect(findCalls('transactions', 'update')).toEqual([])
expect(findCalls('transaction_voucher_links', 'insert')).toEqual([])
})
it('refuses a slice with the wrong sign for the verifikat\'s bank line', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueReads(enqueue)
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[
{ journal_entry_id: E1, amount: 500 },
{ journal_entry_id: E2, amount: -1300 },
],
'user-1',
'1930',
)
expect(result.success).toBe(false)
expect(result.error).toBe('Beloppet för verifikat A-11 har fel riktning: verifikatet bokför -500 på 1930.')
})
it('refuses a slice larger than the verifikat\'s bank line', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueReads(enqueue, { tx: freeTx({ amount: -1000 }) })
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[
{ journal_entry_id: E1, amount: -700 },
{ journal_entry_id: E2, amount: -300 },
],
'user-1',
'1930',
)
expect(result.success).toBe(false)
expect(result.error).toBe('Beloppet för verifikat A-11 (-700) är större än verifikatets rad på 1930 (-500).')
})
it('refuses a zero slice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueReads(enqueue)
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[
{ journal_entry_id: E1, amount: 0 },
{ journal_entry_id: E2, amount: -800 },
],
'user-1',
'1930',
)
expect(result.success).toBe(false)
expect(result.error).toBe('Beloppet för verifikat A-11 får inte vara 0.')
})
it('refuses fewer than two verifikat, and the same verifikat twice, before reading anything', async () => {
const { supabase } = createQueuedMockSupabase()
const one = await linkTransactionToVouchers(supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }], 'user-1')
expect(one).toEqual({ success: false, error: 'En delning kräver minst två verifikat.' })
const dup = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[{ journal_entry_id: E1 }, { journal_entry_id: E1 }],
'user-1',
)
expect(dup).toEqual({ success: false, error: 'Samma verifikat förekommer flera gånger i fördelningen.' })
expect(supabase.from).not.toHaveBeenCalled()
})
it('refuses a transaction that is already booked: junction row, live pointer, payment row, or ignored', async () => {
// Junction row (bulk-book or an earlier split).
let m = createQueuedMockSupabase()
m.enqueue({ data: freeTx({ transaction_voucher_links: [{ journal_entry_id: 'je-x', role: 'bank_line' }] }) })
let r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
expect(r.error).toBe('Transaktionen är redan kopplad till en verifikation.')
// Live pointer.
m = createQueuedMockSupabase()
m.enqueue({ data: freeTx({ journal_entry_id: 'je-live' }) })
m.enqueue({ data: { status: 'posted' } }) // hasLiveJournalEntryLink
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
expect(r.error).toBe('Transaktionen är redan kopplad till en verifikation.')
// Payment row (match-invoice / match-batch).
m = createQueuedMockSupabase()
m.enqueue({ data: freeTx() })
m.enqueue({ data: [{ id: 'ip-1' }] }) // invoice_payments
m.enqueue({ data: [] })
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
expect(r.error).toBe('Transaktionen är redan matchad mot en faktura.')
// Ignored.
m = createQueuedMockSupabase()
m.enqueue({ data: freeTx({ is_ignored: true }) })
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
expect(r.error).toBe('Transaktionen är ignorerad. Återställ den innan du kopplar.')
})
it('re-links a row stranded on a reversed entry (#988), locking on the stale pointer', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: freeTx({ journal_entry_id: 'je-reversed' }) })
enqueue({ data: { status: 'reversed' } }) // hasLiveJournalEntryLink: stale
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: postedEntries })
enqueue({ data: bankLines })
enqueue({ data: [{ id: 'tx-1' }] })
enqueue({ data: null })
enqueue({ data: null })
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
'user-1',
'1930',
)
expect(result.success).toBe(true)
expect(findCalls('transactions', 'eq').some((args) => args[0] === 'journal_entry_id' && args[1] === 'je-reversed')).toBe(true)
})
it('refuses a verifikat that is not posted, one outside the company, and one without a line on the account', async () => {
let m = createQueuedMockSupabase()
enqueueReads(m.enqueue, { entries: [postedEntries[0], { ...postedEntries[1], status: 'draft' }] })
let r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
expect(r.error).toBe('Verifikat A-12 är inte bokförd ännu.')
// Only one of the two ids comes back inside the company.
m = createQueuedMockSupabase()
enqueueReads(m.enqueue, { entries: [postedEntries[0]] })
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
expect(r.error).toBe('Verifikationen kunde inte hittas.')
// E2 books nothing on 1930 (its bank line is on 1940).
m = createQueuedMockSupabase()
enqueueReads(m.enqueue, { lines: [bankLines[0]] })
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1', '1930')
expect(r.error).toBe('Verifikat A-12 saknar rad på 1930')
})
it('refuses when the transaction belongs to another cash account', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: freeTx({ cash_account_id: 'ca-1940' }) })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: { ledger_account: '1940' } }) // cash_accounts cross-check
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
'user-1',
'1930',
)
expect(result).toEqual({ success: false, error: 'Transaktionen hör till 1940, inte 1930' })
})
it('reports a lost race (0 rows locked) as already linked and inserts nothing', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueReads(enqueue)
enqueue({ data: [] }) // lock UPDATE matched nothing
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
'user-1',
'1930',
)
expect(result).toEqual({ success: false, error: 'Transaktionen är redan kopplad till en verifikation.' })
expect(findCalls('transaction_voucher_links', 'insert')).toEqual([])
})
it('rolls the lock back when the junction insert fails', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueReads(enqueue)
enqueue({ data: [{ id: 'tx-1' }] }) // lock UPDATE
enqueue({ data: null, error: { message: 'duplicate key' } }) // junction insert
enqueue({ data: null }) // compensating delete
enqueue({ data: null }) // restore UPDATE
const result = await linkTransactionToVouchers(
supabase as never,
'company-1',
'tx-1',
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
'user-1',
'1930',
)
expect(result).toEqual({ success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' })
expect(findCalls('transaction_voucher_links', 'delete')).toHaveLength(1)
const updates = findCalls('transactions', 'update')
expect(updates).toHaveLength(2)
expect(updates[1][0]).toEqual({ journal_entry_id: null, reconciliation_method: null, is_business: null })
expect(findCalls('payment_match_log', 'insert')).toEqual([])
})
})
// ============================================================
// unlinkReconciliation
// ============================================================
@@ -1438,6 +1847,29 @@ describe('unlinkReconciliation', () => {
expect(result.success).toBe(true)
})
it('unlinks a split row (no pointer, junction rows only) and reports every verifikat it was anchored to (#1553)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: { id: 'tx-1', journal_entry_id: null, reconciliation_method: 'manual' } })
enqueue({ data: [{ journal_entry_id: 'je-a' }, { journal_entry_id: 'je-b' }] }) // junction rows, read BEFORE the delete
enqueue({ data: null }) // transactions update
enqueue({ data: null }) // junction delete
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
expect(result).toEqual({ success: true, previousJournalEntryIds: ['je-a', 'je-b'] })
})
it('still refuses a row with neither pointer nor junction rows', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: { id: 'tx-1', journal_entry_id: null, reconciliation_method: 'manual' } })
enqueue({ data: [] })
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
expect(result.success).toBe(false)
expect(result.error).toBe('Transaction is not linked to any journal entry')
})
it('attributes the audit log row to the acting user, not the company', async () => {
// Regression: unlinkReconciliation used to pass companyId where
// logMatchEvent expects userId, so payment_match_log.user_id recorded the
+122 -27
View File
@@ -8,7 +8,12 @@ import {
SkattekontoLinkError,
unlinkSkattekontoRow,
} from '@/lib/skatteverket/skattekonto-link'
import { manualLink, unlinkReconciliation } from './bank-reconciliation'
import {
fetchJunctionLinkedTxIds,
linkTransactionToVouchers,
manualLink,
unlinkReconciliation,
} from './bank-reconciliation'
import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation'
import { parseAccountKey } from './schemas'
@@ -27,6 +32,13 @@ export interface ReconciliationPair {
/** Outside rows: transaction ids (bank) or skattekonto_transaction ids. */
external_ids: string[]
journal_entry_ids: string[]
/**
* Bank only, for the 1:N shape (one transaction over several verifikat):
* the signed slice per verifikat, in the transaction's sign convention.
* Omitted: each slice defaults to the voucher's net line on the account.
* Either way the slices must sum to the transaction amount.
*/
allocations?: Array<{ journal_entry_id: string; amount: number }>
}
export type PairSkipCode =
@@ -44,6 +56,8 @@ export interface AppliedLink {
external_id: string
journal_entry_id: string
via?: 'line' | 'entry_total'
/** Present on the links of a 1:N split: the slice of the row this verifikat settles. */
allocated_amount?: number
}
export interface SkippedPair {
@@ -118,9 +132,12 @@ async function proposalsAsPairs(
/**
* Link pairs on one account. A pair is one OR MANY outside rows against
* exactly one verifikat (bank: independent links per transaction; skattekonto:
* all-or-nothing with the sum settling the verifikat). One row against many
* verifikat waits for the residual link table and is reported as
* UNSUPPORTED_PAIR_SHAPE, never silently reduced. Dry run validates shapes and resolves proposals without writing.
* all-or-nothing with the sum settling the verifikat), or, on a bank account,
* ONE transaction against SEVERAL verifikat (1:N, issue #1553): all-or-nothing
* with the slices summing to the transaction. Any other shape is reported as
* UNSUPPORTED_PAIR_SHAPE, never silently reduced. Dry run validates shapes and
* resolves proposals without writing; a 1:N dry run also resolves the slices
* so a reviewer sees exactly what would be linked.
* Partial success is first-class: `applied` and `skipped` together cover
* every considered pair.
*/
@@ -160,16 +177,94 @@ export async function matchPairs(
})
}
// Resolved once, lazily: only bank pairs need the ledger account.
let ledgerAccount: string | null = null
const resolveLedgerAccount = async (): Promise<string> => {
if (ledgerAccount) return ledgerAccount
if (parsed.kind !== 'bank') return '1930'
const { data: account } = await supabase
.from('cash_accounts')
.select('ledger_account')
.eq('company_id', companyId)
.eq('id', parsed.cashAccountId)
.maybeSingle<{ ledger_account: string }>()
ledgerAccount = account?.ledger_account ?? '1930'
return ledgerAccount
}
for (const pair of pairs) {
// N outside rows may settle ONE verifikat (the worksheet selection); the
// reverse shape (one row over several verifikat) waits for the residual
// link table and is refused loudly, never silently reduced.
// N outside rows may settle ONE verifikat (the worksheet selection). ONE
// bank transaction may settle SEVERAL verifikat (the split, #1553). A
// skattekonto row never splits (Skatteverket posts each event as its own
// row), and N:M is refused loudly, never silently reduced.
if (pair.journal_entry_ids.length !== 1) {
skipped.push({
pair,
code: 'UNSUPPORTED_PAIR_SHAPE',
message: 'Flera verifikat i samma par stöds inte ännu: ett par är en eller flera händelser mot ett verifikat.',
})
const journalEntryIds = [...new Set(pair.journal_entry_ids)]
const isBankSplit =
parsed.kind === 'bank' && pair.external_ids.length === 1 && journalEntryIds.length >= 2
if (!isBankSplit) {
skipped.push({
pair,
code: 'UNSUPPORTED_PAIR_SHAPE',
message:
parsed.kind === 'bank'
? 'Ett par är en eller flera händelser mot ett verifikat, eller en händelse mot flera verifikat.'
: 'Flera verifikat i samma par stöds inte på skattekontot: ett par är en eller flera händelser mot ett verifikat.',
})
continue
}
if (journalEntryIds.length > 50) {
skipped.push({
pair,
code: 'UNSUPPORTED_PAIR_SHAPE',
message: 'En händelse kan delas på högst 50 verifikat.',
})
continue
}
// Explicit allocations must name exactly the pair's verifikat, once each.
const given = pair.allocations
if (given) {
const namedIds = given.map((a) => a.journal_entry_id)
const namedSet = new Set(namedIds)
const coversPair =
namedSet.size === namedIds.length &&
namedSet.size === journalEntryIds.length &&
journalEntryIds.every((id) => namedSet.has(id))
if (!coversPair) {
skipped.push({
pair,
code: 'UNSUPPORTED_PAIR_SHAPE',
message: 'allocations måste ange ett belopp för varje verifikat i paret, och inga andra.',
})
continue
}
}
const [externalId] = pair.external_ids
const allocationInput = journalEntryIds.map((id) => ({
journal_entry_id: id,
amount: given?.find((a) => a.journal_entry_id === id)?.amount,
}))
try {
const r = await linkTransactionToVouchers(
supabase,
companyId,
externalId,
allocationInput,
userId,
await resolveLedgerAccount(),
{ dryRun },
)
if (!r.success || !r.allocations) {
skipped.push({ pair, code: 'PAIR_NOT_CLOSED', message: r.error ?? 'Kunde inte koppla' })
continue
}
for (const a of r.allocations) {
applied.push({ external_id: externalId, journal_entry_id: a.journal_entry_id, allocated_amount: a.amount })
if (!dryRun) await emitMatched(externalId, a.journal_entry_id)
}
} catch (err) {
const { code, message } = skipCodeFor(err)
skipped.push({ pair, code, message })
}
continue
}
const externalIds = [...new Set(pair.external_ids)]
@@ -206,24 +301,12 @@ export async function matchPairs(
}
}
} else {
const { data: account } = await supabase
.from('cash_accounts')
.select('ledger_account')
.eq('company_id', companyId)
.eq('id', parsed.cashAccountId)
.maybeSingle<{ ledger_account: string }>()
const account = await resolveLedgerAccount()
// Bank N:1 is per-transaction by design (manualLink documents why the
// engine allows several transactions on one verifikat): each link is
// independent, so partial success is reported per transaction.
for (const externalId of externalIds) {
const r = await manualLink(
supabase,
companyId,
externalId,
journalEntryId,
userId,
account?.ledger_account ?? '1930',
)
const r = await manualLink(supabase, companyId, externalId, journalEntryId, userId, account)
if (!r.success) {
skipped.push({
pair: { external_ids: [externalId], journal_entry_ids: [journalEntryId] },
@@ -279,9 +362,11 @@ export async function unmatchLink(
.eq('company_id', companyId)
.eq('id', linkId)
.maybeSingle<{ journal_entry_id: string | null }>()
previous = tx?.journal_entry_id ?? null
const r = await unlinkReconciliation(supabase, companyId, linkId, userId)
if (!r.success) throw new Error(r.error ?? 'Kunde inte koppla bort')
// A split row (1:N) has no pointer: the engine collected its junction
// vouchers before deleting them, so the first one is reported here.
previous = tx?.journal_entry_id ?? r.previousJournalEntryIds?.[0] ?? null
}
await eventBus.emit({
type: 'reconciliation.unmatched',
@@ -318,6 +403,16 @@ export async function setItemIgnored(
if (ignored && tx.journal_entry_id) {
throw new SkattekontoLinkError('En bokförd transaktion kan inte ignoreras.', 'ALREADY_BOOKED')
}
// A row anchored only through transaction_voucher_links (bulk-book, 1:N
// split) has two counterparts in the ledger; ignoring it would drop the
// bank side while the ledger keeps it and manufacture a difference of the
// full amount (issue #1553, field note).
if (ignored && !tx.journal_entry_id) {
const junctionLinked = await fetchJunctionLinkedTxIds(supabase, companyId, [itemId])
if (junctionLinked.has(itemId)) {
throw new SkattekontoLinkError('En bokförd transaktion kan inte ignoreras.', 'ALREADY_BOOKED')
}
}
if (Boolean(tx.is_ignored) !== ignored) {
const { error: updateError } = await supabase
.from('transactions')
+370 -14
View File
@@ -6,6 +6,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
import {
ledgerLineAmountIn,
type LedgerLineAmount,
@@ -1059,23 +1060,38 @@ export async function manualLink(
userId: string,
accountNumber: string = '1930',
): Promise<{ success: boolean; error?: string }> {
// Fetch transaction
const { data: tx, error: txError } = await supabase
// Fetch transaction. The junction rows ride along on the same read: a row
// split over several verifikat (linkTransactionToVouchers) or bulk-booked
// into a samlingsverifikat carries journal_entry_id = NULL, and the pointer
// alone would let it be linked a second time. Only 'bank_line' rows count:
// they are the slices that explain the row's bank amount. A residual
// booking's row (role 'other') is supplementary and must not strand the
// row after a storno of its main verifikat nulls the pointer.
const { data: txRow, error: txError } = await supabase
.from('transactions')
.select('*')
.select('*, transaction_voucher_links(journal_entry_id, role)')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txError || !tx) {
if (txError || !txRow) {
return { success: false, error: 'Transaktionen kunde inte hittas.' }
}
const { transaction_voucher_links: junctionRows, ...tx } = txRow as Record<string, unknown> & {
transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null
}
if (hasBankLineJunctionRow(junctionRows)) {
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
}
// Only a LIVE (posted) pointer blocks re-linking. A transaction still pointing
// at a 'reversed' entry (storno/correction left the link behind) reads as
// "utan koppling" in the UI, so it must be re-linkable to another verifikat
// (issue #988). The stale pointer is overwritten by the locked UPDATE below.
if (tx.journal_entry_id && (await hasLiveJournalEntryLink(supabase, companyId, tx.journal_entry_id))) {
if (
typeof tx.journal_entry_id === 'string' &&
(await hasLiveJournalEntryLink(supabase, companyId, tx.journal_entry_id))
) {
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
}
@@ -1109,7 +1125,7 @@ export async function manualLink(
// currency), stay rejected.
let allowedLineAccounts: string[] = [accountNumber]
let siblingInfo: CashAccountSiblings | null = null
if (tx.cash_account_id) {
if (typeof tx.cash_account_id === 'string') {
const { data: txCa } = await supabase
.from('cash_accounts')
.select('ledger_account')
@@ -1213,7 +1229,8 @@ export async function manualLink(
// lets the stale-pointer overwrite through while a concurrent re-link becomes
// a no-op (0 rows → the "redan kopplad" branch below). Same optimistic-lock
// pattern as lib/transactions/link-journal-entry.ts.
const previousJournalEntryId = (tx.journal_entry_id as string | null) ?? null
const previousJournalEntryId =
typeof tx.journal_entry_id === 'string' ? tx.journal_entry_id : null
const linkUpdate = supabase
.from('transactions')
.update({
@@ -1240,7 +1257,7 @@ export async function manualLink(
eventBus.emit({
type: 'transaction.reconciled',
payload: {
transaction: tx as Transaction,
transaction: tx as unknown as Transaction,
journalEntryId,
method: 'manual' as ReconciliationMethod,
userId,
@@ -1254,16 +1271,340 @@ export async function manualLink(
return { success: true }
}
/** One slice of a bank transaction settled on one verifikat (1:N link). */
export interface VoucherAllocation {
journal_entry_id: string
/** Signed, in the transaction's currency: the transaction's sign convention
* (negative = money out), the same one bulk_book_transactions writes. */
amount: number
}
/** Input slice: an omitted amount defaults to the voucher's net line on the account. */
export interface VoucherAllocationInput {
journal_entry_id: string
amount?: number
}
export interface LinkTransactionToVouchersResult {
success: boolean
error?: string
/** The slices as validated, defaults resolved. Present on success and on dry runs. */
allocations?: VoucherAllocation[]
}
/**
* Link ONE bank transaction to SEVERAL posted verifikat (1:N): a lump payout
* covering utlägg booked per receipt, a Bankgirot deposit aggregating two
* customer payments, a Spiris-era salary voucher per employee paid in one
* transfer (issue #1553). The mirror image of the N:1 shape manualLink
* documents, and the counterpart of the invoice split in match-batch.
*
* Storage: transactions.journal_entry_id stays NULL and one
* transaction_voucher_links row per verifikat carries the signed slice
* (role 'bank_line'), exactly how bulk_book_transactions anchors a
* samlingsverifikat. Every reader that asks "is this row booked?" through
* isTransactionBooked / is_transaction_booked() therefore already sees it;
* the pointer column is never the answer for a split row.
*
* Invariants (all refused, nothing written):
* - the transaction exists in the company, is not ignored and is not booked
* (live pointer, junction row or payment row);
* - at least two distinct verifikat, each posted, in the company, with a
* line on the settlement account;
* - every slice is non-zero, has the sign of that voucher's net line on the
* account and is not larger than it (a voucher cannot absorb more of the
* bank row than it books on the account);
* - the slices sum to the transaction amount within the öre tolerance: the
* same rule match-batch enforces, and the one the reconciliation
* difference depends on (a split that does not close would hide a real
* imbalance behind a "matched" row).
*
* Write order: the optimistic-locked transactions UPDATE first (it is the
* race guard: a concurrent linker makes it match zero rows), then the
* junction rows in one insert; a failed insert rolls the UPDATE back.
*/
export async function linkTransactionToVouchers(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
allocations: VoucherAllocationInput[],
userId: string,
accountNumber: string = '1930',
options: { dryRun?: boolean } = {},
): Promise<LinkTransactionToVouchersResult> {
const journalEntryIds = allocations.map((a) => a.journal_entry_id)
if (new Set(journalEntryIds).size !== journalEntryIds.length) {
return { success: false, error: 'Samma verifikat förekommer flera gånger i fördelningen.' }
}
if (journalEntryIds.length < 2) {
return { success: false, error: 'En delning kräver minst två verifikat.' }
}
if (journalEntryIds.length > 50) {
return { success: false, error: 'En delning kan omfatta högst 50 verifikat.' }
}
const { data: txRow, error: txError } = await supabase
.from('transactions')
.select('*, transaction_voucher_links(journal_entry_id, role)')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txError || !txRow) {
return { success: false, error: 'Transaktionen kunde inte hittas.' }
}
const { transaction_voucher_links: junctionRows, ...tx } = txRow as Record<string, unknown> & {
transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null
}
if (tx.is_ignored === true) {
return { success: false, error: 'Transaktionen är ignorerad. Återställ den innan du kopplar.' }
}
// Stricter than manualLink on purpose: a split is the whole explanation of
// the row, so ANY junction row (a residual's 'other' row included) makes it
// ineligible; the UNIQUE (transaction_id, journal_entry_id) key would refuse
// a re-anchor of that voucher anyway.
if (Array.isArray(junctionRows) && junctionRows.length > 0) {
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
}
if (
typeof tx.journal_entry_id === 'string' &&
(await hasLiveJournalEntryLink(supabase, companyId, tx.journal_entry_id))
) {
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
}
// Third anchor of isTransactionBooked: a payment row (match-invoice,
// match-batch) settles the row through invoice_payments /
// supplier_invoice_payments with the pointer left NULL.
const [{ data: invoicePayments }, { data: supplierPayments }] = await Promise.all([
supabase
.from('invoice_payments')
.select('id')
.eq('transaction_id', transactionId)
.limit(1),
supabase
.from('supplier_invoice_payments')
.select('id')
.eq('transaction_id', transactionId)
.limit(1),
])
if ((invoicePayments?.length ?? 0) > 0 || (supplierPayments?.length ?? 0) > 0) {
return { success: false, error: 'Transaktionen är redan matchad mot en faktura.' }
}
// The transaction must belong to the account being reconciled (same guard
// as manualLink). Sibling-ledger re-pointing is deliberately not offered on
// the split path: every verifikat must carry its line on this account.
if (typeof tx.cash_account_id === 'string') {
const { data: txCa } = await supabase
.from('cash_accounts')
.select('ledger_account')
.eq('id', tx.cash_account_id)
.eq('company_id', companyId)
.maybeSingle()
if (txCa?.ledger_account && txCa.ledger_account !== accountNumber) {
return {
success: false,
error: `Transaktionen hör till ${txCa.ledger_account}, inte ${accountNumber}`,
}
}
}
const { data: entries } = await supabase
.from('journal_entries')
.select('id, status, voucher_series, voucher_number')
.eq('company_id', companyId)
.in('id', journalEntryIds)
const entryById = new Map(
((entries ?? []) as Array<{
id: string
status: string
voucher_series: string | null
voucher_number: number | null
}>).map((e) => [e.id, e]),
)
const labelOf = (id: string): string => {
const e = entryById.get(id)
return e && e.voucher_number != null ? `${e.voucher_series ?? 'A'}-${e.voucher_number}` : id.slice(0, 8)
}
for (const id of journalEntryIds) {
const entry = entryById.get(id)
if (!entry) return { success: false, error: 'Verifikationen kunde inte hittas.' }
if (entry.status !== 'posted') {
return { success: false, error: `Verifikat ${labelOf(id)} är inte bokförd ännu.` }
}
}
// Each voucher's net movement on the account, in the transaction's currency
// (the split persists a reconciliation link, so it must compare in the
// account's own unit; see ledgerLineAmountIn).
const currency = typeof tx.currency === 'string' && tx.currency ? tx.currency : 'SEK'
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('journal_entry_id, debit_amount, credit_amount, currency, amount_in_currency')
.in('journal_entry_id', journalEntryIds)
.eq('account_number', accountNumber)
const netByEntry = new Map<string, number | null>()
for (const line of (lines ?? []) as Array<LedgerLineAmount & { journal_entry_id: string }>) {
const amount = ledgerLineAmountIn(line, currency)
const prev = netByEntry.has(line.journal_entry_id) ? netByEntry.get(line.journal_entry_id) : 0
netByEntry.set(line.journal_entry_id, prev === null || amount === null ? null : roundOre((prev ?? 0) + amount))
}
const txAmount = roundOre(Number(tx.amount))
const resolved: VoucherAllocation[] = []
for (const input of allocations) {
const label = labelOf(input.journal_entry_id)
if (!netByEntry.has(input.journal_entry_id)) {
return { success: false, error: `Verifikat ${label} saknar rad på ${accountNumber}` }
}
const net = netByEntry.get(input.journal_entry_id) ?? null
if (net === null) {
return { success: false, error: `Verifikat ${label} saknar belopp i ${currency} på ${accountNumber}` }
}
const slice = roundOre(input.amount ?? net)
if (Math.abs(slice) < VOUCHER_LINK_AMOUNT_TOLERANCE) {
return { success: false, error: `Beloppet för verifikat ${label} får inte vara 0.` }
}
if (Math.sign(slice) !== Math.sign(net)) {
return {
success: false,
error: `Beloppet för verifikat ${label} har fel riktning: verifikatet bokför ${net} på ${accountNumber}.`,
}
}
if (Math.abs(slice) > Math.abs(net) + VOUCHER_LINK_AMOUNT_TOLERANCE) {
return {
success: false,
error: `Beloppet för verifikat ${label} (${slice}) är större än verifikatets rad på ${accountNumber} (${net}).`,
}
}
resolved.push({ journal_entry_id: input.journal_entry_id, amount: slice })
}
const sliceSum = roundOre(resolved.reduce((sum, a) => sum + a.amount, 0))
if (Math.abs(sliceSum - txAmount) > VOUCHER_LINK_AMOUNT_TOLERANCE) {
return {
success: false,
error: `Fördelningen (${sliceSum}) stämmer inte med transaktionens belopp (${txAmount}).`,
}
}
if (options.dryRun) {
return { success: true, allocations: resolved }
}
// Race guard first: the pointer is re-checked inside the write. A stale
// pointer at a reversed entry (#988) is cleared by locking on its known
// value; a free row locks on NULL. Zero rows means someone else linked the
// row between our read and this write.
const previousJournalEntryId =
typeof tx.journal_entry_id === 'string' ? tx.journal_entry_id : null
const lockUpdate = supabase
.from('transactions')
.update({
journal_entry_id: null,
reconciliation_method: 'manual' as ReconciliationMethod,
is_business: true,
potential_journal_entry_id: null,
potential_match_method: null,
potential_match_confidence: null,
})
.eq('id', transactionId)
.eq('company_id', companyId)
const { data: lockedRows, error: lockError } = await (previousJournalEntryId === null
? lockUpdate.is('journal_entry_id', null)
: lockUpdate.eq('journal_entry_id', previousJournalEntryId)
).select('id')
if (lockError) {
return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' }
}
if (!lockedRows || lockedRows.length === 0) {
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
}
const { error: insertError } = await supabase.from('transaction_voucher_links').insert(
resolved.map((a) => ({
user_id: userId,
company_id: companyId,
transaction_id: transactionId,
journal_entry_id: a.journal_entry_id,
allocated_amount: a.amount,
role: 'bank_line',
})),
)
if (insertError) {
// Roll the lock back so the row is exactly where it was; the junction had
// no rows for this transaction (checked above), so a blanket delete only
// removes what a partial insert may have left.
await supabase
.from('transaction_voucher_links')
.delete()
.eq('company_id', companyId)
.eq('transaction_id', transactionId)
await supabase
.from('transactions')
.update({
journal_entry_id: previousJournalEntryId,
reconciliation_method:
typeof tx.reconciliation_method === 'string'
? (tx.reconciliation_method as ReconciliationMethod)
: null,
is_business: typeof tx.is_business === 'boolean' ? tx.is_business : null,
})
.eq('id', transactionId)
.eq('company_id', companyId)
log.error('linkTransactionToVouchers: junction insert failed, lock rolled back', insertError, {
companyId,
transactionId,
})
return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' }
}
// Behandlingshistorik (BFNAR 2013:2 kap 8): one match event for the row,
// carrying every slice, mirroring the auto-apply loop in runReconciliation.
await logMatchEvent(supabase, userId, transactionId, 'matched', {
matchMethod: 'manual',
newState: {
journal_entry_ids: resolved.map((a) => a.journal_entry_id),
allocations: resolved,
reconciliation_method: 'manual',
},
})
for (const a of resolved) {
try {
eventBus.emit({
type: 'transaction.reconciled',
payload: {
transaction: tx as unknown as Transaction,
journalEntryId: a.journal_entry_id,
method: 'manual' as ReconciliationMethod,
userId,
companyId,
},
})
} catch {
// Non-critical
}
}
return { success: true, allocations: resolved }
}
export interface UnlinkReconciliationResult {
success: boolean
error?: string
/** Verifikat the row was anchored to before the unlink (pointer first, then junction rows). */
previousJournalEntryIds?: string[]
}
/**
* Remove a reconciliation link.
* Only allowed when reconciliation_method IS NOT NULL (prevents unlinking categorization-created entries).
* A row with no pointer but junction rows (a 1:N split) is unlinkable too.
*/
export async function unlinkReconciliation(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
userId: string,
): Promise<{ success: boolean; error?: string }> {
): Promise<UnlinkReconciliationResult> {
// Fetch transaction
const { data: tx, error: txError } = await supabase
.from('transactions')
@@ -1276,8 +1617,21 @@ export async function unlinkReconciliation(
return { success: false, error: 'Transaction not found' }
}
// Collected BEFORE the delete so the audit row and the caller's
// previous_journal_entry_id are never null for a split row.
const previousJournalEntryIds: string[] = tx.journal_entry_id ? [tx.journal_entry_id as string] : []
if (!tx.journal_entry_id) {
return { success: false, error: 'Transaction is not linked to any journal entry' }
const { data: junctionRows } = await supabase
.from('transaction_voucher_links')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('transaction_id', transactionId)
for (const row of (junctionRows ?? []) as Array<{ journal_entry_id: string }>) {
previousJournalEntryIds.push(row.journal_entry_id)
}
if (previousJournalEntryIds.length === 0) {
return { success: false, error: 'Transaction is not linked to any journal entry' }
}
}
if (!tx.reconciliation_method) {
@@ -1298,8 +1652,9 @@ export async function unlinkReconciliation(
return { success: false, error: 'Failed to unlink transaction' }
}
// A residual booking (or a bulk-book) anchors the same transaction through
// transaction_voucher_links as well; "koppla bort" means every anchor goes.
// A residual booking, a bulk-book or a 1:N split anchors the same
// transaction through transaction_voucher_links as well; "koppla bort"
// means every anchor goes.
await supabase
.from('transaction_voucher_links')
.delete()
@@ -1308,12 +1663,13 @@ export async function unlinkReconciliation(
logMatchEvent(supabase, userId, transactionId, 'unmatched', {
previousState: {
journal_entry_id: tx.journal_entry_id,
journal_entry_id: previousJournalEntryIds[0] ?? null,
journal_entry_ids: previousJournalEntryIds,
reconciliation_method: tx.reconciliation_method,
},
})
return { success: true }
return { success: true, previousJournalEntryIds }
}
/** Float tolerance for matching a bank line to a verifikat (0.5 öre). */
@@ -0,0 +1,111 @@
/**
* categorizeMatchedTransaction refuses a transaction that is anchored through
* transaction_voucher_links (issue #1553).
*
* A row bulk-booked into a samlingsverifikat, or split over several verifikat
* (linkTransactionToVouchers), carries journal_entry_id = NULL: the pointer
* alone reads as "unbooked" and the categorize path would book it a second
* time. The guard is role-aware: only 'bank_line' rows are slices of the
* bank amount; a residual booking's 'other' row (lib/reconciliation/
* residual.ts) left behind by a storno of the main verifikat must not strand
* the row (its pointer is NULL and koppla-bort refuses it too).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const mockCreateJE = vi.fn()
const mockCheckPeriodLock = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
}))
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
reverseOrphanedJournalEntry: vi.fn(),
}))
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn(),
}))
vi.mock('@/lib/transactions/link-journal-entry', () => ({
hasLiveJournalEntryLink: vi.fn().mockResolvedValue(false),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/api/v1/check-period-lock', () => ({
checkPeriodLock: (...args: unknown[]) => mockCheckPeriodLock(...args),
}))
import { categorizeMatchedTransaction } from '../categorize-core'
const TX_ID = '00000000-0000-4000-8000-0000000000aa'
const txRow = (links: Array<{ journal_entry_id: string; role: string }>) => ({
id: TX_ID,
company_id: 'company-1',
date: '2026-06-11',
amount: -800,
currency: 'SEK',
amount_sek: -800,
exchange_rate: 1,
description: 'UTBETALNING',
merchant_name: null,
cash_account_id: null,
document_id: null,
journal_entry_id: null,
transaction_voucher_links: links,
})
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockCreateJE.mockResolvedValue(null)
})
describe('categorizeMatchedTransaction: transaction_voucher_links guard (#1553)', () => {
it('returns 409 and writes nothing for a row split over several verifikat (bank_line rows, NULL pointer)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: txRow([
{ journal_entry_id: 'je-utlagg-1', role: 'bank_line' },
{ journal_entry_id: 'je-utlagg-2', role: 'bank_line' },
]),
})
const result = await categorizeMatchedTransaction(supabase as never, 'user-1', 'company-1', TX_ID, {
category: 'expense_other',
})
expect(result.status).toBe(409)
expect(result.error).toMatch(/already has a journal entry/)
expect(findCalls('transactions', 'update')).toEqual([])
expect(mockCreateJE).not.toHaveBeenCalled()
// The read carried the junction rows along: no second query was needed.
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('lets a row carrying only a residual "other" row past the guard', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow([{ journal_entry_id: 'je-residual', role: 'other' }]) })
enqueue({ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }) // company_settings
enqueue({ data: [] }) // resolveSettlementAccount
enqueue({ data: [] }) // ensureFiscalPeriod: no open period
enqueue({ data: [{ period_start: '2026-01-01' }] })
enqueue({ data: null })
mockCheckPeriodLock.mockResolvedValue({ locked: true, reason: 'period_is_closed', fiscal_period_id: 'fp-2026' })
const result = await categorizeMatchedTransaction(supabase as never, 'user-1', 'company-1', TX_ID, {
category: 'expense_other',
})
// Reached the engine (which fails closed on the locked period here):
// the junction guard did not fire.
expect(result.status).not.toBe(409)
expect(mockCreateJE).toHaveBeenCalled()
})
})
@@ -13,7 +13,7 @@
*/
import { describe, it, expect } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { hasLiveJournalEntryLink } from '../link-journal-entry'
import { hasLiveJournalEntryLink, linkTransactionToJournalEntry } from '../link-journal-entry'
describe('hasLiveJournalEntryLink', () => {
it('returns false for a null/undefined pointer without querying', async () => {
@@ -52,3 +52,56 @@ describe('hasLiveJournalEntryLink', () => {
expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', 'je-1')).toBe(true)
})
})
describe('linkTransactionToJournalEntry: junction-row guard (#1553)', () => {
const base = {
id: 'tx-1',
date: '2026-06-11',
amount: -800,
currency: 'SEK',
exchange_rate: null,
journal_entry_id: null,
invoice_id: null,
is_business: null,
potential_invoice_id: null,
potential_supplier_invoice_id: null,
}
it('refuses a transaction anchored through a bank_line junction row (1:N split, bulk-book) even though the pointer is NULL', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: { ...base, transaction_voucher_links: [{ journal_entry_id: 'je-split-a', role: 'bank_line' }] },
error: null,
})
const outcome = await linkTransactionToJournalEntry(supabase as never, 'user-1', 'company-1', {
transactionId: 'tx-1',
journalEntryId: 'je-other',
})
expect(outcome).toEqual({
ok: false,
code: 'LINK_TX_TX_ALREADY_LINKED',
details: { existingJournalEntryId: 'je-split-a' },
})
expect(findCalls('transactions', 'update')).toEqual([])
})
it('lets a residual booking\'s "other" row through: the row must stay re-linkable after a storno of its main verifikat', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { ...base, transaction_voucher_links: [{ journal_entry_id: 'je-residual', role: 'other' }] },
error: null,
})
enqueue({ data: null, error: { message: 'not found' } }) // the verifikat lookup that follows the guard
const outcome = await linkTransactionToJournalEntry(supabase as never, 'user-1', 'company-1', {
transactionId: 'tx-1',
journalEntryId: 'je-other',
})
expect(outcome.ok).toBe(false)
expect((outcome as { code: string }).code).not.toBe('LINK_TX_TX_ALREADY_LINKED')
expect(supabase.from).toHaveBeenCalledWith('journal_entries')
})
})
+17 -3
View File
@@ -43,6 +43,7 @@ import {
type BookingDuplicateExclusions,
} from '@/lib/transactions/booking-duplicate-detection'
import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { createLogger } from '@/lib/logger'
@@ -248,12 +249,25 @@ export async function categorizeMatchedTransaction(
): Promise<CategorizeCoreResult> {
const { category, vatTreatment, vatAmount, notes, allowDuplicate, dimensions, accountOverride } = opts
const { data: transaction, error: fetchError } = await supabase
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
// The junction rows ride along on the same read: a row bulk-booked into a
// samlingsverifikat or split over several verifikat (1:N, #1553) carries
// journal_entry_id = NULL, and the pointer alone would let it be booked a
// second time. Only 'bank_line' rows count (hasBankLineJunctionRow): a
// residual's 'other' row left behind by a storno must stay re-bookable.
const { data: transactionRow, error: fetchError } = await supabase
.from('transactions')
.select('*, transaction_voucher_links(journal_entry_id, role)')
.eq('id', txId)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
if (fetchError || !transactionRow) {
return { error: 'Transaction not found: it may have been deleted.', status: 404 }
}
const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow
if (hasBankLineJunctionRow(junctionLinks)) {
return { error: 'Transaction already has a journal entry: it was categorized in the meantime.', status: 409 }
}
// A stale pointer at a 'reversed' entry (storno/correction left it behind)
// must not block re-categorization: the row reads as "utan koppling" in the
// UI, so a fresh booking has to be allowed (issue #988). Only a live posted
+19
View File
@@ -85,3 +85,22 @@ export function getPrimaryJournalEntryId(
const payment = payments.find((p) => p.transaction_id === tx.id && p.journal_entry_id != null)
return payment?.journal_entry_id ?? null
}
/**
* The re-booking guards' narrower question: does an embedded
* transaction_voucher_links set hold a 'bank_line' row? A bank_line row is a
* slice of the row's bank amount (the bulk-book samlingsverifikat, the 1:N
* split of issue #1553), so its presence means the row is booked and a
* second booking (manualLink, categorize, link-journal-entry) must refuse.
* Rows with role 'other' (a residual booking, lib/reconciliation/residual.ts)
* or 'clearing' are supplementary anchors: after a storno of the main
* verifikat nulls the pointer, that leftover row must not strand the
* transaction with no way to re-book it. The list readers (fetchJunction-
* LinkedTxIds, is_transaction_booked()) keep counting every role.
*/
export function hasBankLineJunctionRow(
rows: Array<{ role?: string | null }> | null | undefined,
): boolean {
if (!Array.isArray(rows)) return false
return rows.some((row) => (row.role ?? 'bank_line') === 'bank_line')
}
+20 -3
View File
@@ -19,6 +19,7 @@ import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-inv
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
import { createLogger } from '@/lib/logger'
import type { Invoice, Transaction } from '@/types'
@@ -131,18 +132,34 @@ export async function linkTransactionToJournalEntry(
// Data minimization (GDPR Art.5(1)(c)): pull only the columns needed for
// validation, optimistic-lock invoice update, invoice_payments insert, and
// the compensating-rollback path. No select('*').
const { data: transaction, error: fetchTxError } = await supabase
// transaction_voucher_links rides along on the same read: a row bulk-booked
// into a samlingsverifikat or split over several verifikat (1:N, #1553)
// carries journal_entry_id = NULL and must still refuse a second link. Only
// 'bank_line' rows count (hasBankLineJunctionRow): a residual's 'other' row
// left behind by a storno must stay re-linkable.
const { data: transactionRow, error: fetchTxError } = await supabase
.from('transactions')
.select(
'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id'
'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id, transaction_voucher_links(journal_entry_id, role)'
)
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (fetchTxError || !transaction) {
if (fetchTxError || !transactionRow) {
return { ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' }
}
const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as typeof transactionRow & {
transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null
}
if (hasBankLineJunctionRow(junctionLinks)) {
const bankLine = junctionLinks!.find((row) => (row.role ?? 'bank_line') === 'bank_line')!
return {
ok: false,
code: 'LINK_TX_TX_ALREADY_LINKED',
details: { existingJournalEntryId: bankLine.journal_entry_id },
}
}
// Only a LIVE (posted) pointer blocks re-linking. A pointer left behind by a
// storno/correction references a 'reversed' entry: the UI already shows the