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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
]
|
||||
},
|
||||
"naiveOreRound": {
|
||||
"count": 629
|
||||
"count": 626
|
||||
},
|
||||
"handRolledInvariants": {
|
||||
"count": 115
|
||||
|
||||
+38
@@ -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;
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user