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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user