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:
Mattsson
2026-05-19 16:10:18 +02:00
committed by GitHub
parent e211ab31be
commit 8a6ce7093e
42 changed files with 3420 additions and 206 deletions
@@ -3,6 +3,21 @@ import { NextResponse } from 'next/server'
import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
import { eventBus } from '@/lib/events/bus'
import { upsertFromPsd2 } from '@/lib/cash-accounts/service'
// Suggested BAS account per currency. Mirrors the AccountPickerDialog defaults
// (SEK→1930, EUR→1932, USD→1933, GBP→1934). The user can re-map in the picker
// after this callback redirects them.
const CURRENCY_DEFAULTS: Record<string, string> = {
SEK: '1930',
EUR: '1932',
USD: '1933',
GBP: '1934',
}
function defaultLedgerForCurrency(currency: string): string {
return CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930'
}
/**
* GET /api/extensions/enable-banking/callback
@@ -170,6 +185,56 @@ export async function GET(request: Request) {
throw new Error(`Failed to update connection: ${updateError.message}`)
}
// Mirror each PSD2 account into cash_accounts so routing decisions read from
// the canonical entity table. The user picks a ledger_account in the
// AccountPickerDialog after this redirect; until then we route SEK→1930,
// EUR→1932, USD→1933, GBP→1934 by convention.
for (const account of accountsMetadata) {
const targetLedger = defaultLedgerForCurrency(account.currency)
try {
await upsertFromPsd2(supabase, updatedConnection.company_id, {
bank_connection_id: updatedConnection.id,
external_uid: account.uid,
currency: account.currency,
ledger_account: targetLedger,
iban: account.iban ?? null,
name: account.name ?? null,
enabled: account.enabled ?? true,
})
} catch (cashErr) {
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
console.error('[enable-banking] Failed to mirror cash_account on callback', {
connectionId: updatedConnection.id,
uid: account.uid,
error: reason,
})
// Persist the failure to event_log so a security review can see that
// a PSD2 account returned by the bank was not mirrored into our
// routing table — otherwise this is only visible in console output
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
try {
await eventBus.emit({
type: 'bank_connection.cash_account_mirror_failed',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountUid: account.uid,
ledgerAccount: targetLedger,
currency: account.currency,
reason,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
console.error('[enable-banking] Failed to emit cash_account_mirror_failed event', {
connectionId: updatedConnection.id,
error: emitError instanceof Error ? emitError.message : String(emitError),
})
}
}
}
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
// ASVS V16 requires this transition to be logged as a security event; emit
// here so the event_log handler persists it (30-day TTL).