fix(storno): return stornoed bank transactions to Att bokfora (#1985)

* fix(storno): return stornoed bank transactions to Att bokfora

reverseEntry() unlinked bank transactions from the reversed entry by
clearing only journal_entry_id. The worklist's "unbooked" predicate is
is_business IS NULL AND is_ignored = false (lib/worklist/types.ts), so
the row stayed "handled": absent from Att bokfora and from the nav badge,
while the storno dialog (reverse_warning) promised the opposite (#1950).

The engine now resets the same triple the uncategorize paths write
(journal_entry_id, is_business, category) plus reconciliation_method,
scoped to rows linked to the reversed entry. Fixed in the engine so the
dashboard, v1 and MCP reverse doors all agree.

Closes #1950

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(storno): release bulk-booked bank rows anchored through transaction_voucher_links

The #1950 fix reset transactions scoped by journal_entry_id, but bulk-booked
samlingsverifikat (bulk_book_transactions RPC) anchor their N>1 bank rows
through transaction_voucher_links only (journal_entry_id stays NULL), so the
reset matched nothing there: all rows kept is_business = true against a
status='reversed' entry, stayed out of Att bokfora and the nav badge, and
is_transaction_booked() still reported them booked. The N=1 variant left a
dangling link row that blocked re-booking (BULK_BOOK_TX_ALREADY_BOOKED) and
kept the reconciliation bridge bucketing the row as matched.

reverseEntry now deletes the reversed entry's junction rows (the same removal
koppla-bort performs) and releases is_business, category and
reconciliation_method only for rows left with no anchor: a remaining-links
read plus journal_entry_id IS NULL guards residual bookings (main verifikat
in journal_entry_id, junction row to the residual verifikat) and
multi-allocated rows so stornoing one voucher never unbooks a still-booked
row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(bookkeeping): restore the booked triple in fix-cash-mismatch's transaction relink

The widened reverseEntry reset (#1950) now nulls is_business, category
and reconciliation_method together with journal_entry_id on the linked
transaction, but the fix-cash-mismatch remediation relinked with only
journal_entry_id. The repaired row ended up booked (pointer at the
posted clearing entry) yet visible in Att bokfora and the nav badge
(worklist predicate: is_business IS NULL), the inverted #1950 symptom;
booking it from the list would conflict-storno the correct clearing
entry and corrupt the AR chain the route just repaired.

The relink now restores the full booked triple, mirroring the
match-invoice route's final update. New route tests cover auth 401,
validation 400, the no-targets path, and assert both relink payloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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:
Jakob Wennberg
2026-08-27 22:24:55 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 533df34369
commit cb9ae15d46
5 changed files with 473 additions and 38 deletions
+3
View File
@@ -1298,6 +1298,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response_<ts>_pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build.
[2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change.
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
[2026-08-27] reverseEntry resets is_business, category and reconciliation_method together with journal_entry_id when it unlinks bank transactions (#1950): the worklist predicate is is_business IS NULL (lib/worklist/types.ts), so clearing only the link hid stornoed rows from Att bokföra and the nav badge while the reverse_warning dialog promised the opposite. The predicate was not switched to journal_entry_id IS NULL because bulk-booked and multi-allocated rows keep it NULL while booked (lib/transactions/is-booked.ts). Fixed in the engine, not per reverse route, so dashboard, v1 and MCP stornos all agree.
[2026-08-27] reverseEntry also deletes the reversed entry's transaction_voucher_links rows and releases (is_business, category, reconciliation_method to null) only the rows left with no anchor: bulk-booked samlingsverifikat anchor N>1 bank rows through the junction alone, so the journal_entry_id-scoped reset (#1950) matched nothing there and the rows stayed out of Att bokföra against a reversed entry. The release is guarded by a remaining-links read plus journal_entry_id IS NULL because a residual booking (lib/reconciliation/residual.ts) keeps the main verifikat in journal_entry_id while its junction row points at the residual verifikat; stornoing the residual must not put a still-booked row back in the list.
[2026-08-27] fix-cash-mismatch restores the full booked triple (is_business, category, reconciliation_method) in its transaction relink instead of reverseEntry growing a skip-reset option: the remediation re-books the same economic event immediately, so the caller mirrors the match-invoice route's final update; an engine opt-out flag would invite every reverse caller to bypass the #1950 return-to-Att-bokfora guarantee.
[2026-08-27] Supplier payment batch creation is atomic via create_supplier_payment_batch (SECURITY DEFINER, #1503): TS keeps the eligibility evaluation and msg_id minting (branding lives in TS), the RPC locks the invoices FOR UPDATE in id order, rechecks active batches inside the transaction, and writes header + items in one statement; domain refusals return jsonb (match_batch_allocate shape) because no write precedes them, the tenant guard raises 42501 (detach_underlag_duplicate shape), and the best-effort cancel of an empty header is gone because the header can no longer outlive its items.
[2026-08-27] Jämkning UI (EmployeeTaxCard) requires both jamkning_valid_from and jamkning_valid_to while the API keeps valid_to optional: isJamkningValid (calculation-engine.ts) never applies a beslut without an end date, so the form enforces what the engine needs; the API/MCP contract is left untouched for compatibility (follow-up: align engine or API, founder call). The legacy PATCH /api/salary/employees/[id] gets the same merged start-date/ordering check as v1 so the UI gets a 400 instead of a silently inert beslut.
[2026-08-27] Jämkning keys reach the employee PATCH body only when the inputs were visible (A-skatt, not sidoinkomst) and edited this session (jamkningPatch + EmployeeTaxValue.jamkning_touched), otherwise omitted like every other sparse field; native required on the two dates is likewise gated on touched: the card reports null for hidden inputs and the route spreads the body, so forwarding those nulls wiped a stored beslut on a sidoinkomst toggle or an FA-skatt phone-number edit (the engine still applies it for FA-skatt), and an API/MCP row without valid_to blocked native form validation on unrelated edits. Untouched-omit over touched-null: the route's own touched-gate already assumes absent keys leave the row alone.
@@ -0,0 +1,216 @@
/**
* Tests for POST /api/bookkeeping/fix-cash-mismatch.
*
* Exercises the remediation through the real withRouteContext wrapper with
* auth/company/write mocked. The load-bearing assertion is the transaction
* relink payload: reverseEntry (step 1 of the remediation) resets the whole
* booked state on the linked row (journal_entry_id, is_business, category,
* reconciliation_method: the #1950 return-to-Att-bokfora fix), so the relink
* must restore the full booked triple, not just the journal_entry_id pointer.
* Restoring only the pointer leaves a booked row with is_business NULL,
* visible in Att bokfora while linked to a posted entry: the inverted #1950
* symptom.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoicePaymentJournalEntry: vi.fn(),
}))
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { createInvoicePaymentJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { POST } from '../route'
const mockReverseEntry = vi.mocked(reverseEntry)
const mockCreateClearing = vi.mocked(createInvoicePaymentJournalEntry)
const mock = createQueuedMockSupabase()
/** Queue the three findAffected reads for one affected payment. */
function enqueueOneAffectedPayment() {
mock.enqueueMany([
// journal_entries: posted cash-path payment JEs
{ data: [{ id: 'je-cash', source_id: 'inv-1', status: 'posted' }] },
// invoices: the invoice still carries its own accrual JE
{
data: [
{
id: 'inv-1',
invoice_number: 'F-100',
journal_entry_id: 'je-invoice',
customer: { name: 'Acme AB' },
},
],
},
// invoice_payments: the affected payment, matched to a bank transaction
{
data: [
{
id: 'pay-1',
invoice_id: 'inv-1',
journal_entry_id: 'je-cash',
amount: 1250,
payment_date: '2026-01-15',
transaction_id: 'tx-1',
},
],
},
])
}
beforeEach(() => {
vi.clearAllMocks()
mock.reset()
requireAuthMock.mockResolvedValue({
user: { id: 'user-1' },
supabase: mock.supabase,
})
requireWriteMock.mockResolvedValue({ ok: true })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockReverseEntry.mockResolvedValue({ id: 'je-storno' } as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateClearing.mockResolvedValue({ id: 'je-clearing' } as any)
})
describe('POST /api/bookkeeping/fix-cash-mismatch', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mock.supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: {},
})
const { status } = await parseJsonResponse(await POST(req, { params: Promise.resolve({}) }))
expect(status).toBe(401)
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns 400 for a non-uuid payment_id', async () => {
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: { payment_id: 'not-a-uuid' },
})
const { status } = await parseJsonResponse(await POST(req, { params: Promise.resolve({}) }))
expect(status).toBe(400)
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns fixed: 0 when no payments are affected', async () => {
mock.enqueue({ data: [] }) // journal_entries: no cash-path JEs
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: {},
})
const { status, body } = await parseJsonResponse<{ fixed: number; results: unknown[] }>(
await POST(req, { params: Promise.resolve({}) }),
)
expect(status).toBe(200)
expect(body).toEqual({ fixed: 0, results: [] })
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('relinks the transaction with the full booked triple, not just the pointer', async () => {
enqueueOneAffectedPayment()
mock.enqueueMany([
// invoices: re-fetch for the clearing entry metadata
{ data: { id: 'inv-1', customer: { name: 'Acme AB' } } },
// invoice_payments: relink update
{ data: null },
// transactions: relink update (the payload under test)
{ data: null },
])
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: {},
})
const { status, body } = await parseJsonResponse<{
fixed: number
failed: number
results: Array<{ ok: boolean; storno_journal_entry_id?: string; new_journal_entry_id?: string }>
}>(await POST(req, { params: Promise.resolve({}) }))
expect(status).toBe(200)
expect(body.fixed).toBe(1)
expect(body.failed).toBe(0)
expect(body.results[0]).toMatchObject({
ok: true,
storno_journal_entry_id: 'je-storno',
new_journal_entry_id: 'je-clearing',
})
expect(mockReverseEntry).toHaveBeenCalledWith(mock.supabase, 'company-1', 'user-1', 'je-cash')
// The relink must restore everything reverseEntry reset: pointer AND the
// booked triple. journal_entry_id alone leaves the row in Att bokfora.
const txUpdate = mock.findCall('transactions', 'update')
expect(txUpdate).toBeDefined()
expect(txUpdate![0]).toEqual({
journal_entry_id: 'je-clearing',
is_business: true,
category: 'income_services',
reconciliation_method: null,
})
// And it targets exactly the matched transaction in the active company.
const txEqCalls = mock
.findCalls('transactions', 'eq')
.map((args) => args as [string, string])
expect(txEqCalls).toContainEqual(['id', 'tx-1'])
expect(txEqCalls).toContainEqual(['company_id', 'company-1'])
})
it('keeps the payment relink to journal_entry_id only', async () => {
enqueueOneAffectedPayment()
mock.enqueueMany([
{ data: { id: 'inv-1', customer: { name: 'Acme AB' } } },
{ data: null }, // invoice_payments update
{ data: null }, // transactions update
])
const req = createMockRequest('/api/bookkeeping/fix-cash-mismatch', {
method: 'POST',
body: {},
})
await parseJsonResponse(await POST(req, { params: Promise.resolve({}) }))
const payUpdate = mock.findCall('invoice_payments', 'update')
expect(payUpdate).toBeDefined()
expect(payUpdate![0]).toEqual({ journal_entry_id: 'je-clearing' })
})
})
+13 -2
View File
@@ -210,11 +210,22 @@ export const POST = withRouteContext(
if (relinkPayErr) throw relinkPayErr
// Re-link the transaction too, so /transactions reflects the correct
// voucher when the user clicks through.
// voucher when the user clicks through. reverseEntry (step 1) resets
// the whole booked state on the linked row: journal_entry_id,
// is_business, category, reconciliation_method (the #1950 return-to-
// Att-bokfora fix), so restoring only the pointer would leave a booked
// row with is_business NULL, visible in Att bokfora and the nav badge
// (worklist predicate: is_business IS NULL). Restore the full booked
// triple, mirroring the match-invoice route's final update.
if (t.transaction_id) {
const { error: relinkTxErr } = await supabase
.from('transactions')
.update({ journal_entry_id: clearing.id })
.update({
journal_entry_id: clearing.id,
is_business: true,
category: 'income_services',
reconciliation_method: null,
})
.eq('id', t.transaction_id)
.eq('company_id', companyId)
if (relinkTxErr) {
+166 -35
View File
@@ -875,28 +875,50 @@ describe('reverseEntry: storno guard', () => {
describe('reverseEntry: bank transaction unlink', () => {
// After a reversal the booked bank transaction must return to "Att bokföra"
// (journal_entry_id cleared) so the user can book it again. The agent paths
// in lib/pending-operations/commit.ts did this manually; the engine now owns
// so the user can book it again. The agent paths in
// lib/pending-operations/commit.ts did this manually; the engine now owns
// it so the dashboard reverse route behaves the same.
it('clears transactions.journal_entry_id for rows booked by the reversed entry', async () => {
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 7,
entry_date: '2026-02-02',
description: 'ALMI AB - Innovationslån',
source_type: 'manual',
source_id: null,
lines: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '2350', debit_amount: 0, credit_amount: 1000 },
],
}
const reversal = { id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' }
//
// "Att bokföra" is is_business IS NULL AND is_ignored = false
// (lib/worklist/types.ts), not journal_entry_id IS NULL: bulk-booked and
// multi-allocated rows keep journal_entry_id NULL while booked. Clearing
// only the link therefore left the stornoed row out of the list and the nav
// badge (#1950), so the engine resets the same triple the uncategorize
// paths write, plus reconciliation_method since the link it described is gone.
//
// Second anchor: bulk-booked rows (bulk_book_transactions RPC) point at the
// verifikat through transaction_voucher_links, and for N>1 that junction is
// the only anchor. The engine deletes those link rows and releases the rows
// that have no anchor left; a row still anchored elsewhere (residual booking:
// main verifikat in journal_entry_id, junction row to the residual verifikat)
// stays booked.
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 7,
entry_date: '2026-02-02',
description: 'ALMI AB - Innovationslån',
source_type: 'manual',
source_id: null,
lines: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '2350', debit_amount: 0, credit_amount: 1000 },
],
}
const reversal = { id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' }
type Filter = [op: string, column: string, value: unknown]
interface RecordedWrite { payload?: unknown; op?: string; filters: Filter[] }
/**
* 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).
*/
function setup(opts: { voucherLinks?: string[]; remainingLinks?: string[] } = {}) {
let jeCall = 0
const jeResults = [
{ data: original, error: null }, // fetch original (.single)
@@ -915,8 +937,33 @@ describe('reverseEntry: bank transaction unlink', () => {
return b
}
const txUpdatePayloads: unknown[] = []
const txFilters: Record<string, unknown> = {}
const txWrites: RecordedWrite[] = []
const linkOps: RecordedWrite[] = []
let linkSelectCall = 0
function recorder(list: RecordedWrite[], resolveWith: (current: RecordedWrite) => unknown) {
const b: Record<string, unknown> = {}
let current: RecordedWrite = { filters: [] }
const start = (op: string) =>
vi.fn().mockImplementation((payload?: unknown) => {
current = { op, payload, filters: [] }
list.push(current)
return b
})
const filter = (op: string) =>
vi.fn().mockImplementation((column: string, value: unknown) => {
current.filters.push([op, column, value])
return b
})
b.update = start('update')
b.select = start('select')
b.delete = start('delete')
b.eq = filter('eq')
b.in = filter('in')
b.is = filter('is')
b.then = (resolve: (v: unknown) => void) => resolve(resolveWith(current))
return b
}
const supabase = {
rpc: vi.fn().mockResolvedValue({ data: 8, error: null }),
@@ -938,28 +985,112 @@ describe('reverseEntry: bank transaction unlink', () => {
if (table === 'journal_entry_lines') {
return { insert: vi.fn().mockResolvedValue({ error: null }) }
}
if (table === 'transactions') {
const b: Record<string, unknown> = {}
b.update = vi.fn().mockImplementation((payload: unknown) => {
txUpdatePayloads.push(payload)
return b
if (table === 'transactions') return recorder(txWrites, () => ({ 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 }
})
b.eq = vi.fn().mockImplementation((col: string, val: unknown) => {
txFilters[col] = val
return b
})
b.then = (resolve: (v: unknown) => void) => resolve({ error: null })
return b
}
return createMockChain()
}),
}
return { supabase, txWrites, linkOps }
}
it('resets journal_entry_id, is_business and category so the row returns to Att bokföra (#1950)', async () => {
const { supabase, txWrites, linkOps } = setup()
const result = await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(result.id).toBe('reversal-1')
expect(txUpdatePayloads).toEqual([{ journal_entry_id: null }])
expect(txFilters).toMatchObject({ company_id: 'company-1', journal_entry_id: 'entry-1' })
expect(txWrites).toHaveLength(1)
expect(txWrites[0].payload).toEqual({
journal_entry_id: null,
is_business: null,
category: null,
reconciliation_method: null,
})
// Scoped to rows linked to the reversed entry only: never a company-wide reset.
expect(txWrites[0].filters).toEqual([
['eq', 'company_id', 'company-1'],
['eq', 'journal_entry_id', 'entry-1'],
])
// The junction is consulted for this entry only; nothing to delete or
// release when it holds no rows.
expect(linkOps).toEqual([
{
op: 'select',
payload: 'transaction_id',
filters: [
['eq', 'company_id', 'company-1'],
['eq', 'journal_entry_id', 'entry-1'],
],
},
])
})
it('deletes transaction_voucher_links rows and releases bulk-booked rows with no anchor left', async () => {
// Samlingsverifikat over three bank rows (journal_entry_id NULL on all
// three, one junction row each). tx-c is also anchored to another
// verifikat through the junction and must stay booked.
const { supabase, txWrites, linkOps } = setup({
voucherLinks: ['tx-a', 'tx-b', 'tx-c'],
remainingLinks: ['tx-c'],
})
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(linkOps).toEqual([
{
op: 'select',
payload: 'transaction_id',
filters: [
['eq', 'company_id', 'company-1'],
['eq', 'journal_entry_id', 'entry-1'],
],
},
{
op: 'delete',
payload: undefined,
filters: [
['eq', 'company_id', 'company-1'],
['eq', 'journal_entry_id', 'entry-1'],
],
},
{
op: 'select',
payload: 'transaction_id',
filters: [
['eq', 'company_id', 'company-1'],
['in', 'transaction_id', ['tx-a', 'tx-b', 'tx-c']],
],
},
])
expect(txWrites).toHaveLength(2)
expect(txWrites[1].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([
['eq', 'company_id', 'company-1'],
['in', 'id', ['tx-a', 'tx-b']],
['is', 'journal_entry_id', null],
])
})
it('leaves the rows alone when every junction-linked row is still anchored elsewhere', async () => {
const { supabase, txWrites, linkOps } = setup({
voucherLinks: ['tx-a'],
remainingLinks: ['tx-a'],
})
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)
})
})
+75 -1
View File
@@ -1241,15 +1241,89 @@ export async function reverseEntry(
// as bokförd forever, and has no re-booking affordance: the agent paths
// (lib/pending-operations/commit.ts) already did this manually after every
// reverseEntry call; the dashboard reverse route did not.
//
// The worklist's "unbooked" predicate is is_business IS NULL (see
// lib/worklist/types.ts), not journal_entry_id IS NULL, so clearing only the
// link left the stornoed row invisible in Att bokföra and in the nav badge
// (#1950) while the storno dialog (reverse_warning) promised the return.
// The engine therefore resets the same triple the uncategorize paths write
// (is_business, category, journal_entry_id), plus reconciliation_method:
// it describes how the link was made, and the link is gone (the koppla-bort
// path in lib/reconciliation/bank-reconciliation.ts resets it the same way).
const { error: unlinkError } = await supabase
.from('transactions')
.update({ journal_entry_id: null })
.update({
journal_entry_id: null,
is_business: null,
category: null,
reconciliation_method: null,
})
.eq('company_id', companyId)
.eq('journal_entry_id', entryId)
if (unlinkError) {
log.error('failed to unlink transactions from reversed entry', unlinkError, { entryId })
}
// Same promise, second anchor. Bulk-booked rows (bulk_book_transactions RPC)
// and residual verifikat (lib/reconciliation/residual.ts) anchor bank lines
// through transaction_voucher_links; for a samlingsverifikat with N>1 rows
// that junction is the ONLY anchor (journal_entry_id stays NULL), so the
// UPDATE above matches nothing and all N rows keep is_business = true
// against a status='reversed' entry. The link rows go the way koppla-bort
// removes them, and a row returns to Att bokföra only when no anchor is
// left: a residual booking keeps the main verifikat in journal_entry_id
// while its junction row points at the small residual verifikat, and
// stornoing the residual must not put a still-booked row back in the list.
const { data: junctionRows, error: junctionReadError } = await supabase
.from('transaction_voucher_links')
.select('transaction_id')
.eq('company_id', companyId)
.eq('journal_entry_id', entryId)
if (junctionReadError) {
log.error('failed to read voucher links of reversed entry', junctionReadError, { entryId })
} else if (junctionRows && junctionRows.length > 0) {
const junctionTxIds = [
...new Set((junctionRows as Array<{ transaction_id: string }>).map((r) => r.transaction_id)),
]
const { error: junctionDeleteError } = await supabase
.from('transaction_voucher_links')
.delete()
.eq('company_id', companyId)
.eq('journal_entry_id', entryId)
if (junctionDeleteError) {
log.error('failed to delete voucher links of reversed entry', junctionDeleteError, { entryId })
} else {
const { data: remainingRows, error: remainingError } = await supabase
.from('transaction_voucher_links')
.select('transaction_id')
.eq('company_id', companyId)
.in('transaction_id', junctionTxIds)
if (remainingError) {
log.error('failed to read remaining voucher links after storno', remainingError, {
entryId,
})
} else {
const stillAnchored = new Set(
((remainingRows ?? []) as Array<{ transaction_id: string }>).map((r) => r.transaction_id),
)
const releaseIds = junctionTxIds.filter((id) => !stillAnchored.has(id))
if (releaseIds.length > 0) {
const { error: releaseError } = await supabase
.from('transactions')
.update({ is_business: null, category: null, reconciliation_method: null })
.eq('company_id', companyId)
.in('id', releaseIds)
.is('journal_entry_id', null)
if (releaseError) {
log.error('failed to release junction-linked transactions of reversed entry', releaseError, {
entryId,
})
}
}
}
}
}
// Same hazard one table over: a period whose opening_balance_entry_id still
// points at the entry we just reversed. getOpeningBalances() reads the linked
// entry's lines directly with no status filter, so the Balansrapport would go