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
+24 -1
View File
@@ -24,11 +24,34 @@ export async function POST(request: Request) {
const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response
const { date_from, date_to, dry_run } = validation.data
const { date_from, date_to, account_number, dry_run } = validation.data
const accountNumber = account_number ?? '1930'
// Defense-in-depth: only allow account numbers the company has registered as
// a cash account. Applies uniformly including '1930' — the cash_accounts
// backfill seeds 1930 for every company that had a SEK PSD2 account, and the
// AccountPickerDialog seeds it for new companies on first connection.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('currency')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount) {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const currency = (cashAccount.currency as string | undefined) ?? 'SEK'
const result = await runReconciliation(supabase, companyId, user.id, {
dateFrom: date_from,
dateTo: date_to,
accountNumber,
currency,
dryRun: dry_run ?? false,
})
+28 -1
View File
@@ -16,8 +16,35 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const accountNumber = searchParams.get('account_number') || '1930'
const status = await getReconciliationStatus(supabase, companyId, dateFrom, dateTo)
// Look up the cash account so we can pair the bank account with the right
// currency. Comparing EUR GL movements against SEK transactions silently
// produces nonsense.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('currency')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount && accountNumber !== '1930') {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
const status = await getReconciliationStatus(
supabase,
companyId,
dateFrom,
dateTo,
accountNumber,
currency,
)
return NextResponse.json({ data: status })
}
@@ -16,8 +16,29 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const accountNumber = searchParams.get('account_number') || '1930'
const lines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
// Defense-in-depth: only allow account numbers that the company has actually
// registered as a cash account. Without this, a curious caller could probe
// arbitrary GL accounts for posted-but-unmatched amounts. Applies uniformly
// including '1930' — the cash_accounts backfill seeds 1930 for every company
// that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new
// companies on first connection.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount) {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const lines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
return NextResponse.json({ data: lines })
}