feat: implement skattekonto drift detection and alerting (#525)

* feat: implement skattekonto drift detection and alerting

- Add skattekonto drift computation logic to compare Skatteverket's saldo with GL 1630 sum.
- Implement alerting mechanism for significant drift changes, with throttling to prevent alert spamming.
- Introduce database functions to sum GL 1630 entries and list unbooked skattekonto rows.

feat: create own account transfer detection

- Develop logic to detect transfers between a company's own cash accounts based on counterparty IBAN.
- Implement tests to validate detection logic under various scenarios, including matching and non-matching IBANs.

feat: establish cash accounts as a first-class entity

- Create cash_accounts table to manage routable cash accounts, replacing ad-hoc JSONB structures.
- Implement functions for listing, upserting, and managing cash accounts, including primary account designation.

feat: enhance GL line reconciliation functionality

- Modify get_unlinked_1930_lines RPC to accept any account number for reconciliation, improving flexibility for different currencies.
- Update related functions to ensure compatibility with the new cash_accounts structure.

feat: capture counterparty IBAN in transactions

- Add counterparty_iban column to transactions table to facilitate intra-account transfer detection.
- Create index for efficient lookups based on counterparty IBAN.

* feat: Enhance cash account handling and reconciliation processes

- Updated reconciliation routes to enforce cash account validation for all account numbers, including '1930'.
- Improved error handling for unknown cash accounts in reconciliation status and unmatched entries routes.
- Changed CashAccountSelector to use sessionStorage instead of localStorage for better data privacy.
- Fixed mapping for employer payroll taxes to route to the correct account (2730 instead of 2731).
- Added safety checks for company IDs in the guessCounterAccount function to prevent injection vulnerabilities.
- Introduced atomic RPC for setting primary cash accounts to avoid intermediate states during updates.
- Seeded default cash accounts for new companies to ensure reconciliation routes are accessible from day one.
- Updated email notifications for drift detection to avoid exposing sensitive financial data.
- Enhanced bank reconciliation logic to handle multi-currency transactions correctly.
- Renamed and updated tests to reflect changes in the underlying RPCs and ensure accurate coverage.
- Migrated existing cash account rules to correct mappings in compliance with Swedish accounting standards.
This commit is contained in:
Mattsson
2026-05-19 16:10:18 +02:00
committed by GitHub
parent e211ab31be
commit 8a6ce7093e
42 changed files with 3420 additions and 206 deletions
@@ -627,6 +627,34 @@ export const enableBankingExtension: Extension = {
return NextResponse.json({ error: 'Kunde inte spara kontoval' }, { status: 500 })
}
// Mirror the user's selection into cash_accounts so routing decisions
// and reconciliation pick up the new enabled state + ledger mapping
// without reading the JSONB column.
{
const { upsertFromPsd2 } = await import('@/lib/cash-accounts/service')
for (const a of updatedAccounts) {
try {
await upsertFromPsd2(supabase, companyId, {
bank_connection_id: connection.id,
external_uid: a.uid,
currency: a.currency,
ledger_account: a.ledger_account ?? '1930',
iban: a.iban ?? null,
name: a.name ?? null,
balance: a.balance ?? null,
balance_updated_at: a.balance_updated_at ?? null,
enabled: a.enabled ?? true,
})
} catch (cashErr) {
log.error('[enable-banking] Failed to mirror cash_account on selection save', {
connectionId: connection.id,
uid: a.uid,
error: cashErr instanceof Error ? cashErr.message : String(cashErr),
})
}
}
}
const newStatus = updatePayload.status ?? connection.status
log.info('[enable-banking] Account selection saved', {
connectionId: connection.id,
+22 -13
View File
@@ -99,19 +99,28 @@ export async function syncAccountTransactions(
const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency))
// Convert Enable Banking format to generic RawTransaction
const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => ({
date: tx.booking_date || tx.date,
description: tx.description || tx.counterparty_name || 'Unknown',
amount: tx.amount,
currency: tx.currency || account.currency,
external_id: `eb_${account.iban || account.uid}_${tx.id}`,
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
merchant_name: tx.counterparty_name || null,
reference: tx.reference || null,
bank_connection_id: connectionId,
import_source: 'enable_banking',
}))
// Convert Enable Banking format to generic RawTransaction. counterparty
// identification: prefer IBAN (international, normalized) over BBAN/BG
// numbers — the own-account detector matches on IBAN first, falling back
// to counterparty_account for Swedish domestic transfers.
const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => {
const cpAccount = tx.counterparty_account ?? null
const looksLikeIban = cpAccount && /^[A-Z]{2}\d/.test(cpAccount.replace(/\s+/g, ''))
return {
date: tx.booking_date || tx.date,
description: tx.description || tx.counterparty_name || 'Unknown',
amount: tx.amount,
currency: tx.currency || account.currency,
external_id: `eb_${account.iban || account.uid}_${tx.id}`,
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
merchant_name: tx.counterparty_name || null,
reference: tx.reference || null,
bank_connection_id: connectionId,
import_source: 'enable_banking',
counterparty_iban: looksLikeIban ? cpAccount!.replace(/\s+/g, '') : null,
counterparty_account: !looksLikeIban ? cpAccount : null,
}
})
const ingestOptions: IngestOptions = {}
if (syncOptions?.skipAutoCategorization) ingestOptions.skipAutoCategorization = true
@@ -1,68 +1,241 @@
import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { guessCounterAccount } from '../lib/skattekonto-booking'
/**
* System seeds mirror supabase/migrations/20260519100000_skattekonto_rules.sql.
* Kept in lockstep so the resolver behaves identically against mock and real DB.
*/
const SEED_RULES = [
{
id: 'sys-1', priority: 10, pattern: 'inbetalning bokförd,inbetalning,överföring från bank',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '__PRIMARY_SEK__', counter_account_ef: null,
label: 'Inbetalning till skattekonto', active: true,
},
{
id: 'sys-2', priority: 10, pattern: 'utbetalning,återbetalning',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '__PRIMARY_SEK__', counter_account_ef: null,
label: 'Utbetalning från skattekonto', active: true,
},
{
id: 'sys-3', priority: 20, pattern: 'debiterad preliminärskatt,preliminärskatt,f-skatt,fskatt',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '2510', counter_account_ef: '2012',
label: 'Preliminär skatt', active: true,
},
{
id: 'sys-4', priority: 20, pattern: 'arbetsgivaravgift,sociala avgifter,agi',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '2730', counter_account_ef: null,
label: 'Arbetsgivaravgifter', active: true,
},
{
id: 'sys-5', priority: 20, pattern: 'avdragen skatt,personalskatt,a-skatt',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '2710', counter_account_ef: null,
label: 'Avdragen skatt anställda', active: true,
},
{
id: 'sys-6', priority: 20, pattern: 'mervärdesskatt,moms,momsdeklaration',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '2650', counter_account_ef: null,
label: 'Redovisningskonto för moms', active: true,
},
{
id: 'sys-7', priority: 25, pattern: 'skattetillägg,förseningsavgift',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '6992', counter_account_ef: null,
label: 'Ej avdragsgilla skatteavgifter', active: true,
},
{
id: 'sys-8', priority: 30, pattern: 'kostnadsränta',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '8423', counter_account_ef: null,
label: 'Kostnadsränta skattekonto', active: true,
},
{
id: 'sys-9', priority: 30, pattern: 'intäktsränta',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '8314', counter_account_ef: null,
label: 'Intäktsränta skattekonto', active: true,
},
]
function makeSupabase() {
return createQueuedMockSupabase()
}
/**
* Queue setup helper. Each call to guessCounterAccount makes:
* 1. skattekonto_rules query (always)
* 2. cash_accounts query (only when a __PRIMARY_SEK__ rule matches)
*
* primarySekRow lets a test stub a cash_accounts.getPrimary result so the
* sentinel resolves to that row's ledger_account. When omitted, the fallback
* '1930' is used (sentinel hit but no row in cash_accounts).
*/
function enqueueRules(
enqueue: ReturnType<typeof createQueuedMockSupabase>['enqueue'],
rules = SEED_RULES,
primarySekRow: { ledger_account: string } | null = null,
) {
enqueue({ data: rules })
enqueue({ data: primarySekRow }) // cash_accounts.maybeSingle()
// anyPrimary fallback inside getPrimary triggers only when first lookup is null
if (!primarySekRow) enqueue({ data: null })
}
describe('guessCounterAccount', () => {
it('routes "Inbetalning bokförd" to bank account 1930', () => {
const guess = guessCounterAccount('Inbetalning bokförd 240412', 'aktiebolag')
it('routes "Inbetalning bokförd" via __PRIMARY_SEK__ sentinel to 1930 fallback', async () => {
const { supabase, enqueue } = makeSupabase()
enqueueRules(enqueue)
const guess = await guessCounterAccount(
supabase as unknown as SupabaseClient, 'company-1', 'Inbetalning bokförd 240412', 'aktiebolag',
)
expect(guess?.account).toBe('1930')
})
it('routes refund-style descriptions to 1930', () => {
expect(guessCounterAccount('Utbetalning 1234', 'aktiebolag')?.account).toBe('1930')
expect(guessCounterAccount('Återbetalning av moms', 'aktiebolag')?.account).toBe('1930')
it('resolves __PRIMARY_SEK__ to the cash_accounts.is_primary row when present', async () => {
const { supabase, enqueue } = makeSupabase()
enqueueRules(enqueue, SEED_RULES, { ledger_account: '1932' })
const guess = await guessCounterAccount(
supabase as unknown as SupabaseClient, 'company-1', 'Inbetalning bokförd', 'aktiebolag',
)
expect(guess?.account).toBe('1932')
})
it('uses 2510 for AB preliminär skatt and 2012 for EF', () => {
it('routes refund-style descriptions via __PRIMARY_SEK__ sentinel', async () => {
const { supabase, enqueue } = makeSupabase()
enqueueRules(enqueue)
expect(
guessCounterAccount('Debiterad preliminärskatt', 'aktiebolag')?.account,
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Utbetalning 1234', 'aktiebolag'))?.account,
).toBe('1930')
enqueueRules(enqueue)
expect(
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Återbetalning av moms', 'aktiebolag'))?.account,
).toBe('1930')
})
it('uses 2510 for AB preliminär skatt and 2012 for EF', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
expect(
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Debiterad preliminärskatt', 'aktiebolag'))?.account,
).toBe('2510')
enqueue({ data: SEED_RULES })
expect(
guessCounterAccount('Debiterad preliminärskatt', 'enskild_firma')?.account,
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Debiterad preliminärskatt', 'enskild_firma'))?.account,
).toBe('2012')
})
it('routes employer payroll taxes to 2731', () => {
it('routes employer payroll taxes to 2730 (clearing/redovisningskonto, not 2731 accrual)', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
expect(
guessCounterAccount('Arbetsgivaravgifter januari', 'aktiebolag')?.account,
).toBe('2731')
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Arbetsgivaravgifter januari', 'aktiebolag'))?.account,
).toBe('2730')
enqueue({ data: SEED_RULES })
expect(
guessCounterAccount('Sociala avgifter Q1', 'aktiebolag')?.account,
).toBe('2731')
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Sociala avgifter Q1', 'aktiebolag'))?.account,
).toBe('2730')
})
it('routes deducted income tax to 2710', () => {
it('routes deducted income tax to 2710', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
expect(
guessCounterAccount('Avdragen skatt anställda', 'aktiebolag')?.account,
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Avdragen skatt anställda', 'aktiebolag'))?.account,
).toBe('2710')
})
it('routes VAT settlements to 2650', () => {
it('routes VAT settlements to 2650', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
expect(
guessCounterAccount('Mervärdesskatt mars', 'aktiebolag')?.account,
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Mervärdesskatt mars', 'aktiebolag'))?.account,
).toBe('2650')
enqueue({ data: SEED_RULES })
expect(
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Moms Q1 2025', 'aktiebolag'))?.account,
).toBe('2650')
expect(guessCounterAccount('Moms Q1 2025', 'aktiebolag')?.account).toBe(
'2650',
)
})
it('routes interest to 8423/8313', () => {
it('routes interest to 8423 (kostnadsränta) and 8314 (skattefri intäktsränta)', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
expect(
guessCounterAccount('Kostnadsränta skattekonto', 'aktiebolag')?.account,
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Kostnadsränta skattekonto', 'aktiebolag'))?.account,
).toBe('8423')
enqueue({ data: SEED_RULES })
// Skattekontoräntan är skattefri per IL 8 kap 7 § — 8314, inte 8313.
expect(
guessCounterAccount('Intäktsränta skattekonto', 'aktiebolag')?.account,
).toBe('8313')
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Intäktsränta skattekonto', 'aktiebolag'))?.account,
).toBe('8314')
})
it('returns null when no keyword matches', () => {
it('routes skattetillägg and förseningsavgift to 6992 (non-deductible)', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
expect(
guessCounterAccount('Något konstigt vi inte känner igen', 'aktiebolag'),
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Skattetillägg 20%', 'aktiebolag'))?.account,
).toBe('6992')
enqueue({ data: SEED_RULES })
// Förseningsavgift contains the substring "förseningsavgift". The "moms" suffix
// would also match a lower-priority rule (2650), but priority 25 (penalty)
// beats priority 20 (moms) — penalty routing wins.
expect(
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Förseningsavgift arbetsgivardeklaration', 'aktiebolag'))?.account,
).toBe('6992')
})
it('does NOT route plain omprövning to 6992 — underlying tax rules win', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
// "Omprövning av momsdeklaration" should route to moms (2650), because
// omprövning is a re-assessment of the underlying tax, not a penalty.
expect(
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Omprövning av momsdeklaration', 'aktiebolag'))?.account,
).toBe('2650')
})
it('returns null for anstånd — SKV-side deferral, GL does not move', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
expect(
await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Anstånd med skattebetalning', 'aktiebolag'),
).toBeNull()
})
it('matches case-insensitively', () => {
it('returns null when no keyword matches', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
expect(
guessCounterAccount('INBETALNING BOKFÖRD 240412', 'aktiebolag')?.account,
await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Något konstigt vi inte känner igen', 'aktiebolag'),
).toBeNull()
})
it('matches case-insensitively (via __PRIMARY_SEK__ sentinel)', async () => {
const { supabase, enqueue } = makeSupabase()
enqueueRules(enqueue)
expect(
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'INBETALNING BOKFÖRD 240412', 'aktiebolag'))?.account,
).toBe('1930')
})
it('returns null when the rules table is empty', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: [] })
expect(
await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Inbetalning bokförd', 'aktiebolag'),
).toBeNull()
})
})
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
computeSkattekontoDrift,
maybeAlertDrift,
} from '../lib/skattekonto-drift'
function fakeCtx(overrides: {
supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
settings: { get: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; clear?: ReturnType<typeof vi.fn> }
emit?: ReturnType<typeof vi.fn>
}) {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'skatteverket',
supabase: overrides.supabase,
emit: overrides.emit ?? vi.fn().mockResolvedValue(undefined),
settings: {
get: overrides.settings.get,
set: overrides.settings.set,
clear: overrides.settings.clear ?? vi.fn(),
},
storage: {} as unknown,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown,
services: {} as unknown,
} as unknown as Parameters<typeof computeSkattekontoDrift>[0]
}
describe('computeSkattekontoDrift', () => {
beforeEach(() => vi.clearAllMocks())
it('returns null when no snapshot has been cached', async () => {
const { supabase } = createQueuedMockSupabase()
const ctx = fakeCtx({
supabase,
settings: { get: vi.fn().mockResolvedValue(null), set: vi.fn() },
})
const drift = await computeSkattekontoDrift(ctx)
expect(drift).toBeNull()
})
it('computes drift = saldoSkatteverket - GL 1630 sum (positive when SKV ahead)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// GL 1630 query: 5000 SEK debit
enqueue({
data: [{ debit_amount: 5000, credit_amount: 0 }],
})
// Unbooked rows query
enqueue({ data: [] })
const ctx = fakeCtx({
supabase,
settings: {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'skattekonto_balance_snapshot') {
return Promise.resolve({
saldo: { saldoSkatteverket: 5500, saldoKronofogden: 0 },
fetchedAt: new Date('2026-06-12T04:00:00Z').getTime(),
})
}
return Promise.resolve(null)
}),
set: vi.fn(),
},
})
const drift = await computeSkattekontoDrift(ctx)
expect(drift).not.toBeNull()
expect(drift!.saldoSkatteverket).toBe(5500)
expect(drift!.glSum1630).toBe(5000)
expect(drift!.drift).toBe(500)
expect(drift!.tolerance).toBe(1)
})
it('honors a per-company override of the tolerance', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] })
enqueue({ data: [] })
const ctx = fakeCtx({
supabase,
settings: {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'skattekonto_balance_snapshot') {
return Promise.resolve({
saldo: { saldoSkatteverket: 1000.5, saldoKronofogden: 0 },
fetchedAt: Date.now(),
})
}
if (key === 'skattekonto_drift_tolerance') return Promise.resolve(100)
return Promise.resolve(null)
}),
set: vi.fn(),
},
})
const drift = await computeSkattekontoDrift(ctx)
expect(drift!.tolerance).toBe(100)
})
})
describe('maybeAlertDrift', () => {
it('does NOT emit when |drift| <= tolerance', async () => {
const { supabase } = createQueuedMockSupabase()
const emit = vi.fn().mockResolvedValue(undefined)
const ctx = fakeCtx({
supabase,
settings: { get: vi.fn().mockResolvedValue(null), set: vi.fn() },
emit,
})
const alerted = await maybeAlertDrift(ctx, {
saldoSkatteverket: 100,
glSum1630: 100.5,
drift: -0.5,
fetchedAt: Date.now(),
tolerance: 1,
unbookedRows: [],
})
expect(alerted).toBe(false)
expect(emit).not.toHaveBeenCalled()
})
it('emits skattekonto.drift_detected on a fresh drift', async () => {
const { supabase } = createQueuedMockSupabase()
const emit = vi.fn().mockResolvedValue(undefined)
const setSpy = vi.fn().mockResolvedValue(undefined)
const ctx = fakeCtx({
supabase,
settings: { get: vi.fn().mockResolvedValue(null), set: setSpy },
emit,
})
const alerted = await maybeAlertDrift(ctx, {
saldoSkatteverket: 5000,
glSum1630: 4000,
drift: 1000,
fetchedAt: Date.now(),
tolerance: 1,
unbookedRows: [],
})
expect(alerted).toBe(true)
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'skattekonto.drift_detected' }),
)
expect(setSpy).toHaveBeenCalledWith(
'skattekonto_drift_last_alert_at',
expect.objectContaining({ lastSign: 1 }),
)
})
it('suppresses repeat alerts within the 24h throttle when sign is unchanged', async () => {
const { supabase } = createQueuedMockSupabase()
const emit = vi.fn().mockResolvedValue(undefined)
const now = Date.now()
const ctx = fakeCtx({
supabase,
settings: {
get: vi.fn().mockResolvedValue({
lastAlertAt: now - 60 * 60 * 1000, // 1h ago
lastSign: 1,
}),
set: vi.fn(),
},
emit,
})
const alerted = await maybeAlertDrift(ctx, {
saldoSkatteverket: 5000,
glSum1630: 4000,
drift: 1000,
fetchedAt: now,
tolerance: 1,
unbookedRows: [],
})
expect(alerted).toBe(false)
expect(emit).not.toHaveBeenCalled()
})
it('re-alerts when the sign flips even within the throttle window', async () => {
const { supabase } = createQueuedMockSupabase()
const emit = vi.fn().mockResolvedValue(undefined)
const ctx = fakeCtx({
supabase,
settings: {
get: vi.fn().mockResolvedValue({
lastAlertAt: Date.now() - 60 * 60 * 1000,
lastSign: 1,
}),
set: vi.fn(),
},
emit,
})
const alerted = await maybeAlertDrift(ctx, {
saldoSkatteverket: 3000,
glSum1630: 4000,
drift: -1000,
fetchedAt: Date.now(),
tolerance: 1,
unbookedRows: [],
})
expect(alerted).toBe(true)
expect(emit).toHaveBeenCalled()
})
})
@@ -0,0 +1,265 @@
import { describe, it, expect } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
parseAgiPeriod,
} from '../lib/skattekonto-match'
const COMPANY = 'company-1'
describe('parseAgiPeriod', () => {
it('extracts year and month from "Arbetsgivardeklaration YYYYMM"', () => {
expect(parseAgiPeriod('Arbetsgivardeklaration 202605')).toEqual({
year: 2026,
month: 5,
})
})
it('accepts the dash variant "YYYY-MM"', () => {
expect(parseAgiPeriod('arbetsgivardeklaration 2026-05')).toEqual({
year: 2026,
month: 5,
})
})
it('is case-insensitive and tolerates leading whitespace', () => {
expect(parseAgiPeriod(' ARBETSGIVARDEKLARATION 202611')).toEqual({
year: 2026,
month: 11,
})
})
it('falls back to a numeric YYYYMM after any AGI-adjacent keyword', () => {
expect(parseAgiPeriod('AGI 202607 inbetalning')).toEqual({
year: 2026,
month: 7,
})
expect(parseAgiPeriod('Arbetsgivaravgift januari 202601')).toEqual({
year: 2026,
month: 1,
})
})
it('returns null when the period token is missing', () => {
// AGI keyword present, no period digits
expect(parseAgiPeriod('Arbetsgivardeklaration')).toBeNull()
})
it('returns null when there is no AGI keyword at all', () => {
expect(parseAgiPeriod('Inbetalning bokförd 240412')).toBeNull()
// OCR-style numerics on a moms row should not match.
expect(parseAgiPeriod('Moms 202605')).toBeNull()
})
it('rejects months outside 1-12', () => {
expect(parseAgiPeriod('Arbetsgivardeklaration 202613')).toBeNull()
expect(parseAgiPeriod('Arbetsgivardeklaration 202600')).toBeNull()
})
it('rejects implausible years', () => {
expect(parseAgiPeriod('Arbetsgivardeklaration 199912')).toBeNull()
})
})
// ──────────────────────────────────────────────────────────────────────
// AGI period disambiguation in findMatchSuggestionsBulk
// ──────────────────────────────────────────────────────────────────────
function lineRow(opts: {
entryId: string
debit?: number
credit?: number
voucherNumber?: number | null
entryDate?: string
description?: string
status?: 'draft' | 'posted' | 'reversed'
}) {
return {
debit_amount: opts.debit ?? 0,
credit_amount: opts.credit ?? 0,
journal_entries: {
id: opts.entryId,
voucher_number: opts.voucherNumber ?? 12,
voucher_series: 'A',
entry_date: opts.entryDate ?? '2026-06-12',
description: opts.description ?? 'AGI maj 2026',
status: opts.status ?? 'posted',
company_id: COMPANY,
},
}
}
describe('findMatchSuggestionsBulk — AGI period disambiguation', () => {
it('picks the AGI-linked entry even when two amount-matches exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// Two journal entries credit 1630 with the same amount — without period
// disambiguation, this would be ambiguous and return no suggestion.
enqueue({
data: [
lineRow({ entryId: 'je-other-month', credit: 12345, entryDate: '2026-06-10' }),
lineRow({ entryId: 'je-agi-may', credit: 12345, entryDate: '2026-06-12' }),
],
})
enqueue({ data: [] }) // none already linked
// AGI declarations lookup
enqueue({
data: [
{
period_year: 2026,
period_month: 5,
salary_run_id: 'sr-may-2026',
},
],
})
// salary_runs lookup
enqueue({
data: [
{
id: 'sr-may-2026',
salary_entry_id: null,
avgifter_entry_id: 'je-agi-may',
vacation_entry_id: null,
},
],
})
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-06-12',
transaktionstext: 'Arbetsgivardeklaration 202605',
belopp_skatteverket: -12345,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(1)
expect(suggestions.get('skv-1')).toMatchObject({
journal_entry_id: 'je-agi-may',
matched_via_agi_period: true,
})
})
it('falls back to amount-only matching when no AGI declaration exists for the period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [lineRow({ entryId: 'je-unique', credit: 9999, entryDate: '2026-06-12' })],
})
enqueue({ data: [] })
// No AGI declaration for 2026-05
enqueue({ data: [] })
// (no salary_runs lookup; agi_declarations was empty)
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-06-12',
transaktionstext: 'Arbetsgivardeklaration 202605',
belopp_skatteverket: -9999,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(1)
expect(suggestions.get('skv-1')).toMatchObject({
journal_entry_id: 'je-unique',
matched_via_agi_period: false,
})
})
it('returns no suggestion when AGI-linked entries do not match the amount', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// Two amount-matching entries, neither is the AGI-linked one.
enqueue({
data: [
lineRow({ entryId: 'je-a', credit: 5000, entryDate: '2026-06-12' }),
lineRow({ entryId: 'je-b', credit: 5000, entryDate: '2026-06-13' }),
],
})
enqueue({ data: [] })
enqueue({
data: [
{ period_year: 2026, period_month: 5, salary_run_id: 'sr-may-2026' },
],
})
enqueue({
data: [
{
id: 'sr-may-2026',
salary_entry_id: 'je-different-amount',
avgifter_entry_id: null,
vacation_entry_id: null,
},
],
})
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-06-12',
transaktionstext: 'Arbetsgivardeklaration 202605',
belopp_skatteverket: -5000,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(0)
})
})
// ──────────────────────────────────────────────────────────────────────
// AGI period boost in findMatchCandidates
// ──────────────────────────────────────────────────────────────────────
function txRow(overrides: Record<string, unknown> = {}) {
return {
id: 'skv-tx-1',
company_id: COMPANY,
transaktionsdatum: '2026-06-12',
belopp_skatteverket: -12345,
journal_entry_id: null,
transaktionstext: 'Arbetsgivardeklaration 202605',
status: 'booked',
...overrides,
}
}
describe('findMatchCandidates — AGI period boost', () => {
it('moves the AGI-linked entry to position 0 even when a closer-by-date entry exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({
data: [
// closer to SKV date but unrelated
lineRow({ entryId: 'je-close', credit: 12345, entryDate: '2026-06-12' }),
// further from SKV date but AGI-linked
lineRow({ entryId: 'je-agi', credit: 12345, entryDate: '2026-06-05' }),
],
})
enqueue({ data: [] }) // none linked
enqueue({
data: [
{ period_year: 2026, period_month: 5, salary_run_id: 'sr-may-2026' },
],
})
enqueue({
data: [
{
id: 'sr-may-2026',
salary_entry_id: null,
avgifter_entry_id: 'je-agi',
vacation_entry_id: null,
},
],
})
const result = await findMatchCandidates(supabase as never, COMPANY, 'skv-tx-1')
expect(result.candidates.map(c => c.journal_entry_id)).toEqual([
'je-agi',
'je-close',
])
expect(result.candidates[0].matched_via_agi_period).toBe(true)
expect(result.candidates[1].matched_via_agi_period).toBe(false)
})
})
+8
View File
@@ -27,6 +27,7 @@ import {
} from './lib/agi-client'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
@@ -1893,6 +1894,13 @@ export const skatteverketExtension: Extension = {
},
},
],
eventHandlers: [
{
eventType: 'skattekonto.drift_detected',
handler: handleSkattekontoDriftDetected,
},
],
}
// ── Helpers ───────────────────────────────────────────────────────────
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { getPrimary as getPrimaryCashAccount } from '@/lib/cash-accounts/service'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -9,8 +10,8 @@ import type {
/**
* Per-row "Bokför" helper.
*
* Takes a stored skattekonto_transactions row, guesses a counter-account
* from the Swedish description text, and creates a DRAFT journal entry
* Loads counter-account rules from `skattekonto_rules` (system seeds + per-company
* overrides), picks the first match by priority, and creates a DRAFT journal entry
* via the bookkeeping engine. The user reviews and commits the draft in
* /bookkeeping/[id].
*
@@ -20,73 +21,37 @@ import type {
* beloppSkatteverket < 0 (debit on tax account, e.g. F-tax charge)
* → Credit 1630, Debit counter-account
*
* The keyword table mirrors lib/bookkeeping/booking-templates.ts entries
* for skattekonto-related events, and is intentionally narrow — when no
* keyword matches, throw SkattekontoBookingError and let the UI route the
* user to a manual entry rather than fabricate a counter-account.
* Anstånd has no system rule on purpose: it's a saldo-only deferral on the SKV
* side and doesn't move the GL. NO_COUNTER_ACCOUNT lets the user handle the rare
* case of anstånd granted across a closed period manually.
*/
const SKATTEKONTO_ACCOUNT = '1630'
/**
* Sentinel emitted by system rules for inbetalning / utbetalning: resolves to the
* company's primary SEK cash account at runtime so the resolver doesn't assume 1930.
* Falls back to '1930' until cash_accounts exists (Item 4 in the bank-architecture
* priority list).
*/
const PRIMARY_SEK_SENTINEL = '__PRIMARY_SEK__'
const PRIMARY_SEK_FALLBACK = '1930'
export type EntityType = 'enskild_firma' | 'aktiebolag'
interface CounterAccountRule {
/** Lower-cased substrings; ANY matching wins. */
match: string[]
/** Counter-account number, possibly entity-type dependent. */
account: string | { aktiebolag: string; enskild_firma: string }
/** Optional human-readable label for the line description. */
label?: string
interface SkattekontoRuleRow {
id: string
priority: number
pattern: string
amount_min: number | null
amount_max: number | null
company_type: 'aktiebolag' | 'enskild_firma' | 'all'
counter_account: string
counter_account_ef: string | null
label: string | null
active: boolean
}
const COUNTER_ACCOUNT_RULES: CounterAccountRule[] = [
// Cash flows in/out
{
match: ['inbetalning bokförd', 'inbetalning', 'överföring från bank'],
account: '1930',
label: 'Inbetalning till skattekonto',
},
{
match: ['utbetalning', 'återbetalning'],
account: '1930',
label: 'Utbetalning från skattekonto',
},
// Preliminary income tax — different liability accounts for AB vs EF
{
match: ['debiterad preliminärskatt', 'preliminärskatt', 'f-skatt', 'fskatt'],
account: { aktiebolag: '2510', enskild_firma: '2012' },
label: 'Preliminär skatt',
},
// Employer payroll taxes
{
match: ['arbetsgivaravgift', 'sociala avgifter', 'agi'],
account: '2731',
label: 'Arbetsgivaravgifter',
},
{
match: ['avdragen skatt', 'personalskatt', 'a-skatt'],
account: '2710',
label: 'Avdragen skatt anställda',
},
// VAT — settlement account
{
match: ['mervärdesskatt', 'moms', 'momsdeklaration'],
account: '2650',
label: 'Redovisningskonto för moms',
},
// Interest — Skatteverket charges/credits interest on the account
{
match: ['kostnadsränta'],
account: '8423',
label: 'Kostnadsränta skattekonto',
},
{
match: ['intäktsränta'],
account: '8313',
label: 'Intäktsränta skattekonto',
},
]
export class SkattekontoBookingError extends Error {
constructor(
message: string,
@@ -102,30 +67,108 @@ export class SkattekontoBookingError extends Error {
}
}
interface CounterAccountMatch {
export interface CounterAccountMatch {
account: string
label: string
}
/**
* Find the counter-account for a Skatteverket transaktionstext.
* Returns null if no rule matches. Public so tests can exercise it.
* Resolve the primary SEK cash account for a company via `cash_accounts`.
* Falls back to '1930' when no primary row exists yet (fresh company before the
* initial PSD2 connection, or a manual-only company that hasn't set a primary).
*/
export function guessCounterAccount(
async function resolvePrimarySekAccount(
supabase: SupabaseClient,
companyId: string,
): Promise<string> {
const primary = await getPrimaryCashAccount(supabase, companyId, 'SEK')
return primary?.ledger_account ?? PRIMARY_SEK_FALLBACK
}
/**
* Find the counter-account for a Skatteverket transaktionstext by consulting
* `skattekonto_rules` (system seeds + per-company overrides). Returns null when
* no rule matches — the booking flow surfaces NO_COUNTER_ACCOUNT to the user.
*
* Rules are matched in priority order (lower numeric priority first), and for each
* rule the `pattern` is split on commas to produce a list of lowercase substrings;
* any substring contained in the normalized text wins.
*/
// Defence-in-depth check for any interpolation site. PostgREST .or() takes a
// raw filter string, so we refuse company ids that aren't plain ASCII safe
// characters (letters, digits, dash, underscore). The id should already be a
// UUID at this call site — but rejecting anything else keeps the .or()
// expression literal regardless of upstream bugs (ASVS V4.5).
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
// Explicit column projection — narrower than select('*'); ensures we don't
// ship override metadata we don't need to the application layer (SOC 2
// CC6.1, ISO 27001 A.8.5 least-privilege data access).
const SKATTEKONTO_RULE_COLUMNS =
'id, priority, pattern, amount_min, amount_max, company_type, counter_account, counter_account_ef, label, active'
export async function guessCounterAccount(
supabase: SupabaseClient,
companyId: string,
transaktionstext: string,
entityType: EntityType,
): CounterAccountMatch | null {
belopp?: number,
): Promise<CounterAccountMatch | null> {
if (!SAFE_ID_PATTERN.test(companyId)) {
// The caller is supposed to pass a validated company id (from
// requireCompanyId). Refuse rather than interpolate an unknown string
// into the PostgREST filter — the .or() string parser is forgiving and
// we don't want to depend on it for safety.
return null
}
const normalized = transaktionstext.toLowerCase()
for (const rule of COUNTER_ACCOUNT_RULES) {
if (rule.match.some(needle => normalized.includes(needle))) {
const account =
typeof rule.account === 'string' ? rule.account : rule.account[entityType]
return {
account,
label: rule.label ?? transaktionstext,
}
const absBelopp = belopp === undefined ? null : Math.abs(belopp)
const { data: rules, error } = await supabase
.from('skattekonto_rules')
.select(SKATTEKONTO_RULE_COLUMNS)
.eq('active', true)
.or(`company_id.eq.${companyId},company_id.is.null`)
.order('priority', { ascending: true })
.order('id', { ascending: true })
if (error || !rules || rules.length === 0) {
return null
}
for (const rule of rules as SkattekontoRuleRow[]) {
if (rule.company_type !== 'all' && rule.company_type !== entityType) {
continue
}
if (absBelopp !== null) {
if (rule.amount_min !== null && absBelopp < Number(rule.amount_min)) continue
if (rule.amount_max !== null && absBelopp > Number(rule.amount_max)) continue
}
const patterns = rule.pattern
.split(',')
.map(p => p.trim().toLowerCase())
.filter(p => p.length > 0)
if (!patterns.some(p => normalized.includes(p))) continue
let account =
entityType === 'enskild_firma' && rule.counter_account_ef
? rule.counter_account_ef
: rule.counter_account
if (account === PRIMARY_SEK_SENTINEL) {
account = await resolvePrimarySekAccount(supabase, companyId)
}
return {
account,
label: rule.label ?? transaktionstext,
}
}
return null
}
@@ -135,7 +178,7 @@ export function guessCounterAccount(
* Throws SkattekontoBookingError on:
* - already-booked rows (journal_entry_id present)
* - missing/locked fiscal period for the transaktionsdatum
* - no keyword match → user must categorize manually
* - no rule match → user must categorize manually
*
* Returns the created JournalEntry. Caller is responsible for writing
* `journal_entry_id` back onto the skattekonto_transactions row.
@@ -178,8 +221,14 @@ export async function bokforSkattekontoTransaction(
const entityType: EntityType =
(settings?.entity_type as EntityType) ?? 'aktiebolag'
// 3. Guess counter-account
const guess = guessCounterAccount(tx.transaktionstext, entityType)
// 3. Resolve counter-account via skattekonto_rules
const guess = await guessCounterAccount(
supabase,
companyId,
tx.transaktionstext,
entityType,
Number(tx.belopp_skatteverket),
)
if (!guess) {
throw new SkattekontoBookingError(
`Vi kunde inte gissa motkontot för "${tx.transaktionstext}". Skapa verifikatet manuellt.`,
@@ -0,0 +1,170 @@
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { formatDate } from '@/lib/utils'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
const log = createLogger('skattekonto-drift-email')
/**
* Email handler for `skattekonto.drift_detected`. Notifies the company contact
* that their cached Skatteverket saldo and the bookkeeping have diverged
* beyond the configured tolerance — without putting the saldo or drift figures
* in the email body. The actual numbers are surfaced behind authenticated UI
* (the dashboard SkattekontoDriftTile) so a misdelivered mail doesn't leak
* financial figures.
*
* Recipient resolution is restricted to active members of the company. A
* stale company_settings.contact_email that no longer corresponds to a
* member is never used. Falls back to the syncing user only if they're
* still an active member.
*
* Degrades silently when no email service is registered (e.g. self-hosted
* installations without Resend configured).
*/
export async function handleSkattekontoDriftDetected(
payload: EventPayload<'skattekonto.drift_detected'>,
ctx?: ExtensionContext,
): Promise<void> {
if (!ctx) {
log.warn('drift event fired without ctx — cannot resolve recipient', {
companyId: payload.companyId,
})
return
}
const email = getEmailService()
if (!email.isConfigured()) {
log.info('email service not configured — skipping drift alert', {
companyId: payload.companyId,
})
return
}
const recipient = await resolveAuthorisedRecipient(ctx, payload.userId)
if (!recipient) {
log.warn('no authorised recipient resolved for drift alert', {
companyId: payload.companyId,
userId: payload.userId,
})
return
}
const fetchedAt = formatDate(new Date(payload.fetchedAt).toISOString())
const appUrl = (process.env.NEXT_PUBLIC_APP_URL || 'https://gnubok.se').replace(/\/$/, '')
const dashboardLink = `${appUrl}/`
const subject = 'Skattekontot stämmer inte med bokföringen'
// Body intentionally carries no figures — only a notification that the
// user should look at the dashboard tile. ISO 27001 A.8.11 / A.5.34: avoid
// outbound financial data to addresses that may be stale.
const lines = [
`Vi har upptäckt en differens mellan ditt skattekonto och bokföringen per ${fetchedAt}.`,
'',
'Logga in på gnubok för att se beloppen och granska skattekonto-raderna:',
dashboardLink,
'',
'Vanliga orsaker att differensen syns redan innan en åtgärd behövs:',
'• Anstånd — saldot förskjuts hos Skatteverket men bokföringen påverkas inte.',
'• Tidsskillnad — F-skatt debiteras den 12:e men förfaller senare, så Skatteverkets saldo kan ligga före bokföringen.',
'• Obokförda skattekonto-rader som väntar på din kategorisering.',
'',
'Skapa inte en rättelseverifikation innan du har granskat raderna i gnubok.',
]
const text = lines.join('\n')
const html = `
<p>Vi har upptäckt en differens mellan ditt skattekonto och bokföringen per ${escapeHtml(fetchedAt)}.</p>
<p><a href="${escapeHtml(dashboardLink)}">Logga in på gnubok</a> för att se beloppen och granska skattekonto-raderna.</p>
<p><strong>Vanliga orsaker att differensen syns redan innan en åtgärd behövs:</strong></p>
<ul>
<li>Anstånd — saldot förskjuts hos Skatteverket men bokföringen påverkas inte.</li>
<li>Tidsskillnad — F-skatt debiteras den 12:e men förfaller senare, så Skatteverkets saldo kan ligga före bokföringen.</li>
<li>Obokförda skattekonto-rader som väntar på din kategorisering.</li>
</ul>
<p>Skapa inte en rättelseverifikation innan du har granskat raderna i gnubok.</p>
`.trim()
try {
const result = await email.sendEmail({
to: recipient,
subject,
text,
html,
})
if (!result.success) {
log.warn('drift email send failed', {
companyId: payload.companyId,
error: result.error,
})
}
} catch (err) {
log.error('drift email send threw', {
companyId: payload.companyId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Resolve the recipient address for the drift alert and verify it belongs to
* an active member of the company. A stale company_settings.contact_email
* (set when a now-revoked admin still owned the company) must never receive
* a drift notification because the bare existence of one is sensitive
* financial signal.
*/
async function resolveAuthorisedRecipient(
ctx: ExtensionContext,
userId: string,
): Promise<string | null> {
// 1. Build the set of active member emails for this company. We accept
// only addresses that appear here.
const { data: members } = await ctx.supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', ctx.companyId)
type MemberRow = { user_id: string; profiles: { email?: string | null } | { email?: string | null }[] | null }
const allowedEmails = new Set<string>()
for (const m of (members ?? []) as MemberRow[]) {
const profile = Array.isArray(m.profiles) ? m.profiles[0] : m.profiles
if (profile?.email) allowedEmails.add(profile.email.toLowerCase())
}
if (allowedEmails.size === 0) return null
// 2. Prefer the configured contact email IF it matches an active member.
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('contact_email')
.eq('company_id', ctx.companyId)
.maybeSingle()
const contactEmail = (settings as { contact_email?: string | null } | null)?.contact_email
if (contactEmail && allowedEmails.has(contactEmail.toLowerCase())) {
return contactEmail
}
// 3. Fall back to the syncing user's email if they're still a member.
const { data: profile } = await ctx.supabase
.from('profiles')
.select('email')
.eq('id', userId)
.maybeSingle()
const userEmail = (profile as { email?: string | null } | null)?.email
if (userEmail && allowedEmails.has(userEmail.toLowerCase())) {
return userEmail
}
return null
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
@@ -0,0 +1,181 @@
import type { SupabaseClient } from '@supabase/supabase-js'
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'
const log = createLogger('skattekonto-drift')
const SKATTEKONTO_BAS_ACCOUNT = '1630'
const DRIFT_TOLERANCE_KEY = 'skattekonto_drift_tolerance'
const DRIFT_LAST_ALERT_KEY = 'skattekonto_drift_last_alert_at'
const DEFAULT_TOLERANCE_SEK = 1
const ALERT_THROTTLE_MS = 24 * 60 * 60 * 1000
export interface SkattekontoDrift {
saldoSkatteverket: number
glSum1630: number
/** SKV saldo - GL 1630 sum. Positive: SKV thinks we owe more than GL says. */
drift: number
fetchedAt: number
/** Tolerance used when this drift was computed, in SEK. */
tolerance: number
/** Skattekonto rows without journal_entry_id, dated <= fetchedAt. */
unbookedRows: Array<{
id: string
transaktionsdatum: string
belopp_skatteverket: number
transaktionstext: string
}>
}
export interface DriftAlertState {
/** ms epoch of the last alert sent for this company. */
lastAlertAt: number
/** Sign of the drift at the last alert (-1, 0, +1). */
lastSign: number
}
/**
* Compute the difference between Skatteverket's cached saldo and the GL sum on
* BAS 1630. Returns null when no snapshot exists yet (fresh company, never
* synced).
*
* The comparison uses the snapshot's `fetchedAt` date as the GL cutoff: a
* skattekonto sync at 04:00 today should only count GL entries posted with
* entry_date <= today, otherwise a manual journal entry created in the same
* day after the SKV pull would inflate the GL side and produce a false drift.
*/
export async function computeSkattekontoDrift(
ctx: ExtensionContext,
): Promise<SkattekontoDrift | null> {
const snapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(
SKATTEKONTO_BALANCE_SNAPSHOT_KEY,
)
if (!snapshot) return null
const fetchedDate = new Date(snapshot.fetchedAt).toISOString().slice(0, 10)
const saldoSkatteverket = Number(snapshot.saldo.saldoSkatteverket) || 0
const glSum1630 = await sumGl1630(ctx.supabase, ctx.companyId, fetchedDate)
// SKV side and GL side use opposite sign conventions in this codebase:
// - SKV saldoSkatteverket > 0 means the taxpayer has a credit balance
// with SKV (money sitting at Skatteverket).
// - GL 1630 stores the SAME asset, so debit > credit means same direction
// as SKV credit balance.
// - sumGl1630 returns (sum(debit) - sum(credit)) which matches saldoSkatteverket.
const drift = Math.round((saldoSkatteverket - glSum1630) * 100) / 100
const toleranceSetting = await ctx.settings.get<number>(DRIFT_TOLERANCE_KEY)
const tolerance = typeof toleranceSetting === 'number' && toleranceSetting > 0
? toleranceSetting
: DEFAULT_TOLERANCE_SEK
const unbookedRows = await listUnbookedRows(ctx.supabase, ctx.companyId, fetchedDate)
return {
saldoSkatteverket: Math.round(saldoSkatteverket * 100) / 100,
glSum1630: Math.round(glSum1630 * 100) / 100,
drift,
fetchedAt: snapshot.fetchedAt,
tolerance,
unbookedRows,
}
}
/**
* Decide whether to emit `skattekonto.drift_detected` for this run, then update
* the throttle state. Returns true when the event was emitted. Suppression
* window is 24h unless the sign of the drift flips — a sign change means
* something materially different is happening and the user should know.
*/
export async function maybeAlertDrift(
ctx: ExtensionContext,
drift: SkattekontoDrift,
): Promise<boolean> {
if (Math.abs(drift.drift) <= drift.tolerance) return false
const currentSign = Math.sign(drift.drift)
const lastState = await ctx.settings.get<DriftAlertState>(DRIFT_LAST_ALERT_KEY)
const now = Date.now()
const withinThrottle =
!!lastState &&
now - lastState.lastAlertAt < ALERT_THROTTLE_MS &&
lastState.lastSign === currentSign
if (withinThrottle) {
log.info('drift detected but within throttle window — skipping alert', {
companyId: ctx.companyId,
drift: drift.drift,
lastAlertAt: lastState!.lastAlertAt,
})
return false
}
await ctx.emit({
type: 'skattekonto.drift_detected',
payload: {
drift: drift.drift,
saldoSkatteverket: drift.saldoSkatteverket,
glSum1630: drift.glSum1630,
fetchedAt: drift.fetchedAt,
unbookedCount: drift.unbookedRows.length,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
await ctx.settings.set<DriftAlertState>(DRIFT_LAST_ALERT_KEY, {
lastAlertAt: now,
lastSign: currentSign,
})
return true
}
async function sumGl1630(
supabase: SupabaseClient,
companyId: string,
cutoffDate: string,
): Promise<number> {
const { data, error } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)')
.eq('account_number', SKATTEKONTO_BAS_ACCOUNT)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.lte('journal_entries.entry_date', cutoffDate)
if (error || !data) {
log.warn('sumGl1630 failed', { companyId, cutoffDate, error: error?.message })
return 0
}
let sum = 0
for (const row of data as Array<{ debit_amount: number | string; credit_amount: number | string }>) {
sum += Number(row.debit_amount || 0) - Number(row.credit_amount || 0)
}
return Math.round(sum * 100) / 100
}
async function listUnbookedRows(
supabase: SupabaseClient,
companyId: string,
cutoffDate: string,
): Promise<SkattekontoDrift['unbookedRows']> {
const { data, error } = await supabase
.from('skattekonto_transactions')
.select('id, transaktionsdatum, belopp_skatteverket, transaktionstext')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.lte('transaktionsdatum', cutoffDate)
.order('transaktionsdatum', { ascending: false })
.limit(50)
if (error || !data) {
log.warn('listUnbookedRows failed', { companyId, cutoffDate, error: error?.message })
return []
}
return data as SkattekontoDrift['unbookedRows']
}
@@ -16,6 +16,12 @@ import type { StoredSkattekontoTransaction } from '../types'
* The candidate query is intentionally strict (exact amount, exact side,
* unused entry) — false positives would be silently destructive. False
* negatives just fall back to "Bokför / Skapa manuellt".
*
* AGI period disambiguation: when transaktionstext carries an explicit period
* token (e.g. "Arbetsgivardeklaration 202605"), the AGI declaration for that
* period uniquely identifies the salary_run, and from there the salary entries.
* That lets the matcher prefer the right entry even when two months happen to
* have identical totals.
*/
const SKATTEKONTO_ACCOUNT = '1630'
@@ -45,18 +51,156 @@ export interface SkattekontoMatchCandidate {
status: 'draft' | 'posted' | 'reversed'
matched_amount: number
matched_side: 'debit' | 'credit'
/**
* True when this candidate was picked because the SKV transaktionstext carried
* an AGI period code matching the originating salary run. Used by the UI to
* show a "period-matched" badge.
*/
matched_via_agi_period?: boolean
}
/**
* Parse an AGI period from a Skatteverket transaktionstext.
*
* Examples that match:
* "Arbetsgivardeklaration 202605"
* "arbetsgivardeklaration 2026-05"
* "AGI 202605"
*
* Returns null when no period token is present or the value is out of range.
*
* The fallback (numeric YYYYMM after any AGI keyword) covers older SKV variants
* that omit the leading word but still place the period adjacent to "AGI" or
* "arbetsgivaravgift" elsewhere in the row.
*/
export function parseAgiPeriod(
transaktionstext: string,
): { year: number; month: number } | null {
const text = transaktionstext.toLowerCase()
const primary = /arbetsgivardeklaration\s*(\d{4})[-]?(\d{2})/i.exec(transaktionstext)
if (primary) {
const year = Number(primary[1])
const month = Number(primary[2])
if (isValidPeriod(year, month)) return { year, month }
}
const agiKeyword = /(arbetsgivardeklaration|arbetsgivaravgift|personalskatt|a-skatt|\bagi\b)/i
if (!agiKeyword.test(text)) return null
const fallback = /(\d{4})[-]?(\d{2})\b/.exec(transaktionstext)
if (fallback) {
const year = Number(fallback[1])
const month = Number(fallback[2])
if (isValidPeriod(year, month)) return { year, month }
}
return null
}
function isValidPeriod(year: number, month: number): boolean {
return Number.isFinite(year) && Number.isFinite(month)
&& year >= 2000 && year <= 2100
&& month >= 1 && month <= 12
}
interface AgiEntryLookup {
/** Set of journal_entry_ids that belong to the AGI's salary run for that period. */
entryIds: Set<string>
}
/**
* Resolve AGI-linked journal entries for a set of (year, month) periods.
*
* For each period: look up agi_declarations (UNIQUE per company per period), then
* walk salary_runs.salary_entry_id / avgifter_entry_id / vacation_entry_id. Any
* of those three entries is a legitimate match target for an SKV row carrying
* the period code.
*/
async function loadAgiEntryIndex(
supabase: SupabaseClient,
companyId: string,
periods: Array<{ year: number; month: number }>,
): Promise<Map<string, AgiEntryLookup>> {
const index = new Map<string, AgiEntryLookup>()
if (periods.length === 0) return index
const uniqueKeys = new Set(periods.map(p => periodKey(p.year, p.month)))
if (uniqueKeys.size === 0) return index
const years = Array.from(new Set(periods.map(p => p.year)))
const months = Array.from(new Set(periods.map(p => p.month)))
const { data: agiRows } = await supabase
.from('agi_declarations')
.select('period_year, period_month, salary_run_id')
.eq('company_id', companyId)
.in('period_year', years)
.in('period_month', months)
const salaryRunIdsByPeriod = new Map<string, string[]>()
for (const row of (agiRows ?? []) as Array<{
period_year: number
period_month: number
salary_run_id: string | null
}>) {
if (!row.salary_run_id) continue
const key = periodKey(row.period_year, row.period_month)
if (!uniqueKeys.has(key)) continue
const existing = salaryRunIdsByPeriod.get(key) ?? []
existing.push(row.salary_run_id)
salaryRunIdsByPeriod.set(key, existing)
}
const allSalaryRunIds = Array.from(
new Set(Array.from(salaryRunIdsByPeriod.values()).flat()),
)
if (allSalaryRunIds.length === 0) return index
const { data: salaryRows } = await supabase
.from('salary_runs')
.select('id, salary_entry_id, avgifter_entry_id, vacation_entry_id')
.eq('company_id', companyId)
.in('id', allSalaryRunIds)
const entryIdsByRun = new Map<string, string[]>()
for (const row of (salaryRows ?? []) as Array<{
id: string
salary_entry_id: string | null
avgifter_entry_id: string | null
vacation_entry_id: string | null
}>) {
const ids = [row.salary_entry_id, row.avgifter_entry_id, row.vacation_entry_id]
.filter((id): id is string => !!id)
entryIdsByRun.set(row.id, ids)
}
for (const [key, runIds] of salaryRunIdsByPeriod) {
const entryIds = new Set<string>()
for (const runId of runIds) {
for (const id of entryIdsByRun.get(runId) ?? []) entryIds.add(id)
}
if (entryIds.size > 0) index.set(key, { entryIds })
}
return index
}
function periodKey(year: number, month: number): string {
return `${year}-${String(month).padStart(2, '0')}`
}
/**
* Bulk-enrich a list of unmatched SKV rows with a `match_suggestion` field
* pointing to a "high confidence" candidate verifikat. We only attach the
* suggestion when there is EXACTLY ONE candidate — multiple matches means
* we can't auto-suggest without risking the wrong link. The user can still
* open the full Matcha-dialog manually in that case.
* pointing to a "high confidence" candidate verifikat.
*
* Done in a single SQL pass to keep listing performance reasonable:
* fetch all 1630-lines for entries in the widest possible date window
* covering all rows, then match in-memory.
* Matching has two layers:
* 1. AGI period-code disambiguation. If the transaktionstext carries a period
* token and the AGI declaration for that period maps to journal entries,
* the matcher prefers candidates from that set even when other amount
* matches exist.
* 2. Strict amount+side match. We auto-suggest only when there is EXACTLY ONE
* candidate to avoid silently linking the wrong entry.
*/
export async function findMatchSuggestionsBulk(
supabase: SupabaseClient,
@@ -64,6 +208,7 @@ export async function findMatchSuggestionsBulk(
rows: Array<{
id: string
transaktionsdatum: string
transaktionstext?: string | null
belopp_skatteverket: number
journal_entry_id: string | null
}>,
@@ -131,6 +276,18 @@ export async function findMatchSuggestionsBulk(
.filter((id): id is string => !!id),
)
// AGI period extraction across the batch.
const periods: Array<{ year: number; month: number }> = []
const periodByRowId = new Map<string, string>()
for (const row of unmatched) {
if (!row.transaktionstext) continue
const period = parseAgiPeriod(row.transaktionstext)
if (!period) continue
periods.push(period)
periodByRowId.set(row.id, periodKey(period.year, period.month))
}
const agiIndex = await loadAgiEntryIndex(supabase, companyId, periods)
const suggestions = new Map<string, SkattekontoMatchCandidate>()
for (const row of unmatched) {
@@ -139,6 +296,11 @@ export async function findMatchSuggestionsBulk(
const rowFrom = addDays(row.transaktionsdatum, -DATE_WINDOW_DAYS)
const rowTo = addDays(row.transaktionsdatum, DATE_WINDOW_DAYS)
const periodEntryIds = (() => {
const key = periodByRowId.get(row.id)
return key ? agiIndex.get(key)?.entryIds ?? null : null
})()
const matches: SkattekontoMatchCandidate[] = []
const seen = new Set<string>()
@@ -166,12 +328,23 @@ export async function findMatchSuggestionsBulk(
status: e.status,
matched_amount: amount,
matched_side: side,
matched_via_agi_period: periodEntryIds?.has(e.id) ?? false,
})
if (matches.length > 1) break
if (matches.length > 1 && !periodEntryIds) break
}
// Auto-suggest only when there's a single unambiguous match.
// Period-code disambiguation: prefer the AGI-linked candidate even when
// multiple amount-matches exist.
if (periodEntryIds) {
const periodMatches = matches.filter(m => m.matched_via_agi_period)
if (periodMatches.length === 1) {
suggestions.set(row.id, periodMatches[0])
continue
}
}
// Fallback: auto-suggest only when there's a single unambiguous amount match.
if (matches.length === 1) {
suggestions.set(row.id, matches[0])
}
@@ -196,7 +369,8 @@ function expectedSide(beloppSkatteverket: number): 'debit' | 'credit' {
* Find existing journal entries that look like the bank side of this
* skattekonto row.
*
* Returns up to 25 candidates ordered by date proximity to the SKV row.
* Returns up to 25 candidates ordered by AGI-period match, then date proximity
* to the SKV row.
*/
export async function findMatchCandidates(
supabase: SupabaseClient,
@@ -300,6 +474,15 @@ export async function findMatchCandidates(
.filter((id): id is string => !!id),
)
// Resolve AGI-linked entries for this single row's period (if any).
const period = tx.transaktionstext ? parseAgiPeriod(tx.transaktionstext) : null
const agiIndex = period
? await loadAgiEntryIndex(supabase, companyId, [period])
: new Map<string, AgiEntryLookup>()
const periodEntryIds = period
? agiIndex.get(periodKey(period.year, period.month))?.entryIds ?? null
: null
const seen = new Set<string>()
const candidates: SkattekontoMatchCandidate[] = []
for (const row of typedRows) {
@@ -316,12 +499,16 @@ export async function findMatchCandidates(
status: e.status,
matched_amount: amount,
matched_side: side,
matched_via_agi_period: periodEntryIds?.has(e.id) ?? false,
})
}
// Order by date proximity to the SKV row, then by voucher number desc.
// Order: AGI-period match first, then date proximity, then voucher number desc.
const target = new Date(tx.transaktionsdatum + 'T00:00:00Z').getTime()
candidates.sort((a, b) => {
if (a.matched_via_agi_period !== b.matched_via_agi_period) {
return a.matched_via_agi_period ? -1 : 1
}
const da = Math.abs(new Date(a.entry_date + 'T00:00:00Z').getTime() - target)
const db = Math.abs(new Date(b.entry_date + 'T00:00:00Z').getTime() - target)
if (da !== db) return da - db