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:
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { detectOwnAccountTransfer } from '../own-account-detector'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
function makeTx(overrides: Partial<Transaction> = {}): Transaction {
|
||||
return {
|
||||
id: 'tx-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: 'conn-sek',
|
||||
external_id: 'eb_sek_1',
|
||||
date: '2026-06-12',
|
||||
description: 'Överföring till EUR-konto',
|
||||
amount: -1000,
|
||||
currency: 'SEK',
|
||||
amount_sek: -1000,
|
||||
exchange_rate: null,
|
||||
exchange_rate_date: null,
|
||||
category: 'uncategorized',
|
||||
is_business: null,
|
||||
invoice_id: null,
|
||||
supplier_invoice_id: null,
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
mcc_code: null,
|
||||
merchant_name: null,
|
||||
receipt_id: null,
|
||||
document_id: null,
|
||||
reconciliation_method: null,
|
||||
import_source: 'enable_banking',
|
||||
reference: null,
|
||||
counterparty_iban: 'SE9550000000054910000003',
|
||||
counterparty_account: null,
|
||||
notes: null,
|
||||
created_at: '2026-06-12T00:00:00Z',
|
||||
updated_at: '2026-06-12T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('detectOwnAccountTransfer', () => {
|
||||
it('returns null when transaction has no counterparty_iban', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: null }),
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when IBAN does not match any cash account for the company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // findByIban miss
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: 'NORANDOMVALUE' }),
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('matches IBAN and returns counter ledger account when present', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// findByIban hit
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'ca-eur',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: 'conn-eur',
|
||||
currency: 'EUR',
|
||||
ledger_account: '1932',
|
||||
iban: 'SE9550000000054910000003',
|
||||
is_primary: false,
|
||||
enabled: true,
|
||||
source: 'enable_banking',
|
||||
},
|
||||
})
|
||||
// pair candidate lookup — find the matching EUR-side leg
|
||||
enqueue({
|
||||
data: [{ id: 'tx-eur-leg', amount: 90.50, date: '2026-06-12' }],
|
||||
})
|
||||
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ amount: -1000, counterparty_iban: 'SE9550000000054910000003' }),
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.counterLedgerAccount).toBe('1932')
|
||||
expect(result!.counterCurrency).toBe('EUR')
|
||||
expect(result!.pairTransactionId).toBe('tx-eur-leg')
|
||||
})
|
||||
|
||||
it('returns pairTransactionId: null when the other leg has not been ingested yet', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'ca-eur',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: 'conn-eur',
|
||||
currency: 'EUR',
|
||||
ledger_account: '1932',
|
||||
iban: 'SE9550000000054910000003',
|
||||
is_primary: false,
|
||||
enabled: true,
|
||||
source: 'enable_banking',
|
||||
},
|
||||
})
|
||||
enqueue({ data: [] }) // pair not present yet
|
||||
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: 'SE9550000000054910000003' }),
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.pairTransactionId).toBeNull()
|
||||
expect(result!.counterLedgerAccount).toBe('1932')
|
||||
})
|
||||
|
||||
it('refuses to pair when the counter ledger code is outside the cash class (19xx)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'ca-bad',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: null,
|
||||
currency: 'SEK',
|
||||
ledger_account: '6991', // not a cash account
|
||||
iban: 'SE9550000000054910000003',
|
||||
is_primary: false,
|
||||
enabled: true,
|
||||
source: 'manual',
|
||||
},
|
||||
})
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: 'SE9550000000054910000003' }),
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fall back to amount-only heuristics when IBAN missing — null instead', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: '' }),
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findCounterpartyTemplate,
|
||||
buildMappingResultFromCounterpartyTemplate,
|
||||
} from './counterparty-templates'
|
||||
import { detectOwnAccountTransfer } from './own-account-detector'
|
||||
import type {
|
||||
MappingRule,
|
||||
MappingResult,
|
||||
@@ -56,6 +57,33 @@ export async function evaluateMappingRules(
|
||||
): Promise<MappingResult> {
|
||||
const bankAccount = settlementAccount || '1930'
|
||||
|
||||
// Pre-step: detect intra-company transfers. When the counterparty IBAN
|
||||
// matches another cash_accounts row for the same company, book both legs
|
||||
// as a transfer between the two ledger accounts instead of running the
|
||||
// priority rules (which would mis-categorize the outflow as an expense).
|
||||
try {
|
||||
const transfer = await detectOwnAccountTransfer(supabase, companyId, transaction)
|
||||
if (transfer) {
|
||||
const isFx =
|
||||
(transaction.currency || '').toUpperCase() !==
|
||||
(transfer.counterCurrency || '').toUpperCase()
|
||||
return buildOwnAccountTransferResult(
|
||||
transaction,
|
||||
bankAccount,
|
||||
transfer.counterLedgerAccount,
|
||||
isFx,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — falling through to normal categorization is correct when
|
||||
// the detector fails. We log so an unexpected upstream error is visible.
|
||||
log.warn('own-account transfer detection failed', {
|
||||
companyId,
|
||||
transactionId: transaction.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch all active rules (user-specific + system defaults), ordered by priority
|
||||
const { data: rules, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
@@ -299,6 +327,48 @@ function getDefaultResult(transaction: Transaction, bankAccount = '1930'): Mappi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a MappingResult for a detected own-account transfer.
|
||||
*
|
||||
* For an outflow (negative amount): debit the counter account, credit this
|
||||
* side's settlement account. The counter side will book the mirror entry when
|
||||
* its row is ingested.
|
||||
*
|
||||
* For an inflow (positive amount): debit this side's settlement account,
|
||||
* credit the counter account.
|
||||
*
|
||||
* Confidence is high (0.95) because IBAN match against the company's own
|
||||
* cash_accounts is an exact identity check, not a heuristic.
|
||||
*
|
||||
* `isFx` flips `requires_review` to true when the two legs sit on different
|
||||
* currencies (e.g. SEK 1930 → EUR 1932). A cross-currency leg generally
|
||||
* realises a kursvinst/kursförlust on 3960/7960 (ÅRL 4 kap 10 §) that the
|
||||
* two-line transfer entry doesn't capture — a human must confirm the FX gain
|
||||
* or loss line rather than auto-booking a potentially incomplete entry.
|
||||
* Same-currency transfers stay auto-bookable.
|
||||
*/
|
||||
function buildOwnAccountTransferResult(
|
||||
transaction: Transaction,
|
||||
bankAccount: string,
|
||||
counterAccount: string,
|
||||
isFx: boolean = false,
|
||||
): MappingResult {
|
||||
const isOutflow = transaction.amount < 0
|
||||
return {
|
||||
rule: null,
|
||||
debit_account: isOutflow ? counterAccount : bankAccount,
|
||||
credit_account: isOutflow ? bankAccount : counterAccount,
|
||||
risk_level: isFx ? 'MEDIUM' : 'LOW',
|
||||
confidence: isFx ? 0.7 : 0.95,
|
||||
requires_review: isFx,
|
||||
default_private: false,
|
||||
vat_lines: [],
|
||||
description: isFx
|
||||
? 'Överföring mellan egna konton (FX — granska kursvinst/förlust)'
|
||||
: 'Överföring mellan egna konton',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any default 1930 references in a mapping result with the actual settlement account.
|
||||
* This allows mapping rules and templates that don't explicitly set a bank account
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Transaction } from '@/types'
|
||||
import { findByIban } from '@/lib/cash-accounts/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('own-account-detector')
|
||||
|
||||
export interface OwnAccountTransfer {
|
||||
/** The cash account the OTHER leg of this transfer belongs to. */
|
||||
counterCashAccountId: string
|
||||
/** BAS ledger account of the counter account (debit/credit target). */
|
||||
counterLedgerAccount: string
|
||||
/** Currency of the counter account (informational — pairing key was IBAN). */
|
||||
counterCurrency: string
|
||||
/**
|
||||
* Paired transaction id when the other leg has already been ingested.
|
||||
* Null when only this leg is present so far — the categorizer still books
|
||||
* the correct transfer entry on this side; the other leg will match when
|
||||
* it arrives.
|
||||
*/
|
||||
pairTransactionId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect that this transaction is a transfer between two of the company's own
|
||||
* cash accounts. Resolution is IBAN-based: we look up `transaction.counterparty_iban`
|
||||
* in `cash_accounts` for the same company. When it matches another account,
|
||||
* return the ledger account of the other side so the categorizer can book
|
||||
* the transfer leg.
|
||||
*
|
||||
* Returns null when:
|
||||
* - the transaction has no counterparty IBAN (manual entries, SIE imports,
|
||||
* older PSD2 rows before counterparty_iban capture)
|
||||
* - the counterparty IBAN doesn't match any cash account for this company
|
||||
*
|
||||
* No amount-only heuristic fallback: silent false positives at FX boundaries
|
||||
* would mis-book legitimate external transfers as own-account moves.
|
||||
*/
|
||||
export async function detectOwnAccountTransfer(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
transaction: Transaction,
|
||||
): Promise<OwnAccountTransfer | null> {
|
||||
const cpIban = transaction.counterparty_iban?.trim()
|
||||
if (!cpIban) return null
|
||||
|
||||
const counterAccount = await findByIban(supabase, companyId, cpIban)
|
||||
if (!counterAccount) return null
|
||||
|
||||
// Defense-in-depth: refuse to route to a non-cash BAS account. cash_accounts
|
||||
// is constrained to BAS class 19 today but a future migration could relax it.
|
||||
if (!/^19[0-9]{2}$/.test(counterAccount.ledger_account)) {
|
||||
log.warn('counter account has non-cash ledger code — refusing to pair', {
|
||||
companyId,
|
||||
counterLedger: counterAccount.ledger_account,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the paired transaction on the other side, if it's already been
|
||||
// ingested. Match on (company_id, bank_connection_id of counter account,
|
||||
// opposite sign, ±2 days, unmatched).
|
||||
//
|
||||
// Within the date window the same account may carry several unrelated rows
|
||||
// of the opposite sign (a supplier payment, a payroll batch, ...). Without
|
||||
// an amount constraint the first one wins, and pairTransactionId can point
|
||||
// at a completely unrelated row. For same-currency transfers we tighten the
|
||||
// filter to the exact opposite amount. For cross-currency we can't — FX
|
||||
// converts the figure — so we fall back to the loose window and then pick
|
||||
// the candidate whose magnitude is closest to the original.
|
||||
const dateFrom = addDays(transaction.date, -2)
|
||||
const dateTo = addDays(transaction.date, 2)
|
||||
const oppositeSign = transaction.amount > 0 ? 'lt' : 'gt'
|
||||
const sameCurrency =
|
||||
transaction.currency?.toUpperCase() === counterAccount.currency?.toUpperCase()
|
||||
|
||||
let q = supabase
|
||||
.from('transactions')
|
||||
.select('id, amount, date')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.gte('date', dateFrom)
|
||||
.lte('date', dateTo)
|
||||
.neq('id', transaction.id)
|
||||
|
||||
if (counterAccount.bank_connection_id) {
|
||||
q = q.eq('bank_connection_id', counterAccount.bank_connection_id)
|
||||
}
|
||||
|
||||
if (sameCurrency) {
|
||||
// Exact opposite amount. Postgres numeric comparison handles trailing
|
||||
// zeroes consistently; bank PSD2 amounts are stored at <= 2 decimals so
|
||||
// an equality match is the right primitive here.
|
||||
q = q.eq('amount', -transaction.amount)
|
||||
} else {
|
||||
q = oppositeSign === 'lt' ? q.lt('amount', 0) : q.gt('amount', 0)
|
||||
}
|
||||
|
||||
const { data: pairCandidates, error } = await q.limit(5)
|
||||
if (error) {
|
||||
log.warn('pair candidate lookup failed', {
|
||||
companyId,
|
||||
transactionId: transaction.id,
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
// Same-currency lookup is already amount-equal so any returned row is a
|
||||
// legitimate pair. Cross-currency: pick the row whose magnitude is closest
|
||||
// to the original, which beats taking whatever DB ordering returns first
|
||||
// when multiple unrelated rows fall inside the window.
|
||||
type PairCandidate = { id: string; amount: number | string; date: string }
|
||||
const candidates = ((pairCandidates ?? []) as PairCandidate[]).filter(p => p.id !== transaction.id)
|
||||
let pair: PairCandidate | null = null
|
||||
if (candidates.length > 0) {
|
||||
if (sameCurrency) {
|
||||
pair = candidates[0]
|
||||
} else {
|
||||
const target = Math.abs(transaction.amount)
|
||||
pair = candidates.reduce<PairCandidate | null>((best, c) => {
|
||||
if (best === null) return c
|
||||
const cAbs = Math.abs(Number(c.amount) || 0)
|
||||
const bestAbs = Math.abs(Number(best.amount) || 0)
|
||||
return Math.abs(cAbs - target) < Math.abs(bestAbs - target) ? c : best
|
||||
}, null)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counterCashAccountId: counterAccount.id,
|
||||
counterLedgerAccount: counterAccount.ledger_account,
|
||||
counterCurrency: counterAccount.currency,
|
||||
pairTransactionId: pair?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function addDays(iso: string, days: number): string {
|
||||
const d = new Date(iso + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
Reference in New Issue
Block a user