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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user