diff --git a/DECISIONS.md b/DECISIONS.md index a71529cc..1f8d1b29 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1340,6 +1340,11 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review). [2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring. [2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card. +[2026-08-29] Bank 1:N (#1553, one bank row over several verifikat) is stored junction-only: transactions.journal_entry_id stays NULL and one transaction_voucher_links row per verifikat carries a signed role='bank_line' slice in the transaction's sign convention (the same shape bulk_book_transactions writes), never a pointer at "the first" voucher: the pointer column is a 1:1 fact, every reader that matters already asks isTransactionBooked / is_transaction_booked() / the GL RPCs' junction count, and a pointer at one of N would make koppla-bort, storno and the bridge all disagree about which voucher the row "belongs" to. The slices must sum to the row (VOUCHER_LINK_AMOUNT_TOLERANCE), each must have the sign of, and be no larger than, that voucher's net line on the account; the locked transactions UPDATE (.is journal_entry_id null, or .eq the stale pointer of a reversed entry per #988) runs BEFORE the one junction insert so a concurrent linker matches zero rows, and a failed insert rolls the lock back. +[2026-08-29] Storno of one of a split's N vouchers releases the row WHOLE (engine reverseEntry): after the reversed voucher's junction rows go, a pointer-less row whose remaining anchors are all role='bank_line' but no longer sum to its amount has its surviving slices deleted and returns to Att bokföra (is_business/category/reconciliation_method null), so the surviving verifikat resurface as unmatched and the user re-splits against the corrected booking. The alternative (keep the partial anchor) would show the row as booked while the bridge moved by the reversed slice with nothing to act on. Rows whose remaining slices still cover the amount (bulk-book, one row per transaction) and rows with a non-bank_line anchor (a residual's 'other' row) are untouched, as before. +[2026-08-29] The re-booking guards (manualLink, categorize-core, link-journal-entry) refuse on a transaction_voucher_links row of role 'bank_line' only (hasBankLineJunctionRow), not on any junction row, while the list/ignore readers (fetchJunctionLinkedTxIds, is_transaction_booked()) keep counting every role: a residual booking's 'other' row survives a storno of the main verifikat, which also nulls reconciliation_method so koppla-bort refuses the row; refusing to re-book it would strand it with no path out. linkTransactionToVouchers itself stays strict (any junction row blocks a split) because the UNIQUE (transaction_id, journal_entry_id) key would refuse re-anchoring that voucher anyway. +[2026-08-29] The worksheet (ManualMatchMode) is the only 1:N surface: its right pane turns multi-select while exactly one bank row is picked and single-select otherwise (so N:M can never be built), the split sends no allocations (each slice defaults to the voucher's bank line and the engine enforces the sum, which is exactly the difference-is-zero gate), and no residual is offered for the split shape. MatchVoucherDialog stays single-pick with a one-line pointer to Bankavstämning > Matcha manuellt: the split needs the sum arithmetic and the unmatched-ledger list the worksheet already has, and a fourth dialog next to match-voucher, match-invoice and dela-betalning was the issue's own anti-goal. +[2026-08-29] gnubok_reconcile_match grew an allocations item schema and the tools/list bench (64 400 ceiling) went from exactly-at to over; paid for by trimming redundant prose in the same family (the account_key and legacy account_number property descriptions of gnubok_get_reconciliation_status repeated the tool description; gnubok_list_reconciliation_items listed the bucket enum in prose), no tool demoted and no ceiling bump. The MCP resolver folds the dry-run preview's split links (allocated_amount on the same external_id) back into ONE staged pair with explicit allocations so the executor re-validates the amounts the reviewer approved; the N:1 bank path still has no server-side sum check and is not advertised as a pair shape for agents beyond what it was. [2026-08-29] Document inbox upload over the hosted body limit goes direct-to-storage through a signed URL, threshold-only (#1551): files that fit a 4.5 MB function body keep the multipart /upload route, files between HOSTED_MAX_UPLOAD_BYTES and the inbox's 10 MB cap take /upload/create -> browser PUT -> /upload/complete. Two paths to keep correct, but the multipart path carries every other channel's semantics (rate limit, dedupe, staged extraction) and the split is at uploadDocument(): everything after it is one shared processArchivedDocument(), so the two paths cannot drift in what they file. The browser PUTs to the RAW Supabase Storage URL, never the /api/storage same-origin proxy the MCP tools get: that proxy buffers the body inside a function and sits under the exact ceiling this exists to get around. The cap stays 10 MB (MAX_FILE_SIZE, MAX_DOCUMENT_SIZE) not the issue's 20 MB: raising it is a founder call that touches the archive contract and the MCP tools. Content dedupe on completePendingDocumentUpload is opt-in (the inbox passes it; the MCP tools do not) because the MCP tools key idempotency on document id === upload id and a dedupe hit returns a different id. No cron sweeps abandoned pending objects: cleanupExpiredPendingDocumentUploads runs lazily per company+user prefix on the next create/complete, same as the MCP path, and an abandoned reservation never has a document row. [2026-08-29] SMTP From builder (extensions/general/email/lib/smtp-service.ts) follows the 2026-08-05 no-"via " rule and honors fromAddress exactly like resend-service.ts (name alone as display name; a verified brand address rides fromAddress; the retry-as-platform-sender path drops both the company sender and the brand address so it always differs): the PR predated #1956, and a self-host must show a customer the same sender shape whichever provider the operator picked. nodemailer transport gets requireTLS by default (SMTP_SECURE=false path) with an explicit SMTP_REQUIRE_TLS=false opt-out: nodemailer's own STARTTLS is opportunistic, so a STARTTLS-stripping on-path attacker would otherwise receive AUTH credentials and every invoice PDF in cleartext; the opt-out exists only for the documented plaintext relay on a trusted Docker/LAN network. [2026-08-28] Company invite accept link returned in-band to the inviter (always, not only under NODE_ENV=development) rather than printed to the server log as #1710 suggested: tokens are SHA-256 hashed at rest (lib/auth/invite-tokens.ts), so the raw link exists exactly once, in the create response, and the inviter is the person who needs it; a log line would put a bearer credential in log storage and still require shell access to the container. Same contract POST /api/team/invite already ships (email_sent + inviteUrl, TeamPanel shareInvite). Acceptance stays email-bound (app/api/team/accept/route.ts requires the signed-in user's email to match the invitation, per the 2026-07-24 line above: invite recovery stays token/cookie based with mailbox possession), so sharing the link over another channel does not widen who can claim the membership. No re-send endpoint added for company invites (revoke + re-invite gives a fresh link; a re-send would be a separate surface). SMTP as a delivery path is deferred to #1746. diff --git a/app/api/reconciliation/accounts/[accountKey]/links/route.ts b/app/api/reconciliation/accounts/[accountKey]/links/route.ts index e97388c0..7e9b57c2 100644 --- a/app/api/reconciliation/accounts/[accountKey]/links/route.ts +++ b/app/api/reconciliation/accounts/[accountKey]/links/route.ts @@ -8,6 +8,13 @@ import { matchPairs } from '@/lib/reconciliation/actions' const PairSchema = z.object({ external_ids: z.array(z.string().uuid()).min(1).max(50), journal_entry_ids: z.array(z.string().uuid()).min(1).max(50), + // Bank 1:N only: the signed slice per verifikat (transaction sign + // convention). Omitted: each slice defaults to the voucher's bank line. + allocations: z + .array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() })) + .min(2) + .max(50) + .optional(), }) const ReconciliationLinksBodySchema = z @@ -25,8 +32,10 @@ const ReconciliationLinksBodySchema = z * POST /api/reconciliation/accounts/{accountKey}/links * * The page's "Koppla" and "Koppla N föreslagna": link outside rows to existing - * verifikat. A human clicked, so this applies directly (dry_run: true for the - * preview). Same service function as v1 and the MCP commit executor. + * verifikat (N:1), or one bank transaction to several verifikat (1:N, with + * optional allocations). A human clicked, so this applies directly (dry_run: + * true for the preview). Same service function as v1 and the MCP commit + * executor. */ export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>( 'reconciliation.accounts.links.create', diff --git a/app/api/reconciliation/accounts/__tests__/route.test.ts b/app/api/reconciliation/accounts/__tests__/route.test.ts index 3abcd5ba..73440520 100644 --- a/app/api/reconciliation/accounts/__tests__/route.test.ts +++ b/app/api/reconciliation/accounts/__tests__/route.test.ts @@ -53,6 +53,8 @@ import { POST as ignorePOST } from '../[accountKey]/items/[itemId]/ignore/route' const ROW = '22222222-2222-4222-8222-222222222222' const ENTRY = '33333333-3333-4333-8333-333333333333' +const ENTRY_2 = '44444444-4444-4444-8444-444444444444' +const CASH = '55555555-5555-4555-8555-555555555555' // Dynamic-route params for the handlers under test; `never` keeps each // handler's own params type while letting one helper serve all of them. const p = (obj: Record) => ({ params: Promise.resolve(obj) }) as never @@ -144,6 +146,33 @@ describe('dashboard reconciliation routes', () => { ) }) + it('links: forwards a bank 1:N pair with allocations, and rejects a one-element allocations array (#1553)', async () => { + matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [], skipped: [] }) + const pair = { + external_ids: [ROW], + journal_entry_ids: [ENTRY, ENTRY_2], + allocations: [ + { journal_entry_id: ENTRY, amount: -500 }, + { journal_entry_id: ENTRY_2, amount: -300 }, + ], + } + const res = await linksPOST( + createMockRequest(`/api/reconciliation/accounts/bank:${CASH}/links`, { method: 'POST', body: { pairs: [pair] } }), + p({ accountKey: `bank:${CASH}` }), + ) + expect(res.status).toBe(200) + expect(matchMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', `bank:${CASH}`, { pairs: [pair] }, { dryRun: false }) + + const invalid = await linksPOST( + createMockRequest(`/api/reconciliation/accounts/bank:${CASH}/links`, { + method: 'POST', + body: { pairs: [{ ...pair, allocations: [{ journal_entry_id: ENTRY, amount: -800 }] }] }, + }), + p({ accountKey: `bank:${CASH}` }), + ) + expect(invalid.status).toBe(400) + }) + it('unlink and ignore call the service with the ids', async () => { const del = await linkDELETE(createMockRequest(`/api/reconciliation/accounts/skattekonto/links/${ROW}`, { method: 'DELETE' }), p({ accountKey: 'skattekonto', linkId: ROW })) expect(del.status).toBe(200) diff --git a/app/api/reconciliation/bank/link/__tests__/route.test.ts b/app/api/reconciliation/bank/link/__tests__/route.test.ts index 42c1fc17..e13a3e50 100644 --- a/app/api/reconciliation/bank/link/__tests__/route.test.ts +++ b/app/api/reconciliation/bank/link/__tests__/route.test.ts @@ -29,8 +29,10 @@ vi.mock('@/lib/auth/require-write', () => ({ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) const manualLinkMock = vi.fn() +const linkToVouchersMock = vi.fn() vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ manualLink: (...args: unknown[]) => manualLinkMock(...args), + linkTransactionToVouchers: (...args: unknown[]) => linkToVouchersMock(...args), })) import { POST } from '../route' @@ -38,6 +40,7 @@ import { POST } from '../route' const emptyParams = { params: Promise.resolve({}) } const TX_ID = '11111111-1111-4111-8111-111111111111' const JE_ID = '22222222-2222-4222-8222-222222222222' +const JE_ID_2 = '33333333-3333-4333-8333-333333333333' describe('POST /api/reconciliation/bank/link', () => { beforeEach(() => { @@ -46,6 +49,7 @@ describe('POST /api/reconciliation/bank/link', () => { requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) requireWriteMock.mockResolvedValue({ ok: true }) manualLinkMock.mockResolvedValue({ success: true }) + linkToVouchersMock.mockResolvedValue({ success: true, allocations: [] }) }) it('returns 401 when unauthenticated', async () => { @@ -128,4 +132,102 @@ describe('POST /api/reconciliation/bank/link', () => { '1930', ) }) + + it('splits the transaction over several verifikat when allocations are given instead of journal_entry_id (1:N, #1553)', async () => { + linkToVouchersMock.mockResolvedValue({ + success: true, + allocations: [ + { journal_entry_id: JE_ID, amount: -500 }, + { journal_entry_id: JE_ID_2, amount: -300 }, + ], + }) + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { + transaction_id: TX_ID, + account_number: '1940', + allocations: [ + { journal_entry_id: JE_ID, amount: -500 }, + { journal_entry_id: JE_ID_2, amount: -300 }, + ], + }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { success: boolean; allocations: unknown[] } }>(response) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + expect(body.data.allocations).toHaveLength(2) + expect(linkToVouchersMock).toHaveBeenCalledWith( + supabase, + 'company-1', + TX_ID, + [ + { journal_entry_id: JE_ID, amount: -500 }, + { journal_entry_id: JE_ID_2, amount: -300 }, + ], + 'user-1', + '1940', + ) + expect(manualLinkMock).not.toHaveBeenCalled() + }) + + it('rejects a body with both journal_entry_id and allocations, with neither, or with a single allocation (400)', async () => { + const both = await POST( + createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { + transaction_id: TX_ID, + journal_entry_id: JE_ID, + allocations: [ + { journal_entry_id: JE_ID, amount: -500 }, + { journal_entry_id: JE_ID_2, amount: -300 }, + ], + }, + }), + emptyParams, + ) + expect(both.status).toBe(400) + + const neither = await POST( + createMockRequest('/api/reconciliation/bank/link', { method: 'POST', body: { transaction_id: TX_ID } }), + emptyParams, + ) + expect(neither.status).toBe(400) + + const single = await POST( + createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, allocations: [{ journal_entry_id: JE_ID, amount: -800 }] }, + }), + emptyParams, + ) + expect(single.status).toBe(400) + expect(manualLinkMock).not.toHaveBeenCalled() + expect(linkToVouchersMock).not.toHaveBeenCalled() + }) + + it('surfaces a refused split as 400 with the service error', async () => { + linkToVouchersMock.mockResolvedValue({ + success: false, + error: 'Fördelningen (-700) stämmer inte med transaktionens belopp (-800).', + }) + const response = await POST( + createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { + transaction_id: TX_ID, + allocations: [ + { journal_entry_id: JE_ID, amount: -400 }, + { journal_entry_id: JE_ID_2, amount: -300 }, + ], + }, + }), + emptyParams, + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + expect(status).toBe(400) + expect(body.error).toBe('Fördelningen (-700) stämmer inte med transaktionens belopp (-800).') + }) }) diff --git a/app/api/reconciliation/bank/link/route.ts b/app/api/reconciliation/bank/link/route.ts index 7fe25b1c..f97393bf 100644 --- a/app/api/reconciliation/bank/link/route.ts +++ b/app/api/reconciliation/bank/link/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' -import { manualLink } from '@/lib/reconciliation/bank-reconciliation' +import { linkTransactionToVouchers, manualLink } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { BankLinkSchema } from '@/lib/api/schemas' @@ -12,22 +12,31 @@ export const POST = withRouteContext( async (request, { supabase, user, companyId }) => { const validation = await validateBody(request, BankLinkSchema) if (!validation.success) return validation.response - const { transaction_id, journal_entry_id, account_number } = validation.data + const { transaction_id, journal_entry_id, allocations, account_number } = validation.data - const result = await manualLink( - supabase, - companyId, - transaction_id, - journal_entry_id, - user.id, - account_number ?? '1930', - ) + // One verifikat: the plain link. Several: the 1:N split (#1553), all or + // nothing, slices summing to the transaction. + const result = journal_entry_id + ? await manualLink(supabase, companyId, transaction_id, journal_entry_id, user.id, account_number ?? '1930') + : await linkTransactionToVouchers( + supabase, + companyId, + transaction_id, + allocations ?? [], + user.id, + account_number ?? '1930', + ) if (!result.success) { return NextResponse.json({ error: result.error }, { status: 400 }) } - return NextResponse.json({ data: { success: true } }) + // A split echoes the slices as validated (defaults resolved); the plain + // link has nothing to add. + const resolvedAllocations = 'allocations' in result ? result.allocations : undefined + return NextResponse.json({ + data: { success: true, ...(resolvedAllocations ? { allocations: resolvedAllocations } : {}) }, + }) }, { requireWrite: true }, ) diff --git a/app/api/transactions/__tests__/route.test.ts b/app/api/transactions/__tests__/route.test.ts index 1273bd09..6924285f 100644 --- a/app/api/transactions/__tests__/route.test.ts +++ b/app/api/transactions/__tests__/route.test.ts @@ -126,6 +126,23 @@ describe('GET /api/transactions', () => { expect(isCall).toEqual({ method: 'is', args: ['journal_entry_id', null] }) }) + it('hides rows anchored only through transaction_voucher_links from unmatched=true (bulk-book, 1:N split, #1553)', async () => { + const txs = [ + makeTransaction({ id: 'tx-open', journal_entry_id: null }), + makeTransaction({ id: 'tx-split', journal_entry_id: null }), + ] + enqueue({ data: txs, error: null }) // transactions list + enqueue({ data: [{ transaction_id: 'tx-split' }], error: null }) // transaction_voucher_links + + const request = createMockRequest('/api/transactions?unmatched=true') + const response = await GET(request, createMockRouteParams({})) + const { status, body } = await parseJsonResponse<{ data: Array<{ id: string }>; has_more: boolean }>(response) + + expect(status).toBe(200) + expect(body.data.map((t) => t.id)).toEqual(['tx-open']) + expect(body.has_more).toBe(false) + }) + it('filters by reconciled=true', async () => { const fromSpy = vi.fn(() => { const chain: Record = {} diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index 87b3dbee..123e6246 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server' -import { scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconciliation' +import { fetchJunctionLinkedTxIds, scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconciliation' import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateTransactionSchema } from '@/lib/api/schemas' @@ -93,9 +93,25 @@ export const GET = withRouteContext('transaction.list', async (request, { supaba return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) } - const rows = data || [] + let rows = data || [] + // has_more is decided on what the DB returned, before the junction filter + // below: a page that came back full means more rows exist past it whether + // or not some of this page's rows turn out to be booked. const hasMore = rows.length > MAX_ROWS - const truncated = hasMore ? rows.slice(0, MAX_ROWS) : rows + // journal_entry_id IS NULL is only the first of the three "booked" anchors + // (lib/transactions/is-booked.ts): a row bulk-booked into a + // samlingsverifikat or split over several verifikat (1:N, #1553) is + // anchored through transaction_voucher_links alone and must leave + // "Att bokföra" all the same. + if (unmatched && rows.length > 0) { + const junctionLinked = await fetchJunctionLinkedTxIds( + supabase, + companyId, + rows.map((row) => row.id as string), + ) + if (junctionLinked.size > 0) rows = rows.filter((row) => !junctionLinked.has(row.id as string)) + } + const truncated = rows.length > MAX_ROWS ? rows.slice(0, MAX_ROWS) : rows return NextResponse.json({ data: truncated, has_more: hasMore, limit: MAX_ROWS }) }) diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts index 7f8d8294..dc9d15b1 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts @@ -18,6 +18,11 @@ import { matchPairs } from '@/lib/reconciliation/actions' const PairSchema = z.object({ external_ids: z.array(z.string().uuid()).min(1).max(50), journal_entry_ids: z.array(z.string().uuid()).min(1).max(50), + allocations: z + .array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() })) + .min(2) + .max(50) + .optional(), }) const LinksRequest = z @@ -38,6 +43,7 @@ const LinksResponse = z.object({ external_id: z.string(), journal_entry_id: z.string(), via: z.enum(['line', 'entry_total']).optional(), + allocated_amount: z.number().optional(), }), ), skipped: z.array( @@ -55,13 +61,13 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links', summary: 'Link outside rows to existing verifikat (pairs or proposals).', description: - 'Body: { pairs: [{ external_ids: [id], journal_entry_ids: [id] }] } and/or { use_proposals: true, confidence_threshold? }. Each pair is validated as the single-link paths validate (row open and not ignored, entry posted and not reversed, the entry\'s account lines settle the amount, entry not already linked) and applied independently: the response lists applied[] and skipped[{pair, code, message}] so partial success is explicit. Codes: UNSUPPORTED_PAIR_SHAPE, ALREADY_LINKED, ENTRY_NOT_FOUND, PAIR_NOT_CLOSED, ROW_IGNORED, NOT_FOUND, LINK_RACE. ?dry_run=true returns the pairs that would be attempted without writing.', + 'Body: { pairs: [{ external_ids: [id], journal_entry_ids: [id], allocations? }] } and/or { use_proposals: true, confidence_threshold? }. Each pair is validated as the single-link paths validate (row open and not ignored, entry posted and not reversed, the entry\'s account lines settle the amount, entry not already linked) and applied independently: the response lists applied[] and skipped[{pair, code, message}] so partial success is explicit. On a bank account a pair may also be ONE transaction against SEVERAL verifikat (1:N): allocations[{journal_entry_id, amount}] gives the signed slice per verifikat (omitted: each slice defaults to the voucher\'s line on the account); the slices must sum to the transaction amount, and each applied link then carries allocated_amount. Codes: UNSUPPORTED_PAIR_SHAPE, ALREADY_LINKED, ENTRY_NOT_FOUND, PAIR_NOT_CLOSED, ROW_IGNORED, NOT_FOUND, LINK_RACE. ?dry_run=true returns the pairs that would be attempted without writing (a 1:N dry run resolves the slices).', useWhen: 'An agent or integration has decided which rows explain each other, or wants to apply the proposals the sync already computed.', doNotUseFor: 'Booking new verifikat for rows that have no counterpart (use the transactions or skattekonto booking endpoints); reconciling across accounts.', pitfalls: [ - 'A pair is one OR MANY outside rows against exactly one verifikat (bank: independent links per transaction; skattekonto: all-or-nothing, the rows must sum to what the verifikat settles). One row against several verifikat is UNSUPPORTED_PAIR_SHAPE until residual booking lands, never silently reduced.', + 'A pair is one OR MANY outside rows against exactly one verifikat (bank: independent links per transaction; skattekonto: all-or-nothing, the rows must sum to what the verifikat settles), or, on a bank account only, ONE transaction against SEVERAL verifikat (all-or-nothing, the slices must sum to the transaction). Several rows against several verifikat, and a skattekonto row against several verifikat, are UNSUPPORTED_PAIR_SHAPE, never silently reduced.', 'A pair must close to the row\'s amount on the expected side (a single matching line, or the entry\'s lines on the account netting to it); a fee or rounding difference is PAIR_NOT_CLOSED here and needs a residual booking first.', 'Links never touch the ledger, so they succeed in locked periods; unlink with DELETE .../links/{linkId} (linkId = the outside row id).', 'Idempotency-Key is required; repeating the same key replays the first response.', diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/route.test.ts index 16d51be7..30391362 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/__tests__/route.test.ts @@ -224,6 +224,37 @@ describe('v1 reconciliation accounts', () => { expect(matchMock).toHaveBeenLastCalledWith(expect.anything(), COMPANY_ID, 'user-1', 'skattekonto', expect.anything(), { dryRun: true }) }) + it('POST links accepts a bank 1:N pair with allocations and echoes allocated_amount on the applied links (#1553)', async () => { + authOk(['reconciliation:write']) + const cash = '55555555-5555-4555-8555-555555555555' + const entry2 = '44444444-4444-4444-8444-444444444444' + const pair = { + external_ids: [ROW], + journal_entry_ids: [ENTRY, entry2], + allocations: [ + { journal_entry_id: ENTRY, amount: -500 }, + { journal_entry_id: entry2, amount: -300 }, + ], + } + matchMock.mockResolvedValueOnce({ + dry_run: false, + considered: 1, + applied: [ + { external_id: ROW, journal_entry_id: ENTRY, allocated_amount: -500 }, + { external_id: ROW, journal_entry_id: entry2, allocated_amount: -300 }, + ], + skipped: [], + }) + const res = await linksPOST( + req(`${BASE}/bank:${cash}/links`, { method: 'POST', body: { pairs: [pair] } }), + params({ accountKey: `bank:${cash}` }), + ) + expect(res.status).toBe(200) + expect(matchMock).toHaveBeenLastCalledWith(expect.anything(), COMPANY_ID, 'user-1', `bank:${cash}`, expect.objectContaining({ pairs: [pair] }), { dryRun: false }) + const body = (await res.json()) as { data: { applied: Array<{ allocated_amount?: number }> } } + expect(body.data.applied.map((a) => a.allocated_amount)).toEqual([-500, -300]) + }) + it('DELETE link unmatches and 404s a non-uuid link id', async () => { authOk(['reconciliation:write']) const ok = await linkDELETE(req(`${BASE}/skattekonto/links/${ROW}`, { method: 'DELETE' }), params({ accountKey: 'skattekonto', linkId: ROW })) diff --git a/components/reconciliation/ManualMatchMode.tsx b/components/reconciliation/ManualMatchMode.tsx index 930295f6..ce9d7bfd 100644 --- a/components/reconciliation/ManualMatchMode.tsx +++ b/components/reconciliation/ManualMatchMode.tsx @@ -19,12 +19,16 @@ import type { ResidualKind } from '@/lib/reconciliation/residual' /** * "Matcha manuellt": the two-pane worksheet from the approved design. Left: * outside rows with no verifikat (bank transactions or skattekonto rows), - * multi-select. Right: verifikat on the account with no outside row, - * single-select. The footer sums both sides; Koppla is enabled only when the - * selection nets to zero, because the engine links N rows to ONE verifikat - * and (for the skattekonto) refuses a group whose sum the verifikat does not - * settle. A non-zero difference is shown, not hidden: booking the residual - * in the same gesture is the next step (6c), until then it says so. + * multi-select. Right: verifikat on the account with no outside row. The + * right pane follows the left: with exactly ONE bank row picked it is + * multi-select (one row settling several verifikat, the 1:N split of #1553); + * with several rows picked it is single-select (N rows settling one + * verifikat). The footer sums both sides; Koppla is enabled only when the + * selection nets to zero, because the engine links N rows to ONE verifikat, + * ONE row to N verifikat with slices summing to the row, and (for the + * skattekonto) refuses a group whose sum the verifikat does not settle. A + * non-zero difference is shown, not hidden: for the N:1 shape the residual + * can be booked in the same gesture; a split must close exactly. */ interface ManualMatchModeProps { @@ -42,7 +46,7 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP const [ledger, setLedger] = useState(null) const [loadError, setLoadError] = useState(false) const [pickedExternal, setPickedExternal] = useState>(new Set()) - const [pickedEntry, setPickedEntry] = useState(null) + const [pickedEntries, setPickedEntries] = useState>(new Set()) const [busy, setBusy] = useState(false) const [residualKind, setResidualKind] = useState('') @@ -79,13 +83,41 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP () => roundOre((external ?? []).filter((i) => pickedExternal.has(i.item_id)).reduce((s, i) => s + i.amount, 0)), [external, pickedExternal], ) - const entry = useMemo(() => (ledger ?? []).find((i) => i.item_id === pickedEntry) ?? null, [ledger, pickedEntry]) - const ledgerSum = entry ? roundOre(entry.amount) : 0 + const entries = useMemo( + () => (ledger ?? []).filter((i) => pickedEntries.has(i.item_id)), + [ledger, pickedEntries], + ) + const entry = entries.length === 1 ? entries[0] : null + const ledgerSum = roundOre(entries.reduce((s, i) => s + i.amount, 0)) const difference = roundOre(externalSum - ledgerSum) - const canLink = pickedExternal.size > 0 && entry !== null && Math.abs(difference) < 0.005 && !busy + // One bank row may settle several verifikat; several rows settle one. + // N:M has no engine shape and the right pane never lets it be built. + const splitMode = !isSkv && pickedExternal.size === 1 + const isSplit = entries.length > 1 + const canLink = + pickedExternal.size > 0 && + entries.length > 0 && + !(pickedExternal.size > 1 && isSplit) && + Math.abs(difference) < 0.005 && + !busy function toggleExternal(id: string) { setPickedExternal((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + // Leaving the one-row shape with several verifikat picked would build + // an N:M selection: drop the verifikat side, the user re-picks one. + if (next.size !== 1) { + setPickedEntries((picked) => (picked.size > 1 ? new Set() : picked)) + } + return next + }) + } + + function toggleEntry(id: string) { + setPickedEntries((prev) => { + if (!splitMode) return prev.has(id) ? new Set() : new Set([id]) const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) @@ -94,14 +126,17 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP } async function link() { - if (!entry || pickedExternal.size === 0) return + if (entries.length === 0 || pickedExternal.size === 0) return setBusy(true) try { + // The split sends no allocations: each slice defaults to the voucher's + // bank line and the engine refuses the set unless the slices sum to + // the row, which is exactly the difference-is-zero gate above. const res = await fetch(`${base}/links`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - pairs: [{ external_ids: [...pickedExternal], journal_entry_ids: [entry.item_id] }], + pairs: [{ external_ids: [...pickedExternal], journal_entry_ids: entries.map((e) => e.item_id) }], }), }) const json = await res.json().catch(() => ({})) @@ -113,13 +148,15 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP const skipped = (json.data?.skipped as Array<{ message: string }> | undefined) ?? [] if (applied === 0 && skipped.length > 0) { toast({ title: t('toast_failed'), description: skipped[0].message, variant: 'destructive' }) + } else if (isSplit) { + toast({ title: t('toast_split_matched', { count: applied }) }) } else { toast({ title: skipped.length > 0 ? t('toast_matched_skipped', { applied, skipped: skipped.length }) : t('toast_matched', { applied }), }) } setPickedExternal(new Set()) - setPickedEntry(null) + setPickedEntries(new Set()) await load() onChanged() } finally { @@ -143,7 +180,7 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP } toast({ title: t('toast_residual_booked', { amount: formatCurrency(Math.abs(Number(json.data?.residual_amount ?? 0)), currency) }) }) setPickedExternal(new Set()) - setPickedEntry(null) + setPickedEntries(new Set()) setResidualKind('') await load() onChanged() @@ -219,12 +256,16 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP )} - {/* Right: verifikat without an outside row, single-select. */} + {/* Right: verifikat without an outside row. Single-select for N:1, + multi-select while exactly one bank row is picked (1:N). */}

{t(isSkv ? 'bucket_unmatched_ledger_skv' : 'bucket_unmatched_ledger_bank')} {ledger.length}

+ {splitMode && ledger.length > 0 && ( +

{t('match_pick_vouchers')}

+ )} {ledger.length === 0 ? (

{t('match_empty_right')}

) : ( @@ -240,23 +281,32 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP {ledger.map((item) => { - const picked = pickedEntry === item.item_id + const picked = pickedEntries.has(item.item_id) return ( setPickedEntry(picked ? null : item.item_id)} + onClick={() => toggleEntry(item.item_id)} className={cn('cursor-pointer', picked ? 'bg-secondary/60' : 'hover:bg-muted/40')} > - setPickedEntry(item.item_id)} - onClick={(e) => e.stopPropagation()} - aria-label={item.description} - className="h-3.5 w-3.5 accent-foreground" - /> + {splitMode ? ( + toggleEntry(item.item_id)} + onClick={(e) => e.stopPropagation()} + aria-label={item.description} + /> + ) : ( + toggleEntry(item.item_id)} + onClick={(e) => e.stopPropagation()} + aria-label={item.description} + className="h-3.5 w-3.5 accent-foreground" + /> + )} {formatDate(item.date)} @@ -285,16 +335,24 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP {t('match_selected_external', { count: pickedExternal.size, amount: formatCurrency(externalSum, currency) })} - {entry - ? t('match_selected_entry', { amount: formatCurrency(ledgerSum, currency) }) - : t('match_no_entry')} + {isSplit + ? t('match_selected_entries', { count: entries.length, amount: formatCurrency(ledgerSum, currency) }) + : entry + ? t('match_selected_entry', { amount: formatCurrency(ledgerSum, currency) }) + : t('match_no_entry')} = 0.005 && pickedExternal.size > 0 && entry ? 'text-warning' : 'text-muted-foreground')} + className={cn('tabular-nums', Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entries.length > 0 ? 'text-warning' : 'text-muted-foreground')} data-ph-mask > {t('match_difference', { amount: formatCurrency(difference, currency) })} + {/* A split has no residual: the slices must sum to the row exactly + (the engine refuses anything else), so the footer says so instead + of offering a fee/interest booking against several verifikat. */} + {Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && isSplit && ( + {t('match_hint_split')} + )} {Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entry && ( isSkv ? ( {t('match_hint_residual_skv')} @@ -320,7 +378,7 @@ export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeP )} diff --git a/components/transactions/MatchVoucherDialog.tsx b/components/transactions/MatchVoucherDialog.tsx index 75b2f1d0..10c31324 100644 --- a/components/transactions/MatchVoucherDialog.tsx +++ b/components/transactions/MatchVoucherDialog.tsx @@ -250,6 +250,13 @@ export function MatchVoucherDialog({ )} + {/* One verifikat per dialog, by design: the 1:N split (one bank + row over several verifikat) needs the sum arithmetic of the + worksheet, so this stays a single pick and points there. */} +

+ Ska händelsen delas på flera verifikat? Använd Bankavstämning → Matcha manuellt. +

+ {/* Discovery affordances: widen the date window, and surface vouchers already matched so another transaction can be attached (N:1). Quiet links, not switches: these are list filters, and the switch diff --git a/extensions/general/mcp-server/__tests__/attention.test.ts b/extensions/general/mcp-server/__tests__/attention.test.ts index 1a85bf1a..26262ce3 100644 --- a/extensions/general/mcp-server/__tests__/attention.test.ts +++ b/extensions/general/mcp-server/__tests__/attention.test.ts @@ -26,8 +26,10 @@ const ctx = (supabase: ReturnType['supabase']) * Tests can override individual slots before invoking by enqueueing in advance. */ function enqueueEmpty(enqueue: (r: { data?: unknown; error?: unknown; count?: number | null }) => void) { - // 1. unbookedHead - enqueue({ count: 0 }) + // 1. unbookedIds (every pointer-null business row; the junction-linked + // ones are subtracted by a lookup that runs after slot 15 and only + // when this list is non-empty) + enqueue({ data: [] }) // 2. unbookedSamples enqueue({ data: [] }) // 3. overdueRows @@ -80,7 +82,7 @@ describe('Accounted://attention', () => { { id: 't-2', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' }, ] - enqueue({ count: 2 }) // unbookedHead + enqueue({ data: txns.map((t) => ({ id: t.id })) }) // unbookedIds enqueue({ data: txns }) // unbookedSamples enqueue({ data: [] }) // overdueRows enqueue({ count: 0 }) // pendingSupplierHead @@ -108,12 +110,33 @@ describe('Accounted://attention', () => { expect(result.summary).toEqual({ total_items: 2, critical: 0, warning: 1, info: 0 }) }) + it('does not count a row anchored only through transaction_voucher_links as unbooked (bulk-book, 1:N split)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const today = new Date().toISOString().slice(0, 10) + const txns = [ + { id: 't-split', date: today, amount: -800, currency: 'SEK', description: 'Utlägg', merchant_name: null }, + { id: 't-open', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' }, + ] + enqueue({ data: txns.map((t) => ({ id: t.id })) }) // 1. unbookedIds + enqueue({ data: txns }) // 2. unbookedSamples + for (let i = 3; i <= 14; i += 1) enqueue({ data: i === 13 || i === 14 ? null : [], count: 0 }) + enqueue({ data: [] }) // 15. unlinked-document candidates + enqueue({ data: [{ transaction_id: 't-split' }] }) // 16. fetchJunctionLinkedTxIds + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + + const cat = result.categories.find((c) => c.key === 'unbooked_transactions') + expect(cat?.count).toBe(1) + expect(cat?.samples.map((s) => s.id)).toEqual(['t-open']) + expect(cat?.next?.args).toEqual({ transaction_id: 't-open' }) + }) + it('escalates unbooked transactions to critical when oldest is > 30 days old', async () => { const { supabase, enqueue } = createQueuedMockSupabase() const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10) const txns = [{ id: 't-old', date: fortyDaysAgo, amount: -100, currency: 'SEK', description: 'X', merchant_name: null }] - enqueue({ count: 1 }) + enqueue({ data: [{ id: 't-old' }] }) enqueue({ data: txns }) enqueue({ data: [] }) enqueue({ count: 0 }) @@ -353,7 +376,7 @@ describe('Accounted://attention', () => { const { supabase, enqueue } = createQueuedMockSupabase() const today = new Date().toISOString().slice(0, 10) - enqueue({ count: 1 }) // unbookedHead + enqueue({ data: [{ id: 't-1' }] }) // unbookedIds enqueue({ data: [{ id: 't-1', date: today, amount: -50, currency: 'SEK', description: 'X', merchant_name: null }] }) enqueue({ data: [] }) // overdueRows enqueue({ count: 1 }) // pendingSupplierHead diff --git a/extensions/general/mcp-server/__tests__/recent-activity.test.ts b/extensions/general/mcp-server/__tests__/recent-activity.test.ts index 2e8b346b..da4c2d29 100644 --- a/extensions/general/mcp-server/__tests__/recent-activity.test.ts +++ b/extensions/general/mcp-server/__tests__/recent-activity.test.ts @@ -54,6 +54,7 @@ function createRecordingSupabase(results: Record = {}) { return chain } chain.eq = passthrough + chain.in = passthrough chain.order = passthrough chain.limit = passthrough chain.then = (resolve: (v: unknown) => void) => @@ -130,6 +131,26 @@ describe('Accounted://recent-activity', () => { expect(result.uncategorized_transaction_count).toBe(1) }) + it('does not count a pointer-null row anchored through transaction_voucher_links (bulk-book, 1:N split)', async () => { + const { supabase, selects } = createRecordingSupabase({ + transactions: { + data: [ + { id: 't-1', journal_entry_id: 'je-1' }, + { id: 't-split', journal_entry_id: null }, + { id: 't-open', journal_entry_id: null }, + ], + }, + transaction_voucher_links: { data: [{ transaction_id: 't-split' }] }, + }) + + const result = (await recentActivityResource.read(ctx(supabase))) as { + uncategorized_transaction_count: number + } + + expect(selects.transaction_voucher_links).toBe('transaction_id') + expect(result.uncategorized_transaction_count).toBe(1) + }) + it('surfaces a query failure instead of reporting zero invoices', async () => { const { supabase } = createRecordingSupabase({ invoices: { error: { message: 'column invoices.total_amount does not exist' } }, diff --git a/extensions/general/mcp-server/__tests__/reconciliation-tools.test.ts b/extensions/general/mcp-server/__tests__/reconciliation-tools.test.ts index 7d6d834b..6b7e677d 100644 --- a/extensions/general/mcp-server/__tests__/reconciliation-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/reconciliation-tools.test.ts @@ -144,6 +144,66 @@ describe('reconciliation MCP tools', () => { expect(out.next).toMatchObject({ tool: 'gnubok_get_reconciliation_status' }) }) + it('reconcile_match folds the links of a bank 1:N split back into ONE staged pair with explicit allocations (#1553)', async () => { + const { supabase } = createQueuedMockSupabase() + const cash = '55555555-5555-4555-8555-555555555555' + const entry2 = '44444444-4444-4444-8444-444444444444' + const otherRow = '66666666-6666-4666-8666-666666666666' + matchMock.mockResolvedValue({ + dry_run: true, + considered: 2, + applied: [ + { external_id: otherRow, journal_entry_id: ENTRY }, + { external_id: ROW, journal_entry_id: ENTRY, allocated_amount: -500 }, + { external_id: ROW, journal_entry_id: entry2, allocated_amount: -300 }, + ], + skipped: [], + }) + const out = (await tool('gnubok_reconcile_match').execute( + { + account_key: `bank:${cash}`, + pairs: [ + { external_ids: [otherRow], journal_entry_ids: [ENTRY] }, + { external_ids: [ROW], journal_entry_ids: [ENTRY, entry2] }, + ], + dry_run: true, + }, + COMPANY, + USER, + supabase as never, + { type: 'api_key', id: 'key-1' } as never, + )) as Record + expect(matchMock).toHaveBeenCalledWith( + supabase, + COMPANY, + USER, + `bank:${cash}`, + expect.objectContaining({ + pairs: [ + { external_ids: [otherRow], journal_entry_ids: [ENTRY] }, + { external_ids: [ROW], journal_entry_ids: [ENTRY, entry2] }, + ], + }), + { dryRun: true }, + ) + const preview = out.preview as Record + expect(preview.pair_count).toBe(2) + // The 1:1 link stays a pair of its own; the two split links become one + // pair carrying the slices the reviewer saw, so the executor re-validates + // exactly those amounts (never two independent manualLink calls). + expect(preview.pairs).toEqual([ + { external_ids: [otherRow], journal_entry_ids: [ENTRY] }, + { + external_ids: [ROW], + journal_entry_ids: [ENTRY, entry2], + allocations: [ + { journal_entry_id: ENTRY, amount: -500 }, + { journal_entry_id: entry2, amount: -300 }, + ], + }, + ]) + }) + it('reconcile_match refuses an empty request and a request with nothing linkable', async () => { const { supabase } = createQueuedMockSupabase() await expect( diff --git a/extensions/general/mcp-server/resources/attention.ts b/extensions/general/mcp-server/resources/attention.ts index a8ff1ab8..51e10f01 100644 --- a/extensions/general/mcp-server/resources/attention.ts +++ b/extensions/general/mcp-server/resources/attention.ts @@ -5,6 +5,8 @@ import { UNLINKED_DOCUMENT_SCAN_CAP, } from '@/lib/documents/unlinked-documents' import { countReconciliationDue } from '@/lib/worklist/categories' +import { fetchJunctionLinkedTxIds } from '@/lib/reconciliation/bank-reconciliation' +import { fetchAllRows } from '@/lib/supabase/fetch-all' type Severity = 'critical' | 'warning' | 'info' @@ -42,7 +44,7 @@ export const attentionResource: McpResource = { const horizon = horizonDate.toISOString().slice(0, 10) const [ - unbookedHead, + unbookedIds, unbookedSamples, overdueRows, pendingSupplierHead, @@ -58,12 +60,20 @@ export const attentionResource: McpResource = { companySettingsRow, unlinkedDocuments, ] = await Promise.all([ - supabase - .from('transactions') - .select('id', { count: 'exact', head: true }) - .eq('company_id', companyId) - .is('journal_entry_id', null) - .eq('is_business', true), + // Ids, not a head count: journal_entry_id IS NULL is only the first of + // the "booked" anchors (lib/transactions/is-booked.ts). Rows bulk-booked + // into a samlingsverifikat or split over several verifikat (1:N) are + // anchored through transaction_voucher_links and are subtracted below. + fetchAllRows<{ id: string }>(({ from, to }) => + supabase + .from('transactions') + .select('id') + .eq('company_id', companyId) + .is('journal_entry_id', null) + .eq('is_business', true) + .order('id') + .range(from, to), + ).catch(() => [] as Array<{ id: string }>), supabase .from('transactions') .select('id, date, amount, currency, description, merchant_name') @@ -71,7 +81,7 @@ export const attentionResource: McpResource = { .is('journal_entry_id', null) .eq('is_business', true) .order('date', { ascending: true }) - .limit(SAMPLE_LIMIT), + .limit(SAMPLE_LIMIT * 4), supabase .from('invoices') .select('id, invoice_number, customer_id, due_date, total, currency, status') @@ -154,16 +164,24 @@ export const attentionResource: McpResource = { const categories: AttentionCategory[] = [] // ── Unbooked business transactions ────────────────────────────── - const unbookedCount = unbookedHead.count ?? 0 + const pointerUnbookedIds = unbookedIds.map((row) => row.id) + const junctionLinked = + pointerUnbookedIds.length > 0 + ? await fetchJunctionLinkedTxIds(supabase, companyId, pointerUnbookedIds) + : new Set() + const unbookedCount = pointerUnbookedIds.filter((id) => !junctionLinked.has(id)).length + const samples = (unbookedSamples.data ?? []) + .filter((row) => !junctionLinked.has(row.id as string)) + .slice(0, SAMPLE_LIMIT) if (unbookedCount > 0) { - const oldest = unbookedSamples.data?.[0] + const oldest = samples[0] const oldestAgeDays = oldest?.date ? daysBetween(oldest.date, today) : 0 categories.push({ key: 'unbooked_transactions', label_sv: 'Obokförda affärstransaktioner', severity: oldestAgeDays > 30 ? 'critical' : 'warning', count: unbookedCount, - samples: unbookedSamples.data ?? [], + samples, next: { description: 'Kategorisera den äldsta obokförda transaktionen.', tool: 'gnubok_categorize_transaction', diff --git a/extensions/general/mcp-server/resources/recent-activity.ts b/extensions/general/mcp-server/resources/recent-activity.ts index 542cd538..644660cb 100644 --- a/extensions/general/mcp-server/resources/recent-activity.ts +++ b/extensions/general/mcp-server/resources/recent-activity.ts @@ -1,4 +1,5 @@ import type { McpResource } from './types' +import { fetchJunctionLinkedTxIds } from '@/lib/reconciliation/bank-reconciliation' export const recentActivityResource: McpResource = { uri: 'Accounted://recent-activity', @@ -41,14 +42,27 @@ export const recentActivityResource: McpResource = { throw new Error(`Failed to read recent transactions: ${transactions.error.message}`) } + // A NULL pointer is not "unbooked" on its own: rows bulk-booked into a + // samlingsverifikat or split over several verifikat (1:N) are anchored + // through transaction_voucher_links (lib/transactions/is-booked.ts). + const recentTx = transactions.data ?? [] + const pointerUnbooked = recentTx.filter((t) => !t.journal_entry_id) + const junctionLinked = + pointerUnbooked.length > 0 + ? await fetchJunctionLinkedTxIds( + supabase, + companyId, + pointerUnbooked.map((t) => t.id as string), + ) + : new Set() + return { limit, journal_entries: journalEntries.data ?? [], invoices: invoices.data ?? [], - transactions: transactions.data ?? [], - uncategorized_transaction_count: (transactions.data ?? []).filter( - (t) => !t.journal_entry_id - ).length, + transactions: recentTx, + uncategorized_transaction_count: pointerUnbooked.filter((t) => !junctionLinked.has(t.id as string)) + .length, } }, } diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index ef1644cf..8e22537a 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -10953,13 +10953,13 @@ export const tools: McpTool[] = [ properties: { account_key: { type: 'string', - description: '"skattekonto", "bank:" or "manual:". Returns the account-keyed status (bridge[], counts, kind block); for manual keys date_to is the balansdag.', + description: '"skattekonto", "bank:" or "manual:"; for manual keys date_to is the balansdag.', }, date_from: { type: 'string', description: 'Start date YYYY-MM-DD' }, date_to: { type: 'string', description: 'End date YYYY-MM-DD' }, account_number: { type: 'string', - description: 'Legacy: cash-account BAS code to reconcile, e.g. "1940". Defaults to "1930". Ignored when account_key is set.', + description: 'Legacy: BAS code, default "1930". Ignored when account_key is set.', }, }, }, @@ -11008,7 +11008,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_reconciliation_items', title: 'Reconciliation Items', - description: 'Rows behind one account\'s reconciliation bridge, bucketed (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming): side, qualified id, amount, proposal with confidence + reasons, allowed actions. Link via gnubok_reconcile_match.', + description: 'Rows behind one account\'s reconciliation bridge, by bucket: side, qualified id, amount, proposal with confidence + reasons, allowed actions. Link via gnubok_reconcile_match.', inputSchema: { type: 'object', additionalProperties: false, @@ -11073,13 +11073,23 @@ export const tools: McpTool[] = [ account_key: { type: 'string', description: '"skattekonto" or "bank:"' }, pairs: { type: 'array', - description: 'One outside row id + one journal_entry_id per pair', + description: 'Rows against one verifikat, or (bank) one row against several', items: { type: 'object', additionalProperties: false, properties: { external_ids: { type: 'array', items: { type: 'string' } }, journal_entry_ids: { type: 'array', items: { type: 'string' } }, + allocations: { + type: 'array', + description: 'Bank 1:N: signed slice per verifikat, summing to the row', + items: { + type: 'object', + additionalProperties: false, + properties: { journal_entry_id: { type: 'string' }, amount: { type: 'number' } }, + required: ['journal_entry_id', 'amount'], + }, + }, }, required: ['external_ids', 'journal_entry_ids'], }, @@ -11100,7 +11110,14 @@ export const tools: McpTool[] = [ }, async execute(args, companyId, userId, supabase, actor) { const accountKey = args.account_key as string - const pairs = (args.pairs as Array<{ external_ids: string[]; journal_entry_ids: string[] }> | undefined) ?? [] + const pairs = + (args.pairs as + | Array<{ + external_ids: string[] + journal_entry_ids: string[] + allocations?: Array<{ journal_entry_id: string; amount: number }> + }> + | undefined) ?? [] const useProposals = args.use_proposals === true if (pairs.length === 0 && !useProposals) { throw new Error('Pass pairs, or use_proposals: true') @@ -11119,10 +11136,32 @@ export const tools: McpTool[] = [ { dryRun: true }, ) if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`) - const resolvedPairs = preview.applied.map((a) => ({ - external_ids: [a.external_id], - journal_entry_ids: [a.journal_entry_id], - })) + // Rebuild the staged pairs from the preview: 1:1 links stay one pair + // each; the links of a bank 1:N split (they carry allocated_amount, all + // on the same row) fold back into ONE pair with explicit allocations, so + // the executor re-validates the exact slices the reviewer approved. + const resolvedPairs: Array<{ + external_ids: string[] + journal_entry_ids: string[] + allocations?: Array<{ journal_entry_id: string; amount: number }> + }> = [] + const splitByRow = new Map>() + for (const a of preview.applied) { + if (typeof a.allocated_amount === 'number') { + const slices = splitByRow.get(a.external_id) ?? [] + slices.push({ journal_entry_id: a.journal_entry_id, amount: a.allocated_amount }) + splitByRow.set(a.external_id, slices) + continue + } + resolvedPairs.push({ external_ids: [a.external_id], journal_entry_ids: [a.journal_entry_id] }) + } + for (const [externalId, slices] of splitByRow) { + resolvedPairs.push({ + external_ids: [externalId], + journal_entry_ids: slices.map((s) => s.journal_entry_id), + allocations: slices, + }) + } if (resolvedPairs.length === 0) { throw new Error('No linkable pairs: nothing to stage') } diff --git a/extensions/general/mcp-server/skills/reconcile-month.ts b/extensions/general/mcp-server/skills/reconcile-month.ts index ab005638..0130a4d0 100644 --- a/extensions/general/mcp-server/skills/reconcile-month.ts +++ b/extensions/general/mcp-server/skills/reconcile-month.ts @@ -46,7 +46,7 @@ Per account: outside vs ledger, what was linked, what the user still has to book ## Rules - Links and sign-offs never touch the ledger; booking does, and always stages. -- One or more outside rows link to one verifikat; other shapes come back as UNSUPPORTED_PAIR_SHAPE. A small fee, interest or rounding difference on a bank account is closed with \`gnubok_reconcile_residual({ account_key, external_ids, journal_entry_id, kind, dry_run: true })\`, then without dry_run: it links the rows and books the difference (6570 / 8410 / 8310 / 3740) in one staged step. Anything larger than the cap is a missing booking, not a fee. +- One or more outside rows link to one verifikat. On a bank account one row may also settle several verifikat (a lump payout over utlägg booked per receipt, a Bankgirot deposit over two customer payments): pass \`journal_entry_ids\` with several ids, optionally \`allocations: [{ journal_entry_id, amount }]\` (signed, the row's sign convention; omitted = each voucher's bank line); the slices must sum to the row. Other shapes come back as UNSUPPORTED_PAIR_SHAPE. A small fee, interest or rounding difference on a bank account is closed with \`gnubok_reconcile_residual({ account_key, external_ids, journal_entry_id, kind, dry_run: true })\`, then without dry_run: it links the rows and books the difference (6570 / 8410 / 8310 / 3740) in one staged step. Anything larger than the cap is a missing booking, not a fee. - Never judge on \`difference\`; the bridge explains it. Judge on \`unexplained_difference\`. - A skattekonto sign-off date cannot pass the saldo snapshot; ask for a fetch. diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index c9e9e628..b2fd2eec 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -2413,14 +2413,28 @@ export const PruneAccountsSchema = z // Bank reconciliation schemas // ============================================================ -export const BankLinkSchema = z.object({ - transaction_id: uuid, - journal_entry_id: uuid, - // Settlement account being reconciled. The voucher must have a line on this - // account and the transaction must belong to it. Defaults to '1930' in the - // route for back-compat. - account_number: accountNumber.optional(), -}) +export const BankLinkSchema = z + .object({ + transaction_id: uuid, + // One verifikat (1:1, or N:1 when other transactions already point at it). + journal_entry_id: uuid.optional(), + // Or several verifikat settled by this one transaction (1:N, #1553): the + // signed slice per verifikat in the transaction's sign convention. The + // slices must sum to the transaction amount; the engine enforces it. + allocations: z + .array(z.object({ journal_entry_id: uuid, amount: z.number() })) + .min(2) + .max(50) + .optional(), + // Settlement account being reconciled. The voucher must have a line on this + // account and the transaction must belong to it. Defaults to '1930' in the + // route for back-compat. + account_number: accountNumber.optional(), + }) + .refine((v) => (v.journal_entry_id ? !v.allocations : Boolean(v.allocations)), { + message: 'Ange journal_entry_id eller allocations, inte båda.', + path: ['journal_entry_id'], + }) export const BankUnlinkSchema = z.object({ transaction_id: uuid, diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts index 01baecbd..15ebca30 100644 --- a/lib/bookkeeping/__tests__/engine.test.ts +++ b/lib/bookkeeping/__tests__/engine.test.ts @@ -913,12 +913,19 @@ describe('reverseEntry: bank transaction unlink', () => { type Filter = [op: string, column: string, value: unknown] interface RecordedWrite { payload?: unknown; op?: string; filters: Filter[] } + type RemainingRow = { transaction_id: string; role?: string; allocated_amount?: number } + type TxRow = { id: string; amount: number; journal_entry_id: string | null } + /** * Mock for reverseEntry. `voucherLinks` are the transaction ids the junction * holds for entry-1; `remainingLinks` what it still holds for those ids after - * the delete (a row anchored to some other verifikat too). + * the delete (a row anchored to some other verifikat too), as ids (one + * bank_line row each, no amount) or as full rows. `txRows` is what the + * partial-split read returns for the rows that still have anchors. */ - function setup(opts: { voucherLinks?: string[]; remainingLinks?: string[] } = {}) { + function setup( + opts: { voucherLinks?: string[]; remainingLinks?: Array; txRows?: TxRow[] } = {}, + ) { let jeCall = 0 const jeResults = [ { data: original, error: null }, // fetch original (.single) @@ -985,12 +992,21 @@ describe('reverseEntry: bank transaction unlink', () => { if (table === 'journal_entry_lines') { return { insert: vi.fn().mockResolvedValue({ error: null }) } } - if (table === 'transactions') return recorder(txWrites, () => ({ error: null })) + if (table === 'transactions') { + return recorder(txWrites, (current) => + current.op === 'select' ? { data: opts.txRows ?? [], error: null } : { error: null }, + ) + } if (table === 'transaction_voucher_links') { return recorder(linkOps, (current) => { if (current.op !== 'select') return { error: null } - const ids = linkSelectCall++ === 0 ? opts.voucherLinks ?? [] : opts.remainingLinks ?? [] - return { data: ids.map((transaction_id) => ({ transaction_id })), error: null } + if (linkSelectCall++ === 0) { + return { data: (opts.voucherLinks ?? []).map((transaction_id) => ({ transaction_id })), error: null } + } + const rows = (opts.remainingLinks ?? []).map((r) => + typeof r === 'string' ? { transaction_id: r, role: 'bank_line', allocated_amount: 0 } : r, + ) + return { data: rows, error: null } }) } return createMockChain() @@ -1062,7 +1078,7 @@ describe('reverseEntry: bank transaction unlink', () => { }, { op: 'select', - payload: 'transaction_id', + payload: 'transaction_id, role, allocated_amount', filters: [ ['eq', 'company_id', 'company-1'], ['in', 'transaction_id', ['tx-a', 'tx-b', 'tx-c']], @@ -1070,11 +1086,21 @@ describe('reverseEntry: bank transaction unlink', () => { }, ]) - expect(txWrites).toHaveLength(2) - expect(txWrites[1].payload).toEqual({ is_business: null, category: null, reconciliation_method: null }) + // [0] unlink, [1] the partial-split read for the row still anchored + // (tx-c), [2] the release of the rows with no anchor left. + expect(txWrites).toHaveLength(3) + expect(txWrites[1]).toEqual({ + op: 'select', + payload: 'id, amount, journal_entry_id', + filters: [ + ['eq', 'company_id', 'company-1'], + ['in', 'id', ['tx-c']], + ], + }) + expect(txWrites[2].payload).toEqual({ is_business: null, category: null, reconciliation_method: null }) // Only rows with no anchor left, and never a row whose journal_entry_id // still points at another verifikat (residual booking). - expect(txWrites[1].filters).toEqual([ + expect(txWrites[2].filters).toEqual([ ['eq', 'company_id', 'company-1'], ['in', 'id', ['tx-a', 'tx-b']], ['is', 'journal_entry_id', null], @@ -1090,7 +1116,58 @@ describe('reverseEntry: bank transaction unlink', () => { await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1') expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select']) - expect(txWrites).toHaveLength(1) + // The unlink plus the partial-split read; no release. + expect(txWrites.map((w) => w.op)).toEqual(['update', 'select']) + }) + + it('releases a 1:N split whole when one of its verifikat is reversed (#1553): surviving slices dropped', async () => { + // tx-s (amount -800) was split over entry-1 (-500) and entry-2 (-300). + // Reversing entry-1 leaves a -300 bank_line slice that no longer explains + // the row: the slice goes, and the row returns to Att bokföra. + const { supabase, txWrites, linkOps } = setup({ + voucherLinks: ['tx-s'], + remainingLinks: [{ transaction_id: 'tx-s', role: 'bank_line', allocated_amount: -300 }], + txRows: [{ id: 'tx-s', amount: -800, journal_entry_id: null }], + }) + + await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1') + + expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select', 'delete']) + expect(linkOps[3].filters).toEqual([ + ['eq', 'company_id', 'company-1'], + ['in', 'transaction_id', ['tx-s']], + ]) + const release = txWrites[txWrites.length - 1] + expect(release.op).toBe('update') + expect(release.payload).toEqual({ is_business: null, category: null, reconciliation_method: null }) + expect(release.filters).toEqual([ + ['eq', 'company_id', 'company-1'], + ['in', 'id', ['tx-s']], + ['is', 'journal_entry_id', null], + ]) + }) + + it('keeps a row whose surviving slices still sum to its amount, or that carries a non-bank_line anchor', async () => { + // tx-full: a bulk-booked-style anchor on another verifikat covering the + // whole amount. tx-res: a residual booking's 'other' row (its main + // verifikat pointer is null here because the storno of THAT verifikat is + // what left it; the residual row is not a slice and is never judged). + const { supabase, txWrites, linkOps } = setup({ + voucherLinks: ['tx-full', 'tx-res'], + remainingLinks: [ + { transaction_id: 'tx-full', role: 'bank_line', allocated_amount: -800 }, + { transaction_id: 'tx-res', role: 'other', allocated_amount: -10 }, + ], + txRows: [ + { id: 'tx-full', amount: -800, journal_entry_id: null }, + { id: 'tx-res', amount: -1010, journal_entry_id: null }, + ], + }) + + await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1') + + expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select']) + expect(txWrites.map((w) => w.op)).toEqual(['update', 'select']) }) }) diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 6caa5a53..69891d09 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -1295,7 +1295,7 @@ export async function reverseEntry( } else { const { data: remainingRows, error: remainingError } = await supabase .from('transaction_voucher_links') - .select('transaction_id') + .select('transaction_id, role, allocated_amount') .eq('company_id', companyId) .in('transaction_id', junctionTxIds) if (remainingError) { @@ -1303,8 +1303,67 @@ export async function reverseEntry( entryId, }) } else { + const remainingByTx = new Map>() + for (const row of (remainingRows ?? []) as Array<{ + transaction_id: string + role?: string | null + allocated_amount?: number | string | null + }>) { + const rows = remainingByTx.get(row.transaction_id) ?? [] + rows.push({ role: row.role ?? 'bank_line', allocated_amount: Number(row.allocated_amount ?? 0) }) + remainingByTx.set(row.transaction_id, rows) + } + // A row split over several verifikat (1:N, lib/reconciliation/ + // bank-reconciliation.ts linkTransactionToVouchers) is anchored by + // 'bank_line' slices that sum to its amount. Reversing one of them + // leaves a partial anchor: the row would read as booked while the + // ledger no longer explains it, and the reconciliation difference + // would move by the reversed slice with nothing to act on. Such a + // row goes back to Att bokföra whole: the surviving slices are + // dropped (their vouchers surface as unmatched again) and the row is + // released like a bulk-booked one. Rows whose remaining anchors + // still sum to the amount (bulk-book, one row per transaction) or + // include a non-bank_line anchor (a residual booking) are untouched. + const partialSplitIds: string[] = [] + const candidates = junctionTxIds.filter((id) => (remainingByTx.get(id) ?? []).length > 0) + if (candidates.length > 0) { + const { data: txRows, error: txReadError } = await supabase + .from('transactions') + .select('id, amount, journal_entry_id') + .eq('company_id', companyId) + .in('id', candidates) + if (txReadError) { + log.error('failed to read split transactions after storno', txReadError, { entryId }) + } else { + for (const tx of (txRows ?? []) as Array<{ + id: string + amount: number | string | null + journal_entry_id: string | null + }>) { + if (tx.journal_entry_id) continue + const rows = remainingByTx.get(tx.id) ?? [] + if (!rows.every((r) => r.role === 'bank_line')) continue + const anchored = rows.reduce((sum, r) => sum + r.allocated_amount, 0) + if (Math.abs(Math.round((anchored - Number(tx.amount ?? 0)) * 100) / 100) > 0.005) { + partialSplitIds.push(tx.id) + } + } + } + } + if (partialSplitIds.length > 0) { + const { error: partialDeleteError } = await supabase + .from('transaction_voucher_links') + .delete() + .eq('company_id', companyId) + .in('transaction_id', partialSplitIds) + if (partialDeleteError) { + log.error('failed to drop the surviving slices of a split transaction after storno', partialDeleteError, { + entryId, + }) + } + } const stillAnchored = new Set( - ((remainingRows ?? []) as Array<{ transaction_id: string }>).map((r) => r.transaction_id), + [...remainingByTx.keys()].filter((id) => !partialSplitIds.includes(id)), ) const releaseIds = junctionTxIds.filter((id) => !stillAnchored.has(id)) if (releaseIds.length > 0) { diff --git a/lib/pending-operations/__tests__/reconciliation-match-executor.test.ts b/lib/pending-operations/__tests__/reconciliation-match-executor.test.ts new file mode 100644 index 00000000..a324a800 --- /dev/null +++ b/lib/pending-operations/__tests__/reconciliation-match-executor.test.ts @@ -0,0 +1,110 @@ +/** + * commitReconciliationMatch, driven through the public commitPendingOperation + * dispatcher. The linking lives in lib/reconciliation/actions.ts matchPairs + * (unit tested there); these tests cover the wiring for the bank 1:N pair of + * issue #1553: the staged pair (one row, several verifikat, allocations) + * reaches matchPairs intact and as ONE pair, never re-split into independent + * 1:1 links, and partial success surfaces in the committed payload. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +const matchMock = vi.fn() +vi.mock('@/lib/reconciliation/actions', async () => { + const actual = await vi.importActual('@/lib/reconciliation/actions') + return { ...actual, matchPairs: (...args: unknown[]) => matchMock(...args) } +}) + +import { commitPendingOperation } from '../commit' + +const CASH = '11111111-1111-4111-8111-111111111111' +const KEY = `bank:${CASH}` +const T1 = '22222222-2222-4222-8222-222222222222' +const E1 = '44444444-4444-4444-8444-444444444444' +const E2 = '55555555-5555-4555-8555-555555555555' + +const splitPair = { + external_ids: [T1], + journal_entry_ids: [E1, E2], + allocations: [ + { journal_entry_id: E1, amount: -500 }, + { journal_entry_id: E2, amount: -300 }, + ], +} + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'reconciliation_match', + status: 'pending', + title: 'test', + params: { account_key: KEY, pairs: [splitPair] }, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-08-29T00:00:00Z', + resolved_at: null, + updated_at: '2026-08-29T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commitPendingOperation: reconciliation_match with a bank 1:N pair (#1553)', () => { + it('passes the staged split pair, allocations included, to matchPairs as one pair', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's committed update + matchMock.mockResolvedValue({ + dry_run: false, + considered: 1, + applied: [ + { external_id: T1, journal_entry_id: E1, allocated_amount: -500 }, + { external_id: T1, journal_entry_id: E2, allocated_amount: -300 }, + ], + skipped: [], + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({})) + + expect(matchMock).toHaveBeenCalledTimes(1) + expect(matchMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', KEY, { pairs: [splitPair] }, { dryRun: false }) + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ account_key: KEY, applied_count: 2, skipped_count: 0 }) + expect((result.data as { applied: Array<{ allocated_amount: number }> }).applied.map((a) => a.allocated_amount)).toEqual([-500, -300]) + }) + + it('reports a refused split (sum mismatch at commit time) as a 409 auto-reject carrying the skip code', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's failed update + matchMock.mockResolvedValue({ + dry_run: false, + considered: 1, + applied: [], + skipped: [ + { pair: splitPair, code: 'PAIR_NOT_CLOSED', message: 'Fördelningen (-800) stämmer inte med transaktionens belopp (-900).' }, + ], + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({})) + + // 409 is a conflict with the ledger's current state, which the + // dispatcher records as rejected (like a 404), not as a failed attempt. + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + expect(result.code).toBe('PAIR_NOT_CLOSED') + expect(result.error).toMatch(/PAIR_NOT_CLOSED/) + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index f02e094a..fe885b18 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -6108,7 +6108,9 @@ async function commitBulkBookInboxItems( * same service the page and the v1 API use. Every pair is re-validated at * commit time (row still open, entry still posted and unlinked, amounts * close); partial success is reported in data.applied / data.skipped rather - * than failing the whole operation, because the pairs are independent. + * than failing the whole operation, because the pairs are independent. A + * bank 1:N pair (one row, several verifikat, allocations) is staged as one + * pair and re-validated as one all-or-nothing split. */ async function commitReconciliationMatch( supabase: SupabaseClient, @@ -6117,7 +6119,13 @@ async function commitReconciliationMatch( params: Record ): Promise { const accountKey = params.account_key as string | undefined - const pairs = params.pairs as Array<{ external_ids: string[]; journal_entry_ids: string[] }> | undefined + const pairs = params.pairs as + | Array<{ + external_ids: string[] + journal_entry_ids: string[] + allocations?: Array<{ journal_entry_id: string; amount: number }> + }> + | undefined if (!accountKey || !Array.isArray(pairs) || pairs.length === 0) { return { error: 'account_key and pairs are required', status: 400 } } diff --git a/lib/reconciliation/__tests__/actions.test.ts b/lib/reconciliation/__tests__/actions.test.ts index 86f05042..9c64b32e 100644 --- a/lib/reconciliation/__tests__/actions.test.ts +++ b/lib/reconciliation/__tests__/actions.test.ts @@ -6,6 +6,8 @@ const linkGroupMock = vi.fn() const unlinkMock = vi.fn() const setIgnoredMock = vi.fn() const manualLinkMock = vi.fn() +const linkToVouchersMock = vi.fn() +const junctionLinkedMock = vi.fn() const unlinkReconciliationMock = vi.fn() const skvStatusMock = vi.fn() const emitMock = vi.fn() @@ -22,6 +24,8 @@ vi.mock('@/lib/skatteverket/skattekonto-link', async (importOriginal) => { }) vi.mock('../bank-reconciliation', () => ({ manualLink: (...args: unknown[]) => manualLinkMock(...args), + linkTransactionToVouchers: (...args: unknown[]) => linkToVouchersMock(...args), + fetchJunctionLinkedTxIds: (...args: unknown[]) => junctionLinkedMock(...args), unlinkReconciliation: (...args: unknown[]) => unlinkReconciliationMock(...args), })) vi.mock('../skattekonto-reconciliation', () => ({ @@ -46,6 +50,7 @@ describe('matchPairs', () => { linkMock.mockReset() linkGroupMock.mockReset() manualLinkMock.mockReset() + linkToVouchersMock.mockReset() skvStatusMock.mockReset() emitMock.mockResolvedValue(undefined) }) @@ -72,7 +77,7 @@ describe('matchPairs', () => { { external_ids: [R1], journal_entry_ids: [E1] }, { external_ids: [R2], journal_entry_ids: [E1] }, { external_ids: [R1, R2], journal_entry_ids: [E2] }, - // 1:M stays refused until the residual link table exists. + // 1:N is a bank-only shape (#1553): a skattekonto row never splits. { external_ids: [R1], journal_entry_ids: [E1, E2] }, ], }) @@ -84,6 +89,8 @@ describe('matchPairs', () => { { external_id: R2, journal_entry_id: E2, via: 'entry_total' }, ]) expect(result?.skipped.map((s) => s.code)).toEqual(['ALREADY_LINKED', 'UNSUPPORTED_PAIR_SHAPE']) + expect(result?.skipped[1].message).toMatch(/skattekontot/) + expect(linkToVouchersMock).not.toHaveBeenCalled() expect(linkGroupMock).toHaveBeenCalledWith(supabase, COMPANY, [R1, R2], E2) expect(emitMock).toHaveBeenCalledTimes(3) expect(emitMock.mock.calls[0][0]).toMatchObject({ @@ -154,6 +161,125 @@ describe('matchPairs', () => { expect(result?.applied).toHaveLength(1) }) + it('splits ONE bank transaction over SEVERAL verifikat through linkTransactionToVouchers (1:N, #1553)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { ledger_account: '1930' } }) // cash_accounts lookup, resolved once + linkToVouchersMock.mockResolvedValue({ + success: true, + allocations: [ + { journal_entry_id: E1, amount: -500 }, + { journal_entry_id: E2, amount: -300 }, + ], + }) + + const result = await matchPairs(supabase as never, COMPANY, USER, `bank:${CASH}`, { + pairs: [{ external_ids: [R1], journal_entry_ids: [E1, E2] }], + }) + + // No allocations given: every slice is left undefined for the engine to + // default to the voucher's bank line and enforce the sum. + expect(linkToVouchersMock).toHaveBeenCalledWith( + supabase, + COMPANY, + R1, + [ + { journal_entry_id: E1, amount: undefined }, + { journal_entry_id: E2, amount: undefined }, + ], + USER, + '1930', + { dryRun: false }, + ) + expect(manualLinkMock).not.toHaveBeenCalled() + expect(result?.applied).toEqual([ + { external_id: R1, journal_entry_id: E1, allocated_amount: -500 }, + { external_id: R1, journal_entry_id: E2, allocated_amount: -300 }, + ]) + expect(result?.skipped).toEqual([]) + expect(emitMock).toHaveBeenCalledTimes(2) + expect(emitMock.mock.calls[1][0]).toMatchObject({ + type: 'reconciliation.matched', + payload: { accountKey: `bank:${CASH}`, externalId: R1, journalEntryId: E2 }, + }) + }) + + it('forwards explicit allocations, refuses a set that does not name exactly the pair\'s verifikat, and never writes on dry run', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { ledger_account: '1931' } }) + linkToVouchersMock.mockResolvedValue({ + success: true, + allocations: [ + { journal_entry_id: E1, amount: -450 }, + { journal_entry_id: E2, amount: -350 }, + ], + }) + + const result = await matchPairs( + supabase as never, + COMPANY, + USER, + `bank:${CASH}`, + { + pairs: [ + { + external_ids: [R1], + journal_entry_ids: [E1, E2], + allocations: [ + { journal_entry_id: E1, amount: -450 }, + { journal_entry_id: E2, amount: -350 }, + ], + }, + // Names a verifikat outside the pair: refused before the engine. + { external_ids: [R2], journal_entry_ids: [E1, E2], allocations: [{ journal_entry_id: E1, amount: -800 }] }, + // N:M has no engine shape on either account kind. + { external_ids: [R1, R2], journal_entry_ids: [E1, E2] }, + ], + }, + { dryRun: true }, + ) + + expect(linkToVouchersMock).toHaveBeenCalledTimes(1) + expect(linkToVouchersMock).toHaveBeenCalledWith( + supabase, + COMPANY, + R1, + [ + { journal_entry_id: E1, amount: -450 }, + { journal_entry_id: E2, amount: -350 }, + ], + USER, + '1931', + { dryRun: true }, + ) + expect(result?.applied).toHaveLength(2) + expect(result?.skipped.map((s) => s.code)).toEqual(['UNSUPPORTED_PAIR_SHAPE', 'UNSUPPORTED_PAIR_SHAPE']) + expect(result?.skipped[0].message).toMatch(/allocations/) + expect(emitMock).not.toHaveBeenCalled() + }) + + it('a refused split is one PAIR_NOT_CLOSED skip for the whole pair (all or nothing)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { ledger_account: '1930' } }) + linkToVouchersMock.mockResolvedValue({ + success: false, + error: 'Fördelningen (-700) stämmer inte med transaktionens belopp (-800).', + }) + + const result = await matchPairs(supabase as never, COMPANY, USER, `bank:${CASH}`, { + pairs: [{ external_ids: [R1], journal_entry_ids: [E1, E2] }], + }) + + expect(result?.applied).toEqual([]) + expect(result?.skipped).toEqual([ + { + pair: { external_ids: [R1], journal_entry_ids: [E1, E2] }, + code: 'PAIR_NOT_CLOSED', + message: 'Fördelningen (-700) stämmer inte med transaktionens belopp (-800).', + }, + ]) + expect(emitMock).not.toHaveBeenCalled() + }) + it('a failed bank link is a PAIR_NOT_CLOSED skip, not a throw', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ data: { ledger_account: '1930' } }) @@ -172,6 +298,8 @@ describe('unmatchLink / setItemIgnored', () => { unlinkMock.mockReset() unlinkReconciliationMock.mockReset() setIgnoredMock.mockReset() + junctionLinkedMock.mockReset() + junctionLinkedMock.mockResolvedValue(new Set()) emitMock.mockResolvedValue(undefined) }) @@ -192,6 +320,29 @@ describe('unmatchLink / setItemIgnored', () => { expect(result).toEqual({ external_id: R1, previous_journal_entry_id: E1 }) }) + it('reports the first junction verifikat as previous_journal_entry_id when a split row (no pointer) is unmatched', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { journal_entry_id: null } }) + unlinkReconciliationMock.mockResolvedValue({ success: true, previousJournalEntryIds: [E1, E2] }) + const result = await unmatchLink(supabase as never, COMPANY, USER, `bank:${CASH}`, R1) + expect(result).toEqual({ external_id: R1, previous_journal_entry_id: E1 }) + expect(emitMock.mock.calls[0][0]).toMatchObject({ + type: 'reconciliation.unmatched', + payload: { externalId: R1, previousJournalEntryId: E1 }, + }) + }) + + it('refuses to ignore a bank transaction anchored only through transaction_voucher_links (1:N split, bulk-book)', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: R1, journal_entry_id: null, is_ignored: false } }) + junctionLinkedMock.mockResolvedValue(new Set([R1])) + await expect(setItemIgnored(supabase as never, COMPANY, `bank:${CASH}`, R1, true)).rejects.toMatchObject({ + code: 'ALREADY_BOOKED', + }) + expect(junctionLinkedMock).toHaveBeenCalledWith(supabase, COMPANY, [R1]) + expect(findCalls('transactions', 'update')).toEqual([]) + }) + it('ignores a skattekonto row via the core helper', async () => { const { supabase } = createQueuedMockSupabase() setIgnoredMock.mockResolvedValue({ skattekonto_transaction_id: R1, is_ignored: true }) diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index 1747f60e..982af68a 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -31,13 +31,14 @@ import { tryReconcileTransaction, runReconciliation, manualLink, + linkTransactionToVouchers, unlinkReconciliation, getReconciliationStatus, scopeTransactionsToAccount, ledgerLineAmountIn, } from '../bank-reconciliation' import type { UnlinkedGLLine } from '../bank-reconciliation' -import { makeTransaction } from '@/tests/helpers' +import { createQueuedMockSupabase, makeTransaction } from '@/tests/helpers' import { eventBus } from '@/lib/events/bus' vi.mock('@/lib/supabase/server') @@ -1009,6 +1010,38 @@ describe('manualLink', () => { expect(result.success).toBe(true) }) + it('refuses a row anchored through a bank_line junction row (bulk-book, 1:N split) even with a NULL pointer', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + const tx = { + ...makeTransaction({ id: 'tx-1', journal_entry_id: null }), + transaction_voucher_links: [{ journal_entry_id: 'je-split-a', role: 'bank_line' }], + } + enqueue({ data: tx }) + + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930') + + expect(result.success).toBe(false) + expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.') + // Nothing past the transaction read: no verifikat lookup, no write. + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('does not treat a residual booking\'s "other" junction row as a link (the row stays re-linkable after a storno)', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + const tx = { + ...makeTransaction({ id: 'tx-1', journal_entry_id: null }), + transaction_voucher_links: [{ journal_entry_id: 'je-residual', role: 'other' }], + } + enqueue({ data: tx }) + enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) + enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] }) + enqueue({ data: [{ id: 'tx-1' }] }) + + const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930') + + expect(result.success).toBe(true) + }) + it('rejects when a concurrent linker won the race (0 rows updated)', async () => { const { supabase, enqueue } = createQueueMockSupabase() const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null }) @@ -1361,6 +1394,382 @@ describe('manualLink', () => { }) }) +// ============================================================ +// linkTransactionToVouchers: ONE bank row over SEVERAL verifikat (1:N, #1553) +// ============================================================ + +describe('linkTransactionToVouchers', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + const E1 = 'je-utlagg-1' + const E2 = 'je-utlagg-2' + const postedEntries = [ + { id: E1, status: 'posted', voucher_series: 'A', voucher_number: 11 }, + { id: E2, status: 'posted', voucher_series: 'A', voucher_number: 12 }, + ] + /** Two utlägg verifikat, each crediting 1930: -500 and -300 in bank terms. */ + const bankLines = [ + { journal_entry_id: E1, debit_amount: 0, credit_amount: 500, currency: null, amount_in_currency: null }, + { journal_entry_id: E2, debit_amount: 0, credit_amount: 300, currency: null, amount_in_currency: null }, + ] + const freeTx = (over: Record = {}) => ({ + ...makeTransaction({ id: 'tx-1', amount: -800, journal_entry_id: null, reconciliation_method: null }), + transaction_voucher_links: [], + ...over, + }) + + /** + * Queue up to and including the ledger read, in the order the function + * consumes it: tx, invoice_payments, supplier_invoice_payments, + * journal_entries, journal_entry_lines. (cash_account_id is null on the + * fixture, so the cash-account cross-check is skipped.) + */ + function enqueueReads( + enqueue: (r: { data?: unknown; error?: unknown }) => void, + opts: { tx?: Record; entries?: unknown[]; lines?: unknown[] } = {}, + ) { + enqueue({ data: opts.tx ?? freeTx() }) + enqueue({ data: [] }) // invoice_payments + enqueue({ data: [] }) // supplier_invoice_payments + enqueue({ data: opts.entries ?? postedEntries }) + enqueue({ data: opts.lines ?? bankLines }) + } + + it('links one row to two verifikat: pointer stays NULL, one signed bank_line slice per verifikat, one matched event each', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + const reconciled = vi.fn() + eventBus.on('transaction.reconciled', reconciled) + enqueueReads(enqueue) + enqueue({ data: [{ id: 'tx-1' }] }) // lock UPDATE + enqueue({ data: null }) // junction insert + enqueue({ data: null }) // payment_match_log + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [ + { journal_entry_id: E1, amount: -500 }, + { journal_entry_id: E2, amount: -300 }, + ], + 'user-1', + '1930', + ) + + expect(result).toEqual({ + success: true, + allocations: [ + { journal_entry_id: E1, amount: -500 }, + { journal_entry_id: E2, amount: -300 }, + ], + }) + // The lock: pointer explicitly NULL, method manual, business, locked on + // the pointer being NULL (a concurrent linker makes it match zero rows). + const [lockPayload] = findCalls('transactions', 'update')[0] + expect(lockPayload).toEqual({ + journal_entry_id: null, + reconciliation_method: 'manual', + is_business: true, + potential_journal_entry_id: null, + potential_match_method: null, + potential_match_confidence: null, + }) + expect(findCalls('transactions', 'is')[0]).toEqual(['journal_entry_id', null]) + // One insert carrying both slices. + const inserts = findCalls('transaction_voucher_links', 'insert') + expect(inserts).toHaveLength(1) + expect(inserts[0][0]).toEqual([ + { user_id: 'user-1', company_id: 'company-1', transaction_id: 'tx-1', journal_entry_id: E1, allocated_amount: -500, role: 'bank_line' }, + { user_id: 'user-1', company_id: 'company-1', transaction_id: 'tx-1', journal_entry_id: E2, allocated_amount: -300, role: 'bank_line' }, + ]) + // Behandlingshistorik row with every slice. + const [logRow] = findCalls('payment_match_log', 'insert')[0] as [Record] + expect(logRow).toMatchObject({ user_id: 'user-1', transaction_id: 'tx-1', action: 'matched', match_method: 'manual' }) + expect((logRow.new_state as { journal_entry_ids: string[] }).journal_entry_ids).toEqual([E1, E2]) + expect(reconciled).toHaveBeenCalledTimes(2) + // Handlers receive the payload itself (lib/events/bus.ts). + expect(reconciled.mock.calls.map((c) => c[0].journalEntryId)).toEqual([E1, E2]) + }) + + it('defaults an omitted slice to the verifikat\'s net line on the account', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueueReads(enqueue) + enqueue({ data: [{ id: 'tx-1' }] }) + enqueue({ data: null }) + enqueue({ data: null }) + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], + 'user-1', + '1930', + ) + + expect(result.success).toBe(true) + expect(result.allocations).toEqual([ + { journal_entry_id: E1, amount: -500 }, + { journal_entry_id: E2, amount: -300 }, + ]) + expect(findCalls('transaction_voucher_links', 'insert')).toHaveLength(1) + }) + + it('dry run validates and resolves the slices without writing', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueueReads(enqueue) + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], + 'user-1', + '1930', + { dryRun: true }, + ) + + expect(result.success).toBe(true) + expect(result.allocations).toHaveLength(2) + expect(findCalls('transactions', 'update')).toEqual([]) + expect(findCalls('transaction_voucher_links', 'insert')).toEqual([]) + }) + + it('refuses when the slices do not sum to the transaction amount, writing nothing', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueueReads(enqueue, { tx: freeTx({ amount: -900 }) }) + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], + 'user-1', + '1930', + ) + + expect(result.success).toBe(false) + expect(result.error).toBe('Fördelningen (-800) stämmer inte med transaktionens belopp (-900).') + expect(findCalls('transactions', 'update')).toEqual([]) + expect(findCalls('transaction_voucher_links', 'insert')).toEqual([]) + }) + + it('refuses a slice with the wrong sign for the verifikat\'s bank line', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueReads(enqueue) + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [ + { journal_entry_id: E1, amount: 500 }, + { journal_entry_id: E2, amount: -1300 }, + ], + 'user-1', + '1930', + ) + + expect(result.success).toBe(false) + expect(result.error).toBe('Beloppet för verifikat A-11 har fel riktning: verifikatet bokför -500 på 1930.') + }) + + it('refuses a slice larger than the verifikat\'s bank line', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueReads(enqueue, { tx: freeTx({ amount: -1000 }) }) + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [ + { journal_entry_id: E1, amount: -700 }, + { journal_entry_id: E2, amount: -300 }, + ], + 'user-1', + '1930', + ) + + expect(result.success).toBe(false) + expect(result.error).toBe('Beloppet för verifikat A-11 (-700) är större än verifikatets rad på 1930 (-500).') + }) + + it('refuses a zero slice', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueReads(enqueue) + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [ + { journal_entry_id: E1, amount: 0 }, + { journal_entry_id: E2, amount: -800 }, + ], + 'user-1', + '1930', + ) + + expect(result.success).toBe(false) + expect(result.error).toBe('Beloppet för verifikat A-11 får inte vara 0.') + }) + + it('refuses fewer than two verifikat, and the same verifikat twice, before reading anything', async () => { + const { supabase } = createQueuedMockSupabase() + + const one = await linkTransactionToVouchers(supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }], 'user-1') + expect(one).toEqual({ success: false, error: 'En delning kräver minst två verifikat.' }) + + const dup = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [{ journal_entry_id: E1 }, { journal_entry_id: E1 }], + 'user-1', + ) + expect(dup).toEqual({ success: false, error: 'Samma verifikat förekommer flera gånger i fördelningen.' }) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('refuses a transaction that is already booked: junction row, live pointer, payment row, or ignored', async () => { + // Junction row (bulk-book or an earlier split). + let m = createQueuedMockSupabase() + m.enqueue({ data: freeTx({ transaction_voucher_links: [{ journal_entry_id: 'je-x', role: 'bank_line' }] }) }) + let r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1') + expect(r.error).toBe('Transaktionen är redan kopplad till en verifikation.') + + // Live pointer. + m = createQueuedMockSupabase() + m.enqueue({ data: freeTx({ journal_entry_id: 'je-live' }) }) + m.enqueue({ data: { status: 'posted' } }) // hasLiveJournalEntryLink + r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1') + expect(r.error).toBe('Transaktionen är redan kopplad till en verifikation.') + + // Payment row (match-invoice / match-batch). + m = createQueuedMockSupabase() + m.enqueue({ data: freeTx() }) + m.enqueue({ data: [{ id: 'ip-1' }] }) // invoice_payments + m.enqueue({ data: [] }) + r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1') + expect(r.error).toBe('Transaktionen är redan matchad mot en faktura.') + + // Ignored. + m = createQueuedMockSupabase() + m.enqueue({ data: freeTx({ is_ignored: true }) }) + r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1') + expect(r.error).toBe('Transaktionen är ignorerad. Återställ den innan du kopplar.') + }) + + it('re-links a row stranded on a reversed entry (#988), locking on the stale pointer', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: freeTx({ journal_entry_id: 'je-reversed' }) }) + enqueue({ data: { status: 'reversed' } }) // hasLiveJournalEntryLink: stale + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: postedEntries }) + enqueue({ data: bankLines }) + enqueue({ data: [{ id: 'tx-1' }] }) + enqueue({ data: null }) + enqueue({ data: null }) + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], + 'user-1', + '1930', + ) + + expect(result.success).toBe(true) + expect(findCalls('transactions', 'eq').some((args) => args[0] === 'journal_entry_id' && args[1] === 'je-reversed')).toBe(true) + }) + + it('refuses a verifikat that is not posted, one outside the company, and one without a line on the account', async () => { + let m = createQueuedMockSupabase() + enqueueReads(m.enqueue, { entries: [postedEntries[0], { ...postedEntries[1], status: 'draft' }] }) + let r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1') + expect(r.error).toBe('Verifikat A-12 är inte bokförd ännu.') + + // Only one of the two ids comes back inside the company. + m = createQueuedMockSupabase() + enqueueReads(m.enqueue, { entries: [postedEntries[0]] }) + r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1') + expect(r.error).toBe('Verifikationen kunde inte hittas.') + + // E2 books nothing on 1930 (its bank line is on 1940). + m = createQueuedMockSupabase() + enqueueReads(m.enqueue, { lines: [bankLines[0]] }) + r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1', '1930') + expect(r.error).toBe('Verifikat A-12 saknar rad på 1930') + }) + + it('refuses when the transaction belongs to another cash account', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: freeTx({ cash_account_id: 'ca-1940' }) }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: { ledger_account: '1940' } }) // cash_accounts cross-check + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], + 'user-1', + '1930', + ) + + expect(result).toEqual({ success: false, error: 'Transaktionen hör till 1940, inte 1930' }) + }) + + it('reports a lost race (0 rows locked) as already linked and inserts nothing', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueueReads(enqueue) + enqueue({ data: [] }) // lock UPDATE matched nothing + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], + 'user-1', + '1930', + ) + + expect(result).toEqual({ success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }) + expect(findCalls('transaction_voucher_links', 'insert')).toEqual([]) + }) + + it('rolls the lock back when the junction insert fails', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueueReads(enqueue) + enqueue({ data: [{ id: 'tx-1' }] }) // lock UPDATE + enqueue({ data: null, error: { message: 'duplicate key' } }) // junction insert + enqueue({ data: null }) // compensating delete + enqueue({ data: null }) // restore UPDATE + + const result = await linkTransactionToVouchers( + supabase as never, + 'company-1', + 'tx-1', + [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], + 'user-1', + '1930', + ) + + expect(result).toEqual({ success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' }) + expect(findCalls('transaction_voucher_links', 'delete')).toHaveLength(1) + const updates = findCalls('transactions', 'update') + expect(updates).toHaveLength(2) + expect(updates[1][0]).toEqual({ journal_entry_id: null, reconciliation_method: null, is_business: null }) + expect(findCalls('payment_match_log', 'insert')).toEqual([]) + }) +}) + // ============================================================ // unlinkReconciliation // ============================================================ @@ -1438,6 +1847,29 @@ describe('unlinkReconciliation', () => { expect(result.success).toBe(true) }) + it('unlinks a split row (no pointer, junction rows only) and reports every verifikat it was anchored to (#1553)', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: { id: 'tx-1', journal_entry_id: null, reconciliation_method: 'manual' } }) + enqueue({ data: [{ journal_entry_id: 'je-a' }, { journal_entry_id: 'je-b' }] }) // junction rows, read BEFORE the delete + enqueue({ data: null }) // transactions update + enqueue({ data: null }) // junction delete + + const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1') + + expect(result).toEqual({ success: true, previousJournalEntryIds: ['je-a', 'je-b'] }) + }) + + it('still refuses a row with neither pointer nor junction rows', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + enqueue({ data: { id: 'tx-1', journal_entry_id: null, reconciliation_method: 'manual' } }) + enqueue({ data: [] }) + + const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1') + + expect(result.success).toBe(false) + expect(result.error).toBe('Transaction is not linked to any journal entry') + }) + it('attributes the audit log row to the acting user, not the company', async () => { // Regression: unlinkReconciliation used to pass companyId where // logMatchEvent expects userId, so payment_match_log.user_id recorded the diff --git a/lib/reconciliation/actions.ts b/lib/reconciliation/actions.ts index 8dfe8ca4..0bfc6452 100644 --- a/lib/reconciliation/actions.ts +++ b/lib/reconciliation/actions.ts @@ -8,7 +8,12 @@ import { SkattekontoLinkError, unlinkSkattekontoRow, } from '@/lib/skatteverket/skattekonto-link' -import { manualLink, unlinkReconciliation } from './bank-reconciliation' +import { + fetchJunctionLinkedTxIds, + linkTransactionToVouchers, + manualLink, + unlinkReconciliation, +} from './bank-reconciliation' import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation' import { parseAccountKey } from './schemas' @@ -27,6 +32,13 @@ export interface ReconciliationPair { /** Outside rows: transaction ids (bank) or skattekonto_transaction ids. */ external_ids: string[] journal_entry_ids: string[] + /** + * Bank only, for the 1:N shape (one transaction over several verifikat): + * the signed slice per verifikat, in the transaction's sign convention. + * Omitted: each slice defaults to the voucher's net line on the account. + * Either way the slices must sum to the transaction amount. + */ + allocations?: Array<{ journal_entry_id: string; amount: number }> } export type PairSkipCode = @@ -44,6 +56,8 @@ export interface AppliedLink { external_id: string journal_entry_id: string via?: 'line' | 'entry_total' + /** Present on the links of a 1:N split: the slice of the row this verifikat settles. */ + allocated_amount?: number } export interface SkippedPair { @@ -118,9 +132,12 @@ async function proposalsAsPairs( /** * Link pairs on one account. A pair is one OR MANY outside rows against * exactly one verifikat (bank: independent links per transaction; skattekonto: - * all-or-nothing with the sum settling the verifikat). One row against many - * verifikat waits for the residual link table and is reported as - * UNSUPPORTED_PAIR_SHAPE, never silently reduced. Dry run validates shapes and resolves proposals without writing. + * all-or-nothing with the sum settling the verifikat), or, on a bank account, + * ONE transaction against SEVERAL verifikat (1:N, issue #1553): all-or-nothing + * with the slices summing to the transaction. Any other shape is reported as + * UNSUPPORTED_PAIR_SHAPE, never silently reduced. Dry run validates shapes and + * resolves proposals without writing; a 1:N dry run also resolves the slices + * so a reviewer sees exactly what would be linked. * Partial success is first-class: `applied` and `skipped` together cover * every considered pair. */ @@ -160,16 +177,94 @@ export async function matchPairs( }) } + // Resolved once, lazily: only bank pairs need the ledger account. + let ledgerAccount: string | null = null + const resolveLedgerAccount = async (): Promise => { + if (ledgerAccount) return ledgerAccount + if (parsed.kind !== 'bank') return '1930' + const { data: account } = await supabase + .from('cash_accounts') + .select('ledger_account') + .eq('company_id', companyId) + .eq('id', parsed.cashAccountId) + .maybeSingle<{ ledger_account: string }>() + ledgerAccount = account?.ledger_account ?? '1930' + return ledgerAccount + } + for (const pair of pairs) { - // N outside rows may settle ONE verifikat (the worksheet selection); the - // reverse shape (one row over several verifikat) waits for the residual - // link table and is refused loudly, never silently reduced. + // N outside rows may settle ONE verifikat (the worksheet selection). ONE + // bank transaction may settle SEVERAL verifikat (the split, #1553). A + // skattekonto row never splits (Skatteverket posts each event as its own + // row), and N:M is refused loudly, never silently reduced. if (pair.journal_entry_ids.length !== 1) { - skipped.push({ - pair, - code: 'UNSUPPORTED_PAIR_SHAPE', - message: 'Flera verifikat i samma par stöds inte ännu: ett par är en eller flera händelser mot ett verifikat.', - }) + const journalEntryIds = [...new Set(pair.journal_entry_ids)] + const isBankSplit = + parsed.kind === 'bank' && pair.external_ids.length === 1 && journalEntryIds.length >= 2 + if (!isBankSplit) { + skipped.push({ + pair, + code: 'UNSUPPORTED_PAIR_SHAPE', + message: + parsed.kind === 'bank' + ? 'Ett par är en eller flera händelser mot ett verifikat, eller en händelse mot flera verifikat.' + : 'Flera verifikat i samma par stöds inte på skattekontot: ett par är en eller flera händelser mot ett verifikat.', + }) + continue + } + if (journalEntryIds.length > 50) { + skipped.push({ + pair, + code: 'UNSUPPORTED_PAIR_SHAPE', + message: 'En händelse kan delas på högst 50 verifikat.', + }) + continue + } + // Explicit allocations must name exactly the pair's verifikat, once each. + const given = pair.allocations + if (given) { + const namedIds = given.map((a) => a.journal_entry_id) + const namedSet = new Set(namedIds) + const coversPair = + namedSet.size === namedIds.length && + namedSet.size === journalEntryIds.length && + journalEntryIds.every((id) => namedSet.has(id)) + if (!coversPair) { + skipped.push({ + pair, + code: 'UNSUPPORTED_PAIR_SHAPE', + message: 'allocations måste ange ett belopp för varje verifikat i paret, och inga andra.', + }) + continue + } + } + const [externalId] = pair.external_ids + const allocationInput = journalEntryIds.map((id) => ({ + journal_entry_id: id, + amount: given?.find((a) => a.journal_entry_id === id)?.amount, + })) + try { + const r = await linkTransactionToVouchers( + supabase, + companyId, + externalId, + allocationInput, + userId, + await resolveLedgerAccount(), + { dryRun }, + ) + if (!r.success || !r.allocations) { + skipped.push({ pair, code: 'PAIR_NOT_CLOSED', message: r.error ?? 'Kunde inte koppla' }) + continue + } + for (const a of r.allocations) { + applied.push({ external_id: externalId, journal_entry_id: a.journal_entry_id, allocated_amount: a.amount }) + if (!dryRun) await emitMatched(externalId, a.journal_entry_id) + } + } catch (err) { + const { code, message } = skipCodeFor(err) + skipped.push({ pair, code, message }) + } continue } const externalIds = [...new Set(pair.external_ids)] @@ -206,24 +301,12 @@ export async function matchPairs( } } } else { - const { data: account } = await supabase - .from('cash_accounts') - .select('ledger_account') - .eq('company_id', companyId) - .eq('id', parsed.cashAccountId) - .maybeSingle<{ ledger_account: string }>() + const account = await resolveLedgerAccount() // Bank N:1 is per-transaction by design (manualLink documents why the // engine allows several transactions on one verifikat): each link is // independent, so partial success is reported per transaction. for (const externalId of externalIds) { - const r = await manualLink( - supabase, - companyId, - externalId, - journalEntryId, - userId, - account?.ledger_account ?? '1930', - ) + const r = await manualLink(supabase, companyId, externalId, journalEntryId, userId, account) if (!r.success) { skipped.push({ pair: { external_ids: [externalId], journal_entry_ids: [journalEntryId] }, @@ -279,9 +362,11 @@ export async function unmatchLink( .eq('company_id', companyId) .eq('id', linkId) .maybeSingle<{ journal_entry_id: string | null }>() - previous = tx?.journal_entry_id ?? null const r = await unlinkReconciliation(supabase, companyId, linkId, userId) if (!r.success) throw new Error(r.error ?? 'Kunde inte koppla bort') + // A split row (1:N) has no pointer: the engine collected its junction + // vouchers before deleting them, so the first one is reported here. + previous = tx?.journal_entry_id ?? r.previousJournalEntryIds?.[0] ?? null } await eventBus.emit({ type: 'reconciliation.unmatched', @@ -318,6 +403,16 @@ export async function setItemIgnored( if (ignored && tx.journal_entry_id) { throw new SkattekontoLinkError('En bokförd transaktion kan inte ignoreras.', 'ALREADY_BOOKED') } + // A row anchored only through transaction_voucher_links (bulk-book, 1:N + // split) has two counterparts in the ledger; ignoring it would drop the + // bank side while the ledger keeps it and manufacture a difference of the + // full amount (issue #1553, field note). + if (ignored && !tx.journal_entry_id) { + const junctionLinked = await fetchJunctionLinkedTxIds(supabase, companyId, [itemId]) + if (junctionLinked.has(itemId)) { + throw new SkattekontoLinkError('En bokförd transaktion kan inte ignoreras.', 'ALREADY_BOOKED') + } + } if (Boolean(tx.is_ignored) !== ignored) { const { error: updateError } = await supabase .from('transactions') diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index e7533932..474ba371 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -6,6 +6,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' +import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' import { ledgerLineAmountIn, type LedgerLineAmount, @@ -1059,23 +1060,38 @@ export async function manualLink( userId: string, accountNumber: string = '1930', ): Promise<{ success: boolean; error?: string }> { - // Fetch transaction - const { data: tx, error: txError } = await supabase + // Fetch transaction. The junction rows ride along on the same read: a row + // split over several verifikat (linkTransactionToVouchers) or bulk-booked + // into a samlingsverifikat carries journal_entry_id = NULL, and the pointer + // alone would let it be linked a second time. Only 'bank_line' rows count: + // they are the slices that explain the row's bank amount. A residual + // booking's row (role 'other') is supplementary and must not strand the + // row after a storno of its main verifikat nulls the pointer. + const { data: txRow, error: txError } = await supabase .from('transactions') - .select('*') + .select('*, transaction_voucher_links(journal_entry_id, role)') .eq('id', transactionId) .eq('company_id', companyId) .single() - if (txError || !tx) { + if (txError || !txRow) { return { success: false, error: 'Transaktionen kunde inte hittas.' } } + const { transaction_voucher_links: junctionRows, ...tx } = txRow as Record & { + transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null + } + if (hasBankLineJunctionRow(junctionRows)) { + return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' } + } // Only a LIVE (posted) pointer blocks re-linking. A transaction still pointing // at a 'reversed' entry (storno/correction left the link behind) reads as // "utan koppling" in the UI, so it must be re-linkable to another verifikat // (issue #988). The stale pointer is overwritten by the locked UPDATE below. - if (tx.journal_entry_id && (await hasLiveJournalEntryLink(supabase, companyId, tx.journal_entry_id))) { + if ( + typeof tx.journal_entry_id === 'string' && + (await hasLiveJournalEntryLink(supabase, companyId, tx.journal_entry_id)) + ) { return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' } } @@ -1109,7 +1125,7 @@ export async function manualLink( // currency), stay rejected. let allowedLineAccounts: string[] = [accountNumber] let siblingInfo: CashAccountSiblings | null = null - if (tx.cash_account_id) { + if (typeof tx.cash_account_id === 'string') { const { data: txCa } = await supabase .from('cash_accounts') .select('ledger_account') @@ -1213,7 +1229,8 @@ export async function manualLink( // lets the stale-pointer overwrite through while a concurrent re-link becomes // a no-op (0 rows → the "redan kopplad" branch below). Same optimistic-lock // pattern as lib/transactions/link-journal-entry.ts. - const previousJournalEntryId = (tx.journal_entry_id as string | null) ?? null + const previousJournalEntryId = + typeof tx.journal_entry_id === 'string' ? tx.journal_entry_id : null const linkUpdate = supabase .from('transactions') .update({ @@ -1240,7 +1257,7 @@ export async function manualLink( eventBus.emit({ type: 'transaction.reconciled', payload: { - transaction: tx as Transaction, + transaction: tx as unknown as Transaction, journalEntryId, method: 'manual' as ReconciliationMethod, userId, @@ -1254,16 +1271,340 @@ export async function manualLink( return { success: true } } +/** One slice of a bank transaction settled on one verifikat (1:N link). */ +export interface VoucherAllocation { + journal_entry_id: string + /** Signed, in the transaction's currency: the transaction's sign convention + * (negative = money out), the same one bulk_book_transactions writes. */ + amount: number +} + +/** Input slice: an omitted amount defaults to the voucher's net line on the account. */ +export interface VoucherAllocationInput { + journal_entry_id: string + amount?: number +} + +export interface LinkTransactionToVouchersResult { + success: boolean + error?: string + /** The slices as validated, defaults resolved. Present on success and on dry runs. */ + allocations?: VoucherAllocation[] +} + +/** + * Link ONE bank transaction to SEVERAL posted verifikat (1:N): a lump payout + * covering utlägg booked per receipt, a Bankgirot deposit aggregating two + * customer payments, a Spiris-era salary voucher per employee paid in one + * transfer (issue #1553). The mirror image of the N:1 shape manualLink + * documents, and the counterpart of the invoice split in match-batch. + * + * Storage: transactions.journal_entry_id stays NULL and one + * transaction_voucher_links row per verifikat carries the signed slice + * (role 'bank_line'), exactly how bulk_book_transactions anchors a + * samlingsverifikat. Every reader that asks "is this row booked?" through + * isTransactionBooked / is_transaction_booked() therefore already sees it; + * the pointer column is never the answer for a split row. + * + * Invariants (all refused, nothing written): + * - the transaction exists in the company, is not ignored and is not booked + * (live pointer, junction row or payment row); + * - at least two distinct verifikat, each posted, in the company, with a + * line on the settlement account; + * - every slice is non-zero, has the sign of that voucher's net line on the + * account and is not larger than it (a voucher cannot absorb more of the + * bank row than it books on the account); + * - the slices sum to the transaction amount within the öre tolerance: the + * same rule match-batch enforces, and the one the reconciliation + * difference depends on (a split that does not close would hide a real + * imbalance behind a "matched" row). + * + * Write order: the optimistic-locked transactions UPDATE first (it is the + * race guard: a concurrent linker makes it match zero rows), then the + * junction rows in one insert; a failed insert rolls the UPDATE back. + */ +export async function linkTransactionToVouchers( + supabase: SupabaseClient, + companyId: string, + transactionId: string, + allocations: VoucherAllocationInput[], + userId: string, + accountNumber: string = '1930', + options: { dryRun?: boolean } = {}, +): Promise { + const journalEntryIds = allocations.map((a) => a.journal_entry_id) + if (new Set(journalEntryIds).size !== journalEntryIds.length) { + return { success: false, error: 'Samma verifikat förekommer flera gånger i fördelningen.' } + } + if (journalEntryIds.length < 2) { + return { success: false, error: 'En delning kräver minst två verifikat.' } + } + if (journalEntryIds.length > 50) { + return { success: false, error: 'En delning kan omfatta högst 50 verifikat.' } + } + + const { data: txRow, error: txError } = await supabase + .from('transactions') + .select('*, transaction_voucher_links(journal_entry_id, role)') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + if (txError || !txRow) { + return { success: false, error: 'Transaktionen kunde inte hittas.' } + } + const { transaction_voucher_links: junctionRows, ...tx } = txRow as Record & { + transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null + } + if (tx.is_ignored === true) { + return { success: false, error: 'Transaktionen är ignorerad. Återställ den innan du kopplar.' } + } + // Stricter than manualLink on purpose: a split is the whole explanation of + // the row, so ANY junction row (a residual's 'other' row included) makes it + // ineligible; the UNIQUE (transaction_id, journal_entry_id) key would refuse + // a re-anchor of that voucher anyway. + if (Array.isArray(junctionRows) && junctionRows.length > 0) { + return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' } + } + if ( + typeof tx.journal_entry_id === 'string' && + (await hasLiveJournalEntryLink(supabase, companyId, tx.journal_entry_id)) + ) { + return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' } + } + // Third anchor of isTransactionBooked: a payment row (match-invoice, + // match-batch) settles the row through invoice_payments / + // supplier_invoice_payments with the pointer left NULL. + const [{ data: invoicePayments }, { data: supplierPayments }] = await Promise.all([ + supabase + .from('invoice_payments') + .select('id') + .eq('transaction_id', transactionId) + .limit(1), + supabase + .from('supplier_invoice_payments') + .select('id') + .eq('transaction_id', transactionId) + .limit(1), + ]) + if ((invoicePayments?.length ?? 0) > 0 || (supplierPayments?.length ?? 0) > 0) { + return { success: false, error: 'Transaktionen är redan matchad mot en faktura.' } + } + + // The transaction must belong to the account being reconciled (same guard + // as manualLink). Sibling-ledger re-pointing is deliberately not offered on + // the split path: every verifikat must carry its line on this account. + if (typeof tx.cash_account_id === 'string') { + const { data: txCa } = await supabase + .from('cash_accounts') + .select('ledger_account') + .eq('id', tx.cash_account_id) + .eq('company_id', companyId) + .maybeSingle() + if (txCa?.ledger_account && txCa.ledger_account !== accountNumber) { + return { + success: false, + error: `Transaktionen hör till ${txCa.ledger_account}, inte ${accountNumber}`, + } + } + } + + const { data: entries } = await supabase + .from('journal_entries') + .select('id, status, voucher_series, voucher_number') + .eq('company_id', companyId) + .in('id', journalEntryIds) + const entryById = new Map( + ((entries ?? []) as Array<{ + id: string + status: string + voucher_series: string | null + voucher_number: number | null + }>).map((e) => [e.id, e]), + ) + const labelOf = (id: string): string => { + const e = entryById.get(id) + return e && e.voucher_number != null ? `${e.voucher_series ?? 'A'}-${e.voucher_number}` : id.slice(0, 8) + } + for (const id of journalEntryIds) { + const entry = entryById.get(id) + if (!entry) return { success: false, error: 'Verifikationen kunde inte hittas.' } + if (entry.status !== 'posted') { + return { success: false, error: `Verifikat ${labelOf(id)} är inte bokförd ännu.` } + } + } + + // Each voucher's net movement on the account, in the transaction's currency + // (the split persists a reconciliation link, so it must compare in the + // account's own unit; see ledgerLineAmountIn). + const currency = typeof tx.currency === 'string' && tx.currency ? tx.currency : 'SEK' + const { data: lines } = await supabase + .from('journal_entry_lines') + .select('journal_entry_id, debit_amount, credit_amount, currency, amount_in_currency') + .in('journal_entry_id', journalEntryIds) + .eq('account_number', accountNumber) + const netByEntry = new Map() + for (const line of (lines ?? []) as Array) { + const amount = ledgerLineAmountIn(line, currency) + const prev = netByEntry.has(line.journal_entry_id) ? netByEntry.get(line.journal_entry_id) : 0 + netByEntry.set(line.journal_entry_id, prev === null || amount === null ? null : roundOre((prev ?? 0) + amount)) + } + + const txAmount = roundOre(Number(tx.amount)) + const resolved: VoucherAllocation[] = [] + for (const input of allocations) { + const label = labelOf(input.journal_entry_id) + if (!netByEntry.has(input.journal_entry_id)) { + return { success: false, error: `Verifikat ${label} saknar rad på ${accountNumber}` } + } + const net = netByEntry.get(input.journal_entry_id) ?? null + if (net === null) { + return { success: false, error: `Verifikat ${label} saknar belopp i ${currency} på ${accountNumber}` } + } + const slice = roundOre(input.amount ?? net) + if (Math.abs(slice) < VOUCHER_LINK_AMOUNT_TOLERANCE) { + return { success: false, error: `Beloppet för verifikat ${label} får inte vara 0.` } + } + if (Math.sign(slice) !== Math.sign(net)) { + return { + success: false, + error: `Beloppet för verifikat ${label} har fel riktning: verifikatet bokför ${net} på ${accountNumber}.`, + } + } + if (Math.abs(slice) > Math.abs(net) + VOUCHER_LINK_AMOUNT_TOLERANCE) { + return { + success: false, + error: `Beloppet för verifikat ${label} (${slice}) är större än verifikatets rad på ${accountNumber} (${net}).`, + } + } + resolved.push({ journal_entry_id: input.journal_entry_id, amount: slice }) + } + const sliceSum = roundOre(resolved.reduce((sum, a) => sum + a.amount, 0)) + if (Math.abs(sliceSum - txAmount) > VOUCHER_LINK_AMOUNT_TOLERANCE) { + return { + success: false, + error: `Fördelningen (${sliceSum}) stämmer inte med transaktionens belopp (${txAmount}).`, + } + } + + if (options.dryRun) { + return { success: true, allocations: resolved } + } + + // Race guard first: the pointer is re-checked inside the write. A stale + // pointer at a reversed entry (#988) is cleared by locking on its known + // value; a free row locks on NULL. Zero rows means someone else linked the + // row between our read and this write. + const previousJournalEntryId = + typeof tx.journal_entry_id === 'string' ? tx.journal_entry_id : null + const lockUpdate = supabase + .from('transactions') + .update({ + journal_entry_id: null, + reconciliation_method: 'manual' as ReconciliationMethod, + is_business: true, + potential_journal_entry_id: null, + potential_match_method: null, + potential_match_confidence: null, + }) + .eq('id', transactionId) + .eq('company_id', companyId) + const { data: lockedRows, error: lockError } = await (previousJournalEntryId === null + ? lockUpdate.is('journal_entry_id', null) + : lockUpdate.eq('journal_entry_id', previousJournalEntryId) + ).select('id') + if (lockError) { + return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' } + } + if (!lockedRows || lockedRows.length === 0) { + return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' } + } + + const { error: insertError } = await supabase.from('transaction_voucher_links').insert( + resolved.map((a) => ({ + user_id: userId, + company_id: companyId, + transaction_id: transactionId, + journal_entry_id: a.journal_entry_id, + allocated_amount: a.amount, + role: 'bank_line', + })), + ) + if (insertError) { + // Roll the lock back so the row is exactly where it was; the junction had + // no rows for this transaction (checked above), so a blanket delete only + // removes what a partial insert may have left. + await supabase + .from('transaction_voucher_links') + .delete() + .eq('company_id', companyId) + .eq('transaction_id', transactionId) + await supabase + .from('transactions') + .update({ + journal_entry_id: previousJournalEntryId, + reconciliation_method: + typeof tx.reconciliation_method === 'string' + ? (tx.reconciliation_method as ReconciliationMethod) + : null, + is_business: typeof tx.is_business === 'boolean' ? tx.is_business : null, + }) + .eq('id', transactionId) + .eq('company_id', companyId) + log.error('linkTransactionToVouchers: junction insert failed, lock rolled back', insertError, { + companyId, + transactionId, + }) + return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' } + } + + // Behandlingshistorik (BFNAR 2013:2 kap 8): one match event for the row, + // carrying every slice, mirroring the auto-apply loop in runReconciliation. + await logMatchEvent(supabase, userId, transactionId, 'matched', { + matchMethod: 'manual', + newState: { + journal_entry_ids: resolved.map((a) => a.journal_entry_id), + allocations: resolved, + reconciliation_method: 'manual', + }, + }) + for (const a of resolved) { + try { + eventBus.emit({ + type: 'transaction.reconciled', + payload: { + transaction: tx as unknown as Transaction, + journalEntryId: a.journal_entry_id, + method: 'manual' as ReconciliationMethod, + userId, + companyId, + }, + }) + } catch { + // Non-critical + } + } + + return { success: true, allocations: resolved } +} + +export interface UnlinkReconciliationResult { + success: boolean + error?: string + /** Verifikat the row was anchored to before the unlink (pointer first, then junction rows). */ + previousJournalEntryIds?: string[] +} + /** * Remove a reconciliation link. * Only allowed when reconciliation_method IS NOT NULL (prevents unlinking categorization-created entries). + * A row with no pointer but junction rows (a 1:N split) is unlinkable too. */ export async function unlinkReconciliation( supabase: SupabaseClient, companyId: string, transactionId: string, userId: string, -): Promise<{ success: boolean; error?: string }> { +): Promise { // Fetch transaction const { data: tx, error: txError } = await supabase .from('transactions') @@ -1276,8 +1617,21 @@ export async function unlinkReconciliation( return { success: false, error: 'Transaction not found' } } + // Collected BEFORE the delete so the audit row and the caller's + // previous_journal_entry_id are never null for a split row. + const previousJournalEntryIds: string[] = tx.journal_entry_id ? [tx.journal_entry_id as string] : [] if (!tx.journal_entry_id) { - return { success: false, error: 'Transaction is not linked to any journal entry' } + const { data: junctionRows } = await supabase + .from('transaction_voucher_links') + .select('journal_entry_id') + .eq('company_id', companyId) + .eq('transaction_id', transactionId) + for (const row of (junctionRows ?? []) as Array<{ journal_entry_id: string }>) { + previousJournalEntryIds.push(row.journal_entry_id) + } + if (previousJournalEntryIds.length === 0) { + return { success: false, error: 'Transaction is not linked to any journal entry' } + } } if (!tx.reconciliation_method) { @@ -1298,8 +1652,9 @@ export async function unlinkReconciliation( return { success: false, error: 'Failed to unlink transaction' } } - // A residual booking (or a bulk-book) anchors the same transaction through - // transaction_voucher_links as well; "koppla bort" means every anchor goes. + // A residual booking, a bulk-book or a 1:N split anchors the same + // transaction through transaction_voucher_links as well; "koppla bort" + // means every anchor goes. await supabase .from('transaction_voucher_links') .delete() @@ -1308,12 +1663,13 @@ export async function unlinkReconciliation( logMatchEvent(supabase, userId, transactionId, 'unmatched', { previousState: { - journal_entry_id: tx.journal_entry_id, + journal_entry_id: previousJournalEntryIds[0] ?? null, + journal_entry_ids: previousJournalEntryIds, reconciliation_method: tx.reconciliation_method, }, }) - return { success: true } + return { success: true, previousJournalEntryIds } } /** Float tolerance for matching a bank line to a verifikat (0.5 öre). */ diff --git a/lib/transactions/__tests__/categorize-core.junction-guard.test.ts b/lib/transactions/__tests__/categorize-core.junction-guard.test.ts new file mode 100644 index 00000000..6dfa4b86 --- /dev/null +++ b/lib/transactions/__tests__/categorize-core.junction-guard.test.ts @@ -0,0 +1,111 @@ +/** + * categorizeMatchedTransaction refuses a transaction that is anchored through + * transaction_voucher_links (issue #1553). + * + * A row bulk-booked into a samlingsverifikat, or split over several verifikat + * (linkTransactionToVouchers), carries journal_entry_id = NULL: the pointer + * alone reads as "unbooked" and the categorize path would book it a second + * time. The guard is role-aware: only 'bank_line' rows are slices of the + * bank amount; a residual booking's 'other' row (lib/reconciliation/ + * residual.ts) left behind by a storno of the main verifikat must not strand + * the row (its pointer is NULL and koppla-bort refuses it too). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const mockCreateJE = vi.fn() +const mockCheckPeriodLock = vi.fn() +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: (...args: unknown[]) => mockCreateJE(...args), +})) +vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({ + reverseOrphanedJournalEntry: vi.fn(), +})) +vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({ + detectBookingDuplicate: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/transactions/inbox-underlag', () => ({ + propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ + upsertCounterpartyTemplate: vi.fn(), +})) +vi.mock('@/lib/transactions/link-journal-entry', () => ({ + hasLiveJournalEntryLink: vi.fn().mockResolvedValue(false), +})) +vi.mock('@/lib/processing-history/append', () => ({ + appendProcessingHistory: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/api/v1/check-period-lock', () => ({ + checkPeriodLock: (...args: unknown[]) => mockCheckPeriodLock(...args), +})) + +import { categorizeMatchedTransaction } from '../categorize-core' + +const TX_ID = '00000000-0000-4000-8000-0000000000aa' + +const txRow = (links: Array<{ journal_entry_id: string; role: string }>) => ({ + id: TX_ID, + company_id: 'company-1', + date: '2026-06-11', + amount: -800, + currency: 'SEK', + amount_sek: -800, + exchange_rate: 1, + description: 'UTBETALNING', + merchant_name: null, + cash_account_id: null, + document_id: null, + journal_entry_id: null, + transaction_voucher_links: links, +}) + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockCreateJE.mockResolvedValue(null) +}) + +describe('categorizeMatchedTransaction: transaction_voucher_links guard (#1553)', () => { + it('returns 409 and writes nothing for a row split over several verifikat (bank_line rows, NULL pointer)', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ + data: txRow([ + { journal_entry_id: 'je-utlagg-1', role: 'bank_line' }, + { journal_entry_id: 'je-utlagg-2', role: 'bank_line' }, + ]), + }) + + const result = await categorizeMatchedTransaction(supabase as never, 'user-1', 'company-1', TX_ID, { + category: 'expense_other', + }) + + expect(result.status).toBe(409) + expect(result.error).toMatch(/already has a journal entry/) + expect(findCalls('transactions', 'update')).toEqual([]) + expect(mockCreateJE).not.toHaveBeenCalled() + // The read carried the junction rows along: no second query was needed. + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('lets a row carrying only a residual "other" row past the guard', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: txRow([{ journal_entry_id: 'je-residual', role: 'other' }]) }) + enqueue({ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }) // company_settings + enqueue({ data: [] }) // resolveSettlementAccount + enqueue({ data: [] }) // ensureFiscalPeriod: no open period + enqueue({ data: [{ period_start: '2026-01-01' }] }) + enqueue({ data: null }) + mockCheckPeriodLock.mockResolvedValue({ locked: true, reason: 'period_is_closed', fiscal_period_id: 'fp-2026' }) + + const result = await categorizeMatchedTransaction(supabase as never, 'user-1', 'company-1', TX_ID, { + category: 'expense_other', + }) + + // Reached the engine (which fails closed on the locked period here): + // the junction guard did not fire. + expect(result.status).not.toBe(409) + expect(mockCreateJE).toHaveBeenCalled() + }) +}) diff --git a/lib/transactions/__tests__/link-journal-entry.test.ts b/lib/transactions/__tests__/link-journal-entry.test.ts index 6d4e984d..d94d09b5 100644 --- a/lib/transactions/__tests__/link-journal-entry.test.ts +++ b/lib/transactions/__tests__/link-journal-entry.test.ts @@ -13,7 +13,7 @@ */ import { describe, it, expect } from 'vitest' import { createQueuedMockSupabase } from '@/tests/helpers' -import { hasLiveJournalEntryLink } from '../link-journal-entry' +import { hasLiveJournalEntryLink, linkTransactionToJournalEntry } from '../link-journal-entry' describe('hasLiveJournalEntryLink', () => { it('returns false for a null/undefined pointer without querying', async () => { @@ -52,3 +52,56 @@ describe('hasLiveJournalEntryLink', () => { expect(await hasLiveJournalEntryLink(supabase as never, 'company-1', 'je-1')).toBe(true) }) }) + +describe('linkTransactionToJournalEntry: junction-row guard (#1553)', () => { + const base = { + id: 'tx-1', + date: '2026-06-11', + amount: -800, + currency: 'SEK', + exchange_rate: null, + journal_entry_id: null, + invoice_id: null, + is_business: null, + potential_invoice_id: null, + potential_supplier_invoice_id: null, + } + + it('refuses a transaction anchored through a bank_line junction row (1:N split, bulk-book) even though the pointer is NULL', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ + data: { ...base, transaction_voucher_links: [{ journal_entry_id: 'je-split-a', role: 'bank_line' }] }, + error: null, + }) + + const outcome = await linkTransactionToJournalEntry(supabase as never, 'user-1', 'company-1', { + transactionId: 'tx-1', + journalEntryId: 'je-other', + }) + + expect(outcome).toEqual({ + ok: false, + code: 'LINK_TX_TX_ALREADY_LINKED', + details: { existingJournalEntryId: 'je-split-a' }, + }) + expect(findCalls('transactions', 'update')).toEqual([]) + }) + + it('lets a residual booking\'s "other" row through: the row must stay re-linkable after a storno of its main verifikat', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { ...base, transaction_voucher_links: [{ journal_entry_id: 'je-residual', role: 'other' }] }, + error: null, + }) + enqueue({ data: null, error: { message: 'not found' } }) // the verifikat lookup that follows the guard + + const outcome = await linkTransactionToJournalEntry(supabase as never, 'user-1', 'company-1', { + transactionId: 'tx-1', + journalEntryId: 'je-other', + }) + + expect(outcome.ok).toBe(false) + expect((outcome as { code: string }).code).not.toBe('LINK_TX_TX_ALREADY_LINKED') + expect(supabase.from).toHaveBeenCalledWith('journal_entries') + }) +}) diff --git a/lib/transactions/categorize-core.ts b/lib/transactions/categorize-core.ts index 19a77602..92ba1cd3 100644 --- a/lib/transactions/categorize-core.ts +++ b/lib/transactions/categorize-core.ts @@ -43,6 +43,7 @@ import { type BookingDuplicateExclusions, } from '@/lib/transactions/booking-duplicate-detection' import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' +import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' import { appendProcessingHistory } from '@/lib/processing-history/append' import { createLogger } from '@/lib/logger' @@ -248,12 +249,25 @@ export async function categorizeMatchedTransaction( ): Promise { const { category, vatTreatment, vatAmount, notes, allowDuplicate, dimensions, accountOverride } = opts - const { data: transaction, error: fetchError } = await supabase - .from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single() + // The junction rows ride along on the same read: a row bulk-booked into a + // samlingsverifikat or split over several verifikat (1:N, #1553) carries + // journal_entry_id = NULL, and the pointer alone would let it be booked a + // second time. Only 'bank_line' rows count (hasBankLineJunctionRow): a + // residual's 'other' row left behind by a storno must stay re-bookable. + const { data: transactionRow, error: fetchError } = await supabase + .from('transactions') + .select('*, transaction_voucher_links(journal_entry_id, role)') + .eq('id', txId) + .eq('company_id', companyId) + .single() - if (fetchError || !transaction) { + if (fetchError || !transactionRow) { return { error: 'Transaction not found: it may have been deleted.', status: 404 } } + const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow + if (hasBankLineJunctionRow(junctionLinks)) { + return { error: 'Transaction already has a journal entry: it was categorized in the meantime.', status: 409 } + } // A stale pointer at a 'reversed' entry (storno/correction left it behind) // must not block re-categorization: the row reads as "utan koppling" in the // UI, so a fresh booking has to be allowed (issue #988). Only a live posted diff --git a/lib/transactions/is-booked.ts b/lib/transactions/is-booked.ts index ae6bfc7e..aec3de5e 100644 --- a/lib/transactions/is-booked.ts +++ b/lib/transactions/is-booked.ts @@ -85,3 +85,22 @@ export function getPrimaryJournalEntryId( const payment = payments.find((p) => p.transaction_id === tx.id && p.journal_entry_id != null) return payment?.journal_entry_id ?? null } + +/** + * The re-booking guards' narrower question: does an embedded + * transaction_voucher_links set hold a 'bank_line' row? A bank_line row is a + * slice of the row's bank amount (the bulk-book samlingsverifikat, the 1:N + * split of issue #1553), so its presence means the row is booked and a + * second booking (manualLink, categorize, link-journal-entry) must refuse. + * Rows with role 'other' (a residual booking, lib/reconciliation/residual.ts) + * or 'clearing' are supplementary anchors: after a storno of the main + * verifikat nulls the pointer, that leftover row must not strand the + * transaction with no way to re-book it. The list readers (fetchJunction- + * LinkedTxIds, is_transaction_booked()) keep counting every role. + */ +export function hasBankLineJunctionRow( + rows: Array<{ role?: string | null }> | null | undefined, +): boolean { + if (!Array.isArray(rows)) return false + return rows.some((row) => (row.role ?? 'bank_line') === 'bank_line') +} diff --git a/lib/transactions/link-journal-entry.ts b/lib/transactions/link-journal-entry.ts index e9870e8c..281007dc 100644 --- a/lib/transactions/link-journal-entry.ts +++ b/lib/transactions/link-journal-entry.ts @@ -19,6 +19,7 @@ import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-inv import { paidAtFromDate } from '@/lib/invoices/paid-at' import { logMatchEvent } from '@/lib/invoices/match-log' import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' +import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' import { createLogger } from '@/lib/logger' import type { Invoice, Transaction } from '@/types' @@ -131,18 +132,34 @@ export async function linkTransactionToJournalEntry( // Data minimization (GDPR Art.5(1)(c)): pull only the columns needed for // validation, optimistic-lock invoice update, invoice_payments insert, and // the compensating-rollback path. No select('*'). - const { data: transaction, error: fetchTxError } = await supabase + // transaction_voucher_links rides along on the same read: a row bulk-booked + // into a samlingsverifikat or split over several verifikat (1:N, #1553) + // carries journal_entry_id = NULL and must still refuse a second link. Only + // 'bank_line' rows count (hasBankLineJunctionRow): a residual's 'other' row + // left behind by a storno must stay re-linkable. + const { data: transactionRow, error: fetchTxError } = await supabase .from('transactions') .select( - 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id' + 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id, transaction_voucher_links(journal_entry_id, role)' ) .eq('id', transactionId) .eq('company_id', companyId) .single() - if (fetchTxError || !transaction) { + if (fetchTxError || !transactionRow) { return { ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' } } + const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as typeof transactionRow & { + transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null + } + if (hasBankLineJunctionRow(junctionLinks)) { + const bankLine = junctionLinks!.find((row) => (row.role ?? 'bank_line') === 'bank_line')! + return { + ok: false, + code: 'LINK_TX_TX_ALREADY_LINKED', + details: { existingJournalEntryId: bankLine.journal_entry_id }, + } + } // Only a LIVE (posted) pointer blocks re-linking. A pointer left behind by a // storno/correction references a 'reversed' entry: the UI already shows the diff --git a/messages/en.json b/messages/en.json index 7ccf2243..e055833e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8235,6 +8235,11 @@ "match_residual_label": "Book the remainder as", "match_residual_pick": "Pick a kind", "match_residual_apply": "Book {amount} and link", + "match_pick_vouchers": "One event selected: tick the vouchers that together cover the amount.", + "match_selected_entries": "{count} vouchers: {amount}", + "match_hint_split": "The difference must be 0 to split an event over several vouchers.", + "match_apply_split": "Link to {count} vouchers", + "toast_split_matched": "Event split over {count} vouchers", "residual_bank_fee": "Bank fee (6570)", "residual_interest_expense": "Interest expense (8410)", "residual_interest_income": "Interest income (8310)", diff --git a/messages/sv.json b/messages/sv.json index 58b36762..7f0c04ce 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -8235,6 +8235,11 @@ "match_residual_label": "Bokför mellanskillnaden som", "match_residual_pick": "Välj typ", "match_residual_apply": "Bokför {amount} och koppla", + "match_pick_vouchers": "En händelse vald: bocka i de verifikat som tillsammans täcker beloppet.", + "match_selected_entries": "{count} verifikat: {amount}", + "match_hint_split": "Differensen måste vara 0 för att dela en händelse på flera verifikat.", + "match_apply_split": "Koppla mot {count} verifikat", + "toast_split_matched": "Händelsen delad på {count} verifikat", "residual_bank_fee": "Bankavgift (6570)", "residual_interest_expense": "Räntekostnad (8410)", "residual_interest_income": "Ränteintäkt (8310)", diff --git a/skills/accounted-api/references/banking.md b/skills/accounted-api/references/banking.md index 14d99df8..ed69b265 100644 --- a/skills/accounted-api/references/banking.md +++ b/skills/accounted-api/references/banking.md @@ -509,13 +509,13 @@ Example response `200`: **Link outside rows to existing verifikat (pairs or proposals).** `scope:reconciliation:write · risk:medium · dry-run · reversible` -Body: { pairs: [{ external_ids: [id], journal_entry_ids: [id] }] } and/or { use_proposals: true, confidence_threshold? }. Each pair is validated as the single-link paths validate (row open and not ignored, entry posted and not reversed, the entry's account lines settle the amount, entry not already linked) and applied independently: the response lists applied[] and skipped[{pair, code, message}] so partial success is explicit. Codes: UNSUPPORTED_PAIR_SHAPE, ALREADY_LINKED, ENTRY_NOT_FOUND, PAIR_NOT_CLOSED, ROW_IGNORED, NOT_FOUND, LINK_RACE. ?dry_run=true returns the pairs that would be attempted without writing. +Body: { pairs: [{ external_ids: [id], journal_entry_ids: [id], allocations? }] } and/or { use_proposals: true, confidence_threshold? }. Each pair is validated as the single-link paths validate (row open and not ignored, entry posted and not reversed, the entry's account lines settle the amount, entry not already linked) and applied independently: the response lists applied[] and skipped[{pair, code, message}] so partial success is explicit. On a bank account a pair may also be ONE transaction against SEVERAL verifikat (1:N): allocations[{journal_entry_id, amount}] gives the signed slice per verifikat (omitted: each slice defaults to the voucher's line on the account); the slices must sum to the transaction amount, and each applied link then carries allocated_amount. Codes: UNSUPPORTED_PAIR_SHAPE, ALREADY_LINKED, ENTRY_NOT_FOUND, PAIR_NOT_CLOSED, ROW_IGNORED, NOT_FOUND, LINK_RACE. ?dry_run=true returns the pairs that would be attempted without writing (a 1:N dry run resolves the slices). **Use when:** An agent or integration has decided which rows explain each other, or wants to apply the proposals the sync already computed. **Do not use for:** Booking new verifikat for rows that have no counterpart (use the transactions or skattekonto booking endpoints); reconciling across accounts. **Pitfalls:** -- A pair is one OR MANY outside rows against exactly one verifikat (bank: independent links per transaction; skattekonto: all-or-nothing, the rows must sum to what the verifikat settles). One row against several verifikat is UNSUPPORTED_PAIR_SHAPE until residual booking lands, never silently reduced. +- A pair is one OR MANY outside rows against exactly one verifikat (bank: independent links per transaction; skattekonto: all-or-nothing, the rows must sum to what the verifikat settles), or, on a bank account only, ONE transaction against SEVERAL verifikat (all-or-nothing, the slices must sum to the transaction). Several rows against several verifikat, and a skattekonto row against several verifikat, are UNSUPPORTED_PAIR_SHAPE, never silently reduced. - A pair must close to the row's amount on the expected side (a single matching line, or the entry's lines on the account netting to it); a fee or rounding difference is PAIR_NOT_CLOSED here and needs a residual booking first. - Links never touch the ledger, so they succeed in locked periods; unlink with DELETE .../links/{linkId} (linkId = the outside row id). - Idempotency-Key is required; repeating the same key replays the first response. @@ -528,7 +528,7 @@ Body: { pairs: [{ external_ids: [id], journal_entry_ids: [id] }] } and/or { use_ Request body: ```ts { - pairs?: { external_ids: string[], journal_entry_ids: string[] }[], + pairs?: { external_ids: string[], journal_entry_ids: string[], allocations?: { journal_entry_id: string, amount: number }[] }[], use_proposals?: boolean, confidence_threshold?: number } @@ -548,8 +548,8 @@ Response `200`: data: { dry_run: boolean, considered: number, - applied: { external_id: string, journal_entry_id: string, via?: "line" | "entry_total" }[], - skipped: { pair: { external_ids: string[], journal_entry_ids: string[] }, code: string, message: string }[] + applied: { external_id: string, journal_entry_id: string, via?: "line" | "entry_total", allocated_amount?: number }[], + skipped: { pair: { external_ids: string[], journal_entry_ids: string[], allocations?: { journal_entry_id: {...}, amount: {...} }[] }, code: string, message: string }[] }, meta: { request_id: string, diff --git a/tests/pg/gl-lines-voucher-links.pg.test.ts b/tests/pg/gl-lines-voucher-links.pg.test.ts index 23f63874..dd0044a9 100644 --- a/tests/pg/gl-lines-voucher-links.pg.test.ts +++ b/tests/pg/gl-lines-voucher-links.pg.test.ts @@ -16,12 +16,21 @@ async function linkThroughJunction(params: { transactionId: string journalEntryId: string amount: number + role?: 'bank_line' | 'other' }): Promise { 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], + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + randomUUID(), + params.userId, + params.companyId, + params.transactionId, + params.journalEntryId, + params.amount, + params.role ?? 'other', + ], ) } @@ -115,4 +124,99 @@ describe('GL line RPCs treat transaction_voucher_links as links', () => { ) expect(withMatched.rows.find((r) => r.journal_entry_id === entry)?.linked_transaction_count).toBe(1) }) + + it('treats ONE transaction split over TWO verifikat (1:N, #1553) as a link on each, counted once per verifikat', async () => { + // A lump payout of -800 settles two utlägg verifikat booked per receipt + // (-500 and -300 on 1930). linkTransactionToVouchers leaves the pointer + // NULL and writes one bank_line slice per verifikat; the GL RPCs must see + // both verifikat as matched, each with exactly one linked transaction, + // and the pointer-based reader must agree through is_transaction_booked(). + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1930' }) + + const controlEntry = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + entryDate: '2026-06-09', + description: 'Utan koppling', + lines: [ + { accountNumber: '6570', debitAmount: 45, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: 45 }, + ], + }) + const utlagg1 = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + entryDate: '2026-06-10', + description: 'Utlägg kvitto 1', + lines: [ + { accountNumber: '5410', debitAmount: 500, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: 500 }, + ], + }) + const utlagg2 = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + entryDate: '2026-06-10', + description: 'Utlägg kvitto 2', + lines: [ + { accountNumber: '6110', debitAmount: 300, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: 300 }, + ], + }) + const txId = await insertTransaction({ + companyId, + userId, + amount: -800, + date: '2026-06-11', + cashAccountId, + journalEntryId: null, + }) + await linkThroughJunction({ userId, companyId, transactionId: txId, journalEntryId: utlagg1, amount: -500, role: 'bank_line' }) + await linkThroughJunction({ userId, companyId, transactionId: txId, journalEntryId: utlagg2, amount: -300, role: 'bank_line' }) + + 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(controlEntry) + expect(unlinkedIds).not.toContain(utlagg1) + expect(unlinkedIds).not.toContain(utlagg2) + + const candidates = await getPool().query<{ journal_entry_id: string }>( + `SELECT journal_entry_id 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(controlEntry) + expect(candidateIds).not.toContain(utlagg1) + expect(candidateIds).not.toContain(utlagg2) + + 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 === utlagg1)?.linked_transaction_count).toBe(1) + expect(withMatched.rows.find((r) => r.journal_entry_id === utlagg2)?.linked_transaction_count).toBe(1) + expect(withMatched.rows.find((r) => r.journal_entry_id === controlEntry)?.linked_transaction_count).toBe(0) + + // The slices explain the whole row, and the canonical predicate agrees. + const slices = await getPool().query<{ total: string }>( + `SELECT sum(allocated_amount)::text AS total + FROM public.transaction_voucher_links + WHERE transaction_id = $1 AND role = 'bank_line'`, + [txId], + ) + expect(Number(slices.rows[0].total)).toBe(-800) + const booked = await getPool().query<{ booked: boolean }>( + `SELECT public.is_transaction_booked($1) AS booked`, + [txId], + ) + expect(booked.rows[0].booked).toBe(true) + }) })