feat(reconciliation): account-keyed engine: one bridge for bank and skattekonto (#1813)

* 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:<cash_account_id> | 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 13:55:08 +02:00
committed by GitHub
parent 7cf15a105f
commit 0a8544e0cb
17 changed files with 2328 additions and 62 deletions
+1
View File
@@ -1176,3 +1176,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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<typeof lineRow>[],
) {
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')
})
})
@@ -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()
})
})
@@ -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<number | null> {
// 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(
@@ -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<string, SkattekontoMatchCandidate>()
// 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<string, EntryView>()
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<string, SkattekontoMatchCandidate[]>()
const periodIdsByRow = new Map<string, Set<string> | 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<string>()
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<string, SkattekontoMatchCandidate>()
const usedEntries = new Set<string>()
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)
@@ -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<RefreshProposalsResult> {
const zero: RefreshProposalsResult = { considered: 0, proposed: 0, cleared: 0, unchanged: 0 }
let rows: OpenRow[]
try {
rows = await fetchAllRows<OpenRow>(
({ 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 }
}
@@ -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,
@@ -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()
})
})
@@ -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<string, unknown> = {}) {
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<string, unknown> = {}) {
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 })
})
})
@@ -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<typeof import('../gl-balance')>()
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> = {}): 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<typeof row>[]
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<typeof ledgerLine>[], 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')
})
})
+81
View File
@@ -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<number | null> {
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)
}
+200
View File
@@ -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:<cash_account_id> one cash_accounts row (PSD2 or file-fed)
* skattekonto the company's Skatteverket tax account (BAS 1630)
* manual:<account_number> 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<typeof AccountKeySchema>
export const ReconciliationKindSchema = z.enum(['bank', 'skattekonto', 'manual'])
export type ReconciliationKind = z.infer<typeof ReconciliationKindSchema>
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<typeof ReconciliationAccountSchema>
/** 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<typeof BridgeLineSchema>
export const ReconciliationItemBucketSchema = z.enum([
'proposed',
'unmatched_external',
'unmatched_ledger',
'matched',
'ignored',
'upcoming',
])
export type ReconciliationItemBucket = z.infer<typeof ReconciliationItemBucketSchema>
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<typeof ReconciliationProposalSchema>
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<typeof ReconciliationItemSchema>
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<typeof ReconciliationStatusSchema>
/** 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
+360
View File
@@ -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<string | null> {
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<ReturnType<typeof getBankReconciliationStatus>>, 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<ReconciliationStatus> {
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<string, unknown>,
}
}
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<ReconciliationAccount[]> {
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<string, string>()
const byIban = new Map<string, CashAccountRow[]>()
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<ReconciliationAccount> => {
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<ReconciliationStatus | null> {
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
}
@@ -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<keyof SkattekontoReconciliationItems>
/** 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<T>(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<SkattekontoRow[]> {
return fetchAllRows<SkattekontoRow>(
({ 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<Map<string, EntryHead>> {
const out = new Map<string, EntryHead>()
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<Map<string, { head: EntryHead; amount: number }>> {
const lines = await fetchEntryLines<LedgerLineRow>({
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<string, { head: EntryHead; amount: number }>()
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<SkattekontoReconciliationResult | null> {
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<string, { head: EntryHead; amount: number }>()
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<number | null>(0),
])
if (ledgerBalance === null || ledgerBefore === null) ledgerReadFailed = true
const liveLinkedEntryIds = new Set<string>()
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<keyof SkattekontoReconciliationItems>).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,
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
]
},
"naiveOreRound": {
"count": 629
"count": 626
},
"handRolledInvariants": {
"count": 115
@@ -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;
+7
View File
@@ -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). */