From 0a8544e0cbf716159d1df79979109e6ac24fccf2 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 24 Aug 2026 13:55:08 +0200 Subject: [PATCH] feat(reconciliation): account-keyed engine: one bridge for bank and skattekonto (#1813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank: | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../skattekonto-match-one-to-one.test.ts | 132 ++++ .../__tests__/skattekonto-proposals.test.ts | 77 +++ .../skatteverket/lib/skattekonto-drift.ts | 39 +- .../skatteverket/lib/skattekonto-match.ts | 130 +++- .../skatteverket/lib/skattekonto-proposals.ts | 112 ++++ .../skatteverket/lib/skattekonto-sync.ts | 9 + .../__tests__/gl-balance.test.ts | 64 ++ lib/reconciliation/__tests__/service.test.ts | 240 +++++++ .../skattekonto-reconciliation.test.ts | 309 +++++++++ lib/reconciliation/gl-balance.ts | 81 +++ lib/reconciliation/schemas.ts | 200 ++++++ lib/reconciliation/service.ts | 360 +++++++++++ .../skattekonto-reconciliation.ts | 589 ++++++++++++++++++ scripts/checks/antipatterns-baseline.json | 2 +- ...o_transactions_suggested_journal_entry.sql | 38 ++ types/skatteverket.ts | 7 + 17 files changed, 2328 insertions(+), 62 deletions(-) create mode 100644 extensions/general/skatteverket/__tests__/skattekonto-match-one-to-one.test.ts create mode 100644 extensions/general/skatteverket/__tests__/skattekonto-proposals.test.ts create mode 100644 extensions/general/skatteverket/lib/skattekonto-proposals.ts create mode 100644 lib/reconciliation/__tests__/gl-balance.test.ts create mode 100644 lib/reconciliation/__tests__/service.test.ts create mode 100644 lib/reconciliation/__tests__/skattekonto-reconciliation.test.ts create mode 100644 lib/reconciliation/gl-balance.ts create mode 100644 lib/reconciliation/schemas.ts create mode 100644 lib/reconciliation/service.ts create mode 100644 lib/reconciliation/skattekonto-reconciliation.ts create mode 100644 supabase/migrations/20260823120000_skattekonto_transactions_suggested_journal_entry.sql diff --git a/DECISIONS.md b/DECISIONS.md index 108b0b73..8d44cef7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1176,3 +1176,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Assistant empty answer is a typed failure (EmptyModelAnswerError, 502 'Assistenten gav inget svar. Försök igen.'), one manual retry, NO auto-retry: the silent 200 with an empty answer was the 'Tänker then nothing' bug after the RIP-3 cutover (1500-token cap on /api/agent/ask), and auto-retry would double model spend while hiding the regression. Related deliberate request-shape change: anthropic-family's step-exhausted fallback now keeps tools with tool_choice none, because replaying a transcript containing tool_use/tool_result without declaring tools is rejected by the Messages API (the old shape 400ed every step-exhausted answer). general.help stays on the single-call runtime (founder decision); the fix raises headroom to 5400 tokens and surfaces the failure instead. [2026-08-24] Issue #1820 self-billed credit fix: creditConfirmNumber()/originalRef fall back invoice_number -> external_invoice_number (typed 400 INVOICE_CREDIT_NO_NUMBER if both null) instead of relaxing the DB numbering constraint or dropping the type-the-number confirm step; the confirm step stays (dropping it is a founder call). The invoice-date Forval chip surfaces in ALL editor modes, not only self-billed: the silent today-default exists in every mode and the chip line already carries the due date. In self-billed mode fakturadatum + mottagningsdatum render uncollapsed next to the external number (transcription fields, not defaults); the panel rows are hidden there because registering the same RHF field twice desyncs the inputs. The v1 credit route's existing id-slice fallback was left unchanged (public API behavior). [2026-08-24] No-IBAN reconnect pairing (issue #1709) uses only per-currency exactly-one-each-side elimination, deliberately WITHOUT name equality: ASPSPs reformat product names between consents, so requiring it would silently disable the fix for the banks that need it, while the one-per-currency guard already bounds a mis-pair to skipping rows whose account+date+amount+occurrence all collide. upsertFromPsd2 needed no change: its explicit reuse_cash_account_id promote path already covers a same-connection holder, so the fix only names the paired row from the callback. +[2026-08-23] Reconciliation engine (PR 1): the skattekonto status engine lives in core lib/reconciliation (reads the core table + the extension snapshot row in extension_data directly) instead of in the skatteverket extension: core must never import @/extensions/*, and the reconciliation facade must work with zero extensions; the matcher stays in the extension and writes its proposals to the row at sync time. Proposals are propose-only (suggested_journal_entry_id is never a link); the same per-entry one-to-one assignment replaces per-row "exactly one candidate". Ledger balances everywhere in reconciliation use the trial-balance predicate status IN (posted, reversed): the drift check summed posted only, which misstated 1630 for every company with a storno on the account. diff --git a/extensions/general/skatteverket/__tests__/skattekonto-match-one-to-one.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-match-one-to-one.test.ts new file mode 100644 index 00000000..3d7957be --- /dev/null +++ b/extensions/general/skatteverket/__tests__/skattekonto-match-one-to-one.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { findMatchSuggestionsBulk } from '../lib/skattekonto-match' + +const COMPANY = 'company-1' + +function lineRow(opts: { + entryId: string + debit?: number + credit?: number + entryDate?: string + voucherNumber?: number +}) { + return { + debit_amount: opts.debit ?? 0, + credit_amount: opts.credit ?? 0, + journal_entries: { + id: opts.entryId, + voucher_number: opts.voucherNumber ?? 1, + voucher_series: 'A', + entry_date: opts.entryDate ?? '2026-08-11', + description: `Verifikat ${opts.entryId}`, + status: 'posted' as const, + company_id: COMPANY, + }, + } +} + +/** Two-step entry-lines pages: parents first, then the lines keyed by entry id. */ +function enqueueLines( + enqueue: (r: { data?: unknown; error?: unknown }) => void, + rows: ReturnType[], +) { + const entries = [...new Map(rows.map((r) => [r.journal_entries.id, r.journal_entries])).values()] + enqueue({ data: entries }) + if (entries.length === 0) return + enqueue({ + data: rows.map((r, i) => ({ + id: `line-${String(i).padStart(4, '0')}`, + journal_entry_id: r.journal_entries.id, + debit_amount: r.debit_amount, + credit_amount: r.credit_amount, + })), + }) +} + +function row(id: string, belopp: number, datum = '2026-08-12', text = 'Inbetalning bokförd') { + return { id, transaktionsdatum: datum, transaktionstext: text, belopp_skatteverket: belopp, journal_entry_id: null } +} + +describe('findMatchSuggestionsBulk: one-to-one assignment and split-line fallback', () => { + it('never proposes the same verifikat to two rows', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueLines(enqueue, [lineRow({ entryId: 'E1', debit: 5000 })]) + enqueue({ data: [] }) // already-linked check + + const out = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + row('r1', 5000), + row('r2', 5000), + ]) + + expect(out.size).toBe(1) + expect(out.get('r1')?.journal_entry_id).toBe('E1') + expect(out.has('r2')).toBe(false) + }) + + it('assigns the nearer-dated row first when rows compete for one entry', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueLines(enqueue, [lineRow({ entryId: 'E1', debit: 5000, entryDate: '2026-08-20' })]) + enqueue({ data: [] }) + + const out = await findMatchSuggestionsBulk(supabase as never, COMPANY, [ + row('r-early', 5000, '2026-08-10'), + row('r-late', 5000, '2026-08-19'), + ]) + + // Rows are assigned in date order; the earlier row claims the only candidate. + expect(out.get('r-early')?.journal_entry_id).toBe('E1') + expect(out.has('r-late')).toBe(false) + }) + + it('proposes an entry whose 1630 lines net to the amount when no single line does', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueLines(enqueue, [ + lineRow({ entryId: 'E2', debit: 3000 }), + lineRow({ entryId: 'E2', debit: 2000 }), + ]) + enqueue({ data: [] }) + + const out = await findMatchSuggestionsBulk(supabase as never, COMPANY, [row('r1', 5000)]) + + expect(out.get('r1')).toMatchObject({ + journal_entry_id: 'E2', + matched_via_entry_total: true, + matched_amount: 5000, + matched_side: 'debit', + }) + }) + + it('a single-line exact match outranks a split-line one', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueLines(enqueue, [ + lineRow({ entryId: 'E1', debit: 5000 }), + lineRow({ entryId: 'E2', debit: 3000 }), + lineRow({ entryId: 'E2', debit: 2000 }), + ]) + enqueue({ data: [] }) + + const out = await findMatchSuggestionsBulk(supabase as never, COMPANY, [row('r1', 5000)]) + expect(out.get('r1')?.journal_entry_id).toBe('E1') + expect(out.get('r1')?.matched_via_entry_total).toBe(false) + }) + + it('two exact candidates for one row stay ambiguous (no proposal)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueLines(enqueue, [lineRow({ entryId: 'E1', debit: 5000 }), lineRow({ entryId: 'E2', debit: 5000 })]) + enqueue({ data: [] }) + + const out = await findMatchSuggestionsBulk(supabase as never, COMPANY, [row('r1', 5000)]) + expect(out.size).toBe(0) + }) + + it('a credit-side row matches credit lines only', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueLines(enqueue, [lineRow({ entryId: 'E1', debit: 5447 }), lineRow({ entryId: 'E2', credit: 5447 })]) + enqueue({ data: [] }) + + const out = await findMatchSuggestionsBulk(supabase as never, COMPANY, [row('r1', -5447)]) + expect(out.get('r1')?.journal_entry_id).toBe('E2') + expect(out.get('r1')?.matched_side).toBe('credit') + }) +}) diff --git a/extensions/general/skatteverket/__tests__/skattekonto-proposals.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-proposals.test.ts new file mode 100644 index 00000000..23746783 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/skattekonto-proposals.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const bulkMock = vi.fn() +vi.mock('../lib/skattekonto-match', () => ({ + findMatchSuggestionsBulk: (...args: unknown[]) => bulkMock(...args), +})) + +import { refreshSkattekontoProposals } from '../lib/skattekonto-proposals' + +const COMPANY = 'company-1' + +function openRow(id: string, suggested: string | null = null) { + return { + id, + transaktionsdatum: '2026-08-12', + transaktionstext: 'Inbetalning bokförd', + belopp_skatteverket: 30000, + journal_entry_id: null, + suggested_journal_entry_id: suggested, + } +} + +describe('refreshSkattekontoProposals', () => { + beforeEach(() => { + vi.clearAllMocks() + bulkMock.mockReset() + }) + + it('writes new proposals, clears stale ones and leaves unchanged rows alone', async () => { + const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase() + enqueue({ data: [openRow('r-new'), openRow('r-stale', 'E-old'), openRow('r-same', 'E-same')] }) + bulkMock.mockResolvedValue( + new Map([ + ['r-new', { journal_entry_id: 'E-new' }], + ['r-same', { journal_entry_id: 'E-same' }], + ]), + ) + enqueue({ data: null }) // update r-new + enqueue({ data: null }) // update r-stale + + const result = await refreshSkattekontoProposals(supabase as never, COMPANY) + + expect(result).toEqual({ considered: 3, proposed: 1, cleared: 1, unchanged: 1 }) + const updates = findCalls('skattekonto_transactions', 'update') + expect(updates).toHaveLength(2) + expect(updates[0][0]).toMatchObject({ suggested_journal_entry_id: 'E-new' }) + expect((updates[0][0] as { suggested_at: string | null }).suggested_at).toBeTruthy() + expect(updates[1][0]).toEqual({ suggested_journal_entry_id: null, suggested_at: null }) + // Only still-open rows may receive a proposal: the write is guarded on journal_entry_id IS NULL. + const isCalls = calls.filter((c) => c.table === 'skattekonto_transactions' && c.method === 'is') + expect(isCalls.every((c) => c.args[0] === 'journal_entry_id' && c.args[1] === null)).toBe(true) + // Only open, non-ignored, SKV-posted rows are considered. + expect(findCalls('skattekonto_transactions', 'eq')).toContainEqual(['status', 'booked']) + expect(findCalls('skattekonto_transactions', 'eq')).toContainEqual(['is_ignored', false]) + }) + + it('returns zeros and never throws when the row read fails', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ error: { message: 'boom' } }) + await expect(refreshSkattekontoProposals(supabase as never, COMPANY)).resolves.toEqual({ + considered: 0, + proposed: 0, + cleared: 0, + unchanged: 0, + }) + expect(bulkMock).not.toHaveBeenCalled() + }) + + it('skips the matcher entirely when there are no open rows', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [] }) + const result = await refreshSkattekontoProposals(supabase as never, COMPANY) + expect(result.considered).toBe(0) + expect(bulkMock).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/skatteverket/lib/skattekonto-drift.ts b/extensions/general/skatteverket/lib/skattekonto-drift.ts index 871740cf..7630fdd9 100644 --- a/extensions/general/skatteverket/lib/skattekonto-drift.ts +++ b/extensions/general/skatteverket/lib/skattekonto-drift.ts @@ -3,7 +3,7 @@ import type { ExtensionContext } from '@/lib/extensions/types' import type { SkattekontoBalanceSnapshot } from '../types' import { SKATTEKONTO_BALANCE_SNAPSHOT_KEY } from './skattekonto-sync' import { createLogger } from '@/lib/logger' -import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' +import { sumAccountBalance } from '@/lib/reconciliation/gl-balance' const log = createLogger('skattekonto-drift') @@ -152,42 +152,19 @@ export async function maybeAlertDrift( * the read fails: 0 is a real balance claim ("nothing booked on 1630"), and * substituting it for a failed read turns every transient DB blip into a * full-saldo drift alert. + * + * Delegates to the core ledger-balance helper so the drift uses the SAME + * status predicate as the trial balance and the bank reconciliation + * (posted + reversed). Summing 'posted' alone excluded a stornoed original + * while counting its reversal, which misstated 1630 by the reversed amount + * for every company with a storno on the account (fixed 2026-08-23). */ async function sumGl1630( supabase: SupabaseClient, companyId: string, cutoffDate: string, ): Promise { - // Driven from the journal_entries side (lib/bookkeeping/entry-lines.ts): - // the scope filters used to sit on a `journal_entries!inner` embed, which - // PostgREST compiles into a correlated LATERAL join that walks the ENTIRE - // journal_entry_lines table across all tenants. Both steps paginate, so a - // company with more than 1000 skattekonto lines is no longer silently - // truncated (which would have under-reported the drift). - let data: Array<{ debit_amount: number | string; credit_amount: number | string }> - try { - data = await fetchEntryLines({ - supabase, - lineColumns: 'debit_amount, credit_amount', - filterEntries: (q: EntryLinesQuery) => - q.eq('company_id', companyId).eq('status', 'posted').lte('entry_date', cutoffDate), - filterLines: (q: EntryLinesQuery) => q.eq('account_number', SKATTEKONTO_BAS_ACCOUNT), - attachEntriesAs: null, - }) - } catch (err) { - log.warn('sumGl1630 failed', { - companyId, - cutoffDate, - error: err instanceof Error ? err.message : String(err), - }) - return null - } - - let sum = 0 - for (const row of data) { - sum += Number(row.debit_amount || 0) - Number(row.credit_amount || 0) - } - return Math.round(sum * 100) / 100 + return sumAccountBalance(supabase, companyId, SKATTEKONTO_BAS_ACCOUNT, { cutoffDate }) } async function listUnbookedRows( diff --git a/extensions/general/skatteverket/lib/skattekonto-match.ts b/extensions/general/skatteverket/lib/skattekonto-match.ts index 00fd860c..69a118c0 100644 --- a/extensions/general/skatteverket/lib/skattekonto-match.ts +++ b/extensions/general/skatteverket/lib/skattekonto-match.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { StoredSkattekontoTransaction } from '../types' import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' +import { roundOre } from '@/lib/money' import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill' /** @@ -59,6 +60,12 @@ export interface SkattekontoMatchCandidate { * show a "period-matched" badge. */ matched_via_agi_period?: boolean + /** + * True when no single 1630 line equals the amount but the entry's 1630 + * lines NET to it (a manual voucher that split the movement over two + * lines). The link still settles the whole entry, so the pair closes. + */ + matched_via_entry_total?: boolean } /** Swedish month names exactly as SKV writes them in prod transaktionstext. */ @@ -353,11 +360,55 @@ export async function findMatchSuggestionsBulk( } const agiIndex = await loadAgiEntryIndex(supabase, companyId, periods) - const suggestions = new Map() + // Per-entry view of the 1630 movement: which single lines exist, and what + // the entry nets to. The net is the entry-level fallback for a manual + // voucher that split one SKV event over two 1630 lines. + type EntryView = { + entry: Row['journal_entries'] + debits: number[] + credits: number[] + net: number + lineCount: number + } + const entryViews = new Map() + for (const line of lines) { + const e = line.journal_entries + if (linkedSet.has(e.id)) continue + const debit = roundOre(Number(line.debit_amount)) + const credit = roundOre(Number(line.credit_amount)) + let view = entryViews.get(e.id) + if (!view) { + view = { entry: e, debits: [], credits: [], net: 0, lineCount: 0 } + entryViews.set(e.id, view) + } + if (debit > 0 && credit === 0) view.debits.push(debit) + if (credit > 0 && debit === 0) view.credits.push(credit) + view.net = roundOre(view.net + debit - credit) + view.lineCount++ + } - for (const row of unmatched) { + // Candidates per row, then a one-to-one assignment across rows: two rows + // that each see "exactly one candidate" must not both be proposed the same + // verifikat (12 same-day-same-amount groups on prod would have done that). + // Rows are assigned in date order; AGI-period matches win inside a row. + const ordered = [...unmatched].sort((a, b) => + a.transaktionsdatum < b.transaktionsdatum + ? -1 + : a.transaktionsdatum > b.transaktionsdatum + ? 1 + : a.id < b.id + ? -1 + : a.id > b.id + ? 1 + : 0, + ) + const candidatesByRow = new Map() + const periodIdsByRow = new Map | null>() + + for (const row of ordered) { const amount = Math.round(Math.abs(Number(row.belopp_skatteverket)) * 100) / 100 const side = expectedSide(Number(row.belopp_skatteverket)) + const signedNet = side === 'debit' ? amount : -amount const rowFrom = addDays(row.transaktionsdatum, -DATE_WINDOW_DAYS) const rowTo = addDays(row.transaktionsdatum, DATE_WINDOW_DAYS) @@ -365,25 +416,16 @@ export async function findMatchSuggestionsBulk( const key = periodByRowId.get(row.id) return key ? agiIndex.get(key)?.entryIds ?? null : null })() + periodIdsByRow.set(row.id, periodEntryIds) const matches: SkattekontoMatchCandidate[] = [] - const seen = new Set() - - for (const line of lines) { - const e = line.journal_entries - if (linkedSet.has(e.id)) continue - if (seen.has(e.id)) continue + for (const view of entryViews.values()) { + const e = view.entry if (e.entry_date < rowFrom || e.entry_date > rowTo) continue - - const debit = Math.round(Number(line.debit_amount) * 100) / 100 - const credit = Math.round(Number(line.credit_amount) * 100) / 100 - const lineMatches = - side === 'debit' - ? debit === amount && credit === 0 - : credit === amount && debit === 0 - if (!lineMatches) continue - - seen.add(e.id) + const singleLine = + side === 'debit' ? view.debits.includes(amount) : view.credits.includes(amount) + const entryTotal = !singleLine && view.lineCount > 1 && view.net === signedNet + if (!singleLine && !entryTotal) continue matches.push({ journal_entry_id: e.id, voucher_number: e.voucher_number, @@ -394,30 +436,58 @@ export async function findMatchSuggestionsBulk( matched_amount: amount, matched_side: side, matched_via_agi_period: periodEntryIds?.has(e.id) ?? false, + matched_via_entry_total: entryTotal, }) - - if (matches.length > 1 && !periodEntryIds) break } + // Nearest date first so the assignment below is deterministic. + matches.sort((a, b) => { + const da = Math.abs(daysBetweenIso(a.entry_date, row.transaktionsdatum)) + const db = Math.abs(daysBetweenIso(b.entry_date, row.transaktionsdatum)) + return da - db || a.journal_entry_id.localeCompare(b.journal_entry_id) + }) + candidatesByRow.set(row.id, matches) + } - // Period-code disambiguation: prefer the AGI-linked candidate even when - // multiple amount-matches exist. + const suggestions = new Map() + const usedEntries = new Set() + + const pick = (row: (typeof ordered)[number]): SkattekontoMatchCandidate | null => { + const free = (candidatesByRow.get(row.id) ?? []).filter(m => !usedEntries.has(m.journal_entry_id)) + const periodEntryIds = periodIdsByRow.get(row.id) if (periodEntryIds) { - const periodMatches = matches.filter(m => m.matched_via_agi_period) - if (periodMatches.length === 1) { - suggestions.set(row.id, periodMatches[0]) - continue - } + const periodMatches = free.filter(m => m.matched_via_agi_period) + if (periodMatches.length === 1) return periodMatches[0] } + // Only an unambiguous amount match is proposed; a split-line match never + // outranks a single-line one. + const exact = free.filter(m => !m.matched_via_entry_total) + if (exact.length === 1) return exact[0] + if (exact.length === 0 && free.length === 1) return free[0] + return null + } - // Fallback: auto-suggest only when there's a single unambiguous amount match. - if (matches.length === 1) { - suggestions.set(row.id, matches[0]) + // Two passes: rows with an AGI period are the best-informed and go first, + // then everyone else in date order. + for (const pass of [true, false]) { + for (const row of ordered) { + if (suggestions.has(row.id)) continue + const hasPeriod = !!periodIdsByRow.get(row.id) + if (hasPeriod !== pass) continue + const chosen = pick(row) + if (!chosen) continue + suggestions.set(row.id, chosen) + usedEntries.add(chosen.journal_entry_id) } } return suggestions } +function daysBetweenIso(a: string, b: string): number { + const ms = new Date(a + 'T00:00:00Z').getTime() - new Date(b + 'T00:00:00Z').getTime() + return Math.round(ms / 86_400_000) +} + function addDays(iso: string, days: number): string { const d = new Date(iso + 'T00:00:00Z') d.setUTCDate(d.getUTCDate() + days) diff --git a/extensions/general/skatteverket/lib/skattekonto-proposals.ts b/extensions/general/skatteverket/lib/skattekonto-proposals.ts new file mode 100644 index 00000000..485556f0 --- /dev/null +++ b/extensions/general/skatteverket/lib/skattekonto-proposals.ts @@ -0,0 +1,112 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { findMatchSuggestionsBulk } from './skattekonto-match' + +const log = createLogger('skattekonto-proposals') + +export interface RefreshProposalsResult { + considered: number + proposed: number + cleared: number + unchanged: number +} + +interface OpenRow { + id: string + transaktionsdatum: string + transaktionstext: string | null + belopp_skatteverket: number | string + journal_entry_id: string | null + suggested_journal_entry_id: string | null +} + +/** + * Recompute the exact-twin proposals for every open SKV row of a company and + * persist them on `skattekonto_transactions.suggested_journal_entry_id`. + * + * Runs after every sync (and can be called after a booking/link mutation). + * A proposal is never a link: only a click, or an approved staged operation, + * moves it into journal_entry_id. Rows that are linked or ignored never + * carry a proposal; rows whose candidate stopped qualifying get it cleared. + * + * Best-effort: logs and returns zeros on failure, never throws, so a + * proposal hiccup cannot fail the sync it rides on. + */ +export async function refreshSkattekontoProposals( + supabase: SupabaseClient, + companyId: string, +): Promise { + const zero: RefreshProposalsResult = { considered: 0, proposed: 0, cleared: 0, unchanged: 0 } + let rows: OpenRow[] + try { + rows = await fetchAllRows( + ({ from, to }) => + supabase + .from('skattekonto_transactions') + .select( + 'id, transaktionsdatum, transaktionstext, belopp_skatteverket, journal_entry_id, suggested_journal_entry_id', + ) + .eq('company_id', companyId) + .eq('status', 'booked') + .eq('is_ignored', false) + .is('journal_entry_id', null) + .order('transaktionsdatum', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (r) => r.id }, + ) + } catch (err) { + log.warn('proposal refresh: row read failed', { + companyId, + error: err instanceof Error ? err.message : String(err), + }) + return zero + } + if (rows.length === 0) return zero + + const suggestions = await findMatchSuggestionsBulk( + supabase, + companyId, + rows.map((r) => ({ + id: r.id, + transaktionsdatum: r.transaktionsdatum, + transaktionstext: r.transaktionstext, + belopp_skatteverket: Number(r.belopp_skatteverket), + journal_entry_id: r.journal_entry_id, + })), + ) + + const now = new Date().toISOString() + let proposed = 0 + let cleared = 0 + let unchanged = 0 + + for (const row of rows) { + const next = suggestions.get(row.id)?.journal_entry_id ?? null + if (next === row.suggested_journal_entry_id) { + unchanged++ + continue + } + const { error } = await supabase + .from('skattekonto_transactions') + .update({ suggested_journal_entry_id: next, suggested_at: next ? now : null }) + .eq('company_id', companyId) + .eq('id', row.id) + // A link written between our read and this update wins: never stamp a + // proposal onto a row that is no longer open. + .is('journal_entry_id', null) + if (error) { + log.warn('proposal refresh: update failed', { + companyId, + rowId: row.id, + error: error.message, + }) + continue + } + if (next) proposed++ + else cleared++ + } + + return { considered: rows.length, proposed, cleared, unchanged } +} diff --git a/extensions/general/skatteverket/lib/skattekonto-sync.ts b/extensions/general/skatteverket/lib/skattekonto-sync.ts index 9af68cab..2b00e804 100644 --- a/extensions/general/skatteverket/lib/skattekonto-sync.ts +++ b/extensions/general/skatteverket/lib/skattekonto-sync.ts @@ -7,6 +7,7 @@ import { computeDedupKey, contentSignature } from '@/lib/skatteverket/skattekont import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { settleAgiTaxPayments } from './agi-tax-settlement' +import { refreshSkattekontoProposals } from './skattekonto-proposals' import { getSaldo, getTransaktioner } from './skattekonto-client' import { SkatteverketAuthError, type SkvAuth } from './api-client' import type { @@ -368,6 +369,14 @@ export async function syncSkattekonto( saldo.saldoSkatteverket, ) + // Exact-twin proposals for the open rows (migration 20260823120000): the + // reconciliation page, the worklist and agents read them from the row + // instead of recomputing per request. Best-effort inside (never throws). + const proposals = await refreshSkattekontoProposals(ctx.supabase, ctx.companyId) + if (proposals.proposed > 0 || proposals.cleared > 0) { + log.info('proposals refreshed', { companyId: ctx.companyId, ...proposals }) + } + // Cache balance snapshot. const snapshot: SkattekontoBalanceSnapshot = { saldo, diff --git a/lib/reconciliation/__tests__/gl-balance.test.ts b/lib/reconciliation/__tests__/gl-balance.test.ts new file mode 100644 index 00000000..1f9fbfee --- /dev/null +++ b/lib/reconciliation/__tests__/gl-balance.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { LEDGER_BALANCE_STATUSES, sumAccountBalance } from '../gl-balance' + +/** + * The two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts) reads the + * parent entries first, then the bare lines keyed by journal_entry_id. + */ +function enqueueGl( + enqueue: (r: { data?: unknown; error?: unknown }) => void, + rows: Array<{ debit_amount: number; credit_amount: number }>, +) { + enqueue({ data: rows.length ? [{ id: 'entry-1' }] : [] }) + if (rows.length === 0) return + enqueue({ + data: rows.map((r, i) => ({ id: `line-${i}`, journal_entry_id: 'entry-1', ...r })), + }) +} + +describe('sumAccountBalance', () => { + beforeEach(() => vi.clearAllMocks()) + + it('sums debit - credit over posted AND reversed entries (the trial-balance predicate)', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueueGl(enqueue, [ + { debit_amount: 1000, credit_amount: 0 }, + { debit_amount: 0, credit_amount: 250.5 }, + ]) + + const sum = await sumAccountBalance(supabase as never, 'company-1', '1630', { + cutoffDate: '2026-08-20', + }) + + expect(sum).toBe(749.5) + const inCalls = findCalls('journal_entries', 'in') + expect(inCalls).toContainEqual(['status', [...LEDGER_BALANCE_STATUSES]]) + expect(LEDGER_BALANCE_STATUSES).toEqual(['posted', 'reversed']) + // The old drift predicate was .eq('status', 'posted'): it must be gone. + expect(findCalls('journal_entries', 'eq')).not.toContainEqual(['status', 'posted']) + expect(findCalls('journal_entries', 'lte')).toContainEqual(['entry_date', '2026-08-20']) + expect(findCalls('journal_entry_lines', 'eq')).toContainEqual(['account_number', '1630']) + }) + + it('applies beforeDate as an exclusive upper bound', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueueGl(enqueue, [{ debit_amount: 10, credit_amount: 0 }]) + + await sumAccountBalance(supabase as never, 'company-1', '1630', { beforeDate: '2025-01-17' }) + + expect(findCalls('journal_entries', 'lt')).toContainEqual(['entry_date', '2025-01-17']) + }) + + it('returns 0 when nothing is booked on the account', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueGl(enqueue, []) + expect(await sumAccountBalance(supabase as never, 'company-1', '1630')).toBe(0) + }) + + it('returns null, never 0, when the read fails', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ error: { message: 'statement timeout' } }) + expect(await sumAccountBalance(supabase as never, 'company-1', '1630')).toBeNull() + }) +}) diff --git a/lib/reconciliation/__tests__/service.test.ts b/lib/reconciliation/__tests__/service.test.ts new file mode 100644 index 00000000..bc2f799c --- /dev/null +++ b/lib/reconciliation/__tests__/service.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const skattekontoStatusMock = vi.fn() +const bankStatusMock = vi.fn() + +vi.mock('../skattekonto-reconciliation', () => ({ + getSkattekontoReconciliationStatus: (...args: unknown[]) => skattekontoStatusMock(...args), +})) +vi.mock('../bank-reconciliation', () => ({ + getReconciliationStatus: (...args: unknown[]) => bankStatusMock(...args), +})) + +import { bankAccountKey, parseAccountKey } from '../schemas' +import { getAccountStatus, listReconciliationAccounts } from '../service' + +const COMPANY = 'company-1' +const ID_A = '11111111-1111-4111-8111-111111111111' +const ID_B = '22222222-2222-4222-8222-222222222222' +const ID_C = '33333333-3333-4333-8333-333333333333' + +function cashAccount(id: string, overrides: Record = {}) { + return { + id, + name: `Konto ${id.slice(0, 2)}`, + ledger_account: '1930', + currency: 'SEK', + iban: null, + enabled: true, + is_primary: false, + source: 'enable_banking', + bank_connection_id: 'conn-1', + updated_at: '2026-08-01T00:00:00Z', + ...overrides, + } +} + +function bankStatus(overrides: Record = {}) { + return { + currency: 'SEK', + bank_transaction_total: 100, + ignored_transaction_total: 0, + ignored_transaction_count: 0, + gl_1930_balance: 100, + gl_1930_period_movement: 100, + gl_1930_opening_balance: 0, + gl_1930_correction_adjustment: 0, + difference: 0, + is_reconciled: true, + matched_count: 3, + unmatched_transaction_count: 0, + unmatched_transaction_total: 0, + unmatched_gl_line_count: 0, + unmatched_gl_line_total: 0, + unexplained_difference: 0, + unconvertible_gl_line_count: 0, + not_reconcilable_reason: null, + ...overrides, + } +} + +describe('account keys', () => { + it('parses the three kinds and rejects anything else', () => { + expect(parseAccountKey(bankAccountKey(ID_A))).toEqual({ kind: 'bank', cashAccountId: ID_A }) + expect(parseAccountKey('skattekonto')).toEqual({ kind: 'skattekonto' }) + expect(parseAccountKey('manual:1910')).toEqual({ kind: 'manual', accountNumber: '1910' }) + expect(parseAccountKey('bank:not-a-uuid')).toBeNull() + expect(parseAccountKey('1930')).toBeNull() + expect(parseAccountKey('')).toBeNull() + }) +}) + +describe('listReconciliationAccounts', () => { + beforeEach(() => { + vi.clearAllMocks() + skattekontoStatusMock.mockReset() + bankStatusMock.mockReset() + }) + + it('lists enabled cash accounts, folds reconnect duplicates by IBAN, and appends the skattekonto when configured', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: [ + cashAccount(ID_A, { is_primary: true, iban: 'SE1', updated_at: '2026-08-10T00:00:00Z' }), + cashAccount(ID_B, { iban: 'SE1', updated_at: '2026-06-01T00:00:00Z' }), + cashAccount(ID_C, { ledger_account: '1931', iban: 'SE2' }), + ], + }) + // latestBankSyncAt per account (withStatus=false skips bankStatus): three maybeSingle reads + enqueue({ data: { created_at: '2026-08-19T06:00:00Z' } }) + enqueue({ data: { created_at: '2026-06-01T06:00:00Z' } }) + enqueue({ data: null }) + skattekontoStatusMock.mockResolvedValue({ + account_key: 'skattekonto', + kind: 'skattekonto', + account_number: '1630', + currency: 'SEK', + as_of: '2026-08-20T04:00:00.000Z', + stale: false, + is_reconciled: false, + unexplained_difference: 0, + counts: { proposed: 2, unmatched_external: 3, unmatched_ledger: 1, matched: 41, ignored: 0 }, + skattekonto: { fetched_at: '2026-08-20T04:00:00.000Z' }, + }) + + const accounts = await listReconciliationAccounts(supabase as never, COMPANY, { + today: '2026-08-20', + withStatus: false, + }) + + expect(accounts.map((a) => a.account_key)).toEqual([ + bankAccountKey(ID_A), + bankAccountKey(ID_B), + bankAccountKey(ID_C), + 'skattekonto', + ]) + const byKey = Object.fromEntries(accounts.map((a) => [a.account_key, a])) + // The older duplicate is marked, never dropped. + expect(byKey[bankAccountKey(ID_B)].superseded_by).toBe(bankAccountKey(ID_A)) + expect(byKey[bankAccountKey(ID_A)].superseded_by).toBeNull() + expect(byKey[bankAccountKey(ID_C)].superseded_by).toBeNull() + // Sync age drives staleness (7 days). + expect(byKey[bankAccountKey(ID_A)].source).toMatchObject({ type: 'psd2', stale: false }) + expect(byKey[bankAccountKey(ID_B)].source.stale).toBe(true) + expect(byKey[bankAccountKey(ID_C)].source).toMatchObject({ synced_at: null, stale: true }) + expect(byKey[bankAccountKey(ID_A)].status).toBeNull() + // Skattekonto row carries the logo and the open counts. + expect(byKey.skattekonto).toMatchObject({ + logo_url: '/logos/skatteverket_color.svg', + source: { type: 'skatteverket_api', stale: false }, + status: { state: 'open', open_counts: { proposed: 2, unmatched_external: 3, unmatched_ledger: 1 } }, + }) + }) + + it('omits the skattekonto when the company has neither snapshot nor rows', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [cashAccount(ID_A, { is_primary: true })] }) + enqueue({ data: null }) + skattekontoStatusMock.mockResolvedValue(null) + + const accounts = await listReconciliationAccounts(supabase as never, COMPANY, { + today: '2026-08-20', + withStatus: false, + }) + expect(accounts.map((a) => a.kind)).toEqual(['bank']) + }) + + it('computes the bank status through the existing engine with the account scope and maps it to the common shape', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [cashAccount(ID_A, { is_primary: true, currency: 'SEK' })] }) + bankStatusMock.mockResolvedValue(bankStatus({ unmatched_transaction_count: 2, unmatched_transaction_total: -1046, is_reconciled: false })) + enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) // latestBankSyncAt inside bankStatus + enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) // latestBankSyncAt for the account row + skattekontoStatusMock.mockResolvedValue(null) + + const accounts = await listReconciliationAccounts(supabase as never, COMPANY, { + today: '2026-08-20', + windowFrom: '2026-01-01', + windowTo: '2026-08-20', + }) + + expect(bankStatusMock).toHaveBeenCalledWith( + supabase, + COMPANY, + '2026-01-01', + '2026-08-20', + '1930', + 'SEK', + ID_A, + true, + ) + expect(accounts[0].status).toMatchObject({ + state: 'open', + open_counts: { proposed: 0, unmatched_external: 2, unmatched_ledger: 0 }, + unexplained_difference: 0, + }) + }) +}) + +describe('getAccountStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + skattekontoStatusMock.mockReset() + bankStatusMock.mockReset() + }) + + it('returns null for an invalid key and for an unknown cash account', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + expect(await getAccountStatus(supabase as never, COMPANY, 'nope')).toBeNull() + enqueue({ data: null }) + expect(await getAccountStatus(supabase as never, COMPANY, bankAccountKey(ID_A))).toBeNull() + }) + + it('dispatches skattekonto to its engine with the window', async () => { + const { supabase } = createQueuedMockSupabase() + skattekontoStatusMock.mockResolvedValue({ account_key: 'skattekonto' }) + const s = await getAccountStatus(supabase as never, COMPANY, 'skattekonto', { + today: '2026-08-20', + windowFrom: '2026-07-01', + windowTo: '2026-07-31', + }) + expect(s).toEqual({ account_key: 'skattekonto' }) + expect(skattekontoStatusMock).toHaveBeenCalledWith(supabase, COMPANY, { + today: '2026-08-20', + windowFrom: '2026-07-01', + windowTo: '2026-07-31', + }) + }) + + it('builds the bank bridge in the #1737 shape', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: cashAccount(ID_A, { is_primary: true }) }) + bankStatusMock.mockResolvedValue( + bankStatus({ + bank_transaction_total: 122288, + gl_1930_period_movement: 122334, + difference: -46, + unmatched_transaction_total: -1046, + unmatched_transaction_count: 1, + unmatched_gl_line_total: -1000, + unmatched_gl_line_count: 1, + unexplained_difference: 0, + is_reconciled: false, + }), + ) + enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) + + const s = await getAccountStatus(supabase as never, COMPANY, bankAccountKey(ID_A), { today: '2026-08-20' }) + if (!s) throw new Error('expected status') + expect(s.kind).toBe('bank') + expect(s.bridge.map((b) => [b.key, b.amount])).toEqual([ + ['bank_transactions', 122288], + ['unmatched_external', 1046], + ['unmatched_ledger', -1000], + ['ledger_balance', 122334], + ]) + expect(s.unexplained_difference).toBe(0) + expect(s.bank).toMatchObject({ bank_transaction_total: 122288 }) + }) +}) diff --git a/lib/reconciliation/__tests__/skattekonto-reconciliation.test.ts b/lib/reconciliation/__tests__/skattekonto-reconciliation.test.ts new file mode 100644 index 00000000..3606802a --- /dev/null +++ b/lib/reconciliation/__tests__/skattekonto-reconciliation.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { roundOre } from '@/lib/money' + +const fetchEntryLinesMock = vi.fn() +const sumAccountBalanceMock = vi.fn() + +vi.mock('@/lib/bookkeeping/entry-lines', () => ({ + fetchEntryLines: (...args: unknown[]) => fetchEntryLinesMock(...args), +})) +vi.mock('../gl-balance', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + sumAccountBalance: (...args: unknown[]) => sumAccountBalanceMock(...args), + } +}) + +import { getSkattekontoReconciliationStatus } from '../skattekonto-reconciliation' + +const COMPANY = 'company-1' +const TODAY = '2026-08-20' +const FETCHED_AT = Date.UTC(2026, 7, 20, 4, 0, 0) + +type Head = { + id: string + status: 'draft' | 'posted' | 'reversed' + voucher_number: number | null + voucher_series: string | null + entry_date: string + description: string + source_type: string | null +} + +function head(id: string, entry_date: string, overrides: Partial = {}): Head { + return { + id, + status: 'posted', + voucher_number: Number(id.replace(/\D/g, '')) || null, + voucher_series: 'A', + entry_date, + description: `Verifikat ${id}`, + source_type: 'manual', + ...overrides, + } +} + +/** A 1630 line as fetchEntryLines returns it: amounts + the parent entry attached. */ +function ledgerLine(h: Head, amount: number) { + return { + id: `line-${h.id}-${amount}`, + journal_entry_id: h.id, + debit_amount: amount > 0 ? amount : 0, + credit_amount: amount < 0 ? -amount : 0, + journal_entries: h, + } +} + +function row( + id: string, + transaktionsdatum: string, + belopp: number, + overrides: Partial<{ + status: 'booked' | 'upcoming' + journal_entry_id: string | null + suggested_journal_entry_id: string | null + is_ignored: boolean + transaktionstext: string + forfallodatum: string | null + }> = {}, +) { + return { + id, + transaktionsdatum, + forfallodatum: null, + transaktionstext: `Händelse ${id}`, + belopp_skatteverket: belopp, + status: 'booked', + journal_entry_id: null, + suggested_journal_entry_id: null, + is_ignored: false, + ...overrides, + } +} + +/** + * Query order in getSkattekontoReconciliationStatus: + * 1. extension_data snapshot (maybeSingle) + * 2. skattekonto_transactions page (fetchAllRows) + * 3. journal_entries heads for linked + suggested ids (one chunk) when any + * The ledger lines come from the mocked fetchEntryLines, the balances from the + * mocked sumAccountBalance. + */ +function enqueueBase( + enqueue: (r: { data?: unknown; error?: unknown }) => void, + opts: { + saldo: number | null + rows: ReturnType[] + heads?: Head[] + fetchedAt?: number + }, +) { + enqueue({ + data: + opts.saldo === null + ? null + : { value: { saldo: { saldoSkatteverket: opts.saldo }, fetchedAt: opts.fetchedAt ?? FETCHED_AT } }, + }) + enqueue({ data: opts.rows }) + const referenced = opts.rows.some((r) => r.journal_entry_id || r.suggested_journal_entry_id) + if (referenced) enqueue({ data: opts.heads ?? [] }) +} + +function ledger(lines: ReturnType[], balances: { cutoff: number | null; before: number | null }) { + fetchEntryLinesMock.mockResolvedValue(lines) + sumAccountBalanceMock.mockImplementation( + async (_s: unknown, _c: unknown, _a: unknown, options: { cutoffDate?: string; beforeDate?: string }) => + options.beforeDate ? balances.before : balances.cutoff, + ) +} + +describe('getSkattekontoReconciliationStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + fetchEntryLinesMock.mockReset() + sumAccountBalanceMock.mockReset() + }) + + it('returns null when the company has neither a snapshot nor rows', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueBase(enqueue, { saldo: null, rows: [] }) + ledger([], { cutoff: 0, before: 0 }) + expect(await getSkattekontoReconciliationStatus(supabase as never, COMPANY, { today: TODAY })).toBeNull() + }) + + it('closes the identity to 0,00 on a mixed fixture and buckets every row where the page shows it', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const A190 = head('A190', '2026-07-12') + const A214 = head('A214', '2026-08-11') + const A219 = head('A219', '2026-08-12') + const A181 = head('A181', '2026-06-30') + const rows = [ + row('r-agi', '2026-07-12', -9142, { journal_entry_id: 'A190' }), + row('r-71106', '2026-08-14', 71106), + row('r-18', '2026-08-03', 18), + row('r-moms', '2026-07-12', -35571), + row('r-30000', '2026-08-12', 30000, { suggested_journal_entry_id: 'A214' }), + row('r-5447', '2026-08-12', -5447, { suggested_journal_entry_id: 'A219' }), + row('r-ign', '2026-07-01', -100, { is_ignored: true }), + row('r-up', '2026-09-12', -5447, { status: 'upcoming', forfallodatum: '2026-09-12' }), + ] + enqueueBase(enqueue, { saldo: 53395, rows, heads: [A190, A214, A219] }) + // Ledger in [history start 2026-07-01, cutoff]: linked A190, the two twins, A181 without event. + ledger( + [ledgerLine(A190, -9142), ledgerLine(A214, 30000), ledgerLine(A219, -5447), ledgerLine(A181, 12500)], + { cutoff: 27911, before: 0 }, + ) + + const s = await getSkattekontoReconciliationStatus(supabase as never, COMPANY, { today: TODAY }) + expect(s).not.toBeNull() + if (!s) return + + expect(s.external_balance).toBe(53395) + expect(s.ledger_balance).toBe(27911) + expect(s.difference).toBe(25484) + // saldo_at_start = 53 395 - (sum of all booked rows = 50 864) = 2 531; ledger before start = 0 + expect(s.skattekonto?.opening_difference).toBe(2531) + expect(s.unexplained_difference).toBe(0) + expect(s.is_reconciled).toBe(false) + expect(s.stale).toBe(false) + + expect(s.counts).toEqual({ proposed: 2, unmatched_external: 3, unmatched_ledger: 3, matched: 1, ignored: 1 }) + expect(s.items.proposed.map((i) => i.item_id).sort()).toEqual(['r-30000', 'r-5447']) + expect(s.items.proposed[0].proposal?.journal_entry_id).toBeDefined() + expect(s.items.proposed[0].proposal?.reasons[0]).toMatch(/exakt belopp/) + expect(s.items.unmatched_external.map((i) => i.item_id).sort()).toEqual(['r-18', 'r-71106', 'r-moms']) + expect(s.items.unmatched_ledger.map((i) => i.item_id).sort()).toEqual(['A181', 'A214', 'A219']) + expect(s.items.matched[0]).toMatchObject({ item_id: 'r-agi', linked_journal_entry_id: 'A190', voucher_number: 190 }) + expect(s.items.ignored[0].item_id).toBe('r-ign') + expect(s.items.upcoming).toHaveLength(1) + expect(s.skattekonto?.upcoming_total).toBe(-5447) + + const byKey = Object.fromEntries(s.bridge.map((b) => [b.key, b])) + expect(byKey.external_balance.amount).toBe(53395) + expect(byKey.unmatched_external.amount).toBe(-60106) + expect(byKey.unmatched_external.count).toBe(5) + expect(byKey.unmatched_ledger.amount).toBe(37053) + expect(byKey.ignored.amount).toBe(100) + expect(byKey.opening_difference.amount).toBe(-2531) + expect(byKey.ledger_balance.amount).toBe(27911) + // The bridge lines sum to the ledger balance: saldo - unlinked - ignored + unlinked ledger - opening + const sum = s.bridge + .filter((b) => b.key !== 'ledger_balance') + .reduce((acc, b) => roundOre(acc + b.amount), 0) + expect(sum).toBe(27911) + }) + + it('treats a link to a reversed entry as a dead link, and the storno pair nets out of the residual', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const E1 = head('E1', '2026-08-01', { status: 'reversed' }) + const E2 = head('E2', '2026-08-02', { source_type: 'storno' }) + enqueueBase(enqueue, { + saldo: 1000, + rows: [row('r1', '2026-08-01', 1000, { journal_entry_id: 'E1' })], + heads: [E1], + }) + ledger([ledgerLine(E1, 1000), ledgerLine(E2, -1000)], { cutoff: 0, before: 0 }) + + const s = await getSkattekontoReconciliationStatus(supabase as never, COMPANY, { today: TODAY }) + if (!s) throw new Error('expected status') + + expect(s.counts.matched).toBe(0) + expect(s.counts.unmatched_external).toBe(1) + expect(s.items.unmatched_external[0]).toMatchObject({ item_id: 'r1', link_problem: 'entry_reversed' }) + expect(s.items.unmatched_external[0].actions).toContain('unmatch') + expect(s.counts.unmatched_ledger).toBe(2) + expect(s.unexplained_difference).toBe(0) + expect(s.difference).toBe(1000) + }) + + it('marks a stale snapshot and never claims reconciled on one', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueBase(enqueue, { saldo: 0, rows: [], fetchedAt: Date.UTC(2026, 6, 1) }) + ledger([], { cutoff: 0, before: 0 }) + + const s = await getSkattekontoReconciliationStatus(supabase as never, COMPANY, { today: TODAY }) + if (!s) throw new Error('expected status') + expect(s.stale).toBe(true) + expect(s.as_of).toBe(new Date(Date.UTC(2026, 6, 1)).toISOString()) + // Nothing open and the identity closes, but the data is 50 days old: reconciled is still true + // (the state machine in the service reports it as stale; staleness is not a mismatch). + expect(s.is_reconciled).toBe(true) + }) + + it('flags a ledger line dated within 5 days of the snapshot as possibly awaiting Skatteverket', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const recent = head('R1', '2026-08-18') + const old = head('R2', '2026-07-01') + enqueueBase(enqueue, { saldo: 0, rows: [row('r1', '2026-07-01', 10)] }) + ledger([ledgerLine(recent, 500), ledgerLine(old, 10)], { cutoff: 510, before: 0 }) + + const s = await getSkattekontoReconciliationStatus(supabase as never, COMPANY, { today: TODAY }) + if (!s) throw new Error('expected status') + const byId = Object.fromEntries(s.items.unmatched_ledger.map((i) => [i.item_id, i])) + expect(byId.R1.awaiting_external).toBe(true) + expect(byId.R2.awaiting_external).toBe(false) + }) + + it('a window scopes the item lists only; older unmatched rows are counted, never hidden', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const old = head('O1', '2026-03-10') + enqueueBase(enqueue, { + saldo: 5000, + rows: [row('r-old', '2026-03-01', 2000), row('r-new', '2026-08-10', 3000)], + }) + ledger([ledgerLine(old, 700)], { cutoff: 700, before: 0 }) + + const s = await getSkattekontoReconciliationStatus(supabase as never, COMPANY, { + today: TODAY, + windowFrom: '2026-07-01', + windowTo: '2026-08-31', + }) + if (!s) throw new Error('expected status') + expect(s.items.unmatched_external.map((i) => i.item_id)).toEqual(['r-new']) + expect(s.items.unmatched_ledger).toHaveLength(0) + expect(s.counts.unmatched_external).toBe(2) + expect(s.counts.unmatched_ledger).toBe(1) + expect(s.older_unmatched_count).toBe(2) + // Totals are unwindowed: 5000 - 5000 (unlinked) + 700 (unlinked ledger) - opening(5000-5000-0=0) = 700 = ledger + expect(s.unexplained_difference).toBe(0) + }) + + it('reports a failed ledger read as null balances and a null residual, never a fabricated 0', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueBase(enqueue, { saldo: 1000, rows: [row('r1', '2026-08-01', 1000)] }) + fetchEntryLinesMock.mockResolvedValue([]) + sumAccountBalanceMock.mockResolvedValue(null) + + const s = await getSkattekontoReconciliationStatus(supabase as never, COMPANY, { today: TODAY }) + if (!s) throw new Error('expected status') + expect(s.ledger_read_failed).toBe(true) + expect(s.ledger_balance).toBeNull() + expect(s.difference).toBeNull() + expect(s.unexplained_difference).toBeNull() + expect(s.is_reconciled).toBe(false) + // The SKV side is still listed so the user can work. + expect(s.counts.unmatched_external).toBe(1) + }) + + it('does not propose an entry that another row already links live', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const E = head('E9', '2026-08-01') + enqueueBase(enqueue, { + saldo: 2000, + rows: [ + row('r-linked', '2026-08-01', 1000, { journal_entry_id: 'E9' }), + row('r-open', '2026-08-01', 1000, { suggested_journal_entry_id: 'E9' }), + ], + heads: [E], + }) + ledger([ledgerLine(E, 1000)], { cutoff: 1000, before: 0 }) + + const s = await getSkattekontoReconciliationStatus(supabase as never, COMPANY, { today: TODAY }) + if (!s) throw new Error('expected status') + expect(s.counts.proposed).toBe(0) + expect(s.counts.unmatched_external).toBe(1) + expect(s.items.unmatched_external[0].item_id).toBe('r-open') + }) +}) diff --git a/lib/reconciliation/gl-balance.ts b/lib/reconciliation/gl-balance.ts new file mode 100644 index 00000000..aba94bcc --- /dev/null +++ b/lib/reconciliation/gl-balance.ts @@ -0,0 +1,81 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' +import { createLogger } from '@/lib/logger' +import { roundOre } from '@/lib/money' + +const log = createLogger('reconciliation/gl-balance') + +/** + * The journal_entries statuses that make up a ledger balance. + * + * Storno keeps the original entry in the books with status 'reversed' and + * posts a separate reversal (source_type 'storno', status 'posted'). A + * balance therefore has to count BOTH: the trial balance + * (lib/reports/trial-balance.ts) and the bank reconciliation engine + * (lib/reconciliation/bank-reconciliation.ts) already do. Summing 'posted' + * alone excludes the reversed original while including its reversal, which + * double-cancels the movement and misstates the account by the reversed + * amount. The skattekonto drift check did exactly that until 2026-08-23. + */ +export const LEDGER_BALANCE_STATUSES = ['posted', 'reversed'] as const + +export interface SumAccountBalanceOptions { + /** Inclusive upper bound on entry_date (YYYY-MM-DD). */ + cutoffDate?: string + /** Inclusive lower bound on entry_date (YYYY-MM-DD). */ + fromDate?: string + /** Exclusive upper bound on entry_date (YYYY-MM-DD); combines with fromDate. */ + beforeDate?: string +} + +/** + * Sum debit - credit on one BAS account over posted + reversed entries. + * + * Returns null (NOT 0) when the read fails: 0 is a real balance claim + * ("nothing booked on this account"), and substituting it for a failed read + * turns a transient DB blip into a full-balance difference. Callers decide + * whether to skip or to surface the failure. + * + * Driven from the journal_entries side via fetchEntryLines so the tenant + * scope never compiles into a cross-tenant LATERAL scan, and both steps + * paginate (PostgREST caps at 1000 rows). + */ +export async function sumAccountBalance( + supabase: SupabaseClient, + companyId: string, + accountNumber: string, + options: SumAccountBalanceOptions = {}, +): Promise { + let rows: Array<{ debit_amount: number | string | null; credit_amount: number | string | null }> + try { + rows = await fetchEntryLines({ + supabase, + lineColumns: 'debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => { + let query = q + .eq('company_id', companyId) + .in('status', [...LEDGER_BALANCE_STATUSES]) + if (options.cutoffDate) query = query.lte('entry_date', options.cutoffDate) + if (options.fromDate) query = query.gte('entry_date', options.fromDate) + if (options.beforeDate) query = query.lt('entry_date', options.beforeDate) + return query + }, + filterLines: (q: EntryLinesQuery) => q.eq('account_number', accountNumber), + attachEntriesAs: null, + }) + } catch (err) { + log.warn('sumAccountBalance failed', { + companyId, + accountNumber, + options, + error: err instanceof Error ? err.message : String(err), + }) + return null + } + + let sum = 0 + for (const row of rows) { + sum += Number(row.debit_amount || 0) - Number(row.credit_amount || 0) + } + return roundOre(sum) +} diff --git a/lib/reconciliation/schemas.ts b/lib/reconciliation/schemas.ts new file mode 100644 index 00000000..fabe482c --- /dev/null +++ b/lib/reconciliation/schemas.ts @@ -0,0 +1,200 @@ +import { z } from 'zod' + +/** + * Shared shapes for the account-keyed reconciliation surface. + * + * One engine, three doors: the dashboard routes, the public v1 API and the + * MCP tools all speak these shapes, so the Zod schemas here are the single + * source for OpenAPI (v1 registry), MCP input/output schemas and the UI + * types. Kind-specific detail lives in a `bank` / `skattekonto` block; the + * common block is what every caller can reason about without knowing the + * account kind. + * + * Account keys: + * bank: one cash_accounts row (PSD2 or file-fed) + * skattekonto the company's Skatteverket tax account (BAS 1630) + * manual: later: accounts with a typed external balance + */ +export const ACCOUNT_KEY_REGEX = + /^(bank:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|skattekonto|manual:\d{4})$/ + +export const AccountKeySchema = z.string().regex(ACCOUNT_KEY_REGEX, 'Ogiltig account_key') +export type AccountKey = z.infer + +export const ReconciliationKindSchema = z.enum(['bank', 'skattekonto', 'manual']) +export type ReconciliationKind = z.infer + +export type ParsedAccountKey = + | { kind: 'bank'; cashAccountId: string } + | { kind: 'skattekonto' } + | { kind: 'manual'; accountNumber: string } + +export function parseAccountKey(key: string): ParsedAccountKey | null { + if (!ACCOUNT_KEY_REGEX.test(key)) return null + if (key === 'skattekonto') return { kind: 'skattekonto' } + if (key.startsWith('bank:')) return { kind: 'bank', cashAccountId: key.slice('bank:'.length) } + return { kind: 'manual', accountNumber: key.slice('manual:'.length) } +} + +export function bankAccountKey(cashAccountId: string): string { + return `bank:${cashAccountId}` +} + +export const SKATTEKONTO_ACCOUNT_KEY = 'skattekonto' as const + +export const ReconciliationSourceSchema = z.object({ + type: z.enum(['psd2', 'bank_file', 'skatteverket_api', 'skatteverket_file', 'manual']), + /** ISO timestamp of the last successful sync / import; null when never. */ + synced_at: z.string().nullable(), + /** True when the outside truth is older than STALE_AFTER_DAYS. */ + stale: z.boolean(), +}) + +export const ReconciliationStateSchema = z.enum([ + 'reconciled', + 'open', + 'stale', + 'not_configured', +]) + +export const ReconciliationAccountSchema = z.object({ + account_key: AccountKeySchema, + kind: ReconciliationKindSchema, + account_number: z.string(), + name: z.string(), + currency: z.string(), + logo_url: z.string().nullable(), + source: ReconciliationSourceSchema, + status: z + .object({ + state: ReconciliationStateSchema, + /** ISO timestamp the status was computed for (snapshot time or now). */ + as_of: z.string(), + unexplained_difference: z.number().nullable(), + open_counts: z.object({ + proposed: z.number().int(), + unmatched_external: z.number().int(), + unmatched_ledger: z.number().int(), + }), + }) + .nullable(), + /** Another enabled cash account shares this IBAN and currency (reconnect duplicate). */ + superseded_by: AccountKeySchema.nullable(), +}) +export type ReconciliationAccount = z.infer + +/** One explanatory line of the bridge: label + amount + the filter that lists its rows. */ +export const BridgeLineSchema = z.object({ + key: z.string(), + label_sv: z.string(), + label_en: z.string(), + amount: z.number(), + count: z.number().int().nullable(), + /** Bucket to request from the items endpoint to see these rows, when any. */ + items_bucket: z.string().nullable(), +}) +export type BridgeLine = z.infer + +export const ReconciliationItemBucketSchema = z.enum([ + 'proposed', + 'unmatched_external', + 'unmatched_ledger', + 'matched', + 'ignored', + 'upcoming', +]) +export type ReconciliationItemBucket = z.infer + +export const ReconciliationItemActionSchema = z.enum([ + 'match', + 'unmatch', + 'book', + 'ignore', + 'unignore', + 'review', +]) + +export const ReconciliationProposalSchema = z.object({ + journal_entry_id: z.string(), + voucher_number: z.number().int().nullable(), + voucher_series: z.string().nullable(), + entry_date: z.string(), + description: z.string(), + entry_status: z.enum(['draft', 'posted', 'reversed']), + confidence: z.number().min(0).max(1), + reasons: z.array(z.string()), +}) +export type ReconciliationProposal = z.infer + +export const ReconciliationItemSchema = z.object({ + /** Qualified id of the row on its side: skattekonto_transaction_id, transaction_id or journal_entry_id. */ + item_id: z.string(), + item_type: z.enum(['skattekonto_transaction', 'transaction', 'journal_entry']), + side: z.enum(['external', 'ledger']), + bucket: ReconciliationItemBucketSchema, + date: z.string(), + description: z.string(), + /** Signed amount in the account's natural direction (debit positive on the ledger side). */ + amount: z.number(), + currency: z.string(), + voucher_number: z.number().int().nullable().optional(), + voucher_series: z.string().nullable().optional(), + entry_status: z.enum(['draft', 'posted', 'reversed']).optional(), + /** Linked counterpart on the other side, when any. */ + linked_journal_entry_id: z.string().nullable().optional(), + /** Why a linked row is not counted as settled (the entry was reversed or is still a draft). */ + link_problem: z.enum(['entry_reversed', 'entry_draft', 'entry_missing']).nullable().optional(), + proposal: ReconciliationProposalSchema.nullable().optional(), + /** Ledger line dated within AWAITING_EXTERNAL_DAYS of the snapshot: the outside side may simply not have posted it yet. */ + awaiting_external: z.boolean().optional(), + actions: z.array(ReconciliationItemActionSchema), +}) +export type ReconciliationItem = z.infer + +export const SkattekontoStatusBlockSchema = z.object({ + saldo_skatteverket: z.number().nullable(), + /** ISO timestamp of the saldo snapshot; null when never synced. */ + fetched_at: z.string().nullable(), + /** Earliest SKV-posted row we hold: the start of the comparable history. */ + history_start: z.string().nullable(), + /** saldo_at_start - ledger balance before history_start. Null without a snapshot. */ + opening_difference: z.number().nullable(), + upcoming_count: z.number().int(), + upcoming_total: z.number(), + ledger_balance_before_start: z.number().nullable(), +}) + +export const ReconciliationStatusSchema = z.object({ + account_key: AccountKeySchema, + kind: ReconciliationKindSchema, + account_number: z.string(), + currency: z.string(), + window: z.object({ from: z.string().nullable(), to: z.string().nullable() }), + as_of: z.string(), + stale: z.boolean(), + /** What the outside says (bank balance or Skatteverket saldo); null when unknown. */ + external_balance: z.number().nullable(), + /** What the ledger says on the account (balance for skattekonto, period movement for bank). */ + ledger_balance: z.number().nullable(), + difference: z.number().nullable(), + unexplained_difference: z.number().nullable(), + is_reconciled: z.boolean(), + bridge: z.array(BridgeLineSchema), + counts: z.object({ + proposed: z.number().int(), + unmatched_external: z.number().int(), + unmatched_ledger: z.number().int(), + matched: z.number().int(), + ignored: z.number().int(), + }), + skattekonto: SkattekontoStatusBlockSchema.nullable(), + /** Today's bank status fields, unchanged, for the bank kind (see bank-reconciliation.ts). */ + bank: z.record(z.string(), z.unknown()).nullable(), +}) +export type ReconciliationStatus = z.infer + +/** Outside truth older than this is flagged stale on every read. */ +export const STALE_AFTER_DAYS = 7 + +/** A ledger line this close to the snapshot may simply be waiting for Skatteverket to post the same event. */ +export const AWAITING_EXTERNAL_DAYS = 5 diff --git a/lib/reconciliation/service.ts b/lib/reconciliation/service.ts new file mode 100644 index 00000000..c34d38fd --- /dev/null +++ b/lib/reconciliation/service.ts @@ -0,0 +1,360 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { roundOre } from '@/lib/money' +import { getReconciliationStatus as getBankReconciliationStatus } from './bank-reconciliation' +import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation' +import { + bankAccountKey, + parseAccountKey, + SKATTEKONTO_ACCOUNT_KEY, + STALE_AFTER_DAYS, + type BridgeLine, + type ReconciliationAccount, + type ReconciliationStatus, +} from './schemas' + +const log = createLogger('reconciliation/service') + +/** + * The account-keyed reconciliation facade: one engine, three doors. + * + * The dashboard routes, the public v1 API and the MCP tools all call these + * functions; none of them re-implements bank or skattekonto logic. Kind + * adapters (bank today via bank-reconciliation.ts, skattekonto via + * skattekonto-reconciliation.ts, manual later) hang off `account_key`, so + * adding an account type is one adapter, never a new set of endpoints. + * + * Core runs with zero extensions: the skattekonto adapter reads the core + * `skattekonto_transactions` table and the snapshot row the extension leaves + * in `extension_data`, and simply reports "not configured" when neither + * exists. + */ + +export interface ListAccountsOptions { + today?: string + /** Compute status per account (N reads). Default true; the rail needs it. */ + withStatus?: boolean + /** Window for the bank bridge ("i perioden"). Defaults to the calendar year of `today`. */ + windowFrom?: string + windowTo?: string +} + +interface CashAccountRow { + id: string + name: string | null + ledger_account: string + currency: string | null + iban: string | null + enabled: boolean | null + is_primary: boolean | null + source: string | null + bank_connection_id: string | null + updated_at: string | null +} + +function isoDate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function daysBetween(a: string, b: string): number { + const ms = new Date(a + 'T00:00:00Z').getTime() - new Date(b + 'T00:00:00Z').getTime() + return Math.round(ms / 86_400_000) +} + +function defaultWindow(today: string): { from: string; to: string } { + return { from: `${today.slice(0, 4)}-01-01`, to: today } +} + +async function latestBankSyncAt( + supabase: SupabaseClient, + companyId: string, + cashAccountId: string, +): Promise { + const { data } = await supabase + .from('transactions') + .select('created_at') + .eq('company_id', companyId) + .eq('cash_account_id', cashAccountId) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + return (data?.created_at as string | undefined) ?? null +} + +/** Bridge lines for the bank kind, mirroring the #1737 status card. */ +function bankBridge(status: Awaited>, accountNumber: string): BridgeLine[] { + const lines: BridgeLine[] = [ + { + key: 'bank_transactions', + label_sv: 'Banktransaktioner i perioden', + label_en: 'Bank transactions in the period', + amount: status.bank_transaction_total, + count: null, + items_bucket: null, + }, + { + key: 'unmatched_external', + label_sv: 'Omatchade banktransaktioner', + label_en: 'Unmatched bank transactions', + amount: roundOre(-status.unmatched_transaction_total), + count: status.unmatched_transaction_count, + items_bucket: 'unmatched_external', + }, + ] + if (status.unmatched_gl_line_total !== null) { + lines.push({ + key: 'unmatched_ledger', + label_sv: `Verifikationer på ${accountNumber} utan banktransaktion`, + label_en: `Vouchers on ${accountNumber} without a bank transaction`, + amount: status.unmatched_gl_line_total, + count: status.unmatched_gl_line_count, + items_bucket: 'unmatched_ledger', + }) + } + if (status.ignored_transaction_count > 0) { + lines.push({ + key: 'ignored', + label_sv: 'Ignorerade transaktioner', + label_en: 'Ignored transactions', + amount: status.ignored_transaction_total, + count: status.ignored_transaction_count, + items_bucket: 'ignored', + }) + } + lines.push({ + key: 'ledger_balance', + label_sv: `Bokfört på ${accountNumber} i perioden`, + label_en: `Booked on ${accountNumber} in the period`, + amount: status.gl_1930_period_movement, + count: null, + items_bucket: null, + }) + return lines +} + +async function bankStatus( + supabase: SupabaseClient, + companyId: string, + account: CashAccountRow, + window: { from: string; to: string }, + today: string, +): Promise { + const currency = account.currency ?? 'SEK' + const raw = await getBankReconciliationStatus( + supabase, + companyId, + window.from, + window.to, + account.ledger_account, + currency, + account.id, + Boolean(account.is_primary), + ) + const syncedAt = await latestBankSyncAt(supabase, companyId, account.id) + const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS + return { + account_key: bankAccountKey(account.id), + kind: 'bank', + account_number: account.ledger_account, + currency, + window: { from: window.from, to: window.to }, + as_of: new Date().toISOString(), + stale, + external_balance: null, + ledger_balance: raw.gl_1930_period_movement, + difference: raw.difference, + unexplained_difference: raw.unexplained_difference, + is_reconciled: raw.is_reconciled, + bridge: bankBridge(raw, account.ledger_account), + counts: { + proposed: 0, + unmatched_external: raw.unmatched_transaction_count, + unmatched_ledger: raw.unmatched_gl_line_count, + matched: raw.matched_count, + ignored: raw.ignored_transaction_count, + }, + skattekonto: null, + bank: raw as unknown as Record, + } +} + +function stateOf(status: ReconciliationStatus | null): ReconciliationAccount['status'] { + if (!status) return null + const state = status.is_reconciled + ? 'reconciled' + : status.stale + ? 'stale' + : 'open' + return { + state, + as_of: status.as_of, + unexplained_difference: status.unexplained_difference, + open_counts: { + proposed: status.counts.proposed, + unmatched_external: status.counts.unmatched_external, + unmatched_ledger: status.counts.unmatched_ledger, + }, + } +} + +/** + * Every account with an outside truth, as the side list shows them: enabled + * cash accounts (deduplicated per IBAN + currency, the reconnect-duplicate + * case measured at 25 rows in 17 companies) plus the skattekonto when the + * company has a saldo snapshot or rows. + */ +export async function listReconciliationAccounts( + supabase: SupabaseClient, + companyId: string, + options: ListAccountsOptions = {}, +): Promise { + const today = options.today ?? isoDate(new Date()) + const withStatus = options.withStatus ?? true + const window = { + from: options.windowFrom ?? defaultWindow(today).from, + to: options.windowTo ?? defaultWindow(today).to, + } + + const { data, error } = await supabase + .from('cash_accounts') + .select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, updated_at') + .eq('company_id', companyId) + .eq('enabled', true) + .order('is_primary', { ascending: false }) + .order('ledger_account', { ascending: true }) + if (error) throw new Error(`Kunde inte hämta kassakonton: ${error.message}`) + const cashAccounts = (data ?? []) as CashAccountRow[] + + // Reconnect duplicates: same IBAN and currency twice. Keep the most recently + // updated row as the live one and mark the other as superseded so a rail + // can fold it away; never drop it silently, it may still hold unlinked rows. + const supersededBy = new Map() + const byIban = new Map() + for (const a of cashAccounts) { + if (!a.iban) continue + const k = `${a.iban}|${a.currency ?? 'SEK'}` + byIban.set(k, [...(byIban.get(k) ?? []), a]) + } + for (const group of byIban.values()) { + if (group.length < 2) continue + const sorted = [...group].sort((x, y) => (y.updated_at ?? '').localeCompare(x.updated_at ?? '')) + const keep = sorted[0] + for (const other of sorted.slice(1)) supersededBy.set(other.id, bankAccountKey(keep.id)) + } + + const bankAccounts = await Promise.all( + cashAccounts.map(async (a): Promise => { + let status: ReconciliationStatus | null = null + let syncedAt: string | null = null + if (withStatus) { + try { + status = await bankStatus(supabase, companyId, a, window, today) + } catch (err) { + log.warn('bank status failed for account', { + companyId, + cashAccountId: a.id, + error: err instanceof Error ? err.message : String(err), + }) + } + } + try { + syncedAt = await latestBankSyncAt(supabase, companyId, a.id) + } catch { + syncedAt = null + } + const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS + return { + account_key: bankAccountKey(a.id), + kind: 'bank', + account_number: a.ledger_account, + name: a.name ?? `Bankkonto ${a.ledger_account}`, + currency: a.currency ?? 'SEK', + logo_url: null, + source: { + type: a.bank_connection_id ? 'psd2' : a.source === 'file' ? 'bank_file' : 'manual', + synced_at: syncedAt, + stale, + }, + status: stateOf(status), + superseded_by: supersededBy.get(a.id) ?? null, + } + }), + ) + + let skattekonto: ReconciliationAccount | null = null + try { + const s = await getSkattekontoReconciliationStatus(supabase, companyId, { today }) + if (s) { + skattekonto = { + account_key: SKATTEKONTO_ACCOUNT_KEY, + kind: 'skattekonto', + account_number: s.account_number, + name: 'Skattekonto', + currency: 'SEK', + logo_url: '/logos/skatteverket_color.svg', + source: { + type: 'skatteverket_api', + synced_at: s.skattekonto?.fetched_at ?? null, + stale: s.stale, + }, + status: s.skattekonto?.fetched_at ? stateOf(s) : { ...stateOf(s)!, state: 'not_configured' }, + superseded_by: null, + } + } + } catch (err) { + log.warn('skattekonto status failed', { + companyId, + error: err instanceof Error ? err.message : String(err), + }) + } + + return skattekonto ? [...bankAccounts, skattekonto] : bankAccounts +} + +export interface GetAccountStatusOptions { + today?: string + windowFrom?: string | null + windowTo?: string | null +} + +/** + * The bridge for one account. Returns null when the key does not resolve to + * an account of this company (callers map that to 404). + */ +export async function getAccountStatus( + supabase: SupabaseClient, + companyId: string, + accountKey: string, + options: GetAccountStatusOptions = {}, +): Promise { + const parsed = parseAccountKey(accountKey) + if (!parsed) return null + const today = options.today ?? isoDate(new Date()) + + if (parsed.kind === 'skattekonto') { + return getSkattekontoReconciliationStatus(supabase, companyId, { + today, + windowFrom: options.windowFrom ?? null, + windowTo: options.windowTo ?? null, + }) + } + + if (parsed.kind === 'bank') { + const { data, error } = await supabase + .from('cash_accounts') + .select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, updated_at') + .eq('company_id', companyId) + .eq('id', parsed.cashAccountId) + .maybeSingle() + if (error) throw new Error(`Kunde inte hämta kassakonto: ${error.message}`) + if (!data) return null + const window = { + from: options.windowFrom ?? defaultWindow(today).from, + to: options.windowTo ?? defaultWindow(today).to, + } + return bankStatus(supabase, companyId, data as CashAccountRow, window, today) + } + + // manual accounts: later adapter + return null +} diff --git a/lib/reconciliation/skattekonto-reconciliation.ts b/lib/reconciliation/skattekonto-reconciliation.ts new file mode 100644 index 00000000..f9995a7d --- /dev/null +++ b/lib/reconciliation/skattekonto-reconciliation.ts @@ -0,0 +1,589 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' +import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill' +import { createLogger } from '@/lib/logger' +import { roundOre } from '@/lib/money' +import { LEDGER_BALANCE_STATUSES, sumAccountBalance } from './gl-balance' +import { + AWAITING_EXTERNAL_DAYS, + SKATTEKONTO_ACCOUNT_KEY, + STALE_AFTER_DAYS, + type BridgeLine, + type ReconciliationItem, + type ReconciliationProposal, + type ReconciliationStatus, +} from './schemas' + +const log = createLogger('reconciliation/skattekonto') + +/** + * Where the skatteverket extension caches the saldo it last fetched. The + * extension writes it through its settings accessor (extension_data keyed by + * company + extension + key); core reads the same row directly so this engine + * works without importing the extension (core must never import + * `@/extensions/*`). + */ +const SKATTEVERKET_EXTENSION_ID = 'skatteverket' +const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot' + +/** Per-bucket cap on returned items; counts and totals are always complete. */ +const MAX_ITEMS_PER_BUCKET = 500 + +const ENTRY_ID_CHUNK = 100 + +type EntryStatus = 'draft' | 'posted' | 'reversed' + +interface SkattekontoRow { + id: string + transaktionsdatum: string + forfallodatum: string | null + transaktionstext: string + belopp_skatteverket: number | string + status: 'booked' | 'upcoming' + journal_entry_id: string | null + suggested_journal_entry_id: string | null + is_ignored: boolean | null +} + +interface EntryHead { + id: string + status: EntryStatus + voucher_number: number | null + voucher_series: string | null + entry_date: string + description: string | null + source_type: string | null +} + +interface LedgerLineRow { + debit_amount: number | string | null + credit_amount: number | string | null + journal_entries: EntryHead +} + +export interface SkattekontoReconciliationOptions { + /** YYYY-MM-DD used when no snapshot exists and for staleness; defaults to now (UTC). */ + today?: string + /** + * Optional window that scopes the ITEM LISTS (what the page shows). The + * bridge is anchored at the snapshot instant and is never windowed: the + * saldo is cumulative, so is the ledger. Unmatched rows older than the + * window are counted in `older_unmatched_count` so a window can never hide + * work. + */ + windowFrom?: string | null + windowTo?: string | null +} + +export interface SkattekontoReconciliationItems { + proposed: ReconciliationItem[] + unmatched_external: ReconciliationItem[] + unmatched_ledger: ReconciliationItem[] + matched: ReconciliationItem[] + ignored: ReconciliationItem[] + upcoming: ReconciliationItem[] +} + +export interface SkattekontoReconciliationResult extends ReconciliationStatus { + items: SkattekontoReconciliationItems + /** Buckets whose item list was capped at MAX_ITEMS_PER_BUCKET. */ + items_truncated: Array + /** Unmatched rows (either side) dated before windowFrom, when a window was given. */ + older_unmatched_count: number + /** Ledger read failed: balances and the residual are null, buckets are still listed. */ + ledger_read_failed: boolean +} + +function round2(n: number): number { + return roundOre(n) +} + +function addDays(iso: string, days: number): string { + const d = new Date(iso + 'T00:00:00Z') + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} + +function isoDate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function parseFetchedAt(value: unknown): Date | null { + if (typeof value === 'number' && Number.isFinite(value)) return new Date(value) + if (typeof value === 'string') { + const asNumber = Number(value) + const d = /^\d+$/.test(value) ? new Date(asNumber) : new Date(value) + return Number.isNaN(d.getTime()) ? null : d + } + return null +} + +function chunk(xs: T[], size: number): T[][] { + const out: T[][] = [] + for (let i = 0; i < xs.length; i += size) out.push(xs.slice(i, i + size)) + return out +} + +async function readSnapshot( + supabase: SupabaseClient, + companyId: string, +): Promise<{ saldo: number; fetchedAt: Date } | null> { + const { data, error } = await supabase + .from('extension_data') + .select('value') + .eq('company_id', companyId) + .eq('extension_id', SKATTEVERKET_EXTENSION_ID) + .eq('key', BALANCE_SNAPSHOT_KEY) + .maybeSingle() + if (error || !data?.value) return null + const value = data.value as { saldo?: { saldoSkatteverket?: unknown }; fetchedAt?: unknown } + const fetchedAt = parseFetchedAt(value.fetchedAt) + const saldo = Number(value.saldo?.saldoSkatteverket) + if (!fetchedAt || !Number.isFinite(saldo)) return null + return { saldo: round2(saldo), fetchedAt } +} + +async function fetchRows(supabase: SupabaseClient, companyId: string): Promise { + return fetchAllRows( + ({ from, to }) => + supabase + .from('skattekonto_transactions') + .select( + 'id, transaktionsdatum, forfallodatum, transaktionstext, belopp_skatteverket, status, journal_entry_id, suggested_journal_entry_id, is_ignored', + ) + .eq('company_id', companyId) + .order('transaktionsdatum', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (r) => r.id }, + ) +} + +async function fetchEntryHeads( + supabase: SupabaseClient, + companyId: string, + ids: string[], +): Promise> { + const out = new Map() + for (const part of chunk(Array.from(new Set(ids)), ENTRY_ID_CHUNK)) { + const { data, error } = await supabase + .from('journal_entries') + .select('id, status, voucher_number, voucher_series, entry_date, description, source_type') + .eq('company_id', companyId) + .in('id', part) + if (error) throw new Error(`Kunde inte läsa verifikat: ${error.message}`) + for (const e of (data ?? []) as EntryHead[]) out.set(e.id, e) + } + return out +} + +/** + * All 1630 movement per journal entry in [fromDate, cutoffDate], over posted + * + reversed entries (the ledger-balance predicate). One item per entry: an + * entry with several 1630 lines nets them, which is also what a link settles. + */ +async function fetchLedgerEntries( + supabase: SupabaseClient, + companyId: string, + fromDate: string | null, + cutoffDate: string, +): Promise> { + const lines = await fetchEntryLines({ + supabase, + entryColumns: 'id, status, voucher_number, voucher_series, entry_date, description, source_type', + lineColumns: 'debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => { + let query = q + .eq('company_id', companyId) + .in('status', [...LEDGER_BALANCE_STATUSES]) + .lte('entry_date', cutoffDate) + if (fromDate) query = query.gte('entry_date', fromDate) + return query + }, + filterLines: (q: EntryLinesQuery) => q.eq('account_number', SKATTEKONTO_ACCOUNT), + }) + const byEntry = new Map() + for (const line of lines) { + const head = line.journal_entries + if (!head) continue + const amount = Number(line.debit_amount || 0) - Number(line.credit_amount || 0) + const existing = byEntry.get(head.id) + if (existing) existing.amount = round2(existing.amount + amount) + else byEntry.set(head.id, { head, amount: round2(amount) }) + } + return byEntry +} + +function proposalFrom(head: EntryHead, row: SkattekontoRow): ReconciliationProposal { + return { + journal_entry_id: head.id, + voucher_number: head.voucher_number, + voucher_series: head.voucher_series, + entry_date: head.entry_date, + description: head.description ?? '', + entry_status: head.status, + confidence: head.status === 'posted' ? 0.95 : 0.8, + reasons: [ + 'exakt belopp på 1630', + `${Math.abs(daysBetween(head.entry_date, row.transaktionsdatum))} dagars avstånd`, + ], + } +} + +function daysBetween(a: string, b: string): number { + const ms = new Date(a + 'T00:00:00Z').getTime() - new Date(b + 'T00:00:00Z').getTime() + return Math.round(ms / 86_400_000) +} + +function inWindow(date: string, from: string | null | undefined, to: string | null | undefined): boolean { + if (from && date < from) return false + if (to && date > to) return false + return true +} + +/** + * The skattekonto (BAS 1630) reconciliation: what Skatteverket says, what the + * ledger says, and the rows that explain the gap. + * + * Identity (matched pairs cancel by construction, see dev design + * "Avstämningsmotorn"): + * + * saldo_at_start = saldo_skatteverket - sum(all SKV-posted rows we hold) + * opening_difference = saldo_at_start - ledger balance before history_start + * difference = saldo_skatteverket - ledger balance at the snapshot + * unexplained = difference - opening_difference + * - sum(unlinked SKV rows) - sum(ignored SKV rows) + * + sum(unlinked 1630 entries) + * + * Every link pairs equal amounts on the expected side, so `unexplained` is + * 0,00 whenever the data is consistent. A non-zero value is an integrity + * finding (a link to an entry whose 1630 line changed, a read that disagrees + * with the trial balance), never a user task. The user's work is the bridge. + * + * Anchored at the snapshot instant; the ledger is summed with + * entry_date <= the snapshot date so a verifikat booked later today cannot + * fabricate a gap. A link to a reversed or draft entry is a dead link: the + * row is treated as unlinked and flagged, because the ledger no longer counts + * that 1630 line the way the link assumed. + * + * Returns null when the company has neither a saldo snapshot nor any + * skattekonto rows (account not configured). + */ +export async function getSkattekontoReconciliationStatus( + supabase: SupabaseClient, + companyId: string, + options: SkattekontoReconciliationOptions = {}, +): Promise { + const today = options.today ?? isoDate(new Date()) + const [snapshot, rows] = await Promise.all([ + readSnapshot(supabase, companyId), + fetchRows(supabase, companyId), + ]) + if (!snapshot && rows.length === 0) return null + + const cutoffDate = snapshot ? isoDate(snapshot.fetchedAt) : today + const asOf = snapshot ? snapshot.fetchedAt.toISOString() : new Date(today + 'T00:00:00Z').toISOString() + const stale = !snapshot || daysBetween(today, cutoffDate) > STALE_AFTER_DAYS + + const booked = rows.filter((r) => r.status === 'booked' && r.transaktionsdatum <= cutoffDate) + const upcoming = rows.filter((r) => r.status === 'upcoming' && !r.is_ignored) + const historyStart = booked.length > 0 ? booked[0].transaktionsdatum : null + + // Linked and suggested entries: one chunked read gives link liveness and + // the voucher facts the proposals carry. + const referencedIds = booked.flatMap((r) => + [r.journal_entry_id, r.suggested_journal_entry_id].filter((x): x is string => !!x), + ) + const heads = await fetchEntryHeads(supabase, companyId, referencedIds) + + // Ledger side. Both reads may fail independently of the row reads; a + // failed balance read yields null balances and a null residual rather than + // a fabricated 0 (same posture as the drift check). + let ledgerReadFailed = false + let ledgerEntries = new Map() + try { + ledgerEntries = await fetchLedgerEntries(supabase, companyId, historyStart, cutoffDate) + } catch (err) { + ledgerReadFailed = true + log.warn('ledger entries read failed', { + companyId, + error: err instanceof Error ? err.message : String(err), + }) + } + const [ledgerBalance, ledgerBefore] = await Promise.all([ + sumAccountBalance(supabase, companyId, SKATTEKONTO_ACCOUNT, { cutoffDate }), + historyStart + ? sumAccountBalance(supabase, companyId, SKATTEKONTO_ACCOUNT, { beforeDate: historyStart }) + : Promise.resolve(0), + ]) + if (ledgerBalance === null || ledgerBefore === null) ledgerReadFailed = true + + const liveLinkedEntryIds = new Set() + const items: SkattekontoReconciliationItems = { + proposed: [], + unmatched_external: [], + unmatched_ledger: [], + matched: [], + ignored: [], + upcoming: [], + } + let unlinkedExternalTotal = 0 + let ignoredTotal = 0 + let olderUnmatched = 0 + const counts = { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 0, ignored: 0 } + + const pushCapped = (bucket: keyof SkattekontoReconciliationItems, item: ReconciliationItem) => { + if (items[bucket].length < MAX_ITEMS_PER_BUCKET) items[bucket].push(item) + } + const visible = (date: string) => inWindow(date, options.windowFrom, options.windowTo) + const olderThanWindow = (date: string) => !!options.windowFrom && date < options.windowFrom + + for (const row of booked) { + const amount = round2(Number(row.belopp_skatteverket)) + const base = { + item_id: row.id, + item_type: 'skattekonto_transaction' as const, + side: 'external' as const, + date: row.transaktionsdatum, + description: row.transaktionstext, + amount, + currency: 'SEK', + } + + if (row.is_ignored) { + ignoredTotal = round2(ignoredTotal + amount) + counts.ignored++ + if (visible(row.transaktionsdatum)) { + pushCapped('ignored', { ...base, bucket: 'ignored', actions: ['unignore'] }) + } + continue + } + + const linkedHead = row.journal_entry_id ? heads.get(row.journal_entry_id) : undefined + const linkProblem: ReconciliationItem['link_problem'] = row.journal_entry_id + ? !linkedHead + ? 'entry_missing' + : linkedHead.status === 'reversed' + ? 'entry_reversed' + : linkedHead.status === 'draft' + ? 'entry_draft' + : null + : null + + if (row.journal_entry_id && linkedHead && linkProblem === null) { + liveLinkedEntryIds.add(row.journal_entry_id) + counts.matched++ + if (visible(row.transaktionsdatum)) { + pushCapped('matched', { + ...base, + bucket: 'matched', + linked_journal_entry_id: row.journal_entry_id, + voucher_number: linkedHead.voucher_number, + voucher_series: linkedHead.voucher_series, + entry_status: linkedHead.status, + actions: ['unmatch'], + }) + } + continue + } + + // Unlinked (or dead link): counts toward the bridge either way. + unlinkedExternalTotal = round2(unlinkedExternalTotal + amount) + if (olderThanWindow(row.transaktionsdatum)) olderUnmatched++ + + const suggestedHead = row.suggested_journal_entry_id + ? heads.get(row.suggested_journal_entry_id) + : undefined + const proposal = + suggestedHead && suggestedHead.status !== 'reversed' && !liveLinkedEntryIds.has(suggestedHead.id) + ? proposalFrom(suggestedHead, row) + : null + + if (proposal) { + counts.proposed++ + if (visible(row.transaktionsdatum)) { + pushCapped('proposed', { + ...base, + bucket: 'proposed', + linked_journal_entry_id: row.journal_entry_id, + link_problem: linkProblem, + proposal, + actions: ['match', 'book', 'ignore'], + }) + } + } else { + counts.unmatched_external++ + if (visible(row.transaktionsdatum)) { + pushCapped('unmatched_external', { + ...base, + bucket: 'unmatched_external', + linked_journal_entry_id: row.journal_entry_id, + link_problem: linkProblem, + proposal: null, + actions: linkProblem ? ['match', 'book', 'unmatch'] : ['book', 'match', 'ignore'], + }) + } + } + } + + // Ledger entries in the comparable history that no live link settles. + let unlinkedLedgerTotal = 0 + const awaitingFrom = addDays(cutoffDate, -AWAITING_EXTERNAL_DAYS) + const sortedLedger = Array.from(ledgerEntries.values()).sort((a, b) => + a.head.entry_date < b.head.entry_date ? -1 : a.head.entry_date > b.head.entry_date ? 1 : 0, + ) + for (const { head, amount } of sortedLedger) { + if (liveLinkedEntryIds.has(head.id)) continue + if (amount === 0) continue + unlinkedLedgerTotal = round2(unlinkedLedgerTotal + amount) + counts.unmatched_ledger++ + if (olderThanWindow(head.entry_date)) olderUnmatched++ + if (!visible(head.entry_date)) continue + pushCapped('unmatched_ledger', { + item_id: head.id, + item_type: 'journal_entry', + side: 'ledger', + bucket: 'unmatched_ledger', + date: head.entry_date, + description: head.description ?? '', + amount, + currency: 'SEK', + voucher_number: head.voucher_number, + voucher_series: head.voucher_series, + entry_status: head.status, + awaiting_external: head.entry_date >= awaitingFrom, + actions: ['review', 'match'], + }) + } + + let upcomingTotal = 0 + for (const row of upcoming) { + const amount = round2(Number(row.belopp_skatteverket)) + upcomingTotal = round2(upcomingTotal + amount) + pushCapped('upcoming', { + item_id: row.id, + item_type: 'skattekonto_transaction', + side: 'external', + bucket: 'upcoming', + date: row.forfallodatum ?? row.transaktionsdatum, + description: row.transaktionstext, + amount, + currency: 'SEK', + actions: [], + }) + } + + // Totals and the identity. + const allBookedTotal = booked.reduce((s, r) => round2(s + Number(r.belopp_skatteverket)), 0) + const saldo = snapshot?.saldo ?? null + const saldoAtStart = saldo === null ? null : round2(saldo - allBookedTotal) + const openingDifference = + saldoAtStart === null || ledgerBefore === null ? null : round2(saldoAtStart - ledgerBefore) + const difference = saldo === null || ledgerBalance === null ? null : round2(saldo - ledgerBalance) + const unexplained = + difference === null || openingDifference === null + ? null + : round2( + difference - openingDifference - unlinkedExternalTotal - ignoredTotal + unlinkedLedgerTotal, + ) + + const isReconciled = + !!snapshot && + !ledgerReadFailed && + counts.unmatched_external === 0 && + counts.proposed === 0 && + counts.unmatched_ledger === 0 && + Math.abs(openingDifference ?? 0) < 0.01 && + Math.abs(unexplained ?? 0) < 0.01 + + const bridge: BridgeLine[] = [ + { + key: 'external_balance', + label_sv: 'Saldo hos Skatteverket', + label_en: 'Balance at Skatteverket', + amount: saldo ?? 0, + count: null, + items_bucket: null, + }, + { + key: 'unmatched_external', + label_sv: 'Händelser som saknas i bokföringen', + label_en: 'Events missing from the ledger', + amount: round2(-unlinkedExternalTotal), + count: counts.unmatched_external + counts.proposed, + items_bucket: 'unmatched_external', + }, + { + key: 'unmatched_ledger', + label_sv: 'Rader på 1630 utan händelse hos Skatteverket', + label_en: '1630 lines without a Skatteverket event', + amount: unlinkedLedgerTotal, + count: counts.unmatched_ledger, + items_bucket: 'unmatched_ledger', + }, + ] + if (counts.ignored > 0) { + bridge.push({ + key: 'ignored', + label_sv: 'Ignorerade händelser', + label_en: 'Ignored events', + amount: round2(-ignoredTotal), + count: counts.ignored, + items_bucket: 'ignored', + }) + } + if (openingDifference !== null && Math.abs(openingDifference) >= 0.01) { + bridge.push({ + key: 'opening_difference', + label_sv: `Ingående skillnad per ${historyStart ?? cutoffDate}`, + label_en: `Opening difference at ${historyStart ?? cutoffDate}`, + amount: round2(-openingDifference), + count: null, + items_bucket: null, + }) + } + bridge.push({ + key: 'ledger_balance', + label_sv: 'Bokfört på 1630', + label_en: 'Booked on 1630', + amount: ledgerBalance ?? 0, + count: null, + items_bucket: null, + }) + + const truncated = (Object.keys(items) as Array).filter( + (k) => items[k].length >= MAX_ITEMS_PER_BUCKET, + ) + + return { + account_key: SKATTEKONTO_ACCOUNT_KEY, + kind: 'skattekonto', + account_number: SKATTEKONTO_ACCOUNT, + currency: 'SEK', + window: { from: options.windowFrom ?? null, to: options.windowTo ?? null }, + as_of: asOf, + stale, + external_balance: saldo, + ledger_balance: ledgerBalance, + difference, + unexplained_difference: unexplained, + is_reconciled: isReconciled, + bridge, + counts, + skattekonto: { + saldo_skatteverket: saldo, + fetched_at: snapshot ? snapshot.fetchedAt.toISOString() : null, + history_start: historyStart, + opening_difference: openingDifference, + upcoming_count: upcoming.length, + upcoming_total: upcomingTotal, + ledger_balance_before_start: ledgerBefore, + }, + bank: null, + items, + items_truncated: truncated, + older_unmatched_count: olderUnmatched, + ledger_read_failed: ledgerReadFailed, + } +} diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index de325700..49e02243 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -7,7 +7,7 @@ ] }, "naiveOreRound": { - "count": 629 + "count": 626 }, "handRolledInvariants": { "count": 115 diff --git a/supabase/migrations/20260823120000_skattekonto_transactions_suggested_journal_entry.sql b/supabase/migrations/20260823120000_skattekonto_transactions_suggested_journal_entry.sql new file mode 100644 index 00000000..51292d7c --- /dev/null +++ b/supabase/migrations/20260823120000_skattekonto_transactions_suggested_journal_entry.sql @@ -0,0 +1,38 @@ +-- Migration: skattekonto_transactions.suggested_journal_entry_id +-- +-- Reconciliation proposals for the skattekonto (1630). Until now the "Möjlig +-- dubblett av A 214" hint was recomputed on every page load by +-- findMatchSuggestionsBulk and lived nowhere, so nothing outside the +-- /skattekonto request (worklist counts, the reconciliation summary, agents) +-- could know that a row already has an exact twin in the ledger. Measured on +-- prod 2026-08-20: 757 of 2 397 "open" rows had an exact-amount 1630 line +-- within +-7 days, i.e. the work was already done through another door. +-- +-- The sync writes the single best candidate here (one-to-one across rows, +-- AGI period first, then nearest date) and clears it when the row is linked +-- or the candidate stops qualifying. It is a PROPOSAL, never a link: +-- journal_entry_id is the only link, and only a click (or an approved staged +-- operation) moves a proposal into it. Propose-only by design; see +-- DECISIONS.md 2026-08-23. +-- +-- ON DELETE SET NULL mirrors journal_entry_id: a deleted draft must not leave +-- a dangling pointer. A proposal is meaningless on a linked row, so the +-- partial index only covers unlinked rows: that is the worklist predicate +-- "rows with a twin" and the count the reconciliation summary reads. +-- +-- RLS: the company-scoped SELECT/UPDATE policies on skattekonto_transactions +-- already cover these columns; no policy change. + +ALTER TABLE public.skattekonto_transactions + ADD COLUMN IF NOT EXISTS suggested_journal_entry_id UUID + REFERENCES public.journal_entries(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS suggested_at TIMESTAMPTZ; + +COMMENT ON COLUMN public.skattekonto_transactions.suggested_journal_entry_id IS + 'Best exact-twin verifikat proposed by the sync (one-to-one across rows). A proposal, never a link: journal_entry_id is the link.'; +COMMENT ON COLUMN public.skattekonto_transactions.suggested_at IS + 'When suggested_journal_entry_id was last written by the sync.'; + +CREATE INDEX IF NOT EXISTS idx_skattekonto_transactions_suggested_open + ON public.skattekonto_transactions (company_id) + WHERE suggested_journal_entry_id IS NOT NULL AND journal_entry_id IS NULL; diff --git a/types/skatteverket.ts b/types/skatteverket.ts index 76d63b28..683292b0 100644 --- a/types/skatteverket.ts +++ b/types/skatteverket.ts @@ -32,6 +32,13 @@ export interface StoredSkattekontoTransaction { file_import_id: string | null imported_at: string updated_at: string + /** + * Best exact-twin verifikat proposed by the sync (migration 20260823120000). + * A proposal, never a link: journal_entry_id is the only link. Optional on + * the type because rows fetched with a narrower select omit it. + */ + suggested_journal_entry_id?: string | null + suggested_at?: string | null } /** Row shape for the `skattekonto_file_imports` tracking table (DB → app). */