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
+6
View File
@@ -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()
})
})
+70
View File
@@ -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
+141
View File
@@ -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)
}
+217
View File
@@ -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}`)
}
}
+1
View File
@@ -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):
+26
View File
@@ -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 } }
+4
View File
@@ -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
View File
@@ -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 */
+49 -18
View File
@@ -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)
}
+2
View File
@@ -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()