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