feat(reconciliation): residual booking + junction-aware bridge (#1862)

When the worksheet selection (N bank rows vs one verifikat) misses by a
few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad /
Ränteintäkt / Öresavrundning och koppla' books the remainder on
6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the
main verifikat and anchors the residual verifikat through
transaction_voucher_links. Bank accounts only (Skatteverket posts ränta
and avgifter as rows of their own), capped at 5 000 kr, direction-checked
against the kind; links are made first and undone if the booking is
refused. Dashboard + v1 doors (transactions:write, Idempotency-Key,
dry run), API skill regenerated.

The bridge now treats transaction_voucher_links as links on both sides:
migration 20260824190000 re-creates get_unlinked_gl_lines and
get_account_gl_lines_for_matching to count junction-linked verifikat as
matched (pg-real test), and the TS engine + items do the same for the
transactions. This also stops bulk-booked samlingsverifikat from
polluting the open buckets. 'Koppla bort' drops the junction rows too.

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-25 08:30:54 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 0a3cb1a31a
commit d88df74b85
21 changed files with 1496 additions and 15 deletions
+1
View File
@@ -1188,6 +1188,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead.
[2026-08-24] Manual matching (PR 6b) ships N:1 only (many outside rows -> one verifikat): bank links are independent per transaction (the engine allows it by design), skattekonto groups are all-or-nothing with the sum settling the verifikat (one guarded UPDATE, partial hit rolled back). 1:M (one row over several verifikat) and residual booking wait for a link table in 6c: the single journal_entry_id pointer on both row kinds cannot express them, and faking it (pointing the row at the residual verifikat) would break the bridge. The worksheet therefore enables Koppla only when the selection nets to zero and says so otherwise.
[2026-08-24] Skattekonto payment file gets pain.001 through the supplier-payment generator (generateSupplierPain001), not the salary pain001 generator: the payment is a plain BG+OCR giro transfer (no SALA CtgyPurp), and the supplier dialect is the Validex-validated shape for exactly that; the LB path stays the default so nothing changes for banks still on LB.
[2026-08-24] Residual booking (6c) reuses transaction_voucher_links instead of a new link table: the junction already models one transaction against several verifikat (bulk-book), and both GL RPCs plus the TS bridge now treat it as a link, which also stops samlingsverifikat from polluting the open buckets. Residuals are bank-only (Skatteverket posts ränta/avgifter as their own rows), capped at 5 000 kr (above that it is a missing booking, not a fee), direction-checked against the kind, and the links are made before the booking and undone if the booking is refused.
[2026-08-24] query_journal status default 'posted' -> 'all' (posted+reversed, the trial-balance inclusion rule) after a customer's agent summed posted-only lines over a storno-heavy Q2, found phantom VAT residuals on 2614/2641/2645/2647 and demanded a revert of correct books: one-leg sums are never balances. Funded the new status_filter_warning field and richer status docs inside the tools/list token budget by trimming sibling descriptions in the same tool rather than bumping the 59.95K ceiling (the payload guard's own guidance); warning fires off an entry-level opposite-status head count, exact for what the sentence claims and cheap, instead of re-running the line fetch.
[2026-08-24] Detach-duplicate underlag ships as a SECURITY DEFINER RPC (detach_underlag_duplicate) instead of loosening the document triggers: the WORM guards stay intact for every other path, the carve-out is transaction-local (gnubok.allow_delete) and audit-logged first, and detach is refused unless another anchored underlag remains on the verifikat (BFL 5 kap 7 par) AND a remaining sibling has an identical sha256_hash (only byte-identical duplicates detach; skeptic-hardened 2026-08-24, along with an enforced posted-status guard and company_id on the audit row). Pinned docs (transactions.document_id / supplier_invoices.document_id) stay replace-only.
[2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva").
@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { bookResidualAndLink, ReconciliationResidualError } from '@/lib/reconciliation/residual'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ISO_DATE_RE } from '@/lib/invariants'
import { ensureInitialized } from '@/lib/init'
// Booking a residual commits a verifikat; the engine emits journal_entry
// events that extension handlers subscribe to.
ensureInitialized()
const ResidualBodySchema = z.object({
external_ids: z.array(z.string().uuid()).min(1).max(50),
journal_entry_id: z.string().uuid(),
kind: z.enum(['bank_fee', 'rounding', 'interest_income', 'interest_expense']),
entry_date: z.string().regex(ISO_DATE_RE).optional(),
description: z.string().max(200).optional(),
dry_run: z.boolean().optional(),
})
/**
* POST /api/reconciliation/accounts/{accountKey}/residual
*
* The worksheet's "bokför mellanskillnaden och koppla": books the remainder
* of a selection (bank rows vs one verifikat) as a bank fee / interest /
* rounding verifikat and links the selection. Bank accounts only.
*/
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
'reconciliation.accounts.residual',
async (request, { supabase, user, companyId }, { params }) => {
const { accountKey } = await params
if (!AccountKeySchema.safeParse(accountKey).success) {
return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
}
const parsed = ResidualBodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Ogiltig body: external_ids, journal_entry_id och kind krävs' }, { status: 400 })
}
try {
const result = await bookResidualAndLink(
supabase,
companyId,
user.id,
accountKey,
{
external_ids: parsed.data.external_ids,
journal_entry_id: parsed.data.journal_entry_id,
kind: parsed.data.kind,
entry_date: parsed.data.entry_date,
description: parsed.data.description,
},
{ dryRun: parsed.data.dry_run === true },
)
if (!result) {
return NextResponse.json({ error: 'Okänt konto för det här företaget' }, { status: 404 })
}
return NextResponse.json({ data: result })
} catch (err) {
if (err instanceof ReconciliationResidualError) {
const status =
err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 404 : 400
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status })
}
throw err
}
},
{ requireWrite: true },
)
@@ -0,0 +1,98 @@
/**
* Tests for POST /api/reconciliation/accounts/{accountKey}/residual (cookie
* session, withRouteContext). The residual engine is mocked; the wrapper is real.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
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() }))
const residualMock = vi.fn()
vi.mock('@/lib/reconciliation/residual', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/residual')>('@/lib/reconciliation/residual')
return { ...actual, bookResidualAndLink: (...args: unknown[]) => residualMock(...args) }
})
import { ReconciliationResidualError } from '@/lib/reconciliation/residual'
import { POST } from '../[accountKey]/residual/route'
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 URL = `http://localhost/api/reconciliation/accounts/${KEY}/residual`
const p = (obj: Record<string, string>) => ({ params: Promise.resolve(obj) }) as never
const body = { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }
describe('POST /api/reconciliation/accounts/{accountKey}/residual', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
residualMock.mockResolvedValue({ dry_run: false, residual_journal_entry_id: 'res-1', residual_amount: -10, applied: [], skipped: [] })
})
it('401 without a session', async () => {
requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
const res = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(res.status).toBe(401)
})
it('400 on a body without kind, 404 on a malformed key', async () => {
const bad = await POST(createMockRequest(URL, { method: 'POST', body: { external_ids: [T1], journal_entry_id: E1 } }), p({ accountKey: KEY }))
expect(bad.status).toBe(400)
const badKey = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: '1930' }))
expect(badKey.status).toBe(404)
expect(residualMock).not.toHaveBeenCalled()
})
it('books and links, forwarding dry_run', async () => {
const res = await POST(createMockRequest(URL, { method: 'POST', body: { ...body, dry_run: true } }), p({ accountKey: KEY }))
expect(res.status).toBe(200)
expect(residualMock).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
KEY,
{ external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', entry_date: undefined, description: undefined },
{ dryRun: true },
)
const { body: out } = await parseJsonResponse<{ data: { residual_journal_entry_id: string } }>(res)
expect(out.data.residual_journal_entry_id).toBe('res-1')
})
it('maps refusals to 400 + code, missing rows to 404, unknown account to 404, and requires write', async () => {
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('för stort', 'RESIDUAL_TOO_LARGE'))
const refused = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(refused.status).toBe(400)
expect((await parseJsonResponse<{ code: string }>(refused)).body.code).toBe('RESIDUAL_TOO_LARGE')
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ROWS_NOT_FOUND'))
const missingRows = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(missingRows.status).toBe(404)
residualMock.mockResolvedValueOnce(null)
const unknown = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(unknown.status).toBe(404)
requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) })
const forbidden = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(forbidden.status).toBe(403)
})
})
@@ -0,0 +1,147 @@
/**
* POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual
*
* Book the remainder of a bank selection (N transactions vs one verifikat)
* as a small verifikat (bank fee / interest / rounding) and link the
* selection in the same call. Writes to the ledger: transactions:write, the
* same scope that books a bank transaction. Dry-runnable; Idempotency-Key
* required.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { bookResidualAndLink, ReconciliationResidualError, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ISO_DATE_RE } from '@/lib/invariants'
import { ensureInitialized } from '@/lib/init'
ensureInitialized()
const ResidualRequest = z.object({
external_ids: z.array(z.string().uuid()).min(1).max(50),
journal_entry_id: z.string().uuid(),
kind: z.enum(['bank_fee', 'rounding', 'interest_income', 'interest_expense']),
entry_date: z.string().regex(ISO_DATE_RE).optional(),
description: z.string().max(200).optional(),
})
const LineSchema = z.object({ account_number: z.string(), debit_amount: z.number(), credit_amount: z.number() })
const ResidualResponse = z.object({
dry_run: z.boolean(),
residual_journal_entry_id: z.string().optional(),
residual_amount: z.number().optional(),
applied: z.array(z.object({ external_id: z.string(), journal_entry_id: z.string() })).optional(),
skipped: z.array(z.object({ code: z.string(), message: z.string() })).optional(),
would_book: z
.object({
kind: z.string(),
counter_account: z.string(),
ledger_account: z.string(),
currency: z.string(),
transactions_total: z.number(),
entry_net: z.number(),
residual_amount: z.number(),
entry_date: z.string(),
description: z.string(),
lines: z.array(LineSchema),
})
.optional(),
})
registerEndpoint({
operation: 'reconciliation.accounts.residual',
method: 'POST',
path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual',
summary: 'Book the remainder of a bank selection as a fee/interest/rounding verifikat and link the selection.',
description:
`Body: { external_ids: [transaction ids], journal_entry_id, kind: "bank_fee" | "interest_expense" | "interest_income" | "rounding", entry_date?, description? }. Computes the difference between the transactions' sum and the verifikat's net on the bank account, books it on 6570 / 8410 / 8310 / 3740 against the bank account (dated on the latest transaction by default), links the transactions to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (bank:<cash_account_id>). Refused when the difference is 0 (RESIDUAL_ZERO), above ${RESIDUAL_MAX_AMOUNT} kr (RESIDUAL_TOO_LARGE: that is a missing booking, not a fee), or when the kind points the wrong way (RESIDUAL_DIRECTION). ?dry_run=true returns would_book without writing.`,
useWhen:
'A manual match misses by a small amount that is genuinely a bank fee, interest or rounding, and you want to close it in one step instead of booking a verifikat and then linking.',
doNotUseFor:
'Skattekonto rows (Skatteverket posts ränta and avgifter as their own rows: link them), or differences that are really a missing booking (book that properly).',
pitfalls: [
'The kind must match the direction: money that left the bank unbooked is bank_fee / interest_expense; money that arrived unbooked is interest_income; rounding works either way.',
'Links are made before the booking and undone if the booking is refused (a locked period), so a refusal leaves nothing half done.',
'Idempotency-Key is required; repeating the same key replays the first response.',
],
example: {
request: { external_ids: ['22222222-2222-4222-8222-222222222222'], journal_entry_id: '44444444-4444-4444-8444-444444444444', kind: 'bank_fee' },
response: {
data: {
dry_run: false,
residual_journal_entry_id: '55555555-5555-4555-8555-555555555555',
residual_amount: -10,
applied: [{ external_id: '22222222-2222-4222-8222-222222222222', journal_entry_id: '44444444-4444-4444-8444-444444444444' }],
skipped: [],
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'transactions:write',
risk: 'medium',
idempotent: false,
reversible: false,
dryRunSupported: true,
request: { body: ResidualRequest },
response: { success: dataEnvelope(ResidualResponse) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey: string }> }>(
'reconciliation.accounts.residual',
async (request, ctx, params) => {
const { accountKey } = await params.params
if (!AccountKeySchema.safeParse(accountKey).success) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'accountKey', message: 'Okänt konto.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = ResidualRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
try {
const result = await bookResidualAndLink(ctx.supabase, ctx.companyId!, ctx.userId, accountKey, parsed.data, {
dryRun: ctx.dryRun,
})
if (!result) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'accountKey', message: 'Okänt konto för det här företaget.' },
})
}
if (ctx.dryRun) {
return dryRunPreview(result, { requestId: ctx.requestId, log: ctx.log })
}
return ok(result, { requestId: ctx.requestId })
} catch (err) {
if (err instanceof ReconciliationResidualError) {
const v1Code =
err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 'NOT_FOUND' : 'VALIDATION_ERROR'
return v1ErrorResponseFromCode(v1Code, ctx.log, {
requestId: ctx.requestId,
details: { code: err.code, message: getErrorMessage(err) },
})
}
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,129 @@
/**
* Tests for POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual:
* real withApiV1 wrapper (auth, scope, membership, idempotency, dry-run), engine mocked.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const { residualMock } = vi.hoisted(() => ({ residualMock: vi.fn() }))
vi.mock('@/lib/reconciliation/residual', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/residual')>('@/lib/reconciliation/residual')
return { ...actual, bookResidualAndLink: residualMock }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { ReconciliationResidualError } from '@/lib/reconciliation/residual'
import { POST } from '../[accountKey]/residual/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) queues.set(t, Array.isArray(val) ? [...val] : [val])
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
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 URL = `http://localhost/api/v1/companies/${COMPANY_ID}/reconciliation/accounts/${KEY}/residual`
const body = { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }
function req(init: { body?: unknown; idem?: boolean; dryRun?: boolean } = {}): Request {
const headers: Record<string, string> = {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
}
if (init.idem !== false) headers['Idempotency-Key'] = `idem-${Math.random().toString(36).slice(2)}-aaaa-4abc-8def-1234567890ab`
if (init.dryRun) headers['X-Dry-Run'] = 'true'
return new Request(URL, { method: 'POST', headers, body: init.body !== undefined ? JSON.stringify(init.body) : undefined })
}
function authOk(scopes: string[]) {
mockValidate.mockResolvedValue({ valid: true, userId: 'user-1', keyId: 'key-1', keyName: 'Test key', scopes, mode: 'live' })
}
const params = (accountKey = KEY) => ({ params: Promise.resolve({ companyId: COMPANY_ID, accountKey }) }) as never
describe('v1 reconciliation residual', () => {
beforeEach(() => {
vi.clearAllMocks()
mockServiceClient.mockReturnValue(makeFlexibleSupabase({ company_members: { data: { role: 'owner' } }, idempotency_keys: { data: null } }))
residualMock.mockResolvedValue({ dry_run: false, residual_journal_entry_id: 'res-1', residual_amount: -10, applied: [{ external_id: T1, journal_entry_id: E1 }], skipped: [] })
})
it('401 without a valid key; 403 without transactions:write', async () => {
mockValidate.mockResolvedValue({ valid: false, error: 'invalid' })
expect((await POST(req({ body }), params())).status).toBe(401)
authOk(['reconciliation:write'])
expect((await POST(req({ body }), params())).status).toBe(403)
})
it('needs an Idempotency-Key, validates the body, 404s a bad key', async () => {
authOk(['transactions:write'])
expect((await POST(req({ body, idem: false }), params())).status).toBe(400)
expect((await POST(req({ body: { external_ids: [T1] } }), params())).status).toBe(400)
expect((await POST(req({ body }), params('1930'))).status).toBe(404)
expect(residualMock).not.toHaveBeenCalled()
})
it('books and links, and previews on dry run', async () => {
authOk(['transactions:write'])
const res = await POST(req({ body }), params())
expect(res.status).toBe(200)
expect((await res.json()).data.residual_journal_entry_id).toBe('res-1')
expect(residualMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'user-1', KEY, body, { dryRun: false })
residualMock.mockResolvedValue({ dry_run: true, would_book: { residual_amount: -10 } })
const dry = await POST(req({ body, dryRun: true }), params())
expect(dry.status).toBe(200)
expect(residualMock).toHaveBeenLastCalledWith(expect.anything(), COMPANY_ID, 'user-1', KEY, body, { dryRun: true })
})
it('maps refusals to VALIDATION_ERROR with the residual code, and missing rows to NOT_FOUND', async () => {
authOk(['transactions:write'])
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('fel riktning', 'RESIDUAL_DIRECTION'))
const refused = await POST(req({ body }), params())
expect(refused.status).toBe(400)
const out = await refused.json()
expect(out.error.code).toBe('VALIDATION_ERROR')
expect(out.error.details.code).toBe('RESIDUAL_DIRECTION')
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ENTRY_NOT_FOUND'))
expect((await POST(req({ body }), params())).status).toBe(404)
})
})
+48 -1
View File
@@ -14,6 +14,7 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
import { roundOre } from '@/lib/money'
import type { ReconciliationAccount, ReconciliationItem } from '@/lib/reconciliation/schemas'
import type { ReconciliationWindow } from './AccountOverview'
import type { ResidualKind } from '@/lib/reconciliation/residual'
/**
* "Matcha manuellt": the two-pane worksheet from the approved design. Left:
@@ -43,6 +44,7 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP
const [pickedExternal, setPickedExternal] = useState<Set<string>>(new Set())
const [pickedEntry, setPickedEntry] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [residualKind, setResidualKind] = useState<ResidualKind | ''>('')
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
const isSkv = account.kind === 'skattekonto'
@@ -125,6 +127,31 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP
}
}
async function bookResidual() {
if (!entry || pickedExternal.size === 0 || !residualKind) return
setBusy(true)
try {
const res = await fetch(`${base}/residual`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ external_ids: [...pickedExternal], journal_entry_id: entry.item_id, kind: residualKind }),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
toast({ title: t('toast_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' })
return
}
toast({ title: t('toast_residual_booked', { amount: formatCurrency(Math.abs(Number(json.data?.residual_amount ?? 0)), currency) }) })
setPickedExternal(new Set())
setPickedEntry(null)
setResidualKind('')
await load()
onChanged()
} finally {
setBusy(false)
}
}
if (loadError) {
return (
<AttnLine action={{ label: t('older_show'), onClick: () => void load() }}>{t('load_failed')}</AttnLine>
@@ -269,7 +296,27 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP
{t('match_difference', { amount: formatCurrency(difference, currency) })}
</span>
{Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entry && (
<span className="text-[12.5px] text-muted-foreground">{t('match_hint_residual')}</span>
isSkv ? (
<span className="text-[12.5px] text-muted-foreground">{t('match_hint_residual_skv')}</span>
) : (
<span className="flex items-center gap-2 text-[12.5px]">
<label htmlFor="residual-kind" className="text-muted-foreground">{t('match_residual_label')}</label>
<select
id="residual-kind"
value={residualKind}
onChange={(e) => setResidualKind(e.target.value as ResidualKind | '')}
className="h-8 rounded-lg border border-border bg-background px-2 text-[12.5px]"
>
<option value="">{t('match_residual_pick')}</option>
{(difference < 0 ? ['bank_fee', 'interest_expense', 'rounding'] : ['interest_income', 'rounding']).map((k) => (
<option key={k} value={k}>{t(`residual_${k}`)}</option>
))}
</select>
<Button size="sm" variant="outline" onClick={() => void bookResidual()} disabled={!residualKind || busy} aria-busy={busy}>
{t('match_residual_apply', { amount: formatCurrency(Math.abs(difference), currency) })}
</Button>
</span>
)
)}
<span className="ml-auto">
<Button size="sm" onClick={() => void link()} disabled={!canLink} aria-busy={busy}>
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `134`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `135`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -106,6 +106,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/journal-entries/batch-create",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/items/:itemId/ignore",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen",
"POST /api/v1/companies/:companyId/reconciliation/bank/run",
+1
View File
@@ -162,6 +162,7 @@ import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/[itemId]/ignore/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route'
// Dimensions PR2: registry list + value creation (kostnadsställe/projekt).
import '@/app/api/v1/companies/[companyId]/dimensions/route'
+1
View File
@@ -145,6 +145,7 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'GET /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff': 'reconciliation:read',
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff': 'reconciliation:signoff',
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen': 'reconciliation:signoff',
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual': 'transactions:write',
// Phase 5 PR-3: Reports + import async. Reports are read-only over
// existing lib/reports/* generators; imports are async over the Phase 4
@@ -2178,7 +2178,7 @@ describe('unscoped cash-account diagnostic', () => {
expect(firstFrom?.args[0]).toBe('transactions')
const firstSelect = calls.find((c) => c.method === 'select')
expect(firstSelect?.args[0]).toBe(
'date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id',
'id, date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id',
)
})
+10 -2
View File
@@ -3,12 +3,14 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
const skvStatusMock = vi.fn()
const fetchUnlinkedMock = vi.fn()
const junctionMock = vi.fn()
vi.mock('../skattekonto-reconciliation', () => ({
getSkattekontoReconciliationStatus: (...args: unknown[]) => skvStatusMock(...args),
}))
vi.mock('../bank-reconciliation', () => ({
fetchUnlinkedGLLines: (...args: unknown[]) => fetchUnlinkedMock(...args),
fetchJunctionLinkedTxIds: (...args: unknown[]) => junctionMock(...args),
scopeTransactionsToAccount: (q: unknown) => q,
}))
@@ -26,6 +28,8 @@ describe('listAccountItems', () => {
vi.clearAllMocks()
skvStatusMock.mockReset()
fetchUnlinkedMock.mockReset()
junctionMock.mockReset()
junctionMock.mockResolvedValue(new Set())
})
it('returns null for an invalid key', async () => {
@@ -69,8 +73,11 @@ describe('listAccountItems', () => {
{ id: 't-link', date: '2026-08-03', description: 'Lön', merchant_name: null, amount: -31200, currency: 'SEK', journal_entry_id: 'e-1', potential_journal_entry_id: null, potential_match_method: 'manual', potential_match_confidence: null, is_ignored: false, reconciliation_method: 'manual' },
{ id: 't-prop', date: '2026-08-02', description: 'Swish', merchant_name: 'Swish 123', amount: 2400, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: 'e-2', potential_match_method: 'auto_fuzzy', potential_match_confidence: '0.82', is_ignored: false, reconciliation_method: null },
{ id: 't-open', date: '2026-08-01', description: 'Elgiganten', merchant_name: null, amount: -1046, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null, is_ignored: false, reconciliation_method: null },
// Anchored only through transaction_voucher_links (bulk-book / residual): matched, pointer NULL.
{ id: 't-junc', date: '2026-07-31', description: 'Samlingsverifikat', merchant_name: null, amount: -500, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null, is_ignored: false, reconciliation_method: null },
],
})
junctionMock.mockResolvedValue(new Set(['t-junc']))
fetchUnlinkedMock.mockResolvedValue([
{ line_id: 'l1', journal_entry_id: 'e-3', debit_amount: 0, credit_amount: 600, line_description: null, entry_date: '2026-08-18', voucher_number: 231, voucher_series: 'A', entry_description: 'Elgiganten', source_type: 'manual' },
{ line_id: 'l2', journal_entry_id: 'e-3', debit_amount: 0, credit_amount: 400, line_description: null, entry_date: '2026-08-18', voucher_number: 231, voucher_series: 'A', entry_description: 'Elgiganten', source_type: 'manual' },
@@ -83,10 +90,11 @@ describe('listAccountItems', () => {
expect(byId['t-open']).toMatchObject({ bucket: 'unmatched_external', actions: ['book', 'match', 'ignore'] })
expect(byId['t-link']).toMatchObject({ bucket: 'matched', linked_journal_entry_id: 'e-1', actions: ['unmatch'] })
expect(byId['t-ign']).toMatchObject({ bucket: 'ignored', actions: ['unignore'] })
expect(byId['t-junc']).toMatchObject({ bucket: 'matched', actions: ['unmatch'] })
expect(byId['e-3']).toMatchObject({ bucket: 'unmatched_ledger', side: 'ledger', amount: -1000, voucher_number: 231 })
// Work order: proposed, unmatched external, unmatched ledger, ignored, upcoming, matched
expect(result?.items.map((i) => i.bucket)).toEqual(['proposed', 'unmatched_external', 'unmatched_ledger', 'ignored', 'matched'])
expect(result?.total_count).toBe(5)
expect(result?.items.map((i) => i.bucket)).toEqual(['proposed', 'unmatched_external', 'unmatched_ledger', 'ignored', 'matched', 'matched'])
expect(result?.total_count).toBe(6)
})
it('returns null for an unknown cash account', async () => {
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const createEntryMock = vi.fn()
const findPeriodMock = vi.fn()
const matchMock = vi.fn()
const unmatchMock = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => createEntryMock(...args),
findFiscalPeriod: (...args: unknown[]) => findPeriodMock(...args),
}))
vi.mock('../actions', () => ({
matchPairs: (...args: unknown[]) => matchMock(...args),
unmatchLink: (...args: unknown[]) => unmatchMock(...args),
}))
import { bookResidualAndLink, ReconciliationResidualError } from '../residual'
const COMPANY = 'company-1'
const USER = 'user-1'
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 RES = '55555555-5555-4555-8555-555555555555'
function tx(overrides: Record<string, unknown> = {}) {
return { id: T1, date: '2026-08-05', amount: -1010, description: 'Leverantör AB', journal_entry_id: null, is_ignored: false, cash_account_id: CASH, ...overrides }
}
function entry(net = -1000) {
return {
id: E1,
status: 'posted',
description: 'Faktura 1234',
lines: [
{ account_number: '2440', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: net > 0 ? net : 0, credit_amount: net < 0 ? -net : 0 },
],
}
}
describe('bookResidualAndLink', () => {
beforeEach(() => {
vi.clearAllMocks()
createEntryMock.mockReset()
findPeriodMock.mockReset()
matchMock.mockReset()
unmatchMock.mockReset()
findPeriodMock.mockResolvedValue('fp-1')
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [{ external_id: T1, journal_entry_id: E1 }], skipped: [] })
createEntryMock.mockResolvedValue({ id: RES })
})
it('returns null for an unknown account, refuses the skattekonto, refuses an unknown kind', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
expect(await bookResidualAndLink(supabase as never, COMPANY, USER, 'nope', { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' })).toBeNull()
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, 'skattekonto', { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toMatchObject({ code: 'RESIDUAL_UNSUPPORTED_KIND' })
enqueue({ data: null }) // cash account lookup: unknown for this company
expect(await bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' })).toBeNull()
})
it('previews a bank fee: expense on 6570 against the bank account, dated on the transaction', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx()] })
enqueue({ data: entry(-1000) })
const result = await bookResidualAndLink(
supabase as never,
COMPANY,
USER,
KEY,
{ external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' },
{ dryRun: true },
)
expect(result).toMatchObject({
dry_run: true,
would_book: {
residual_amount: -10,
counter_account: '6570',
entry_date: '2026-08-05',
description: 'Bankavgift: Faktura 1234',
lines: [
{ account_number: '6570', debit_amount: 10, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 10 },
],
},
})
expect(matchMock).not.toHaveBeenCalled()
expect(createEntryMock).not.toHaveBeenCalled()
})
it('links first, then books the residual, then anchors it through the junction', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx()] })
enqueue({ data: entry(-1000) })
enqueue({ data: null }) // junction insert
const result = await bookResidualAndLink(supabase as never, COMPANY, USER, KEY, {
external_ids: [T1],
journal_entry_id: E1,
kind: 'bank_fee',
})
expect(result).toMatchObject({ dry_run: false, residual_journal_entry_id: RES, residual_amount: -10 })
expect(matchMock).toHaveBeenCalledWith(supabase, COMPANY, USER, KEY, { pairs: [{ external_ids: [T1], journal_entry_ids: [E1] }] }, { dryRun: false })
expect(createEntryMock).toHaveBeenCalledWith(
supabase,
COMPANY,
USER,
expect.objectContaining({ fiscal_period_id: 'fp-1', entry_date: '2026-08-05', source_type: 'manual' }),
)
expect(findCall('transaction_voucher_links', 'insert')?.[0]).toMatchObject({
transaction_id: T1,
journal_entry_id: RES,
allocated_amount: -10,
role: 'other',
})
})
it('refuses a zero residual, a residual above the cap, and a kind in the wrong direction', async () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx({ amount: -1000 })] })
enqueue({ data: entry(-1000) })
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toMatchObject({ code: 'RESIDUAL_ZERO' })
reset()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx({ amount: -9000 })] })
enqueue({ data: entry(-1000) })
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toMatchObject({ code: 'RESIDUAL_TOO_LARGE' })
reset()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx({ amount: 1005 })] })
enqueue({ data: entry(1000) })
// The bank received MORE than booked: a bank fee (expense) is the wrong kind.
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toMatchObject({ code: 'RESIDUAL_DIRECTION' })
expect(matchMock).not.toHaveBeenCalled()
})
it('books interest income on the income side when the bank received more', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx({ amount: 1002.5 })] })
enqueue({ data: entry(1000) })
const result = await bookResidualAndLink(
supabase as never,
COMPANY,
USER,
KEY,
{ external_ids: [T1], journal_entry_id: E1, kind: 'interest_income' },
{ dryRun: true },
)
expect(result).toMatchObject({
dry_run: true,
would_book: {
residual_amount: 2.5,
lines: [
{ account_number: '1930', debit_amount: 2.5, credit_amount: 0 },
{ account_number: '8310', debit_amount: 0, credit_amount: 2.5 },
],
},
})
})
it('undoes the links when the residual booking is refused', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx()] })
enqueue({ data: entry(-1000) })
createEntryMock.mockRejectedValue(new Error('Perioden är låst'))
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toThrow(/låst/)
expect(unmatchMock).toHaveBeenCalledWith(supabase, COMPANY, USER, KEY, T1)
})
it('reports a failed link without booking anything', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx()] })
enqueue({ data: entry(-1000) })
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [], skipped: [{ pair: { external_ids: [T1], journal_entry_ids: [E1] }, code: 'PAIR_NOT_CLOSED', message: 'Verifikationen saknar rad på 1930' }] })
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toBeInstanceOf(ReconciliationResidualError)
expect(createEntryMock).not.toHaveBeenCalled()
})
})
+51 -3
View File
@@ -731,6 +731,7 @@ export async function getReconciliationStatus(
// manufacture a phantom, unexplainable difference. Ordered on id (unique) so
// pages never duplicate or skip rows across boundaries.
type StatusTxRow = {
id?: string | null
date: string | null
amount: number | string | null
journal_entry_id: string | null
@@ -741,7 +742,7 @@ export async function getReconciliationStatus(
const transactions = await fetchAllRows<StatusTxRow>(({ from, to }) => {
let txQuery = supabase
.from('transactions')
.select('date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id')
.select('id, date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id')
.eq('company_id', companyId)
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
@@ -756,6 +757,16 @@ export async function getReconciliationStatus(
currency,
})
// Transactions anchored through transaction_voucher_links (bulk-booked
// samlingsverifikat, residual bookings) carry journal_entry_id = NULL on the
// row itself. They are settled all the same, so the matched/unmatched split
// below must see them; is_transaction_booked() is the SQL twin of this.
const junctionLinkedTxIds = await fetchJunctionLinkedTxIds(
supabase,
companyId,
transactions.map((tx) => tx.id).filter((id): id is string => typeof id === 'string'),
)
// Get GL bank-account lines. We fetch posted AND reversed entries and count
// them TOGETHER: the exact inclusion rule the trial balance and balance sheet
// use (see lib/reports/trial-balance.ts, which sums `['posted','reversed']`).
@@ -926,13 +937,15 @@ export async function getReconciliationStatus(
// Matched/unmatched partition the RECONCILABLE (non-ignored) set, so
// matched_count + unmatched_transaction_count always equals the number of
// rows behind bank_transaction_total.
const matchedCount = reconcilableTx.filter((tx) => tx.journal_entry_id !== null).length
const isLinked = (tx: StatusTxRow): boolean =>
tx.journal_entry_id !== null || (typeof tx.id === 'string' && junctionLinkedTxIds.has(tx.id))
const matchedCount = reconcilableTx.filter(isLinked).length
// The gross split behind the net: what the user actually recognises as
// "what moved on the bank", so the page never has to explain "netto".
const bankInflow = reconcilableTx.reduce((sum, tx) => sum + Math.max(Number(tx.amount) || 0, 0), 0)
const bankOutflow = reconcilableTx.reduce((sum, tx) => sum + Math.min(Number(tx.amount) || 0, 0), 0)
const unmatchedTx = reconcilableTx.filter((tx) => tx.journal_entry_id === null)
const unmatchedTx = reconcilableTx.filter((tx) => !isLinked(tx))
const unmatchedTransactionCount = unmatchedTx.length
const unmatchedTransactionTotal = unmatchedTx.reduce(
(sum, tx) => sum + (Number(tx.amount) || 0),
@@ -1207,6 +1220,14 @@ 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.
await supabase
.from('transaction_voucher_links')
.delete()
.eq('company_id', companyId)
.eq('transaction_id', transactionId)
logMatchEvent(supabase, userId, transactionId, 'unmatched', {
previousState: {
journal_entry_id: tx.journal_entry_id,
@@ -1467,6 +1488,33 @@ export async function fetchUnlinkedGLLines(
}
}
/**
* Ids of the given transactions that are anchored to a verifikat through
* transaction_voucher_links (journal_entry_id NULL on the row itself). Chunked
* on the id list so a busy window never pushes the .in() past URL limits;
* a failed read returns the empty set rather than throwing, mirroring
* fetchUnlinkedGLLines' legacy contract.
*/
export async function fetchJunctionLinkedTxIds(
supabase: SupabaseClient,
companyId: string,
transactionIds: string[],
): Promise<Set<string>> {
const out = new Set<string>()
const CHUNK = 150
for (let i = 0; i < transactionIds.length; i += CHUNK) {
const chunk = transactionIds.slice(i, i + CHUNK)
const { data, error } = await supabase
.from('transaction_voucher_links')
.select('transaction_id')
.eq('company_id', companyId)
.in('transaction_id', chunk)
if (error) return out
for (const row of (data ?? []) as Array<{ transaction_id: string }>) out.add(row.transaction_id)
}
return out
}
/** A match candidate that carries how many transactions already point at it. */
export interface GLLineForMatching extends UnlinkedGLLine {
/** Transactions settling this entry ON THE REQUESTED ACCOUNT (plus legacy
+9 -2
View File
@@ -1,6 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { fetchUnlinkedGLLines, scopeTransactionsToAccount } from './bank-reconciliation'
import { fetchJunctionLinkedTxIds, fetchUnlinkedGLLines, scopeTransactionsToAccount } from './bank-reconciliation'
import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation'
import {
parseAccountKey,
@@ -147,11 +147,18 @@ export async function listAccountItems(
const { data, error: txError } = await query.order('date', { ascending: false }).order('id', { ascending: true })
if (txError) throw new Error(`Kunde inte hämta transaktioner: ${txError.message}`)
const rows = (data ?? []) as BankTxRow[]
// Rows anchored only through transaction_voucher_links (bulk-book,
// residual bookings) are matched too; their pointer column is NULL.
const junctionLinked = await fetchJunctionLinkedTxIds(
supabase,
companyId,
rows.filter((tx) => !tx.journal_entry_id && !tx.is_ignored).map((tx) => tx.id),
)
{
for (const tx of rows) {
const bucket: ReconciliationItemBucket = tx.is_ignored
? 'ignored'
: tx.journal_entry_id
: tx.journal_entry_id || junctionLinked.has(tx.id)
? 'matched'
: tx.potential_journal_entry_id
? 'proposed'
+336
View File
@@ -0,0 +1,336 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { roundOre } from '@/lib/money'
import { ISO_DATE_RE } from '@/lib/invariants'
import { createLogger } from '@/lib/logger'
import { matchPairs, unmatchLink, type AppliedLink, type SkippedPair } from './actions'
import { parseAccountKey } from './schemas'
const log = createLogger('reconciliation/residual')
/**
* Residual booking: the worksheet selection (N bank rows against one
* verifikat) misses by a few kronor, and the remainder is a real event the
* books lack: a bank fee, interest, öresavrundning. This books that remainder
* as its own small verifikat on the bank account and settles the selection in
* the same gesture: the rows point at the main verifikat as usual, and the
* residual verifikat is anchored to the first row through
* transaction_voucher_links, which the bridge treats as a link.
*
* Bank accounts only: Skatteverket posts ränta and avgifter as rows of their
* own on the skattekonto, so a skattekonto pair that does not close is a
* wrong booking, never a missing fee.
*/
export type ResidualKind = 'bank_fee' | 'rounding' | 'interest_income' | 'interest_expense'
export const RESIDUAL_KINDS: Record<
ResidualKind,
{ account: string; label_sv: string; label_en: string; direction: 'out' | 'in' | 'any' }
> = {
// Money that left the bank without a booking: expenses.
bank_fee: { account: '6570', label_sv: 'Bankavgift', label_en: 'Bank fee', direction: 'out' },
interest_expense: { account: '8410', label_sv: 'Räntekostnad', label_en: 'Interest expense', direction: 'out' },
// Money that reached the bank without a booking: income.
interest_income: { account: '8310', label_sv: 'Ränteintäkt', label_en: 'Interest income', direction: 'in' },
// Either way, öre-level.
rounding: { account: '3740', label_sv: 'Öresavrundning', label_en: 'Rounding', direction: 'any' },
}
export type ResidualErrorCode =
| 'RESIDUAL_UNSUPPORTED_KIND'
| 'RESIDUAL_ROWS_NOT_FOUND'
| 'RESIDUAL_ROW_NOT_OPEN'
| 'RESIDUAL_ENTRY_NOT_FOUND'
| 'RESIDUAL_ENTRY_NOT_POSTED'
| 'RESIDUAL_ZERO'
| 'RESIDUAL_TOO_LARGE'
| 'RESIDUAL_DIRECTION'
| 'RESIDUAL_NO_PERIOD'
| 'RESIDUAL_LINK_FAILED'
| 'RESIDUAL_INVALID_DATE'
export class ReconciliationResidualError extends Error {
readonly code: ResidualErrorCode
constructor(message: string, code: ResidualErrorCode) {
super(message)
this.name = 'ReconciliationResidualError'
this.code = code
}
}
/** Above this, a "residual" is a missing booking, not a fee: refuse so the user books it properly. */
export const RESIDUAL_MAX_AMOUNT = 5000
export interface ResidualInput {
external_ids: string[]
journal_entry_id: string
kind: ResidualKind
/** Defaults to the latest selected transaction's date. */
entry_date?: string
/** Defaults to "<kind label>: <main verifikat description>". */
description?: string
}
export interface ResidualPreview {
account_key: string
kind: ResidualKind
counter_account: string
ledger_account: string
currency: string
transactions_total: number
entry_net: number
/** transactions_total - entry_net: negative = the bank paid more than booked. */
residual_amount: number
entry_date: string
description: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
export type ResidualResult =
| { dry_run: true; would_book: ResidualPreview }
| {
dry_run: false
residual_journal_entry_id: string
residual_amount: number
applied: AppliedLink[]
skipped: SkippedPair[]
}
interface TxRow {
id: string
date: string
amount: number | string
description: string | null
journal_entry_id: string | null
is_ignored: boolean | null
cash_account_id: string | null
}
export async function bookResidualAndLink(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountKey: string,
input: ResidualInput,
options: { dryRun?: boolean } = {},
): Promise<ResidualResult | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
if (parsed.kind !== 'bank') {
throw new ReconciliationResidualError(
'Restbelopp bokförs bara på bankkonton. På skattekontot är ränta och avgifter egna händelser: koppla dem i stället.',
'RESIDUAL_UNSUPPORTED_KIND',
)
}
const spec = RESIDUAL_KINDS[input.kind]
if (!spec) {
throw new ReconciliationResidualError('Okänd typ av restbelopp.', 'RESIDUAL_UNSUPPORTED_KIND')
}
if (input.entry_date && !ISO_DATE_RE.test(input.entry_date)) {
throw new ReconciliationResidualError('Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.', 'RESIDUAL_INVALID_DATE')
}
const { data: account } = await supabase
.from('cash_accounts')
.select('id, ledger_account, currency')
.eq('company_id', companyId)
.eq('id', parsed.cashAccountId)
.maybeSingle<{ id: string; ledger_account: string; currency: string | null }>()
if (!account) return null
const ledgerAccount = account.ledger_account
const currency = account.currency ?? 'SEK'
const ids = [...new Set(input.external_ids)]
if (ids.length === 0 || ids.length > 50) {
throw new ReconciliationResidualError('Välj mellan 1 och 50 banktransaktioner.', 'RESIDUAL_ROWS_NOT_FOUND')
}
const { data: txRows, error: txError } = await supabase
.from('transactions')
.select('id, date, amount, description, journal_entry_id, is_ignored, cash_account_id')
.eq('company_id', companyId)
.in('id', ids)
if (txError || !txRows || txRows.length !== ids.length) {
throw new ReconciliationResidualError('Någon av banktransaktionerna hittades inte.', 'RESIDUAL_ROWS_NOT_FOUND')
}
const txs = txRows as TxRow[]
if (txs.some((t) => t.journal_entry_id || t.is_ignored)) {
throw new ReconciliationResidualError(
'En av transaktionerna är redan kopplad eller ignorerad.',
'RESIDUAL_ROW_NOT_OPEN',
)
}
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id, status, description, lines:journal_entry_lines ( account_number, debit_amount, credit_amount )')
.eq('id', input.journal_entry_id)
.eq('company_id', companyId)
.maybeSingle<{
id: string
status: string
description: string | null
lines: Array<{ account_number: string; debit_amount: number | string; credit_amount: number | string }> | null
}>()
if (entryError || !entry) {
throw new ReconciliationResidualError('Verifikatet hittades inte.', 'RESIDUAL_ENTRY_NOT_FOUND')
}
if (entry.status !== 'posted') {
throw new ReconciliationResidualError('Verifikatet är inte bokfört.', 'RESIDUAL_ENTRY_NOT_POSTED')
}
const entryNet = roundOre(
(entry.lines ?? [])
.filter((l) => l.account_number === ledgerAccount)
.reduce((s, l) => s + Number(l.debit_amount || 0) - Number(l.credit_amount || 0), 0),
)
const txTotal = roundOre(txs.reduce((s, t) => s + Number(t.amount || 0), 0))
const residual = roundOre(txTotal - entryNet)
if (Math.abs(residual) < 0.005) {
throw new ReconciliationResidualError(
'Summorna stämmer redan: koppla utan restbelopp.',
'RESIDUAL_ZERO',
)
}
if (Math.abs(residual) > RESIDUAL_MAX_AMOUNT) {
throw new ReconciliationResidualError(
`Restbeloppet är större än ${RESIDUAL_MAX_AMOUNT} kr. Det är en saknad bokföring, inte en avgift: bokför den som ett eget verifikat.`,
'RESIDUAL_TOO_LARGE',
)
}
const direction: 'out' | 'in' = residual < 0 ? 'out' : 'in'
if (spec.direction !== 'any' && spec.direction !== direction) {
throw new ReconciliationResidualError(
direction === 'out'
? 'Banken har betalat ut mer än vad som är bokfört: restbeloppet är en kostnad (avgift eller räntekostnad), inte en intäkt.'
: 'Banken har tagit emot mer än vad som är bokfört: restbeloppet är en intäkt (ränteintäkt), inte en kostnad.',
'RESIDUAL_DIRECTION',
)
}
const amount = roundOre(Math.abs(residual))
// Expense: the bank line is a credit (money left), the counter account a debit.
// Income: the bank line is a debit (money arrived), the counter a credit.
const lines =
direction === 'out'
? [
{ account_number: spec.account, debit_amount: amount, credit_amount: 0 },
{ account_number: ledgerAccount, debit_amount: 0, credit_amount: amount },
]
: [
{ account_number: ledgerAccount, debit_amount: amount, credit_amount: 0 },
{ account_number: spec.account, debit_amount: 0, credit_amount: amount },
]
const latestTxDate = txs.map((t) => t.date).sort().at(-1) ?? new Date().toISOString().slice(0, 10)
const entryDate = input.entry_date ?? latestTxDate
const description =
input.description?.trim() ||
`${spec.label_sv}: ${entry.description?.trim() || txs[0].description?.trim() || 'avstämning'}`
const preview: ResidualPreview = {
account_key: accountKey,
kind: input.kind,
counter_account: spec.account,
ledger_account: ledgerAccount,
currency,
transactions_total: txTotal,
entry_net: entryNet,
residual_amount: residual,
entry_date: entryDate,
description,
lines,
}
if (options.dryRun) return { dry_run: true, would_book: preview }
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
throw new ReconciliationResidualError(
`Det finns inget räkenskapsår för ${entryDate}.`,
'RESIDUAL_NO_PERIOD',
)
}
// Link first (reversible), then book. If the booking is refused (a lock, a
// trigger), the links are undone so the selection is back where it was.
const linkResult = await matchPairs(
supabase,
companyId,
userId,
accountKey,
{ pairs: [{ external_ids: ids, journal_entry_ids: [entry.id] }] },
{ dryRun: false },
)
if (!linkResult || linkResult.applied.length !== ids.length) {
// Undo whatever did link before reporting.
for (const a of linkResult?.applied ?? []) {
try {
await unmatchLink(supabase, companyId, userId, accountKey, a.external_id)
} catch {
// best effort
}
}
const first = linkResult?.skipped[0]
throw new ReconciliationResidualError(
first ? first.message : 'Kunde inte koppla transaktionerna till verifikatet.',
'RESIDUAL_LINK_FAILED',
)
}
let residualEntry: { id: string }
try {
residualEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description,
source_type: 'manual',
lines,
})
} catch (err) {
for (const id of ids) {
try {
await unmatchLink(supabase, companyId, userId, accountKey, id)
} catch {
// best effort
}
}
throw err
}
// Anchor the residual verifikat to the selection through the junction (the
// rows' pointer column already holds the main verifikat). One row carries
// the whole residual so Sum(allocated_amount) per verifikat equals its bank
// side, the invariant bulk-book keeps.
const { error: linkError } = await supabase.from('transaction_voucher_links').insert({
user_id: userId,
company_id: companyId,
transaction_id: ids[0],
journal_entry_id: residualEntry.id,
allocated_amount: residual,
role: 'other',
})
if (linkError) {
log.error('residual verifikat booked but the junction link failed', linkError, {
companyId,
accountKey,
residualEntryId: residualEntry.id,
})
}
log.info('residual booked and linked', {
companyId,
accountKey,
kind: input.kind,
residual,
residualEntryId: residualEntry.id,
linked: ids.length,
})
return {
dry_run: false,
residual_journal_entry_id: residualEntry.id,
residual_amount: residual,
applied: linkResult.applied,
skipped: linkResult.skipped,
}
}
+10 -1
View File
@@ -7913,7 +7913,16 @@
"match_no_entry": "No voucher selected",
"match_difference": "Difference {amount}",
"match_hint_residual": "The difference must be 0 to link. Book the remainder first, then link.",
"match_apply": "Link {count} events"
"match_apply": "Link {count} events",
"match_hint_residual_skv": "The difference must be 0 to link. On the tax account, interest and fees are events of their own: link them instead.",
"match_residual_label": "Book the remainder as",
"match_residual_pick": "Pick a kind",
"match_residual_apply": "Book {amount} and link",
"residual_bank_fee": "Bank fee (6570)",
"residual_interest_expense": "Interest expense (8410)",
"residual_interest_income": "Interest income (8310)",
"residual_rounding": "Rounding (3740)",
"toast_residual_booked": "{amount} booked and linked"
},
"skattekonto": {
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
+10 -1
View File
@@ -7913,7 +7913,16 @@
"match_no_entry": "Inget verifikat valt",
"match_difference": "Differens {amount}",
"match_hint_residual": "Differensen måste vara 0 för att koppla. Bokför mellanskillnaden först, sedan kopplar du.",
"match_apply": "Koppla {count} händelser"
"match_apply": "Koppla {count} händelser",
"match_hint_residual_skv": "Differensen måste vara 0 för att koppla. På skattekontot är ränta och avgifter egna händelser: koppla dem i stället.",
"match_residual_label": "Bokför mellanskillnaden som",
"match_residual_pick": "Välj typ",
"match_residual_apply": "Bokför {amount} och koppla",
"residual_bank_fee": "Bankavgift (6570)",
"residual_interest_expense": "Räntekostnad (8410)",
"residual_interest_income": "Ränteintäkt (8310)",
"residual_rounding": "Öresavrundning (3740)",
"toast_residual_booked": "{amount} bokfört och kopplat"
},
"skattekonto": {
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
+4 -3
View File
@@ -8,7 +8,7 @@ description: >-
transactions and reconciliation, payroll (lön), VAT/moms and financial
reports, SIE import/export, documents, webhooks. Covers auth with
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
pagination, scopes), and all 134 endpoints.
pagination, scopes), and all 135 endpoints.
---
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
@@ -140,7 +140,7 @@ call can undo it, e.g. invoice credit).
## Endpoint index
API version `2026-05-12`, 134 operations. Paths are shown without
API version `2026-05-12`, 135 operations. Paths are shown without
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
### Core (4)
@@ -251,7 +251,7 @@ POST /companies/{companyId}/documents/{id}/link : Link a document to a journal e
POST /companies/{companyId}/inbox-items/{id}/stamp : Mark an inbox item as consumed by a journal entry [scope:documents:write risk:low idempotent]
```
### Banking (21)
### Banking (22)
Full detail: [references/banking.md](references/banking.md)
@@ -264,6 +264,7 @@ GET /companies/{companyId}/reconciliation/accounts/{accountKey}/items : List the
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/items/{itemId}/ignore : Ignore or restore one outside row [scope:reconciliation:write risk:low idempotent dry-run reversible]
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/links : Link outside rows to existing verifikat (pairs or proposals) [scope:reconciliation:write risk:medium dry-run reversible]
DELETE /companies/{companyId}/reconciliation/accounts/{accountKey}/links/{linkId} : Remove a link between an outside row and a verifikat [scope:reconciliation:write risk:low idempotent dry-run reversible]
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/residual : Book the remainder of a bank selection as a fee/interest/rounding verifikat and link the selection [scope:transactions:write risk:medium dry-run]
GET /companies/{companyId}/reconciliation/accounts/{accountKey}/signoff : Sign-off history for one reconcilable account [scope:reconciliation:read risk:low idempotent reversible]
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/signoff : Mark an account reconciled through a date (sign-off) [scope:reconciliation:signoff risk:medium dry-run reversible]
POST /companies/{companyId}/reconciliation/accounts/{accountKey}/signoff/{signoffId}/reopen : Reopen (undo) a reconciliation sign-off [scope:reconciliation:signoff risk:low idempotent dry-run reversible]
@@ -348,6 +348,60 @@ Response `200`:
---
### `POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual`
**Book the remainder of a bank selection as a fee/interest/rounding verifikat and link the selection.**
`scope:transactions:write · risk:medium · dry-run`
Body: { external_ids: [transaction ids], journal_entry_id, kind: "bank_fee" | "interest_expense" | "interest_income" | "rounding", entry_date?, description? }. Computes the difference between the transactions' sum and the verifikat's net on the bank account, books it on 6570 / 8410 / 8310 / 3740 against the bank account (dated on the latest transaction by default), links the transactions to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (bank:<cash_account_id>). Refused when the difference is 0 (RESIDUAL_ZERO), above 5000 kr (RESIDUAL_TOO_LARGE: that is a missing booking, not a fee), or when the kind points the wrong way (RESIDUAL_DIRECTION). ?dry_run=true returns would_book without writing.
**Use when:** A manual match misses by a small amount that is genuinely a bank fee, interest or rounding, and you want to close it in one step instead of booking a verifikat and then linking.
**Do not use for:** Skattekonto rows (Skatteverket posts ränta and avgifter as their own rows: link them), or differences that are really a missing booking (book that properly).
**Pitfalls:**
- The kind must match the direction: money that left the bank unbooked is bank_fee / interest_expense; money that arrived unbooked is interest_income; rounding works either way.
- Links are made before the booking and undone if the booking is refused (a locked period), so a refusal leaves nothing half done.
- Idempotency-Key is required; repeating the same key replays the first response.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `accountKey` | path | `string` | yes | |
Request body:
```ts
{
external_ids: string[],
journal_entry_id: string,
kind: "bank_fee" | "rounding" | "interest_income" | "interest_expense",
entry_date?: string,
description?: string
}
```
Response `200`:
```ts
{
data: {
dry_run: boolean,
residual_journal_entry_id?: string,
residual_amount?: number,
applied?: { external_id: string, journal_entry_id: string }[],
skipped?: { code: string, message: string }[],
would_book?: { kind: string, counter_account: string, ledger_account: string, currency: string, transactions_total: number, entry_net: number, residual_amount: number, entry_date: string, description: string, lines: { account_number: string, debit_amount: number, credit_amount: number }[] }
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
---
### `GET /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/signoff`
**Sign-off history for one reconcilable account.**
@@ -0,0 +1,192 @@
-- The bank reconciliation's ledger side treats a verifikat as "matched" only
-- when a transactions row points at it (transactions.journal_entry_id). A
-- verifikat settled through transaction_voucher_links (a samlingsverifikation
-- from bulk-book, or a residual booking on the Avstämning page) carries no
-- such pointer: its rows have journal_entry_id NULL and the link lives in the
-- junction. Both GL RPCs therefore listed those verifikat as "utan
-- banktransaktion" while the TS side listed their transactions as unmatched:
-- the bridge still netted to zero, but the open buckets were polluted and a
-- residual booking could never disappear from the worksheet.
--
-- This re-creates get_unlinked_gl_lines and get_account_gl_lines_for_matching
-- with the junction counted as a link, exactly as is_transaction_booked()
-- (20260529120000) already does for the inbox. Signatures, tenant guards and
-- grants are unchanged. The TS twin is fetchJunctionLinkedTxIds in
-- lib/reconciliation/bank-reconciliation.ts.
CREATE OR REPLACE FUNCTION public.get_unlinked_gl_lines(
p_company_id UUID,
p_account_number TEXT DEFAULT '1930',
p_date_from DATE DEFAULT NULL,
p_date_to DATE DEFAULT NULL
)
RETURNS TABLE (
line_id UUID,
journal_entry_id UUID,
debit_amount NUMERIC,
credit_amount NUMERIC,
line_description TEXT,
entry_date DATE,
voucher_number INT,
voucher_series TEXT,
entry_description TEXT,
source_type TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
jel.id AS line_id,
je.id AS journal_entry_id,
jel.debit_amount,
jel.credit_amount,
jel.line_description,
je.entry_date,
je.voucher_number,
je.voucher_series,
je.description AS entry_description,
je.source_type
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_number = p_account_number
AND je.company_id = p_company_id
AND je.status = 'posted'
AND je.source_type IS DISTINCT FROM 'opening_balance'
AND je.source_type IS DISTINCT FROM 'storno'
AND je.source_type IS DISTINCT FROM 'correction'
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
AND NOT EXISTS (
SELECT 1
FROM public.transactions t
WHERE t.journal_entry_id = je.id
AND t.company_id = p_company_id
)
AND NOT EXISTS (
SELECT 1
FROM public.transaction_voucher_links l
WHERE l.journal_entry_id = je.id
AND l.company_id = p_company_id
)
-- Tenant guard: anon/authenticated may only read their own companies;
-- service_role and direct/superuser access (no JWT role) bypass.
AND (
coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '')
NOT IN ('anon', 'authenticated')
OR je.company_id IN (SELECT public.user_company_ids())
)
ORDER BY je.entry_date, je.voucher_number;
$$;
CREATE OR REPLACE FUNCTION public.get_account_gl_lines_for_matching(
p_company_id UUID,
p_account_number TEXT DEFAULT '1930',
p_date_from DATE DEFAULT NULL,
p_date_to DATE DEFAULT NULL,
p_include_matched BOOLEAN DEFAULT false
)
RETURNS TABLE (
line_id UUID,
journal_entry_id UUID,
debit_amount NUMERIC,
credit_amount NUMERIC,
line_description TEXT,
entry_date DATE,
voucher_number INT,
voucher_series TEXT,
entry_description TEXT,
source_type TEXT,
linked_transaction_count INT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
jel.id AS line_id,
je.id AS journal_entry_id,
jel.debit_amount,
jel.credit_amount,
jel.line_description,
je.entry_date,
je.voucher_number,
je.voucher_series,
je.description AS entry_description,
je.source_type,
-- Account-scoped: a transaction provably on ANOTHER cash account (its
-- cash_accounts row resolves to a different ledger_account) does not make
-- this voucher "matched" for p_account_number. A NULL / unresolvable cash
-- account keeps counting for every account (conservative legacy behavior).
-- Junction-linked transactions count exactly like pointer-linked ones.
(
(
SELECT count(*)
FROM public.transactions t
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
WHERE t.journal_entry_id = je.id
AND t.company_id = p_company_id
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
) + (
SELECT count(*)
FROM public.transaction_voucher_links l
JOIN public.transactions t ON t.id = l.transaction_id
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
WHERE l.journal_entry_id = je.id
AND l.company_id = p_company_id
AND t.journal_entry_id IS DISTINCT FROM je.id
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
)
)::int AS linked_transaction_count
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_number = p_account_number
AND je.company_id = p_company_id
AND je.status = 'posted'
AND je.source_type IS DISTINCT FROM 'opening_balance'
AND je.source_type IS DISTINCT FROM 'storno'
AND je.source_type IS DISTINCT FROM 'correction'
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
AND (
p_include_matched
OR (
NOT EXISTS (
SELECT 1
FROM public.transactions t
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
WHERE t.journal_entry_id = je.id
AND t.company_id = p_company_id
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
)
AND NOT EXISTS (
SELECT 1
FROM public.transaction_voucher_links l
JOIN public.transactions t ON t.id = l.transaction_id
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
WHERE l.journal_entry_id = je.id
AND l.company_id = p_company_id
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
)
)
)
-- Tenant guard: anon/authenticated may only read their own companies;
-- service_role and direct/superuser access (no JWT role) bypass.
AND (
coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '')
NOT IN ('anon', 'authenticated')
OR je.company_id IN (SELECT public.user_company_ids())
)
ORDER BY je.entry_date, je.voucher_number;
$$;
-- CREATE OR REPLACE preserves the ACL; re-assert least privilege so this
-- migration stands alone on a fresh replay (20260611130000).
REVOKE EXECUTE ON FUNCTION public.get_unlinked_gl_lines(uuid, text, date, date) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_unlinked_gl_lines(uuid, text, date, date) TO authenticated, service_role;
REVOKE EXECUTE ON FUNCTION public.get_account_gl_lines_for_matching(uuid, text, date, date, boolean) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_account_gl_lines_for_matching(uuid, text, date, date, boolean) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';
+118
View File
@@ -0,0 +1,118 @@
import { randomUUID } from 'node:crypto'
import { describe, it, expect } from 'vitest'
import { getPool } from './setup'
import { seedCompany, insertCashAccount, insertTransaction, insertPostedJournalEntry } from './fixtures'
// pg-real coverage for 20260824190000_gl_lines_consider_voucher_links: a
// verifikat anchored to a transaction only through transaction_voucher_links
// (bulk-book samlingsverifikat, residual bookings) must count as matched in
// both GL RPCs exactly like a pointer-linked one: absent from the unlinked
// list, absent from the default matching candidates, present with a
// linked_transaction_count of 1 when matched vouchers are included.
async function linkThroughJunction(params: {
userId: string
companyId: string
transactionId: string
journalEntryId: string
amount: number
}): Promise<void> {
await getPool().query(
`INSERT INTO public.transaction_voucher_links
(id, user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
VALUES ($1, $2, $3, $4, $5, $6, 'other')`,
[randomUUID(), params.userId, params.companyId, params.transactionId, params.journalEntryId, params.amount],
)
}
describe('GL line RPCs treat transaction_voucher_links as links', () => {
it('hides a junction-linked verifikat from the unlinked list and the default candidates', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
// Control: an unlinked verifikat on 1930 keeps surfacing.
const unlinkedEntry = await insertPostedJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-06-10',
description: 'Utan koppling',
lines: [
{ accountNumber: '6570', debitAmount: 45, creditAmount: 0 },
{ accountNumber: '1930', debitAmount: 0, creditAmount: 45 },
],
})
// Junction-linked: the transaction's pointer stays NULL.
const junctionEntry = await insertPostedJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-06-11',
description: 'Bankavgift: restbelopp',
lines: [
{ accountNumber: '6570', debitAmount: 10, creditAmount: 0 },
{ accountNumber: '1930', debitAmount: 0, creditAmount: 10 },
],
})
const txId = await insertTransaction({
companyId,
userId,
amount: -10,
date: '2026-06-11',
cashAccountId,
journalEntryId: null,
})
await linkThroughJunction({ userId, companyId, transactionId: txId, journalEntryId: junctionEntry, amount: -10 })
const unlinked = await getPool().query<{ journal_entry_id: string }>(
`SELECT journal_entry_id FROM public.get_unlinked_gl_lines($1, '1930', NULL, NULL)`,
[companyId],
)
const unlinkedIds = unlinked.rows.map((r) => r.journal_entry_id)
expect(unlinkedIds).toContain(unlinkedEntry)
expect(unlinkedIds).not.toContain(junctionEntry)
const candidates = await getPool().query<{ journal_entry_id: string; linked_transaction_count: number }>(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching($1, '1930', NULL, NULL, false)`,
[companyId],
)
const candidateIds = candidates.rows.map((r) => r.journal_entry_id)
expect(candidateIds).toContain(unlinkedEntry)
expect(candidateIds).not.toContain(junctionEntry)
const withMatched = await getPool().query<{ journal_entry_id: string; linked_transaction_count: number }>(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching($1, '1930', NULL, NULL, true)`,
[companyId],
)
const junctionRow = withMatched.rows.find((r) => r.journal_entry_id === junctionEntry)
expect(junctionRow?.linked_transaction_count).toBe(1)
const controlRow = withMatched.rows.find((r) => r.journal_entry_id === unlinkedEntry)
expect(controlRow?.linked_transaction_count).toBe(0)
})
it('does not double count a transaction that is both pointer- and junction-linked to the same verifikat', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
const entry = await insertPostedJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-06-12',
lines: [
{ accountNumber: '1930', debitAmount: 1000, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: 1000 },
],
})
const txId = await insertTransaction({ companyId, userId, amount: 1000, date: '2026-06-12', cashAccountId, journalEntryId: entry })
await linkThroughJunction({ userId, companyId, transactionId: txId, journalEntryId: entry, amount: 1000 })
const withMatched = await getPool().query<{ journal_entry_id: string; linked_transaction_count: number }>(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching($1, '1930', NULL, NULL, true)`,
[companyId],
)
expect(withMatched.rows.find((r) => r.journal_entry_id === entry)?.linked_transaction_count).toBe(1)
})
})