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:
@@ -630,6 +630,12 @@ export const BankUnlinkSchema = z.object({
|
||||
export const RunReconciliationSchema = z.object({
|
||||
date_from: isoDate.optional(),
|
||||
date_to: isoDate.optional(),
|
||||
// BAS settlement account to reconcile against (e.g. '1930', '1932'). Defaults
|
||||
// to '1930' server-side so existing clients stay correct.
|
||||
account_number: z
|
||||
.string()
|
||||
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
|
||||
.optional(),
|
||||
dry_run: z.boolean().optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { detectOwnAccountTransfer } from '../own-account-detector'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
function makeTx(overrides: Partial<Transaction> = {}): Transaction {
|
||||
return {
|
||||
id: 'tx-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: 'conn-sek',
|
||||
external_id: 'eb_sek_1',
|
||||
date: '2026-06-12',
|
||||
description: 'Överföring till EUR-konto',
|
||||
amount: -1000,
|
||||
currency: 'SEK',
|
||||
amount_sek: -1000,
|
||||
exchange_rate: null,
|
||||
exchange_rate_date: null,
|
||||
category: 'uncategorized',
|
||||
is_business: null,
|
||||
invoice_id: null,
|
||||
supplier_invoice_id: null,
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
mcc_code: null,
|
||||
merchant_name: null,
|
||||
receipt_id: null,
|
||||
document_id: null,
|
||||
reconciliation_method: null,
|
||||
import_source: 'enable_banking',
|
||||
reference: null,
|
||||
counterparty_iban: 'SE9550000000054910000003',
|
||||
counterparty_account: null,
|
||||
notes: null,
|
||||
created_at: '2026-06-12T00:00:00Z',
|
||||
updated_at: '2026-06-12T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('detectOwnAccountTransfer', () => {
|
||||
it('returns null when transaction has no counterparty_iban', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: null }),
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when IBAN does not match any cash account for the company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // findByIban miss
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: 'NORANDOMVALUE' }),
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('matches IBAN and returns counter ledger account when present', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// findByIban hit
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'ca-eur',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: 'conn-eur',
|
||||
currency: 'EUR',
|
||||
ledger_account: '1932',
|
||||
iban: 'SE9550000000054910000003',
|
||||
is_primary: false,
|
||||
enabled: true,
|
||||
source: 'enable_banking',
|
||||
},
|
||||
})
|
||||
// pair candidate lookup — find the matching EUR-side leg
|
||||
enqueue({
|
||||
data: [{ id: 'tx-eur-leg', amount: 90.50, date: '2026-06-12' }],
|
||||
})
|
||||
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ amount: -1000, counterparty_iban: 'SE9550000000054910000003' }),
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.counterLedgerAccount).toBe('1932')
|
||||
expect(result!.counterCurrency).toBe('EUR')
|
||||
expect(result!.pairTransactionId).toBe('tx-eur-leg')
|
||||
})
|
||||
|
||||
it('returns pairTransactionId: null when the other leg has not been ingested yet', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'ca-eur',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: 'conn-eur',
|
||||
currency: 'EUR',
|
||||
ledger_account: '1932',
|
||||
iban: 'SE9550000000054910000003',
|
||||
is_primary: false,
|
||||
enabled: true,
|
||||
source: 'enable_banking',
|
||||
},
|
||||
})
|
||||
enqueue({ data: [] }) // pair not present yet
|
||||
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: 'SE9550000000054910000003' }),
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.pairTransactionId).toBeNull()
|
||||
expect(result!.counterLedgerAccount).toBe('1932')
|
||||
})
|
||||
|
||||
it('refuses to pair when the counter ledger code is outside the cash class (19xx)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'ca-bad',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: null,
|
||||
currency: 'SEK',
|
||||
ledger_account: '6991', // not a cash account
|
||||
iban: 'SE9550000000054910000003',
|
||||
is_primary: false,
|
||||
enabled: true,
|
||||
source: 'manual',
|
||||
},
|
||||
})
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: 'SE9550000000054910000003' }),
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fall back to amount-only heuristics when IBAN missing — null instead', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await detectOwnAccountTransfer(
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTx({ counterparty_iban: '' }),
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findCounterpartyTemplate,
|
||||
buildMappingResultFromCounterpartyTemplate,
|
||||
} from './counterparty-templates'
|
||||
import { detectOwnAccountTransfer } from './own-account-detector'
|
||||
import type {
|
||||
MappingRule,
|
||||
MappingResult,
|
||||
@@ -56,6 +57,33 @@ export async function evaluateMappingRules(
|
||||
): Promise<MappingResult> {
|
||||
const bankAccount = settlementAccount || '1930'
|
||||
|
||||
// Pre-step: detect intra-company transfers. When the counterparty IBAN
|
||||
// matches another cash_accounts row for the same company, book both legs
|
||||
// as a transfer between the two ledger accounts instead of running the
|
||||
// priority rules (which would mis-categorize the outflow as an expense).
|
||||
try {
|
||||
const transfer = await detectOwnAccountTransfer(supabase, companyId, transaction)
|
||||
if (transfer) {
|
||||
const isFx =
|
||||
(transaction.currency || '').toUpperCase() !==
|
||||
(transfer.counterCurrency || '').toUpperCase()
|
||||
return buildOwnAccountTransferResult(
|
||||
transaction,
|
||||
bankAccount,
|
||||
transfer.counterLedgerAccount,
|
||||
isFx,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — falling through to normal categorization is correct when
|
||||
// the detector fails. We log so an unexpected upstream error is visible.
|
||||
log.warn('own-account transfer detection failed', {
|
||||
companyId,
|
||||
transactionId: transaction.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch all active rules (user-specific + system defaults), ordered by priority
|
||||
const { data: rules, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
@@ -299,6 +327,48 @@ function getDefaultResult(transaction: Transaction, bankAccount = '1930'): Mappi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a MappingResult for a detected own-account transfer.
|
||||
*
|
||||
* For an outflow (negative amount): debit the counter account, credit this
|
||||
* side's settlement account. The counter side will book the mirror entry when
|
||||
* its row is ingested.
|
||||
*
|
||||
* For an inflow (positive amount): debit this side's settlement account,
|
||||
* credit the counter account.
|
||||
*
|
||||
* Confidence is high (0.95) because IBAN match against the company's own
|
||||
* cash_accounts is an exact identity check, not a heuristic.
|
||||
*
|
||||
* `isFx` flips `requires_review` to true when the two legs sit on different
|
||||
* currencies (e.g. SEK 1930 → EUR 1932). A cross-currency leg generally
|
||||
* realises a kursvinst/kursförlust on 3960/7960 (ÅRL 4 kap 10 §) that the
|
||||
* two-line transfer entry doesn't capture — a human must confirm the FX gain
|
||||
* or loss line rather than auto-booking a potentially incomplete entry.
|
||||
* Same-currency transfers stay auto-bookable.
|
||||
*/
|
||||
function buildOwnAccountTransferResult(
|
||||
transaction: Transaction,
|
||||
bankAccount: string,
|
||||
counterAccount: string,
|
||||
isFx: boolean = false,
|
||||
): MappingResult {
|
||||
const isOutflow = transaction.amount < 0
|
||||
return {
|
||||
rule: null,
|
||||
debit_account: isOutflow ? counterAccount : bankAccount,
|
||||
credit_account: isOutflow ? bankAccount : counterAccount,
|
||||
risk_level: isFx ? 'MEDIUM' : 'LOW',
|
||||
confidence: isFx ? 0.7 : 0.95,
|
||||
requires_review: isFx,
|
||||
default_private: false,
|
||||
vat_lines: [],
|
||||
description: isFx
|
||||
? 'Överföring mellan egna konton (FX — granska kursvinst/förlust)'
|
||||
: 'Överföring mellan egna konton',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any default 1930 references in a mapping result with the actual settlement account.
|
||||
* This allows mapping rules and templates that don't explicitly set a bank account
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Transaction } from '@/types'
|
||||
import { findByIban } from '@/lib/cash-accounts/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('own-account-detector')
|
||||
|
||||
export interface OwnAccountTransfer {
|
||||
/** The cash account the OTHER leg of this transfer belongs to. */
|
||||
counterCashAccountId: string
|
||||
/** BAS ledger account of the counter account (debit/credit target). */
|
||||
counterLedgerAccount: string
|
||||
/** Currency of the counter account (informational — pairing key was IBAN). */
|
||||
counterCurrency: string
|
||||
/**
|
||||
* Paired transaction id when the other leg has already been ingested.
|
||||
* Null when only this leg is present so far — the categorizer still books
|
||||
* the correct transfer entry on this side; the other leg will match when
|
||||
* it arrives.
|
||||
*/
|
||||
pairTransactionId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect that this transaction is a transfer between two of the company's own
|
||||
* cash accounts. Resolution is IBAN-based: we look up `transaction.counterparty_iban`
|
||||
* in `cash_accounts` for the same company. When it matches another account,
|
||||
* return the ledger account of the other side so the categorizer can book
|
||||
* the transfer leg.
|
||||
*
|
||||
* Returns null when:
|
||||
* - the transaction has no counterparty IBAN (manual entries, SIE imports,
|
||||
* older PSD2 rows before counterparty_iban capture)
|
||||
* - the counterparty IBAN doesn't match any cash account for this company
|
||||
*
|
||||
* No amount-only heuristic fallback: silent false positives at FX boundaries
|
||||
* would mis-book legitimate external transfers as own-account moves.
|
||||
*/
|
||||
export async function detectOwnAccountTransfer(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
transaction: Transaction,
|
||||
): Promise<OwnAccountTransfer | null> {
|
||||
const cpIban = transaction.counterparty_iban?.trim()
|
||||
if (!cpIban) return null
|
||||
|
||||
const counterAccount = await findByIban(supabase, companyId, cpIban)
|
||||
if (!counterAccount) return null
|
||||
|
||||
// Defense-in-depth: refuse to route to a non-cash BAS account. cash_accounts
|
||||
// is constrained to BAS class 19 today but a future migration could relax it.
|
||||
if (!/^19[0-9]{2}$/.test(counterAccount.ledger_account)) {
|
||||
log.warn('counter account has non-cash ledger code — refusing to pair', {
|
||||
companyId,
|
||||
counterLedger: counterAccount.ledger_account,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the paired transaction on the other side, if it's already been
|
||||
// ingested. Match on (company_id, bank_connection_id of counter account,
|
||||
// opposite sign, ±2 days, unmatched).
|
||||
//
|
||||
// Within the date window the same account may carry several unrelated rows
|
||||
// of the opposite sign (a supplier payment, a payroll batch, ...). Without
|
||||
// an amount constraint the first one wins, and pairTransactionId can point
|
||||
// at a completely unrelated row. For same-currency transfers we tighten the
|
||||
// filter to the exact opposite amount. For cross-currency we can't — FX
|
||||
// converts the figure — so we fall back to the loose window and then pick
|
||||
// the candidate whose magnitude is closest to the original.
|
||||
const dateFrom = addDays(transaction.date, -2)
|
||||
const dateTo = addDays(transaction.date, 2)
|
||||
const oppositeSign = transaction.amount > 0 ? 'lt' : 'gt'
|
||||
const sameCurrency =
|
||||
transaction.currency?.toUpperCase() === counterAccount.currency?.toUpperCase()
|
||||
|
||||
let q = supabase
|
||||
.from('transactions')
|
||||
.select('id, amount, date')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.gte('date', dateFrom)
|
||||
.lte('date', dateTo)
|
||||
.neq('id', transaction.id)
|
||||
|
||||
if (counterAccount.bank_connection_id) {
|
||||
q = q.eq('bank_connection_id', counterAccount.bank_connection_id)
|
||||
}
|
||||
|
||||
if (sameCurrency) {
|
||||
// Exact opposite amount. Postgres numeric comparison handles trailing
|
||||
// zeroes consistently; bank PSD2 amounts are stored at <= 2 decimals so
|
||||
// an equality match is the right primitive here.
|
||||
q = q.eq('amount', -transaction.amount)
|
||||
} else {
|
||||
q = oppositeSign === 'lt' ? q.lt('amount', 0) : q.gt('amount', 0)
|
||||
}
|
||||
|
||||
const { data: pairCandidates, error } = await q.limit(5)
|
||||
if (error) {
|
||||
log.warn('pair candidate lookup failed', {
|
||||
companyId,
|
||||
transactionId: transaction.id,
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
// Same-currency lookup is already amount-equal so any returned row is a
|
||||
// legitimate pair. Cross-currency: pick the row whose magnitude is closest
|
||||
// to the original, which beats taking whatever DB ordering returns first
|
||||
// when multiple unrelated rows fall inside the window.
|
||||
type PairCandidate = { id: string; amount: number | string; date: string }
|
||||
const candidates = ((pairCandidates ?? []) as PairCandidate[]).filter(p => p.id !== transaction.id)
|
||||
let pair: PairCandidate | null = null
|
||||
if (candidates.length > 0) {
|
||||
if (sameCurrency) {
|
||||
pair = candidates[0]
|
||||
} else {
|
||||
const target = Math.abs(transaction.amount)
|
||||
pair = candidates.reduce<PairCandidate | null>((best, c) => {
|
||||
if (best === null) return c
|
||||
const cAbs = Math.abs(Number(c.amount) || 0)
|
||||
const bestAbs = Math.abs(Number(best.amount) || 0)
|
||||
return Math.abs(cAbs - target) < Math.abs(bestAbs - target) ? c : best
|
||||
}, null)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counterCashAccountId: counterAccount.id,
|
||||
counterLedgerAccount: counterAccount.ledger_account,
|
||||
counterCurrency: counterAccount.currency,
|
||||
pairTransactionId: pair?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function addDays(iso: string, days: number): string {
|
||||
const d = new Date(iso + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
|
||||
'bank_connection.consent_granted',
|
||||
'bank_connection.account_selection_changed',
|
||||
'bank_connection.revoked',
|
||||
'bank_connection.cash_account_mirror_failed',
|
||||
]
|
||||
|
||||
// Excluded (with reasoning):
|
||||
|
||||
@@ -52,6 +52,20 @@ export type CoreEvent =
|
||||
| { type: 'bank_connection.consent_granted'; payload: { connectionId: string; bankName: string | null; accountCount: number; consentExpiresAt: string | null; userId: string; companyId: string } }
|
||||
| { type: 'bank_connection.account_selection_changed'; payload: { connectionId: string; bankName: string | null; previousStatus: string; newStatus: string; enabledCount: number; totalCount: number; userId: string; companyId: string } }
|
||||
| { type: 'bank_connection.revoked'; payload: { connectionId: string; bankName: string | null; userId: string; companyId: string } }
|
||||
// Emitted when the PSD2 callback fails to mirror a returned account into
|
||||
// cash_accounts. ASVS V16 / ISO 27001 A.8.15 — security-relevant failures
|
||||
// must land in a structured audit log (event_log, 30-day TTL) rather than
|
||||
// being lost to console.error.
|
||||
| { type: 'bank_connection.cash_account_mirror_failed'; payload: {
|
||||
connectionId: string
|
||||
bankName: string | null
|
||||
accountUid: string
|
||||
ledgerAccount: string
|
||||
currency: string
|
||||
reason: string
|
||||
userId: string
|
||||
companyId: string
|
||||
} }
|
||||
// Periods
|
||||
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
@@ -107,6 +121,18 @@ export type CoreEvent =
|
||||
| { type: 'skattekonto.balance.changed'; payload: { previousBalance: number; currentBalance: number; userId: string; companyId: string } }
|
||||
| { type: 'skattekonto.transaction.upcoming'; payload: { transaktionsdatum: string; forfallodatum: string; transaktionstext: string; beloppSkatteverket: number; userId: string; companyId: string } }
|
||||
| { type: 'skattekonto.connection.expired'; payload: { reason: 'REFRESH_EXHAUSTED' | 'SESSION_EXPIRED' | 'TOKEN_CORRUPTED'; userId: string; companyId: string } }
|
||||
// Fired when the SKV saldo and GL 1630 sum diverge beyond the configured
|
||||
// tolerance. The drift handler emails the company contact; UI surfaces a
|
||||
// dashboard tile via /api/extensions/skatteverket/skattekonto/drift.
|
||||
| { type: 'skattekonto.drift_detected'; payload: {
|
||||
drift: number // SKV saldo - GL 1630 sum (signed)
|
||||
saldoSkatteverket: number
|
||||
glSum1630: number
|
||||
fetchedAt: number // ms epoch from the snapshot
|
||||
unbookedCount: number // skattekonto rows without journal_entry_id ≤ fetchedAt
|
||||
userId: string
|
||||
companyId: string
|
||||
} }
|
||||
// Company & account lifecycle
|
||||
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
||||
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CoreEvent } from '@/lib/events/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import { listForCompany as cashAccountsList, getPrimary as cashAccountsGetPrimary } from '@/lib/cash-accounts/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type {
|
||||
ExtensionContext,
|
||||
@@ -116,6 +117,9 @@ function createStorage(supabase: SupabaseClient): ExtensionStorage {
|
||||
function createServices(): ExtensionServices {
|
||||
return {
|
||||
ingestTransactions,
|
||||
getCashAccounts: (supabase, companyId, opts) => cashAccountsList(supabase, companyId, opts),
|
||||
getPrimaryCashAccount: (supabase, companyId, currency) =>
|
||||
cashAccountsGetPrimary(supabase, companyId, currency),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -1,6 +1,12 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CoreEvent, CoreEventType } from '@/lib/events/types'
|
||||
import type { EntityType, RawTransaction, IngestResult, IngestOptions } from '@/types'
|
||||
import type {
|
||||
CashAccount,
|
||||
EntityType,
|
||||
IngestOptions,
|
||||
IngestResult,
|
||||
RawTransaction,
|
||||
} from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Extension Marketplace Types
|
||||
@@ -167,6 +173,18 @@ export interface ExtensionStorage {
|
||||
/** Core services exposed to extensions */
|
||||
export interface ExtensionServices {
|
||||
ingestTransactions(supabase: SupabaseClient, companyId: string, userId: string, raw: RawTransaction[], options?: IngestOptions): Promise<IngestResult>
|
||||
/**
|
||||
* List a company's cash accounts (cash_accounts table). Replaces ad-hoc reads
|
||||
* of bank_connections.accounts_data for routing decisions. Returns rows
|
||||
* sorted by `is_primary DESC, ledger_account ASC`.
|
||||
*/
|
||||
getCashAccounts(supabase: SupabaseClient, companyId: string, opts?: { enabledOnly?: boolean }): Promise<CashAccount[]>
|
||||
/**
|
||||
* Primary cash account for a company, optionally filtered by currency.
|
||||
* Falls back to the global primary when no currency-specific row matches.
|
||||
* Used by the skattekonto __PRIMARY_SEK__ sentinel and transfer-pairing.
|
||||
*/
|
||||
getPrimaryCashAccount(supabase: SupabaseClient, companyId: string, currency?: string): Promise<CashAccount | null>
|
||||
}
|
||||
|
||||
/** Context passed to extension lifecycle hooks and event handlers */
|
||||
|
||||
@@ -62,6 +62,19 @@ export interface ReconciliationOptions {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
dryRun?: boolean
|
||||
/**
|
||||
* Settlement account number to reconcile against (e.g. '1930' for SEK,
|
||||
* '1932' for EUR). Defaults to '1930' so existing callers stay correct.
|
||||
* The cash_accounts table is the source of truth for which BAS codes are
|
||||
* routable for a given company.
|
||||
*/
|
||||
accountNumber?: string
|
||||
/**
|
||||
* Currency to filter transactions on. Defaults to 'SEK' for back-compat;
|
||||
* future multi-currency reconciliation passes the currency of the selected
|
||||
* cash account so EUR transactions reconcile against 1932 etc.
|
||||
*/
|
||||
currency?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -72,13 +85,15 @@ export interface ReconciliationOptions {
|
||||
* Try to reconcile a single transaction against a pool of unlinked GL lines.
|
||||
* Returns the best match or null. Purely in-memory, no DB calls.
|
||||
*
|
||||
* Only reconciles SEK transactions.
|
||||
* `expectedCurrency` filters which transactions can match — defaults to 'SEK'
|
||||
* so existing callers behave identically.
|
||||
*/
|
||||
export function tryReconcileTransaction(
|
||||
transaction: Transaction,
|
||||
glLines: UnlinkedGLLine[]
|
||||
glLines: UnlinkedGLLine[],
|
||||
expectedCurrency: string = 'SEK',
|
||||
): ReconciliationMatch | null {
|
||||
if (transaction.currency !== 'SEK') return null
|
||||
if (transaction.currency !== expectedCurrency) return null
|
||||
if (glLines.length === 0) return null
|
||||
|
||||
const txAmount = transaction.amount
|
||||
@@ -148,10 +163,16 @@ export async function runReconciliation(
|
||||
userId: string,
|
||||
options: ReconciliationOptions = {}
|
||||
): Promise<ReconciliationRunResult> {
|
||||
const { dateFrom, dateTo, dryRun = false } = options
|
||||
const {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
dryRun = false,
|
||||
accountNumber = '1930',
|
||||
currency = 'SEK',
|
||||
} = options
|
||||
|
||||
// Fetch unlinked GL lines via RPC
|
||||
const glLines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
|
||||
const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
|
||||
|
||||
// Fetch unmatched transactions
|
||||
let query = supabase
|
||||
@@ -159,7 +180,7 @@ export async function runReconciliation(
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('currency', 'SEK')
|
||||
.eq('currency', currency)
|
||||
|
||||
if (dateFrom) query = query.gte('date', dateFrom)
|
||||
if (dateTo) query = query.lte('date', dateTo)
|
||||
@@ -171,7 +192,7 @@ export async function runReconciliation(
|
||||
}
|
||||
|
||||
// Run greedy matching, highest confidence first
|
||||
const matches = greedyMatch(transactions as Transaction[], glLines)
|
||||
const matches = greedyMatch(transactions as Transaction[], glLines, currency)
|
||||
|
||||
if (dryRun) {
|
||||
return { matches, applied: 0, errors: 0 }
|
||||
@@ -226,20 +247,27 @@ export async function runReconciliation(
|
||||
|
||||
/**
|
||||
* Compare bank transaction totals vs GL bank account balance.
|
||||
*
|
||||
* `bankAccount` and `currency` must agree (e.g. 1932 + EUR). When the caller
|
||||
* omits currency it defaults to SEK for back-compat with the single-account
|
||||
* call sites that only ever reconciled 1930. Multi-currency callers must pass
|
||||
* both — comparing EUR GL movements against SEK transaction totals would
|
||||
* silently produce nonsense.
|
||||
*/
|
||||
export async function getReconciliationStatus(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
bankAccount = '1930'
|
||||
bankAccount = '1930',
|
||||
currency: string = 'SEK',
|
||||
): Promise<ReconciliationStatus> {
|
||||
// Get all transactions in range
|
||||
let txQuery = supabase
|
||||
.from('transactions')
|
||||
.select('amount, journal_entry_id, reconciliation_method')
|
||||
.eq('company_id', companyId)
|
||||
.eq('currency', 'SEK')
|
||||
.eq('currency', currency)
|
||||
|
||||
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
|
||||
if (dateTo) txQuery = txQuery.lte('date', dateTo)
|
||||
@@ -305,7 +333,7 @@ export async function getReconciliationStatus(
|
||||
|
||||
// Unlinked GL lines count (RPC excludes source_type='opening_balance' since
|
||||
// 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql)
|
||||
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo)
|
||||
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, bankAccount, dateFrom, dateTo)
|
||||
|
||||
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
|
||||
|
||||
@@ -484,19 +512,21 @@ export async function unlinkReconciliation(
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Fetch unlinked bank GL lines for account 1930. Multi-account support
|
||||
* (Plusgiro 1920, kreditkort 1940, EUR-konto 1931, etc.) requires a different
|
||||
* RPC and is not yet implemented — until then this helper is intentionally
|
||||
* scoped to 1930 so callers cannot silently lose data on other accounts.
|
||||
* Fetch unlinked GL lines for a settlement account. `accountNumber` defaults to
|
||||
* '1930' for back-compat; multi-account customers (Plusgiro 1920, kreditkort
|
||||
* 1940, EUR-konto 1932, etc.) pass the BAS code of the account they're
|
||||
* reconciling. The CashAccountSelector populates this from cash_accounts.
|
||||
*/
|
||||
export async function fetchUnlinkedGLLines(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountNumber: string = '1930',
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
): Promise<UnlinkedGLLine[]> {
|
||||
const { data, error } = await supabase.rpc('get_unlinked_1930_lines', {
|
||||
const { data, error } = await supabase.rpc('get_unlinked_gl_lines', {
|
||||
p_company_id: companyId,
|
||||
p_account_number: accountNumber,
|
||||
p_date_from: dateFrom || null,
|
||||
p_date_to: dateTo || null,
|
||||
})
|
||||
@@ -551,7 +581,8 @@ function isDateWithinRange(date1: string, date2: string, dayRange: number): bool
|
||||
*/
|
||||
function greedyMatch(
|
||||
transactions: Transaction[],
|
||||
glLines: UnlinkedGLLine[]
|
||||
glLines: UnlinkedGLLine[],
|
||||
expectedCurrency: string = 'SEK',
|
||||
): ReconciliationMatch[] {
|
||||
const usedTransactions = new Set<string>()
|
||||
const usedGLLines = new Set<string>()
|
||||
@@ -561,10 +592,10 @@ function greedyMatch(
|
||||
const candidates: ReconciliationMatch[] = []
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (tx.currency !== 'SEK') continue
|
||||
if (tx.currency !== expectedCurrency) continue
|
||||
|
||||
for (const line of glLines) {
|
||||
const match = tryReconcileTransaction(tx, [line])
|
||||
const match = tryReconcileTransaction(tx, [line], expectedCurrency)
|
||||
if (match) {
|
||||
candidates.push(match)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@ export async function ingestTransactions(
|
||||
merchant_name: raw.merchant_name || null,
|
||||
reference: raw.reference || null,
|
||||
import_source: raw.import_source || null,
|
||||
counterparty_iban: raw.counterparty_iban || null,
|
||||
counterparty_account: raw.counterparty_account || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
Reference in New Issue
Block a user