Files
accounted/lib/cash-accounts/service.ts
T
Mattsson 8a6ce7093e 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.
2026-05-19 16:10:18 +02:00

218 lines
6.5 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { CashAccount, CashAccountSource } from '@/types'
import { createLogger } from '@/lib/logger'
const log = createLogger('cash-accounts')
/**
* Canonical read/write surface for cash_accounts.
*
* Replaces ad-hoc reads of bank_connections.accounts_data for routing decisions.
* UI panels that just display balances may still read accounts_data until the
* follow-up migration drops that column.
*
* All methods accept an authenticated SupabaseClient and rely on RLS for tenancy
* isolation. Defense-in-depth filter by company_id is applied regardless.
*/
export interface ListCashAccountsOptions {
enabledOnly?: boolean
}
export interface UpsertFromPsd2Input {
bank_connection_id: string
external_uid: string
currency: string
ledger_account: string
iban?: string | null
name?: string | null
balance?: number | null
balance_updated_at?: string | null
enabled?: boolean
}
export async function listForCompany(
supabase: SupabaseClient,
companyId: string,
opts: ListCashAccountsOptions = {},
): Promise<CashAccount[]> {
let q = supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.order('is_primary', { ascending: false })
.order('ledger_account', { ascending: true })
if (opts.enabledOnly) q = q.eq('enabled', true)
const { data, error } = await q
if (error) {
log.error('listForCompany failed', { companyId, error: error.message })
return []
}
return (data ?? []) as CashAccount[]
}
/**
* Primary cash account for a company. Filters by currency when provided. Falls
* back to the global primary (`is_primary = true`) when no currency-specific
* match exists.
*
* Used by skattekonto-booking's __PRIMARY_SEK__ sentinel and by transfer-pairing
* to identify the company's default settlement account.
*/
export async function getPrimary(
supabase: SupabaseClient,
companyId: string,
currency?: string,
): Promise<CashAccount | null> {
let q = supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.eq('is_primary', true)
.limit(1)
if (currency) q = q.eq('currency', currency.toUpperCase())
const { data, error } = await q.maybeSingle()
if (error) {
log.warn('getPrimary failed', { companyId, currency, error: error.message })
}
if (data) return data as CashAccount
if (currency) {
// Fall back to any-currency primary so a company without a SEK account still
// resolves the sentinel — rare but possible (manual cash-on-hand only).
const { data: anyPrimary } = await supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.eq('is_primary', true)
.maybeSingle()
if (anyPrimary) return anyPrimary as CashAccount
}
return null
}
export async function findByIban(
supabase: SupabaseClient,
companyId: string,
iban: string,
): Promise<CashAccount | null> {
if (!iban) return null
const { data, error } = await supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.eq('iban', iban)
.maybeSingle()
if (error) {
log.warn('findByIban failed', { companyId, iban, error: error.message })
return null
}
return (data as CashAccount | null) ?? null
}
/**
* Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on
* (company_id, bank_connection_id, external_uid). When the row exists, balance
* and ledger_account are refreshed; the rest of the metadata stays put.
*
* Never sets is_primary — that's owned by the user via the AccountPicker or by
* the initial-backfill migration.
*/
export async function upsertFromPsd2(
supabase: SupabaseClient,
companyId: string,
input: UpsertFromPsd2Input,
): Promise<void> {
const payload = {
company_id: companyId,
bank_connection_id: input.bank_connection_id,
external_uid: input.external_uid,
iban: input.iban ?? null,
name: input.name ?? null,
currency: input.currency.toUpperCase(),
ledger_account: input.ledger_account,
balance: input.balance ?? null,
balance_updated_at: input.balance_updated_at ?? null,
enabled: input.enabled ?? true,
source: 'enable_banking' as CashAccountSource,
}
const { error } = await supabase
.from('cash_accounts')
.upsert(payload, { onConflict: 'company_id,bank_connection_id,external_uid' })
if (error) {
log.error('upsertFromPsd2 failed', {
companyId,
bankConnectionId: input.bank_connection_id,
externalUid: input.external_uid,
error: error.message,
})
throw new Error(`cash_accounts upsert failed: ${error.message}`)
}
}
/**
* Toggle a cash account's enabled flag. Used by the AccountPicker when a user
* opts in or out of syncing a particular PSD2 account.
*/
export async function setEnabled(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string,
enabled: boolean,
): Promise<void> {
const { error } = await supabase
.from('cash_accounts')
.update({ enabled })
.eq('company_id', companyId)
.eq('id', cashAccountId)
if (error) throw new Error(`cash_accounts setEnabled failed: ${error.message}`)
}
/**
* Remap a cash account to a different BAS ledger account. Triggers RLS + the
* (company_id, ledger_account) UNIQUE constraint — surface conflict errors so
* the UI can prompt the user to resolve.
*/
export async function setLedgerAccount(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string,
ledgerAccount: string,
): Promise<void> {
const { error } = await supabase
.from('cash_accounts')
.update({ ledger_account: ledgerAccount })
.eq('company_id', companyId)
.eq('id', cashAccountId)
if (error) throw new Error(`cash_accounts setLedgerAccount failed: ${error.message}`)
}
/**
* Mark a cash account as the primary for its company. Delegates to the
* `set_cash_account_primary` RPC so the clear-old-primary and set-new-primary
* updates happen inside a single transaction. The intermediate "no primary"
* state is never visible to concurrent readers — important because
* skattekonto-booking's __PRIMARY_SEK__ resolver runs through getPrimary() and
* would otherwise see null in the gap and mis-route the counter account.
*/
export async function setPrimary(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string,
): Promise<void> {
const { error } = await supabase.rpc('set_cash_account_primary', {
p_company_id: companyId,
p_cash_account_id: cashAccountId,
})
if (error) {
throw new Error(`cash_accounts setPrimary failed: ${error.message}`)
}
}