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