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:
@@ -62,6 +62,19 @@ export interface ReconciliationOptions {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
dryRun?: boolean
|
||||
/**
|
||||
* Settlement account number to reconcile against (e.g. '1930' for SEK,
|
||||
* '1932' for EUR). Defaults to '1930' so existing callers stay correct.
|
||||
* The cash_accounts table is the source of truth for which BAS codes are
|
||||
* routable for a given company.
|
||||
*/
|
||||
accountNumber?: string
|
||||
/**
|
||||
* Currency to filter transactions on. Defaults to 'SEK' for back-compat;
|
||||
* future multi-currency reconciliation passes the currency of the selected
|
||||
* cash account so EUR transactions reconcile against 1932 etc.
|
||||
*/
|
||||
currency?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -72,13 +85,15 @@ export interface ReconciliationOptions {
|
||||
* Try to reconcile a single transaction against a pool of unlinked GL lines.
|
||||
* Returns the best match or null. Purely in-memory, no DB calls.
|
||||
*
|
||||
* Only reconciles SEK transactions.
|
||||
* `expectedCurrency` filters which transactions can match — defaults to 'SEK'
|
||||
* so existing callers behave identically.
|
||||
*/
|
||||
export function tryReconcileTransaction(
|
||||
transaction: Transaction,
|
||||
glLines: UnlinkedGLLine[]
|
||||
glLines: UnlinkedGLLine[],
|
||||
expectedCurrency: string = 'SEK',
|
||||
): ReconciliationMatch | null {
|
||||
if (transaction.currency !== 'SEK') return null
|
||||
if (transaction.currency !== expectedCurrency) return null
|
||||
if (glLines.length === 0) return null
|
||||
|
||||
const txAmount = transaction.amount
|
||||
@@ -148,10 +163,16 @@ export async function runReconciliation(
|
||||
userId: string,
|
||||
options: ReconciliationOptions = {}
|
||||
): Promise<ReconciliationRunResult> {
|
||||
const { dateFrom, dateTo, dryRun = false } = options
|
||||
const {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
dryRun = false,
|
||||
accountNumber = '1930',
|
||||
currency = 'SEK',
|
||||
} = options
|
||||
|
||||
// Fetch unlinked GL lines via RPC
|
||||
const glLines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
|
||||
const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
|
||||
|
||||
// Fetch unmatched transactions
|
||||
let query = supabase
|
||||
@@ -159,7 +180,7 @@ export async function runReconciliation(
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('currency', 'SEK')
|
||||
.eq('currency', currency)
|
||||
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
@@ -171,7 +192,7 @@ export async function runReconciliation(
|
||||
}
|
||||
|
||||
// Run greedy matching, highest confidence first
|
||||
const matches = greedyMatch(transactions as Transaction[], glLines)
|
||||
const matches = greedyMatch(transactions as Transaction[], glLines, currency)
|
||||
|
||||
if (dryRun) {
|
||||
return { matches, applied: 0, errors: 0 }
|
||||
@@ -226,20 +247,27 @@ export async function runReconciliation(
|
||||
|
||||
/**
|
||||
* Compare bank transaction totals vs GL bank account balance.
|
||||
*
|
||||
* `bankAccount` and `currency` must agree (e.g. 1932 + EUR). When the caller
|
||||
* omits currency it defaults to SEK for back-compat with the single-account
|
||||
* call sites that only ever reconciled 1930. Multi-currency callers must pass
|
||||
* both — comparing EUR GL movements against SEK transaction totals would
|
||||
* silently produce nonsense.
|
||||
*/
|
||||
export async function getReconciliationStatus(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
bankAccount = '1930'
|
||||
bankAccount = '1930',
|
||||
currency: string = 'SEK',
|
||||
): Promise<ReconciliationStatus> {
|
||||
// Get all transactions in range
|
||||
let txQuery = supabase
|
||||
.from('transactions')
|
||||
.select('amount, journal_entry_id, reconciliation_method')
|
||||
.eq('company_id', companyId)
|
||||
.eq('currency', 'SEK')
|
||||
.eq('currency', currency)
|
||||
|
||||
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
|
||||
if (dateTo) txQuery = txQuery.lte('date', dateTo)
|
||||
@@ -305,7 +333,7 @@ export async function getReconciliationStatus(
|
||||
|
||||
// Unlinked GL lines count (RPC excludes source_type='opening_balance' since
|
||||
// 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql)
|
||||
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
|
||||
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, bankAccount, dateFrom, dateTo)
|
||||
|
||||
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
|
||||
|
||||
@@ -484,19 +512,21 @@ export async function unlinkReconciliation(
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Fetch unlinked bank GL lines for account 1930. Multi-account support
|
||||
* (Plusgiro 1920, kreditkort 1940, EUR-konto 1931, etc.) requires a different
|
||||
* RPC and is not yet implemented — until then this helper is intentionally
|
||||
* scoped to 1930 so callers cannot silently lose data on other accounts.
|
||||
* Fetch unlinked GL lines for a settlement account. `accountNumber` defaults to
|
||||
* '1930' for back-compat; multi-account customers (Plusgiro 1920, kreditkort
|
||||
* 1940, EUR-konto 1932, etc.) pass the BAS code of the account they're
|
||||
* reconciling. The CashAccountSelector populates this from cash_accounts.
|
||||
*/
|
||||
export async function fetchUnlinkedGLLines(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountNumber: string = '1930',
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
): Promise<UnlinkedGLLine[]> {
|
||||
const { data, error } = await supabase.rpc('get_unlinked_1930_lines', {
|
||||
const { data, error } = await supabase.rpc('get_unlinked_gl_lines', {
|
||||
p_company_id: companyId,
|
||||
p_account_number: accountNumber,
|
||||
p_date_from: dateFrom || null,
|
||||
p_date_to: dateTo || null,
|
||||
})
|
||||
@@ -551,7 +581,8 @@ function isDateWithinRange(date1: string, date2: string, dayRange: number): bool
|
||||
*/
|
||||
function greedyMatch(
|
||||
transactions: Transaction[],
|
||||
glLines: UnlinkedGLLine[]
|
||||
glLines: UnlinkedGLLine[],
|
||||
expectedCurrency: string = 'SEK',
|
||||
): ReconciliationMatch[] {
|
||||
const usedTransactions = new Set<string>()
|
||||
const usedGLLines = new Set<string>()
|
||||
@@ -561,10 +592,10 @@ function greedyMatch(
|
||||
const candidates: ReconciliationMatch[] = []
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (tx.currency !== 'SEK') continue
|
||||
if (tx.currency !== expectedCurrency) continue
|
||||
|
||||
for (const line of glLines) {
|
||||
const match = tryReconcileTransaction(tx, [line])
|
||||
const match = tryReconcileTransaction(tx, [line], expectedCurrency)
|
||||
if (match) {
|
||||
candidates.push(match)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user