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
co-authored by Claude Fable 5 Jakob Wennberg
parent 7cf15a105f
commit 0a8544e0cb
17 changed files with 2328 additions and 62 deletions
@@ -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,